diff --git a/.gitignore b/.gitignore index 5ac2beda..357fded9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,11 +26,8 @@ references/* # Claude Code session state .claude/ -# Superpowers brainstorm visual-companion scratch (mockups regenerate; not source) -/.superpowers/ launch.log launch-*.log -proveout*.log launch.utf8.log n4-verify*.log @@ -90,12 +87,3 @@ C[€- # Junction to Claude Code per-project memory (Obsidian vault visibility) claude-memory -studio-shots/ - -# MP1b acdream-bake output — user-machine artifact, never committed -# (docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5). -*.pak - -# session-local physics capture artifacts (worktree root) -/resolve-*.jsonl -/launch-*.log diff --git a/AcDream.slnx b/AcDream.slnx index abf3defb..c004e101 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -1,9 +1,7 @@ - - @@ -16,8 +14,6 @@ - - diff --git a/CLAUDE.md b/CLAUDE.md index 6c5b5e4b..e328ce51 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -108,17 +108,18 @@ movement queries. ## Current state -**Currently working toward: M1.5 — Indoor world feels right** (building/cellar demo -DONE + gated; REMAINING = the critical path: **#137 dungeon collision**, -**#138 teleport-OUT**, **A7 dungeon lighting** #79/#93). One **user-report-driven -parity track** still interleaves at the issue level (NOT a milestone; see the M1.5 -note in the milestones doc): **D.2b retail UI** (next: container-switching — -`claude-memory/project_d2b_retail_ui.md`). The **R5 movement-manager arc is DONE** -(2026-07-05, V5 facade shipped; close-out banner in -`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`; carried: #167, R6/TS-42). -**Track MP** (modern-pipeline perf side track, dedicated sessions only — roadmap -"Track MP") is at MP0. M2 (CombatMath) deferred. Keep this paragraph ≤6 lines + pointers — detail in the docs -below, NOT here. +**Currently working toward: M1.5 — Indoor world feels right.** The +building/cellar demo is DONE + user-gated, but M1.5 was EXTENDED 2026-06-13 +to include **dungeon support (full Phase G.3)** — dungeons don't work at +all: terrain-less dungeon landblocks aren't supported by the streaming/ +load/render/physics pipeline (`LandblockLoader.Load` null with no +`LandBlock`; streamer needs a terrain mesh; teleport snaps before hydration +→ ocean — issue **#133**). M1.5 does NOT land until dungeons work; M2 +(CombatMath) deferred. Currently brainstorming the G.3 dungeon-support spec. +Recent closes (2026-06-12/13): #119/#128, #112, #113, #124, +#129/#130/#131/#132, UN-2, #108-residual, #127, #125; #116 partial (Ghidra +threshold fix). Keep this paragraph ≤5 lines + pointers — detail in the +docs below, NOT here. For canonical state, read in this order: - [`docs/plans/2026-05-12-milestones.md`](docs/plans/2026-05-12-milestones.md) — milestone targets + freeze list per milestone @@ -950,12 +951,6 @@ via `PlayerMovementController.ApplyServerRunRate`) or from - `ACDREAM_PROBE_FLAP=1` — capture probe for indoor visibility decisions at frame boundaries. Used to converge the U.4c flap fix (root indoor visibility at player's cell, not eye). -- `ACDREAM_PROBE_STICKY=1` — per-guid sticky-melee timeline: `[sticky]` - lifecycle lines (STICK/UNSTICK/LEASE-EXPIRE/TARGET-status teardown), - per-armed-tick steer lines (signed gap dist, applied delta, heading - delta), `[sticky-snap-skip]` at the suppressed NPC UP-snap site. - Heavy while a pack is stuck (~60 Hz × stuck count). Converged the - #171 residuals (the deep-overlap sign pin AP-82). - `ACDREAM_CAPTURE_RESOLVE=` — live capture of every player-side `PhysicsEngine.ResolveWithTransition` call. Each call appends one JSON Lines record with full inputs, PhysicsBody snapshot before AND diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 9f19098e..80581f8a 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -46,1696 +46,15 @@ Copy this block when adding a new issue: --- -## #179 — Lightning flash has no indoor gate (dormant until weather strobes ship) - -**Status:** OPEN (dormant — zero production impact today) -**Severity:** LOW (latent) -**Filed:** 2026-07-06 -**Component:** render — scene lighting UBO / weather - -**Description:** `mesh_modern.frag` adds `uFogParams.z × vec3(0.6, 0.6, 0.75)` -(the lightning-flash bump) to EVERY fragment, and `SceneLightingUbo.Build` -copies `atmo.LightningFlash` with no indoor gating (the GameWindow fog -override explicitly preserves `.z`). Today `WeatherState._flashLevel` is 0 -in production ("Production never TriggerFlashes" — test hook only), so -nothing is visible. The moment storm strobes ship, sealed dungeons will -flash blue-violet with every strike. Retail's indoor lighting path (flat -ambient, sun killed via the seen_outside gate) carries no storm terms. - -**Acceptance:** the flash term is zeroed for `playerInsideCell` frames (or -gated at the UBO build), verified by a storm-in-dungeon probe when weather -strobes land. Found during the #176/#177 investigation -(`docs/research/2026-07-06-176-177-render-pair-investigation.md`). - ---- - -## #178 — Retire the A8 double-sided cell-shell stopgap (CullMode.Landblock → None) - -**Status:** OPEN -**Severity:** LOW-MEDIUM (correctness/perf debt; 2× shell fragment load) -**Filed:** 2026-07-06 -**Component:** render — EnvCellRenderer MDI draw - -**Description:** `EnvCellRenderer.RenderModernMDIInternal` still carries the -Phase A8 visual-gate stopgap: `if (cullMode == CullMode.Landblock) cullMode -= CullMode.None;` — "render cell polys double-sided while the architectural -cause is isolated." Every cell shell draws two-sided to this day. Retail -draws cell polygons single-sided (the drawing BSP + winding decide facing). -The "architectural cause" (winding convention vs the frame-global CW -front-face) was never isolated; the stopgap outlived its gate. Retiring it -needs the winding audit (which side do CellStruct polys wind under our -extraction?) + a visual gate — walls/floors must not vanish. - -**Acceptance:** cell shells draw with proper backface culling, no missing -surfaces at the Holtburg + Facility Hub gates. Found during the #176/#177 -investigation (`docs/research/2026-07-06-176-177-render-pair-investigation.md`). - ---- - -## #177 — Dungeon stairs pop in/out across levels (invisible until entering the room; last step vanishes running down) - -**⚠️ UPDATE 2026-07-06 (visual gate) — this is NOT lighting.** The A7 visible-cell -light-scoping fix shipped + was probe-validated, but the user's gate showed the stairs -STILL not visible looking back from the corridor (zoom-out changes the last-step case). -Eye-position/flood behavior ⇒ a portal-VISIBILITY miss at the stair cells -(0178/0182/0183), NOT the "its LIGHTS went dark" attribution recorded below. Re-diagnose -as visibility. See the render digest banner. - -**Status:** OPEN -**Severity:** MEDIUM (visible geometry churn in the M1.5 dungeon) -**Filed:** 2026-07-06 -**Component:** render — indoor portal-flood visibility (dungeon multi-level) - -**Description (user, Facility Hub, 2026-07-06 gate session):** a staircase -connecting two levels (a) disappears on roughly the last step when running -DOWN it, (b) is not visible when looking into the stair room from the -corridor, and (c) pops into existence on entering the room. Classic -portal-visibility miss: the stair geometry's cell is not reached by the -portal flood from the viewer's cell until the viewer crosses into it. - -**Status:** OPEN — root cause CONFIRMED; fix DEFERRED to the A7 -dungeon-lighting arc (the cap-raise fix was live-tested and REVERTED, -see below). -**Root cause (confirmed via the probe launches):** the geometry never -vanishes — its LIGHTS do. `BuildPointLightSnapshot` keeps only the -`MaxGlobalLights=128` point lights nearest THE CAMERA; the Facility Hub -registers 366 fixtures, so 238 are evicted per frame by camera distance. -A room whose torches all rank past the cap renders at bare 0.2 ambient -(near-black in a dungeon = "not visible"); approaching re-admits them -("pops into existence"); the eviction boundary sweeping with the camera -drops the ramp's lights mid-descent ("disappears on the last step"). -Retail's `minimize_object_lighting` (0x0054d480) has no global -camera-nearest cap. -**Why the fix is deferred:** raising the cap to 1024 (commit `4d25e04d`) -made the pops stop but exposed three unported retail lighting semantics -that DOMINATE the frame with the full pool active: (a) lights reach -through solid floors/walls — retail registers lights per-CELL -(`insert_light` 0x0054d1b0) so the under-room portal light never touches -the corridor above; our flat sphere-overlap has no reach notion; (b) -stationary weenie fixtures ride the DYNAMIC 1/d falloff (~9× retail's -static 1/d³ at 3 m — the #143 isDynamic misassignment for ACE-served -fixtures); (c) an unexplained striped z-fight-like artifact on lit floor -regions (user screenshot). Reverted to 128 (`AP-85` documents the -stopgap; the desired-end-state pin is Skip'd in LightManagerTests). -**A7 fix shape:** per-cell light registration (insert_light port) + -static curve for stationary fixtures + the stripe hunt, THEN uncap. -Full investigation ledger: -`docs/research/2026-07-06-176-177-render-pair-investigation.md`. - -**Acceptance:** the staircase renders whenever its room is visible through -the connecting opening, and stays rendered through the full descent. - ---- - -## #176 — Purple flashing on dungeon floors at cell seams, camera-angle dependent - -**⚠️ UPDATE 2026-07-06 (visual gate) — the light-set/camera-cap theory is REFUTED.** The -A7 scoping fix shipped + was probe-validated (~285 through-floor lights dropped/frame), -yet the purple flash was UNCHANGED. `[light-detail]` names the real cause: a single -over-bright purple POINT light — `kind=Point range=9 intensity=100 color=(0.784,0,0.784)` -(200/255,0,200/255) — washing the floor. NEXT: identify its owning entity/Setup; decide -whether `intensity=100` is a dat mis-parse or a portal/effect light we render wrong. Not -set-composition, not through-floor. See the render digest banner. - -**Status:** OPEN -**Severity:** MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon) -**Filed:** 2026-07-06 -**Component:** render — floor-portal polygons / portal surface state - -**Description (user, Facility Hub, 2026-07-06 gate sessions):** the floor -flashes with a purple overlay at cell seams, at certain camera angles only. -Initially suspected to be the #137 physics oscillation exposed by the -render; the physics fix landed (seam shake gone, user-gated) and the flash -REMAINS — so it is a render-side issue in its own right, correlated with -camera angle. - -**Status:** OPEN — root cause CONFIRMED; fix DEFERRED to the A7 -dungeon-lighting arc (see #177 for the revert story — same mechanism, -same deferral). -**Root cause (confirmed via the probe launches):** per-cell LIGHTING pops, -not a draw failure. The probe run reproduced the flash while the ambient -branch ([light] — stable 0.2 grey) and the portal flood ([pv-input] — -zero drops in 54k frames) were provably healthy, which eliminated the -last CPU-side theories and left the one channel the probes cannot see: -per-cell 8-light SET COMPOSITION. The camera-capped snapshot (128 of the -Hub's 366 fixtures, nearest-to-camera) evicts in-range lights of visible -cells; the flipping unit is a CELL, so the discontinuities sit at exactly -cell-seam granularity, swing with the camera position (the chase boom), -and the dominant flipping light is the under-room PORTALS' purple — -hence purple flashes on the floor. Twelve other mechanisms were refuted -first — ledger in -`docs/research/2026-07-06-176-177-render-pair-investigation.md`. -**Deferral:** the uncapped pool (live-tested `4d25e04d`, reverted) -stabilizes the pops but floods rooms with through-floor portal light -(no per-cell reach semantics), over-strong dynamic-curve fixture light, -and a striped floor artifact — the A7 arc owns the real fix (per-cell -`insert_light` registration + static fixture curve + stripe hunt, then -uncap). Register row AP-85. - -**Acceptance:** no purple/placeholder flashes on dungeon floors from any -camera angle at the corridor seams. - ---- - -## #175 — Door collision registers the Setup PLACEMENT pose, not the motion-table CLOSED pose (phantom slab behind the visual door) - -**Status:** 🟡 FIX SHIPPED 2026-07-05 (same session) — pending user gate (Facility Hub double door: closed blocks AT the visual panels from both sides, no embed, no phantom wall; Holtburg cottage door unregressed). -**FIX:** `ShadowShapeBuilder.FromSetup` gains a `partPoseOverride` (BSP part -shapes only; CylSphere/Sphere unchanged); `RegisterServerEntityCollision` -derives it via `GameWindow.MotionTableDefaultPose` — the wire MotionTableId's -default style, first cycle, LowFrame part frames (the closed/idle pose retail's -live CPhysicsPart holds). Null / short poses fall back per-part to placement -frames (table-less entities + landblock statics unchanged). Register row -AP-84 (one-shot registration snapshot vs retail's per-frame live pose — -equivalent for the door lifecycle since open = ETHEREAL). Pins: the three -`FromSetup_*` tests in `Issue175HubDoorPoseInspectionTests`. -**Severity:** MEDIUM-HIGH (embed into doors from one side; phantom wall on the other — can push the player out of use radius) -**Filed:** 2026-07-05 -**Component:** physics — server-entity collision registration (door part poses) - -**Description (user, Facility Hub door guid 0x78A020C7 / Setup 0x02000C9D):** -running at the door embeds the player INTO the visual panel (deep enough to -camera-clip to the other side); the actual blocking plane sits displaced to -the FAR side, and approaching from that side there's a phantom wall in front -of the visual door — far enough that the door can be out of use range. - -**Mechanism (dat-confirmed, 2026-07-05):** the hub door is a DOUBLE door — -Setup 0x02000C9D has 3 parts; panels part[0]/part[1] (GfxObj 0x01002936, -physics slab 1.66×0.29×2.95 m) pose in the Setup's `Default` PLACEMENT -frames at yaw **−150° / −30°** with origin **(±0.88, −0.44, 1.37)** — an -AJAR pose displaced 0.44 m behind the doorway plane. The RENDERED door poses -its panels from the motion table's default (closed) state via the sequencer -(the setup itself has no DefaultMotionTable; the wire spawn supplies it). -Collision registers via `ShadowShapeBuilder.FromSetup`, which reads the -PLACEMENT frames (`Resting|Default|first`) — so the physical slabs sit at -the ajar placement pose while the visuals show closed panels: the exact -offset the user walked into. Retail tests each part's LIVE pose -(`CPhysicsPart` — see the #150 notes: for a CLOSED door the live pose IS -the motion-table closed pose; the open swing never matters because ETHEREAL -bypasses collision entirely). - -**Fix shape (retail-faithful, next session):** the BSP shadow shapes for -server entities with a sequencer must use the SEQUENCER's part transforms -(the motion-table default/closed pose) instead of the raw placement frames — -either sample at registration (the sequencer exists by then — verify spawn -wiring order) or re-register via `ShadowObjectRegistry.UpdatePosition`-style -refresh after the sequencer's first advance. Parts without animation data -keep the placement-frame fallback. Watch: entScale composition, multi-part -dedup ([[feedback_dedup_keys_after_cardinality_change]]), and the Holtburg -single-door apparatus must stay green (its placement pose ≈ closed pose, -which is why #99/#150 never surfaced this). - -**Files:** `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (placement-frame -read), `src/AcDream.App/Rendering/GameWindow.cs` -(`RegisterServerEntityCollision` ~4130), inspection -`tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs`. - -**Acceptance:** at the Facility Hub double door: closed door blocks AT the -visual panels (no embed, no phantom wall on either side); open door fully -passable; use radius reachable from both sides. Holtburg cottage door -unregressed (door apparatus green). - ---- - -## #174 — Door Use dies after the first jump: the RemoveLinkAnimations seam stripped animations without retail's queue drain - -**Status:** 🟡 FIX SHIPPED 2026-07-05 (same session) — pending user gate (jump around, then use the Facility Hub door from close AND from ~3 m). -**FIX:** the `MotionInterpreter.RemoveLinkAnimations` seam is retail -`CPhysicsObj::RemoveLinkAnimations` 0x0050fe20 — a tailcall to -`CPartArray::HandleEnterWorld` 0x00517d70 → `MotionTableManager:: -HandleEnterWorld` 0x0051bdd0: strip the sequence's link animations AND drain -`pending_animations` completely (each pop relays MotionDone → the interp pops -its `pending_motions` node in lockstep). acdream bound the seam to the BARE -sequence strip (`RemoveAllLinkAnimations`), so every jump's LeaveGround -removed the animations that queued manager nodes were counting down on — -orphaning them and permanently damming BOTH queues; `MotionsPending()` then -starved every armed moveto (the far-range walk-to-door and the close-range -use turn — both door faces below). Rebound at both production sites -(GameWindow remote bindings + the player's EnterPlayerModeNow block) to -`Manager.HandleEnterWorld()`; harness mirrors updated; pins -`Issue174LinkStripDrainTests` (seam drains both queues; new motions queue + -complete after). The `UseDone` (0x01C7) display gap stays open below. -**Severity:** HIGH (can't open doors reliably → blocks the #137 door acceptance + normal play) -**Filed:** 2026-07-05 -**Component:** interaction — B.4b Use pipeline / AP-23 speculative moveto deferral (R5-V5 facade) - -**Description (user, Facility Hub 0x8A02, door guid 0x78A020C7 Setup 0x02000C9D -useRadius=0.50):** double-clicking / R-using the door does nothing. The same -door opens fine from the retail client on the same ACE, and acdream renders -the observed swing correctly (inbound path healthy). - -**Evidence (3 wire captures + app log, 2026-07-05):** -1. First attempt (log): `use-deferred seq=624` fired (the close-range - deferral COMPLETED once — arrival callback ran), then 625 + 642–647 sent - far-range. Door never opened. ACE's replies to those weren't captured. -2. Retail control (capture `door-use3.pcapng`): retail sent - `[0xF7B1][seq][0x36][guid]` ×5 — ACE responded EVERY time with door - `0xF74C` UpdateMotion + `0xF74B` SetState (ETHEREAL toggling 0x1001C ↔ - 0x10018), broadcast to BOTH clients. Message format identical to ours. -3. acdream re-try (capture `door-use4.pcapng` + log): 2× DoubleClick + 4× R - — picks land, but ZERO `[B.4b] use` lines and ZERO door-guid packets on - the wire. The Use is swallowed CLIENT-SIDE before the send. - -**Mechanism (code trace):** `SendUse` close-range branch (≤2 m by the AP-23 -bucket — this door's flags → 2.0 m, overriding its real 0.5 m) parks the -action in `_pendingPostArrivalAction` and fires it ONLY from -`MoveToComplete(WeenieError.None)` (natural completion of the speculative -TurnToObject installed through the R5-V5 facade). A cancel (user input) or a -never-starting turn silently eats the use — no toast, no log (the deferral -prints are probe-gated). Candidates for "never completes": (a) the -`BeginTurnToHeading` `MotionsPending()` early-return starving the turn (the -#170 class, local-player edition — the log shows a steady -`MOTIONDONE pending=True` stream); (b) every attempt instantly cancelled by -concurrent user movement input (retail-faithful per-attempt, but the user -also clicked while standing still). Also noted: `GameEventType.UseDone` -(0x01C7) is parsed nowhere (not registered in `GameEventWiring`) — ACE -rejection reasons are invisible; and the first attempt's 7 sent uses never -opened the door either (suspect: concurrent outbound movement cancelling -ACE's MoveToChain server-side — unproven, replies not captured). - -**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (`SendUse` ~12607, -`OnAutoWalkArrivedSendDeferredAction` ~12761, `InstallSpeculativeTurnToTarget` -~12842, `MoveToFactory` callback wiring ~13530); -`src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`BeginNextNode` / -`BeginTurnToHeading` gates). Captures in the session scratchpad -(`door-use3.pcapng`, `door-use4.pcapng`). - -**ROOT MECHANISM FOUND (2026-07-05 evening, probe round `launch-174-autowalk.log`): -the local player's pending-motion queue drains at ~1 node/sec and backs up -minutes deep during active play — MotionsPending() then starves every -manager-driven movement.** Chain, all evidence in the log: -1. Fresh session, standing at the door: every Use completes same-tick - (`[autowalk-end] err=None` ×6, seqs 11–16 sent, door opens — "now it - works??"). Queue shallow ⇒ pipeline healthy. -2. After the jump/run sequence: the LAST player `pending=False` completion - is at the first `MovementJump Press` (log line 371); from there to the - end (line 939) every player MOTIONDONE reports `pending=True` — INCLUDING - at rest, with old jump-family motions (0x6500000D/0F) still completing - minutes later. That is a slow-draining BACKLOG, not one immortal node. -3. With MotionsPending() true, `BeginTurnToHeading`/`BeginMoveForward` - (retail 0x00529b90 `if (motions_pending) return`) never start: - - far range (wire-proven, seqs 98–101): Use SENT, ACE replies mt-6 - MoveToObject (objDist=0.50 — ACE is healthy), `[autowalk-begin] - mt=0x06` arms, body NEVER walks ([autowalk-up] position frozen) → ACE - waits forever → door never opens. Same as the original session's - 642–647. - - close range (round-3 silence): TurnToObject armed, never completes → - `_pendingPostArrivalAction` never fires → use eaten with zero feedback. -4. Retail contrast: the #170 live cdb drain trace showed retail's queue - stays SHALLOW (add_to_queue == MotionDone, drained same-tick); our - `CheckForCompletedMotions` completes ~one node per animation cycle, so - adds outpace drains during any active play. This is the #170 - pending_motions-flood family — LOCAL-player drain-rate edition (the - remote fix `427332ac` removed the flood's feeder; the local queue's - DRAIN semantics are the divergence here). - -**Next (fix session):** oracle-first on the drain: decomp -`MotionTableManager::CheckForCompletedMotions` (0x0051bfd0) + -`AnimationDone/MotionDone` pop semantics — which queued nodes retail -completes per tick (superseded/non-playing nodes must flush immediately, -not serialize behind animations). Add a queue-dump probe (node ids + ages) -before changing anything. Then re-verify the door BOTH branches + re-check -`UseDone` (0x01C7) wiring so ACE rejections become visible. -DO NOT band-aid: no MotionsPending bypass in BeginTurnToHeading (the gate -is verbatim retail), no deferral-skipping (turn-to-face is retail). - ---- - -## #173 — Observed character jumping into a ceiling hovers at the roof until the arc decays (no collision-velocity response on remotes) - -**Status:** 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (watch a second client jump into the 0x0007 dungeon roof; it should bounce down immediately like the local player). -**Severity:** MEDIUM (remote-motion fidelity indoors; lands visibly late) -**Filed:** 2026-07-05 -**Component:** physics — remote dead-reckoning collision response - -**Description (user, 0x0007 dungeon):** watching another character jump into -the dungeon roof, the observed char sticks to the ceiling until the jump arc -would naturally have come down — "like we are calculating the entire jump -instead of actually checking the collision" — and lands later than retail, -with the animation pinned at the roof. The LOCAL player's own jump bounces -off the roof immediately. - -**Root cause (code-confirmed):** the remote DR tick integrates the -VectorUpdate launch ballistically and DOES sweep collision -(`ResolveWithTransition`, GameWindow remote block) — the sweep pins the -POSITION at the ceiling — but the retail post-transition velocity response -(`CPhysicsObj::handle_all_collisions`, pc:282699-282715: reflect -`v −= (1+elasticity)·dot(v,n)·n`) was only ever ported for the LOCAL player -(L.3a, `PlayerMovementController` ~:940). The remote body kept its +Z launch -velocity, re-integrated it into the roof every tick, and only descended once -gravity burned the arc off. Retail runs handle_all_collisions after every -SetPositionInternal for every physics object — remotes included. - -**Fix (this commit):** mirror the local L.3a reflection block in the remote -sweep's post-resolve path (same formula, same AD-25 airborne-before-AND-after -suppression so corridor slides and landings don't reflect, same Inelastic -zero-out). Register AD-25 extended to cover both sites. - -**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep, #173 -block after `rm.Body.Position = resolveResult.Position`). - -**Acceptance:** from acdream, watch a second client jump into a dungeon -ceiling: the observed char deflects off the roof immediately and lands at -retail timing; grounded remote movement (corridor wall slides, NPC chases) -unchanged. - ---- - -## #172 — Town-network portal platform blocks instead of stepping up (CCylSphere family was never ported) - -**Status:** 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (walk up onto the Holtburg portal platform, then the 0x0007 dungeon run). -**Severity:** HIGH (blocks dungeon access — gates the whole #137 repro) -**Filed:** 2026-07-05 -**Component:** physics — CylSphere object collision response - -**Description (user):** the Holtburg town-network portal sits on a stone -platform the player collides with instead of stepping up onto it (retail just -walks up). Entity `0xC0A9B465` = landblock stab #0x65, Setup `0x020019E3`, -one CylSphere **r=2.597 m, h=0.256 m** — a 26 cm disc, trivially steppable in -retail. Surfaced the moment #149 (`4cf6eeb`) started registering BSP-less -stab CylSpheres (before that fix the platform had NO collision at all, so the -player clipped through it — the collision *shape* was the #149 fix; the -collision *response* was never retail). - -**Root cause (probe-confirmed, `launch-137-repro.log`):** the pre-port -`CylinderCollision` was a hand-rolled approximation (AP-6): step-up gate + -radial wall-slide only. Every contact returned `Slid` with a horizontal rim -normal (`[cyl-test] … result=Slid`, `[resolve] … n=(0.99,-0.11,0.00)`) and -the player orbited the rim forever. The step-up *gate* passed (clearance -0.256 ≤ 0.6) but `DoStepUp`'s internal step-down probe could never validate a -landing ON the cylinder top — a cylinder has no polygons, and the port had no -`step_sphere_down` cap-landing (top-disc contact plane). Airborne landings on -tops (`land_on_cylinder` + the Collide-flag exact-TOI branch) were missing -too. - -**Fix (this commit):** verbatim port of the full retail `CCylSphere` family — -dispatcher `intersects_sphere` 0x0053b440, `collides_with_sphere` 0x0053a880, -`normal_of_collision` 0x0053ab50, `collide_with_point` 0x0053acb0, -`slide_sphere` 0x0053b2a0, `step_sphere_up` 0x0053b310, `land_on_cylinder` -0x0053b3d0, `step_sphere_down` 0x0053a9b0. Pseudocode + settled BN -ambiguities + two ACE-bug findings: -`docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md`. -Register: AP-6 retired, AP-83 added (PerfectClip TOI tail per ACE, dead code -in M1.5). Conformance: `CylSphereFamilyTests` (grounded step-up-onto-top on -the exact platform shape, tall-cylinder block, airborne top landing, ethereal -Layer-2 guard); the #42 self-shadow control assertion updated to the retail -observable (denied movement, not the old artifact radial push). - -**Files:** `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylinderCollision` -+ `Cyl*` family), `tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs`. - -**Acceptance:** walk straight onto the Holtburg town-network portal platform -(no rim slide); jumping onto it also lands. Doors/torches/NPC cylinders -unregressed (suites green; #150 open-door behavior unchanged). Likely also -advances #137's door-foot half — re-check in the dungeon repro. - ---- - -## #171 — Group melee: monsters interpenetrate + facing drifts (sticky melee unbound, arrival radii = 0) - -**Status:** DONE (2026-07-04) — **user visual gate PASSED** ("Looks good, ship it") -after three slices: `5bd2b8bc` (R5-V3 sticky binding + real radii) + `7a823176` -(NPC UP-snap suppression while stuck — TS-44) + `69966950` (sticky deep-overlap -back-off sign pin — AP-82; ACE's literal decode steered INTO the target when the -overlap exceeded one tick's step; 1661-tick probe capture refuted it against the -retail oracle). `ACDREAM_PROBE_STICKY=1` stays as permanent gated diagnostics -(PhysicsDiagnostics family). (Move to Recently closed on next ISSUES tidy.) -History below. Original landing note: StickyManager/ -PositionManager (ported R5-V1) now BOUND: `StickTo`/`Unstick`/`UnstickFromObject` -seams → the host's `PositionManager` (remote + player), `AdjustOffset` composed -at the retail `UpdatePositionInternal` slot before the collision sweep, -`UseTime` (1 s lease watchdog) at the `UpdateObjectInternal` tail, real setup -cylsphere radii threaded (own via `EnsureRemoteMotionBindings`/player wiring, -target via `RouteServerMoveTo`), the SERVERVEL leg now also yields to a stuck -entity (TS-41), exit-world/teleport teardown wired (remote teleport gap filed -TS-43). TS-39 retired. Harness: 2 new sticky scenarios in -`RemoteChaseEndToEndHarnessTests` (arrive→stick→strafe-track→lease-expiry; -unstick-on-rearm→re-stick). -**Severity:** MEDIUM (visual — group combat feel vs retail) -**Filed:** 2026-07-04 (user report during the #170 visual gate: "some monsters were -partly inside other monsters while hitting me… some orientation of the monsters -are a bit off") -**Component:** remote entity, MoveTo/PositionManager, R5-V3 - -**Description:** in a pack melee, acdream attackers end up partly inside each -other with slightly stale facings vs retail on the same ACE. Two code-grounded -causes (both already-tracked R5-V3 scope), plus one server-side caveat: - -1. **Sticky melee is a no-op (register TS-39).** ACE arms every melee chase with - `Sticky|UseFinalHeading|MoveAway|FailWalk` - (`references/ACE/…/Monster_Navigation.cs:416`). Retail's sticky arrival hands - off to `PositionManager::StickTo` → `StickyManager::adjust_offset` - (0x00555430): per-tick 0.3 m edge-gap + facing tracking against the moving - target. Our `MoveToManager.BeginNextNode` invokes the `StickTo` seam — unbound - — so attackers complete-and-freeze at stale arrival poses until the next wire - re-arm. -2. **Arrival radii are zero.** ACE/retail arrive edge-to-edge - (`cylinder_distance`, own + target radii from the PartArray/setup — - `ACE PhysicsObj.MoveToObject` reads `GetRadius()/GetHeight()` from the TARGET). - acdream binds `getOwnRadius: () => 0f` (GameWindow `EnsureRemoteMotionBindings`, - the explicit R5-V3 pin) AND `RouteServerMoveTo` never sets - `MovementStruct.Radius/Height` → both radii 0 → every attacker closes ~one - body-radius deeper than retail → dogpile. -3. Caveat: ACE has no server-side monster-vs-monster avoidance — some overlap is - server-authoritative and shows on retail too. Acceptance = retail parity, not - zero overlap. - -Ruled out: the between-snap collision sweep (remotes run full -`ResolveWithTransition`; `CollisionExemption` keeps creature cylinders collidable -for non-viewer movers). - -**Approved fix (R5-V3):** port `StickyManager` + bind `PositionManager` -`StickTo`/`UnStick` seams + thread setup-derived cylsphere radii through the -MoveTo distance math. **SSOT:** `docs/research/2026-07-04-171-sticky-melee-handoff.md` -+ pickup `docs/research/2026-07-04-171-pickup-prompt.md`. - -**Acceptance:** side-by-side vs retail in a scamp pack: attackers hold separation -+ face the player while it strafes; user visual gate. - -**Gate 1 result (2026-07-04):** "in general it is better" but three residuals: -(1) monsters sometimes pushed INTO the player; (2) monsters sometimes attack -while facing the wrong way; (3) "flashing/flapping instead of gliding". All -three = ONE mechanism: the legacy NPC UP handler hard-snaps position (5678) + -orientation (5714) + velocity/cycle UNCONDITIONALLY, fighting the armed sticky -per tick (ACE's authoritative rest pose sits ~0.6 m out + lags the strafing -target's bearing; sticky pulls to 0.3 m + live facing → oscillation at UP -cadence). Retail is immune BY ARCHITECTURE: UPs flow through the -InterpolationManager into the same adjust_offset chain where sticky OVERWRITES -them while armed. **Residual fix:** suppress the NPC UP snaps while stuck -(register TS-44 — the retail chain semantics translated to the snap path) + -`ACDREAM_PROBE_STICKY=1` apparatus (`[sticky]` lifecycle/steer lines + -`[sticky-snap-skip]`, all per-guid). Awaiting gate 2. - -## #170 — Remote creature chase+attack renders wrong vs retail (glide, over-frequency, uniform attack anims) - -**Status:** DONE (2026-07-04) — **user visual gate PASSED** ("looks good, as close -to retail now as I can see"). Fixes: `427332ac` (per-frame re-dispatch flood) + -`d2ccc80e` (velocity refresh) + `1051fc83` (armed moveto always ticks UseTime — the -SERVERVEL starvation) + probe strip. Gate telemetry (launch-170-gate2.log): run -installs ≈ 1:1 with arms for every chasing creature (was 16:1), zero -armed-moveto UseTime skips, queue depth 1. (Move to Recently closed on next -ISSUES tidy.) History below. Fix #1 (`427332ac`): the per-frame -`apply_current_movement` re-dispatch flooded `pending_motions` to ~1.3M → deleted; -flood 1.3M→~1, "stuck attack" gone (user-confirmed), run installs 1→10. Fix #2 (this -session): **the "slow Ready drain" framing was WRONG** — a full-stack offline harness -(`RemoteChaseEndToEndHarnessTests`, real MoveToManager + MotionInterpreter + -AnimationSequencer + omega integration in GameWindow's exact tick order) proved the -Core drain/turn/run pipeline healthy (turn completes <1 s, run sustains, add==done). -Corrected per-guid log attribution (launch-drainq.log) showed the REAL funnel: 16 arms -→ 11 turns dispatched → **1 run install**, because the per-tick branch arbitration -routed any UP-receiving NPC to the SERVERVEL leg (`HasServerVelocity` synthesized from -position deltas) which **skips `MoveToManager.UseTime`** — the armed moveto was starved -for exactly the duration of the server-side chase (`[npc-tick] branch=SERVERVEL (skips -UseTime) mtState=MoveToObject`), legs stayed Ready while the body glided on synthesized -velocity; the manager only woke in UP-silent gaps and was interrupted by the next UM. -Retail runs `MovementManager::UseTime` UNCONDITIONALLY per tick -(`CPhysicsObj::UpdateObjectInternal` 0x005156b0 @0x00515998) and has no wire-velocity -leg-driver. FIX: an armed moveto (`MovementTypeState != Invalid`) always takes the -MOVETO leg; SERVERVEL remains only for non-moveto entities (register TS-41; drain-order -one-frame divergence also pinned + filed as TS-42). **SSOT + pickup:** -`docs/research/2026-07-04-170-creature-run-handoff.md` + -`docs/research/2026-07-04-170-pickup-prompt.md` (residual sections superseded by this -entry). SUPERSEDES the earlier MovementManager-coexistence hypothesis (`eb423fb7`, -wrong shape — but the starvation IS a coexistence bug at the tick-arbitration altitude) -and keeps the `d2ccc80e` velocity fix. #159 was a red herring here. NEXT: user visual -gate (retail side-by-side, chase a fleeing player) → then strip the #170 probes -(`s_mvtoDiag`, `s_drainDiag`, `[npc-tick]`, `UM ↳ actions`) and close. -**Severity:** MEDIUM (visual — remote combat / aggro) -**Filed:** 2026-07-03 (user retail side-by-side during the R5-V2 visual gate) -**Component:** animation, remote entity, combat - -**Description:** A monster chasing + attacking the player (aggro) renders wrong -vs the retail client running against the SAME local ACE. User side-by-side: -retail plays proper animations, positions the monster better, plays DIFFERENT -attack animations (variety), and the attack-animation FREQUENCY is correct; -acdream shows the creature "stuck in the attack animation, gliding after me." -Because retail gets the identical ACE motion stream and renders it correctly, -the divergence is CLIENT-side (acdream's UM/animation handling), **not** ACE. - -Three distinct sub-divergences: -1. **Wrong/uniform attack animations** → **#159 is DONE (`2de5a011`) but was a - RED HERRING for this creature.** The Mite Scamp's attacks are wire - `0x62/0x63/0x64` = `AttackHigh1/Med1/Low1`, which live in the ALREADY-CORRECT - low block — they were never misnumbered, and `CombatAnimationPlanner` isn't - even wired into the runtime dispatch (the live path is `AnimationCommandRouter` - → `MotionInterpreter`/`AnimationSequencer`). #159 fixed a genuine latent bug in - the late block (Offhand*/Attack4-6/Punch*) but does not touch this symptom. - The "uniform/same animation" is therefore NOT a classification-numbering bug — - it is sub-bug 2 below (the same attack replays because `pending_motions` - completes+re-queues) and/or the sequencer not selecting per-command frames. -2. **Over-frequency / stuck attack** — motion trace: ACE streams attack UMs - (`mt=0x00 cmd=0x62/0x63/0x64 spd=0.97`) on top of the `mt=0x06` chase; - acdream's `pending_motions` completes+re-queues in a tight MOTIONDONE loop - (`pending=True` spam) → over-plays. R3/R4 pending_motions/MotionDone area. -3. **Glide** — the creature's position moves (mt-6 moveto + UP dead-reckon) but - no locomotion legs play (attacks override) → smooth slide. AP-80 / #160 - dead-reckon-vs-animation family. - -**Evidence:** `ACDREAM_DUMP_MOTION` trace for guid 0x80000244 — `mt-0 stance 0x3C` -→ `mt-6 chase spd 2.03` → stream of `mt-0` attacks `0x62/63/64 spd 0.97`; -MOTIONDONE loop between `0x8000003C` (stance) and the dispatched motion. - -**NOT V2/R5:** the voyeur target-tracking correctly makes the creature chase -(the `mt-6` is proof); this is the animation/position layer. - -**Where:** `MotionInterpreter`/`AnimationSequencer` attack-motion dispatch + -`pending_motions`; `CombatAnimationPlanner` (#159); `ServerControlledLocomotion` -+ remote dead-reckoning (glide). - -**Acceptance:** side-by-side with retail — a chasing+attacking Mite Scamp shows -correct attack-animation variety, correct frequency, and steps/runs (no glide). - -**Investigation 2026-07-04 (code-grounded mechanism, HYPOTHESIS — pending live -confirmation):** traced the live remote path end-to-end. The chase (`mt-6` -MoveToObject) is owned by the entity's verbatim `MoveToManager` -(`GameWindow.RouteServerMoveTo`, ~4899) — this is what moves the legs. The -attacks arrive as SEPARATE `mt-0` InterpretedMotionState UMs and flow through -`MotionInterpreter.MoveToInterpretedState` (~4985). Two seams make the creature -glide + over-play: -1. **Every inbound UM fires the `unpack_movement` head interrupt** - (`remoteMot.Motion.InterruptCurrentMovement`, GameWindow ~4893) which is bound - to `MoveTo.CancelMoveTo(ActionCancelled)` (GameWindow ~4319). So each `mt-0` - attack UM **cancels the active chase MoveTo.** -2. `MoveToInterpretedState` → `ApplyInterpretedMovement(cancelMoveTo:true)` - installs the UM's `ForwardCommand`, which for an attack-only UM defaults to - **Ready/idle** (`ims.ForwardCommand = fullMotion`, null/0 → Ready, ~4933) → - the run cycle is replaced by idle while the body keeps its dead-reckon - translation → **glide**; the creature is repeatedly knocked idle+attack - rather than running (over-play / "stuck"). -The `ServerActionStamp` 15-bit gate (`MoveToInterpretedState` ~2845) IS faithfully -ported, so identical-stamp replays are already suppressed — the "uniform anim" is -NOT a stamp bug. This coexistence of MoveTo (chase) + interpreted-state (attack) -is **explicitly R5/MovementManager scope** (GameWindow ~4816 "LoseControlToServer -autonomy handoff is R5/MovementManager scope"). So #170 sub-bugs 2/3 are -**downstream of the incomplete MovementManager port (R5-V4), not #159 and not a -localized bug.** NEXT: (a) confirm with a labelled `ACDREAM_DUMP_MOTION=1` capture -of the Mite Scamp (exact mt-6/mt-0 interleave + what ForwardCommand + stamps the -attack UMs carry), (b) retail side-by-side — does retail keep the legs running -during attacks and re-establish the chase between swings? — then port the retail -MovementManager coexistence (R5-V4). Do NOT guess a fix in this revert-prone path. - -## #169 — Cold-spawn "hole": world doesn't load / invisible player when spawning far from Holtburg - -**Status:** DONE (`9b06a9b8`, 2026-07-03) — root confirmed live via a landblock-load -probe. **Cause:** the two-tier streamer marks every window landblock "resident" -at bootstrap (`StreamingRegion.MarkResidentFromBootstrap`) BEFORE their async -loads land. A character saved FAR from the startup center (0xA9B4 Holtburg) -triggers a login-spawn `RecenterTo` against that stale, half-loaded window; -`RecenterTo` trusts `_tierResidence`, so the old/new window overlap is never -re-enqueued — a permanent HOLE of resident-but-never-loaded landblocks (zero -`BUILD-NULL`; they're simply skipped). The player spawns in the hole → its -landblock never loads → terrain/NPCs/player never draw ("world loads behind me, -character invisible"). Probe: spawn at 0xADAF, column 0xAD loaded Y=0xA3-0xAA + -0xB0-0xC0 but MISSING Y=0xAB-0xAF; player at 0xADAF (Y=0xAF) dead in the gap. -**Fix:** a far login-spawn moves the render origin like an outdoor teleport -(which already calls `StreamingController.ForceReloadWindow` — drop stale window -+ re-bootstrap fresh at the new origin); the login-spawn recenter now flags that -rebuild (network thread → set flag → render thread consumes it before the Tick). -Verified: 0xADAF now loads (216 entities), full world draws, player recovers. -No-op for a normal Holtburg login. NOT R5 (pre-existing streaming bug). - -## #168 — Invisible player / character disappears (pending-bucket trap) - -**Status:** DONE (`315af02f`, 2026-07-03) — root confirmed live via -`ACDREAM_PROBE_ENT`. **Cause:** a persistent server-spawned entity that spawns -into a not-yet-loaded landblock is parked in `GpuWorldState._pendingByLandblock`. -`RelocateEntity` (per-frame, keeps the player homed to its current landblock so -it draws) scanned ONLY `_loaded`, so it silently no-op'd on a pending entity — -and the player fell through all recovery paths (the AddLandblock drain already -ran empty; the server-object re-hydrate excludes the player; RelocateEntity -couldn't reach pending) → stranded invisible. This is the mechanism behind BOTH -"cold-spawn invisible" AND "character disappears running out of Holtburg far -enough" (both = player parked in pending, never recovered). **Fix:** -`RelocateEntity` now removes the entity from whichever bucket it occupies -(`_loaded` OR `_pendingByLandblock`) then re-appends to its current landblock — -promoting a stranded pending entity to drawn as soon as its landblock loads. -Test: `GpuWorldStateTests.RelocateEntity_StrandedInPending_MovesToLoadedTarget` -(red→green). Composes with #169. NOT R5-V2 (verified read-only re: pos/streaming). - -## #167 — ConstraintManager leash unported (arming + two unknown x87 constants) - -**Status:** OPEN (deferred, filed 2026-07-03 during R5-V1) -**Severity:** LOW (server-position rubber-band + jump-during-rubber-band gate) -**Component:** physics, constraint - -**Description:** R5-V1 ported `ConstraintManager` (the server-position -rubber-band leash) as a Core class for structural completeness of -`PositionManager`, but it is never ARMED in acdream. Retail arms the leash -ONLY from `SmartBox::HandleReceivedPosition` (0x00453fd0) — on every inbound -server position packet, anchoring the mover to self (remotes) or the received -position (player) — with a start/max distance band from -`CPhysicsObj::GetStartConstraintDistance` (0x0050ebc0) and -`GetMaxConstraintDistance` (0x0050ec10). acdream's position reconciliation is -not `SmartBox`, so nothing calls `PositionManager.ConstrainTo`, and -`IsFullyConstrained` stays false (= register **TS-35**'s current stub -behavior — jump never blocked by the leash). - -**Blockers:** (1) the two distance constants are **x87 float returns BN -elided** — `GetStart/MaxConstraintDistance` decompile to a bare -`this->m_position;` expression with the actual returned value lost to the -FPU-return-elision artifact. Recovering them needs a live cdb read of `st0` -after the call (retail debugger toolchain) or a Ghidra re-decompile with a -corrected float-return signature. (2) The arming site (`SmartBox`'s inbound -position-reconciliation branches A/B/C) has no acdream equivalent yet — wiring -it means teaching acdream's position path to re-anchor the leash on every -server position update, then feeding the `adjust_offset` taper into the body -integration (same chokepoint as the sticky wiring, R5-V3). - -**Where:** `src/AcDream.Core/Physics/Motion/ConstraintManager.cs` (class, -unarmed); the read gate is `PhysicsBody.IsFullyConstrained` (TS-35) via -`jump_is_allowed`. Decomp: `docs/research/2026-07-03-r5-managers/`. - -**Acceptance:** the two constants are recovered (cdb/Ghidra), acdream arms the -leash on inbound server positions, `IsFullyConstrained` fires while -rubber-banding, and a jump attempt inside the tight leash is blocked -(0x47) matching retail; TS-35 + this issue retire together. - -## #160 — Remote moveto: run animation pace vs actual movement speed mismatch - -**Status:** CLOSED (2026-07-03, `41006e79`, user-verified same session). -**Root cause:** remote interps carried NO weenie, so retail's -apply_run_to_command rate chain (`weenie ? (InqRunRate() ?: my_run_rate) -: 1.0`, raw 305062-305076) took the degenerate 1.0 branch and the wire's -MoveToRunRate (stored in MyRunRate by the mt-6/7 unpack, M13) was never -consumed — run dispatches at speed 1.0 = slow-motion legs + crawl. Fix: -`RemoteWeenie` (retail's per-object ACCWeenieObject stand-in; InqRunRate -fails → my_run_rate fallback) on every RemoteMotion interp. -**Symptom:** observing a retail player's server MoveToChain from acdream: -close-range movetos WALK correctly; at run distance the RUN cycle plays but -the body moves visibly slower than the legs (treadmilling). The legs' pace -comes from the manager's dispatch (`_DoMotion` → `apply_run_to_command` × -`MyRunRate` = the wire's mt-6 `MoveToRunRate`, observed 4.50 for a -high-skill char); the body's position is queue-chased from ACE's actual -UpdatePosition stream — evidently slower than that rate. Speed-source -disagreement: either ACE's chain moves slower than the advertised runRate, -or our dispatched cycle speed over-scales (compare: local player run pace -was correct). **Evidence to gather:** UP cadence Δpos/Δt for the mover vs -`RunAnimSpeed × dispatched speed`; check what speed ACE's MoveToChain -actually steps at (Player_Move.cs / Creature GetRunRate usage). -**Where:** GameWindow `TickRemoteMoveTo` + the L.3 M2 queue chase vs -`MotionTableDispatchSink` dispatch speed. Related: TS-33 cadence rows. - -## #165 — Remote entities penetrate walls ("swallowed a bit") before stopping - -**Status:** OPEN -**Severity:** MEDIUM (visual-only, remote view) -**Filed:** 2026-07-03 (user observation during the R2-R4 visual pass) -**Component:** physics, remote dead-reckoning - -**Description:** Observing a retail-client mover from acdream: when the -mover runs into a wall, the observed remote runs INTO the wall a bit — -"they get swallowed a bit by the wall" — instead of stopping flush at it -like the mover does on their own screen. The mover's client stops them at -the wall; ACE's UP stream reports at-the-wall positions; the acdream-side -remote body ends up partially inside the geometry. - -**Root cause / status:** Unknown — candidates, in suspicion order: -(1) dead-reckoning overshoot: between UPs the DR tick advances the body -along its velocity; if that segment's collision resolve doesn't stop at -the wall (or runs with a start position already server-snapped into -contact), the body tunnels until the next UP pulls it back; (2) the L.3 -M2 queue-chase walking toward a waypoint AT the wall with the remote's -cylinder ignoring the wall plane (check which resolve path the chase -uses and whether it carries the remote's ShadowEntry exclusions only — -#42 — or accidentally broader exclusions); (3) retail remotes collide via -the same per-cell shadow lists as the player (see -feedback_retail_per_cell_shadow_list) — verify our remote resolve -consults building/EnvCell geometry at all in the observed cells. -NOTE: capture first — ACDREAM_PROBE_RESOLVE on the remote's guid at a -wall shows whether the resolver reports Collided-but-position-inside or -never sees the wall. - -**Where:** GameWindow remote DR tick (`TickAnimations` player-remote -pipeline + queue chase), `PhysicsEngine.ResolveWithTransition` remote -callers. - -**Acceptance:** a retail mover pressed against a wall shows flush at the -wall from acdream, matching the retail-observer view side-by-side. - -## #166 — Slope-landing glide + bounce absent (retail "sled" on downhill jumps) - -**Status:** OPEN (post-R6 polish — user: "we could polish later") -**Severity:** LOW (feel/polish) -**Filed:** 2026-07-03 (user observation during the R2-R4 visual pass) -**Component:** physics, landing - -**Description:** In retail, jumping down a hill often lands with a short -glide (sled) and a bounce before settling; acdream lands clean and dead. -The user explicitly classified this as later polish — "a bit deeper -physics than this." - -**Root cause / status:** This is the REGISTER-PREDICTED composite of -three known deferred deviations: **AD-25** (landing wall-bounce velocity -reflection suppressed — its risk column literally names "slope-landing -momentum won't reproduce"), **AP-7** (`calc_friction` threshold 0.0 -without retail's 0.25-with-state-gate — sled deceleration differs), and -**TS-4** (Path-6 steep-poly slide-tangent shortcut — airborne-steep -contact chain diverges). Retiring those three rows IS this issue; do them -together against a retail cdb capture of a downhill jump (velocity + -contact-plane trace at landing). - -**Where:** `PlayerMovementController.cs:874` (AD-25 suppression), -`PhysicsBody.cs:307` (AP-7), `BSPQuery.cs:2001` (TS-4). - -**Acceptance:** side-by-side downhill jump: acdream glides/bounces like -retail; flat-ground landings unchanged; no micro-bounce death spiral -(the reason AD-25 exists) reintroduced. - -## #164 — UM action-replay dispatches drop the per-action Autonomous bit - -**Status:** DONE (2026-07-04, R5-V4) — `DispatchInterpretedMotion` now threads -the action's autonomy into the dispatch params (`Autonomous` = the 0x1000 -splice, raw 305982); the stored `InterpretedState.Actions` node carries the -real autonomy. Conformance: -`MotionInterpreterFunnelTests.Actions_ReplayCarriesAutonomyIntoTheInterpretedList`. -(Move to Recently closed on next ISSUES tidy.) Original finding below. -**Filed:** OPEN (2026-07-03, filed during #161) -**Finding:** retail's `move_to_interpreted_state` action loop sets the -dispatch params' Autonomous bit (0x1000) from each action's autonomy flag -(raw 305982: `var_28 ^= ((autonomous << 0xc) ^ var_28) & 0x1000`), which -flows into `InterpretedMotionState::AddAction`'s stored node. Our -`MoveToInterpretedState` action loop dispatches with `new -MovementParameters { Speed }` — Autonomous stays false. No observed -symptom today (stored-node autonomy has no current consumer); fix when -R5/R6 touches the action list. **Where:** -`MotionInterpreter.MoveToInterpretedState` action loop / -`DispatchInterpretedMotion` (doc comment marks the gap). - -## #159 — CombatAnimationPlanner uses 2013-decomp command numbering, not ACE/DRW - -**Status:** DONE (`2de5a011`, 2026-07-04) — the whole `CombatAnimationMotionCommands` block now derives each constant directly from `DatReaderWriter.Enums.MotionCommand` by name (`= (uint)Drw.Name`), so the values ARE the oracle by construction and can never drift again. Ground truth was taken by reflecting over the same DatReaderWriter 2.1.7 assembly the runtime binds (409 enum values). Blocks 1-2 (stances + single melee, 0x3C-0x12A) were already correct; the late block (Offhand*/Attack4-6/Punch*, 0x173-0x19A) was all +3 shifted; `Reload` was the dead 2013 value `0x100000D4` (absent from DRW) → now the real `0x40000016` SubState. New parity test `PlanFromWireCommand_LateCombatBlock_UsesAceDrwNumbering` pins 14 ACE wire values through the full wire→resolve→classify pipeline. **Caveat (relevant to #170):** `CombatAnimationPlanner` is not yet wired into the runtime — the live dispatch is `AnimationCommandRouter` → `MotionInterpreter`/`AnimationSequencer` — so this is a *latent* correctness fix. It does NOT by itself change the #170 Mite Scamp symptom, whose attacks `0x62/0x63/0x64` live in the already-correct low block. #170 sub-bugs 2 (MOTIONDONE loop) + 3 (glide) remain. -**Severity:** MEDIUM (late-combat animation classification wrong against ACE) -**Filed:** 2026-06-30 -**Component:** animation, combat - -**Description:** `CombatAnimationPlanner.CombatAnimationMotionCommands` hardcodes the late-combat command constants (the `Offhand*` / `Attack4-6` / `Punch*` block) using **2013-decomp numbering** instead of the ACE/DatReaderWriter numbering that ACE actually broadcasts and that the local DAT MotionTables use. Per the +3-ish low-word shift documented in the ACE-vs-2013 gap research, e.g. `OffhandSlashHigh` should be `0x10000173` not `0x10000170`; `AttackLow6` should be `0x1000018E` not `0x1000018B`. Against a live ACE server these specific commands will be silently misclassified (resolver returns the correct ACE value, but the planner's set contains the 2013 value, so no match). - -**Root cause / status:** Pre-existing — surfaced by the L.1b command-catalog slice (commit pending). The old blind `0x016E–0x0197` override in `MotionCommandResolver` masked the matching test (`CombatAnimationPlannerTests.MotionCommandResolver_UsesNamedRetailLateCombatCommands`) by force-mapping the same wire range to 2013-class values, so the test agreed with the planner's wrong numbering. Deleting the override (correct) exposed the mismatch. NOT a regression: for the real ACE wire value (`0x0173`), the resolver returns `0x10000173` both before and after the override deletion, so runtime behavior is unchanged — the planner was already misclassifying it. The fix is to renumber the `CombatAnimationMotionCommands` block to the ACE/DRW values (cross-check each constant against `DatReaderWriter.Enums.MotionCommand`). - -**Files:** `src/AcDream.Core/Combat/CombatAnimationPlanner.cs:268-307` (the hardcoded `CombatAnimationMotionCommands` block). - -**Research:** `docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md`. - -**Acceptance:** Each late-combat constant in `CombatAnimationMotionCommands` matches its `DatReaderWriter.Enums.MotionCommand` value; a parity test asserts the ACE wire values (`0x0173 → OffhandSlashHigh`, etc.) classify correctly through `ClassifyMotionCommand`. - ---- - -## #158 — Character window — deferred polish - -**Status:** OPEN -**Severity:** LOW -**Filed:** 2026-06-26 -**Component:** ui - -**Description:** The Character window (`LayoutDesc 0x2100002E`, `CharacterStatController`) was user-accepted 2026-06-26 as good-enough for the D.2b track, but the user noted "still needs some polish for later." Known candidates (user will enumerate the full list later): level-number glyph fidelity vs retail's exact large dat font; icon crispness in the attribute rows; heritage/title strings are sample data, not wired from the live character; exact string-table wording not ported (caption text hardcoded vs sourced from dat string tables). - -**Root cause / status:** Not a bug — the Attributes tab is functionally complete + visually confirmed. Deferred post-M5. - -**Files:** `src/AcDream.App/UI/Layout/CharacterStatController.cs`, `src/AcDream.App/UI/Layout/LayoutImporter.cs`. - -## #157 — Live game (GameWindow) doesn't thread the per-element dat-font resolver (Fix C is studio-only) - -**Status:** OPEN -**Severity:** LOW -**Filed:** 2026-06-26 -**Component:** ui - -**Description:** Fix C (commit `a0d3395`) added a per-element dat `FontDid` resolver to `LayoutImporter.Import` + `DatWidgetFactory` — each text element gets its own dat font instead of one global font. Wired into the STUDIO path (StudioWindow → LayoutSource → LayoutImporter), but GameWindow's four `LayoutImporter.Import`/`Build` calls explicitly pass `null`, so the LIVE game still uses a single global font. - -**Fix:** Build a `Func` closure over a `ConcurrentDictionary` near where `vitalsDatFont`/`largeDatFont` load (~`GameWindow.cs:1820`), reusing `UiDatFont.Load` via `GetOrAdd` (same pattern as `RenderStack.ResolveDatFont`), and pass it to the four Import sites: vitals `0x2100006C` (~1820-1835), chat (~1875), toolbar `0x21000016` (~2019), inventory `0x21000023` (~2157). `CharacterStatController.Bind` already has null-means-keep-dat-font semantics, so it benefits automatically. Backward-compat: resolver applies the dat font as a default; explicit `DatFont` still overrides. - -**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (4 import sites), `src/AcDream.App/Rendering/RenderBootstrap.cs` (`RenderStack.ResolveDatFont` = pattern to mirror). **Relates to:** Fix C `a0d3395`; register row AP-75. - -## #156 — Studio: inventory window (0x21000023) renders all-black in preview - -**Status:** OPEN -**Severity:** MEDIUM -**Filed:** 2026-06-25 -**Component:** ui (studio) - -**Description:** `AcDream.App ui-studio "" --layout 0x21000023 --screenshot out.png` renders a flat ~1.2 KB dark-canvas image — the inventory frame / grid / paperdoll don't draw. No exception, no console output. Other studio panels render fine; the inventory works in the live game (F12). Matters for the planned multi-window "full mockup" studio mode. - -**Root cause / status:** Pre-existing (identical at commits 9021600 + eccacc5; NOT a regression). The binding does not throw. Likely the studio host/render-stack omits state the inventory's whole-window Draggable frame + sub-window mounts need, or a sprite-resolve / z-order gap specific to the studio host. - -**Files:** `src/AcDream.App/Studio/FixtureProvider.cs` (case 0x21000023u), `src/AcDream.App/Studio/StudioWindow.cs`, `src/AcDream.App/Studio/PanelFbo.cs`. - -## #155 — Outdoor terrain textures look stretched + blurry vs retail (missing landscape DETAIL-texture overlay) - -**Status:** OPEN — **root cause VERIFIED (oracle-first); fix attempt implemented + REVERTED** (rendered the ground black). Ready for a focused fresh implementation pass. -**Severity:** MEDIUM (visible — outdoor ground reads soft/stretched everywhere vs retail's crisp ground; user side-by-side screenshots) -**Filed:** 2026-06-25 -**Component:** render — terrain texturing (WB-derived modern path: `TerrainAtlas` / `TerrainModernRenderer` / `terrain_modern.frag`) - -**Description (user-observed, 2026-06-25):** Outdoor terrain textures look stretched and less detailed than retail — a cobblestone road's stones look too big/blurry, grass is softer. Side-by-side: retail crisp, acdream smeared. - -**Root cause (VERIFIED — decomp + dat, not guessed):** acdream renders one base texture tiled **once per 24 m landcell** (`terrain_modern.frag:49` `const float TILE = 1.0;`, `vBaseUV` 0→1 per cell) and **ignores the dat's detail-texture fields entirely**. Retail overlays a high-frequency **landscape detail texture** on the base ground; that overlay is what keeps retail's ground crisp up close. Filtering is NOT the problem (the ground array already has mipmaps + `LinearMipmapLinear` + 16× anisotropy, `TerrainAtlas.cs:187-193`); resolution is NOT the problem (terrain is 512² in both Portal and HighRes). - -**Verified retail mechanism (decomp `docs/research/named-retail/acclient_2013_pseudo_c.txt`):** -- Dat fields on `DatReaderWriter.Types.TerrainTex` (confirmed via reflection on Chorizite.DatReaderWriter 2.1.7): `DetailTextureId` (`QualifiedDataId`, `.DataId`) + `DetailTexTiling` (`uint`). acdream currently reads neither (only `TextureId`). -- `ACRender::landPolyDraw` 0x006b6525 (lines 702299-702300): **detail UV = `curr_detail_tiling × baseUV`** (the detail texture tiles `detail_tiling×` relative to the base). -- `landPolyDraw` 0x006b6450-0x006b649c (lines 702263-702287): **distance fade** — detail alpha is FULL at camera depth `< 10`, lerps to 0 by depth `50`, gone beyond. (Detail only sharpens up close.) -- 0x0059cab4 / lines 425099-425112: blend = **`TEXOP_MODULATE`** (modern equivalent: MODULATE2X `base*detail*2`, neutral at gray 0.5). -- `TexMerge::GetDetailTiling` 0x00503a10 (line 263852); `LScape::GenerateDetailSurfaces`/`SetDetailTexturing` 0x00506b40 (line 268389). Gated by the `EnvironmentDetailTextures` graphics preference (`Render::m_RenderPrefs.EnvironmentDetailTextures`). -- **FAITHFULNESS NOTE:** retail uses a **single landscape detail-tiling SCALAR** — `GetDetailTiling(region, 0)` reads `TerrainDesc[0].DetailTexTiling` (category 0 = landscape; default **2.0** @0x0081eca8), stored in global `Render::curr_detail_tiling` and applied to ALL terrain UVs. It is **NOT per-terrain-type**. Prefer the single scalar. - -**⚠ DO-NOT-RETRY:** the first investigation agent **FABRICATED a base `TexTiling` field** — it does **NOT** exist on `TerrainTex` (only `DetailTexTiling`). Do not re-introduce a "read `TexTiling`, multiply the base UV" fix; the base tiling is not a per-type dat field. (Verify the actual base landcell UV scale in the decomp before touching `TILE`.) - -**Fix attempt (implemented + REVERTED, this session):** built a parallel detail `GL_TEXTURE_2D_ARRAY` (per type from `DetailTextureId`) + `uDetailTiling[]` + the shader block `rgb *= mix(vec3(1.0), detail*2.0, fade)` with the 10→50 depth fade. It **compiled, linked, ran with no GL error** — but the ground rendered **black/dark up close**: the detail array sampled near-zero, so MODULATE2X collapsed `rgb` to 0. **The bindless handles were verified NOT swapped** (renderer correctly binds `(terrain, alpha, detail)`). The bug is in the detail-texture **DATA**: the no-detail gray fill (`NoDetailGray=128`) didn't land as neutral, OR `DetailTextureId` decodes dark/black, OR AC's detail textures are not 0.5-centered (so MODULATE2X darkens). Reverted via `git checkout` — terrain back to the working (blurry) state. - -**NEXT (debugging steps for the fresh pass):** -1. **Dump the detail array content** — are the layers ~0.5 gray or ~0 black? (Throwaway: decode each `DetailTextureId` + print mean brightness.) This tells you gray-fill vs decode vs texture-content. -2. If the textures decode dark, AC detail textures may not be gray-centered → use plain MODULATE or re-center, not MODULATE2X. Confirm the actual blend visually. -3. Confirm `DetailTextureId` → `SurfaceTexture` → `RenderSurface` resolves (do the detail textures even exist for these terrain types? many may be 0 → must fall to the gray no-op). -4. Use retail's **single landscape scalar** (`TerrainDesc[0].DetailTexTiling`, default 2.0), not per-type (faithfulness note above). -5. Gate behind an `EnvironmentDetailTextures` equivalent. -6. **Secondary (separate, unconfirmed):** is the base `TILE=1.0` (once per cell) correct vs retail? Verify the landcell base UV scale in the decomp during the fix — it may also contribute to "stretched." - -**Files (the reverted attempt, for reference):** `TerrainAtlas.cs` (detail array build + gray fill + `DetailTiling`), `terrain_modern.frag` (uDetail sampler + MODULATE2X + fade), `TerrainModernRenderer.cs` (bind detail handle + upload `uDetailTiling[]`). - -**Milestone note:** freeze-override render-polish (off the M1.5 critical path); resume when prioritized. Detail-overlay is a real retail feature acdream lacks — register an AP row when it ships (or note it as a known divergence until then). - -**Acceptance:** outdoor ground (cobblestone road + grass) reads crisp up close matching retail, detail fades cleanly past ~50 m, no overall color/brightness shift (modulate neutral); user side-by-side vs retail. - ---- - -## #151 — Far-town (Arwic) collision broken at login: terrain barely grounds + city/perimeter walls never block - -**Status:** 🟡 city/perimeter walls **FIXED + user-verified** (`9743537`, 2026-06-24); terrain-grounding sub-question OPEN but re-scoped LOW (likely not a real defect). -**Severity:** MEDIUM (walls were the HIGH part — fixed; terrain residual unverified) -**Filed:** 2026-06-24 -**Component:** physics — building collision-shell registration (walls); far-town terrain grounding (residual) - -**Description (user-observed 2026-06-24):** In Arwic (a far town), the city/perimeter walls had NO collision even on a FRESH login (you walked straight through them); houses blocked fine. The `ACDREAM_CAPTURE_RESOLVE` capture also showed the player grounded only **3%** of resolves at Arwic vs **100%** at Holtburg. - -**⚠️ Premise corrected — this was NOT the #145 far-town frame.** #146's `bldOrigin` probe proved Arwic *buildings* are correctly framed (anchored ~12 m from the player, not km off). So the streaming frame works fine at far towns; the walls were a different, tractable bug. - -**Root cause — CITY WALLS (FIXED `9743537`):** a town perimeter wall is stored in `LandBlockInfo.Buildings` as a **doorless shell** — `Issue147ArwicBuildingsDumpTests` shows **16 of Arwic's 30 buildings are portal-less**, ringing the town at 24 m intervals (x=12/132, y=12/108). The building-collision cache loop **skipped them** — `if (building.Portals.Count == 0) continue;` (a filter meant only for the transit/entry feature, `CellTransit.CheckBuildingTransit`) — so their collision shell was never registered, even though retail's `find_building_collisions` (0x006b5300) tests the shell BSP independent of the portal list. Fix: cache portal-less buildings too (empty portal list, shell BSP intact). User-verified: walls now block (`Collided`/`Slid`). - -**Root cause — TERRAIN 3% GROUNDED (OPEN, LOW):** a SEPARATE question, NOT confirmed as a real defect. The player never falls through Arwic terrain (z stable); the low `contactPlaneValid` rate is plausibly a flag/measurement nuance (server-held z + settled zero-move resolves not re-setting a contact plane) rather than missing terrain collision. Before treating it as a bug: re-capture `ACDREAM_CAPTURE_RESOLVE` at Arwic post-wall-fix and check jump/slope feel. If it IS real, it's #145 cell-relative-frame territory. - -**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (building-cache loop ~6958, portal-less skip removed); `tests/AcDream.Core.Tests/Physics/Issue147ArwicBuildingsDumpTests.cs` (dat fixture). Terrain residual: `CellTransit.cs` / `PhysicsEngine.cs` frame. - -**Acceptance:** city/perimeter walls block in far towns — **DONE**. Terrain-grounding residual: confirm whether it's a real defect before acting. - ---- - -## #152 — Building/house-wall collision lost after portaling INTO a town (works on fresh login) - -**Status:** DONE (`49d743f`, 2026-06-24) — root confirmed via the `bldOrigin` probe (Holtburg shell cached at the Arwic frame, ~5.5 km off) and fixed by re-basing the building cache on each landblock apply (`PhysicsDataCache.RemoveBuildingsForLandblock` + clear-then-repopulate in `ApplyLoadedTerrain`). Verified on the exact repro (Arwic login → Holtburg portal): `bldOrigin` now at the wall, channel returns `Collided`/`Slid`; suites green. (Move to Recently closed on next tidy.) -**Severity:** MEDIUM (clip through house/building walls after every portal-in; doors still block; terrain solid) -**Filed:** 2026-06-24 -**Component:** physics — building collision channel + streaming-relative frame (teleport recenter) - -**Description (user-observed 2026-06-24):** Static building/house walls block normally on a FRESH login to a town (Holtburg confirmed), but after logging in elsewhere and PORTALING into the town, the same walls no longer block — you clip straight through. Server-spawned DOORS still block (they register their own collision via CreateObject), and terrain stays solid — so the symptom is specifically dat-static building-wall collision after a portal-in. - -**Root cause (capture-narrowed, one probe from confirmed):** The building collision channel (`TransitionTypes.FindBuildingCollisions` → `PhysicsDataCache.GetBuilding(cellId)` → BSP test vs `BuildingPhysics.WorldTransform`) IS reached after a portal-in — the `[bldg-channel]` probe fires at Holtburg post-portal (`cell=0xA9B40022 model=0x01000C17`) — but every test returns `result=OK` (no penetration) as the foot-sphere walks into the wall (`arwic-to-holtburg-bldg-capture.log`). The building's `WorldTransform` is computed from `_liveCenter` AT CACHE TIME (`GameWindow.cs` ~6969: `building.Frame.Origin + origin`, `origin = (lb − _liveCenter)·192`) and `CacheBuilding` is IDEMPOTENT (`PhysicsDataCache.cs:444` `if (_buildings.ContainsKey) return;` — never re-cached, never cleared on unload). A teleport recenters `_liveCenter`, so the cached shell BSP can sit at a stale world offset → the sphere never penetrates → no block. Same streaming-relative-frame family as #145/#147. - -**Next step (one probe before fixing):** add `building.WorldTransform.Translation` to the `[bldg-channel]` line; if it's offset from the visual building / player position after a portal-in, the stale-transform root is confirmed. Fix = invalidate `_buildings[landcell]` on `RemoveLandblock` so it re-caches with the current frame on reload (targeted), or store the transform cell-relative (the #145 frame). **No guess-patch (DO-NOT-RETRY collision area).** - -**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (~6944–7003 building cache loop), `src/AcDream.Core/Physics/PhysicsDataCache.cs` (`CacheBuilding`/`GetBuilding`/`_buildings`, ~441–464), `src/AcDream.Core/Physics/TransitionTypes.cs` (`FindBuildingCollisions` 2805, `[bldg-channel]` probe 2874). - -**Research:** captures `a-wall-resolve.jsonl` (Holtburg 100% grounded, 876/882 wall-moves no block, doors collide), `arwic-to-holtburg-bldg-capture.log` (post-portal `result=OK`), `holtburg-bldg-capture.log` (fresh-login channel fires). Relates to `claude-memory/project_physics_collision_digest.md` + #145/#147. - -**Acceptance:** after portaling into Holtburg from elsewhere, building/house walls block exactly as on a fresh login. - ---- - -## #153 — Portals only work once per session (can't run in, run out, re-enter) - -**Status:** 🟡 PARTIALLY FIXED — REOPENED 2026-06-21 (a NARROWER residual). The cell-relative carried-anchor (Option B, Slices 1–3+7, `438bb68`→`403a338`) **fixes the cascade for a STREAMED-terrain arrival** (live-verified: `+Je` ran through ~10 far-town landblocks across 2 sessions, zero march). BUT a third live session found the cascade **RECURS when a teleport arrives onto a NOT-YET-STREAMED landblock NEAR AN EDGE.** Evidence (`launch5.log`): arrival `[snap] claim=0xC98C0028 pos=(113.666,190.259,22.010) branch=NO-LANDBLOCK (lbs=0) -> verbatim` (local Y=190.3, **1.7 m from the 192 boundary**) → cell marches `0xC98C(lbY 0x8C) → 0xC9FE(lbY 0xFE)` +2/tick while Z free-falls 22→−19; the outbound wire then sends the marched cell `C9FE0031` with compensating garbage `localY=−21684` → ACE `MOVEMENT SPEED` / `failed transition` → player stuck. Contrast same session: hub `0x00070133` VALIDATED→fine; far town `0x977B000C` arrived unstreamed but **mid-block** (Y=73.8) → Z free-fall only, NO march. **ROOT (hypothesis, NOT yet apparatus-confirmed):** outdoor teleport places immediately but the destination streams a few ticks later (`streaming: dungeon EXIT-expand -> (201,140)` logs just after placement); during that gap the resolve runs against an empty world, and at an edge the player crosses into the unstreamed neighbour where the carried anchor (which computes to `(0,0,0)` at the recentered center) gives no protection. Same root as the Z free-fall (`#135`/`#138` placed-but-unstreamed gap). **DO NOT guess-patch** (original #145 burned 5 attempts) — needs a capture + an anchor/guard diagnostic at the crossing FIRST. Likely fix shape: a streaming-gap HOLD (freeze the per-tick resolve — no march, no fall — until the player's landblock loads; the async equivalent of retail's synchronous load), which would fix both symptoms. Suites still green; `TeleportFarTownRunawayTests` covers the STREAMED case only. [Prior "fixed" banner was premature — 2 clean sessions wasn't enough for a streaming-timing bug.] - -**Status (historical):** REOPENED 2026-06-21 — RESIDUAL: a teleport to a FAR town triggers a per-frame **resolver runaway** (the 2026-06-20 source-drop fix holds for Holtburg↔dungeon, but far-town destinations regress). #138 (objects come back) is confirmed SEPARATE + fixed. Root cause under research (retail-decomp oracle workflow). -**Severity:** HIGH (blocks far-town portal travel; breaks collision after the teleport — user, 2026-06-21) -**Filed:** 2026-06-20 -**Component:** net/streaming/physics — teleport (0xF751) + PortalSpace + arrival + per-frame cell-resolve / coordinate-frame rebase - -**⚠️ REOPENED 2026-06-21 — far-town teleport resolver runaway (capture in hand):** -Teleport dungeon (0,7) → far town **(201,91) = 0xC95B** (reached via the Town Network hub). The -client PLACES the player correctly via the #145 verbatim path -(`[snap] claim=0xC95B0001 pos=(14.8,0.3,12.005) branch=NO-LANDBLOCK -> verbatim`), then the -**very next per-frame physics resolve runs away**: cell membership marches one landblock SOUTH per -frame (`0xC95B → 0xC95A → … → 0xC900`, the landblock Y byte counting down) while the position -drifts (X +0.12/frame, Y −0.38/frame) and the player falls (Z 12 → −6.5) — i.e. the resolve never -REBASES the local position into the new landblock's frame, so it keeps "crossing" boundaries. It -settles "on ground" at cell `0xC900` local `(−6.1, −30.2, 12)`. ACE then sees a move from the true -spot `C95B0001 [14.8,0.3,12]` to `C9000008 [34.7, 17410, 24]` — landblock Y byte = 0 but local -Y = **17410 ≈ 91 landblocks of leaked offset** — an inconsistent (cell, local) pair → rejects every -move (`MOVEMENT SPEED` / `failed transition`) → no server-confirmed position → **collision gone** -(player falls through the world). `reason=resolver` (per-frame physics), NOT `teleport`. -Holtburg↔dungeon works because Holtburg is the startup center; the far town exposes the gap. -**Re-hydrate (#138) EXONERATED:** the re-hydrate fired only for Holtburg + the dungeon (log lines -412/498/710); it did NOT fire for the far town during the runaway (746+) — and it only adds RENDER -entities, never the physics `_landblocks` the resolver iterates. -**Apparatus:** `[cell-transit]` trail (`ACDREAM_PROBE_CELL=1`) + `desync-capture.jsonl` (72,401 -`ResolveWithTransition` frames, `ACDREAM_CAPTURE_RESOLVE`). **ROOT CAUSE — VERIFIED 2026-06-21 (multi-agent workflow + adversarial verification, decomp-confirmed against live Ghidra):** -It is a **cell-membership cascade**, NOT a real free-fall. The physics body stays small + correct -(capture: max|Y|≈86 m, settles at the true rest `(-6.1,-30.2,12.0)`); only the **cell id** marches -one landblock south per physics quantum (~33 ms) until the landblock-Y byte underflows to 0x00. The -`17410` is a **wire-conversion artifact** (the outbound `localY = Position.Y − (lbY−_liveCenterY)·192` -adds back 91×192 once `lbY` marched to 0), never a physics value. -**The bug (single source line):** `CellTransit.cs:736` — -`cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin)` **discards the bool**. -For a far town, the player is placed at the southern landblock edge (world-Y≈0.3) with **stale -southward running velocity** (teleport doesn't idle the motion state); the first tick crosses Y=0 -into landblock `0xC95A`, which **has not streamed in yet** → `TryGetTerrainOrigin` returns `false` -with `origin=Vector3.Zero` (the comment at `CellTransit.cs:732-735` self-documents this "legacy -anchor-frame" fallback). `(0,0)` is handed to `AddAllOutsideCells` (`:758`) → `GetOutsideLcoord` -(`LandDefs.cs:110-111`) does `ly += floor(worldY/24)` = `floor(-0.088/24) = -1` → cell marches one -block south. The correct origin `(0,-192)` would give `floor(191.9/24)=+7` (right region) — the -missing −192 rebase IS the bug. Every subsequent neighbor is also unstreamed → the cascade -self-perpetuates 91 times. **Far-town-only** because Holtburg (the streaming startup center) has all -neighbors permanently registered, so the fallback never fires + spawns are mid-block (no Y=0 cross). -**Retail contrast (decomp-confirmed, addresses re-verified in Ghidra patchmem):** retail stores -position CELL-RELATIVE (`Position{ uint objcell_id; Frame{ m_fOrigin ∈ [0,192) } }`, `acclient.h:30659`) -and rebases the ACTUAL stored origin in place on every placement via `Position::adjust_to_outside` -(0x00504A40) → `LandDefs::adjust_to_outside` (0x005A9BC0, wraps origin into [0,192) + recomputes the -cell id) called from `AdjustPosition` (0x00511D80) / `SetPositionInternal` (0x00515BD0); cross-cell -offsets come only from `get_block_offset` (0x0043E630, delta of two cell ids, zero within a -landblock). An inconsistent (cell, local) pair is structurally impossible in retail. acdream's -streaming-relative physics frame (`worldXY=(lb−_liveCenter)·192+local`, per-landblock baked -WorldOffset) is the deeper divergence; the proximate bug is the discarded-bool `(0,0)` fallback. - -**FIX DIRECTION (report-only — awaiting user approval):** -- **4a (targeted, faithful, this-session):** honor `TryGetTerrainOrigin`'s `false` at `CellTransit.cs:736` - — when the current landblock's terrain isn't resident, the outdoor pick has no trustworthy - block-local frame, so HOLD the current (cell, position) verbatim (no march), the same - "frame-not-yet-authoritative ⇒ return input" contract the NO-LANDBLOCK Resolve branch already uses. - PAIR with **idle the motion state on teleport arrival** (stop re-applying the dungeon running - velocity the same frame as `SetPosition`) so the body never attempts the first crossing before - terrain streams. Adaptation (not a direct retail port — retail gets it free via cell-relative - storage) ⇒ needs a divergence-register row. Deterministic replay test from the capture - (`TeleportFarTownRunawayTests`: with `0xC95A` unregistered, resolve returns `currentCellId` - unchanged). Consumer sites to cover via the early-return: `:758/242` (seed), `:845` (pick), `:484`. -- **4b (architectural, file as a phase, brainstorm-gated):** port retail's cell-relative physics - `Position` + make `_liveCenter` render-only + `get_block_offset` as the only cross-cell translation. - Removes the whole class structurally. Multi-commit. -**Open items:** map-edge wedge (verbatim-hold could hover the player at a boundary if the needed -neighbor never streams — likely mitigated by the motion-idle + the destination center always -streaming; verify). **Confidence: HIGH** (all 3 adversarial lenses holdsUp=true; capture- and -decomp-verified). Full report: workflow `wf_87607d15-c43`. - -**[ORIGINAL #145 — source-frame overlap, FIXED 2026-06-20; history below]** - -**Description (user, 2026-06-20):** A portal can only be used ONCE per session. The first teleport works; a subsequent portal use (run out, or re-enter) does not. Goal: run in, run out, repeatedly. - -**Root cause (CONFIRMED — code trace + live cdb-style [phys-lb] probe):** the teleport OUT of a dungeon mis-rooted the player into the SOURCE dungeon's coordinate frame, desyncing all movement from ACE. acdream uses a streaming-RELATIVE coordinate frame (positions relative to `_liveCenterX/Y`) and recenters on teleport, but **resident physics landblocks keep their load-time world-offset**. After recentering onto the outdoor destination, the collapsed source dungeon (loaded at offset (0,0) when it was the center) and the destination (also the new center → offset (0,0)) **overlap**, and the Z-agnostic outdoor cell-snap (`AdjustPosition`, iterating `_physicsEngine._landblocks`) returns the dungeon — for the arrival placement AND every per-frame resolve — so the player was rooted at a dungeon cell (`0x00070019`) at Holtburg's position. acdream then sent dungeon-frame positions; ACE (which knows the player is at Holtburg) rejected every one (`WARN: failed transition … to 0x0007…`), so the player couldn't move, never reached a portal, and ACE never re-broadcast the Holtburg objects. Teleport IN works because its placement is cell-keyed (indoor `FindVisibleChildCell` validates the specific claimed cell) and it pre-collapses; the OUTDOOR resolve is the grid-snap, which the overlap fools. - -**Fix (2026-06-20) — server-authoritative teleport placement (user-approved approach):** -1. **Drop the stale source center landblock from physics at the teleport recenter** (`GameWindow.OnLivePositionUpdated`, `differentLandblock` branch → `_physicsEngine.RemoveLandblock(EncodeLandblockId(oldCenter))`). Only the offset-(0,0) center collides with the destination-local position, so removing it alone clears the overlap; the arrival + per-frame resolve then fall through to the server position (`PhysicsEngine.Resolve` NO-LANDBLOCK verbatim, `:605`) until the destination streams in. -2. **Place outdoor teleports immediately** (`TeleportArrivalRules.Decide` — outdoor → Ready). Holding is futile: streaming does NOT progress while the player is held in PortalSpace (the destination only loads once placement flips to InWorld). Indoor unchanged (`IsSpawnCellReady` hold). Gate suppression during the hold kept (`DungeonStreamingGate`). -3. **Clear a dangling `CellGraph.CurrCell` when its landblock is removed** (`PhysicsEngine.RemoveLandblock`). Without this, dropping the dungeon left CurrCell pointing at the orphaned dungeon cell; the dungeon-streaming gate (keyed on CurrCell) kept streaming collapsed onto the gone landblock, so the destination never streamed → only skybox. Clearing it lets the gate read "not in a dungeon" → `ExitDungeonExpand` → destination streams in → CurrCell re-acquires. - -Tests: `DungeonStreamingGateTests` (4), `TeleportArrivalRulesTests` (4). Registers AP-36 + AD-2. Build + 2727 tests green. Edge logs added to `EnterDungeonCollapse`/`ExitDungeonExpand`. - -**Files:** `GameWindow.cs` `OnLivePositionUpdated` (drop stale center) + `TeleportArrivalReadiness`; `src/AcDream.App/World/TeleportArrivalController.cs` (`TeleportArrivalRules`); `src/AcDream.App/Streaming/DungeonStreamingGate.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs` (`RemoveLandblock` CurrCell clear + NO-LANDBLOCK verbatim). - -**Acceptance:** run into a portal, run out, re-enter — repeatedly in one session, each time placing + streaming correctly. Verify the dungeon→outdoor exit completes promptly (log `teleport complete`, not `teleport HOLD gave up … force-snapping`) with `streaming: dungeon EXIT-expand`, the outdoor world fully streamed, collision working. Also re-checks **#138**. - ---- - -## #148 — Status-bar backpack icon should toggle the inventory window (stateful open/closed) - -**Status:** OPEN -**Severity:** MEDIUM -**Filed:** 2026-06-22 - -The backpack icon in the status bar / toolbar should be **clickable to open/close the inventory -window** (toggle), and its art should reflect the inventory's state — a **closed** backpack when the -inventory is closed, an **open** backpack when it's open. Today the inventory opens via F12 only; -there's no clickable status-bar affordance and the icon isn't stateful. Wire the click → inventory -toggle (the window manager / `UiHost` RegisterWindow + the F12 toggle path) and swap the icon sprite -on the inventory window's visibility change. Investigate the retail status-bar backpack element + -its open/closed sprites when picked up. - ---- - -## #147 — Inventory item-grid scrolling polish - -**Status:** OPEN -**Severity:** LOW -**Filed:** 2026-06-22 - -The inventory contents-grid scrolling (gutter scrollbar `0x100001C7` bound to `UiItemList.Scroll`, -whole-row clip via `UiScrollable`) works but needs visual/UX polish — smoothness, thumb behavior, -wheel step, row-clip edges. Confirm the exact symptoms with the user when picked up. Filed from the -D.2b inventory visual gate. - ---- - -## #146 — D.2b inventory capacity-bar visual polish - -**Status:** OPEN -**Severity:** LOW -**Filed:** 2026-06-22 - -The per-side-bag / main-pack container **capacity fill bar** (faithful port of retail -`UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) shipped + is -visually confirmed (`src/AcDream.App/UI/UiItemSlot.cs` `CapacityFill`; -`InventoryController.SetCapacityBar`). User: "looks good, we need to polish it a bit, but lets do -that later." Get the specific polish list from the user when picked up. Candidate points (confirm -against retail): - -- **Exact bar rect / anchor** — currently right-anchored flush (`X = Width − barW`); the dat element - rect is `X=26 Y=1 W=5 H=30` (a 5px right margin). Flush was the visual-gate call; reconcile with - the dat once the desired look is pinned. -- **Fill direction** — currently bottom-up (assumed); the dat `m_eDirection` (property `0x6f`) isn't - read (cf. AP-50). Confirm bottom-up vs retail. -- **Closed-bag fill** — a closed side bag reads empty until opened (contents aren't indexed until - `ViewContents`). If retail shows real fill for closed bags, the per-container item counts must be - pre-loaded at login (the lazy-load question — also the container-switching "open verification"). - -See divergence register **AP-59**. - ---- - -## #145 — Inventory window panels occluded by the full-window backdrop (importer ignores ZLevel) - -**Status:** DONE (2026-06-21 · `45a5cc5` + continuation `417b137`) — VISUALLY CONFIRMED. The first fix (`45a5cc5`) folded `ZLevel` into `ZOrder` and put the backdrop behind the **ZLevel-0 top-level** panels, which made the **paperdoll** (its base root is ZLevel 0) render — so it *looked* done. But the **mounted backpack/3D-items panels** inherited their sub-window root's **ZLevel 1000** (via `ElementReader.Merge`'s zero-wins-base rule), so the `ReadOrder − ZLevel·10000` fold sank them to ZOrder ≈ −10,000,000 — *behind* the backdrop (ZLevel 100 → ≈ −1,000,000). The Alphablend backdrop then **washed out** the backpack/3D-items captions + burden meter + item cells (only the bright paperdoll survived the wash). Surfaced once B-Controller populated those panels. Continuation fix `417b137`: the sub-window mount keeps each slot's **own** frame ZLevel (not the base root's 1000), so panels sit in front of the backdrop. Confirmed live (Burden 17% + vertical bar + "Contents of Backpack" + full item grid all render on the dark backdrop). Locked by `InventoryFrameImportProbe` (real-dat: each mounted panel's ZOrder > the backdrop's). DO-NOT-RETRY: a mounted sub-window slot must NOT inherit the base layout root's ZLevel. Per-slot paperdoll silhouettes (generic `UiDatElement` sprite — need `0x10000032` UiItemSlot + per-slot art) → Sub-phase C. -**Severity:** MEDIUM (blocks B-Grid visual acceptance — the gmInventoryUI nested panels render blank) -**Filed:** 2026-06-20 -**Component:** ui — LayoutImporter / DatWidgetFactory z-order - -**Description:** With `ACDREAM_RETAIL_UI=1`, F12 shows the `gmInventoryUI` (`0x21000023`) frame + title + the dark full-window backdrop sprite, but the three nested panels (paperdoll / backpack / 3D-items) render **blank**. The B-Grid sub-window mount IS attaching the panels' content — confirmed: the paperdoll base element `0x100001D4` (root of `0x21000024`) has 25+ equip-slot children — but the content is **occluded**. - -**Root cause / status:** `DatWidgetFactory.Create` maps `ZOrder ← ReadOrder` only (`~:81`). In `0x21000023` the full-window backdrop element `0x100001D0` has `ReadOrder=4` (drawn AFTER the panels at `ReadOrder 1/2/3`), so it paints OVER them. Retail keeps the backdrop behind via **`ZLevel`** (backdrop `ZLevel=100` vs panels `ZLevel=0`); the importer ignores `ZLevel` because every vitals element was `ZLevel=0`, so it never mattered until now. Fix: factory `ZOrder` should honor `ZLevel` (higher `ZLevel` = further back) with `ReadOrder` as the tiebreaker — e.g. `ZOrder = ReadOrder − ZLevel·K`. `ElementInfo` has no `ZLevel` field yet (add it + read in `ElementReader.ToInfo` + carry in `Merge`). **Regression risk:** touches z-order for every window — must verify chat (`0x21000006`) + toolbar (`0x21000016`) `ZLevel`s (vitals are all 0) and visually regression-test. - -**Files:** `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`e.ZOrder = (int)info.ReadOrder`, ~:81); `src/AcDream.App/UI/Layout/ElementReader.cs` (`ElementInfo` — add `ZLevel`; `ToInfo`; `Merge`); `src/AcDream.App/UI/Layout/LayoutImporter.cs`. - -**Research:** `docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md` (B-Grid); dat dump of `0x21000023` (panels ReadOrder 1-3 / ZLevel 0; backdrop `0x100001D0` ReadOrder 4 / ZLevel 100). - -**Acceptance:** F12 shows the nested paperdoll/backpack/3D-items panels over the backdrop (backpack burden meter + slot borders visible); vitals/chat/toolbar unchanged; full suite green. - ---- - -## #154 — Dungeon interiors still read too dim vs retail (torch-sparse stretches + per-vertex bake) - -**Status:** OPEN -**Severity:** MEDIUM (dungeons "better but not good enough" — user, 2026-06-20) -**Filed:** 2026-06-20 -**Component:** render — indoor lighting (dungeon) - -**Description (user, 2026-06-20):** After the indoor-lighting fixes (`0d8b827`/`57c2ab7`) dungeons are much better lit than the old flat-0.2 dark, but still not good enough vs retail. The `0x0007` Town Network dungeon HAS torches (the log shows 62 orange + 57 cream `intensity=100` lights selected, plus the viewer fill + magenta portals), so torch cells light up — but torch-sparse corridors (e.g. cell `0x0149`) are lit only by the viewer fill on the 0.2 ambient, and the overall brightness still trails retail. - -**Root cause / status (candidates, UNVERIFIED — do not guess):** (a) the per-vertex Gouraud bake on low-poly dungeon walls under-lights torch cells (torches evaluated only at the few cell-struct corners — AP-35 residual); (b) the sealed-dungeon flat 0.2 ambient may be too dark vs retail; (c) retail may lean harder on the viewer light or carry more/brighter dynamics. **Approach:** side-by-side cdb capture of retail's dungeon (`world_lights` active set + `world_lights.ambient_color` + the viewer-light intensity) at the SAME spot, compared to acdream's `[light]`/`[light-detail]` there. - -**Files:** `mesh_modern.vert pointContribution` (per-vertex bake); `LightManager` (viewer light); `GameWindow.UpdateSunFromSky` (0.2 sealed ambient); `EnvCellRenderer.GetCellLightSet`. - -**Research:** memory `reference_retail_ambient_values.md` (2026-06-20 resolution note); cdb toolchain (CLAUDE.md). - -**Acceptance:** dungeon brightness/warmth matches retail side-by-side (torch cells AND torch-sparse stretches). - ---- - -## #142 — Windowed-building interiors read "like outdoors" (indoor lighting regime is per-frame, not per-stage) - -**Status:** DONE (2026-06-20) — `ef5049f` (per-instance sun gate) + `0d8b827`. The diagnosed cause (per-frame sun/ambient regime) was a RED HERRING: the ambient + sun were already retail-faithful. The REAL bug was a landblock-key lookup in `EnvCellRenderer.GetCellLightSet` (`cellId & 0xFFFF0000` vs the streaming key `0xXXYYFFFF`) that starved EVERY interior wall of point lights. Once fixed, interiors lit correctly (+ the retail viewer light + weenie fixture lights). User-confirmed "looks like retail now." (Move to Recently closed on next ISSUES tidy.) -**Severity:** MEDIUM (visible — windowed town buildings + look-ins are sun-lit/flat instead of torch-lit warm vs retail) -**Filed:** 2026-06-20 -**Component:** render — indoor lighting regime (sun + ambient) - -**Description (user, at the #140 gate):** The Agent of Arcanum house is much brighter/lit indoors in retail (both looking in from outside AND when inside); in acdream it is "not lit" — looking in and inside both "feel like outdoors." The meeting hall (a sealed interior) looked OK, so it's specifically WINDOWED buildings + look-ins. - -**Root cause / status:** acdream's lighting REGIME (sun on/off + which ambient) is a per-FRAME global keyed on the PLAYER's cell (`GameWindow.cs:8107` `playerInsideCell`, from `:8061` `playerSeenOutside`, into `UpdateSunFromSky` `:8122`/`:10786`). Retail's is per-DRAW-STAGE: `PView::DrawCells` (0x005a4840) draws ALL EnvCells in the `useSunlightSet(0)` interior stage (0x005a49f3) — torch-lit, no sun — regardless of `SeenOutside`. So acdream's windowed interiors (`SeenOutside=true`) + look-ins stay in the outdoor regime (sun + outdoor ambient) where retail uses the indoor regime. This is the **AP-43 residual** made visible. Torches are already per-cell (AP-43); the SUN + AMBIENT are the remaining per-frame-global parts. **Fix direction:** make sun+ambient per-draw (per-object/cell) like AP-43's torches — needs a brainstorm (UBO second-ambient + per-instance indoor selector vs a third `uLightingMode`). Resolves AP-43. - -**Files:** `GameWindow.cs:8061/8107/8122/10786` (regime), `mesh_modern.vert accumulateLights` (~:188/:193), `WbDrawDispatcher.IndoorObjectReceivesTorches` (:2076), `EnvCellRenderer` (mode-1). - -**Research:** `docs/research/2026-06-20-indoor-lighting-regime-handoff.md` (full handoff — retail decomp + acdream refs + fix fork + validation plan). Register AP-43. - -**Acceptance:** Agent of Arcanum interior torch-lit/warm both looking-in and inside (user side-by-side vs retail); sealed interiors + dungeons unchanged. - ---- - -## #143 — Portal swirl doesn't light the room (no dynamic-light registration) - -**Status:** DONE (2026-06-20) — `0d8b827` (the portal weenie's magenta `Setup.Light`, intensity100/falloff6/(0.784,0,0.784), now registers via the weenie-light path + reaches the walls via the landblock-key fix) + `57c2ab7` (dynamic lights take retail's D3D `1/d` attenuation + range×1.5 so the portal spreads softly instead of pooling). User-confirmed "looks good." (Move to Recently closed on next ISSUES tidy.) -**Severity:** LOW-MEDIUM (visible — retail's portal swirl tints the room; acdream's casts no light) -**Filed:** 2026-06-20 -**Component:** render — dynamic point lights - -**Description (user, at the #140 gate):** Inside the meeting hall, retail's portal swirl lights up the room; in acdream it does not. - -**Root cause / status:** The portal swirl is a DYNAMIC light in retail (`add_dynamic_light` 0x0054d420 → `minimize_envcell_lighting` 0x0054c170 enables the cell's dynamic subset). acdream registers ONLY static `Setup.Lights` (`GameWindow.cs` ~:6404) — no dynamic lights, so the portal casts nothing. Captured retail params (predecessor cdb): `intensity=100, falloff=6, color=(0.784,0,0.784)` magenta. **Fix:** register a dynamic `LightSource` for portal-swirl entities (or read the portal model's own dat lights); it then flows through the existing point-light path and the EnvCell bake. Keep it indoor (out of the AP-43 outdoor gate). - -**Files:** portal/particle spawn path (TBD); `GameWindow.cs` `RegisterOwnedLight` (~:6404); `LightManager` (PointSnapshot / UnregisterByOwner). - -**Research:** `docs/research/2026-06-20-indoor-lighting-regime-handoff.md` (§#143). - -**Acceptance:** portal swirl visibly tints the meeting-hall room vs retail. - ---- - -## #144 — Empty item-slot press+drag+release still emits a Click - -**Status:** OPEN -**Severity:** LOW -**Filed:** 2026-06-20 -**Component:** ui — D.2b drag-drop spine (B.1) - -**Description:** Pressing an EMPTY `UiItemSlot`, moving the cursor >3 px (which makes `UiRoot.BeginDrag` cancel the drag because the empty cell's `GetDragPayload()` returns null), then releasing over the same cell still emits a `Click` (the press becomes a click on release, since no drag armed). This is the toolkit's general click semantics — every non-draggable widget (buttons included) fires Click after sub-gesture mouse movement; only an *armed* drag suppresses the click. Today it is a **no-op**: the toolbar's `Clicked` handler guards `if (Cell.ItemId != 0)`, so an empty-slot click uses nothing. - -**Root cause / status:** Surfaced in the B.1 spine code review (Task 3). Whether retail fires nothing vs. a no-op click on an empty cell after a >3 px move is **unverified** — deliberately NOT "fixed" with a speculative `_dragCancelled` guard, because that would make empty item-slots behave differently from every other widget and would be guessing at retail behavior (CLAUDE.md forbids both). Becomes relevant only if a future panel (e.g. the inventory grid in Stream C) wires an empty-cell click handler that does NOT guard on occupancy. Action when touched: verify retail's empty-cell press+move+release behavior (cdb/decomp) before changing anything; if it must differ, it earns a divergence-register row. - -**Files:** `src/AcDream.App/UI/UiRoot.cs` (`BeginDrag` cancel path, `OnMouseUp` Click emit), `src/AcDream.App/UI/UiItemSlot.cs` (`OnEvent` Click→Clicked). - -**Research:** `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md` §6. - ---- - -## #141 — Toolbar interactivity — selected-object display - -**Status:** IN PROGRESS (D.5.3a health + name + flash — DONE & visually confirmed 2026-06-20; mana + stack slider still deferred). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140. -**Severity:** MEDIUM -**Filed:** 2026-06-17 -**Component:** ui — D.5 toolbar / selection - -**Description:** The action bar (D.5.1) is the retail "selected object" display. Wire the B.4 WorldPicker/selection state to the toolbar's currently-hidden elements: the two meters 0x100001A1 (selected-object Health) / 0x100001A2 (selected-object Mana) + the stack slider 0x100001A4 + the object-name line, so the bar shows what the player has selected in the world. Click-to-use + the peace/war stance indicator already shipped in D.5.1. Promote to roadmap D.5.3 (already listed there). - -**Root cause / status:** The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port. -- **D.5.3a (2026-06-18):** the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via `SelectedObjectController` (port of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `QueryHealth (0x01BF)` sent on select. Spec/plan: `docs/superpowers/specs|plans/2026-06-18-d53a-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet) and the stack entry/slider (0x100001A3/A4 — stack-split UI). Divergence row AP-46. -- **D.5.3a visual gate PASSED (2026-06-20):** name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits `8f627cc` (fixes), `0796585` (CLI apparatus). **Remaining for #141:** Mana meter (0x100001A2) + stack entry/slider (0x100001A3/A4). - -**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`). - -**Research:** `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` (meter element ids + wire catalog). - -**Acceptance:** Selecting a world object populates the toolbar meters and name line; deselecting clears them. Matches retail side-by-side. - ---- - -## #140 — A7 "Fix D": outdoor objects too bright near torches - -**Status:** RESOLVED (`b7d655b`, 2026-06-19 — user-confirmed side-by-side at the Holtburg meeting hall) -**Severity:** MEDIUM (visible — buildings blow out warm near torches vs retail; ambient/sun itself is correct after Fix C) -**Filed:** 2026-06-18 -**Component:** render — point lighting on outdoor objects - -**RESOLUTION (2026-06-19, round 2):** The "bake vs D3D-FF" framing below was the WRONG question — neither lights the building exterior. Retail's per-object torch binder `minimize_object_lighting` (0x0054d480) runs ONLY `if (Render::useSunlight == 0)` (`DrawMeshInternal` 0x0059f398), and the OUTDOOR landscape stage runs `useSunlightSet(1)` (`PView::DrawCells` 0x005a485a before `LScape::draw`). So retail lights outdoor objects (building exterior shells, scenery, outdoor creatures) with the **sun + ambient ONLY — never wall torches**. acdream was torch-lighting them. Fix: `WbDrawDispatcher.ComputeEntityLightSet` now gates torch selection on the object being indoor (`ParentCellId` is an EnvCell) via `IndoorObjectReceivesTorches`; outdoor objects get the sun only. acdream reads the dat falloffs faithfully (the orange torch is genuinely `Falloff 6`; the "reach too long" theory was a red herring). Register **AP-43**; the indoor-vs-outdoor *sun* half uses a per-frame player-inside global (residual logged in AP-43). Full handoff: `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md` (RESOLVED banner). Indoor-lighting follow-ups the user raised at the gate (windowed-building interior regime; portal swirl as a dynamic light) are SEPARATE M1.5 work, not part of this issue. - -**Description (user):** Outdoor buildings (e.g. the Holtburg meeting hall) read much brighter near torches in acdream than in retail — the walls blow out warm where retail stays dim. The general ambient/sun is correct after Fix C (`57c1135`); this is specifically the per-object point-light *contribution*. - -**Root cause / status:** GROUNDED but BLOCKED on one capture. Retail's object point-light path (`config_hardware_light` 0x0059ad30): `Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒1/d, `Range=falloff×rangeAdjust` (`rangeAdjust=1.5`⇒9 m), `material.diffuse=(1,1,1)`. CONTRADICTION: by that math a torch 3 m away = color×33 ⇒ retail walls should blow to WHITE — but they're DIM. Material/range/intensity all captured + ruled out. So the scaling is in the building's RENDER PATH (unknown). Leading hypothesis: static buildings DON'T use D3D hardware lighting — they use the `SetStaticLightingVertexColors` BAKE (`calc_point_light`, like cells), and the captured `intensity=100` light was a different object (player/portal). **DO NOT port the D3D-FF model — the math says it would make objects brighter, not dimmer.** - -**Files:** `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`/`accumulateLights`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`); `LightBake.cs` (verbatim calc_point_light, unwired). - -**Research:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md` (full grounding + cdb cheat-sheet + the next capture); `claude-memory/reference_retail_ambient_values.md`. - -**Acceptance:** Determine the building's actual render path (bake vs D3D-FF; is `SetStaticLightingVertexColors` 0x0059cfe0 called for it / is `D3DRS_LIGHTING` on), then make the object torch contribution match retail — user side-by-side sign-off (meeting hall stays dim near torches). - ---- - -## #139 — D.2b retail UI polish: chat text colors + buttons - -**Status:** OPEN -**Severity:** LOW (cosmetic fit-and-finish — the widget generalization works and matches the prior hand-made build; this is polish vs a side-by-side retail client) -**Filed:** 2026-06-16 -**Component:** ui — D.2b retail UI (chat window + buttons) - -**Description (user):** After the widget-generalization pass landed (2026-06-16), two areas want a polish pass against retail: -1. **Chat text colors** — the per-`ChatKind` transcript text colors need tuning to match retail more precisely. Current values come from a live cdb dump of the named `RGBAColor` constants (colorWhite / BrightPurple / LightBlue / Green / LightRed / Grey) mapped per `ChatKind` in `ChatWindowController.RetailChatColor`. The four common kinds (speech/tell/channel/system) are confirmed; the rarer kinds (emote, soul-emote, combat, popup) map to the nearest named color and may be off — verify each against a side-by-side retail client. -2. **Buttons** — the chat buttons (Send, Max/Min, and the channel "Chat ▸" menu button) want visual polish: **pressed / hover state feedback** (`UiButton` currently draws only its default-state sprite; the dat carries `Normal`/`Pressed`/`Highlight` states it does not yet switch on), plus a check that the face 3-slice + autosize read cleanly at all widths. - -**Root cause / status:** Deferred polish, NOT a regression — the generalized chat matches the prior hand-made build (user-confirmed 2026-06-16). `UiButton` intentionally mirrors `UiDatElement`'s single-state render (pressed-state was out of the generalization's scope); chat colors are best-effort from the cdb dump. - -**Files:** -- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — `RetailChatColor(ChatKind)` per-kind color map. -- `src/AcDream.App/UI/UiButton.cs` — `ActiveFile()` / `OnEvent` (no pressed-state swap yet; dat has Normal/Pressed/Highlight). -- `src/AcDream.App/UI/UiMenu.cs` — `DrawButtonFace` (Normal vs Pressed sprite) for the channel button. - -**Research:** `claude-memory/reference_retail_chat_colors.md` (the cdb chat-color dump + recipe). - -**Acceptance:** Chat text colors and button (pressed/hover) states match a side-by-side retail client — user's visual sign-off. - ---- - -## #138 — Teleport OUT of a dungeon loads the outdoor world incompletely + position desync - -**Status:** FIX SHIPPED 2026-06-21 (worktree `claude/thirsty-goldberg-51bb9b`; pending user visual gate). Server-object re-delivery root cause confirmed via ACE + holtburger cross-reference; fixed by client-side re-hydrate from the retained spawn table. Player-vanish (B) addressed by a pending-bucket rescue fix (candidate — needs visual confirm). -**Severity:** MEDIUM (breaks the dungeon→outdoor transition; collision + visuals wrong after exit) -**Filed:** 2026-06-14 -**Component:** streaming — dungeon collapse↔expand (the #133/#135 collapse) + teleport-arrival - -**Description (user):** taking a portal OUT of a dungeon to the outdoor world often loads -the world incompletely — **fewer objects than expected (e.g. missing trees/scenery)**, and -**collision doesn't work properly**. There's also a **position desync**: "it's like I'm not -moving while my character is moving" (the avatar animates/advances but the player's -actual position / camera doesn't track, or vice-versa). - -**Root cause / status (hypothesis — needs investigation):** very likely a gap in the -dungeon-streaming **collapse→expand** introduced for #133/#135. Inside a dungeon, streaming -is COLLAPSED to the single dungeon landblock (radius-0). On teleport OUT, -`StreamingController.ExitDungeonExpand` must rebuild the full 25×25 outdoor window at the new -center. Suspects: (a) the expand doesn't fully re-enqueue / re-hydrate the outdoor landblocks -(→ missing trees/scenery + no collision because shadow-object registration never ran for the -un-hydrated blocks); (b) the teleport-arrival recenter (`OnLivePositionUpdated`) + -`PreCollapseToDungeon`/observer interaction leaves the streaming observer pinned wrong after -exit; (c) the position desync = the player controller / streaming observer disagree on the -post-exit world position (the avatar moves in one frame, the streaming/camera in another). -Pairs with #135 (`712f17f`/`2c92375`) — same collapse machinery; the EXIT path is the gap. - -**UPDATE 2026-06-20 — the position-desync HALF is FIXED via #145; the remaining half is narrowed + RE-SCOPED to entity render/lifecycle:** #145 fixed the cell-rooting (the player was rooted into the source dungeon's frame on teleport-out → ACE rejected all movement → "avatar moves but position doesn't track"). With that fixed (server-authoritative placement + drop-stale-center + CurrCell clear), the outdoor TERRAIN now streams + renders and movement is accepted. What REMAINS, observed in the #145 verification run: -- **(A) Server-spawned objects don't render after teleport-back.** NOT a re-broadcast gap — the `ACDREAM_DUMP_LIVE_SPAWNS` trace shows ACE DOES re-send `CreateObject` for all Holtburg weenies on return (Doors, NPCs "Agent of the Arcanum"/"Wedding Planner", "Holtburg Meeting Hall Portal", chests, …) and acdream receives + processes them (`OnLiveEntitySpawnedLocked` → `AppendLiveEntity`). They just don't appear. So the bug is downstream: entity render/storage during the teleport streaming churn — candidates: the re-added live entities are dropped by a subsequent `GpuWorldState.AddLandblock` record-replace for the same landblock, OR the per-instance render data (`WbEntitySpawnAdapter.OnCreate`) isn't built/registered, OR a render-root/visibility gate while `CurrCell` re-acquires. -- **(B) Own avatar stops rendering after a couple of round-trips.** The player entity (persistent, rescued+re-injected via `GpuWorldState.DrainRescued`→`AppendLiveEntity` at `GameWindow` ~:7421) is lost/duplicated across repeated rescue/re-inject cycles. Cumulative (first trip OK, later trips vanish). - -Both are the **entity-lifecycle/render path across a teleport**, NOT the streaming collapse/expand (which now works — terrain streams). Start here: instrument `GpuWorldState` (AppendLiveEntity / AddLandblock / RemoveLandblock / DrainRescued) to trace one guid (a Door + the player 0x5000000B) across an in→out cycle and find where it leaves the rendered set. - -**Files:** `src/AcDream.App/Streaming/GpuWorldState.cs` (`AddLandblock` pending-merge vs record-replace, `AppendLiveEntity`, `RemoveLandblock`/rescue, `DrainRescued`, `RelocateEntity`), `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` ~:2696, rescued re-inject ~:7421), the per-instance render adapter (`WbEntitySpawnAdapter`). The streaming collapse/expand (`StreamingController`) is no longer the suspect. - -**UPDATE 2026-06-21 — root re-scoped again: it's RE-DELIVERY, not render-cull or cache.** A deep dive (probes: `[ent]` append/remove + `[ent-flat]` rendered-set count + `[dyn]` DrawDynamicsLast cull) eliminated the 2026-06-20 hypotheses: -- **NOT the Tier-1 cache.** Re-created live entities get a fresh monotonic `Id = _liveEntityIdCounter++` (`GameWindow` ~:3251), so `EntityClassificationCache` (keyed on `Id`) is ALWAYS a miss for them — never the cause. *(Side-finding, separate minor bug: the cache has a demote-vs-unload invalidation asymmetry — `RemoveEntitiesFromLandblock` fires `_onLandblockUnloaded` (`:~495`) but `RemoveLandblock` does NOT, violating the documented "demote OR unload" intent. NOT the #138 cause; fix-with-verification later.)* -- **Render path is FINE when entities are present.** At login `[dyn] rootOutdoor=True dyn=54 drawn=33` — the dynamics partition (`InteriorEntityPartition` :55, every `ServerGuid!=0` with `MeshRefs>0` → dynamic) + `DrawDynamicsLast` draw them. -- **The ACTUAL cause: re-delivery is unreliable / absent.** `notan/+Je` walk-around run: after teleport-out, `live:spawn` doors = **0**, `[ent] +` door appends = **0**, `[ent-flat] server=1` (only the player), `[dyn] dyn=1` — the server delivered **ZERO** Holtburg objects on return; they never reach acdream. (An earlier `testaccount2` run got ~15 re-sent, but the user confirmed they stayed missing then too — so re-delivery is partial AND unreliable across accounts/sessions.) "Other clients see +Je" → the server has correct player state; acdream's LOCAL world is simply missing the objects. The 2026-06-20 "NOT a re-broadcast gap" claim above was WRONG — it IS (intermittently) a re-broadcast gap. -- **Mechanism:** acdream UNLOADS the landblock's server-spawned objects on teleport-IN (the dungeon collapse unloads neighbours, including Holtburg), and on the way back NOTHING restores them; ACE does not reliably re-broadcast them (known-set/awareness desync, likely worsened by the rapid relaunch churn). - -**FIX SHIPPED 2026-06-21 — client-side re-hydrate from the retained spawn table (re-projection, not ACE re-send):** - -**⚠️ Handoff correction:** the 2026-06-21 handoff said to re-hydrate from `ClientObjectTable` ("keeps ALL objects with their positions"). That is WRONG — `ClientObject` (`src/AcDream.Core/Items/ClientObject.cs`) is the INVENTORY data model (container/slot/equip/icon/value/stack); it carries **no world position, no cell id, no Setup/PhysicsDesc/mesh**. It cannot build a render entity. The real retained world-object table is **`GameWindow._lastSpawnByGuid`** (`Dictionary`) — the parsed `CreateObject` records, carrying Position + Setup + MotionTable + AnimPartChanges + palette. It is pruned ONLY by a server `DeleteObject` or a spawn de-dup, so it **survives the dungeon collapse** (verified: the collapse path `StreamingController.Tick → GpuWorldState.RemoveLandblock` never calls `RemoveLiveEntityByServerGuid`, the only thing that touches `_lastSpawnByGuid`). - -**Cross-reference confirmation (this is retail-faithful, not a workaround):** ACE (`references/ACE/Source/ACE.Server/Physics/Common/ObjectMaint.cs`) **never clears a player's `KnownObjects` set on a normal teleport** (`teleport_visibility_fix` is off by default + flagged non-retail), so it will NOT re-send objects it thinks we still have — it relies on the client retaining its table and doing its own 25 s/384 m visibility cull. holtburger (`references/holtburger/.../handlers/player.rs`) **keeps its entire object table across a teleport** (only suspends physics bodies) and re-projects its displayed world from that table; stale far objects self-evict after a 25 s timeout. So "client keeps the object table and re-renders from it" IS retail behavior. acdream's render entities are the projection; the collapse drops the projection for FPS (AP-36), and re-hydrate rebuilds it on reload. - -**Implementation (A — server objects):** new `StreamingController` `onLandblockLoaded` callback fires after `AddLandblock` (Loaded path = dungeon-exit expand) and `AddEntitiesToExistingLandblock` (Promoted = Far→Near). `GameWindow.RehydrateServerEntitiesForLandblock` builds the set of server guids already present in `GpuWorldState`, snapshots `_lastSpawnByGuid`, and — via the pure `LandblockEntityRehydrator.SelectGuidsToRehydrate` (selects spawns in the loaded landblock that have a world mesh, are not the player, and are NOT already present) — replays `OnLiveEntitySpawnedLocked` for each missing guid under the dat lock. The replay's own `RemoveLiveEntityByServerGuid` de-dup scrubs the state the collapse orphaned (the entity lingers in `_entitiesByServerGuid` after `RemoveLandblock` even though its render entity is gone — so the present-gate keys on `GpuWorldState`, not that map). Idempotent + no double-build on initial login (entities already present → skipped). Commit: `LandblockEntityRehydrator.cs` + `StreamingController.cs` + `GameWindow.cs`; tests `LandblockEntityRehydratorTests` (7). Register row **AP-48** (no 25 s cull → a re-hydrate may restore an object the server silently dropped — port holtburger's cull to close). - -**Implementation (B — player vanish, candidate):** `GpuWorldState.RemoveLandblock` rescued persistent entities only from `_loaded`, silently dropping a persistent entity sitting in the **pending bucket** (the player is re-injected via `AppendLiveEntity` every frame; right after a teleport its landblock hasn't streamed yet → pending; if that landblock is then unloaded mid-churn the player was dropped → "vanishes after a couple round-trips"). Fix: rescue persistent pending entries too. Tests `RemoveLandblock_RescuesPersistentEntity_FromPendingBucket` (+ negative). This is a provable correctness fix for the "persistent ⇒ survives unload" invariant; it is the leading candidate for symptom B but needs user re-verification to confirm it is the complete cause. - -**UPDATE 2026-06-24 — symptom B (avatar vanish) ACTUAL root found + FIXED (`afd5f2a`, user-confirmed + trace-verified):** the pending-bucket rescue above was necessary but NOT the whole cause. The real culprit: the per-frame avatar-sync (`GameWindow` ~:8018) calls `GpuWorldState.RelocateEntity` using the player controller's cell, which stays the **FROZEN SOURCE cell** until `PlaceTeleportArrival` materializes the destination. Mid-transit it dragged the avatar — which the teleport's `DrainRescued` re-inject had correctly placed at the destination center — back into the now-UNLOADED source landblock's pending bucket, where nothing recovers it (`RelocateEntity` only scans `_loaded`). Fix: skip the per-frame relocate while `_playerController.State == PortalSpace`. Verified by a new env-gated avatar-lifecycle probe (`ACDREAM_PROBE_ENT` / `EntityVanishProbe`): pre-fix `[ent] APPEND lb=0x0007FFFF(source) -> PENDING -> DRAWSET ABSENT` never recovered; post-fix every teleport goes `RESCUE -> ABSENT -> APPEND(destination) -> PRESENT` and stays drawn. **The remaining #138 collision residual split into #146 (Holtburg portal-in building walls) + #147 (Arwic far-town frame).** - -**Acceptance:** portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery present), **server objects (doors/NPCs/portals) render**, **own avatar renders across repeated round-trips**, collision works, position tracks (no avatar-vs-camera desync). - ---- - -## #137 — Dungeon collision incorrect at doors and wall openings - -**Status:** OPEN -**Severity:** MEDIUM (movement/collision correctness in dungeons) -**Filed:** 2026-06-14 -**Component:** physics — EnvCell collision (doors, portal openings, cell geometry) - -**Description (user):** collision is still wrong in dungeons — **doors** and **openings in -walls** in particular. (Symptoms not fully characterized yet: likely walking through -openings that should block / blocking at openings that should pass, and door collision not -matching the door's open/closed state.) - -**✅ CORRIDOR GATE PASSED 2026-07-06 evening (user: "not collision anymore. -Good.")** — the corridor phantom arc (mechanisms 1–3) is user-verified -FIXED. REMAINING #137 scope from the same gate session: -- **Window/opening climb FIXED + GATE PASSED 2026-07-06 (user: "Looks - good", incl. the taller-capsule regression sweep — doorways/seams/stairs - clean): the player's collision capsule TOPPED OUT AT 1.2 m.** The callers passed - `sphereHeight: 1.2f` and `InitPath` places the head sphere center at - `height − radius` = 0.72 — the top 0.63 m of a 1.83 m character had NO - collision. The dat human Setup 0x02000001 (dumped in - `HumanSetup_CollisionSpheres_DatTruth`): spheres `(0,0,0.475) r=0.48` + - `(0,0,1.350) r=0.48` (top 1.83 = Setup.Height 1.835); retail collides - with that list verbatim (`CPhysicsObj::transition` 0x00512dc0 → - `init_sphere(GetNumSphere, GetSphere, scale)`). At the corridor-end - window alcove (0x8A020179 → 0x8A02017E: sill face 0.70 m, opening 1.3 m - tall, sloped funnel behind — full-vertex dump in - `WindowShaft_FullPolyDump`), the missing head let the step-up's - placement pass and the player climbed in head-through-lintel. Fix: both - live callers now pass 1.835 (capsule top; head center 1.355 ≈ dat - 1.350); register TS-46 documents the residual 5 mm scalar - approximation. Pins: `WindowOpening_HeadCannotFit_EntryBlocked` (walked - approach wall-slides and never enters 0x8A02017E) + - `WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides` (the raised - placement rejects on the head-vs-lintel). Captured-input replay - fixtures keep their recorded 1.2 inputs — InitPath unchanged. -- Doors half (block/pass per open state) — unchanged. -Two RENDER issues also observed at the gate (filed separately below as -#176/#177): the purple floor flashing at seams is angle/camera-dependent -(the floor IS a portal polygon to the under-room — likely the portal -surface drawn under some culling state), and a stairs pop-in/out between -levels (the #119 visibility class, dungeon edition). - -**SEAM SHAKE FIXED (same day): the stale `footCenter` in `CheckOtherCells`' -per-cell loop** — the P2 cellar-lip lesson one loop deeper. A mid-loop -other-cell query can MOVE the sphere (the boundary full-hit dispatches -step_sphere_up; the successful climb lifts the foot +0.6 mm and returns -OK), and the remaining cells were then queried with the by-value -pre-climb center — 0.4 mm inside the floor slab, grazing the under-room's -ceiling and firing the chain below. Retail's `check_other_cells` reads the -LIVE `sphere_path.global_sphere` per cell (pc:272717+). Fix: re-read -`footCenter = sp.GlobalSphere[0].Origin` per iteration. All three -`Issue137CorridorSeamReplayTests` repros un-skipped and GREEN; full suites -green. Visual gate pending. (The step 3 "restore clobbers CheckPos" wording -below was the right CLASS but the wrong site — CheckPos was fine; the -stale copy was the loop's captured parameter.) - -**GATE 2026-07-06 FAILED — THIRD MECHANISM CHARACTERIZED (the seam shake), -deterministic offline repro secured:** with mechanisms 1+2 fixed the dead -stop became a SHAKE at cell seams (+ purple floor flashing there — almost -certainly the render exposing the same per-frame position/OnWalkable -oscillation; re-check after the physics fix). Full chain, every link -probe-traced (`launch-137-seam-probes.log`, capture -`resolve-137-seam-capture.jsonl` tick 4101 ×46): -1. Corridor cells sit above under-rooms; the shared floor slab is - double-faced (up-face + underside as separate physics polys) and IS a - portal plane (e.g. 0x8A020165's ramp over 0x8A020166). The resting foot - sphere is permanently within ±0.5 mm of THREE thresholds there (poly-hit - r−ε, walkable r−ε, portal-straddle r+ε). -2. Walking across the boundary at the flat-floor height penetrates the - ramp slab by ~0.4 mm → foot full-hit on the up-face → StepSphereUp → - step-down accepts the ramp (+0.6 mm lift, CheckPos −5.999, - `[stepsphereup] stepped=True`). -3. **THE BUG: the lifted position is then LOST** — the next pass runs at - the UNLIFTED height (GlobalSphere center −5.520 vs the lifted −5.519; - the P2 stale-snapshot class, single-slot Save/RestoreCheckPos clobber - suspected — retail `CTransition::step_up` 0x0050b6cc restores ONLY on - failure) → the re-test at 0.4 mm inside the slab grazes the NEIGHBOR - under-room's CEILING (the slab underside, n≈(−0.03,0,−1)) within the - near-miss window → recorded (retail records it too — pos_hits_sphere - registers geometric hits pre-cull) → neg-poly step-up dispatch with the - DOWNWARD normal → the nested step-down finds no walkable at exact - tangency → StepUpSlide → slide_sphere(down normal vs up contact plane) - → the opposing branch → reversed-movement collision normal → Collided → - validate revert (Contact/OnWalkable stripped) → next step's AdjustOffset - zeroes → out==in every frame = the shake. Retail never enters at step 3: - its kept step-up lift leaves the sphere ON the surface, no graze. -4. Offline repro: `Issue137CorridorSeamReplayTests` (3 tests, currently - `Skip="#137 seam shake"`) reproduce the block deterministically — the - key was hydrating THREE portal rings (the under-room 0x8A020166 is - ring-3; with fewer rings the flood can't add it and everything passes). - NEXT: read our `TransitionalInsert` attempt loop against retail - 0x0050b6f0 to find the restore that clobbers the successful step-up's - position; fix; un-skip the three tests. - -**CORRIDOR PHANTOM mechanisms 1+2 FIXED 2026-07-06 (see -`docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md` -for the full audit):** mechanism 2 = BSPQuery Contact-branch stub slide -responses leaked sliding normals retail's BSP layer never writes (fixed: -real `slide_sphere` routing + success-gated body writeback). Mechanism 1 as -theorized is REFUTED: the recorded wall normal `(−1.00,0.03,−0.03)` matches -NO dat polygon (world-space sweep of both seam cells + all portal-adjacent -neighbors) — it is the SYNTHETIC negated movement direction from -`slide_sphere`'s opposing-normals branch, which our port let survive by -returning OK where retail returns COLLIDED_TS (0x0053762c; second fix). The -PortalSide polys to 0x011E were a red herring: cell 0x8A02016E has IDENTITY -rotation, the polys are ±Y planes perpendicular to the run (directionally -culled), retail's physics-BSP leaves reference them too, and the dat's -keep-PortalSide/strip-ExactMatch asymmetry reads as intentional (solid -window/grate-class portals) — NO portal-poly filter needed, no cdb session -needed for this repro. Dat-backed replay -(`Issue137CorridorSeamReplayTests`) reproduces the live frame exactly and -runs the corridor clean. The issue's DOOR half remains open. - -**CHARACTERIZED 2026-07-05 (Facility Hub corridor repro, probe + dat evidence) -— two stacked mechanisms (historical; see the 2026-07-06 resolution above):** -1. **PortalSide portal polygons are IN the physics polygon set and we treat - them as solid.** Live: running the corridor, the seam crossing - `0x8A02016E → 0x8A02017A` (x≈85.25) records a wall hit with normal - (−1,0,0) — straight against the movement (`launch-175-verify2.log:42858`). - Dat (`Issue137CorridorSeamInspectionTests`): cell 0x8A02016E's portals to - 0x011E (polys 1/3/5, flags=**PortalSide**, no ExactMatch) are PRESENT in - `CellStruct.PhysicsPolygons` — every ExactMatch portal in the same cell is - absent from the physics set. The cell's rotation maps those local ±Y portal - planes to world ±X — the phantom mid-corridor wall. Retail must honor the - portal's SIDE (pass from one side / solid from the other, or pass when the - neighbor is loaded); we collide with the raw polygon unconditionally. - **Oracle findings so far (2026-07-05 evening — greps done, question - OPEN):** `CCellStruct::UnPack` (0x00533d00) loads physics_polygons + - physics_bsp verbatim — NO portal-poly stripping at load; - `CPolygon::pos_hits_sphere`/`hits_sphere`/`polygon_hits_sphere_slow_but_sure` - (0x005394f0/0x00539540/0x00538a10) are pure geometry — no portal check; - `CCellPortal` (0x0053bab0) carries portal→CPolygon ptr + portal_side + - exact_match but nothing in the BSP test chain consults it. So retail's - passability for a PortalSide physics poly is NOT a load filter and NOT a - poly-level flag — remaining candidates: the transit/membership order - makes the sphere test the NEIGHBOR cell first (never hitting the portal - poly from the passable side), or a sidedness interaction - (stippling=NoPos + approach direction). NEXT: cdb-attach retail at this - exact corridor (0x8A02016E→011E portals) per the CLAUDE.md step −1 - protocol — the decomp alone hasn't settled it. -2. **The stale sliding normal then wedges all forward motion** (the #116 - slide-response family): after the single seam hit, EVERY subsequent - forward resolve returns `ok=False hit=no` with zero advance — the - body-persisted SlidingNormal (−1,0,0) projects the +X offset to exactly - zero in AdjustOffset, aborting at step 0 BEFORE any collision test could - update the state — an ABSORBING wedge escaped only by strafing ("push - through on the side"). Retail re-derives slide state per frame - (get_object_info pc:279992 governs only the NEXT frame — #116 notes); - audit who clears the body's sliding normal when no contact recurs. - - **MECHANISM 2 FIXED 2026-07-06 (audit complete — full lifecycle in - `docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`):** - retail's ONLY in-transition sliding-normal writer is - `validate_transition` (0x0050ac21); the BSP/sphere layer never writes it, - and the body persistence (`SetPositionInternal` 0x005154c2/0x005154e1) - is success-only. Our BSPQuery Contact-branch full-hit responses were - STUBS (`SetSlidingNormal + return Slid`) where retail dispatches the - real `slide_sphere` — the seam hit (a SUCCESSFUL full-advance resolve, - `ok=True` in the log, not a failed one) leaked the phantom wall's normal - into the body, and the seed absorbed every later forward push. Fix: - both stub sites now route through the real - `Transition.SlideSphereInternal` (`CSphere::slide_sphere` 0x00537440, - in-frame, no sliding write) and the body writeback is gated on - transition success. Pins: `Issue137SlidingNormalLifecycleTests` (2 site - pins + the persist/absorb/clear wall lifecycle). Register: TS-4 amended - (steep-tangent sites still write the normal — documented), TS-45 added - (`SphereCollision`'s write — same class, out of blast radius). The - absorbed exactly-anti-parallel frame at a REAL wall is retail-faithful - (the persisted normal is a "still pressed" cache); only the phantom - PROVENANCE was the bug. Corridor re-test rides the mechanism-1 session. - -**Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus), -`src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See -`claude-memory/project_physics_collision_digest.md` (the collision SSOT + DO-NOT-RETRY table). - -**Acceptance:** doors block/pass per their open/closed state; wall openings pass; solid walls -block — matching retail, in the 0x0007 dungeon. - ---- - -## #136 — DONE — "red cone" in the 0x0007 dungeon was an editor-only placement marker acdream drew (retail hides it) - -**Status:** FIXED `6f81e2c` (2026-06-14) — verified live via frame dump: the red cone + -green floor "petals" are gone, all real dungeon decorations still render. User-approved -frozen-phase fix. -**Severity:** LOW (cosmetic; one marker in one dungeon) -**Filed/Fixed:** 2026-06-14 -**Component:** rendering — EnvCell static-object hydration (WB-derived path) vs retail degrade - -**Description:** In the `0x0007` Town Network dungeon a bright-RED downward cone (+ a -green/red shape on the floor) rendered ~6 m from the login spawn; the user's side-by-side -retail client showed NOTHING there. Became visible only after the #135 login-into-dungeon -fix placed the player at the exact saved spawn next to it. - -**Root cause (definitive):** the cone is ONE dat-hydrated EnvCell static object (`guid=0`, -`id=0x40000835`, Setup `0x02000C39` / GfxObj `0x010028CA`) baked into cell `0x00070145`, -using pure red+green MARKER surfaces (`0x08000109` red, `0x0800010A` green). It is an -**editor-only placement marker**: its `DIDDegrade` table `0x11000118` = -`{slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX}` — visible ONLY at distance 0 (the -WorldBuilder editor origin) and degraded to GfxObj **id 0 (= nothing)** at any real distance. -Retail's distance-based degrade (`CPhysicsPart::UpdateViewerDistance` 0x0050E030 → `Draw` -0x0050D7A0 draws `gfxobj[deg_level]`) therefore never draws it in the live client. acdream's -render path is extracted from **WorldBuilder**, which — being an editor — renders every cell -static's base mesh directly and has **no degrade handling at all** (zero `DIDDegrade` refs in -`references/WorldBuilder`), so acdream inherited "show the marker" and drew it forever. (NOT -a texture/lighting bug — the cone's *own* object 0x70007055 decodes tan and was a red -herring; the marker is a separate `guid=0` dat static.) - -**Fix (`6f81e2c`):** `GfxObjDegradeResolver.IsRuntimeHiddenMarker()` detects the editor-marker -pattern (`HasDIDDegrade` + `Degrades[0].MaxDist==0` + a degrade entry with `Id==0`). EnvCell -static-object hydration (`GameWindow.cs` ~5793) skips such GfxObjs — whole-stab for bare -GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via `meshRefs.Count==0`). -Faithful equivalent of retail's runtime degrade for static geometry (always viewed at -distance > 0); real LOD objects (`slot0.MaxDist>0`) and degrade-to-real-mesh objects are -untouched. 4 new `GfxObjDegradeResolver` unit tests. - -**Follow-up (not done):** outdoor `LandBlockInfo.Objects` stabs could carry the same markers; -apply `IsRuntimeHiddenMarker` there too if any surface. Also revealed (separate): the per- -pixel point-light shader overblows close torches (no per-channel `min(scale·color,color)` cap -vs retail `calc_point_light`) — the bright-red dungeon WALL under normal lighting; tracked -under the #79/#93 A7 lighting umbrella. - ---- - -## #135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles - -**Status:** DONE `712f17f`+`2c92375` (2026-06-14) — user-verified: login into the 0x0007 dungeon is FPS-steady from the start; dungeon loads + places the player. (NOTE: the teleport-OUT path has a separate streaming gap — see #138.) -**Severity:** LOW (startup-only; self-corrects) -**Filed:** 2026-06-14 -**Component:** streaming — first-frame bootstrap vs the dungeon collapse - -**FIX (2026-06-14):** pre-collapse streaming the instant we recenter onto a SEALED -dungeon cell at login/teleport, before the first `NormalTick` bootstraps the window. -- `StreamingController.PreCollapseToDungeon(cx,cy)` — fires the existing `EnterDungeonCollapse` - early (idempotent), so the expensive ocean-grid neighbour window is never enqueued - (teleport) / is enqueued-then-immediately-cleared for a cheap Holtburg frame (login). -- `GameWindow.IsSealedDungeonCell(cellId)` — reads the `EnvCell` dat `SeenOutside` flag - (the same flag the hydrated `ObjCell.SeenOutside` + the per-frame gate use) so a cottage/inn - interior keeps its outdoor surround; excludes the 0xFFFE/0xFFFF shell ids. -- Hooks in `OnLiveEntitySpawnedLocked` (login) + `OnLivePositionUpdated` (teleport). -- Observer robustness: during a teleport `PortalSpace` hold the observer follows the - recentered destination (not the frozen position); `_lastLivePlayerLandblockId` is now - filtered to the player guid (resolving a Phase A.1 TODO) so a stray NPC update can't drift - the login-hold observer off the dungeon and trip `ExitDungeonExpand`. -Adversarially reviewed (3 lenses); register row AP-36 amended. Tests in -`StreamingControllerDungeonGateTests` (5 new, incl. the real Tick-then-PreCollapse ordering). - -**Description:** On login into a dungeon, FPS starts ~10 and climbs over ~30 s before -settling (then 1000+ fps). User: "we still have about 30ish seconds before FPS is ramped -up; when logging in I get like 10 then it slowly increases." - -**Root cause / status:** The #133 streaming collapse (`5686050`/`d9e7dd6`/`7d8da99`) only -engages once CurrCell resolves to a sealed cell (the snap, a few s in). Before that the -first Tick bootstraps the full 25×25 window, so ~24 neighbour ocean-grid dungeons (+ their -~19k entities) load, then unload when the collapse fires. The collapse-at-snap change moved -the trigger from finalize-time (~30 s) toward snap-time but the bootstrap churn remains. -Clean fix = pre-collapse at login when the spawn cell is a sealed dungeon cell so the full -window never enqueues (touches the sensitive login spawn path — do carefully; no band-aid). - -**Files:** `GameWindow.cs:6885` (streaming Tick gate); `StreamingController.cs` (collapse); -login recenter `OnLiveEntitySpawnedLocked` ~2470. - -**Acceptance:** Login into a dungeon reaches steady-state FPS within ~1–2 s (no full-window -neighbour load/unload churn). - ---- - -## #134 — Player "lags downward" instead of gliding along a dungeon ramp edge - -**Status:** OPEN -**Severity:** LOW-MEDIUM (movement feel; not a hard traversal block) -**Filed:** 2026-06-14 -**Component:** physics — slope-walk / edge-slide response - -**Description:** Running up or down against a dungeon ramp's edge, the player "sort of lags -downwards" instead of gliding/sliding ALONG the ramp surface (up when running up, down when -running down). Reported in the 0x0007 Town Network dungeon ramp after #133. - -**Root cause / status:** Surfaced (not caused) by the #133 connector-cell physics -registration (`3e006d3`): the ramp connector cell's collision is now fully resident in the -physics graph, so the slope-walk / edge-slide response on it is exercised for the first time. -"Lag down" suggests the slide velocity is projected toward gravity rather than along the -contact plane (the slope tangent). Likely the retail edge-slide / slope-slide response is -incomplete — see #32 (retail edge-slide/cliff-slide/precipice-slide incomplete) and the -AP-6 / TS-1 / TS-4 slide rows in the divergence register. NO band-aid — port the retail -slide-response. - -**Files:** `src/AcDream.Core/Physics/` (slide-response in TransitionTypes / BSPQuery); ramp -cell 0x0007014D + neighbours. - -**Acceptance:** Running up a walkable ramp climbs it smoothly; running into the edge slides -along the slope (up/down per input direction), matching retail feel. - ---- - ## #133 — Teleport into a dungeon snaps the player BEFORE the dungeon landblock streams in → lands at the old landblock's frame (ocean), not the dungeon **Status:** OPEN — promoted to **Phase G.3** (Dungeon streaming + portal space + `PlayerTeleport` handling), **PULLED INTO M1.5** (user decision 2026-06-13: the indoor world isn't done while dungeons are broken; full -G.3 scope chosen). Spec: `docs/superpowers/specs/2026-06-13-dungeon-support-design.md`; -G.3a plan: `docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md`. -This is now an M1.5 exit-gate blocker, not deferred. - -**PROGRESS (2026-06-13 PM — G.3a core LANDED + Bug A fixed; gate exposed #95):** -the teleport-timing root cause IS fixed. G.3a shipped the `TeleportArrivalController` -hold-until-hydration (`7947d7a`/`aca4b46`/`f22121b`) + the validated-claim -landblock-prefix fix (`2ce5e5c`, "Bug A"). Live gate proof: a real `PlayerTeleport` -into the `0x0007` dungeon held through the 46 km jump and grounded the player on the -dungeon's walkable floor (`[snap] claim=0x00070143 VALIDATED -> z=0.000`) — **no -ocean.** The "terrain-less landblock" framing was refuted earlier (dat probe: dungeon -= flat-terrain LandBlock + EnvCells). REMAINING blockers, both exposed at the gate: -(1) **#95 CONFIRMED LIVE** — the dungeon renders as "thin air" because WB-DIAG blows -up to ~9.1M instances/frame at `0x0007` (see #95); (2) **possible Bug C** — per-tick -membership may still drift in the dungeon's negative-local-Y frame (ACE `movement -pre-validation failed` spam) — re-gate after Bug A to confirm. NOTE: a render-only -EnvCell hydration decouple was tried in G.3a and REVERTED (`e7058ca`) — it made the -player character invisible at Holtburg (it touched the shared building hydration -path); re-approach separately if a geometry-less collision cell ever needs it. - -**NEW GAP (2026-06-13 PM — login-INTO-a-dungeon):** logging in while the saved -character is inside a far dungeon hangs at the auto-entry hold (player frozen, -no `[snap]`/`auto-entered player mode`, movement input ignored). Root: the -streaming center is set ONCE at startup to the default (`_liveCenterX/Y = centerX/ -centerY`, `GameWindow.cs:1942` → "centered on 0xA9B4FFFF") and the login spawn never -recenters it; a dungeon spawn 46 km away never streams, so `IsSpawnCellReady(spawn -cell)` stays false and the #107 hold waits forever. The TELEPORT-arrival path -recenters (G.3a `TeleportArrivalController`); the LOGIN path does not. Fix shape = -recenter streaming onto the spawn landblock when the login spawn first arrives -(mind the #107 auto-entry hold's `SampleTerrainZ(pe.Position)` frame after the -recenter). Pre-existing; only surfaces now that the test character can be saved in -a dungeon. Workaround to unblock testing: move `+Acdream` out of the dungeon -server-side (ACE) before logging in. **FIXED 2026-06-13 (`47ae237`)** — the login -player-spawn path now recenters `_liveCenterX/Y` onto the spawn landblock (mirrors -the teleport-arrival recenter; no-op for a same-landblock Holtburg login). Verified -live: `live: login spawn — recentering streaming from (169,180) to (0,7)` → dungeon -streams → `auto-entered player mode` in the dungeon. - -**✅ DUNGEON RENDERS — M1.5 milestone (2026-06-13 PM, autonomous /loop, objectively -verified).** With Bug A (`2ce5e5c`) + login-into-dungeon (`47ae237`), a live launch -into the `0x0007` dungeon: player grounded on the dungeon floor (`[snap] claim=0x00070143 -VALIDATED z=0.000`), correct membership (cell stays `0x0007…`, ZERO ACE `failed -transition` spam), and the render budget is sane — **WB-DIAG instances ~39,000 -(meshMissing=0)** vs the 9.1M pre-Bug-A blowup (#95, now RESOLVED as a Bug-A symptom). -User-confirmed: "no errors from ACE this time." - -**✅ DUNGEON FPS FIXED + GREY BARRIER FIXED (2026-06-14, user-confirmed).** Two -separate causes, both resolved: - -- **FPS (was 14–30, now ~1000+):** AC dungeons sit adjacent in the "ocean" landblock - grid, so the 25×25 (farRadius=12) streaming window pulled ~129 neighbour dungeons + - their ~19k particle emitters / entities each frame. Fix = **collapse streaming to the - player's single dungeon landblock** when CurrCell is a sealed EnvCell (`!SeenOutside`), - with landblock-level hysteresis to stop collapse↔expand thrash. Confirmed against ACE - (`landblock.IsDungeon → return adjacents` with no neighbours): dungeons have no neighbour - landblocks, so collapsing to the one block is retail-faithful. Commits `5686050` (collapse) - + `d9e7dd6` (hysteresis) + `2561918` (pin to CurrCell's landblock, not the position-derived - one — the negative cell-local-Y made `floor(pp.Y/192)` land one block off and unload the - REAL dungeon). Divergence register: AP-36. - -- **GREY BARRIER (the "barrier above the ramp" / cellar-mouth grey):** portals-only - connector cells (ramp mouths, stair landings, cellar throats) build **0 drawable - sub-meshes**, and BOTH cell-registration gates (`BuildLoadedCell` → visibility - `_cellVisibility`, and `CacheCellStruct` → the physics cell graph) were gated on - `cellSubMeshes.Count > 0`. So a connector cell never registered → the portal flood - hit a **lookup-miss** at its opening (the un-flooded opening shows the clear/grey - colour) AND the camera eye-sweep couldn't transit through it. Fix = register EVERY - cell with a valid cellStruct for visibility + physics; only the *drawing* registration - stays gated on having sub-meshes. Commits `d90c538` (visibility) + `3e006d3` (physics - graph). The physics-graph half EXPOSED the ramp slide-response feel (now **#134**). - Three render-MATH theories (portal_side centroid, on-screen clip, near-eye projection) - were instrumented and REFUTED before the real lookup-miss cause was found — apparatus - discipline held. Render-pipeline digest updated. - -Residual (filed separately): login FPS ramp **#135**; ramp slide-response **#134**; the -A7 per-vertex lighting bake (below) is the remaining "lighting off" work. - -**✅ A7 dungeon lighting — selection fix LANDED + objectively verified (`a80061b`).** The -"lighting off" report was NOT missing torches — the `ACDREAM_PROBE_LIGHT` diagnostic -(`d6fb788`) showed the dungeon correctly gets retail's flat 0.2 indoor ambient + sun zeroed -(`UpdateSunFromSky`, `playerInsideCell` true) AND **2227 torch/point-lights register**. The -bug was the active-light SELECTION: `LightManager.Tick` dropped any light whose range didn't -reach the VIEWER (`DistSq > Range²·slack² → skip`), so a room with 2227 torches lit only the -~1 the player stood inside (`activeLights≈1`, rest at flat 0.2). Retail's D3D model picks the -8 NEAREST lights and applies the hard range-cutoff PER SURFACE in the shader -(`mesh_modern.frag: if (d < range)`). Fix = drop the viewer-range candidacy filter, take the -nearest 8. Probe after: **`activeLights` 2→8** in the dungeon (the room's 8 nearest torches now -light it). Core lighting suite green. Then `Range = Falloff × 1.5` (retail `rangeAdjust`, -`config_hardware_light` 0x0059adc, `a80061b`+) widened the pools. Ambient 0.20 is -retail-faithful (`SmartBox::SetWorldAmbientLight(0.2f)`); the 0.30 was a red herring -(`CreatureMode` paperdoll renderer, not world cells). - -**⚠️ REAL remaining cause — REVISED 2026-06-14 (the earlier "mis-read intensity" theory is -REFUTED).** `intensity=100` is the **REAL dat value** (raw-byte verified `00 00 C8 42` = 100.0f; -DatReaderWriter 2.1.7 parses it correctly; the garbage `cone` is MSVC `CD CD CD CD` -uninitialized fill Turbine baked into the dat — point lights never read it). **DO NOT `÷100`.** -The actual divergence is the **[HIGH] `no-static-light-burnin`**: retail bakes ALL of a cell's -reaching static lights **PER-VERTEX once** (`D3DPolyRender::SetStaticLightingVertexColors` -0x0059cfe0 → `calc_point_light` 0x0059c8b0, Gouraud-interpolated → uniform, never blown out via -the per-channel min-to-colour clamp), while we light **per-PIXEL with only the 8 nearest-to- -CAMERA lights** → bright pools near torches, dark between, and a crescent that slides as the -camera re-ranks the 8-slot list. Diagnosed via a 5-agent investigation + a clean Ghidra -decompile (the BN pseudo-C is x87-mangled). **LANDED:** the per-pixel `(1-dist/falloff_eff)` -shader ramp (`007e287`, necessary but NOT sufficient — it can't fix the per-vertex-vs-per-pixel -structure) + the GL-free `LightBake` Core (`3b93f91`: the verbatim `calc_point_light` port + -7 conformance tests). **REMAINING — the A7 integration:** add a per-vertex linear-RGB colour -attribute to the cell mesh + a bake driver keyed on `envCellId` (NOT the dedup `cellGeomId` — -adjacent rooms share a geom but not their torches) + consume it in `mesh_modern.frag` for cell -draws; bound the bake's light set to the player dungeon (#133's FPS collapse already does this). -Belongs to the #79/#93 indoor-lighting umbrella; outdoor static objects + building shells still -use the per-pixel-8 path (the same spottiness — separate follow-up). **NOTE — dungeon FPS is -FIXED** (was 14–30 from streaming ~129 neighbour ocean-grid dungeons; now ~1000+ fps after the -#133 streaming collapse + the allocation-free 8-light partial-select, `5872bcf`/`5686050`). +G.3 scope chosen). Brainstorming the spec → `docs/superpowers/specs/`. +This is now an M1.5 exit-gate blocker, not deferred. The investigation +below found it's not a single bug but a whole-feature gap (terrain-less +dungeon landblocks unsupported across the pipeline). **Severity:** HIGH (any far/dungeon teleport is unusable) **Filed:** 2026-06-13 (M1.5 dungeon-demo gate attempt — meeting-hall portal) **Component:** physics/streaming — teleport-arrival snap vs async landblock hydration @@ -2568,19 +887,7 @@ Retail oracle for cell-id hysteresis: `acclient_2013_pseudo_c.txt:308742-308783` ## #95 — Dungeon portal-graph visibility blowup (see-through-walls / other dungeons rendered) -**Status:** RESOLVED 2026-06-13 — **the 9.1M-instance blowup was a SYMPTOM of Bug A -(wrong dungeon membership), NOT an unbounded portal flood.** Chain of evidence: (1) a -headless diagnostic on the real `0x0007` dungeon (`Issue95DungeonFloodDiagnosticTests`, -`95d9dab`) measured `PortalVisibilityBuilder` visiting only **1–17 cells** per root — -already tightly bounded and a strict *subset* of the stab_list (`VisibleCells`, which is -the BIG set: avg 120, max 204 of 205 cells). So porting `grab_visible_cells` stab_list -bounding would have made it WORSE — **DO NOT do that.** (2) The 9.1M blowup was captured at -the G.3a gate *before* Bug A's fix (`2ce5e5c`), when the player's membership wrongly -resolved to `0xA9B3` (Holtburg) → the render rooted at the wrong place. (3) With Bug A + -login-into-dungeon (`47ae237`) fixed, a live launch into `0x0007` measured -**instances=~39,000 (down from 9.1M, ~230×), meshMissing=0**, dungeon renders, no ACE -errors. The flood was never the bug. **Originally** also: explained user-observed -"dungeons are broken" +**Status:** OPEN — **explains user-observed "dungeons are broken"** **Severity:** HIGH (blocks all dungeon navigation visually) **Filed:** 2026-05-21 **Component:** rendering, visibility, EnvCell portal traversal @@ -4033,20 +2340,7 @@ regression on the species that already render correctly. ## #39 — Run↔Walk cycle transition not visible on observed player remotes (acdream-as-observer) -**Status:** CLOSED 2026-07-02 (superseded by L.2g; refinement machinery DELETED in the S5 commit) -**Closure note:** the root-cause narrative below is WRONG — the 2026-05-06 -"wire goes silent on Shift toggle" finding was refuted at three oracles + a -fresh live capture (`docs/research/2026-07-02-inbound-motion-deviation-map.md`, -S0 section): retail sends a fresh MoveToState on HoldRun toggle while moving -(`CommandInterpreter` 0x006b37a8 → `SendMovementEvent`), ACE rebroadcasts every -MoveToState (`GameActionMoveToState.cs:36`), and the S0 capture shows explicit -`0x0005↔0x0007` UMs on each toggle. Retail has NO pace→cycle adaptation -anywhere (DEV-2). The `ApplyPlayerLocomotionRefinement` layer this issue added -was itself causing Ready↔Run thrash against legitimate flags=0 stop UMs and -was deleted; player-remote cycles are UM-driven only. Remaining transition -polish (funnel, stop path, link pose) is tracked as roadmap L.2g S2–S4. - -**Original status:** OPEN — VERIFY-PENDING (cases #1/#2/#4/#5 user-verified working 2026-05-06; cases #3/#6/#7 unverified in live test) +**Status:** OPEN — VERIFY-PENDING (cases #1/#2/#4/#5 user-verified working 2026-05-06; cases #3/#6/#7 unverified in live test) **Severity:** LOW (most cases now visibly correct after the 2026-05-06 fix sequence; remaining unverified cases are direction-flip — believed to work via direct UM but not explicitly exercised) **Filed:** 2026-05-03 **Component:** physics / motion / animation @@ -6608,222 +4902,6 @@ outdoors at the angle that previously erased it. # Recently closed -## #170 — Remote creature chase+attack renders wrong vs retail (glide, run not sustained) - -**Status:** DONE (2026-07-04, user visual gate passed — "as close to retail now as -I can see"). Three-fix arc: `427332ac` deleted the per-frame -`apply_current_movement` re-dispatch that flooded `pending_motions` to ~1.3M -(chase turn permanently blocked → slide); `d2ccc80e` refreshed remote body -velocity from `get_state_velocity`; `1051fc83` fixed the residual — the -SERVERVEL per-tick branch skipped `MoveToManager.UseTime` for any UP-receiving -NPC, starving the armed moveto for the whole server-side chase (funnel 16 arms → -1 run install; retail runs `MovementManager::UseTime` unconditionally, -`UpdateObjectInternal` 0x005156b0 @0x00515998) — an armed moveto now always takes -the MOVETO leg. The intermediate "Ready drain too slow" hypothesis was DISPROVEN -by the full-stack offline harness (`RemoteChaseEndToEndHarnessTests` + -`RemoteChaseDrainBisectTests`, kept as conformance). Register: TS-41 narrowed, -TS-42 added (one-frame drain-order divergence, R6). Gate telemetry -(launch-170-gate2.log): installs ≈ arms for every creature, zero armed-moveto -UseTime skips, queue depth 1. Probes stripped in the closing commit. The full -investigation record stays in the OPEN-section body (moves here on next tidy) + -`docs/research/2026-07-04-170-creature-run-handoff.md`. - -## #163 — Strip the R4-V5 stall-investigation diagnostics - -**Status:** DONE (2026-07-03, `5ebe2be3`) — removed `[autowalk-gate]` (+ -`_lastAutowalkGateLogTime` throttle clock, PlayerMovementController), -`[autowalk-feed]` (GameWindow player tracker feed), and the short-lived -`[MOVETO-CANCEL]` #162 capture probe. The durable `ProbeAutoWalk` family -(PhysicsDiagnostics owner + DebugPanel toggle + the permanent `[autowalk]` -probe sites) stays. Suite 3,964 green. - -## #162 — Observer-side moveto cancelled by ACE's autonomous MTS reflections (the "glide" class) - -**Status:** DONE (2026-07-03) — resolved WITHOUT adaptation; user-verified. -The user's A/B came back **retail does NOT glide** (new-fact branch), and -the follow-up acdream capture (launch-162, post-#161) showed acdream now -matches: walk/run by distance on mt-6 movetos (retail -`MovementParameters.GetCommand` threshold), clean takeover on a mid-chain -key press. Three-part evidence: -1. **Mechanism re-audit vs raw:** the head-interrupt IS unconditional - (`unpack_movement` 0x00524440 → `interrupt_current_movement` 0x005101f0 - → `MovementManager::CancelMoveTo(0x36)`), and `MoveToManager::CancelMoveTo` - (0x00529930) is gated on an ARMED moveto (drain + CleanUp + - StopCompletely; no-op otherwise). Our ports are verbatim — retail's - observer loses its armed moveto on the first reflection TOO. -2. **Wire capture:** ACE's mt-0 reflections carry the mover's REAL - locomotion (WalkForward @ -2.92 backward, RunForward @ 4.50, turns) — - so a cancel is always followed by correct legs; the no-glide outcome - needs no moveto protection. The `[MOVETO-CANCEL]` probe showed zero - mover-side cancels in normal use-walks (the mover's client doesn't emit - MTS mid-chain unless keys are pressed) and retail-correct - replace-cancels on an NPC's own successive TurnToObject UMs. -3. **The originally-observed glide is no longer reproducible** — killed by - the R4-V5 fix stack (un-ticked remote MoveToManagers `006cf659`, TS-40 - link-strip `350fb5e3`, #160 run-rate `41006e79`) + #161's apply-pass - params fix `b1cf0102` (ModifyInterpretedState=true corrupted interpreted - state on EVERY UM apply, not just landings). -The theorized "narrow ACE-compat adaptation" is dead — nothing to adapt. - -## #161 — Remote jump landing holds the falling pose (no landing anim) - -**Status:** DONE (2026-07-03, `b1cf0102`) — user live-verified ("Yes works!"). -Three legs, all retail-decode, zero adaptations added: -1. **Apply-pass params:** retail's `apply_interpreted_movement` runs ALL its - dispatches with `ModifyInterpretedState=false` — the ctor default 0x1EE0F - is REWRITTEN by a bitfield store the BN decomp smears into the mush - expression at raw 305778 (`(word & 0x37ff) | cancelMoveTo<<15 | - disableJump<<17`; 0x37ff clears SetHoldKey/ModifyInterpretedState/ - CancelMoveTo). ACE MotionInterp.cs:444-449 confirms. Our pass dispatched - with ctor defaults → the airborne Falling substitution clobbered - `InterpretedState.ForwardCommand` → HitGround's re-apply re-dispatched - Falling instead of the preserved pre-fall wire command. (The retail exit: - fwd is PRESERVED through the fall; HitGround re-dispatches it; the motion - table plays the Falling→X landing link — no wire input needed.) Bonus - decode: raw arg3 = DisableJumpDuringLink, so `(N, 0)` callers mean - allowJump=TRUE — all 9 caller polarities fixed; the W6 entry-cache - (built on the wrong "retail self-heals via hoisted registers" theory) - deleted; `copy_movement_from`'s `current_style` copy (raw 0051e757) - added to the UM flat-copy. NOTE: the handoff's "HitGround raw @305949" - was a mislabel — 305949 is inside `move_to_interpreted_state`; real - HitGround is 0x00528ac0 → `apply_current_movement(0,0)`. -2. **Landing order:** both GameWindow landing blocks cleared the Gravity - state bit BEFORE `Motion.HitGround()`, whose verbatim `state & 0x400` - gate no-opped the whole retail re-apply (retail never clears GRAVITY on - landing); the UP-driven block never called HitGround at all. Both now: - transients → `Motion.HitGround()` → `MoveTo.HitGround()` → THEN the DR - gravity-clear (register row AP-81; retire in R6). -3. **K-fix17 (the handoff's Q3):** its SetCycle DID execute — and re-set - the Falling cycle, because it read the leg-1-clobbered ForwardCommand - (0x40000015 ≠ 0 defeated the Ready fallback). Both SetCycle blocks - deleted — superseded by the retail path, which also plays the landing - LINK animation SetCycle never did. -Tests: `HitGround_AfterFall_RedispatchesPreservedForward_ExitsFalling` -lifecycle pin; `AirborneBody_NoCycleDispatches_OnlyTurnStop` assertion -flipped (it had PINNED the bug value). Suite 3,964 green. Spawned #164 -(action-replay Autonomous bit). - -## #150 — Open doors still blocked at the threshold (ethereal target not skipped in the step-down pass) - -**Status:** DONE (2026-06-25) — after the retail collision sweep, an OPEN door -(ETHEREAL_PS 0x4 set on Use) still stopped the player with a small residual block at -the sill ("can-sized cylinder on the ground threshold"), even though the door swings -open visually. Root: the resolver runs two collision passes per step — the main -sweep and a step-down (foot-sphere "is there floor?") sub-pass. acdream tested the -ethereal door in BOTH; the main pass cleared it (Layer-1 Path-1 + the Layer-2 -override) but the **step-down pass had no escape** and its `Collided` result survived -as the threshold block. Retail's `CPhysicsObj::FindObjCollisions` (pc:276795-276806) -SKIPS an ethereal target entirely when `sphere_path.step_down != 0` — it only tests -it in the main pass — so an open door is fully passable everywhere (the swung panel's -position is irrelevant; ethereal = no collision, the animation is purely visual). -Fix: port that exact branch — `if ((state & 0x4) || (mover.Ethereal && (state & 0x1) -== 0)) && sp.StepDown → continue`. `TransitionTypes.cs` per-object loop. Live-verified -(user "Yes it works"; the door now blocks 0× while open [0x1000C], still blocks while -closed [0x10008]; pre-fix it blocked 217× while open). Core suite green (1595/0). The -earlier "animate the door collision" theory was WRONG and dropped — the user correctly -noted that if collision followed the swung panel you'd bump the panel in its open -position, which retail does not do. Likely also advances the door half of #137. - -## #149 — BSP-less landblock statics (torches/braziers/lamp-posts) were walk-through - -**Status:** DONE (2026-06-25, `4cf6eeb`) — town props placed as landblock stabs -whose ONLY collision is a Setup CylSphere/Sphere (no physics BSP) had ZERO collision -shapes registered → walk-through. Root: the ISSUES #83 / A1.6 gate `!_isLandblockStab` -skipped Setup cyl/sphere for ALL stabs, on the false assumption *"landblock stabs -collide via BSP only (retail CBuildingObj)."* Confirmed end-to-end via live retail -cdb: the Holtburg torch (Setup `0x020005D8`, world (105.99,17.17)) hits -`CPhysicsObj::FindObjCollisions` with `num_cylsphere=1`, cyl h=2.2 — a cylsphere, -matching the dat (cylSphere r=0.2 h=2.2) exactly; the StabList confirms stab[95]= -`0x020005D8` at that position. Fix: gate the Setup cyl/sphere registration on -`entityBsp == 0` (retail's binary dispatch — BSP if the object has one, ELSE -cyl/sphere; see `feedback_retail_binary_dispatch`) instead of stab-ness — preserves -#83's anti-doubling (stab WITH a BSP → BSP-only) while restoring collision for -BSP-less stabs. Live-verified (torch + candle/brazier family block now; ~115 -cyl/sphere Setups register across streamed landblocks). The earlier selection-sphere -hypothesis was WRONG and reverted — the cdb's r=0.48 sphere was the player/NPC body -(every body sphere is ~0.48), not the torch; capturing the *target* at -`FindObjCollisions` (not `this` in `intersects_sphere`) + confirming by position + -Setup-id is the correct cdb method. `GameWindow.cs:7281`. Core suite green (1595/0). -(Numbered #149 to stay clear of main's #148; worktree branched before #145–148.) - -## Dense-town (Arwic) FPS deep-dive — 75 → ~165 fps - -**Status:** DONE (2026-06-24) — `290e731` (cell-object draw batching) + `9f51a4d` -(cell-particle consolidation, also fixed a latent additive double-draw in -multi-aperture cells); apparatus stripped `a9d06a6`. Pixels-identical, build + -full suite green. **The handoff's "GPU-bound / ~12 ms GPU" was wrong** — that was a -glFinish self-measurement artifact; real GPU ~0.5 ms, the frame is ~96 % CPU-bound -(per-cell rebuild cost scaling with visible buildings). Remaining headroom -(scenery-CPU `WbDrawDispatcher` rebuild ~2.1 ms + terrain-GPU ~2.1 ms) deliberately -NOT pursued — both in frozen load-bearing subsystems, HIGH risk for ~15 fps at one -extreme view already over target. SSOT: `docs/research/2026-06-23-dense-town-fps-attribution-report.md`. -Lessons: `memory/feedback_render_perf_measurement.md`. - ---- - -## D.2b — Inventory window finish (Stage 1): scroll + frame + resize + 102 slots SHIPPED - -**Closed:** 2026-06-21 -**Component:** ui — D.2b inventory window (UiItemList scroll, UiNineSlicePanel frame, vertical resize) - -The inventory window now matches retail's 2D presentation (minus the 3D paperdoll doll = Stage 2). Shipped `366af0c`→`1be7e65` (build + full suite green: Core.Net 334 / App 543 / UI 425 / Core 1530; spec/plan `docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-window-finish*.md`): -- **Scroll** — `UiItemList` clip+scroll via the shared `UiScrollable` (whole-row clip; cells exempted from the per-frame anchor pass — that was the "grid escapes the window" root cause); gutter scrollbar `0x100001C7` bound like ChatWindowController; mouse-wheel. -- **Frame** — wrapped in the 8-piece bevel chrome (`UiNineSlicePanel`) like vitals/chat/toolbar. -- **Vertical resize** — bottom-edge drag, horizontal blocked (`ResizeX=false`); grid/sub-window/scrollbar/backdrop stretch (`Left|Top|Bottom`), paperdoll + side-bags pinned; scrollbar thumb reflects view/content. Expand-only Min=default..Max=560 (AP-54). -- **102-slot grid** — contents grid pads empty frames to the main-pack capacity (default 102, AP-53); side-bag column pads to 7 (AP-52). -**Visually confirmed** (scroll, frame, resize, 102 slots). **Remaining (handoff `docs/research/2026-06-21-d2b-inventory-finish-handoff.md`):** wrong empty-slot background art (inventory + equip slots — the OPEN issue above) + main-pack backpack icon (AP-51); Stage 2 = paperdoll `UiViewport` doll + per-slot equip silhouettes. - ---- - -## D.2b-B — Inventory wire layer (B-Wire): player-property delivery + builders/parsers SHIPPED - -**Closed:** 2026-06-21 -**Component:** net/ui — D.2b inventory wire (player props + inventory GameMessages/GameActions) - -The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5) instead of the client-side sum. **Root cause was delivery, not binding** — the binding already read `Properties.Ints[5]`, but the value never arrived: login PD parsed the player's int table then dropped it; live `PrivateUpdatePropertyInt 0x02CD` was unparsed; `ObjectTableWiring` gated all non-UiEffects ints out. Fixes: `ClientObjectTable.UpsertProperties` (create-if-absent) + the PD handler upserts the player's `PropertyBundle`; new `PrivateUpdatePropertyInt 0x02CD` parser + WorldSession dispatch + player-int route; loosened the int-apply gate to apply ALL ints; `InventoryController.Concerns` refreshes on the player's own object (C1d). Plus latent-bug fixes (`0x0022` 4th field `containerType`; `0x00A0` `weenieError`), new builders (`DropItem 0x001B`, `GetAndWieldItem 0x001A`, `NoLongerViewingContents 0x0195`) + `Send*` wrappers, new parsers (`ViewContents 0x0196`, `SetStackSize 0x0197`, `InventoryRemoveObject 0x0024`) + GameEvent registration. Spec/plan `docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-wire*.md`. Commits `b56087b`→`7badecf` (build + full suite green: Core.Net 334 / App 534 / UI 425 / Core 1530; Opus phase-boundary review APPROVED with byte-for-byte ACE verification). AP-48/AP-49 reworded to fallback-only (confirm + delete at the visual gate). **Pending the burden-bar visual gate** (does the live bar match ACE + update on pick-up/drop?). Remaining D.2b inventory: contents-grid scroll polish (below), B-Drag (inventory drag SOURCE + `SourceKind==Inventory`), Sub-phase C (paperdoll). - ---- - -## D.2b-B — Inventory controller (B-Controller): grid population + burden meter + captions SHIPPED - -**Closed:** 2026-06-21 -**Component:** ui — D.2b inventory window (gmInventoryUI 0x21000023) - -`InventoryController.Bind` wired into the existing inventory-init block in `GameWindow.cs` (commit `03fbf44`). The controller populates the "Contents of Backpack" grid (6 cols × 32 px) and the pack-selector strip from `ClientObjectTable`, drives the vertical burden meter via `BurdenMath.EncumbranceCapacity/LoadRatio/LoadToFill`, and attaches "Burden" + "Contents of Backpack" + `%` captions. Four divergence rows added: AP-48 (client-side burden sum), AP-49 (aug capacity unwired), AP-50 (meter direction from geometry), AP-51 (main-pack icon placeholder). Divergence register + docs commit: `docs(D.2b-B)`. - -**VISUALLY CONFIRMED 2026-06-21.** At the visual gate the controller's *logic* was correct (live diagnostics: 36 items + 1 side bag bound, burden Str 290 → 17%, captions' `DrawStringDat` called, meter sprites resolved), but two RENDER bugs hid the result; both fixed (`417b137`): (1) backdrop wash-out — the #145 continuation above; (2) captions — the caption elements resolve to `UiText`, and driving a nested *child* `UiText` didn't paint, so `AttachCaption` now drives the host `UiText` directly. After the fixes: dark backdrop behind, "Burden 17%" + vertical bar + side-bag + "Contents of Backpack" + full item grid all render. **Remaining open sub-phases:** B-Wire (parse `EncumbranceVal`/PropertyInt 5 from the wire to retire AP-48), B-Drag (drag-from-inventory SourceKind branch closes B.2), Sub-phase C (paperdoll equip-slot `UiItemSlot` registration), + the contents-grid scroll polish (next issue). - ---- - -## Inventory "Contents of Backpack" grid overflows (no scroll) - -**Status:** DONE (2026-06-21 · D.2b inventory finish Stage 1, `366af0c`→`1be7e65`) — the grid clips to its panel + scrolls via the gutter scrollbar `0x100001C7` (`UiScrollable` + whole-row clip; cells exempted from the anchor pass), pads to the full 102-slot main-pack capacity, and the window is vertically resizable (bottom edge). Visually confirmed. -**Component:** ui — D.2b inventory (gm3DItemsUI grid `0x100001C6`) - -**Description:** `InventoryController` populates the 6-col contents grid with ALL of the player's loose pack items via `UiItemList` grid mode, so a pack with >18 items (6 cols × 3 visible rows in the 192×96 panel) overflows BELOW the panel/frame ("part of the inventory hanging off the window"). Retail scrolls the grid via the side gutter scrollbar (`0x100001C7`). Fix: wire the gutter `UiScrollbar` to the grid + clip the grid to the panel (scroll the overflow). Spec listed scrolling as out-of-scope for B-Controller (`docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md` §10), so this is a deliberate follow-up, not a regression. - ---- - -## Inventory + equipment slots show the wrong empty-slot background art - -**Status:** INVENTORY PORTION FIXED + VISUALLY CONFIRMED 2026-06-22 — contents grid + side-bag + main-pack empty art dat-resolved (`ItemListCellTemplate`, commits `b862640`→`22d9231`; user-confirmed the container column reads correct). STILL OPEN: paperdoll equip-slot silhouettes (Sub-phase C) + main-pack backpack icon (AP-51) + the selected-container triangle/selection-square (container-switching sub-phase, AP-56). -**Filed:** 2026-06-21 -**Component:** ui — D.2b inventory (UiItemSlot empty sprite + paperdoll equip slots) - -**Description:** User-flagged at the Stage-1 visual gate: the empty cells in the contents grid + side-bag column use `UiItemSlot.EmptySprite = 0x060074CF` (the TOOLBAR empty-slot border) — the wrong art for inventory pack slots. The paperdoll equipment slots render a generic blue `UiDatElement` border, not per-slot equip silhouettes. Both look wrong vs retail. - -**Root cause / status:** `UiItemSlot.EmptySprite` is a single hardcoded default (`0x060074CF`) shared by all empty cells. The inventory/side-bag pack slots need the gm3DItemsUI/gmBackpackUI cell-template (`0x21000037`) empty-state background instead. The equip slots need the `0x10000032` `UIElement_UIItem` registration + per-slot silhouette art (Sub-phase C). - -**Resolution (inventory portion, 2026-06-22):** Ported retail's `UIElement_ItemList::InternalCreateItem` (`0x004e3570`) resolver into `ItemListCellTemplate.ResolveEmptySprite` — reads attribute `0x1000000e` off each list element → catalog `0x21000037` prototype's `ItemSlot_Empty`. **Correction to the original diagnosis:** `0x060074CF` is the *generic shared* item-slot empty (the `ItemSlot_Empty` of many catalog prototypes), not "the toolbar's"; `0x21000037` is a catalog of ~50 per-slot-kind empties — so it was never a one-constant swap. Pinned per-list (real-dat test): contents `0x100001C6` → `0x06004D20`; side-bag `0x100001CA` + main-pack `0x100001C9` → `0x06000F6E` (the inner dark slot, resolved through `BaseElement` inheritance). **Visual-gate correction (2026-06-22):** the first cut's frame-first heuristic grabbed the container prototype's DirectState child `0x06005D9C` — which is the open/**selected**-container TRIANGLE indicator, not a background — and stamped it onto every empty container cell. Fixed: resolve the inner `ItemSlot_Empty` through inheritance (`FindIconEmpty`); the triangle + green/yellow selection square is deferred to the container-switching sub-phase (AP-56). `UiItemList.CellEmptySprite` carries it; `InventoryController` + `GameWindow` wire it. Divergence AP-55 (toolbar still hardcoded) + AP-56 (selected-container indicator deferred). Spec/plan `docs/superpowers/{specs,plans}/2026-06-22-d2b-empty-slot-art*.md`. - -**Files:** `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite`); `src/AcDream.App/UI/Layout/InventoryController.cs` (set the inventory pack-slot empty sprite on the cells it creates); paperdoll equip slots → Sub-phase C. - -**Research:** `.layout-dumps/uiitem-0x21000037.txt` (cell template states); `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`. Also pending: the main-pack backpack icon (AP-51). - -**Acceptance:** empty inventory + side-bag cells show the retail pack-slot frame; equip slots show per-slot silhouettes. - ---- - ## #113 — Phantom staircase: REOPENED 2026-06-11, folded into the HOLISTIC BUILDING-RENDER PORT **Status:** REOPENED — root cause #2 found (drawing-BSP-orphaned no-draw polys: diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index abb563ae..b7a710c0 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -37,13 +37,14 @@ accepted-divergence entries (#96, #49, #50). --- -## 1. Intentional architecture (IA) — 16 rows +## 1. Intentional architecture (IA) — 14 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | IA-1 | Contact-plane pre-seed on grounded movers (**#96 ACCEPTED** per ISSUES.md) — retail's `CTransition::init` clears `contact_plane_valid`; we seed from the body's previous-frame plane | `src/AcDream.Core/Physics/PhysicsEngine.cs:919` | Removing it broke last-step stair `step_up` (`892019b`, reverted); seed propagates the body's *real current* plane, behavior matched retail in the A6.P3 gates | A stale pre-seeded plane lets `AdjustOffset` project sub-step 1 onto a plane retail wouldn't have yet — wrong slope motion / step-up acceptance right after leaving a surface | `CTransition::init`, pc:272547 family | | IA-2 | Lateral self-heal beyond retail's keep-curr: when no candidate contains the sphere, try `FindVisibleChildCell` over the claim's stab-list before keeping the claim | `src/AcDream.Core/Physics/CellTransit.cs:912` | Reuses the recovery retail's own `AdjustPosition` performs (:280028 stab-list mode), applied at the `find_cell_list` site to heal near-miss claims without a doorway crossing | In containment-gap geometry, membership flips to a neighbouring room where retail keeps curr — wrong render root / collision cell at gap positions | `find_cell_list` keep-curr pc:308788-308825; `find_visible_child_cell` :311444 | | IA-3 | `get_state_velocity` prefers dat cycle velocity (`MotionData.Velocity × speedMod`) over the decompiled constant; constant kept only as max-speed clamp | `src/AcDream.Core/Physics/MotionInterpreter.cs:315` | Retail's constant equals the Humanoid RunForward `MotionData.Velocity`, so both paths agree on retail dats; dat is ground truth for other MotionTables (r03 §1.3) | Where dat velocity ≠ constant, body speed differs from the retail binary — DR / observer drift on exotic creatures or modded dats | `FUN_00528960`; `_DAT_007c96e0` RunAnimSpeed | +| IA-4 | `MultiplyFramerate` omits retail's negative-factor StartFrame↔EndFrame swap (direction encoded in Framerate sign instead) | `src/AcDream.Core/Physics/AnimationSequencer.cs:129` | Our callers (ForwardSpeed updates) only pass positive factors; Advance loop handles negative framerates against StartFrame as lower bound | A future negative-factor caller (reverse playback) scales without swapping bounds — wrong frame range traversal instead of clean reversal | `FUN_005267E0`; ACE Sequence.cs L277-287 | | IA-5 | Per-ENTITY vertex-derived AABB culling (+5 m animated-drift margin; animated entities bypass cull) vs retail per-PART dat drawing spheres | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:693` (bounds at `src/AcDream.Core/World/WorldEntity.cs:153`, `src/AcDream.Core/Meshing/GfxObjBounds.cs:14`; dead `PerEntityCullRadius=5.0f` at dispatcher :210) | Batched MDI rendering can't cheaply cull per part; bounds derive from the SAME dat vertex data that gets drawn (containment by construction — the **#119** fix, `6a9b529`; memory: feedback_culling_bounds_from_drawn_data) | Geometry escaping bounds+margin (pose drift >5 m, a hydration path skipping `SetLocalBounds`) makes the whole entity vanish on-screen — the #119 vanishing-staircase class | `CGfxObj.drawing_sphere` / viewconeCheck 0x005a09a4 | | IA-6 | Chat scrollback 500 lines vs retail ~200 (configurable) | `src/AcDream.Core/Chat/ChatLog.cs:19` | Strictly more useful for a dev client + plugins; deliberate default | Negligible — only if a plugin/UI behavior is ever specified against retail's exact retention cap | retail chat scrollback (~200) | | IA-7 | PhysicsScript replay keyed by (scriptId, entityId) replaces the prior instance; retail's ScriptManager linked list could hold duplicates | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs:51` | Prevents duplicate-stacking on server retriggers; flat keyed list simpler than retail's linked schedule; hedged to retail's common path | A server intentionally layering the same script on the same object shows ONE effect where retail shows several (overlapping casts/impacts) | `ScriptManager::Start` FUN_0051be40 / tick FUN_0051bfb0 | @@ -51,25 +52,25 @@ accepted-divergence entries (#96, #49, #50). | IA-9 | One unified camera matrix for terrain — retail's separate `LScape::update_viewpoint` landscape viewpoint does not exist | `src/AcDream.App/Rendering/TerrainModernRenderer.cs:266` | Phase W T4.2: with one matrix everywhere, viewpoint-desync bugs are unrepresentable — the unification IS the correctness argument | Anything retail derives from the landcell-relative viewpoint (float precision at extreme coords, viewpoint-keyed state) has no analogue; a future port expecting it silently reads the camera | `LScape::update_viewpoint`; `LScape::draw` 0x00506330 | | IA-10 | Transparent groups sorted back-to-front per GROUP by first-instance position (no within-group sort) vs retail per-poly BSP-order draw | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1364` (comparer :1662) | One MDI call per pass requires group-granularity ordering; per-poly sorting is incompatible with instanced multi-draw; works when group instances are spatially coherent | Spatially spread or interleaved transparent groups composite in the wrong order — popping / wrong see-through layering as the camera moves | retail per-poly BSP-order transparent draw (D3DPolyRender / PView::DrawCells) | | IA-11 | Tier-1 cross-frame batch-classification cache for static entities (retail re-walks part arrays every frame) | `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs:12` | Issue #53 perf tier; invariants documented (keys = EntityId + OWNING-landblock hint post-**#119** fix `2163308`; invalidation at despawn/LB-unload; mutation audit 2026-05-10) | Key collision or missed invalidation serves one entity another's batches — session-sticky wrong meshes (the #119 broken-stairs/water-barrel symptom) | retail per-frame part-array classification (no cache) | -| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md | +| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md | | IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) | | IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` | -| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing dat-sprite window frames, not a byte-port of keystone.dll's LayoutDesc binary tree. Both the vitals window (`LayoutDesc 0x2100006C`) and the chat window (`LayoutDesc 0x21000006`) are rendered by the LayoutDesc importer; `UiNineSlicePanel`/`RetailChromeSprites` now back only plugin panels | `src/AcDream.App/UI/Layout/LayoutImporter.cs` (vitals + chat) + `src/AcDream.App/UI/Layout/ChatWindowController.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is DATA-DRIVEN from the dat: the `LayoutImporter` reads `LayoutDesc 0x2100006C`/`0x21000006` and resolves chrome element positions + sprite ids directly from parsed dat fields; vitals locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C`/`0x21000006` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) | -| IA-17 | Toolbar window FRAME is toolkit-supplied (per-window UiNineSlicePanel 8-piece bevel, drawn over content via UiElement.OnDrawAfterChildren) rather than the window-manager-owned chrome retail paints uniformly around every window. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the dat stacks both rows always. | `src/AcDream.App/Rendering/GameWindow.cs` (toolbar mount) + `src/AcDream.App/UI/UiNineSlicePanel.cs` + `src/AcDream.App/UI/UiCollapsibleFrame.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | LayoutDesc 0x21000016 has NO baked frame; retail's toolbar frame is window-manager chrome (keystone.dll). We draw the same reusable 8-piece bevel chat/vitals use; border drawn over content so the toolbar's 2px-wide row-2 right cap (W=8) can't poke through. Same pattern as the chat window. | Until a central window manager owns chrome uniformly, per-window wraps can drift (size/offset/z-order) from each other and from retail; the border-over-content rule is the toolkit's, not the WM's | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) | -| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` | --- -## 2. Adaptation (AD) — 33 rows +## 2. Adaptation (AD) — 27 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | AD-1 | Lost-cell machinery replaced by recoverable outdoor demote (**#107** safety net) + outdoor-restore `max(terrainZ, z)` under-terrain lift; retail goes `GotoLostCell` | `src/AcDream.Core/Physics/PhysicsEngine.cs:553` (+ :808) | acdream has no lost-cell state machine; outdoor landcell is the recoverable equivalent; the #107 auto-entry hold should make the demote branch unreachable | Gap in the hold → player committed to outdoor terrain inside/under a building (fake-grounded spawn, fall-through); a legit below-heightmap server restore is silently lifted — upward warp vs server | `GotoLostCell` pc:283418; `SetPositionInternal` 0x00515bd0, pc:283892-283945 | -| AD-2 | Async spawn gates replacing retail's synchronous cell load. **#135 refinement:** an INDOOR spawn/teleport (cell ≥ 0x0100, hydratable) gates ONLY on the EnvCell floor (`IsSpawnCellReady`), NOT the terrain heightmap; an OUTDOOR spawn (or an unhydratable indoor claim that demotes outdoor) gates on the terrain-ready hold (**#106**). A dungeon's negative-offset cells can place the spawn's WORLD position in a neighbour terrain landblock the #135 dungeon collapse doesn't load, so a terrain requirement would hang indoor login/teleport forever (cellReady true, terrain null) — the player lands on the cell floor, terrain is irrelevant indoors. Claims beyond NumCells skip the gate (demoted). **#145 refinement (2026-06-22):** an OUTDOOR teleport additionally requires the destination's OWN landblock terrain to be resident (`PhysicsEngine.IsLandblockTerrainResident(destCell)`, which `StreamingController` priority-applies so it flips in ~hundreds of ms) before placing — and `SampleTerrainZ` is short-circuited behind it. On a teleport OUT of a dungeon the collapsed SOURCE dungeon stays resident at the same streaming-local coords and answers `SampleTerrainZ` (terrain 0), so without the load check the resolve ran against the dungeon's cells and rooted the player at a dungeon-frame cell id (ACE then rejected all movement as "failed transition"). Decision in `TeleportWorldReady`; the hold→materialize→regain-control transit is driven by `TeleportAnimSequencer` (the retail fade cover, ticked in `OnUpdate`), which replaced the retired `TeleportArrivalController` | `src/AcDream.App/Rendering/GameWindow.cs` (`isSpawnGroundReady` lambda ~1010 + `TeleportWorldReady` ~5512 + the TAS transit tick in `OnUpdate`) (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge. Indoor-on-cellReady is the faithful equivalent of retail's synchronous cell load + place-on-floor (terrain under a dungeon is meaningless; the pre-#135 terrain hold only passed because the 25×25 window streamed the neighbour terrain) | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode; an indoor spawn whose cell never hydrates now holds on cellReady alone (no terrain backstop) — but that path is exactly the #107 hold | retail synchronous cell load before SetPosition (no gate exists) | +| AD-2 | Async spawn gates replacing retail's synchronous cell load: terrain-ready hold (**#106**) + indoor cell-hydration hold (**#107**, `IsSpawnCellReady`); claims beyond NumCells skip the gate (demoted) | `src/AcDream.App/Rendering/GameWindow.cs:1008` (+ `src/AcDream.App/Input/PlayerModeAutoEntry.cs:69`, `src/AcDream.Core/Physics/PhysicsEngine.cs:468`) | Entering earlier integrates gravity against an empty world (free-fall into void); the gate is the async-streaming equivalent of retail's blocking load; a looser "any struct present" version reproduced the transparent-interior wedge | Gate opens early → raw claim commit → outdoor demote mid-building; predicate never satisfied (streamer stall, dat edge case) → login wedges in pre-player mode | retail synchronous cell load before SetPosition (no gate exists) | | AD-3 | Outdoor seeds always walk the transit array (retail skips the walk when the seed CLandCell is null/unloaded); per-cell lookups no-op on unhydrated data | `src/AcDream.Core/Physics/CellTransit.cs:503` | Equivalence argument: with nothing hydrated every lookup inside the walk no-ops, so the result matches retail's skipped walk | Near partially-streamed landblocks, building-transit promotion silently can't fire until structs hydrate — membership stays outdoor while the player is inside a building | `CObjCell::find_cell_list` 0052b535-0052b56c (null-CLandCell case) | | AD-4 | `point_in_cell` against an unhydrated CellBSP returns false (skip) rather than the null-node "inside" default; retail never queries unloaded cells | `src/AcDream.Core/Physics/CellTransit.cs:588` | The null-node default would make an unhydrated cell spuriously claim every point; skipping is the conservative streaming-safe choice | During hydration, a point genuinely inside a not-yet-loaded cell resolves outdoor/stale — transient membership misclassification driving wrong collision set and render root | `CEnvCell::find_visible_child_cell` :311397; cell-BSP vtable[0x84] | | AD-5 | Outdoor `point_in_cell` is an identity compare against the global XY-column cell from `LandDefs.AdjustToOutside` (no per-cell containment test) | `src/AcDream.Core/Physics/CellTransit.cs:865` | Landcells are disjoint 24 m columns — identity-compare against the column under the sphere centre is exactly equivalent to retail's per-candidate test | If block-origin/lcoord math is wrong at a landblock seam, the compare silently never matches — outdoor membership freezes at boundaries (the pre-#106 symptom) | `find_cell_list` pick pc:308788-308825; `CLandCell::point_in_cell` (get_block_offset pc:308804) | | AD-6 | Per-LANDBLOCK shadow re-flood on hydration vs retail per-CELL `recalc_cross_cells` | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:339` | The streaming unit IS the landblock; one hook per hydration event covers both race directions (entity-before-cells, cells-after-spawn) | Any cell-hydration path that doesn't raise the landblock hook leaves an entity's shadow set stale — walk-through / missing collisions in just-streamed cells | `CObjCell::init_objects` → `recalc_cross_cells`, 0x0052b420 / 0x00515a30 | +| AD-7 | Full collision exemption on ETHEREAL alone; retail requires ETHEREAL_PS **and** IGNORE_COLLISIONS_PS (ETHEREAL-alone takes the unported `obstruction_ethereal` path) | `src/AcDream.Core/Physics/CollisionExemption.cs:78` | ACE's `Door.Open()` broadcasts ETHEREAL only (0x0001000C); without the shortcut, opened doors stay solid on ACE | ETHEREAL-only targets generate NO contact where retail records contact-but-allows-passage; against a retail-semantics server the bit means something different than we implement | pc:276782 (combined gate), :276795 (obstruction_ethereal) | +| AD-8 | MoveTo arrival gate `max(minDistance, distanceToObject)`; retail tests `dist <= min_distance` only | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:161` | ACE ships the threshold in `distance_to_object` with `min_distance == 0`; without the max, monsters never "arrive" and oscillate at melee range (user-reported 2026-04-28) | A server using both wire fields with retail semantics + large `distance_to_object` makes remotes stop short of the retail arrival point | `MoveToManager::HandleMoveToPosition` chase-arrival | +| AD-9 | 1.5 s stale-destination give-up timer on remote MoveTo (retail's MoveToManager runs until cancelled) | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:136` | Liveness guard sized to ACE's ~1 Hz re-emit cadence; prevents steering toward a stale destination after a missed cancel (the run-in-place symptom) | A server emitting MoveTo slower than ~1.5 s makes remotes freeze mid-chase and snap later instead of steering continuously | MoveToManager (no equivalent timeout) | | AD-10 | Remote slope projection relocated to the queue-empty/head-reached combiner boundary; retail projects inside `CTransition::adjust_offset` during the sweep | `src/AcDream.Core/Physics/PositionManager.cs:47` | Remote bodies don't run a full local transition sweep; boundary projection removes the ~5 Hz Z staircase on slopes, no-op on flat ground | The single-point terrain-normal sample can differ from the sweep's contact plane (cell boundaries, props underfoot) — remote Z drift / stair-stepping | `CTransition::adjust_offset` pc:272296-272346 | | AD-11 | Useability fallback: retail blocks Use entirely on null/zero useability; we allow it (behavioral fallback in the `IsUseableTarget` caller; justification recorded here) | `src/AcDream.Core/Physics/PhysicsDiagnostics.cs:163` | ACE's seed DB ships many weenies with `_useability` unset; without the fallback doors/lifestones/creatures are un-Useable on ACE | Objects a retail-faithful server intentionally marks non-useable become useable in acdream — wrong interaction gating when the ACE-ships-null assumption stops holding | `ItemHolder::UseObject` pc:402923 | | AD-12 | SecondaryAttributeTable coefficients hardcoded (Health=End×0.5, Stam=End×1.0, Mana=Self×1.0) instead of dat-read; unknown attributes contribute 0 | `src/AcDream.Core/Player/LocalPlayerState.cs:279` | Coefficients never vary across retail dat versions; re-confirmed by ACE AttributeFormula.cs + holtburger; dat port can replace later | A customized portal.dat with modified vital formulas silently yields wrong max-vitals; a missing attribute snapshot underestimates max | SecondaryAttributeTable portal.dat 0x0E0..0x0E2; `CreatureVital::GetMaxValue` 0x0058F2DD | @@ -85,113 +86,62 @@ accepted-divergence entries (#96, #49, #50). | AD-22 | Async streamed mesh loading with point-of-use self-heal (`EnsureLoaded` re-request in the dispatcher's per-frame meshMissing path, **#128**); retail loads synchronously — geometry is never absent | `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:211` | Documented convergence argument: the self-heal makes absence transient, converging the async pipeline to retail's never-absent guarantee | A missing mesh referenced OUTSIDE the dispatcher's walk (a future consumer not touching meshMissing) stays permanently invisible — the #119/#128 broken-stairs class; best case, late pop-in | retail synchronous content load (note at WbMeshAdapter.cs:211) | | AD-23 | Live entities with `ServerGuid != 0` and null `ParentCellId` are culled (ClipSlotCull) while indoor clip routing is active; retail objects are always cell-resident (synchronous add-to-cell at creation) | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:484` | Phase U.4 policy: parentless = unresolved indoors, equivalent to retail's not-in-any-visible-cell ⇒ not drawn, *given membership resolves promptly* | An entity whose membership lags (late CreateObject hydration, resolver hiccup) blinks invisible while the player is indoors, even in plain sight | retail per-cell object lists in PView traversal | | AD-24 | EnvCell shell geometry hash-deduplicated ((environmentId, structure, surface overrides) → 31-multiplier hash) and instanced; retail draws each CEnvCell's own structure directly | `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs:276` | Verbatim WB EnvCellRenderManager port (Phase A8); dedup is what makes the single-VAO MDI cell pipeline cheap; intended visuals identical | A hash collision between distinct tuples renders the wrong interior shell in some room with NO diagnostic firing — wrong walls/floor in a dungeon room | retail `PView::DrawCells` → per-cell drawing_bsp (cited at :319) | -| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding. **2026-07-05 (#173): the same reflection + suppression now also runs in the remote DR sweep** (remote jumps hitting ceilings reflect like the local player; both sites share the rule and this row) | `src/AcDream.App/Input/PlayerMovementController.cs:874`; `src/AcDream.App/Rendering/GameWindow.cs` (remote sweep post-resolve, #173 block) | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral — both the local and remote landing snaps use that gate); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | -| AD-27 | Use/PickUp action fired on natural moveto completion via the `MoveToComplete` client-addition seam (retail's `CleanUpAndCallWeenie` contains no weenie call in this build and notifies nothing on arrival); retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Rendering/GameWindow.cs:12939` (MoveToComplete subscription) + `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`MoveToComplete` seam doc) | ACE's server-side chain may have timed out by the time our body arrives; the close-range deferred send hits ACE's WithinUseRadius fast-path. R4-V5 re-anchored from the deleted B.6 `AutoWalkArrived` event — same fires-on-arrival-only contract (never on cancel) | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius; `MoveToManager::CleanUpAndCallWeenie` 00529650 §7e (no weenie call) | -| AD-28 | Chat transcript (`UiText`) and input (`UiChatInput`) are two separate widget classes placed inside their dat-authored container panels; retail's `ChatInterface` uses a single mode-flagged `UIElement_Text` (Type-12) that switches between read and edit mode | `src/AcDream.App/UI/Layout/ChatWindowController.cs:135` (transcript) + `:150` (input) | `UIElement_Text` is inside keystone.dll with no PDB/decomp; a two-widget split is functionally equivalent (read-only scroll, editable input) and is the structural adaptation required by our UiElement architecture | A future consumer expecting a single widget for both read/write (e.g. a plugin calling the chat API and getting one widget back) must be written to the two-widget contract | `UIElement_Text` (Type-12) @ keystone.dll; `gmMainChatUI::PostInit` @0x4ce130 | -| AD-29 | `ClientObjectTable` fires global `ObjectAdded`/`ObjectUpdated`/`ObjectRemoved` events; consumers filter by guid on their end. Retail dispatches per-object via `NoticeRegistrar` observer dispatch — each UI cell observes only its specific object guid | `src/AcDream.Core/Items/ClientObjectTable.cs:48` (events); `src/AcDream.App/UI/Layout/ToolbarController.cs:115` (guid filter) | `NoticeRegistrar` is inside keystone.dll with no PDB/decomp; global broadcast + consumer-side filter is functionally equivalent for the current panel count and object volumes seen in practice | At high object counts (>1 000 objects), every `ObjectUpdated` wakes every subscribed consumer — O(n·m) notification cost instead of retail's O(1) per-observer dispatch; a consumer that forgets the guid filter processes all objects (a latent correctness bug) | `NoticeRegistrar` (keystone.dll, no PDB); retail per-object observer registration in `CObjectMaint` | -| AD-30 | Cell-march preserves seed landblock id when `TryGetTerrainOrigin` returns false for an outdoor seed (#145 D, 2026-06-22): `BuildCellSetAndPickContaining` returns `currentCellId` verbatim rather than marching via `blockOrigin=(0,0,0)`; retail never encounters this state (cells stored block-local, no streaming-gap concept) | `src/AcDream.Core/Physics/CellTransit.cs:765` | Equivalence argument: "preserve-verbatim when unregistered" is the same contract as `PhysicsEngine.Resolve`'s NO-LANDBLOCK branch; the player's cell stays the last known-correct cell until the landblock's terrain registers — no march, no lbX=0 wire | A body whose seed landblock is genuinely absent for >1 physics tick holds its last-known cell rather than discovering the true containing cell; transient only — corrects the instant terrain registers; an indoor seed is explicitly excluded from the guard (outdoor low < 0x100 gate) | `CObjCell::find_cell_list` + block-local storage (retail has no streaming gap); `TryGetTerrainOrigin` pc path | -| AD-31 | Teleport transit covered by a full-screen black FADE (`FadeOverlay` + `TeleportAnimSequencer`) instead of retail's 3D portal-tunnel swirl (2026-06-22, spec C). Opaque black holds through the tunnel states; the world ramps back in on `WorldFadeIn`. | `src/AcDream.App/Rendering/FadeOverlay.cs` + `GameWindow.cs` (TAS transit tick; `_teleportFadeAlpha = ShowTunnel ? 1 : FadeAlpha`) | The fade is a functional cover that hides the (now-fast) destination load + the post-materialization object flood; the TAS state machine + golden timing constants are retail-verbatim — only the tunnel *graphic* is approximated. Sibling to AP-49 (fade-curve). | Visual-only: the transit shows a black cover, not the animated swirl. Retire by porting the `gmSmartBoxUI` 3D tunnel render. | `gmSmartBoxUI::UseTime` 0x004d6e30 (tunnel render, unported); `TELEPORT_ANIM_*` golden constants (spec §2.1) | -| AD-32 | Movement-event staleness gate ADOPTS a newer-incarnation instance stamp and applies the event immediately; retail queues the blob for the not-yet-created object (`SmartBox::QueueBlobForObject`, dispatch return 4) and replays it once that incarnation exists (L.2g S1, 2026-07-02) | `src/AcDream.Core/Physics/MotionSequenceGate.cs:105` | acdream has no per-object blob queue; a newer instance seq means the new incarnation's CreateObject is in flight, and that CreateObject re-seeds the same gate (advance-only), so old-incarnation stragglers still drop | A motion event for a new incarnation applies to the OLD body for up to one CreateObject round-trip — brief wrong-cycle flicker on respawn/re-instance if the new incarnation's motion table differs | 0xF74C dispatch pc:357214-357239 (`is_newer(update_times[8], seq)` + `QueueBlobForObject`) | -| AD-33 | `CSequence.frame_number` at C# `double` (64-bit); retail is x87 `long double` (80-bit extended) — every frame-boundary comparison ran at extended precision on retail (Phase R1, 2026-07-02) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`FrameNumber`) | `double` is the widest C# float type; the R1 port removes ACE-style boundary epsilons so comparisons are exact-int against bare boundaries, minimizing ULP sensitivity (ACE's `float` is far worse) | A frame landing within 1 double-ULP of an integer boundary could classify differently than retail's 80-bit compare — sub-frame timing skew at pathological framerate×dt combinations | `acclient.h:30747` (`long double frame_number`) | -| AD-34 | Retail's intrusive `DLListBase`/`DLListData` lists are managed `LinkedList`s; node identity via `LinkedListNode<>` references (Phase R1 anim list, 2026-07-02; extended R2-Q3 to `MotionTableManager.pending_animations`; extended R4-V2 to `MoveToManager.pending_actions` — whose node type, retail `MoveToManager::MovementNode`, is RENAMED `MoveToNode` to avoid colliding with R2's `Motion/MotionNode.cs` pending_motions node) | `src/AcDream.Core/Physics/Motion/CSequence.cs` (`_animList`); `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`_pendingAnimations`); `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (`_pendingActions`) + `MoveToNode.cs` | Same topology + cursor semantics (curr_anim/first_cyclic/tail-anchored scans are node references); unlink/delete becomes `Remove(node)`; conformance tests pin the surgery state tables | Any retail behavior depending on the −4 pointer adjustment or node memory reuse (none observed in the decomp) would diverge; a reader grepping for retail's `MovementNode` name must find it via this row | `acclient.h` DLListBase; `r1-csequence-decomp.md` §0; `r2-motiontable-decomp.md` §11; `r4-moveto-decomp.md` node factories §4a | -| AD-35 | `MotionTableManager.PerformMovement`'s unhandled-type default case returns the named sentinel `0xFFFFFFFF` (`MotionTableManagerError.NotHandled`); retail's compiled code leaks the `CSequence*` pointer reinterpreted as the return code (BN-confirmed artifact, dead/unreachable — callers gate on type first) (R2-Q3, 2026-07-02) | `src/AcDream.Core/Physics/Motion/MotionTableManager.cs` (`PerformMovement` default case) | No retail caller consults the return value for unhandled types (RawCommand/StopRawCommand/MoveTo\*/TurnTo\* route elsewhere); returning a stable non-zero sentinel preserves the only observable contract (non-zero = not success) without fabricating a pointer-shaped number | If a future port wires a caller that passes unhandled types AND branches on the exact return value, it would see `0xFFFFFFFF` where retail saw an arbitrary pointer — flag at that port | `PerformMovement` 0x0051c0b0 (`r2-motiontable-decomp.md` §11 default-case note) | -| AD-36 | `IMotionDoneSink.MotionDone` consumed for CREATURE-class entities only: R3-W2 binds the seam to the entity's `MotionInterpreter.MotionDone` (player via `PlayerMovementController.Motion`, remotes via `RemoteMotion.Motion`, resolved at fire time); interp-less entities (statics that never receive a UM/UP and so never get a `RemoteMotion`) keep a diagnostic-recorder-only target — retail gives every CPhysicsObj a MovementManager/CMotionInterp (R2-Q4 seam, narrowed R3-W2, 2026-07-02; R5-V5 gave every `RemoteMotion`/player ONE literal `MovementManager` facade, so the residue is only the no-RemoteMotion class) | `src/AcDream.App/Rendering/GameWindow.cs` (TickAnimations MotionDoneTarget bind) | Motion for entities without a `RemoteMotion` (never UM/UP-touched) completes via the manager queue alone; nothing consumes their MotionDone until every sequencer-owning entity gets a host/`RemoteMotion` (doors DO have one since the R4-V5 door fix — first UM creates it) | An entity behavior depending on pending_motions bookkeeping in that no-RemoteMotion class (none known) would silently no-op | `CPhysicsObj::MotionDone` 0x0050fdb0; retire when every sequencer-owning entity constructs a `RemoteMotion`/host (post-M1.5 entity-class unification; R5-V5 closed the facade half) | +| AD-25 | Wall-bounce velocity reflection suppressed on landing (fires only airborne-before AND airborne-after); retail bounces unless grounded→grounded-and-not-sledding | `src/AcDream.App/Input/PlayerMovementController.cs:1212` | Our per-frame architecture amplifies the artifact (post-reflection +Z defeats the `Velocity.Z <= 0` landing-snap gate → micro-bounce death spiral); at elasticity 0.05 retail's landing bounce is imperceptible; sledding reverts to retail rule | Landing-reflection-dependent behavior (slope-landing momentum, high-elasticity surfaces) won't reproduce; the suppression masks the landing-snap gate fragility and could outlive its reason | `handle_all_collisions` pc:282699-282715; ACE PhysicsObj.cs:2656-2721 | +| AD-26 | Auto-walk arrival requires facing alignment (invented 5° arrive / 30° walk-while-turning bands); retail's check is `dist <= radius` exact | `src/AcDream.App/Input/PlayerMovementController.cs:575` | ACE does the final `Rotate(target)` server-side before the Use callback; without a local gate the body used items while facing away (user feedback 2026-05-15). Thresholds are NOT retail constants | Arrival delayed by the rotation phase; if heading convergence fights another yaw writer, `AutoWalkArrived` never fires and the queued Use/PickUp never completes | `MoveToManager::HandleMoveToPosition`; `apply_interpreted_movement` | +| AD-27 | Use/PickUp action re-sent on natural auto-walk arrival; retail sends the action once (server MoveToChain callback completes it) | `src/AcDream.App/Input/PlayerMovementController.cs:322` | ACE's server-side chain may have timed out by the time our body arrives; the close-range re-send hits ACE's WithinUseRadius fast-path | If the server's chain has NOT timed out, the action executes twice — door toggles open-then-closed, use-once interactions double-fire; protocol noise on non-ACE servers | ACE CreateMoveToChain / WithinUseRadius | --- -## 3. Documented approximation (AP) — 73 rows (AP-79 retired R5-V2 — the P4 TargetTracker adapter replaced by the ported TargetManager voyeur system; AP-82 added R5-V3 — sticky deep-overlap sign pin; AP-6 retired 2026-07-05 — full CCylSphere family ported verbatim, residual AP-83 added same commit) +## 3. Documented approximation (AP) — 34 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal` → `find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position | - +| AP-2 | Visual-AABB fallback collision shape for Setups with no retail physics data; retail emits NO shapes (phantom). **#101** fixed the GfxObj-only class; the Setup-without-shapes fallback remains | `src/AcDream.Core/Physics/PhysicsDataCache.cs:96` | Lets the player collide with decorative meshes shipping no CylSphere/part-BSP instead of walking through furniture-like props | Retail-phantom entities block movement (the **#100/#101** family), and the synthetic box gives non-retail push-out normals when it collides | `CPartArray::InitParts` (cited at PhysicsDataCache.cs:386-389) | | AP-3 | Step-down chain triggered only when contact is invalid OR steeper than walkable; retail's `transitional_insert` OK-path ALWAYS runs it | `src/AcDream.Core/Physics/TransitionTypes.cs:1197` | Conditional preserves the observed-to-matter cases (edge departure, steep cliff-slide) without running the chain every step (per pc:273191 agent reports) | Steps where retail runs step-down despite a valid walkable contact (bump maintenance, edge-slide arming) are skipped — float-off or missed edge slides in untested geometry | `transitional_insert` OK-path pc:273191 | | AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) | | AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 | +| AP-6 | Analytic swept-sphere cylinder collision (XY overlap + step-over + wall-slide) instead of retail CylSphere functions via the 6-path dispatcher; A6.P6 step-over branch ports `step_sphere_up`'s clearance check | `src/AcDream.Core/Physics/TransitionTypes.cs:2601` | Claimed to match retail for the exercised cases (trunks, NPC bodies, door foot-colliders); step-over and step_up_slide fallback retro-fitted from retail when the door phantom surfaced | Unported branches (push direction, interpenetration resolution) differ from retail against cylinder entities — the phantom-collision / sticky-NPC family | `CCylSphere::step_sphere_up` pc:324516-324538 | | AP-7 | `calc_friction` threshold 0.0 with retail's state gate missing; retail uses 0.25 gated by an undecoded state check | `src/AcDream.Core/Physics/PhysicsBody.cs:307` | Bumping the threshold without the gate hammered normal walking (3 → 0.16 m/s); as-read 0.0 kept; locomotion probably state-exempted in retail. Filed L.3c-followup | Friction engages under different conditions — post-landing slides, knockback decay, sledding speeds mismatch retail's deceleration | pc:276702-276705 (state gate + 0.25) | +| AP-8 | Remote MoveTo driver is a minimum viable subset: no target re-tracking, no sticky/StickTo, no fail-distance detector, no sphere-cylinder distance variant | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:44` | All server-side concerns the local body needn't model; ACE re-emits MoveTo ~1 Hz with refreshed origins, substituting for re-tracking | If the re-emit cadence assumption breaks (or sticky-follow packets appear), chase/flee motion visibly diverges — orbiting, overshoot, giving up where retail tracks | `MoveToManager::HandleMoveToPosition` 0x00529d80 | +| AP-9 | Fixed π/2 rad/s in-motion turn rate; per-creature TurnSpeed unwired | `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:77` | Matches ACE's monster TurnSpeed default; field hook documented for the future port | Creatures with non-default turn speeds rotate at the wrong rate — facing-correction mismatch vs retail observers | run_turn_factor 0x007c8914; `apply_run_to_command` 0x00527be0 | | AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) | | AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 | | AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) | | AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager | | AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) | | AP-15 | WeenieError translation table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs | -| AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) | +| AP-16 | Global nearest-8 viewer-distance light selection with 10% range slack (own r13 design); retail bound D3D lights per object/cell | `src/AcDream.Core/Lighting/LightManager.cs:10` | Honors retail's 8-hardware-light constraint while fitting a global-uniform shader; 1.1 slack is anti-pop hysteresis | With >7 nearby lights, different objects are lit than retail would light (retail's per-object pick can light a far object by ITS nearest lights); pop thresholds differ | r13 §12.2 (acdream design); retail D3D 8-light constraint | | AP-17 | Spell metadata from third-party CSV (3,956 rows, bad rows silently skipped), not the portal.dat SpellTable; Family feeds stacking decisions | `src/AcDream.Core/Spells/SpellTable.cs:10` | The dat spell-table port (obfuscated/encrypted aspects) wasn't done; CSV closed #11 fast and unblocked #6 stacking | Any CSV↔dat drift (wrong Family, missing rows) silently produces wrong buff-stacking winners and wrong panel info | portal.dat SpellTable 0x0E00000E | | AP-18 | Radar/indicator RGBA hand-tuned from screenshots; dispatch order ports `GetBlipColor` exactly but the real `RGBAColor_Radar*` static data is unrecovered | `src/AcDream.Core/Ui/RadarBlipColors.cs:33` | Color constants live in retail static data not yet extracted; comment invites tightening when recovered | Blip/indicator hues differ subtly from retail color cues | `gmRadarUI::GetBlipColor` 0x004d76f0; RGBAColor_Radar* (unrecovered) | | AP-19 | `PortalSideEpsilon` 0.01 (≈1 cm) instead of retail F_EPSILON ≈ 0.0002 — a documented render-root-lag tolerance, NOT a retail constant. DO-NOT-RETRY: T2 (BR-4) tried the retail value; CornerFloodReplay refuted it | `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs:49` | Retail's tight epsilon only works with eye-exact swept curr_cell tracking; our viewer cell lags the eye by up to ~1 cm at pressed corners. Tighten after the #108-membership family + cdstW near-clip pin land | A 1 cm misclassification band at portal planes can flood or cull a portal the eye hasn't crossed — one-frame leaks / grey flashes at knife-edge doorway/corner positions | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | | AP-21 | Entity translucency: two-pass alpha-test (N.5 Decision 2, invented 0.95/0.05 thresholds); AlphaBlend + Additive + InvAlpha all composite under (SrcAlpha, 1−SrcAlpha) — retail applies per-surface D3D blend incl. true additive. EnvCellRenderer + ParticleBatcher DO switch to additive; divergence confined to GfxObj/Setup entities via WbDrawDispatcher | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1563` (+ `Shaders/mesh_modern.frag:10`; #52 amendment removed the α≥0.95 discard) | Matches original WB's model; keeps the bindless MDI pipeline at two indirect draws; spec §6 documents the falsifiable fallback — a third indirect call with `glBlendFunc(SrcAlpha, One)` (~30 min) on a magic-content regression | Additive glow/magic entity surfaces composite darker / occlude instead of brightening — the predicted regression once spell VFX density increases; α<0.05 discard drops faint fringes retail blends | SurfaceType.Additive → D3DBLEND_ONE per-surface routing | | AP-22 | Invented `setup.Radius` cylinder (height = Height or Radius×2) for shapeless live entities; shape + height formula not from the retail shape walk | `src/AcDream.App/Rendering/GameWindow.cs:3250` | ShadowShapeBuilder (faithful walk) only emits CylSphere/Sphere/Part-BSP; the legacy cylinder preserves prior behavior so rare decorative props don't lose collision | Those props collide with an invented footprint (especially the Radius×2 height guess) — slides/blocks at non-retail distances | `find_obj_collisions` → `CPartArray::FindObjCollisions` pc:286236 | -| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + the speculative local TurnToObject/MoveToObject install through the player's MoveToManager (R4-V5; retail's client use flow issues the same manager calls, §9a/§9b). **R5-V3 narrowed it: the speculative install now threads the target's REAL setup radius/height (`GetSetupCylinder`, same as the wire mt-6 route) and the player's own radius is real — only the use-radius BUCKETS remain invented** (retail reads the object's UseRadius property) | `src/AcDream.App/Rendering/GameWindow.cs` (`InstallSpeculativeTurnToTarget`) | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for a completion that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius; `CPhysicsObj::TurnToObject/MoveToObject` callers §9a/§9b | -| AP-24 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:176` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) | +| AP-23 | Invented per-type use-radius heuristic (3 m creatures / 2 m doors-lifestones-portals-corpses / 0.6 m rest) for close-range gating + speculative turn-to-target | `src/AcDream.App/Rendering/GameWindow.cs:11120` | ACE broadcasts nothing actionable on the close branch (WithinUseRadius shortcut); the true radius arrives only on the far MoveToObject branch — a local stand-in is required (B.6) | A target whose real UseRadius differs from the bucket misjudges the gate — Use/PickUp deferred for an auto-walk that never comes, or fires early into a server "too far" | ACE Player_Move.cs:66; wire MoveToObject (type 6) carries the true radius | +| AP-24 | Jump charge fill rate guessed at 2.0 extent/s (full in 0.5 s); retail's divisor illegible (clobbered x87 in `GetPowerBarLevel`). Height→velocity formula is byte-faithful | `src/AcDream.App/Input/PlayerMovementController.cs:170` | Only time-to-fill diverges; 2.0/s matched retail muscle memory better than 1.0/s; targeted Ghidra decompile of 0x0056ADE0 already flagged (M2 research) | Every held-spacebar jump reaches a different extent than the same hold in retail — fence/gap jumps succeed/fail differently until the constant is recovered | FUN_0056ade0 (GetPowerBarLevel) | | AP-25 | Run/Jump skill pushed to movement = attributeBonus + Init + Ranks — no augmentations, multipliers, or vitae | `src/AcDream.Core.Net/GameEventWiring.cs:346` | Closest to ACE's CreatureSkill.Current short of porting the full Aug/Multiplier/Vitae chain (K-fix7/13) | A character with augs or post-death vitae predicts wrong local run speed / jump arc — dying would NOT slow the local player though the server moves them slower: drift + snap-back | ACE CreatureSkill.Current; ACE Skill.cs (Jump=22, Run=24) | | AP-26 | DDD interrogation answered with an empty dat-version list (count=0); retail reports actual dat iteration state | `src/AcDream.Core.Net/Messages/DddInterrogationResponse.cs:18` | ACE is satisfied by the empty ack; pattern from holtburger | A dat-patching-enabled server could push a full patch or reject on version mismatch — the lie is harmless only while the server never acts on it | DDD flow 0xF7E5/0xF7E6 | | AP-27 | PlayerDescription trailer: GameplayOptions skipped by a 4-byte-aligned heuristic scan for a valid inventory parse; options blob captured opaque, never decoded (retail decodes + applies UI options) | `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs:69` | Variable-length opaque blobs; mirrors holtburger's heuristics; follow-up issue extends when panels consume those sections | An options blob that coincidentally parses as a valid inventory (or inventory not landing at EOF) yields wrong/empty inventory+equipped at login; retail-persisted UI options silently ignored | ACE GameEventPlayerDescription.WriteEventBody; holtburger events.rs:195-218 | | AP-28 | 3D audio falloff via OpenAL InverseDistanceClamped with picked constants (ref 2 m, max 1000 m, rolloff 1); voice pool/eviction IS cited to retail | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:146` | Stands in for retail's DirectSound-era attenuation; r05 §5.3 documents inverse-square behavior but the three AL params were picked, not ported | Sounds attenuate at a different rate — too loud/quiet at range side-by-side; gain-driven eviction comparisons inherit the skew | FUN_00550ad0 (voice pool only); r05 §5.3 | | AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` | -| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1110` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | +| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 | | AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter | | AP-32 | Cell shells DRAW +0.02 m above the dat EnvCell origin (`ShellDrawLiftZ`, z-fight vs coplanar terrain); retail draws at the origin verbatim. Split invariant: PHYSICS + visibility graph UNLIFTED (f35cb8b, **#119**-residual), every DRAW-space consumer of portal/cell geometry LIFTED (OutsideView color gate via `Build(drawLiftZ)`, seal/punch fans — **#130**) | `src/AcDream.App/Rendering/GameWindow.cs:5604` (const at `PortalVisibilityBuilder.ShellDrawLiftZ`) | Shell floors coplanar with terrain z-fight in our z-buffered frame; the 2 cm lift is the documented stand-in | A new draw-space consumer of portal/cell polygons that forgets the lift re-opens a 2 cm seam at horizontal aperture edges (the #130 top-edge strip, ~7 px at 2.4 m); a visibility consumer that picks up the LIFTED transform re-opens the #119-residual horizontal-portal side-cull | retail draws cell geometry at the dat EnvCell origin (no lift) | | AP-33 | Interior-root look-in cells (**#124** sub-pass) draw their statics + DYNAMICS + emitters WHOLE — no per-part/per-object viewcone check; retail viewconeCheck's each vs the installed view (the **#131** portal closure: a server object in a look-in cell drew nowhere — dynamics-last culls cells absent from the main cone, and post-seal it z-fails anyway) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawBuildingLookIns`) | The main viewcone has no entries for look-in cells; over-include is the safe direction (z-correct, repainted outside apertures by the root's shells); look-in cell counts are small (~1-3 cells) | A few wasted draws on content outside the doorway region (repainted); no under-draw direction remains | `viewconeCheck` 0x0054c250; nested `DrawCells` objects pc:432878 | | AP-34 | Landscape-stage alpha deferral is a TWO-PHASE slice split (statics-early / dynamics+particles+weather-late around the **#124** look-ins) + outdoor-root attached scene emitters moved to the post-frame pass, not retail's single deferred alpha flush. Residual: building exteriors' / outside-stage dynamics' own translucent MESH batches still draw within their stage draw call (before later stage content) | `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (`DrawLandscapeThroughOutsideView` late loop) + `GameWindow` post-frame Scene pass | The MDI dispatcher draws translucency inside each Draw call; a faithful FlushAlphaList port needs a global deferred alpha list across all landscape draws — the split covers the user-visible cases (#131 portal swirl, #132 candle flame indoors + outdoors) | Translucent landscape content drawn early and screen-overlapped by content drawn later in the stage gets overpainted (no depth self-protection) — the portal-swirl/candle-flame class re-appears in the residual configurations | `D3DPolyRender::FlushAlphaList` (DrawCells pc:432722) | -| AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression, called from `GameWindow.OnUpdate` ~:7401) + `GameWindow:IsSealedDungeonCell` + `:OnLiveEntitySpawnedLocked`/`:OnLivePositionUpdated` (login/teleport pre-collapse hooks) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277 | -| AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/GameWindow.cs:10421` (`UpdateSunFromSky`, unchanged) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | -| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | -| AP-37 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Vitals number elements are meter children (not recursed); `VitalsController` attaches a centered `UiText` child for the cur/max number (Task 8 landed — retail `gmVitalsUI` uses `UIElement_Text`), so `UiMeter.Label` is no longer used for vitals (`UiText.Centered` reuses the meter's former centering formula → pixel-identical, user-confirmed). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port is deferred to Plan 2. Locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape renders an empty/wrong meter — no oracle diff until the Plan-2 port lands | `UIElement_Meter::DrawChildren` @0x46fbd0; `docs/research/2026-06-15-layoutdesc-format.md` | -| AP-38 | Chat transcript renders pre-split `ChatLog` lines 1:1; no in-element word-wrap at the panel's current pixel width | `src/AcDream.App/UI/UiText.cs` | Retail does in-element wrap via `UIElement_Text::SizeToFit`; our pre-split lines are always shorter than 440 px in practice; a line that overflows clips at the edge rather than wrapping | Very long server system messages (server shutdowns, broadcast announcements) clip rather than wrapping — no information loss, just visual truncation | `UIElement_Text::SizeToFit` @0x467980; `gmMainChatUI` layout | -| AP-39 | Chat lines carry one color per `ChatKind` (per-line solid color); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.App/UI/UiText.cs:13` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line per-kind coloring is the correct tonal palette and covers all existing chat types | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp) | -| AP-40 | Single default translucency for the chat window chrome; no focused/unfocused opacity transition; dat font face/size taken from the vitals `vitalsDatFont` (same dat font, not a chat-specific size lookup) | `src/AcDream.App/Rendering/GameWindow.cs` (chatController binding line) | Retail fades the chat window to ~80% alpha when unfocused (`gmMainChatUI::UpdateAlpha @0x4cdea0`); the opacity animation deferred to the Plan-2 window-manager input integration; sharing `vitalsDatFont` is safe — retail uses the same AC-default font for both | The chat window is always fully opaque/same-font rather than subtly fading when idle; no wrong text, but the focused/unfocused breathing rhythm is absent | `gmMainChatUI::UpdateAlpha` @0x4cdea0; `UCF::SetAceFont @0x4d3940` | -| AP-41 | Scrollbar thumb 3-slice cap fallback only: single-tile draw (`0x06004C63`) used only when `ThumbTopSprite`/`ThumbBotSprite` are unset; the chat controller passes all three cap ids so the 3-slice path is drawn in practice | `src/AcDream.App/UI/UiScrollbar.cs:35` | The fallback single-tile path is unreachable when caps are bound (chat controller always sets them); the 3-slice path is the active code path | Only if a future caller omits the cap ids will the fallback fire — no visual regression in the chat window | `UIElement_Scrollbar::UpdateLayout @0x4710d0`; cap sprites `0x06004C60` (top) + `0x06004C66` (bottom) from base layout `0x2100003E` | -| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 | -| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) | -| AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 | -| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` (incl. type-default underlay) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay). Opacity now matches retail (full). | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect. | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 | -| AP-48 | Inventory burden reads the player's wire `EncumbranceVal` (PropertyInt 5) when present — delivered by B-Wire (login PD-bundle `UpsertProperties` + live `PrivateUpdatePropertyInt 0x02CD`); `ClientObjectTable.SumCarriedBurden` remains only as a defensive fallback for when the server omits it. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`); `src/AcDream.Core.Net/ObjectTableWiring.cs` (player-int route) | B-Wire ports the retail read (`CACQualities::InqLoad` reads the server value); the client sum is now fallback-only, kept defensively. CONFIRM the server actually sends EncumbranceVal at the B-Wire visual gate, then DELETE this row. | If the server omits EncumbranceVal, the bar falls back to the client sum (the original drift) until the first 0x02CD — confirm at the gate. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 | -| AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) read from the player's wire property bundle when present (B-Wire PD `UpsertProperties`); defaults to 0 (correct for un-augmented characters) when absent. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | B-Wire delivers the player's full PropertyInt table (incl. `0xE6` when the server sets it) via `UpsertProperties`; aug=0 is the correct no-aug default. CONFIRM the server sends `0xE6` for an augmented char at the gate, then DELETE. | An augmented character whose server omits `0xE6` reads slightly low capacity (bar fills higher than retail); un-augmented chars are exact. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation | -| AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc | -| AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) draws the constant backpack icon via a hardcoded base-icon literal `0x0600127E` + `ItemType.Container` (composited over the Container type-underlay), rather than resolving it at runtime the way retail does. Sprite VISUALLY CONFIRMED (2026-06-22 live gate). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`, main-pack cell) | Retail's `IconData::RenderIcons` IsThePlayer branch draws a CONSTANT backpack with `m_itemType = TYPE_CONTAINER` (the original AP-51 "equipped-pack weenie icon" premise was wrong). The exact runtime resolution is unconfirmed: a research dat-dump misreported `GetDIDByEnum(0x10000004,7)` as the green tile `0x060011F4` (which rendered green-no-pack at the gate); the real backpack `0x0600127E` was identified by dat export + user confirmation. We pin the visually-verified literal (cf. AP-55). Retire when the runtime resolve is reproduced + confirmed. | If the pinned sprite diverges from what retail resolves at runtime, the main-pack icon goes stale. | `IconData::RenderIcons` IsThePlayer branch 0x0058d1ee (decomp 407546-407549); sprite `0x0600127E` dat-exported + visually confirmed | -| AP-52 | Side-bag column slot count defaults to 7 (the dat column height) when the player's `ContainersCapacity` is absent/0. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ContainersCapacity` is not reliably on the player ClientObject yet; 7 is the standard side-pack cap + the dat column (`0x100001CA`, 36×252) holds exactly 7. | An 8th-pack (Shadow of the Seventh Mule aug) character shows one too few side-bag slots — cosmetic. | retail gmBackpackUI side-pack column; ACE `ContainersCapacity` | -| AP-53 | Contents grid slot count defaults to 102 (main pack) or 24 (side bag) when the open container's `ItemsCapacity` is absent/0. Container-switching is now wired (D.2b): clicking a side bag sends `Use 0x0036` and the grid repopulates from `ViewContents 0x0196`. This row covers only the capacity *default* when the server omits `ItemsCapacity`. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | `ItemsCapacity` is not reliably on every ClientObject yet; 102/24 are the retail standard capacities. Retire when `ItemsCapacity` is confirmed delivered for all containers. | A non-102/24 pack shows the wrong empty-slot count — cosmetic only. | retail main-pack capacity 102; side-pack 24; ACE `ItemsCapacity`; `ViewContents 0x0196` | -| AP-54 | Inventory window vertical resize is a toolkit approximation: bottom-edge drag, expand-only (Min = dat default 372 px, Max = 560 px constant), contents grid/sub-window/scrollbar/backdrop stretched via overridden `Left\|Top\|Bottom` anchors. | `src/AcDream.App/Rendering/GameWindow.cs` (inventory frame setup) | retail's gmInventoryUI resize lives in keystone.dll (no decomp); the anchor-stretch + 372..560 range matches the observed retail behavior for the dev loop. | The expand range / which elements reflow could differ from retail (e.g. retail may allow shrink-below-default); shrink is intentionally disabled for now. | retail gmInventoryUI resize (keystone.dll, no decomp); `UiNineSlicePanel` resize | -| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` | -| AP-57 | The open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail keying (`item.itemID == openContainerId / selectedItemId` per `UpdateOpenContainerIndicator 0x004e3070` / `ItemList_SetSelectedItem 0x004e2fe0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/InventoryController.cs` | The procedural overlay produces the correct visual result; the 36×36 container prototype (`0x1000033F`) lacks the square child, so the procedural overlay is the only way to show the square on a selected bag — the dat-state path retail uses would require the prototype to be richer than it is. | A cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail for the open/selected indicator cases. | `UIElement_ItemList::UpdateOpenContainerIndicator` 0x004e3070; `ItemList_SetSelectedItem` 0x004e2fe0; `SetOpenContainerState` 0x004e1200; `SetSelectedState` 0x004e1240 | -| AP-58 | Inventory item **selection is panel-local + visual-only** — clicking a grid item moves the green square but does not wire to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`SelectItem`) | The selected-object bar + 3D-world selection wiring is deferred to the selection follow-up; the panel-local square is the correct first step. | Selecting an inventory item doesn't populate the selected-object strip as retail does — functional gap deferred. | `gmInventoryUI`/`gmBackpackUI` item-select → `ClientUISystem::SetSelectedObject`; `SelectedObjectController` | -| AP-59 | The per-cell container **capacity bar** (retail `UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0`, element `0x10000347`) is drawn as a **procedural `UiItemSlot` overlay** (track `0x06004D22` full + fill `0x06004D23` clipped bottom-up to `GetContents(guid).Count / ItemsCapacity`), not via a real dat `UIElement_Meter` child. **Right-anchored flush** (the dat rect X=26 in a 36px cell sat ~5px off the edge — flush per the visual gate) and **bottom-up fill assumed** (the dat `m_eDirection` 0x6f isn't read, cf. AP-50). A CLOSED side bag reads empty until opened (its contents aren't indexed until `ViewContents`) — faithful to retail's known-children count, divergent if retail pre-loads. | `src/AcDream.App/UI/UiItemSlot.cs` (`CapacityFill` draw); `src/AcDream.App/UI/Layout/InventoryController.cs` (`SetCapacityBar`) | UiItemSlot is a behavioral leaf that paints overlays procedurally (cf. AP-57); the meter sprites + fill formula are the faithful port. Right-anchor + bottom-up were visual-gate calls. Further visual polish is deferred — ISSUES #146. | Fill direction / exact bar rect could differ from retail's `m_eDirection` + dat X; closed-bag bars read empty if retail pre-loads container counts. | `UIElement_UIItem::UpdateCapacityDisplay` 0x004e16e0; element `0x10000347` (back `0x06004D22` / front `0x06004D23`); `GetNumContainedItems` | -| AP-60 | Inventory drag **`OnDragLift` is a no-op** + the source cell is **not dimmed**: retail dims the lifted item's source cell (`RecvNotice_ItemListBeginDrag`); acdream leaves the item in place during the drag + the floating ghost. | `src/AcDream.App/UI/Layout/InventoryController.cs` | Cosmetic only — the dragged item is still unambiguously identifiable; dimming the source requires tracking the source cell reference across drag events, deferred to a polish pass. | A drag in progress doesn't visually mark its origin — cosmetic only, no functional effect. | `RecvNotice_ItemListBeginDrag` acclient_2013_pseudo_c.txt | -| AP-61 | Drop on a **CLOSED side bag is advisory-accept**: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`) | A drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Faithful only when the bag has been opened (then `GetContents` is populated). The server's `0x00A0` + optimistic rollback is the authoritative safety net. | A drop into a closed-but-full bag shows green → brief flicker → snap-back instead of retail's pre-emptive red circle. | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` | -| AP-62 | **MissileAmmo slot mask LIKELY** — `0x100001E0 → MissileAmmo 0x800000` is inferred; the decomp immediate at `GetLocationInfoFromElementID` 173676 is corrupted to a string pointer, so the mapping was not directly confirmed from the named-retail source. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`SlotMap`) | Dropping ammo onto that slot wields to the wrong location if the mapping is wrong. Gate-verify via dat dump + cdb. | Ammo wields to the wrong equip location — functional gap if wrong. | `GetLocationInfoFromElementID` decomp 173676; `acclient.h:3193` INVENTORY_LOC | -| AP-63 | **Dual-wield-into-shield-slot special not implemented** — retail `OnItemListDragOver` (decomp 174302) lets a melee-capable item also drop on the Shield slot; acdream's `wieldMask = ValidLocations & slotMask` rejects it. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`HandleDropRelease`) | A dual-wielder cannot off-hand a melee weapon via the doll. | Dual-wield via drag-onto-shield-slot is blocked — functional gap for dual-wield characters. | `gmPaperDollUI::OnItemListDragOver` decomp 174302 | -| AP-64 | **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise corrected by the next authoritative message. Gate-verify via WireMCP. | `src/AcDream.Core.Net/GameEventWiring.cs` (0x00A0 handler) | If ACE uses a different reject opcode for wield, the optimistic state is left dangling until the next full update. | Rejected wield briefly shows the item as equipped in the doll — cosmetic flicker or stuck state if `0x00A0` is not the rejection path. | `InventoryServerSaveFailed 0x00A0`; WireMCP gate-verify | -| AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` | -| AP-66 | **Empty equip slots show a generic frame (Slice 1) instead of the retail doll-backed transparent look** — retail's slot-view leaves empty armor slots transparent with the live 3-D doll body behind them; Slice 1 has no doll yet, so empty slots render a visible frame (`0x06004D20`, the inventory grid's empty square) so every position is seen + usable. The "figure" in the paperdoll is the doll, NOT a per-slot silhouette. | `src/AcDream.App/UI/Layout/PaperdollController.cs` (`emptySlotSprite`); `src/AcDream.App/Rendering/GameWindow.cs` (PaperdollController.Bind) | Retired in Slice 2 when the doll `UiViewport` (`0x100001D5`) renders behind the slots and the empty slots flip to transparent (`EmptySprite=0`). | Empty equip slots look like plain framed squares instead of revealing the character body — cosmetic, pending Slice 2's doll viewport. | `gmPaperDollUI` init `SetVisible(0)` per slot; user retail screenshots (slot-view) | -| AP-67 | Server-spawned weenie fixtures (lanterns, braziers, glowing items carrying `Setup.Lights`) register their lights at their SPAWN-frame world position via `RegisterOwnedLight` in `OnLiveEntitySpawnedLocked` (mirroring the dat-static path in `ApplyLoadedTerrainLocked`); the light does NOT follow the object if it later moves. Torn down on despawn/respawn by the now-unconditional `UnregisterOwner` in `RemoveLiveEntityByServerGuid`. | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawnedLocked` weenie-light block; `RemoveLiveEntityByServerGuid`) | Closes the prior gap where ONLY dat-baked statics registered lights, so server-placed lanterns cast nothing; the overwhelming majority of light-bearing weenies (wall lanterns, braziers, candelabra) are stationary, so spawn-frame placement matches retail; retail's `add_dynamic_light` re-places the light from the object frame each frame | A moving light-bearing weenie (e.g. a torch-carrying NPC) leaves its light at the spawn position instead of following — rare; the faithful fix is to re-place on `OnLivePositionUpdated` | retail object-borne lights `insert_light` 0x0054d1b0 / `add_dynamic_light` 0x0054d420 (per-frame object frame) | -| AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` | -| AP-69 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render | -| AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) | -| AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 | -| AP-72 | **Per-element dat-font resolver (Fix C) wired into the STUDIO path only** — `LayoutImporter.Import` + `DatWidgetFactory` support a `Func` resolver that gives each text element its own dat font; `StudioWindow` supplies it. `GameWindow`'s four `Import`/`Build` call sites pass `null`, so the live game still uses a single global font. **Retire with #157.** | `src/AcDream.App/Rendering/RenderBootstrap.cs` (`RenderStack.ResolveDatFont` — pattern to mirror into GameWindow) + `src/AcDream.App/Studio/StudioWindow.cs` (resolver wired) + `src/AcDream.App/Rendering/GameWindow.cs` (~4 import sites pass null) | Studio is the proving-ground path; GameWindow threading is Issue #157 (small, safe, orthogonal to M5 gameplay). The live panels still render with the global dat font (same as before Fix C), so behavior is unchanged in the shipped game. | Text elements whose dat `FontDid` differs from the global font render with the wrong size/face in the live game — cosmetic; the studio shows the correct dat font. | `DatWidgetFactory.BuildText`; `LayoutImporter.Import(fontResolver)`; commit `a0d3395` (Fix C) | --- -| AP-75 | **Adapter-boundary `adjust_motion` + locomotion velocity/omega synthesis**: `SetCycle` still (a) remaps TurnLeft/SideStepLeft/WalkBackward to their mirrors with negated speed BEFORE dispatch (retail adjusts in `CMotionInterp` — R3 scope; GameWindow's local-player path passes raw ids), and (b) post-dispatch overwrites sequence velocity (low-bytes 05/06/07/0F/10) and omega (0D/0E, only when dat-silent) with the retail locomotion constants — retail drives BODY velocity from `get_state_velocity`, not the sequence accumulators (R2-Q4 carry-over of the pre-Q4 adapter tail) | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`SetCycle` head remap + tail synthesis) | (a) preserves unadjusted GameWindow callers until R3-W6 unifies the local player onto MotionInterpreter; (b) preserves the remote-DR and local Option-B velocity consumers until R6 root motion drives the body (sibling of IA-3) | (a) none while callers stay in the known set; (b) a dat whose locomotion MotionData carries a REAL velocity different from the constants gets overwritten — exotic-creature speed skew (same class as IA-3's risk) | `CMotionInterp::adjust_motion` @305343; `get_state_velocity` 0x00528960; retire (a) R3-W6, (b) R6 | -| AP-76 | **Remote rotation from the ObservedOmega side-channel**: the R2-Q5 sink callbacks (`MotionTableDispatchSink.TurnApplied/TurnStopped`) seed `RemoteMotion.ObservedOmega = (0,0,-(pi/2)*signedTurnSpeed)` on wire turns and zero it on turn-stops; GameWindow's per-tick step applies ObservedOmega (preferring it over sequence omega) to the remote body's orientation. Retail rotates the body from the SEQUENCE omega inside the per-tick apply_physics chain (CSequence::apply_physics -> CPhysicsObj omega integration) (carried verbatim from the deleted RemoteMotionSink; H17) | `src/AcDream.App/Rendering/GameWindow.cs` (sink callbacks in OnLiveMotionUpdated + the omegaToApply step in TickAnimations) | Same angular rate retail derives (pi/2 rad/s x turn speed); starts rotation the same tick as the wire turn without waiting for R6's per-tick order; UpdatePosition orientation snaps bound any drift | If the dat's turn modifier omega differs from the pi/2 constant, remote rotation rate diverges until an orientation snap; double-application risk if R6 lands apply_physics-driven rotation without deleting this seam | retire in R6 (retail per-tick order: apply_physics drives remote rotation) | -| AP-77 | **`apply_current_movement`'s interpreted-branch tail (`ApplyCurrentMovementInterpreted`) writes body velocity DIRECTLY via `get_state_velocity`/`set_local_velocity` when grounded, instead of dispatching through an `IInterpretedMotionSink`** — retail's real `apply_interpreted_movement` (0x00528600) drives velocity indirectly through `DoInterpretedMotion`'s animation-table backend (the SAME function the funnel's `ApplyInterpretedMovement` already uses WITH a sink); this dual-dispatch call site (`HitGround`/`LeaveGround`/`ReportExhaustion`/hold-key toggles/`SetWeenieObject`/`SetPhysicsObject`) has no sink threaded through it | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`ApplyCurrentMovementInterpreted`) | Direct continuation of the pre-R3-W4 `apply_current_movement` approximation (R3-W4 plan explicitly keeps this shape rather than relocating it — "the direct grounded-velocity write MOVES to the controller-side call site unchanged"); correct for the grounded, no-animation-table-yet state acdream is in today | A MotionTable whose locomotion cycle bakes a DIFFERENT velocity than `get_state_velocity`'s constants would silently diverge from what the animation actually plays, since this path never touches the animation backend at all | `CMotionInterp::apply_interpreted_movement` 0x00528600; retire when a sink is threaded through `apply_current_movement`'s callers (R6 root motion) | -| AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables (R4-V4 note; pre-existing mechanism, row added per the V4 plan) | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | -| AP-81 | **Remote-DR gravity toggled via the Gravity STATE bit**: the jump handler sets `Body.State \|= Gravity` at VectorUpdate and both landing blocks clear it after `HitGround()`; retail keeps GRAVITY set for the object's whole life and gates gravity ACCELERATION on the Contact transient (`calc_acceleration`) (pre-existing K-fix9/K-fix15 mechanism, row added during #161 — which also fixed the ordering so `Motion.HitGround()`'s verbatim `state&0x400` gate runs BEFORE the clear) | `src/AcDream.App/Rendering/GameWindow.cs` (VectorUpdate jump handler + the two landing blocks) | The DR tick integrates gravity only for airborne remotes; the flag dance delivers exactly that without porting the full contact-gated `calc_acceleration` chain; the #161 ordering fix keeps the retail HitGround contract satisfied | Any NEW call into `Motion.HitGround`/`LeaveGround` placed after the clear silently no-ops on the gravity gate (the #161 leg-2 class); grounded remotes carry a non-retail state word (probes comparing state bits vs retail mislead) | `CPhysicsObj::calc_acceleration` (contact-gated); `set_on_walkable` 0x00511310; retire in R6 (contact-gated accel + persistent GRAVITY) | -| AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | -| AP-85 | **⚠️ CORRECTED 2026-07-06: visible-cell scoping SHIPPED (`LightSource.CellId` from `entity.ParentCellId` + `BuildPointLightSnapshot(camPos, visibleCells)` over the portal-flood set; probe-proven to drop ~285 through-floor lights/frame in the Hub; end-state pin un-skipped). The visual gate REFUTED the light-cap #176/#177 attribution stated below — BOTH symptoms were UNCHANGED, so #176 = an intensity-100 purple point light `(0.784,0,0.784)` and #177 = a portal-visibility miss (see digest banner). Residual deviation now: 128 backstop vs retail 40+7 (0x0081ec94/8), no dynamic-priority split — benign (visible-scoped pool is 1–9). HISTORICAL claim below.** **Per-frame flat point-light snapshot capped at the 128 lights nearest THE CAMERA** (`BuildPointLightSnapshot`); retail registers lights per-CELL (`insert_light` 0x0054d1b0) and `minimize_object_lighting` (0x0054d480) consults the reaching set with NO global pool cap. The cap BITES in the Facility Hub (366 registered fixtures → 238 evictions/frame) and the eviction is the CONFIRMED mechanism of #176 (purple seam flash — an in-range torch of a visible cell ranks past the cap and drops from that cell's 8-set; per-cell Gouraud pops as the camera moves) + #177 (a stair room's fixtures all past the cap render it 0.2-ambient-dark until approach). ⚠️ Raising to 1024 was live-tested 2026-07-06 and REVERTED: the uncapped pool exposes (a) light-through-solid-floors (no per-cell reach/occlusion — the under-room portal light washes the corridor above), (b) stationary weenie fixtures on the DYNAMIC 1/d falloff (~9× retail's static 1/d³ at 3 m; #143 misassignment for ACE-served fixtures), (c) an unexplained striped floor artifact. Fix = the A7 arc: per-cell light registration + static curve for fixtures + the stripe hunt, THEN uncap | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights` — the load-bearing-stopgap comment); desired-end-state pin (Skip) `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` | The 128 cap keeps the light pool local to the camera, which accidentally APPROXIMATES per-cell reach (far lights can't leak through floors into view) — the least-wrong state until A7 ports real per-cell registration | The #176/#177 pop class stays live until A7 (purple flashes at seams; unlit rooms popping lit on approach); any dungeon with >128 fixtures has camera-dependent per-cell lighting | `minimize_object_lighting` 0x0054d480 (no global pool cap); `insert_light` 0x0054d1b0 (per-cell registration); `calc_point_light` 0x0059c8b0 (static 1/d³ bake curve) | -| AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose (the closed pose for doors — `GameWindow.MotionTableDefaultPose`); retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Rendering/GameWindow.cs` (`MotionTableDefaultPose` + the RegisterServerEntityCollision override); `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | -| AP-83 | **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). NOT exercised in M1.5: no mover sets PerfectClip (players never do; the non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified). Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail is dead code until missiles arm PerfectClip | If missiles (F.3) arm PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping missiles | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | - -## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded) +## 4. Temporary stopgap (TS) — 30 rows | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain | | TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 | | TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall | -| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | -| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 | +| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place | `src/AcDream.Core/Physics/BSPQuery.cs:2001` | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | +| TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2) | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | | TS-8 | `MagicUpdateEnchantment` (0x02C2) records carry no StatMod — mid-session buffs don't move vital max until relog (**#7/#12**) | `src/AcDream.Core/Spells/Spellbook.cs:150` | The wire parser hasn't been extended to the full ~60-64 byte Enchantment payload; PlayerDescription's block IS parsed | Vitals HUD percent reads differently from retail for the whole session after any buff cast | `EnchantAttribute` 0x00594570; holtburger magic/types.rs | @@ -207,28 +157,16 @@ accepted-divergence entries (#96, #49, #50). | TS-18 | `LandCell.BuildingCellId` (CSortCell building bridge) declared but never populated — always null in Stage 1 | `src/AcDream.Core/World/Cells/LandCell.cs:19` | Cell graph shipped in stages; population is explicitly membership Stage 2 (the outdoor→indoor entry path the physics digest flags as unvalidated) | Cell-graph paths that should discover a building's EnvCells from the outdoor cell silently find nothing — the doorway-entry bug class | CSortCell (acclient.h:31880) | | TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) | | TS-20 | GfxObj polys drawn by dictionary iteration, not DrawingBSP traversal (**#113**): physics/no-draw polys referenced by no BSP node render as visible surfaces; the `CollectDrawingBspPolygonIds` filter exists (:1004) but is NOT applied (naive walk made doors disappear, `e46d3d9` un-applied, user-gated 2026-06-11) | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1027` | Correct fix is full BSP-traversal-order drawing per the holistic port handoff (docs/research/2026-06-11-building-render-holistic-port-handoff.md); the id filter must first be diagnosed on a door GfxObj (Issue113PhantomStairsDumpTests) | Phantom geometry visible NOW (Holtburg meeting-hall "staircase" wall ramp 0x010014C3; 8 orphan polys on hill cottage 0x01000827); draw order also diverges from retail's BSP order | D3DPolyRender drawing-BSP traversal; ConstructMesh 0x0059dfa0 | -| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands (the stale "we don't parse yet" comment was FIXED in R4-V5; K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:311` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 | -| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1177` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 | -| TS-24 | RawMotionState action list always empty at runtime — the packer emits `num_actions` (bits 11–15) + per-action u16 pairs (L.2b, `RawMotionState::Pack` 0x0051ed10), and R3-W1 gives `RawMotionState`/`InterpretedMotionState` the retail-faithful action FIFO (`AddAction`/`RemoveAction`/`ApplyMotion`/`RemoveMotion`, `src/AcDream.Core/Physics/RawMotionState.cs` + `MotionInterpreter.cs`), but nothing calls `AddAction` yet — the outbound caller still builds an empty `Actions` list, so discrete motion events (emotes, one-shots) are still never broadcast | `src/AcDream.App/Rendering/GameWindow.cs:8297` (empty Actions); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:91`; FIFO capability `src/AcDream.Core/Physics/RawMotionState.cs` | Discrete client-initiated motions (D2) not wired yet; packer-ready, state-ready (W1), runtime emission lands with R3-W2's `add_to_queue`/`DoInterpretedMotion` population | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | `RawMotionState::Pack` 0x0051ed10; num_actions `PackBitfield` acclient.h:46487 | -| TS-25 | `current_style` (stance, flag bit 0x2) never populated at runtime — the packer now emits it when it differs from the retail default 0x8000003D (L.2b), but the outbound caller leaves `CurrentStyle` at default (stance not tracked here) | `src/AcDream.App/Rendering/GameWindow.cs:8286` (CurrentStyle left default); packer `src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs:80` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | `RawMotionState::Pack` current_style 0x0051ed10 | -| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets. (The 0xF74C UpdateMotion side of this gap CLOSED 2026-07-02 — L.2g S1 `MotionSequenceGate` ports the retail instance/movement/server-control gate; the UP side + teleport/force-position stamps remain open) | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write); `CPhysicsObj::newer_event` 0x00451b10 | +| TS-21 | Default run/jump skills 200/300 tuned to feel until the first PlayerDescription lands; "we don't parse yet" comment is STALE (K-fix7 parses PD → SetCharacterSkills) | `src/AcDream.App/Input/PlayerMovementController.cs:341` | Defaults rule only pre-PD or on PD parse failure; jump bumped 200→300 on user complaint (3.01 m max felt too low) | Any window with defaults live predicts run/jump speeds the server disagrees with — observer rubber-banding, local snap-backs | retail height = (skill/(skill+1300))×22.2 + 0.05 | +| TS-22 | `adjust_motion` not ported — backward (×−0.65) / strafe (×−1) translation hand-mirrored at controller call sites; `get_state_velocity` returns (0,0,0) for backward/strafe-left | `src/AcDream.App/Input/PlayerMovementController.cs:1021` | Duplication exists because LeaveGround through the unported path wiped strafe/backward jump velocity (straight-up backward jumps) | Any NEW `get_state_velocity` consumer during backward/strafe motion silently gets zero velocity (the exact prior bug class); hand-mirrored formulas can drift from the grounded block they copy | FUN_00528010 (adjust_motion); FUN_00528960 | +| TS-23 | PK/PKLite/Impenetrable mover bits never set (PlayerKillerStatus not parsed from PD); moverFlags always `IsPlayer ∣ EdgeSlide` | `src/AcDream.App/Input/PlayerMovementController.cs:1128` | Non-PK pair walks through other non-PK players — retail's default for ACE's character-creation defaults too | On a PK/PKLite character, local client lets players walk through where retail collides — prediction vs server disagree the moment PvP statuses enter play | PWD._bitfield acclient.h:6431-6463; pc:406898-406918 | +| TS-24 | RawMotionState command list always empty (bits 11-31 = 0) — discrete motion events (emotes, one-shots) never packed outbound | `src/AcDream.Core.Net/Messages/MoveToState.cs:34` | Discrete client-initiated motions aren't implemented yet; documented builder scope | When player-triggered emotes land, they silently never broadcast — observers see idle while the local client animates | RawMotionState pack (holtburger types.rs) | +| TS-25 | `FlagCurrentStyle` (stance, bit 0x2) never written to outbound MoveToState | `src/AcDream.Core.Net/Messages/MoveToState.cs:130` | Stance switching is M2 combat scope | Once combat-mode switching ships, mid-stance MoveToStates omit the style — server/observers keep the stale stance, wrong cycle family for every subsequent movement | RawMotionFlags CurrentStyle 0x2 (holtburger) | +| TS-26 | UpdatePosition's four u16 sequence numbers parsed but never checked for freshness; retail rejects stale/out-of-order packets | `src/AcDream.Core.Net/Messages/UpdatePosition.cs:30` | Loopback ACE rarely reorders, so the gap is invisible in the dev loop | On a real network, a reordered/post-teleport straggler applies as-is — remotes snap backward / flicker; a teleport-vs-position race renders an entity in the wrong cell | PositionPack trailer (ACE PositionPack.cs::Write) | | TS-27 | Retransmit handling absent: `RetransmitRequests`/`RejectRetransmit` parsed, but nothing re-sends lost outbound or requests missing inbound sequences (class-doc gap list otherwise stale — ack/position/chat exist) | `src/AcDream.Core.Net/WorldSession.cs:29` | Deferred since the one-shot test harness; dev loop is loopback (no loss) | On any lossy link a dropped fragment is gone forever — entities never spawn, chat vanishes, reassembly stalls; server retransmit requests ignored until session timeout. Stale doc list also misleads readers | PacketHeaderFlags RequestRetransmit 0x1000 / Retransmission 0x1 | | TS-28 | LoginComplete sent on PlayerCreate (0xF746) arrival; retail sends it after the portal-space transition animation finishes (no such animation exists yet) | `src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs:30` | acdream has no portal-space animation; "InWorld" phrasing in the file is slightly stale (trigger is PlayerCreate) | Server flips the character out of the loading state and pushes initial updates while the client may still be streaming — server logic assuming retail's load-screen duration fires against a half-initialized client | retail post-EnterWorld flow (holtburger messages.rs:391-422) | | TS-29 | Background music (MIDI) + ambient loops not ported: PlayMusic/StopMusic no-op; StartAmbient reserves a handle that never plays | `src/AcDream.App/Audio/OpenAlAudioEngine.cs:331` | Explicitly outside R5 audio-phase scope; a landblock-attached ambient system is planned separately | Silent world where retail has music/atmosphere; code trusting StartAmbient's handle to mean "playing" is already subtly wrong (StopAmbient looks up a never-created source) | retail MIDI + ambient system (r05) | -| TS-30 | Numbered chat tabs (element ids `0x10000522`–`0x10000525`) render as clickable buttons but do not switch channel filter or affect the transcript — tab state is a no-op | `src/AcDream.App/UI/Layout/ChatWindowController.cs:210` | Retail's tab switching routes transcript lines by chat channel (`gmMainChatUI::gmScrollWindow` sub-windows per tab); the tab wiring is D.5 scope | Tab clicks produce no visible transcript change; retail would filter to the selected channel — all chat always shows in all tabs | `gmMainChatUI::PostInit` tab setup @0x4ce2a0; holtburger chat tab handling | -| TS-31 | Squelch toggle absent (no `/squelch` slash command, no clickable name-tags to silence); retail's squelch list filters incoming chat lines | `src/AcDream.Core/Chat/ChatLog.cs` | Squelch is a social / moderation feature deferred to post-M1.5; the data structure (`ChatLog`) has no squelch set today | Any player can spam all clients; clickable-name-tag contextual menu (used in retail to squelch, tell, add-to-friends) is absent | `ChatFilter::IsSquelched`; retail right-click player name → Squelch menu | -| TS-32 | `ClientObjectTable` has no pre-queue for a child `CreateObject` that arrives before its parent (out-of-order PARENTED create); such objects are ingested as root objects and their `ContainerId` links a not-yet-known container. Retail's `null_object_table` + `null_weenie_object_table` hold unresolvable objects until the parent arrives | `src/AcDream.Core/Items/ClientObjectTable.cs` (`Ingest`) | PD↔`CreateObject` ordering is handled (upsert semantics); out-of-order PARENTED creates are observed only at high packet loss or in vendor/corpse multi-object bursts on non-loopback links; deferred to D.5.5+ | A container's child object arriving before the container is ingested as a root item — it won't appear in `GetContents` until the next `RecordMembership` or a move event corrects the parent link | `CObjectMaint::null_object_table` / `null_weenie_object_table` (acclient.h / named-retail pc) | -| TS-33 | Outbound position-send tracker over-stamped after MoveToState: `NotePositionSent` writes last-sent position + cell + contact-plane after BOTH the MTS and AP sends, but retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` after an MTS — only `SendPositionEvent` (0x006b4770, the AP path) stamps all three. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where retail uses `ShouldSendPositionEvent` (0x006b45e0 — interval OR cell-change OR contact-plane-change), AND acdream's frame-changed diff compares POSITION only (`ApproxPositionEqual`) where retail's `Frame::is_equal` compares the full frame incl. ORIENTATION — a stationary heading change (R4-V5: the MoveToManager's `HandleTurnToHeading` arrival snap, `set_heading(send:true)`) never triggers an AP, so the server keeps the stale facing until the player next moves. Masked against ACE (ACE rotates server-side on its own mt-8/9 / close-range-use paths and broadcasts the result) | `src/AcDream.App/Rendering/GameWindow.cs:8331` (MTS `NotePositionSent`); `src/AcDream.App/Input/PlayerMovementController.cs` (`ApproxPositionEqual` + the heartbeat diff); the player MoveToManager `setHeading` seam in `EnterPlayerModeNow` (the `send` flag's would-be consumer) | D5 audit-only in the L.2b wire-parity slice; the full cadence port (`ShouldSendPositionEvent` gate + split MTS/AP stamping + full-frame `Frame::is_equal` diff) is a dedicated follow-up slice (R7 outbound) | AP heartbeat cadence diverges from retail — acdream may suppress or reorder autonomous-position sends differently after movement-state sends, and a stationary server-commanded turn leaves observers with stale facing until the next movement; on a real network this shifts the position-correction rhythm | `CommandInterpreter::SendMovementEvent` 0x006b4680, `SendPositionEvent` 0x006b4770, `ShouldSendPositionEvent` 0x006b45e0, `Frame::is_equal` (pc:700263) | -| TS-40 | Retail's `physics_obj->cell` ("placed in the world") is proxied by the explicit `PhysicsBody.InWorld` flag — set by `SnapToCell` (local player placement) and `RemoteMotion` construction (remotes exist only for world entities); consumed by `CMotionInterp`'s detached-object link-strip guards (`if (cell == 0) RemoveLinkAnimations`, raw @305627). Replaces the UNREGISTERED `CellPosition.ObjCellId == 0` proxy, which only the local player ever seeded (#145 `SnapToCell`), so every REMOTE body read "detached" and every dispatched transition link (door swings, remote walk↔run links) was stripped the same tick it was appended — the 2026-07-03 door-snap bug | `src/AcDream.Core/Physics/PhysicsBody.cs` (`InWorld`); `src/AcDream.Core/Physics/MotionInterpreter.cs` (3 guard sites) | acdream has no per-body CObjCell pointer; a boolean placement flag carries exactly the guard's retail meaning until cell-pointer plumbing exists | A body used without either placement path (a future entity class constructing bodies directly) reads detached and loses transition links until its creation site sets the flag | `CMotionInterp::DoInterpretedMotion` 0x00528360 tail @305627; `CPhysicsObj::RemoveLinkAnimations` | -| TS-35 | `PhysicsBody.IsFullyConstrained` is a stub property (default `false`, never set by any physics code), read by `jump_is_allowed`'s verbatim `IsFullyConstrained` gate (raw 305524-305525) | `src/AcDream.Core/Physics/PhysicsBody.cs` (`IsFullyConstrained`) | R3-W3 needed the read site to port `jump_is_allowed`'s full chain. **R5-V1 CORRECTED the mechanism** (the earlier "per-cell contact-plane / doorway-jamming" guess was WRONG): the write side is the **ConstraintManager server-position rubber-band leash** — armed by `SmartBox::HandleReceivedPosition` on every inbound server position, `IsFullyConstrained` = `max*0.9 < offset`. R5-V1 ported `ConstraintManager` (`src/AcDream.Core/Physics/Motion/ConstraintManager.cs`) but does NOT arm it (no acdream `SmartBox` + two x87 distance constants BN elided) — so this read stays false. Arming = issue #167 | A body retail would consider fully constrained (still rubber-banding toward a server position inside the tight leash) never refuses the jump (0x47) — a jump succeeds mid-rubber-band where retail blocks it. Low practical risk (the leash band is tight + short-lived) | `CPhysicsObj::IsFullyConstrained` 0x0050ec60 → `ConstraintManager::IsFullyConstrained` 0x005560d0; `jump_is_allowed` 0x005282b0; arming `SmartBox::HandleReceivedPosition` 0x00453fd0 (issue #167) | -| TS-37 | RETIRED misattribution note (not a live divergence — kept here as the historical record R3-W3 closes): the S2a port had `contact_allows_move` (0x00528240) arm `StandingLongJump` as a side effect, explicitly flagged "PRE-EXISTING acdream side effect (not part of 0x00528240)". R3-W3 deletes that side effect; `ChargeJump` (0x005281c0) is now the ONLY arming site, matching retail exactly. No further action — recorded per the register's retire-in-same-commit rule | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`contact_allows_move`, `ChargeJump`) | N/A — retired | N/A — retired | `CMotionInterp::charge_jump` 0x005281c0 @305448 | -| TS-38 | `MotionInterpreter.Initted` defaults to `true` in both constructors, not retail's `false` — retail's `CMotionInterp` is never observed pre-`enter_default_state` (every real construction path calls it before exposing the interpreter); acdream's constructors are used directly by ~40 pre-existing tests and both App call sites as complete, immediately-usable objects with no separate "enter default state" step | `src/AcDream.Core/Physics/MotionInterpreter.cs` (`Initted` property + both constructors) | Defaulting `true` is the C# equivalent of "the constructor already did what `enter_default_state` would have done to this flag" — `EnterDefaultState()` remains available, verbatim, for the REST of retail's reset semantics (state defaults, sentinel enqueue, `LeaveGround` tail) when a caller wants them | None observed: no code path needs `apply_current_movement`/`ReportExhaustion` to no-op before an explicit `EnterDefaultState()` call, since nothing constructs a `MotionInterpreter` and defers initialization today. If a future caller DOES need staged construction (build now, `EnterDefaultState()` later), it must explicitly set `Initted = false` first | `CMotionInterp::enter_default_state` 0x00528c80 @306124 sets `initted = 1`; retire if/when construction is staged through `EnterDefaultState()` uniformly | -| TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) | -| TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) | -| TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) | -| TS-46 | Player/remote collision spheres are passed as TWO SCALARS (radius, capsule-top height) and reconstructed by `SpherePath.InitPath` (foot center at `radius`, head center at `height − radius`) — retail passes the Setup's SPHERE LIST verbatim (`CPhysicsObj::transition` 0x00512dc0 → `init_sphere(GetNumSphere, GetSphere, m_scale)`). With the corrected callers (0.48, 1.835 = Setup.Height) the reconstruction sits 5 mm off the dat: foot center 0.480 vs dat 0.475, head center 1.355 vs dat 1.350 (human Setup 0x02000001 spheres `(0,0,0.475) r=0.48`, `(0,0,1.350) r=0.48`). Remotes also use the hardcoded HUMAN dims regardless of creature Setup/scale. (The pre-2026-07-06 value 1.2f put the head TOP at 1.2 m — 0.63 m of headless character; the #137 window climb. Fixed same day.) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`InitPath`), `src/AcDream.App/Input/PlayerMovementController.cs` + `src/AcDream.App/Rendering/GameWindow.cs` (the two `sphereHeight:` call sites) | The scalar API predates the Setup ingestion; 5 mm is below the visual/feel threshold for the human capsule and keeps every captured-input replay fixture byte-identical | 5 mm offsets can flip marginal grazes near the r−ε/r+ε knife edges (today's seam class); creature-scale remotes collide with human-sized capsules until setup-derived dims are plumbed | `CPhysicsObj::transition` 0x00512dc0; dat Setup 0x02000001; retire by plumbing the Setup sphere list into `InitPath` | -| TS-45 | `SphereCollision` (the shadow-object Sphere response) is a hand-rolled 3-D wall-slide that ALSO calls `SetSlidingNormal` — retail's `CSphere::intersects_sphere` (0x00537A80) dispatches `CSphere::slide_sphere` (0x00537440), which slides in-frame and never writes `collision_info.sliding_normal` (the only in-transition writer is `validate_transition` 0x0050ac21). Same leak class as the #137 mechanism-2 stubs fixed 2026-07-06 (BSPQuery Contact branch); left in place because the response's blocking semantics for sphere-shaped server objects are untested against the real slide and #171 sticky-melee behavior is freshly gated | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision`, the `ci.SetSlidingNormal` tail) | The in-frame push-out already moves the check position; the extra sliding normal only persists on transition success, and pure-Sphere shadow shapes are rare (most creatures/statics are CylSphere, which routes through the real `SlideSphere` since #172) | A sphere-shaped object touch persists a normal retail would discard — an exactly-anti-parallel follow-up push absorbs to a zero offset (#137 wedge class) at that object until an oblique input clears it | `CSphere::intersects_sphere` 0x00537A80 → `slide_sphere` 0x00537440 (pc:321678+); fix = route the tail through `Transition.SlideSphere` like `CylSlideSphere` does | -| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port | +| TS-30 | UI panels drawn as flat translucent rectangles + 1 px border; retail composes 9-slice dat sprite backgrounds via LayoutDesc trees | `src/AcDream.App/UI/UiPanel.cs:10` | Development visibility until the D.2b retail-look toolkit consumes the dat assets | Purely visual until D.2b — but pixel-position assumptions built against the placeholder (hit regions, layout constants) may not survive the swap to retail sprite metrics | RenderSurface 0x06xxxxxx 9-slice; LayoutDesc 0x21xxxxxx | --- @@ -243,8 +181,8 @@ equivalence argument (promote to AD/AP) or a fix. | UN-1 | `CheckOtherCells` iterates the overlap set SORTED by cell id; retail walks the CELLARRAY in build order — and the loop halts on the first non-OK result, so order is behavior-bearing | `src/AcDream.Core/Physics/CellTransit.cs:1718` | Justified only as "deterministic order for greppable probe logs" — no equivalence argument vs retail's array order recorded | A sphere straddling two cells that would each return a different non-OK result halts on a different cell than retail — different collision normal / slide direction at multi-cell straddles | `CTransition::check_other_cells` pc:272717-272798 | | UN-3 | AdminEnvirons fog-override RGB tints hardcoded with no retail constant cited (RedFog 0.60/0.05/0.05 etc.); Snapshot replaces fog COLOR only, keeping keyframe distances on an unverified assumption | `src/AcDream.Core/World/WeatherState.cs:350` | Enum semantics cite ACE EnvironChangeType + r12 §5.2; no source for the RGB values or the color-only override scope | A server-forced fog event renders the wrong hue and/or wrong density vs what retail clients showed for the same packet | AdminEnvirons 0xEA60; ACE EnvironChangeType.cs | | UN-4 | GfxObj double-sided/negative-surface handling keeps WB's legacy logic (cull-mode double-siding, no reversed-winding duplicate, different neg-surface predicate) while the CellStruct path follows the retail-cited `ConstructMesh` reading | `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs:1059` (CellStruct contrast :1396-1410) | No recorded justification on the GfxObj side — it is the unmodified WB extraction; the retail citation was added only to the CellStruct path | GfxObj models retail draws via duplicated-reversed-winding get wrong back-face lighting (normals not inverted) or missing/extra negative faces — dark or absent faces from behind | `D3DPolyRender::ConstructMesh` 0x0059dfa0 | +| UN-5 | Run multiplier applied to backward (and strafe) speed while the wire reports speed 1.0; the 0.65 backward factor IS retail's, the runMul on top is justified only by feel ("~2.4× ratio felt wrong"); strafe cites holtburger, backward cites nothing | `src/AcDream.App/Input/PlayerMovementController.cs:909` | Feel fix (K-fix3); no retail citation for run-scaling backward movement | If retail does NOT run-scale backward, the local body moves up to ~2.4× faster backward than the wire declares — observers dead-reckon slower and see lag/teleport when backing up at run | adjust_motion FUN_00528010 (0.65 only); holtburger common.rs (sidestep) | | UN-6 | Fixed 200 ms sleep between ConnectRequest and ConnectResponse; retail inserts no delay. Annotated only as "with 200ms race delay"; the 2026-06-04 audit flagged it, the follow-up refuted "forbidden workaround" but wrote no fuller rationale back | `src/AcDream.Core.Net/WorldSession.cs:484` | Presumed ACE port+1 listener race guard — four words, no citation | Every login eats a flat 200 ms; if the race needs longer on a loaded server, the handshake fails intermittently (ConnectResponse ignored → CharacterList never arrives, exit-29 shape) with no retry — a timing constant masking an unconfirmed root cause | (none recorded) | -| UN-7 | Outdoor OBJECT point lighting uses `calc_point_light` (wrap/norm + per-channel cap, `~1/d²`) for ALL meshes including static buildings, but retail's object path is unconfirmed — `config_hardware_light` (0x0059ad30) sets D3D-FF point lights (`Diffuse=color×intensity`, `Attenuation=(0,1,0)`⇒`1/d`, `Range=falloff×1.5`, `material.diffuse=white`) yet that math would blow walls WHITE while retail stays DIM, so static buildings may instead use the `SetStaticLightingVertexColors` bake. Model + the brightness-scaling factor both UNRESOLVED (issue #140 / Fix D) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution`); `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`) | Fix A/B ported calc_point_light + per-object selection for objects without confirming retail uses that model for static buildings; cdb captured the D3D-FF path but it contradicts the observed dim result | Outdoor buildings blow out warm near torches (the #140 meeting-hall symptom); whichever model is wrong, the object torch contribution is too strong | `config_hardware_light` 0x0059ad30; `SetStaticLightingVertexColors` 0x0059cfe0; `rangeAdjust=1.5` 0x00820cc4 — see docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md | --- @@ -258,23 +196,25 @@ WITH that phase, not before. 1. **TS-20 — GfxObj DrawingBSP traversal (#113)** — phantom geometry is visible in Holtburg RIGHT NOW; the holistic port handoff already specs the fix; first diagnose the id filter against a door GfxObj. 2. **TS-27 — Retransmit handling** — sole hard blocker for any non-loopback play; failure mode is silent permanent stalls (entities never spawn). Also fix the stale class-doc gap list while there. 3. **TS-4 — Path-6 steep slide-tangent shortcut** — landing/contact state diverges on every airborne-steep hit; the L.5+ retail-strict followup is already filed with the missing-ingredient analysis. -4. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). -5. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing. -6. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass. -7. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. -8. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. -9. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger. -10. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now. -11. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler. -12. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. +4. **UN-5 — Backward/strafe run multiplier** — potential ~2.4× local-vs-wire speed mismatch on a common input (S at run); one cdb session against retail answers it. +5. **UN-1 — CheckOtherCells iteration order** — behavior-bearing halt order with a log-cosmetics justification; trivial to fix (iterate CELLARRAY build order, sort only in probe output). +6. **TS-1 — PrecipiceSlide stop-at-edge** — visible movement mismatch at every cliff/roof edge; diagnostic already records which ingredient is missing. +7. **TS-22 — adjust_motion port** — active bug-class generator: any new `get_state_velocity` consumer during backward/strafe silently gets zero velocity. +8. **TS-26 — Position sequence freshness** — real-network correctness; pairs naturally with TS-27 in one transport-hardening pass. +9. **UN-6 — 200 ms ConnectResponse sleep** — unexplained constant on every login with an intermittent-failure shape; either find the ACE race and cite it, or replace with an acknowledged-ready check. +10. **UN-4 — GfxObj sides/negative-surface logic** — diagnose against the retail-cited CellStruct interpretation on a known double-sided GfxObj; promote to AP with a citation or align it. +11. **TS-8 — MagicUpdateEnchantment StatMod parse (#7/#12)** — vitals wrong for the whole session after any buff; parser shape is known from holtburger. +12. **TS-13 — CallPES/DefaultScript animation hooks** — the blocker comment is stale since C.1.5a shipped PhysicsScriptRunner; possibly a cheap wire-up now. +13. **UN-3 — AdminEnvirons tints** — invented RGB constants + unverified color-only scope; one decomp lookup against the 0xEA60 handler. +14. **TS-19 — Legacy ChaseCamera deletion** — already marked "pending the follow-up deletion commit"; its continued existence can mask or manufacture flap symptoms during debugging. **Phase-gated (do WITH the phase, flagged here so they aren't forgotten):** M2 combat must land TS-2 (BspOnlyDispatch terms), TS-5 (CanJump gating), TS-23 (PK bits), TS-25 (stance in MoveToState), TS-17 (AttackConditions), and revisit AP-13 (ComputeDamage) + AP-24 (jump-charge constant via the 0x0056ADE0 decompile). Emote work must land TS-24 (command-list packing). -Membership Stage 2 must land TS-18 (BuildingCellId). -The audio phase lands TS-9/TS-29; the animation-hook layer lands +Membership Stage 2 must land TS-18 (BuildingCellId). D.2b lands TS-30; +the audio phase lands TS-9/TS-29; the animation-hook layer lands TS-10/TS-11/TS-12/TS-13/TS-14. --- diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index be58ad63..b0527cea 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -30,68 +30,6 @@ our tree (see CLAUDE.md for the full breakdown): interface WB's internals expect (O-D7 fallback; `ObjectMeshManager` has 26 internal `_dats.*` call sites — above the 20-site inline-swap threshold). -**MP1a (2026-07-05): CPU mesh-extraction half moved to `AcDream.Content`.** -The GL-free portion of the former `ObjectMeshManager` — dat read → polygon -walk → vertex/index build → inline BCn/palette texture decode → -`ObjectMeshData` — is now `MeshExtractor` in a new `src/AcDream.Content/` -assembly (no Silk.NET dependency), so the MP1b bake tool can run the exact -same extraction code offline without an OpenGL context. This was a -mechanical, verbatim move (namespace + visibility only) per -`docs/superpowers/plans/2026-07-05-mp1a-content-extraction.md` — no -behavior change, no divergence-register row. - -- `src/AcDream.Content/MeshExtractor.cs` — the `Prepare*` family - (`PrepareMeshData`, `PrepareSetupMeshData`, `PrepareGfxObjMeshData`, - `PrepareEnvCellMeshData`, `PrepareCellStructMeshData`, - `PrepareCellStructEdgeLineData`) + private helpers (`CollectParts`, - `CollectEmittersFromScript`, `ComputeBounds`, `BuildPolygonIndices`, - `BuildCellStructPolygonIndices`) and the decoded-texture cache / - `ThreadLocal` that back them. -- `src/AcDream.Content/ObjectMeshData.cs` — the CPU-side boundary records: - `VertexPositionNormalTexture`, `StagedEmitter`, `ObjectMeshData`, - `MeshBatchData`, `TextureBatchData`. -- `src/AcDream.Content/TextureKey.cs` — the atlas dedup key, lifted out of - the GL-owning `TextureAtlasManager` (which stays in App and now - references the lifted struct). -- `src/AcDream.Content/UploadFormats.cs` — Content-owned - `UploadPixelFormat`/`UploadPixelType` enums carried by - `MeshBatchData`/`TextureBatchData` instead of - `Silk.NET.OpenGL.PixelFormat`/`PixelType` (Content must stay - Silk.NET-free — the bake tool must not ship GL binaries). Underlying - values are the GL ABI constants, numerically identical to the Silk.NET - members; App casts at its single upload boundary (the `AddTexture` call - in `UploadGfxObjMeshData`) via a lifted nullable enum conversion — - value- and null-preserving. -- `src/AcDream.Content/IDatReaderWriter.cs`, `EdgeLineBuilder.cs` — GL-free - dependencies of the extractor, moved (namespace-only) alongside it. -- **Side-stage sink seam:** `CollectEmittersFromScript` pre-loads particle - GfxObj meshes mid-extraction and, pre-MP1a, enqueued them directly onto - `ObjectMeshManager._stagedMeshData`. The extractor now takes an - `Action? sideStagedSink` constructor parameter; App wires - it to `_stagedMeshData.Enqueue`, preserving the original - immediate-enqueue semantics exactly — including on a mid-`Prepare*` - throw (preloads staged before a malformed-dat texture-decode exception - survive, as they always did). The MP1b bake tool passes its own - collector. -- **Stays in `src/AcDream.App/Rendering/Wb/`:** `ObjectMeshManager` (now a - thin wrapper owning the staged-queue/worker-pool/Dispose-quiesce lifecycle - and all GL upload — constructs one `MeshExtractor` and delegates every - former `Prepare*` call site to it), `ObjectRenderData`/`ObjectRenderBatch` - (hold a GL `TextureAtlasManager` field), `TextureAtlasManager`, - `DatCollectionAdapter` (concrete `DatCollection`-backed implementation of - the now-Content-namespaced `IDatReaderWriter`), `GeometryUtils` (used only - by App-side raycasting, not by extraction), `AcSurfaceMetadata`/ - `AcSurfaceMetadataTable` (not on the extraction path), `Building.cs` - (explicitly out of scope). -- `AcDream.Core` is untouched; `AcDream.Content` references `AcDream.Core` - (for `TextureHelpers`, `Sphere`/`BoundingBox` via `Chorizite.Core.Lib`); - `AcDream.App` references `AcDream.Content`. `AcDream.Core` does NOT - reference `AcDream.Content` (one-way dependency, per Code Structure Rule 2). -- Reason: MP1 (`docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` - §6.1) — the bake tool needs the identical mesh/texture extraction code - running with no GL context, so baked pak output and live-client output stay - byte-identical. - **Workflow:** Before re-implementing any AC-specific rendering or dat-handling algorithm, **check this inventory first**. If we already extracted it (🟢 sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 58584d4b..79c7f4e5 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -293,32 +293,14 @@ successfully 2026-04-30 for the steep-roof case. Matching binaries #### Phase A7 — Indoor lighting fidelity (RenderDoc + retail-decomp driven) -**Now also owns #176/#177 (2026-07-06):** the Facility Hub purple seam -flash + stair-room light pop-in are ROOT-CAUSED to this phase's "light -visibility culling" layer — a camera-nearest `MaxGlobalLights=128` -snapshot cap evicts in-range lights of visible cells (Hub has 366 -fixtures), so per-cell 8-light sets churn as the camera moves. -Uncapping was live-tested and reverted because the full pool exposes the -per-cell-reach + fixture-curve defects below (through-floor light, -1/d-vs-1/d³). Analysis PRE-PAID — see -`docs/research/2026-07-06-176-177-handoff-A7-lighting.md` (the fix order: -per-cell `insert_light` registration → static fixture curve → stripe -hunt → uncap) + register AP-85. - **Hypothesis layers (less mapped than physics):** - Per-cell environment-light tag association — indoor cells should inherit only their own env lights, not outdoor day-cycle. - Light visibility culling — what lights actually contribute to each - cell's render. **CONFIRMED bug here (#176/#177): no per-cell light - registration — lights are a flat world-space sphere-overlap pool that - reaches through solid floors; retail's `insert_light` 0x0054d1b0 - scopes each light to its cell.** + cell's render. - Per-entity light direction transform — held-item-spotlight bug (#L-spotlight) is per-entity attribution gone wrong. - Static-stab atmospheric inheritance (#81). -- **Fixture falloff curve (#176/#177): stationary server-spawned - fixtures ride the DYNAMIC 1/d path (#143 `isDynamic`); should be the - static 1/d³ bake (`calc_point_light` 0x0059c8b0).** **Investigation methodology:** less existing infrastructure than physics. Requires: @@ -442,22 +424,10 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar. **Sub-pieces:** - **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`). - **✓ SHIPPED — D.2a — ImGui scaffold + `AcDream.UI.Abstractions` layer.** Shipped 2026-04-25. Wires ImGui as the short-term backend behind `ACDREAM_DEVTOOLS=1`. Defines `IPanel` / `IPanelHost` / `IPanelRenderer` / `ICommandBus` + the first ViewModel (`VitalsVM`) in the new `AcDream.UI.Abstractions` project. First real panel: `VitalsPanel` reading HP from `CombatState.GetHealthPercent`. **Backend pivoted Hexa.NET.ImGui → ImGui.NET + `Silk.NET.OpenGL.Extensions.ImGui` during integration** — Hexa's native OpenGL3 backend does its own GL function resolution via GLFW/SDL and crashed with `0xC0000005` in `ImGuiImplOpenGL3.InitNative` against Silk.NET (no GLFW/SDL present). The Silk.NET extension is purpose-built for this scenario and is the `ImGui.NET` mitigation path that `docs/plans/2026-04-24-ui-framework.md` already called out as a "one-morning operation". Stam/Mana return `float?` null in D.2a because absolute values need `LocalPlayerState` + `PlayerDescription (0x0013)` parsing (filed post-D.2a). 11 new `AcDream.UI.Abstractions.Tests` green. -- **✓ SHIPPED — D.2b — Custom retail-look backend (Spec 1: markup engine + first panel).** Shipped 2026-06-14 (`626d06e`→`019350f`). Wired the dormant `UiHost`/`UiElement` tree into `GameWindow` (`ACDREAM_RETAIL_UI=1`) and built the **Approach-C markup engine**: `MarkupDocument` (XML → `UiElement` subtree, `{Binding}` reflection) + `ControlsIni` stylesheet loader + an `IUiRegistry` plugin UI surface (plugins ship markup + a binding, drained into the same `UiRoot`). First retail-faithful panel: a markup-driven Vitals window (`vitals.xml`) — 8-piece dat-sprite frame (`UiNineSlicePanel`) + three `UiMeter` bars (red/gold/blue + cur/max numbers) bound to `VitalsVM`, rendering live over the 3D world and coexisting with the ImGui devtools path. Retired divergence TS-30, added IA-15. Spec `docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`; plan `docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md`. NOTE the prove-out corrected two stale "facts": retail vitals are **bars not orbs**, stamina is **gold not cyan**. **Remaining: D.3 (AcFont — using the stb stopgap for now) + the glassy gradient bar sprite / brightness tune (polish); input integration (movable/clickable windows — the `UiRoot` drag/click machinery exists but isn't bridged to the Phase-K dispatcher yet); and the rest of the panels (D.5).** -- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 1 + default flip).** Plan 1 shipped 2026-06-15. *Retroactive registration — the spec requested this phase but it was not pre-registered before implementation.* Data-driven vitals window from `LayoutDesc 0x2100006C`: `LayoutImporter` resolves `BaseElement`/`BaseLayoutId` inheritance, walks the `ElementDesc` tree, and builds a live `UiElement` tree via `DatWidgetFactory` (Type 7 → `UiMeter`; all others → `UiDatElement` generic renderer). `VitalsController` binds live HP/Stamina/Mana by element id (mirrors retail `gmVitalsUI`). A/B visual gate **PASSED**: pixel-identical to the hand-authored window. **Default flip shipped 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`; the hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): the dat edge-anchors reflow the pieces on width change per retail `UIElement::UpdateForParentSizeChange @0x00462640` (an earlier "fixed-size" note was wrong — inverted edge-flag reading, corrected; stretch is `RightEdge==1`). Faithful grip/dragbar-*driven* drag/resize INPUT is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bars) + glyph pixel-snap (sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`; plan: `docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`. -- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — chat-window re-drive).** Shipped 2026-06-15. `GameWindow`'s hand-authored chat block (`UiNineSlicePanel` + inline `UiChatView`) replaced by `ChatWindowController.Bind(LayoutDesc 0x21000006, …)` — the same importer path as vitals. `ChatWindowController` places `UiChatView` (transcript) + `UiChatInput` (text entry + on-submit) + `UiChatScrollbar` (scrollbar thumb) + `UiChannelMenu` (channel selector) inside the dat-authored chrome; dead local statics `BuildRetailChatLines`/`RetailChatColor` deleted from `GameWindow` (moved into the controller). Wired to `_commandBus` (same `LiveCommandBus` as the ImGui `ChatPanel`) so type+Enter dispatches `SendChatCmd` server-ward. Transcript keyboard set from `_uiHost.Keyboard` for Ctrl+C/Ctrl+A. 392 tests green. Added divergence rows AD-28 / AP-38–40 / TS-30–31; updated IA-15. -- **✓ SHIPPED — D.2b LayoutDesc importer (Plan 2 — widget generalization).** Shipped 2026-06-16 (`b7f7e2b`→`89626cd`). The hand-named chat widgets became GENERIC, Type-registered widgets built by `DatWidgetFactory` (`1→UiButton`, `6→UiMenu`, `7→UiMeter`, `11→UiScrollbar`, `12→UiText`); `UiField` (editable) ships controller-placed. `ChatWindowController` + `VitalsController` collapsed to thin `gm*UI::PostInit`-style find-by-id binders — this is the reusable toolkit + assembly pattern the future inventory/vendor/spell-bar windows build on. New `UiElement.ConsumesDatChildren` leaf-widget rule: behavioral widgets reproduce their dat sub-elements procedurally, so the importer must not build their children (an invisible Menu label child was swallowing the button click → dropdown wouldn't open). **Type 3 deliberately NOT registered** → `UiField` (acdream's Type-3 elements are inert sprite-bearing chrome/containers → stay `UiDatElement`; a subagent's Type-3→`UiField` registration was reverted — it blanked the vitals bevel + masked the regression by weakening a test). The editable input resolves to Type 12 → controller-placed `UiField` (Variant B). Vitals numbers rewired to a centered `UiText` (Task 8) — `UiText.Centered` reuses the meter's former centering formula, pixel-identical. Both visual gates (chat + vitals) **user-confirmed**; 404 tests green; new `chat_21000006.json` golden fixture. Amended AP-37, narrowed AP-41, added AP-42. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-16-d2b-widget-generalization*.md`. +- **D.2b — Custom retail-look backend.** Implements the same `IPanel` / `IPanelRenderer` contracts using a custom retained-mode toolkit sourced from retail dat assets. Requires D.2a shipped. Panels get reskinned one at a time; ImGui stays as the `ACDREAM_DEVTOOLS=1` overlay forever. The original 2026-04-17 scaffold research (`UiRoot` / `UiElement` / `UiPanel` / `UiHost` + retail event codes + focus / drag-drop state machine + `WorldMouseFallThrough`) is the implementation foundation here — see `docs/research/retail-ui/`. - **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity. **(D.2b dependency — needs the custom renderer.)** -- **✓ SHIPPED — D.4 — Dat sprites + 9-slice panel backgrounds.** Shipped 2026-06-14 with D.2b. `TextureCache.GetOrUploadRenderSurface(id, out w, out h)` decodes `RenderSurface` (`0x06xxxxxx`) **directly** (not via the Surface→SurfaceTexture chain — the prove-out finding) → GL `Texture2D`; `DrawSprite` (explicit UV-rect, per-texture batch) added to `TextRenderer` + `UiRenderContext` + a `uUseTexture=2` RGBA frag branch; `UiNineSlicePanel` composes the universal 8-piece bevel (corners `0x060074C3..C6`, edges `0x060074BF/C0/C1/C2`, center `0x06004CC2`). Remaining art polish: the glassy gradient bar fill sprite (D.2b polish). +- **D.4 — Dat sprites + 9-slice panel backgrounds.** Load `RenderSurface` (`0x06xxxxxx`) as GL textures; add `DrawSprite` to `UiRenderContext`. Enables retail panel art. **(D.2b dependency.)** - **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05. **(Targets `AcDream.UI.Abstractions` — ships with D.2a using ImGui-rendered widgets; reskinned by D.2b.)** The *chat* panel originally listed under D.5 shipped early in Phase I (I.4 input + I.7 combat translator superseded the chat-panel design here); this entry now covers Attributes / Skills / Paperdoll / Inventory / Spellbook / Fellowship / Allegiance only. -- **✓ SHIPPED — D.5.1 — Toolbar (action bar).** Shipped 2026-06-16/17 (`30b28c2`→`0e7a083`, branch claude/hopeful-maxwell-214a12). First data-driven *game* panel: `gmToolbarUI` (`LayoutDesc 0x21000016`) — 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real **composited** item icons (opaque type-default underlay via the `EnumIDMap 0x10000004` resolve), **occupancy-gated slot numbers 1–9** (occupied = dark-box peace/war `0x10000042/43`; empty = background `0x1000005e` from cell composite `0x10000341`), **click-to-use** (`ItemHolder::UseObject` → `0x0036`), **peace/war stance** indicator live-wired to `CombatState`, **movable**, and a **chrome frame** (UiNineSlicePanel drawn over content via the new `UiElement.OnDrawAfterChildren` hook). New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural leaf) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory branch) + `IconComposer` (CPU layered composite). `CreateObject.TryParse` extended to the full ACE-order weenie-header tail to capture `IconId`/`IconOverlay`/`IconUnderlay` → `ItemRepository.EnrichItem` → re-render. Spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`; research drop `docs/research/2026-06-16-*deep-dive.md` + synthesis. Divergence IA-16/IA-17 added. **User-confirmed** (numbers, icons, frame). Per-task spec+code-review throughout. -- **✓ SHIPPED — D.5.2 — Stateful item-icon system.** Shipped 2026-06-17/18 (`419c3ac`..`fb288ad`, branch claude/hopeful-maxwell-214a12; **visually verified on a live Coldeve server**). Faithful retail icon composite (`IconData::RenderIcons` @0x0058d180): (1) `UiEffects` bitfield captured from the `CreateObject` weenie header (was discarded) → `ItemInstance.Effects`; (2) `IconComposer.GetIcon` rewritten as a 2-stage composite — Stage 1 = drag icon (base + custom overlay) + the effect treatment, Stage 2 = type-default underlay + custom underlay + drag. The effect treatment ports the **surface overload** of `SurfaceWindow::ReplaceColor` (`0x004415b0`): the textured effect tile (`EnumIDMap 0x10000005` by `LowestSetBit(effects)+1`, fallback `0x21` solid-black) is copied **per-pixel** into the icon's pure-white pixels — magical items take the tile's GRADIENT hue, mundane items go black; (3) `PublicUpdatePropertyInt (0x02CE)` parser + `WorldSession.ObjectIntPropertyUpdated` event + `GameWindow` subscription → `ItemRepository.UpdateIntProperty` → icon re-composites live. **Appraise (`0x00C9`) carries NO icon data** (ACE proof: `Icon`/`IconOverlay`/`IconUnderlay`/`UiEffects` all lack `[AssessmentProperty]`) — dropped as a no-op. **Two visual-verification fixes landed after the subagent build:** the `effects==0` recolor MUST run (mundane white edges → black, `40c97a5`) and the tint is a per-pixel GRADIENT not a flat color (the surface overload, `fb288ad`) — both confirmed via clean Ghidra + named decomp. Divergence: IA-16 retired; IA-18 (per-pixel surface-copy anti-regression) + AP-45 (0x02CE sequence) added; **AP-43/AP-44 retired by the visual fixes**. Spec/plan/research: `docs/superpowers/{specs,plans}/2026-06-17-d2b-stateful-icon*.md`, `docs/research/2026-06-17-stateful-icon-RESOLVED.md`. -- **D.5 remaining — sub-phase ledger.** D.5.1 (toolbar + the `UiItemSlot`/`UiItemList`/`IconComposer` spine) ✅, D.5.2 (stateful icons) ✅, D.5.4 (client object/item data model) ✅, D.2b-B window manager (`abbd97b`) ✅, D.2b-B B-Grid (inventory sub-window mount + `UiItemList` grid) ✅, D.2b-B B-Controller (inventory population + burden meter + captions, `03fbf44`; **visually confirmed 2026-06-21** — two render bugs fixed at the gate `417b137`: backdrop wash-out [a #145 continuation — mounted sub-window slots must keep their own frame ZLevel, not inherit the base root's 1000] + captions [drive the host UiText directly]) ✅, D.2b-B B-Wire (`EncumbranceVal`/PropertyInt 5 wire delivery, AP-48/49 retired) ✅, inventory window finish Stage 1 (scroll/frame/vertical-resize/102-slot grid) ✅, empty-slot art ✅, container-switching + open/selected indicators + main-pack icon + per-bag capacity bar ✅, B-Drag (inventory drag-drop / item moving) ✅, **Sub-phase C (paperdoll): Slice 1 equip slots ✅ + Slice 2 3-D doll `UiViewport` (Type 0xD via the `IUiViewportRenderer` RTT seam) + Slots toggle ✅ (visually confirmed 2026-06-25, `8fa66c2` — pose/camera/heading all retail-verbatim)** ✅, **UI Studio** (`AcDream.App ui-studio`) ✅, **importer dat-fidelity (Fix A/B/C)** ✅, and **Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`) ✅ are shipped. Build order from here: **(d) finish the bar — D.5.3 remaining (selected-object meter/mana, stack entry/slider) + spell shortcuts + the selection→selected-object-bar wiring (AP-58); ISSUES #146/#147/#148.** Each ☐ below gets its own brainstorm → spec → plan. -- **✓ SHIPPED — UI Studio** (2026-06-26, branch `claude/hopeful-maxwell-214a12`, ~33693c6→HEAD). Standalone `AcDream.App ui-studio [--layout 0xNNNN | --dump ] [--screenshot ]` Silk tool that previews any panel through the **production renderer** (`RenderBootstrap.cs` + GameWindow untouched). Sources: 26-window retail dump (`docs/research/2026-06-25-retail-ui-layout-dump.json`) via `--dump`, or live dat import + fixtures via `--layout`. Interactive canvas (click-routing to `UiHost`, Interact/Inspect ImGui toggle), headless `--screenshot`. Architecture: `src/AcDream.App/Studio/` (`StudioWindow`, `PanelFbo`, `FixtureProvider`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. ISSUES #156 (inventory all-black in studio) and #157 (GameWindow font resolver) filed. -- **✓ SHIPPED — Importer dat-fidelity** (Fix A/B/C, 2026-06-26, same branch). **Boundary established: look = importer (font/justification/color from dat); state/behavior = runtime controller logic.** Fix A: justification property `0x14`/`0x15` → `UiText` Centered/RightAligned/VerticalJustify. Fix B: FontColor property `0x1B` → `UiText.DefaultColor`; character colors proved RUNTIME (no importer fix). Fix C: FontDid → per-element dat font via a resolver in `DatWidgetFactory` — **STUDIO path only**; GameWindow passes `null` (Issue #157). Fix 4 (UIState-group activation): INVESTIGATED — no importer fix exists; the dat has no Visible encoding; runtime `gm*UI` is correct. -- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. -- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. -- **☐ D.5.3 — Toolbar selected-object display (issue #140) + spell shortcuts.** Wire the B.4 `WorldPicker`/selection state → the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line, so the bar shows the player's currently-selected world object. Plus **spell shortcuts** — pinned *spells* (vs items) don't render their glyphs yet (`ToolbarController.Populate` skips `ObjectGuid==0`). Together these finish "the bar." (Click-to-use + the peace/war stance indicator landed in D.5.1.) -- **☐ D.5.5+ — Core panels.** Inventory (`gmInventoryUI`/`gmBackpackUI`), equipment/paperdoll (`gmPaperDollUI`/`gm3DItemsUI` + the `UiViewport` 3D doll), vendor, trade, spellbook. Research drop done (`docs/research/2026-06-16-*`). Depends on **D.5.4** (data model) + the item-slot/list/icon spine (D.5.1/D.5.2) + the **window manager** (Plan 2: open/close/z-order/persist + faithful grip/dragbar drag-resize) + the drag-drop spine wired (`UiRoot` has the chain; the per-cell accept/drop hooks are still stubs in `UiField`). Also deferred from D.5.1: drag/reorder + the `AddShortcut`/`RemoveShortcut` mutate wire. - **D.6 — HUD.** Vital orbs (scissor-rect partial fill, dat sprites `0x060013B2`), radar (`0x06001388` / `0x06004CC1`, 1.18× range factor), compass strip (scrolling U), target name plate, damage floaters, selection indicator. See slice 06. **(Targets `AcDream.UI.Abstractions` — ships with D.2a; reskinned by D.2b.)** Phase I.2 retired the StbTrueTypeSharp `DebugOverlay` but kept `TextRenderer` + `BitmapFont` alive specifically for D.6's world-space HUD elements (damage floaters, name plates) — they need raw GL text drawing that ImGui can't reach into the 3D scene. - **D.7 — Cursor manager.** OS + dat-sourced custom cursors (`FUN_0043c1c0` GDI HCURSOR builder pattern from slice 03). **(D.2b dependency.)** - ~~**D.8 — Sound.**~~ **Superseded — shipped as Phase E.2** (`SoundTable`/`Sound` dat decode, OpenAL 16-voice engine, per-entity 3D positional audio via `AudioHookSink`). Entry kept here for history; see the shipped table. @@ -630,33 +600,6 @@ change must keep both phase plans in sync. - **L.1b — Command router + motion-state cleanup.** Extract tested `SetCycle` vs `PlayAction` routing, add missing `MotionCommand` constants, and split death `Sanctuary` action from persistent `Dead` substate. - *(Partial, shipped 2026-06-30:* dual `IMotionCommandCatalog` seam — - `AceModernCommandCatalog` runtime default + full-extraction - `Retail2013CommandCatalog` conformance; deleted the blind 0x016E–0x0197 - override; exposed + filed the `CombatAnimationPlanner` 2013-numbering bug - as ISSUES #159 — **fixed 2026-07-04 `2de5a011`**, the constants now derive - directly from `DatReaderWriter.Enums.MotionCommand` by name. - Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`.)* - *(D6.2a, shipped 2026-07-01:* ported `CMotionInterp::adjust_motion` / - `apply_run_to_command` / `apply_raw_movement` into `MotionInterpreter` and - unified the local velocity + keyboard turn + jump onto one input-built - `RawMotionState` → `get_state_velocity` — backward/strafe-left no longer zero, - strafe is now retail-exact (`~1.56×runRate`, clamped ≤3.75), turn omega from - interpreted `turn_speed`, hand-mirrored formulas deleted. User smoke sign-off - 2026-07-01 (strafe-left moves + symmetric, backward outpaces strafe, jump - lateral, turn feel unchanged; no crash/rejection). Retired register - TS-22 + UN-5; added TS-34 (IsCreature guard no-op). `WalkAnimSpeed` unified to - the retail-exact 3.11999989f. Pseudocode: - `docs/research/2026-07-01-d6-motion-interp-pseudocode.md`.)* - *(D6.2b, shipped 2026-07-01:* the wire now sends the retail-faithful RAW - `forward_speed=1.0` (omitted by default-difference packing) instead of runRate. - Echo-test settled the open question: ACE RECOMPUTES the broadcast run speed from - run skill and auto-upgrades WalkForward+HoldKey.Run → RunForward — sending 1.0 - still broadcasts RunForward @ runRate, and a retail observer saw +Acdream run at - full pace. The stale `MovementData.cs`-citing "ACE relays" comment was - corrected. Wire + velocity are now both raw-1.0; threading them onto one shared - RawMotionState (removing section-6's duplicate build) is a no-op-behavior - cleanup follow-up.) - **L.1c — Combat animation wiring.** Combat mode tracking, draw/sheath style transitions, attack swings by stance/power/height, hit reactions, evades/blocks/parries, and death handoff. @@ -717,88 +660,6 @@ diagnostic scaffolding, not yet the final collision system. tests to real-world fixtures and verify local acdream view plus retail observer view. ACE accepting a position is a compatibility check, not proof of fine-grained retail collision parity. -- **Phase R — retail motion & animation ground-up reconstruction (2026-07-02, - user mandate: total overhaul, verbatim, all entity classes, no frozen - code).** Plan of record: `docs/plans/2026-07-02-retail-motion-animation-rewrite.md` - — stages R1 (CSequence) → R8 (cutover audit). SUPERSEDES the L.2g S3–S6 - slices below; L.2g S1/S2/S5 (shipped 2026-07-02: staleness gate, inbound - funnel + 183-case live-retail conformance harness, pace-guesser deletion) - are absorbed as components. NOTE: this Phase R is unrelated to the retired - "R1→R8 refactor sketch" from early 2026 — same letter, new plan. - **R1 SHIPPED 2026-07-02** (verbatim CSequence core + adapter rehost, - a987cad1 tail). **R2 SHIPPED 2026-07-02** (Q0–Q6; Fix B/fast-path/ - stop-anim-fallback/G17/RemoteMotionSink deleted, AP-73 retired). - **R3 SHIPPED 2026-07-03** (W0–W7: pending_motions + MotionDone consumer, - jump family, ground transitions with K-fix18 DELETED, the ONE - DoInterpretedMotion + zero-tick flush, LOCAL PLAYER unification — - UpdatePlayerAnimation + the synthesis layer deleted; trail in the plan - doc). **R4 SHIPPED 2026-07-03** (V0–V6: the verbatim MoveToManager, all - 33 members + conformance harness; mt 6/7/8/9 wire completion; remote AND - local-player cutovers — RemoteMoveToDriver, PlanMoveToStart, and the - whole B.6 auto-walk DELETED; the P1 autonomous-echo gate ported verbatim; - TS-36 bound — input/jump/teleport cancel movetos through the retail - interrupt chain; retired AD-8/AD-9/AP-8/AP-9/AD-26/TS-36; trail in the - plan doc). R2+R3+R4 visual pass PASSED 2026-07-03 (#161–#163 closed; - #164–#166 filed). **R5-V1 SHIPPED 2026-07-03 `3d89446d`** — PositionManager - facade + StickyManager + ConstraintManager + the full TargetManager voyeur - system ported to Core, fully tested, UNWIRED (purely additive; renamed the - misnamed `Physics.PositionManager` combiner → `RemoteMotionCombiner`); - ConstraintManager unarmed (issue #167, TS-35 stays). Decomp/ACE/plan: - `docs/research/2026-07-03-r5-managers/`. **R5-V2 SHIPPED 2026-07-03 `fffe90b3`** - — wired the voyeur system per-entity via `EntityPhysicsHost`, retired AP-79; - verified live (remote creatures chase). Same session fixed two PRE-EXISTING - streaming bugs surfaced while visual-gating V2 (the user's "world doesn't load" - report): **#168** pending-bucket trap (`315af02f`) + **#169** cold-spawn - streaming hole (`9b06a9b8`) — both verified live. Filed **#170** (remote - creature chase+attack animation divergence vs retail — glide/over-frequency/ - uniform attacks; decomposes into #159 + pending_motions loop + dead-reckon - glide). **#170 CLOSED + gated 2026-07-04** (`427332ac` re-dispatch flood + - `1051fc83` armed-moveto-always-ticks). **R5-V3 SHIPPED + gated 2026-07-04** - (#171 sticky melee, three slices `5bd2b8bc`/`7a823176`/`69966950` — TS-39 - retired, TS-43/TS-44/AP-82 filed). **R5-V4 behavioral items SHIPPED + gated - 2026-07-04 `f423884b`** (head style-on-change all mt types, #164 autonomy - bit, mt-0 wire flags consumed). **R5-V5 SHIPPED 2026-07-05 — the R5 arc is - DONE**: the `MovementManager` facade (retail acclient.h `/* 3463 */`, - methods 0x00524000-0x00524790) now owns each entity's interp+moveto pair — - ONE per entity (`RemoteMotion.Movement`, `PlayerMovementController.Movement`, - chase-harness mirror); the three wiring sites construct through - `MoveToFactory` + `MakeMoveToManager` (0x00524000) and the relay shapes - (`UseTime` 0x005242f0, `HitGround` 0x00524300, `HandleExitWorld` 0x00524350, - `CancelMoveTo` 0x005241b0, `HandleUpdateTarget` 0x00524790, - `PerformMovement` 0x005240d0) replace the loose-pair call sites. Structural, - zero behavior change; 15 facade conformance tests; suite 4052 green with the - 183-case/funnel/moveto/chase/sticky suites unmodified. Arc close-out: - `docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`. Open follow-ons - carried: #167 (ConstraintManager arming), TS-42 per-tick order (R6). -- **L.2g — Inbound motion interpretation (remote-entity CMotionInterp funnel).** - ACTIVE 2026-07-02. Port retail's inbound motion pipeline verbatim for ALL - remote entities (players, NPCs, monsters — one funnel, user-approved): - `move_to_interpreted_state` (0x005289c0) + `apply_interpreted_movement` - (0x00528600) + `StopInterpretedMotion` stop path + `MotionTableManager` - bookkeeping (`remove_redundant_links` / `CheckForCompletedMotions` / - `MotionDone`), delete the non-retail UP-pace→cycle inference layer, restore - the walk↔run link pose. Slice order S0–S6 + full deviation map (DEV-1..10, - adversarially verified): `docs/research/2026-07-02-inbound-motion-deviation-map.md`. - Acceptance: walk↔run toggle on an observed remote reacts at wire latency with - no compounding animation/position desync; no stop-slide; user visual - side-by-side vs retail observer. - -**Shipped (2026-06-30) — L.2b outbound wire parity (decomp-verbatim, tests-first):** -- **D1** — `MoveToState` now packs `RawMotionState` by retail default-difference - (`RawMotionState::Pack` 0x0051ed10): a field bit is set only when the value - differs from its retail default, so over-sent `forwardSpeed=1.0` / `holdKey=None` - are gone. New `AcDream.Core.Physics.RawMotionState` data type + `RawMotionStatePacker`. -- **D3** — MoveToState trailing byte = `(standingLongjump?2:0)|(contact?1:0)` - (`MoveToStatePack::Pack` 0x005168f0); explicit `contact`/`standingLongjump` params. -- **D4** — `JumpAction` rewritten to retail `JumpPack` (0x00516d10): extent · velocity · - full Position · 4 timestamps; spurious objectGuid/spellId removed. -- **D5** — audited + confirmed divergent (MTS over-stamping vs retail `SendMovementEvent`); - left unchanged, recorded as register **TS-33**, deferred to a dedicated cadence-port slice. -- Golden-byte tests (Core.Net.Tests) for all packers; full suite green. -- ACE smoke + user visual sign-off 2026-07-01: login → run/turn/RunLock all - accepted (server echoes player motion, no rejection), NPCs animate incl. - inbound MoveTo type-7 + TurnToHeading type-9 (no crash), graceful exit. -- Spec: `docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md`. **Acceptance:** - A developer can trace the active movement path: input/motion -> body @@ -1198,50 +1059,6 @@ port in any phase — no separate listing here. --- -### Track MP — Modern Pipeline (performance side track; user-commissioned 2026-07-05) - -> **⏸ PARKED 2026-07-05 (user decision) — WINS BANKED, REMAINDER DEFERRED TO THE FUTURE.** -> The track's actual trigger — the FPS/ms counter *fluctuating wildly* (read by the -> user as inefficiency) — is **RESOLVED**: MP0 (frame profiler), MP1a (Content -> extraction), and **MP-Alloc safe batch** shipped and merged to local main -> (`093cdb6d`, suite 4188 green, user-confirmed steady FPS). **ECS (MP3) + the -> rest are DEFERRED**: ECS is the *throughput* lever (raise avg FPS ~230→300), a -> DIFFERENT goal from steadiness — revisit ONLY if the user wants a higher raw FPS -> number and accepts the rewrite risk. Rust rejected. Parked (resume from the table -> below): MP1b pak/bake (needs the 865 GB EnvCell-dedup slice; loading-speed lever), -> MP-Alloc hard sites, MP1c, MP2. Full rationale: `project_mp_track_findings.md`. - -**Spec:** `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` (the -umbrella design — read it first). **Goal:** smoothness first (no frame over -~16 ms during any traversal), then 300+ FPS sustained in dense towns. -Perf-only per `feedback_render_perf_not_faithfulness_gated` — pixels and -game-feel stay identical; the simulation's retail-mirroring OO structure is -untouched. NOT part of M1.5 — runs in dedicated side-track sessions; M1.5 -critical path wins every conflict. - -**Sequencing note (2026-07-05, evidence-driven):** the steady-state allocation -triage (MP-Alloc) was **pulled forward ahead of the pak cutover (MP1c)** after a -54-site allocation audit proved the town GC churn is ~90–95% OUTSIDE the MP3 draw -surface (so MP3 won't fix it) and independent of the pak — it's the felt-most-often -smoothness tax and mostly easy faithfulness-neutral buffer-reuse work. See -`project_mp_track_findings.md`. Load-time smoothness (the pak, MP1c) is the rarer -hitch and follows. - -| Phase | What | Status | -|---|---|---| -| MP0 | Honest frame profiler (`[frame-prof]`) + baseline capture | ✅ SHIPPED 2026-07-05 — gate PASSED (`docs/research/2026-07-05-mp0-baseline.md`); headline: town spikes = GC churn (1.5–3 MB/frame), teleport hitch 211 ms | -| MP1a | `AcDream.Content` extraction (GL-free MeshExtractor + boundary records out of App) | ✅ SHIPPED 2026-07-05 — user-gated (renders identical, zero tripwires, perf-neutral); 8 commits `651d041e`..`b0758d77` | -| MP1b | Pak format + `acdream-bake` CLI + mmap zero-copy `PakReader` + equivalence test | 🔵 code + review-fixes done; **full-bake gate found 865 GB → EnvCell dedup slice REQUIRED before shipped** (spec §6.6) | -| **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 20–87ms → 6–10ms, alloc spikes (30–75MB single-frame) eliminated, gen2 GC 5–11/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` | -| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` pooling — the residual steady ~1.6MB/frame | ⚪ OPTIONAL follow-up — would lower the median too, but the wild-swing complaint is already resolved; each needs its own careful gate (batch correctness / physics faithfulness) | -| MP1c | Streaming cutover to pak + hitch gate (vs the 211 ms baseline) | ⚪ after MP-Alloc | -| MP2 | Retail distance-degrade port, hide-only cut (retires a divergence row) | ⚪ | -| MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever | -| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | ⚪ hard-queued behind M1.5 #137 | -| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated | - ---- - ## Cross-cutting work tracked in parallel - **Test coverage.** Each phase lands with unit + integration tests in `tests/`. Current count: 98 Core + 96 Core.Net = 194. Keep the ratio as new phases land. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 7d3286f6..fdbb5ef3 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -225,40 +225,6 @@ synthesis workarounds were removed by A6.P4. Remaining feel-level debt is tracked (#116 slide-response, partial Ghidra fix shipped; A7 indoor lighting fidelity not yet done — folded forward). -**Interleaved parity tracks (reconciled 2026-07-04 — NOT milestones).** Two -work streams have run alongside M1.5, both driven by the user's live -side-by-side-vs-retail sessions, both issue-level per the operating rules -(observed defects → ISSUES → fix → user gate), and both stay subordinate -to M1.5 rather than becoming milestones of their own: (1) **D.2b retail -UI** — inventory window / paperdoll / item interaction shipped and gated; -next container-switching (`claude-memory/project_d2b_retail_ui.md`); -(2) **the R5 movement-manager arc — DONE 2026-07-05** — the retail -MovementManager family port (TargetManager voyeur system V2, -StickyManager/PositionManager V3, V4 behavioral items, V5 MovementManager -facade — every entity now holds ONE `MovementManager` owning its -interp+moveto pair) that closed the #170 chase-slide and #171 pack-melee -gates (`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` has the -arc close-out). Rationale: -rule 1's "one active milestone" governs where NEW scope goes — these -tracks fix observed retail-parity defects in already-shipped systems, -which the operating rules have always allowed; declaring them milestones -would manufacture scope where a tactical ledger (ISSUES + handoffs) -already carries them. - -**Modern Pipeline side track (Track MP — user-commissioned 2026-07-05; NOT -a milestone, NOT part of M1.5).** A performance modernization arc (baked -asset pipeline → retail distance-degrade → ECS render world with delta -submission → zero-alloc frame loop) targeting smoothness first, then 300+ -FPS in dense towns. Spec: -`docs/superpowers/specs/2026-07-05-modern-pipeline-design.md`; phase table -in the roadmap ("Track MP"). **Freeze exception (explicit, user-authorized -2026-07-05):** this track may reopen the otherwise-frozen streaming and -WB-rendering subsystems — but only under its own gated phases (pixel- and -feel-identical conformance gates, legacy path deleted at each gate), never -as ad-hoc rework. Rule 2's freeze still binds all NON-MP work. MP runs in -dedicated side-track sessions; the M1.5 critical path wins every conflict, -and MP4 is hard-queued behind #137. - **Still OPEN in M1.5 — dungeon support (Phase G.3, issue #133).** Dungeons don't work: the streaming/load/render/physics pipeline was built entirely around outdoor landblocks (terrain + scattered buildings) and has no path @@ -488,7 +454,7 @@ Open the inventory panel — retail-skinned with the right font, icons, and local lighting). - **C.3** — Palette range tuning (skin/hair/eye colors match retail). - **C.4** — Double-sided translucent polys. -- **D.2b** — Custom retail-look UI backend. **In progress:** vitals + chat + toolbar + inventory (window manager, B-Grid, B-Controller, B-Wire, drag-drop, container-switching) + paperdoll (equip slots, 3-D doll UiViewport, Slots toggle) + **Character window** (Attributes tab, visually confirmed 2026-06-26) + **UI Studio** (standalone panel previewer + 26-window retail dump + interactive click-routing + headless screenshot) + **importer dat-fidelity** (Fix A/B/C: justification, FontColor, per-element FontDid resolver [studio-only; #157 tracks live-game path]) are shipped. Next: D.5.3 selected-object bar + spell shortcuts; #158 Character window polish deferred. +- **D.2b** — Custom retail-look UI backend. - **D.3–D.7** — AcFont + dat sprites + core panels reskinned + HUD orbs + cursor manager. - **L.1f** — NPC/monster + item-use animation coverage. diff --git a/docs/plans/2026-07-02-retail-motion-animation-rewrite.md b/docs/plans/2026-07-02-retail-motion-animation-rewrite.md deleted file mode 100644 index a3534d2d..00000000 --- a/docs/plans/2026-07-02-retail-motion-animation-rewrite.md +++ /dev/null @@ -1,174 +0,0 @@ -# Phase R — retail motion & animation stack, ground-up reconstruction - -Date: 2026-07-02. **Mandate (user, verbatim intent):** a complete new -movement + animation system, verbatim retail equivalent — all movement, -inbound and outbound, all animation, for players, NPCs, and monsters. No -frozen code, no bandaids, no guessing, no approximation. Total overhaul. - -**Execution shape:** a staged verbatim RECONSTRUCTION, not a big-bang -cutover — each stage builds a retail class 1:1 with a conformance harness, -cuts consumers over, and DELETES the legacy path in the same stage. "New -system" is the destination; the client keeps running between stages. The -cdb-golden technique is proven (183/183 live-retail dispatch conformance in -S2a) and is the acceptance mechanism for every stage: the user's eyes are a -final sanity pass only. - -Supersedes the L.2g S3–S6 slice plan (S1/S2/S5 landed and are absorbed as -components). This doc is the plan of record; the deviation map -(`docs/research/2026-07-02-inbound-motion-deviation-map.md`), the funnel -pseudocode (`…-s2-inbound-funnel-pseudocode.md`), and the 2026-06-04 -sequencer deep-dive are its research base. - -## The retail module map to reconstruct (per physics object, ALL classes) - -``` -CPhysicsObj (local player, remote player, NPC, monster — ONE pipeline) - ├─ MovementManager unpack_movement 0x00524440 (10-way dispatch), - │ │ PerformMovement, MotionDone relay - │ ├─ CMotionInterp raw_state + interpreted_state + pending_motions, - │ │ DoMotion / DoInterpretedMotion / StopInterpretedMotion, - │ │ apply_raw/interpreted/current_movement, my_run_rate, - │ │ HitGround / LeaveGround / ReportExhaustion, jump family - │ └─ MoveToManager movement types 6/7/8/9 (MoveToObject/Position, - │ TurnToObject/Heading), node stepping, arrival radii, - │ fail distance, CanCharge walk/run selection - ├─ CPartArray → MotionTableManager pending_animations, add_to_queue 0x0051bfe0, - │ │ remove_redundant_links 0x0051bf20, - │ │ CheckForCompletedMotions 0x0051be00 → AnimationDone - │ └─ CMotionTable::GetObjectSequence 0x00522860 - │ │ same-substate fast path (change_cycle_speed + - │ │ subtract/combine_motion), link path (get_link + - │ │ style-default double-hop), re_modify, is_allowed - │ └─ CSequence anim-node DLList, append_animation 0x00525510, - │ remove_cyclic_anims, clear_physics, velocity/omega - │ accumulators, update/update_internal + apply_physics - │ (root motion), placement frames, hook dispatch - ├─ PositionManager InterpolationManager [PORTED ✓ L.3] + StickyManager - │ + ConstraintManager - └─ per-tick UpdateObjectInternal 0x005156b0 order: - CPartArray.Update (CSequence root motion) → PositionManager.adjust_offset - (chase REPLACES) → Frame.combine → UpdatePhysicsInternal → FULL transition - sweep (ALL entities — retires the remote no-sweep fork) → MovementManager.UseTime - / CPartArray.HandleMovement / PositionManager.UseTime → process_hooks -``` - -## Already-verbatim components (absorbed, not rewritten) - -- `MotionSequenceGate` (S1) — is_newer 3-stamp gate, live-validated. -- Inbound funnel: `MoveToInterpretedState` / `ApplyInterpretedMovement` / - `DispatchInterpretedMotion` / verbatim `contact_allows_move` (S2a) + - 183-case live-trace conformance suite. -- `InterpolationManager` (L.3) — chase constants re-verified 2026-07-02. -- Outbound: `RawMotionState::Pack` default-difference, MoveToStatePack - trailer, JumpPack (L.2b/D6.2b, golden-byte tests); dual command catalogs - (L.1b); `adjust_motion`/`apply_run_to_command`/`apply_raw_movement`/ - `get_state_velocity` (D6). - -Everything else is reconstruction scope — especially: `AnimationSequencer` -internals (becomes the CSequence port's host or is replaced by it), the -three per-tick drive paths in `GameWindow.TickAnimations`, `RemoteMotionSink` -(temporary S2b seam — dissolves into GetObjectSequence), `RemoteMoveToDriver`, -`ServerControlledLocomotion`, the motion half of `PlayerMovementController`, -the 300 ms stop-detection window, NPC UP hard-snaps. - -## Stage plan (each: pseudocode → harness → port → cutover → DELETE legacy → register sweep) - -- **R1 — CSequence verbatim. SHIPPED 2026-07-02** (commits 1371c2a1 P0/P1, 778744bf P2, 5138b8fb P3, 658b91d8 P4, 9147344a P5, +P6): the verbatim core (AnimSequenceNode/CSequence/FrameOps, 56 conformance tests) + the AnimationSequencer adapter rehost deleting the legacy epsilon/stale-head/safety-cap/per-node-flag mechanisms; root motion flows through Advance(dt, Frame) = retail update(Frame*). Registers AD-33/AD-34. Node list, framerate/rate math, velocity+ - omega accumulators (set/combine/subtract), update/update_internal root - motion, apply_physics, placement frames, hook dispatch. Goldens: dat - MotionData fixtures + a cdb trace of append_animation/remove_cyclic_anims - args (script pattern: tools/cdb/l2g-observer.cdb). Cutover: becomes the - sequencer core behind the existing AnimationSequencer API, then the API - narrows to retail's. -- **R2 — GetObjectSequence + MotionTableManager. SHIPPED 2026-07-02 (pending - the stage visual pass).** Fast path, - link path (restores the walk↔run link pose — old S4), re_modify (modifier - blend — retires AP-73), pending_animations + remove_redundant_links + - CheckForCompletedMotions → AnimationDone→MotionDone chain (old S3). - RemoteMotionSink's single-cycle pick DELETED — GetObjectSequence decides. - Progress: Q0 pins (dc54a3e4) + Q1 MotionState (2345da30), Q2 CMotionTable all-4-branch - GetObjectSequence + statics (98f58db9, 44 tests), Q3 MotionTableManager+ - pending_animations (aa65990a, 47 tests), Q4 adapter cutover (3b9d9bb6 — - SetCycle/PlayAction → PerformMovement; Fix B / fast-path / stop-anim - fallback / G17 gate DELETED; 11-scenario trace conformance a6235a36). - Q5 RemoteMotionSink DELETED (d82f07d4 — funnel → MotionTableDispatchSink → - PerformMovement; AP-73 retired; spawn/despawn run initialize_state/ - HandleExitWorld; live smoke green: MOTIONDONE chain firing in-world). - Q6 sweep done with Q5. OUTSTANDING: the ONE user visual pass (walk↔run - stride continuity, turn-while-running legs, emote overlay, stop settle). - R3 prep done in parallel: research base 8eff3978 + W0 pins cd0289be (all - 10 ambiguities resolved, adversarially verified). -- **R3 — CMotionInterp completion. SHIPPED 2026-07-03 (pending the stage - visual pass).** pending_motions/MotionDone, DoMotion, jump family - (jump_charge_is_allowed/motion_allows_jump verbatim — the misattribution - found in S2a), HitGround/LeaveGround/ReportExhaustion, enter_default_state. - LOCAL PLAYER unified (edge-driven CommandInterpreter altitude; the - synthesis layer + UpdatePlayerAnimation deleted). - Trail: W-1 research 8eff3978, W0 pins cd0289be (A1-A10 adversarially - verified), W1 state completion 86649591, W2 pending_motions 37167991, - W3 jump family af476444, W4 ground transitions + K-fix18 DELETED - e214acdf, W5 one-DoInterpretedMotion + zero-tick flush df7b096d - (discovery: the dispatch RESULT gates queue+state writes — sink returns - bool), W6 local-player unification fb7beb70 (map fc5a2cda; discoveries: - ChargeJump never wired, CurrentHoldKey shadow-field staleness, the - autonomous-flag clobber). Registers through the arc: retired AP-73/ - AP-74/AP-78/TS-34/IA-4 + the Fix-B-class inventions; added AD-36 - (narrowed), AP-75/76/77, TS-35/36/37/38. EXPECTED-DIFFS for the visual - pass: #45 sidestep factor + ANIM_SPEED_SCALE retired (local now matches - remotes), auto-walk-at-run walk-pace legs (R4), ApplyServerRunRate echo - live through fast re-speed. R3+R2 share ONE visual pass. -- **R4 — MoveToManager verbatim. SHIPPED 2026-07-03 (pending the stage - visual pass).** Types 6/7/8/9 (TurnToObject/TurnToHeading — the dropped - D9/DEV-5 commands), node stepping, arrival, fail distance, CanCharge. - RemoteMoveToDriver + ServerControlledLocomotion.PlanMoveToStart + B.6 - auto-walk all DELETED. - Trail: research base 988304e1, V0 pins 386b1ce5 (P1-P7 resolved; P1 - autonomous-echo gate + P3 heading_diff mirror adversarially sealed, P3 - down to instruction bytes), V1 command-selection family e0d2492c - (GetCommand + CanCharge fast-path + MoveToMath), V2 the verbatim manager - addc8e97 (all 33 members, 101 tests, seam-injected harness), V3 wire - completion a144e873 (mt 8/9 parse + full params exposure + sticky - trailer), V4 remote cutover 7016b26c (per-remote manager, P4 - TargetTracker adapter, retail unpack dispatch; retired AD-8/AD-9/AP-8/ - AP-9; smoke log verified clean), V5 local-player cutover b3decdfa (P1 - gate ported verbatim — ACE's autonomous echo dropped before unpack; - B.6 deleted; TS-36 bound — input/jump/teleport cancel through the retail - interrupt chain; run-rate re-anchored to PD skills + mt-6/7 my_run_rate, - echo tap deleted with NO AD row; MoveToComplete client seam widened to - natural completion for AD-27; adversarial-review fixes: remote HitGround - relay, mt-8 wire_heading degrade, remote curTime clock), V6 register/ - docs sweep (AD-34 widened with the MoveToNode rename, NEW TS-39 StickTo/ - Unstick no-op seams → R5, TS-33 extended with the orientation-diff gap, - TS-21/AD-25/AP-24/AP-30 re-anchored). EXPECTED-DIFFS for the visual - pass: melee-range stop distance (retail cylinder distance — the AD-8 - max() class is gone), auto-walk legs now walk/run per CanCharge with - real turn cycles during corrections, walk-pace close-in demote. - OUTSTANDING: the stage visual pass (folds into the pending R2+R3 pass). -- **R5 — MovementManager + MovementSystem facade.** One per-object pipeline - for every entity class; StickyManager (stick_to_object — the motionFlags - 0x100 bit) + ConstraintManager ports; GameWindow's OnLiveMotionUpdated - shrinks to parse→MovementSystem.HandleMovementEvent. -- **R6 — per-tick UpdateObjectInternal order.** Retail tick order for ALL - entities incl. the transition sweep for remotes (retires the L.3-M2 - no-sweep fork + the path A/B split + the 300 ms stop window + NPC UP - hard-snap special cases). GameWindow.TickAnimations sheds its motion - logic entirely (Code Structure Rule 1). -- **R7 — outbound autonomy cadence.** ShouldSendPositionEvent (0x006b45e0) - + the MTS/AP stamp split — retires TS-33. Outbound becomes 100% verbatim. -- **R8 — cutover audit.** grep-sweep for legacy motion code, register - reconciliation (every AD/AP/TS motion row retired or re-justified), full - live protocol (walk/run/toggle/turn/circle/stop/jump/MoveTo/TurnTo, - player+NPC+monster), ONE final user visual pass. - -## Standing rules for every stage - -1. Decomp is the oracle; ACE the interpretation aid; cdb/TTD the runtime - arbiter when they disagree (the S0/S2 pattern). -2. No stage ships without its conformance fixtures. Golden sources: dat - tables, cdb traces (both observer + actor side), captured wire logs. -3. Every commit: build + full suite green; register rows added/retired in - the same commit; roadmap stage table updated on stage completion. -4. New code lives in `src/AcDream.Core/Physics/Motion/` (pure logic; GL-free) - with App seams only for rendering handoff (Structure Rule 2). -5. Delete, don't gate: when a stage cuts over, the legacy path is REMOVED in - that stage (mandate: no frozen code, no bandaids). diff --git a/docs/research/2026-06-15-chat-window-redrive-handoff.md b/docs/research/2026-06-15-chat-window-redrive-handoff.md deleted file mode 100644 index 33d12e92..00000000 --- a/docs/research/2026-06-15-chat-window-redrive-handoff.md +++ /dev/null @@ -1,135 +0,0 @@ -# Chat-window re-drive — session handoff (2026-06-15) - -**Status:** brainstorm STARTED (context gathered, design questions open) — not yet -designed or implemented. Resume with `superpowers:brainstorming`. - -**Branch:** `claude/hopeful-maxwell-214a12` — **continue UI work HERE** (the user's -call: UI stays on this branch; dungeon lighting / M1.5 goes to a *separate* branch -off `main`, it's unrelated and easy to merge). This branch is already current with -`main` (merged `5ac9d8c`). - ---- - -## Where we are (what shipped this session) - -**D.2b LayoutDesc importer — Plan 1 SHIPPED + flipped to default + post-flip fixes.** -The vitals window is now data-driven from the dat `LayoutDesc 0x2100006C` (no -per-window graphics code). Read **`claude-memory/project_d2b_retail_ui.md`** (the -SSOT crib) FIRST — it has the full architecture + every correction. Key commits: - -- `bf77a23` — flip: importer is the default vitals at `ACDREAM_RETAIL_UI=1`; the - hand-authored `vitals.xml` + the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired. -- `8aa643f` — horizontal resize: edge-anchor mapping corrected to retail - `UIElement::UpdateForParentSizeChange @0x00462640` (`RightEdge==1`=stretch). -- `43064ba` — stamina/mana numbers: `TextRenderer` now draws sprites in - **submission (painter) order** (was per-texture batched → later bars overpainted - their own numbers). -- `34243f2` — number sharpness: `DrawStringDat` **pixel-snaps** each glyph dest. - -**The importer toolkit to REUSE (all in `src/AcDream.App/UI/Layout/`):** -- `ElementReader` — `ElementInfo` POCO + `Merge` (BaseElement/BaseLayoutId - inheritance) + `ToAnchors` (edge-flag → AnchorEdges, decomp-correct). -- `UiDatElement` — generic per-DrawMode sprite renderer (the fallback widget). -- `DatWidgetFactory` — `Type → widget` hybrid: Type 7→`UiMeter`, 12→skip, else - generic; sets rect + anchors + `ZOrder=ReadOrder`. **Behavioral Types map to a - dedicated widget; the widget CONSUMES the element's children (leaf — importer - does not recurse them).** This is the pattern the chat re-drive extends. -- `LayoutImporter` — `Import`/`ImportInfos`/`Build`/`BuildFromInfos` + cycle-guarded - `Resolve`. `ImportedLayout.FindElement(id)` for binding by id. -- `VitalsController` — binds live data to widgets by element id (mirrors retail - `gmVitalsUI::PostInit`). The chat controller will mirror this. -- Format reference: **`docs/research/2026-06-15-layoutdesc-format.md`** (ElementDesc - API, Type table, DrawMode, inheritance). NOTE its §4 edge-flag history: the FIRST - reading was inverted; the CORRECT model (per `@0x00462640`) is now in the doc + - `ToAnchors` — `RightEdge==1`=stretch, `LeftEdge==2`=track-right. - ---- - -## Next task: re-drive the chat window through the importer (Plan 2 chat piece) - -Today the chat window is **hand-authored**, not data-driven. The goal mirrors the -vitals re-drive: read the chat window's dat `LayoutDesc`, build it via -`LayoutImporter`, and bind the live chat through a `ChatController`. - -### Current chat window (what to reproduce / replace) -- Built in `src/AcDream.App/Rendering/GameWindow.cs` in the `if (_options.RetailUi)` - block (~line 1836, "Retail chat window"). -- `UiNineSlicePanel` (hand-authored 8-piece chrome) at `(10,432)`, `440×184`, - `MinWidth 180 / MinHeight 80`, draggable + resizable. -- Hosts a `UiChatView` (`src/AcDream.App/UI/UiChatView.cs`): scrollable transcript, - **bottom-pinned**, mouse-wheel scrollback, **drag-select + Ctrl+C copy + Ctrl+A**, - whole-line vertical clipping. **READ-ONLY** (no input box). Uses the **debug - bitmap font** (`_debugFont`), NOT the dat font. `LinesProvider` polled each frame. -- Data: `ChatVM` (`displayLimit: 200`) → `RecentLinesDetailed()` → per-`ChatKind` - colour via `RetailChatColor(...)` (local static in GameWindow). - -### Chat pipeline (already shipped, Phase I — reuse, don't rebuild) -`ChatLog (Core) → ChatVM (UI.Abstractions) → view`; outbound `input → -ChatInputParser → LiveCommandBus → WorldSession`. See -`claude-memory/project_chat_pipeline.md`. The re-drive is a VIEW/layout change; the -pipeline stays. - -### Retail chat UI classes (decomp oracles — analogous to gmVitalsUI) -`gmMainChatUI`, `gmFloatyMainChatUI`, `gmFloatyChatUI`, `gmChatOptionsUI` -(`docs/research/named-retail/acclient.h` ~line 54923; `symbols.json` has -`gmMainChatUI::Register` etc.). Chat-layout notes: -`docs/research/retail-ui/05-panels.md:120` (chat window layout) + -`06-hud-and-assets.md:651` (every chat window layout is a `LayoutDesc`). - -### FIRST research step (the Task-1 analogue): identify the chat `LayoutDesc` id -The vitals id was `0x2100006C`; the chat window's id is **NOT yet known**. Find it: -- `dump-vitals-layout [0xId]` enumerates LayoutDescs (it already lists all - layouts containing given element ids). Use it to scan for the chat window, or grep - the decomp for the layout id referenced by `gmMainChatUI`/`gmFloatyMainChatUI`. -- Then dump it and enumerate its element Types (expect a scroll/list region + - scrollbar, maybe a text-input/edit element + channel tabs) — this drives the - factory/widget work. - ---- - -## Open design questions (resume the brainstorm here) - -1. **Scope.** Re-drive the EXISTING read-only window (frame from dat + reuse - `UiChatView` for the transcript, parity with today), OR expand to the FULL retail - chat (input box for typing, channel tabs)? Recommendation to discuss: do the - frame re-drive + transcript first (parity), defer input/tabs to a follow-up — - but confirm with the user. -2. **Behavioral widgets.** The chat dat layout introduces the long-tail Types the - vitals didn't have — Type 5 `ListBox`, Type 0xB `Scrollbar`, maybe Type 0xC - `Text`/edit. Two options: - - **(A, recommended) Hybrid reuse** — like Type-7→`UiMeter`: map the transcript - region's Type → the existing `UiChatView` (which already scrolls/selects/copies); - a `ChatController` binds the tail by element id. Minimal new code; fastest parity. - - **(B) Port faithful widgets** — implement `UiScrollbar`/`UiListBox` per the - decomp so the dat's scrollbar element drives scrolling. More faithful, more work; - better as a later step. -3. **Dat font for the transcript.** Switch `UiChatView` from the debug bitmap font - to the dat font (`UiDatFont`, faithful + now pixel-snapped) — OR keep the debug - font for parity first? `UiChatView`'s measure/selection logic is `BitmapFont`-based, - so a dat-font port is non-trivial (a `UiDatFont` measure/advance path + selection - hit-test rework). Likely a follow-up, not the first cut. - ---- - -## Watchouts / lessons (don't regress these) -- **`TextRenderer` draws sprites in submission order** (`_spriteSegs`). Do NOT revert - to per-texture batching — it overpaints later same-atlas text (the stamina/mana bug). -- **`DrawStringDat` pixel-snaps glyphs.** Keep it (sharp text on resize). -- **Edge-flag/anchor model is `@0x00462640`** (`RightEdge==1`=stretch). The format - doc §4's first reading was inverted; trust the corrected `ToAnchors`. -- **Behavioral widgets are leaf** — the factory's widget consumes the element's dat - children; the importer doesn't recurse into them. Apply the same to the chat - transcript widget. -- **Don't fabricate dat reader internals** — `Chorizite.DatReaderWriter` is a NuGet - package (not in `references/`); verify member names via the dump tool / reflection. - -## Process for the next session -1. Read `claude-memory/project_d2b_retail_ui.md`, this handoff, and - `docs/research/2026-06-15-layoutdesc-format.md`. -2. Resume `superpowers:brainstorming` — settle scope + behavioral-widget approach - (the 3 questions above), present a design, write the spec. -3. Then `superpowers:writing-plans` → `superpowers:subagent-driven-development` - (same flow that shipped the vitals importer cleanly). -4. Stay on `claude/hopeful-maxwell-214a12`. Visual checks: launch live (ACE on - `127.0.0.1:9000`) with `ACDREAM_RETAIL_UI=1`; test accounts `testaccount2 / - testpassword2` or `notan / MittSnus81!` (character `+Je`). diff --git a/docs/research/2026-06-15-layoutdesc-format.md b/docs/research/2026-06-15-layoutdesc-format.md deleted file mode 100644 index e3fb8b45..00000000 --- a/docs/research/2026-06-15-layoutdesc-format.md +++ /dev/null @@ -1,491 +0,0 @@ -# LayoutDesc Format Enumeration Reference - -**Date:** 2026-06-15 -**Author:** Task 1 of the LayoutDesc Importer plan (`docs/superpowers/plans/2026-06-15-layoutdesc-importer.md`) -**Sources:** -- Dat dumps: `dump-vitals-layout` on `0x2100006C`, `0x21000014`, `0x21000075`, `0x2100003F` -- Retail decomp: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR PDB) -- DatReaderWriter 2.1.7 reflection probe (deleted after use) - -This doc is the ground-truth API table for Tasks 2–6. Where it corrects a plan assumption, the correction is called out in **§ Corrections to plan assumptions** at the end. - ---- - -## 1. `ElementDesc` — exact API - -All members are **public fields** (not properties), except `ElementId`, `Type`, `BaseElement`, `BaseLayoutId`, `DefaultState`, `ReadOrder` which are also fields. There are no `ElementDesc` properties used by the importer. - -| Member | Kind | Type | Notes | -|--------|------|------|-------| -| `ElementId` | **field** | `uint` | unique element id (e.g. `0x100000E6`) | -| `Type` | **field** | `uint` | element class id — **not an enum in DRW**; raw uint | -| `BaseElement` | **field** | `uint` | base element id in base layout (0 = no base) | -| `BaseLayoutId` | **field** | `uint` | layout id where base element lives (0 = no base) | -| `DefaultState` | **field** | `UIStateId` (enum) | the element's initial active state | -| `ReadOrder` | **field** | `uint` | draw order within parent | -| `X` | **field** | `uint` | left position within parent, in pixels | -| `Y` | **field** | `uint` | top position within parent, in pixels | -| `Width` | **field** | `uint` | pixel width | -| `Height` | **field** | `uint` | pixel height | -| `ZLevel` | **field** | `uint` | z-order (0 in all vitals elements) | -| `LeftEdge` | **field** | `uint` | left anchor flag (see §4) | -| `TopEdge` | **field** | `uint` | top anchor flag (see §4) | -| `RightEdge` | **field** | `uint` | right anchor flag (see §4) | -| `BottomEdge` | **field** | `uint` | bottom anchor flag (see §4) | -| `StateDesc` | **field** | `StateDesc?` | the element's "DirectState" (no name); null if absent | -| `States` | **field** | `Dictionary` | named states (e.g. `HideDetail`, `ShowDetail`) | -| `Children` | **field** | `Dictionary` | child elements keyed by their `ElementId` | - -**Important:** `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are all `uint`, not `int` or `float`. Cast to `float`/`int` when constructing `ElementInfo`. - -The dump tool iterates both properties and fields; the scalars (`X`, `Y`, etc.) are found as **fields**. - ---- - -## 2. `StateDesc` — exact API - -| Member | Kind | Type | Notes | -|--------|------|------|-------| -| `StateId` | **field** | `uint` | redundant with the dict key | -| `PassToChildren` | **field** | `bool` | | -| `IncorporationFlags` | **field** | `IncorporationFlags` | | -| `Properties` | **field** | `Dictionary` | keyed by property-id (uint); see §3 | -| `Media` | **field** | `List` | polymorphic list of media items | - -### States dictionary key type - -`ElementDesc.States` is `Dictionary`. The dump shows string names like `"HideDetail"` and `"ShowDetail"` because the dump tool calls `.Key.ToString()` on the `UIStateId` enum values. The actual key is a `UIStateId` enum: - -```csharp -// Key: UIStateId.HideDetail = 268435462 (0x10000006) -// Key: UIStateId.ShowDetail = 268435463 (0x10000007) -``` - -See §6 for the full `UIStateId` enum. - -**Iterating in code:** -```csharp -foreach (var s in d.States) - ReadState(s.Value, s.Key.ToString(), info); // s.Key is UIStateId; .ToString() gives "HideDetail" etc. -``` - ---- - -## 3. Properties (`StateDesc.Properties`) — how font DID and fill are stored - -`StateDesc.Properties` is `Dictionary`. The `BaseProperty` base class has: -- `BasePropertyType PropertyType` (enum) -- `uint MasterPropertyId` -- `bool ShouldPackMasterPropertyId` - -Concrete subclasses (`DatReaderWriter.Types.*`): - -| Subclass | Field | Type | Notes | -|----------|-------|------|-------| -| `BoolBaseProperty` | `Value` | `bool` | | -| `IntegerBaseProperty` | `Value` | `int` | | -| `FloatBaseProperty` | `Value` | `float` | | -| `EnumBaseProperty` | `Value` | `uint` | | -| `DataIdBaseProperty` | `Value` | `uint` | a dat object DID | -| `ArrayBaseProperty` | `Value` | `List` | array of sub-properties | -| `ColorBaseProperty` | `Value` | `ColorARGB` | `struct { byte Blue, Green, Red, Alpha }` | -| `StringInfoBaseProperty` | `Value` | `StringInfo` | | -| `VectorBaseProperty` | `Value` | `Vector3` | | -| `Bitfield32BaseProperty` | `Value` | `uint` | | -| `Bitfield64BaseProperty` | `Value` | `ulong` | | -| `InstanceIdBaseProperty` | `Value` | `uint` | | -| `StructBaseProperty` | `Value` | `Dictionary` | | - -### Property key meanings (confirmed from decomp + dat inspection) - -| Key | Type found in dat | Meaning | Decomp ref | -|-----|-------------------|---------|-----------| -| `0x1A` | `ArrayBaseProperty` (contains `DataIdBaseProperty`) | **Font DID** — array with one item; the inner `DataIdBaseProperty.Value` is the font dat object id | `UIElement_Text::SetFontDIDHelper(this, 0x1a, ...)` @`0x46829e` | -| `0x1B` | `ArrayBaseProperty` (contains `ColorBaseProperty`) | **Font color** — array with one item; `ColorARGB {R,G,B,A}` | `UIElement_Text::SetFontColorHelper(this, 0x1b, ...)` @`0x4682c2` | -| `0x14` | `EnumBaseProperty` | **Horizontal justification** | `UIElement_Text::SetHorizontalJustification` @`0x467200` | -| `0x15` | `EnumBaseProperty` | **Vertical justification** | `UIElement_Text::SetVerticalJustification` @`0x467230` | -| `0x1C` / `0x1D` | `ArrayBaseProperty` | Tag font color / tag font | (secondary font style for in-text tags) | -| `0x16` | `BoolBaseProperty` | Some text flag | | -| `0x21` | `BoolBaseProperty` | One-line mode | | -| `0x23` | `IntegerBaseProperty` | Left margin | | -| `0x24` | `IntegerBaseProperty` | Top margin | | -| `0x25` | `IntegerBaseProperty` | Right margin | | -| `0x26` | `IntegerBaseProperty` | Bottom margin | | -| `0x27` | `BoolBaseProperty` | Some text option | | -| `0x20` | `BoolBaseProperty` | Some text option | | -| `0x69` | — (NOT in dat) | **Fill percent** — set at runtime via `UIElement::SetAttribute_Float(meter, 0x69, fillRatio)` | `gmVitalsUI::Update` @`0x4bff2a` | -| `0xCB` | `BoolBaseProperty` | Some text option | | - -**Critical point for font DID extraction:** -Property `0x1A` is an `ArrayBaseProperty` containing ONE `DataIdBaseProperty`. To read the font DID: -```csharp -if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0) - if (arr.Value[0] is DataIdBaseProperty did) - fontDid = did.Value; // e.g. 0x40000000 -``` - -**Confirmed for element `0x10000376` (the vitals text prototype):** -- Property `0x1A` → `DataIdBaseProperty.Value = 0x40000000` (font DID) -- Property `0x1B` → `ColorBaseProperty.Value = {B=255,G=255,R=255,A=255}` (white) - -**The fill (`0x69`) is NOT in the dat.** It is pushed at runtime by `gmVitalsUI::Update` calling `UIElement::SetAttribute_Float(meter, 0x69, ratio)`. The importer does not read this from the dat — the `VitalsController` sets it via `UiMeter.Fill` after binding. - ---- - -## 4. Edge-anchor flags (`LeftEdge`/`TopEdge`/`RightEdge`/`BottomEdge`) - -These are `uint` fields on `ElementDesc`. The values found across all four vitals layouts are: - -| Value | Meaning | Where observed | -|-------|---------|---------------| -| `0` | Not present / no constraint | Base layout `0x2100003F` (zero-size elements) | -| `1` | **Stretch / track-far** — for LeftEdge: pin left (near); for RightEdge: stretch (track parent's right edge); for TopEdge: pin top; for BottomEdge: stretch (track parent's bottom) | Most vitals pieces | -| `2` | **Track-right (for LeftEdge) / fixed-far (for RightEdge)** — LeftEdge=2 means the element's LEFT side tracks the parent's RIGHT edge (fixed-width piece that moves right); RightEdge=2 means the right edge is fixed relative to the parent right (no stretch) | Corners/right-side pieces | -| `3` | **Centered / floating** — contributes no anchor on that axis | The expand-detail overlay child `0x100004A9` | -| `4` | **Both-sides** — both near AND far edges fire simultaneously | Seen in child layout meter elements | - -### Anchor logic (retail-faithful, per `UIElement::UpdateForParentSizeChange @0x00462640`) - -The **far-axis fields** (RightEdge, BottomEdge) drive stretch: -- **RightEdge==1** ⇒ the right edge tracks the parent's right edge (**STRETCH**; designRight+delta) -- **RightEdge==2** ⇒ designRight is fixed (no stretch) -- **LeftEdge==2** ⇒ a fixed-width piece's left side tracks the parent's right edge (it **moves right**) -- **LeftEdge==1** ⇒ pin left at designX (near-pin) -- **value==4** ⇒ both near AND far fire simultaneously (stretch + keep near) -- **value==3** ⇒ centered / floating (no anchor on that axis) -- **value==0** ⇒ no anchor (prototype-only) - -This is the INVERSE of the earlier §Corrections reading ("1=near, 2=far"), which was wrong. The decomp is authoritative: `UIElement::UpdateForParentSizeChange @0x00462640` in `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 108459–108668. - -**Correct `ToAnchors` logic (as implemented in `ElementReader.cs`):** -```csharp -// Per UIElement::UpdateForParentSizeChange @0x00462640 -public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom) -{ - var a = AnchorEdges.None; - if (left == 1 || left == 4) a |= AnchorEdges.Left; - if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right; - if (top == 1 || top == 4) a |= AnchorEdges.Top; - if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom; - if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left - return a; -} -``` - -**Verified against all 19 vitals pieces** (format doc §11). At-rest render (no resize) is pixel-identical — anchors only fire on resize. Value `3` contributes no anchor on its axis and falls through to the Left|Top default only when all four values are 3 or 0. - ---- - -## 5. `MediaDesc` kinds - -`StateDesc.Media` is `List`. The concrete types found across the vitals layouts: - -| Subclass | Fields | Used in vitals? | Notes | -|----------|--------|----------------|-------| -| `MediaDescImage` | `uint File`, `DrawModeType DrawMode`, `MediaType Type` | YES — all sprite images | The primary media type | -| `MediaDescCursor` | `uint File`, `uint XHotspot`, `uint YHotspot`, `MediaType Type` | YES — grip/dragbar cursor | Sets the mouse cursor when hovering the element | -| `MediaDescAnimation` | `float Duration`, `DrawModeType DrawMode`, `List Frames`, `MediaType Type` | not in vitals | Animated sprite | -| `MediaDescAlpha` | `uint File`, `MediaType Type` | not in vitals | Alpha overlay | -| `MediaDescFade` | `float StartAlpha, EndAlpha, Duration`, `MediaType Type` | not in vitals | Fade transition | -| `MediaDescSound` | `uint File`, ... | not in vitals | | -| `MediaDescState` | `UIStateId StateId`, ... | not in vitals | State transition | -| `MediaDescJump` | `uint JumpItemIndex`, ... | not in vitals | | -| `MediaDescMessage` | `uint Id`, ... | not in vitals | | -| `MediaDescPause` | `float MinDuration, MaxDuration`, ... | not in vitals | | -| `MediaDescMovie` | `PStringBase FileName`, ... | not in vitals | | - -Elements can have **multiple media items** in the same `StateDesc.Media` list — e.g. a grip element has both a `MediaDescImage` (the sprite) and a `MediaDescCursor` (the cursor shape). Iterate all items; for rendering pick the `MediaDescImage`; for cursor behavior pick `MediaDescCursor`. - ---- - -## 6. `DrawModeType` enum (confirmed from reflection) - -`DatReaderWriter.Enums.DrawModeType` (the type on `MediaDescImage.DrawMode`): - -| Name | Value | Behavior | Used in vitals? | -|------|-------|----------|----------------| -| `Undefined` | 0 | (not used) | no | -| `Normal` | 1 | **Tile at native width** (UV-repeat; matches `ImgTex::TileCSI` @`0x53e740`) | YES — all bar sprites, chrome | -| `Overlay` | 2 | Blended overlay (not observed in vitals) | no | -| `Alphablend` | 3 | **Blended overlay** — used for the "ShowDetail" expand panels | YES — `ShowDetail` state sprites | - -**The vitals window uses only `Normal` (1) and `Alphablend` (3).** No `Stretch` value exists in `DrawModeType` — the plan's mention of a "Stretch" draw-mode is NOT a value in this enum. There is a `MediaType.Stretch = 12` in a separate enum but that refers to a different concept (animation sequence? not a blit mode). Do not branch on `Stretch` in `UiDatElement`. - ---- - -## 7. `UIStateId` enum (key type for `ElementDesc.States`) - -`DatReaderWriter.Enums.UIStateId`. Key values relevant to the vitals window: - -| Name | Value | -|------|-------| -| `Undef` | 0 | -| `Normal` | 1 | -| `HideDetail` | 268435462 (= `0x10000006`) | -| `ShowDetail` | 268435463 (= `0x10000007`) | -| `IsCharacter` | 268435542 (= `0x10000056`) | -| `IsAccount` | 268435543 (= `0x10000057`) | - -The dump prints these as strings ("HideDetail", "ShowDetail") via `UIStateId.ToString()`. When iterating `d.States`, `s.Key.ToString()` gives the readable name. - ---- - -## 8. Type → meaning → render method → widget bucket - -From `UIElement::RegisterElementClass` calls in the decomp. The mapping is CONFIRMED by retail: - -| Type (uint) | Class registered | Render method | Widget bucket | Vitals? | -|-------------|-----------------|---------------|---------------|---------| -| 0 | — (no registration) | text label; inherits from `UIElement_Text` behavior via `UIElement_Scrollable` | **behavioral** → dat-font label widget | YES — the text overlay (e.g. `0x100000EB/ED/EF`) | -| 1 | `UIElement_Button::Register()` | `UIRegion::DrawHere` (vtable) | **behavioral** → button widget | no | -| 2 | `UIElement_Dragbar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (drag region) | YES — top/bottom drag bars | -| 3 | `UIElement_Field::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` | YES — container/group elements, chrome corners/edges | -| 4 | (unregistered in stdlib; may be custom) | — | generic fallback | no | -| 5 | `UIElement_ListBox::Register()` | `UIRegion::DrawHere` | **behavioral** → list widget | no | -| 6 | `UIElement_Menu::Register()` | `UIRegion::DrawHere` | **behavioral** → menu widget | no | -| **7** | `UIElement_Meter::Register()` | **`UIElement_Meter::DrawChildren`** @`0x46fbd0` | **behavioral** → `UiMeter` | **YES — the three vitals bars** | -| 8 | `UIElement_Panel::Register()` | `UIRegion::DrawHere` | generic → `UiDatElement` | no | -| 9 | `UIElement_Resizebar::Register()` | `UIRegion::DrawHere` | **generic** → `UiDatElement` (grip) | YES — resize grips (corners + edges) | -| 0xB | `UIElement_Scrollbar::Register()` | `UIRegion::DrawHere` | **behavioral** → scrollbar | no | -| **0xC** | `UIElement_Text::Register()` | `UIElement_Text::DrawSelf` @`0x467aa0` | **behavioral** → dat-font label | YES — Type=0 elements have BaseElement which resolves to a Type=0x0C in the base | -| 0xD | `UIElement_Viewport::Register()` | — | behavioral → 3D viewport | no | -| 0xE | `UIElement_Browser::Register()` | — | behavioral → browser | no | -| 0x10 | `UIElement_ColorPicker::Register()` | — | behavioral → color picker | no | -| 0x11 | `UIElement_GroupBox::Register()` | — | behavioral → group box | no | -| **0x12** | — (Type=12 in base layout) | No render method registered — these are **style prototypes** (zero-size elements used as `BaseElement` sources, never instantiated directly) | skip/omit | YES — `0x2100003F` is full of Type=12 elements | -| 0x13–0x19 | `ConfirmationDialog*` / `MessageDialog*` / etc. | dialog widgets | behavioral → dialog | no | -| 0x1000xxxx | `gmVitalsUI`, `gmAttributeUI`, etc. | game-specific custom classes | **custom widget** (registered with high ids) | YES — the stacked vitals window root `0x100005F9` has `Type=268435533=0x10000009`; the floaty row root has Type=`268435465=0x10000009`… actually see below | - -### Root element types in the vitals layouts - -- `0x2100006C` root element `0x100005F9`: `Type = 268435533 = 0x10000009` → `gmVitalsUI::Register` registers type `0x10000009` -- `0x21000014` root element `0x100000E5`: `Type = 268435465 = 0x10000009` — wait, `268435465 = 0x10000009` ✓ - -Actually: `268435533 = 0x1000000D` (not 9). Let me recompute: -- `268435533 decimal`: `268435456 + 77 = 0x10000000 + 0x4D = 0x1000004D` — that's `gmVitalsUI`-ish but a different id. -- `268435465`: `268435456 + 9 = 0x10000009` — confirmed `gmVitalsUI` type. - -The correct decomp cross-check: `UIElement::RegisterElementClass(0x10000009, gmVitalsUI::Create)` @`0x4bfe1a`. The stacked vitals window root `0x100005F9` has `Type=268435533`. `268435533 = 0x1000004D` which would be a different registered type. The floaty row root `0x100000E5` has `Type=268435465 = 0x10000009` = confirmed `gmVitalsUI`. - -The key observation: **the root element's Type selects the `gmVitalsUI` C++ class**, which is the window-level controller. In our importer, we don't need to match this: the `LayoutImporter` walks children, and the `VitalsController` binds the meter elements by id directly — the root type is irrelevant to Plan 1. - -**Plan 1 relevant types (vitals window only):** - -| Type | Role | Bucket | -|------|------|--------| -| 0 | text overlay label (BaseElement → Type 12 for font, but the element itself renders as text) | behavioral → dat-font label | -| 2 | drag bar (top/bottom) | generic | -| 3 | container / chrome edge / corner (no children hierarchy in vitals) | generic | -| 7 | meter | behavioral → `UiMeter` | -| 9 | resize grip (corners + edges) | generic | -| 12 | style prototype — zero-size, never directly rendered | skip | -| 0x10000009 | `gmVitalsUI` root — the window itself | behavioral → window root (use as container) | -| 0x1000004D | the stacked-window root | same | - ---- - -## 9. `LayoutDesc` fields - -| Member | Kind | Type | Notes | -|--------|------|------|-------| -| `Id` | property | `uint` | dat object id | -| `HeaderFlags` | property | `DBObjHeaderFlags` | | -| `DBObjType` | property | `DBObjType` | always `LayoutDesc` | -| `DataCategory` | property | `uint` | | -| `Width` | **field** | `uint` | screen-space width context (800 in all observed layouts) | -| `Height` | **field** | `uint` | screen-space height context (600 in all observed layouts) | -| `Elements` | **field** | `HashTable` (DRW-internal type) | top-level elements, keyed by `ElementId`. Iterable with `foreach (var kv in ld.Elements)`. | - ---- - -## 10. Inheritance chain for vitals number-text elements - -All three vitals text labels (`0x100000EB` health, `0x100000ED` stamina, `0x100000EF` mana) share: -- `Type = 0` (text element, no render registration — renders via inherited machinery) -- `BaseElement = 268436342 = 0x10000376` -- `BaseLayoutId = 553648191 = 0x2100003F` - -The base element `0x10000376` in `0x2100003F`: -- `Type = 12` (style prototype — zero-size, never rendered directly) -- `StateDesc.Properties`: - - `0x1A` → `ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]` — **font DID = `0x40000000`** - - `0x1B` → `ArrayBaseProperty[ ColorBaseProperty{R=255,G=255,B=255,A=255} ]` — white - - `0x14` → `EnumBaseProperty{Value=1}` — horizontal justification = 1 - - `0x15` → `EnumBaseProperty{Value=1}` — vertical justification = 1 - - `0x23`, `0x25` → `IntegerBaseProperty{Value=0}` — margins - -The inheritance chain for the text element in the importer is: -``` -derived (Type=0, no StateDesc media, no font prop itself) - inherits from base 0x10000376 in layout 0x2100003F (Type=12) - → font DID = 0x40000000 (from property 0x1A) - → font color = white ARGB(255,255,255,255) (from property 0x1B) -``` - -The derived text element overrides `Width/Height/X/Y` (from the dat element's fields) but inherits the font DID and color from the base element's `Properties`. - -**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements are **skipped entirely**: `Type = 0` (derived) inherits `Type = 12` from the base prototype `0x10000376` via `ElementReader.Merge` (zero-wins-nothing rule — the derived Type 0 inherits the base's Type 12), and `DatWidgetFactory` returns null for Type 12. This means no `UiDatElement` is created for them. For the vitals window this is correct: the numbers render via `UiMeter.Label` bound by the `VitalsController`, not a dat text node. A dedicated dat-text widget (Type 0) is Plan 2. - ---- - -## 11. Vitals window `0x2100006C` — confirmed element map - -Root: `0x100005F9` (160×58, Type=`0x1000004D`, LeftEdge=1, TopEdge=1, RightEdge=1, BottomEdge=2) - -### Chrome (all Type=3, `DrawMode=Normal`) - -| Id | X | Y | W | H | LeftEdge | TopEdge | RightEdge | BottomEdge | Sprite | -|----|---|---|---|---|----------|---------|-----------|------------|--------| -| `0x10000633` | 0 | 0 | 5 | 5 | 1 | 1 | 2 | 2 | `0x060074C3` (TL corner) | -| `0x10000634` | 5 | 0 | 150 | 5 | 1 | 1 | 1 | 2 | `0x060074BF` (top edge) | -| `0x10000635` | 155 | 0 | 5 | 5 | 2 | 1 | 1 | 2 | `0x060074C4` (TR corner) | -| `0x10000636` | 0 | 5 | 5 | 48 | 1 | 1 | 2 | 1 | `0x060074C0` (left edge) | -| `0x10000637` | 0 | 53 | 5 | 5 | 1 | 2 | 2 | 1 | `0x060074C5` (BL corner) | -| `0x10000638` | 5 | 53 | 150 | 5 | 1 | 2 | 1 | 1 | `0x060074C1` (bottom edge) | -| `0x10000639` | 155 | 53 | 5 | 5 | 2 | 2 | 1 | 1 | `0x060074C6` (BR corner) | -| `0x1000063A` | 155 | 5 | 5 | 48 | 2 | 1 | 1 | 1 | `0x060074C2` (right edge) | - -### Drag bars (Type=2) - -| Id | X | Y | W | H | Notes | -|----|---|---|---|---|-------| -| `0x1000063C` | 5 | 0 | 150 | 5 | top drag bar; also has `MediaDescCursor` cursor `0x06006119` | -| `0x10000640` | 5 | 53 | 150 | 5 | bottom drag bar; same cursor | - -### Resize grips (Type=9 — corners + edges) - -| Id | X | Y | W | H | Corner/Edge | -|----|---|---|---|---|-------------| -| `0x1000063B` | 0 | 0 | 5 | 5 | TL grip | -| `0x1000063D` | 155 | 0 | 5 | 5 | TR grip | -| `0x1000063E` | 0 | 5 | 5 | 48 | left grip | -| `0x1000063F` | 0 | 53 | 5 | 5 | BL grip | -| `0x10000641` | 155 | 53 | 5 | 5 | BR grip | -| `0x10000642` | 155 | 5 | 5 | 48 | right grip | - -Each grip has a `MediaDescImage` + a `MediaDescCursor` in its `StateDesc.Media` list. - -### Meter elements (Type=7 — `UiMeter`) - -| Id | X | Y | W | H | Purpose | -|----|---|---|---|---|---------| -| `0x100000E6` | 5 | 5 | 150 | 16 | Health meter | -| `0x100000EC` | 5 | 21 | 150 | 16 | Stamina meter | -| `0x100000EE` | 5 | 37 | 150 | 16 | Mana meter | - -Each meter has: -- Child `0x100000E7` (back layer, Type=3): three sub-children `E8`/`E9`/`EA` (left/center/right slices, back sprites) - - `E8` has `RightEdge=2` (pin far right), `EA` has `LeftEdge=2` (pin far left) — the classic 3-slice anchor pattern -- Child `0x00000002` (front layer container, Type=3): three sub-children `E8`/`E9`/`EA` (front sprites), plus child `0x100004A9` (expand detail overlay, HideDetail/ShowDetail states) -- Child `0x100000EB/ED/EF` (text label, Type=0): BaseElement=`0x10000376`, BaseLayoutId=`0x2100003F` → inherits font `0x40000000` - -### Sprite ids confirmed from dump - -**Health bar** (back=`E7` layer / front=`00000002.E8-EA` layer): -- Back left: `0x0600747E`, center: `0x0600747F`, right: `0x06007480` -- Front left: `0x06007481`, center: `0x06007482`, right: `0x06007483` -- ShowDetail overlay: `0x06007490` (back) / `0x06007491` (front) - -**Stamina bar:** -- Back left: `0x06007484`, center: `0x06007485`, right: `0x06007486` -- Front left: `0x06007487`, center: `0x06007488`, right: `0x06007489` -- ShowDetail: `0x06007492` / `0x06007493` - -**Mana bar:** -- Back left: `0x0600748A`, center: `0x0600748B`, right: `0x0600748C` -- Front left: `0x0600748D`, center: `0x0600748E`, right: `0x0600748F` -- ShowDetail: `0x06007494` / `0x06007495` - ---- - -## 12. Inheritance resolution rules - -1. If `d.BaseElement != 0 && d.BaseLayoutId != 0`: load base layout, find base element, call `Resolve()` recursively on it, then `Merge(base, derived)`. -2. Merge semantics: **derived overrides, base is the default**. `Width`/`Height`/`X`/`Y` come from the derived element's fields (even if zero — zero is a valid override for prototypes). `FontDid` is inherited if the derived element's base chain provides it and the derived doesn't explicitly set it. -3. Type=12 elements in the base layout (`0x2100003F`) are pure property stores — **never render them**. They exist only to be referenced as `BaseElement`. -4. Cycle-guard: track already-visited `(BaseLayoutId, BaseElement)` pairs to avoid infinite loops. - ---- - -## § Corrections to plan assumptions - -### 1. Edge-flag semantics are INVERTED from the earlier §4 reading - -**Original §4 reading (Task 2 shipped):** `1=near, 2=far, 4=stretch` → `right==2||right==4` for Right anchor. -**That was wrong.** The correct semantics, per `UIElement::UpdateForParentSizeChange @0x00462640`: - -| Edge value | LeftEdge meaning | RightEdge meaning | -|-----------|-----------------|------------------| -| 0 | no anchor | no anchor | -| 1 | pin left (near) → **Left** | track parent's right edge (stretch) → **Right** | -| 2 | track parent's right edge (moves right) → **Right** | fixed right (no stretch) | -| 3 | centered / floating (no anchor) | centered / floating (no anchor) | -| 4 | both-sides → **Left + Right** | both-sides → **Left + Right** | - -The far-axis field (RightEdge, BottomEdge) value `1` means **stretch** (track the parent's far edge), NOT "near-pin." This is the INVERSE of what was documented in the original §4. - -**Correct `ToAnchors` (as fixed in `ElementReader.cs` 2026-06-15):** -```csharp -// Per UIElement::UpdateForParentSizeChange @0x00462640 -public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom) -{ - var a = AnchorEdges.None; - if (left == 1 || left == 4) a |= AnchorEdges.Left; - if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right; - if (top == 1 || top == 4) a |= AnchorEdges.Top; - if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom; - if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; - return a; -} -``` - -Also: the `ElementReader.ToAnchors` signature in the plan uses `(int left, ...)` but the fields are `uint`. Use `(uint left, ...)` or cast at call site. - -### 2. `X`, `Y`, `Width`, `Height`, `LeftEdge`, etc. are `uint`, not `float` or `int` - -The plan's `ToInfo()` code uses `d.X, d.Y` etc. as though they are already numeric-assignable. They are `uint`, so the assignment `X = d.X` etc. requires an explicit cast `(float)d.X` in the `ElementInfo` struct. - -### 3. `ElementDesc.Type` is `uint`, not an enum - -The plan writes `(int)d.Type`. `d.Type` is `uint`, so `(int)d.Type` is valid C# (checked context would overflow for values > `int.MaxValue`, but the registered types are all small or `0x10000009` which fits in int). Better: store `Type` as `uint` in `ElementInfo` to avoid signed overflow on game-specific ids like `0x1000004D`. - -### 4. `DrawModeType` has no `Stretch` value - -The plan mentions handling `Stretch` in `UiDatElement`. The `DrawModeType` enum has only `{Undefined=0, Normal=1, Overlay=2, Alphablend=3}`. There is no `Stretch` draw mode in this enum. Drop the `Stretch` branch. - -### 5. `d.States` key is `UIStateId`, not `string` - -The plan writes `foreach (var s in d.States) ReadState(s.Value, s.Key, info);` treating `s.Key` as a string. The key is `UIStateId` (an enum). Use `s.Key.ToString()` for the string name, or compare directly via `UIStateId.HideDetail` etc. - -### 6. Font DID is in `ArrayBaseProperty`, not a direct property - -The plan's `// font DID (property 0x1A) read here once the format doc confirms the property API.` comment is the right place. The actual read is: -```csharp -if (sd.Properties.TryGetValue(0x1Au, out var raw) && raw is ArrayBaseProperty arr && arr.Value.Count > 0) - if (arr.Value[0] is DataIdBaseProperty did) - info.FontDid = did.Value; -``` - -### 7. Fill (`0x69`) is NOT in the dat - -The plan says `SetAttribute_Float(meter, 0x69, fillRatio)` is a runtime operation. Confirmed: property `0x69` does not appear in any dat layout. The fill is set at runtime by the controller. The importer should not attempt to read it. - -### 8. Type=12 elements are style prototypes — skip them entirely - -Elements with `Type=12` in the base layout `0x2100003F` are zero-size property bags used as `BaseElement` sources. They should not be instantiated as widgets. The `DatWidgetFactory` switch should have a `12 => null` (skip) case, or the importer should skip top-level elements with `Width==0 && Height==0 && Type==12` — though the safest check is just `Type == 12`. - ---- - -## § Plan 1 surface vs long tail - -**Plan 1 (vitals conformance) uses:** -- Types: 2, 3, 7, 9, 12 (skip), 0 (text, generic fallback), 0x10000009/0x1000004D (root window — treat as container) -- DrawModes: `Normal` (1), `Alphablend` (3) -- Media: `MediaDescImage`, `MediaDescCursor` -- Properties: `0x1A` (font DID, from inheritance), `0x1B` (font color, from inheritance) -- States: `HideDetail`, `ShowDetail` - -**Plan 2 (long tail):** -- Types: 1 (button), 5 (listbox), 6 (menu), 8 (panel), 0xB (scrollbar), 0xC (text widget proper), 0xD (viewport), 0x10 (color picker), 0x11 (groupbox), dialog types (0x13–0x19), all `gm*UI` custom types -- DrawModes: `Overlay` (2), any future additions -- Media: `MediaDescAnimation`, `MediaDescFade`, `MediaDescSound`, `MediaDescState`, etc. diff --git a/docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md b/docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md deleted file mode 100644 index 3834ba94..00000000 --- a/docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md +++ /dev/null @@ -1,115 +0,0 @@ -# Handoff — next UI phase: action bar / quick slots + inventory + equipment (paperdoll) - -**Date:** 2026-06-16 -**From:** the session that landed the D.2b widget generalization (merged to `main` at `78c9187`). -**Purpose:** kick off a **deep retail-faithful research phase** for the next three game panels, before any implementation. This doc + the new-session prompt at the bottom are the entry point. - ---- - -## 1. Where we are (what you're building on) - -The **D.2b retail-UI toolkit is complete and on `main`.** You have: - -- **A generic, Type-registered widget toolkit** built by `DatWidgetFactory` from the dat `LayoutDesc`: `UiButton` (Type 1), `UiMenu` (6), `UiMeter` (7), `UiScrollbar` (11), `UiText` (12), plus `UiField` (editable, controller-placed) and `UiDatElement` (generic chrome/container fallback). All in `src/AcDream.App/UI/`. -- **The assembly pattern**: a window is a dat `LayoutDesc` → `LayoutImporter.Import(...)` walks the `ElementDesc` tree → `DatWidgetFactory` builds each element generically → a thin **`gm*UI::PostInit`-style controller** finds widgets by id (`layout.FindElement(id) as UiX`) and binds live data/behavior. See `VitalsController` and `ChatWindowController` for the two worked examples. -- **Key toolkit rules** (read `claude-memory/project_d2b_retail_ui.md` first — it's the START-HERE digest with the full DO-NOT-RETRY list): - - `UiElement.ConsumesDatChildren` — behavioral widgets are **leaf** (the importer doesn't build their dat sub-elements; they reproduce them procedurally). - - The base-chain Type resolution (`ElementReader.Merge`) already surfaces each element's real registered Type. - - Type 3 is **chrome/container** in acdream's layouts (stays `UiDatElement`), NOT a factory-registered editable Field. - -**This phase's job is to build the next real game panels on that toolkit** — but they're complex (live item state, drag-drop, wire messages, icon rendering), so we research first per the project's mandatory **grep named → decompile → cross-ref → pseudocode → port** workflow (CLAUDE.md). - -These panels are the roadmap's **F.5 / D.5 "core panels"** (Attributes / Skills / Paperdoll / Inventory / Spellbook). - ---- - -## 2. The three targets + their retail entry points (concrete anchors) - -All confirmed from the named decomp (`docs/research/named-retail/acclient_2013_pseudo_c.txt`): - -### A. Action bar / quick slots → `gmToolbarUI` (element class `0x10000007`) -The retail "toolbar" is the shortcut bar. Confirmed methods (grep `gmToolbarUI::`): -- `UseShortcut(slot)`, `AddShortcut`, `RemoveShortcut`, `RemoveShortcutInSlotNum`, `FlushShortcuts`, `CreateShortcutToItem`, `IsShortcutEligible(ACCWeenieObject*)`, `IsShortcutSlotAvailable`, `GetFirstEmptyShortcutToTheRightOf`, `RecvNotice_AddShortcut/RemoveShortcut/UseShortcut`. -- Slots hold **`UIElement_UIItem`** widgets (`UIElement_UIItem::SetShortcutNum` / `SetDelayedShortcutNum`); the underlying object is an **`ACCWeenieObject`** with `SetShortcutNum`. -- Spell shortcuts: `UIElement_ItemList::ItemList_InsertSpellShortcut`, `CM_Magic::SendNotice_AddSpellShortcut`. - -### B. Inventory → `gmInventoryUI` (element class `0x10000023`), `gmBackpackUI` (`0x10000022`) -The inventory window + nested backpacks/side-packs. Items are server-spawned **weenies** (`ACCWeenieObject`) — see `claude-memory/feedback_weenie_vs_static.md` (selectable/interactable items are server weenies, not dat-baked). - -### C. Equipment / paperdoll → `gmPaperDollUI` (element class `0x10000024`), `gm3DItemsUI` (`0x10000021`) -The paperdoll window shows equipped/wielded items + the character doll. `gm3DItemsUI` is likely the 3D doll viewport (a rotating character model with equipped gear). - -### Shared building blocks (the toolkit pieces these need that we DON'T have yet) -- **`UIElement_UIItem`** (element class `0x10000032`) — **the item-in-a-slot widget**: an icon (from the weenie's `IconDataID`), drag-drop (retail `UIElement_Field`'s `CatchDroppedItem` / `MouseOverTop` hooks — note our `UiField` already documents these as future drag-drop hooks), a shortcut number, a quantity/burden overlay, a selection/highlight. **This is the most important new generic widget** — all three panels are grids/lists of these. -- **`UIElement_ItemList`** (`0x10000031`) — a scrollable list/grid of items (retail's ListBox-for-items). Maps to a `UiListBox` (Type 5, not yet built) or a `UiItemGrid`. -- **The window manager** (the *other* deferred Plan-2 piece): open/close/z-order/persist, drag via Dragbar (Type 2), resize via Resizebar (Type 9). Inventory/paperdoll/toolbar are pop-up/dockable windows that need this. -- **Drag-drop infrastructure**: item drag between inventory ↔ equip ↔ toolbar ↔ ground, with the wire messages it triggers. - ---- - -## 3. The research questions (the deep-research scope) - -Produce a research doc per panel (or one combined doc) under `docs/research/` answering these, each with a **retail anchor** (named `class::method` + decomp line, or `LayoutDesc`/element-class id) and cross-referenced against the references in §4. - -**Common / cross-cutting (do this first — it unblocks all three):** -1. **The dat `LayoutDesc` id** for each panel (`gmToolbarUI` / `gmInventoryUI` / `gmPaperDollUI`). The element-class ids above are the *registered class*; find the actual `LayoutDesc` (`0x21xxxxxx`) that builds each window. Use the `AcDream.Cli` layout-dump tools (`dump-vitals-layout 0xId`, the `LayoutIndexDump`) and grep the decomp for the class's `Create`/`PostInit`. -2. **`UIElement_UIItem`** (`0x10000032`) — full port spec: what Type does it resolve to in the dat? How does it render an item icon from the weenie's `IconDataID` (→ `RenderSurface`/`Icon` overlay — cross-ref WorldBuilder/ACViewer icon decode)? How are quantity, burden, wielded/selected states drawn? What's the drag-drop state machine (`MouseOverTop`/`CatchDroppedItem`)? -3. **The item/container data model**: items are `ACCWeenieObject`s. How does the client learn container contents — which wire messages (CreateObject, the container/inventory messages), and how is the container hierarchy (main pack → backpacks → side-packs) represented? What does acdream already parse (cross-ref the wire-message catalog, §4)? - -**Action bar (`gmToolbarUI`):** -4. The shortcut slot model — how many slots/bars, item vs spell shortcuts, what a slot stores (`ShortcutNum`), `IsShortcutEligible`. -5. Persistence + wire: is the toolbar server-persisted? What messages add/remove/use a shortcut (`RecvNotice_AddShortcut/RemoveShortcut/UseShortcut`) and what does activation send (use-item / cast-spell)? -6. Drag-from-inventory-to-slot + drag-to-reorder (`CreateShortcutToItem`, `GetFirstEmptyShortcutToTheRightOf`). - -**Inventory (`gmInventoryUI` / `gmBackpackUI`):** -7. The window layout (the item grid, the side-pack tabs/list, burden/value summary). -8. The full set of inventory wire messages (server → client item arrival + client → server actions: pick up, drop, give, move-between-containers, split-stack, merge-stack). Cross-ref ACE (what the server sends/validates) + holtburger (what the client sends). -9. Icon rendering: weenie `IconDataID` → texture (+ any underlay/overlay/highlight for ID'd vs unidentified, wielded, selected). - -**Equipment / paperdoll (`gmPaperDollUI` / `gm3DItemsUI`):** -10. The equip/wield slots — the coverage/location enum (which slots exist, their screen positions on the doll). -11. The wield/unwield wire messages (equip an item, the server's response, the resulting `ObjDesc` change on the character model). -12. The paperdoll rendering — is it the 2D doll image + slot icons, or a 3D character viewport (`gm3DItemsUI`)? How does it assemble the equipped-character appearance (cross-ref ACViewer's ObjDesc/CreaturePalette + WorldBuilder for the model)? - ---- - -## 4. References (the hierarchy — cross-reference at least two per question) - -Per CLAUDE.md's reference table: -- **Named retail decomp** (`docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` + `symbols.json`) — **primary oracle for the UI logic** (the `gm*UI` / `UIElement_UIItem` classes). Grep by `class::method`. -- **holtburger** (`references/holtburger/`) — **primary oracle for client behavior + wire**: what a client sends/receives for inventory, equip, use-item, drag-drop. Look in `client/` + `session/`. -- **ACE** (`references/ACE/Source/ACE.Server/`) — **server expectations**: the inventory/equip/move game-action handlers, what the server validates + broadcasts. -- **WorldBuilder** + **ACViewer** — **icon + 3D-model rendering**: `IconDataID` → texture decode (ACViewer `TextureCache.IndexToColor` / WorldBuilder `TextureHelpers`); the equipped-character ObjDesc assembly (ACViewer `StaticObjectManager` / `CreaturePalette`). -- **Chorizite.ACProtocol** (`references/Chorizite.ACProtocol/Types/*.cs`) — protocol field order for the inventory/equip messages. - -**Existing acdream research/memory to read first (don't re-research what's done):** -- `claude-memory/project_d2b_retail_ui.md` — the toolkit + the find-by-id controller pattern (START HERE). -- `claude-memory/feedback_weenie_vs_static.md` — items are server weenies. -- `claude-memory/project_interaction_pipeline.md` — the existing WorldPicker / Select / UseSelected interaction (B.4) — the use/pickup path partly exists. -- `claude-memory/MEMORY.md` → the **wire-message catalog** research (`research/2026-06-04-wire-message-catalog.md`): 256 opcodes, what acdream parses vs stubs vs is-missing — the inventory/equip opcodes' parse status is in there. -- `docs/research/2026-06-15-layoutdesc-format.md` — the `LayoutDesc`/`ElementDesc` format (for reading the panel layouts). -- The `AcDream.Cli` layout-dump tools (`dump-vitals-layout`, `LayoutIndexDump`, `LayoutFixtureDump` from this session's Task 1) — for dumping any panel's `LayoutDesc`. - ---- - -## 5. Deliverable + approach - -**Report-only research** — no implementation, no code changes (use the `/investigate` discipline, or just produce research docs). Output: one or more `docs/research/2026-06-NN-*-deep-dive.md` docs (mirroring the existing `2026-06-04-*-deep-dive.md` set), each with: -- The retail anchors (class::method + decomp line; `LayoutDesc`/element-class ids). -- The wire-message catalog for the panel (direction, trigger, field layout, ACE handler, acdream parse status). -- The item/container model + the `UIElement_UIItem` port spec. -- The drag-drop mechanics. -- **A concrete "what the toolkit needs" list** — the new generic widgets (`UiItemSlot`/`UIElement_UIItem` port, `UiListBox`/item-grid, the window manager) + which `Type` they register at — so the *next* session can go straight to a brainstorm → spec → plan. -- An end-of-doc `MEMORY.md` index line. - -**Suggested approach:** this is broad (3 panels × decomp + 5 references + dat + wire). A **multi-agent research Workflow** fits well — e.g. one agent per panel for the class/LayoutDesc/wire scoping, plus one agent on the shared `UIElement_UIItem`/icon-rendering/drag-drop spine, then synthesize. (Ultracode authorizes this.) Or run the panels sequentially. Either way, finish with a synthesis that names the new toolkit widgets. - ---- - -## 6. New-session prompt (paste this into a fresh session) - -> Deep retail-faithful **research phase** (report-only, no code) for acdream's next three UI panels, building on the now-complete D.2b widget toolkit (merged to `main`). **Read the handoff first: `docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md`**, then `claude-memory/project_d2b_retail_ui.md`. -> -> **Targets + confirmed retail entry points** (named decomp): action bar / quick slots = `gmToolbarUI` (element class `0x10000007`); inventory = `gmInventoryUI` (`0x10000023`) + `gmBackpackUI` (`0x10000022`); equipment/paperdoll = `gmPaperDollUI` (`0x10000024`) + `gm3DItemsUI` (`0x10000021`). Shared spine: `UIElement_UIItem` (`0x10000032`, the item-in-a-slot widget) + `UIElement_ItemList` (`0x10000031`). Items are server `ACCWeenieObject` weenies. -> -> **Produce** a `docs/research/` deep-dive per panel (+ a synthesis) answering the §3 research questions in the handoff — each with a retail anchor (class::method + decomp line / `LayoutDesc` + element-class id), the panel's wire-message catalog (cross-ref holtburger=client, ACE=server, Chorizite.ACProtocol=field order), the item/container model, the `UIElement_UIItem` port spec + icon rendering (cross-ref WorldBuilder/ACViewer for `IconDataID`→texture), and the drag-drop mechanics. **End with a concrete "new generic widgets the toolkit needs" list** (the item-slot widget, an item list/grid, the window manager) + the `Type` each registers at, so the following session can brainstorm → spec → plan the build. Use a multi-agent research Workflow (one agent per panel + one on the shared item-slot/icon/drag-drop spine) — Ultracode is authorized. Follow the mandatory grep-named→cross-ref→pseudocode workflow; do not write implementation code this phase. diff --git a/docs/research/2026-06-16-action-bar-toolbar-deep-dive.md b/docs/research/2026-06-16-action-bar-toolbar-deep-dive.md deleted file mode 100644 index de3e30de..00000000 --- a/docs/research/2026-06-16-action-bar-toolbar-deep-dive.md +++ /dev/null @@ -1,191 +0,0 @@ -# Action bar / quick slots (`gmToolbarUI`) — retail-faithful deep dive - -**Date:** 2026-06-16 -**Panel:** action bar / shortcut bar — retail class `gmToolbarUI`, element class `0x10000007`, `LayoutDesc 0x21000016` (root element 300×122). -**Scope:** handoff §3 Q1 (LayoutDesc/element map) + Q4 (shortcut slot model) + Q5 (wire + persistence) + Q6 (drag-drop / reorder). Report-only; no code written this phase. -**Builds on:** the D.2b importer/widget toolkit (`src/AcDream.App/UI/` + `…/UI/Layout/`). The "spine" item-slot/icon doc referenced in the handoff prompt does **NOT exist** in this worktree (searched `**/*spine*`, `**/*item-slot*`, the named path — all NOT FOUND), so the `UIElement_UIItem` / `UIElement_ItemList` facts below are derived here directly from the decomp; a later synthesis should reconcile with the spine doc if it lands. - -## 1. Summary + confidence legend - -The retail toolbar is **one `gmToolbarUI` window** that contains **18 single-cell item slots** (two rows of 9: top `0x100001A7..AF`, bottom `0x100006B7..BF`), each slot a **`UIElement_ItemList` (element class `0x10000031`)** holding at most one **`UIElement_UIItem` (class `0x10000032`)**. A slot stores nothing but the item it currently holds; the persistent model is `ShortCutManager::shortCuts_[18]` (an array of `ShortCutData{ index_, objectID_, spellID_ }`) living on the `CPlayerModule`. Shortcuts are **server-persisted as a character option** — they arrive in the big `PlayerDescription` login message (the `CharacterOptionDataFlag::SHORTCUT` block, **already parsed by acdream**) and are mutated live by two C2S game actions: **`AddShortCut 0x019C`** and **`RemoveShortCut 0x019D`** (both already have outbound builders in acdream). Activation of a slot does **not** use a "use-shortcut" wire message — it routes through the ordinary **use-item** path (`ItemHolder::UseObject`), so it reuses acdream's existing B.4 interaction pipeline. Drag-from-inventory and drag-to-reorder are handled by `gmToolbarUI` being an `ItemListDragHandler` (multiple inheritance) whose `HandleDropRelease` resolves the target slot and calls `CreateShortcutToItem` / `AddShortcut` / `GetFirstEmptyShortcutToTheRightOf`. - -The 2 Meters + 1 Scrollbar in the layout dump are **NOT** bar paging or extra vitals: they are the **selected-object Health & Mana meters** (`0x100001A1`/`0x100001A2`) and the **stack-size split slider** (`0x100001A4`), all inside the `m_pSelObjectField` sub-panel and **hidden by default** (`SetVisible(0)` in `PostInit`) — they appear only when you select an object / split a stack. - -**Confidence legend** — **CONFIRMED** = quoted from named decomp or a reference file I opened; **LIKELY** = inferred from confirmed facts (source named); **UNVERIFIED** = educated guess, flagged. - -## 2. LayoutDesc / element map (Q1) — CONFIRMED against `.layout-dumps/toolbar-0x21000016.txt` - -`LayoutDesc 0x21000016` (Id 553648150). The dump's `Width=800 Height=600` is the LayoutDesc canvas; the **root element `0x10000191`** (ElementId 268435857, **Type `0x10000463` = the registered `gmToolbarUI` class type**) is **300×122** — that 300×122 matches the handoff's stated size and is the real window. The root's Type value `268435463 = 0x10000007`… correction: dump shows `Type = 268435463` which is `0x10000007` (the `gmToolbarUI` class id) — i.e. the root element registers as the panel class itself, exactly like `gmToolbarUI::GetUIElementType` returns `0x10000007` (decomp line 196707: `return 0x10000007;`). CONFIRMED. - -### 2a. The 18 shortcut slots — element→slot-index map - -`gmToolbarUI::InitShortcutArray` (decomp line 197051) wires the slots by walking `GetChildRecursive(this, )` in order and `DynamicCast(0x10000031)` (= `UIElement_ItemList`), registering each with the drag handler and pushing into `m_shortcutSlots`. The push order **is** the slot index. The 18 ids extracted from the function body (decomp 197054–197560): - -| Slot # | Element id | Row | Dump X,Y (W×H) | Hotkey msg (use / select) | -|---|---|---|---|---| -| 0 | `0x100001A7` | top | 6,58 (32×32) | `0x10000042` / `0x1000004E` | -| 1 | `0x100001A8` | top | 38,58 | `0x10000043` / `0x1000004F` | -| 2 | `0x100001A9` | top | 70,58 | `0x10000044` / `0x10000050` | -| 3 | `0x100001AA` | top | 102,58 | `0x10000045` / `0x10000051` | -| 4 | `0x100001AB` | top | 134,58 | `0x10000046` / `0x10000052` | -| 5 | `0x100001AC` | top | 166,58 | `0x10000047` / `0x10000053` | -| 6 | `0x100001AD` | top | 198,58 | `0x10000048` / `0x10000054` | -| 7 | `0x100001AE` | top | 230,58 | `0x10000049` / `0x10000055` | -| 8 | `0x100001AF` | top | 262,58 | `0x1000004A` / `0x10000056` | -| 9 | `0x100006B7` | bottom | 6,90 | `0x1000004B` / `0x10000057` | -| 10 | `0x100006B8` | bottom | 38,90 | `0x1000004C` / `0x10000058` | -| 11 | `0x100006B9` | bottom | 70,90 | `0x1000004D` / `0x10000059` | -| 12 | `0x100006BA` | bottom | 102,90 | `0x10000132` / `0x10000138` | -| 13 | `0x100006BB` | bottom | 134,90 | `0x10000133` / `0x10000139` | -| 14 | `0x100006BC` | bottom | 166,90 | `0x10000134` / `0x1000013A` | -| 15 | `0x100006BD` | bottom | 198,90 | `0x10000135` / `0x1000013B` | -| 16 | `0x100006BE` | bottom | 230,90 | `0x10000136` / `0x1000013C` | -| 17 | `0x100006BF` | bottom | 262,90 | `0x10000137` / `0x1000013D` | - -CONFIRMED — slot ids from `InitShortcutArray`; X/Y from the dump; hotkey msg ids from `gmToolbarUI::ListenToGlobalMessage` (decomp 197564). The hotkey routing: -- `0x10000042..0x1000004D` → `UseShortcut(this, msg-0x10000042, 1)` → slots **0–11**, **use** (arg3=1). (decomp 197576–197591) -- `0x1000004E..0x10000059` → `UseShortcut(this, msg-0x1000004E, 0)` → slots **0–11**, **select** (arg3=0). (decomp 197592–197606) -- `0x10000132..0x10000137` → `UseShortcut(this, 0xC..0x11, 1)` → slots **12–17**, **use**. (decomp 197616–197645) -- `0x10000138..0x1000013D` → `UseShortcut(this, 0xC..0x11, 0)` → slots **12–17**, **select**. (decomp 197646–197674) - -The slot count `18` is independently confirmed by the header struct `ShortCutManager::shortCuts_[18]` (`acclient.h` line 36492–36494: `struct __cppobj ShortCutManager : PackObj { ShortCutData *shortCuts_[18]; };`) and the login-restore loop `for (i=0; i<0x12; i++)` in `UpdateFromPlayerDesc` (decomp 198879). ACE's comment corroborates the UX: *"there are two rows. The top row is 1-9, the bottom row has no hotkeys"* (`Player_Character.cs:250`). - -Slot template: each slot's `ElementDesc` has `BaseElement=0x100001B2` / `BaseLayoutId=553648150` (the dump's last element, `0x100001B2`, ElementId 268435890, which itself inherits `BaseElement=268436281 BaseLayoutId=553648189`). `0x100001B2` is the slot **prototype** (W=32 H=32) — i.e. the 18 slot elements are clones of one `UIElement_ItemList` prototype. LIKELY (from the dump's BaseElement chain; the resolved Type would surface 0x10000031 via `ElementReader.Merge`, exactly as the toolkit memory describes for Type-0 inheritance). - -### 2b. The selected-object sub-panel + the "extra" widgets (resolves the prompt's "2 Meters / 1 Scrollbar = ?") - -From `gmToolbarUI::PostInit` (decomp 198119) — all `GetChildRecursive` + `DynamicCast`: - -| Element id | Field | DynamicCast Type | Dump location | Purpose | -|---|---|---|---|---| -| `0x1000019D` | `m_pUseObjectButton` | (button) | 55,27 (23×31) | the **Use** button (sprite `0x06001129`, Ghosted `0x0600120E`) | -| `0x100001A5` | `m_pExamineObjectButton` | (button) | 218,27 (22×31) | the **Examine/Appraise** button (sprite `0x06001127`) | -| `0x1000019E` | `m_pSelObjectField` | (Type 3 container) | 78,27 (140×31) | the selected-object info sub-panel (dump `0x1000019E`) | -| `0x1000019F` | `m_pSelObjectName` | `DynamicCast(0xC)` Text | child of A field | selected object's **name** | -| **`0x100001A1`** | `m_pSelObjectHealthMeter` | `DynamicCast(7)` **Meter** | child | **Meter #1 = target Health bar** | -| **`0x100001A2`** | `m_pSelObjectManaMeter` | `DynamicCast(7)` **Meter** | child | **Meter #2 = target Mana bar** | -| `0x100001A3` | `m_pStackSizeEntryBox` | `DynamicCast(0xC)` Text | child | the **stack-split number entry** (gets `NumberInputFilter`) | -| **`0x100001A4`** | `m_pStackSizeSlider` | `DynamicCast(0xB)` **Scrollbar** | 50,13 (90×14), Type 11 | **Scrollbar = the stack-split slider** | - -`PostInit` ends (decomp 198307–198310) by hiding all four: `m_pSelObjectHealthMeter/ManaMeter/StackSizeEntryBox/StackSizeSlider → SetVisible(0)`. **So the 2 Meters and the Scrollbar are NOT toolbar paging or persistent vitals — they are the on-demand "selected object" readout + the stack-split slider, hidden until needed.** CONFIRMED. - -Panel-launcher buttons (open inventory/spellbook/etc.) wired into `m_buttonInfoArray` with a `panelID` attribute (`0x10000029`): `0x10000197, 0x10000198, 0x10000199, 0x1000055A, 0x1000019A, 0x1000019B, 0x100001B1` (decomp 198179–198303). `0x100001B1` (X=238 W=63, sprite `0x06004CF7` Alphablend, with child `0x1000046C` = `m_pInventoryButtonDragOverlay`) is the **inventory button that also serves as a "drop item into your pack" target** (see §5). The `0x1000019C/0x10000196` Type-3 elements (sprites `0x0600112B/0x0600112C`) are decorative dividers; the `0x10000194` element drives `UpdateAmmoNumber` (the ammo-count readout, decomp 198081). Text0x34 in the pre-dump label = the 0x34 (52) text/field/image sub-elements across this whole tree (chrome + the above); they are NOT 52 slots. - -## 3. Shortcut slot model (Q4) — CONFIRMED - -**A slot holds an item, the player module holds the model.** Each `m_shortcutSlots[i]` is a `UIElement_ItemList`; `UseShortcut`/`RemoveShortcutInSlotNum` read the item via `UIElement_ListBox::GetItem(slot, 0)` then `DynamicCast(0x10000032)` (= `UIElement_UIItem`) and read the **object id at field offset `+0x5FC`** on the `UIItem` (decomp 196415, 196519, 196811: `*(uint32_t*)((char*)eax_1 + 0x5fc)`). That `+0x5FC` is the weenie/object id the slot points at. UNVERIFIED exact field name (offset only); LIKELY the `UIItem`'s bound object id. - -**`ShortCutData` (the persistent unit)** — verbatim header (`acclient.h:36484`): -```c -struct __cppobj ShortCutData : PackObj { - int index_; // slot number (0..17) - unsigned int objectID_;// item guid (0 if spell shortcut) - unsigned int spellID_; // spell id (0 for item shortcut) -}; -``` -Constructed `CShortCutData(&var_10, index, objectID, spellID)` (decomp 489341: `index_=arg2; objectID_=arg3; spellID_=arg4`). For an **item** shortcut the toolbar always passes `spellID=0` (`CShortCutData(&var_10, i_1, arg2, 0)` in `AddShortcut`, decomp 196874). - -**Number of slots / bars:** 18 slots in 2 visible rows of 9 (top row = hotkeys 1-9, bottom = no hotkeys but addressable via `UseShortcut(0xC..0x11)`). There is **no separate "bar paging"** — all 18 are always present; the layout just stacks two rows. CONFIRMED (§2a). - -**Item vs spell shortcuts.** The data model has a `spellID_` slot, **but in practice the toolbar holds only items.** Confirmation from three angles: -1. The toolbar's add paths only ever construct item shortcuts (`AddShortcut`/`CreateShortcutToItem` pass `spellID=0`). -2. Spell shortcuts live in a **different** list — the spellbook's `m_spellList` via `UIElement_ItemList::ItemList_InsertSpellShortcut` (decomp 232294) and the spell-bar hotbars (the `SpellLists8` / `hotbar_spells` block, separate from `SHORTCUT`). `CM_Magic::SendNotice_AddSpellShortcut` (decomp 682275) is a **local UI notice** (dispatched via `gmGlobalEventHandler` to notice handlers), **not** a wire send and **not** routed to `gmToolbarUI`. -3. Chorizite's own comment on `ShortCutData.SpellId`: *"May not have been used in prod? … I don't think you could put spells in shortcut spot…"* (`ShortCutData.generated.cs:34`). CONFIRMED — the toolbar is item-only; the `spellID_`/spell-bar machinery is a separate spellbook concern (out of scope for the action-bar widget). - -**`IsShortcutEligible(ACCWeenieObject*)`** (decomp 196261, `__stdcall`): returns true unless the object is null, **OR** it's the player itself / a creature you don't own, **OR** it's currently inside the open vendor's container. Logic (decomp 196268–196300): -- if `(pwd._bitfield & 4) == 0` (not "owned"?) and not a player → fall through; else require `IsPlayer()`. -- then `if ((InqType() & 0x10) != 0)` (Creature type bit) require `IsPlayer()` to continue; -- then read `pwd._containerID`; eligible (`return 1`) **iff** `_containerID == 0` OR `_containerID != UISystem->vendorID` — i.e. anything not sitting in the currently-open vendor window is eligible. CONFIRMED (paraphrase of the branch tree). - -**`IsShortcutSlotAvailable(slot)`** (decomp 196575): `slot` in range AND `UIElement_ItemList::GetNumUIItems(slot)==0` (empty). CONFIRMED. - -**Activation — `UseShortcut(slot, useFlag)`** (decomp 196395): -1. Get the `UIItem` in the slot; read its object id from `+0x5FC`. -2. If a **target mode** is active (`UISystem->targetMode != TARGET_MODE_NONE`, e.g. a spell awaiting a target): `ClientUISystem::ExecuteTargetModeForItem(objId, targetMode)` then clear target mode. (decomp 196412–196421) -3. Else if `useFlag != 0`: `ItemHolder::UseObject(objId, 0, 0)` — the **standard use-item** action. (decomp 196429) -4. Else (`useFlag==0`): `ACCWeenieObject::SetSelectedObject(objId, 0)` — just select it. (decomp 196433) - -So **toolbar activation is the ordinary use-item path**, not a bespoke message. `ItemHolder::UseObject` (decomp 402923) has a **0.2 s throttle** (`m_timeLastUsed + 0.2`, decomp 402933) and then dispatches the use via the inventory-request path (`DetermineUseResult` → 0x0036 "Use" or 0x0035 "UseWithTarget"). LIKELY (the exact 0x0035/0x0036 branch is deep in `UseObject`; the throttle + dispatch are CONFIRMED, the opcode selection is inferred from acdream's existing `InteractRequests.cs` opcodes 0x0035/0x0036). - -## 4. Wire + persistence (Q5) - -### 4a. Persistence = a character option in `PlayerDescription` (login restore) - -Shortcuts are saved server-side (ACE: `CharacterPropertiesShortcutBar`, `Player_Character.cs:235`) and shipped to the client **inside the `PlayerDescription` login message** in the `CharacterOptionDataFlag::SHORTCUT` (0x1) block — `count:u32` then `count × ShortCutData`. CONFIRMED in three refs: -- holtburger `events.rs:514-524` (`PlayerDescriptionEventData.shortcuts`, *"List of user-defined shortcuts for the action bar"* line 124). -- ACE `Player_Character.cs:238 GetShortcuts()` reads `Character.GetShortcuts(...)` → `List` for the description. -- **acdream already parses this**: `PlayerDescriptionParser.cs:345-356` reads `count` then `ShortcutEntry(Index, ObjectGuid, SpellId, Layer)` per entry, exposed on `Parsed.Shortcuts`. - -Client-side restore: `gmToolbarUI::UpdateFromPlayerDesc` (decomp 198838) → `FlushShortcuts()`, gets the `CPlayerModule`'s `ShortCutManager`, then `for (i=0; i<0x12; i++) { objId = shortCuts_[i]->objectID_ (+8); if (objId) AddShortcut(this, objId, i, 0); }` (decomp 198879–198893). The `0` final arg = **do NOT echo to server** (it's already persisted). CONFIRMED. - -### 4b. Live mutation — two C2S game actions - -| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream parse status | -|---|---|---|---|---|---|---| -| `0x019C` | AddShortCut | C→S | `AddShortcut(…, send=1)` builds `CShortCutData(slot,objId,0)` → `CM_Character::Event_AddShortCut` | `GameActionAddShortcut.Handle` → `Player.HandleActionAddShortcut(shortcut)` → `Character.AddOrUpdateShortcut(Index,ObjectId)` | `Character_AddShortCut { ShortCutData Shortcut }` | **builder present** (outbound `InventoryActions.BuildAddShortcut`, see note) | -| `0x019D` | RemoveShortCut | C→S | `RemoveShortcut(…, send=1)` → `CM_Character::Event_RemoveShortCut(slotIndex)` | `GameActionRemoveShortcut.Handle` → `Player.HandleActionRemoveShortcut(index)` → `Character.TryRemoveShortcut(index)` | `Character_RemoveShortCut { uint Index }` | **builder present** (`InventoryActions.BuildRemoveShortcut`) | -| (—) | shortcut list | S→C | login | part of `PlayerDescription` `SHORTCUT` block | `ShortCutData` in description | **parsed** (`PlayerDescriptionParser.cs:345`) | - -Opcode values triple-confirmed: decomp `Event_AddShortCut` packs `*(uint32_t*)var_c = 0x19c` (decomp 679733) and `Event_RemoveShortCut` packs `0x19d` (decomp 680332); ACE `GameActionType.cs:77-78` (`AddShortCut=0x019C, RemoveShortCut=0x019D`); holtburger `opcodes.rs:371-374` (commented, same values). - -**Wire field order — `ShortCutData` payload (16 bytes), CONFIRMED across 3 refs:** -``` -Index : u32 (slot 0..17) -ObjectId : u32 (item guid; 0 for spell) -SpellId : u16 (LayeredSpell.id; 0 for item) -Layer : u16 (LayeredSpell.layer; 0 for item) -``` -- Chorizite `ShortCutData.generated.cs:41-46` (`Index`, `ObjectId`, then `LayeredSpellId.Read` = u16 id + u16 layer). -- ACE `Shortcut.cs:33-42` `ReadShortcut` (`Index`, `ObjectId`, `ReadLayeredSpell`). -- holtburger `shortcuts.rs:13-34` (`index u32`, `object_id Guid`, `spell_id u16`, `layer u16`). -RemoveShortCut payload = just `Index:u32` (Chorizite `Character_RemoveShortCut.generated.cs:33`; ACE `GameActionRemoveShortcut.cs:9`; decomp packs `*(uint32_t*)eax_3 = arg1` at 680335). - -**⚠ acdream builder field-naming bug to fix at port time (not a wire bug).** `InventoryActions.BuildAddShortcut(seq, slotIndex, objectType, targetId)` (`InventoryActions.cs:99-110`) writes 24 bytes = 8-byte envelope (`0xF7B1` + seq) + `slotIndex`(u32) + `objectType`(u32) + `targetId`(u32). The **byte layout is correct for item shortcuts** (slot, then guid, then a final dword that for items is `0` = SpellId|Layer), but the parameter names are wrong/misleading: the 2nd field is `Index`, the 3rd is `ObjectId`, and the 4th dword is `SpellId(u16)|Layer(u16)` — there is no separate "objectType". A faithful builder should take `(seq, uint index, uint objectGuid, ushort spellId, ushort layer)` and pack the spell as two u16s. For the toolbar's item-only use, callers must pass `objectGuid` as the 3rd arg and `0` as the 4th. LIKELY a latent bug if anyone wired a "objectType" semantic; flag in the divergence register when the toolbar lands. (CONFIRMED file contents; the "bug" judgment is mine.) - -**ACE's reorder note (important UX contract):** *"When a shortcut is added on top of an existing item, the client automatically sends the RemoveShortcut command for that existing item first, then will add the new item, and re-add the existing item to the appropriate place."* (`Player_Character.cs:254`). This is exactly the `HandleDropRelease` sequence in §5. CONFIRMED. - -## 5. Drag-drop for the toolbar (Q6) — CONFIRMED - -`gmToolbarUI` multiply-inherits `ItemListDragHandler` (constructor sets the `ItemListDragHandler::vftable`, decomp 196680) and registers itself as the drag handler on **every** slot's `UIElement_ItemList` in `InitShortcutArray` (`RegisterItemListDragHandler(slot, &this->vtable)`, decomp 197069 etc.). Drops land in **`gmToolbarUI::HandleDropRelease`** (decomp 197971): - -1. Read source `UIItem` (`ebp = msg.dwParam1+8`) and drop-target element (`ebx = msg.dwParam1+0x10`). (decomp 197974–197976) -2. **If the target is the inventory button** (`ebx->m_desc.m_elementID == 0x100001B1`): this is "drop item into my pack." `InqDropIconInfo` extracts the dragged object id; then if owned by player → `CPlayerSystem::PlaceInBackpack(objId, 0)`, else → `ItemHolder::AttemptToPlaceInContainer(objId, playerId, …)`. (decomp 198031–198056) — i.e. dropping on the inventory button moves the *real item* into your pack, it does not create a shortcut. -3. **Else (target is a shortcut slot):** find which slot `i` is the ancestor of the drop target (`IsAncestorOfMe(ebx, m_shortcutSlots[i])`, decomp 197991), `InqDropIconInfo(ebp, &objId, &var_4, &flags)`. Then on `objId != 0`: - - **drop flags `(flags & 0xE) == 0`** (a fresh drag from inventory, not a within-bar move): `RemoveShortcutInSlotNum(i, 1)` (evict whatever was there, returns its objId `eax_13`), `CreateShortcutToItem(objId, i, 1, 0)` (place the dragged item in slot `i`, send=1). If the evicted `eax_13` was a different item, `GetFirstEmptyShortcutToTheRightOf(i)` and `AddShortcut(eax_13, thatSlot, 1)` to relocate it. (decomp 198007–198018) - - **else if `(flags & 4) != 0`** (a within-bar reorder, `m_lastShortcutNumDragged` is the source slot): `RemoveShortcutInSlotNum(i, 1)` → `AddShortcut(objId, i, 1)`; if an item was displaced and `IsShortcutSlotAvailable(m_lastShortcutNumDragged)`, put the displaced item back into the **vacated source slot** (`AddShortcut(eax_15, m_lastShortcutNumDragged, 1)`). (decomp 198020–198027) - -This is precisely ACE's "remove the existing one, add the new one, re-add the existing item to the appropriate place." CONFIRMED. - -**Slot-resolution helpers (Q6 core):** -- **`CreateShortcutToItem(objId, slotOrNeg1, send, fromServer)`** (decomp 196905): null-check; get `ACCWeenieObject`; if `IsShortcutEligible`. If `slot != 0xFFFFFFFF` → `RemoveShortcut(objId,1); AddShortcut(objId, slot, 1)` (decomp 196928–196930). Else (slot unspecified) it scans for a home (the loop at 196954+, with a "no empty slot" `DisplayStringInfo` notice when full, decomp 196945–196949). This is the entry called by `RecvNotice_AddShortcut` and the keyboard "add selected to toolbar" (`0x1000010D` → `CreateShortcutToItem(selectedID, 0xFFFFFFFF, 1, 0)`, decomp 197613). -- **`AddShortcut(objId, slot, send)`** (decomp 196825): if `slot` out of range, find the **first empty** slot (linear scan, decomp 196836–196848). Then `ItemList_Flush(slot); ItemList_AddItem(slot, objId); SetShortcutNum(weenie, slot)` (or `SetDelayedShortcutNum` if the weenie isn't loaded yet, decomp 196861–196867). If `send`, build `CShortCutData(slot, objId, 0)` → `Event_AddShortCut` (wire) + `PlayerModule::AddShortCut` (local model) (decomp 196873–196876). -- **`RemoveShortcut(objId, send)`** (decomp 196462): scan slots for the one containing `objId` (`ItemList_IsInList`), `ItemList_Flush`, `SetShortcutNum(weenie, 0xFFFFFFFF)`; if `send`, `Event_RemoveShortCut(slotIndex)` + `PlayerModule::RemoveShortCut(slotIndex)`; returns the slot index (or `0xFFFFFFFF`). (decomp 196471–196496) -- **`RemoveShortcutInSlotNum(slot, send)`** (decomp 196502): read the `UIItem` objId at `+0x5FC`, `RemoveShortcut(objId, send)`, return the evicted objId. (decomp 196519–196524) -- **`GetFirstEmptyShortcutToTheRightOf(slot)`** (decomp 196536): scan `slot+1 .. end` for an empty `ItemList` (`GetNumUIItems==0`); if none, wrap-scan `0 .. slot`; return `0xFFFFFFFF` if the bar is full. (decomp 196539–196569) -- **`FlushShortcuts()`** (decomp 196442): `ItemList_Flush` every slot (visual clear; does NOT touch the server). Used by login restore. (decomp 196451–196457) - -## 6. New toolkit widgets this introduces - -The toolbar needs the same item-slot spine the inventory/paperdoll need; it adds the slot-grid + drag-handler concept on top. - -| Widget | dat Type it registers at | leaf vs container | Purpose | -|---|---|---|---| -| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | resolves to a class id, not a numeric toolkit Type (it's a `UIElement` subclass `0x10000032`, registered via `RegisterElementClass`, not Types 1-0x12); in acdream's factory this is a **new behavioral leaf widget** | **leaf** (`ConsumesDatChildren=>true`) | the item-in-a-slot: icon from weenie `IconId` (+ underlay/overlay/highlight), stack-size + selection state, holds the bound object id (retail `+0x5FC`). **Shared with inventory + paperdoll** — build once. | -| **`UiItemList`** (port of `UIElement_ItemList` / `UIElement_ListBox`, class `0x10000031`) | new behavioral widget at class `0x10000031` (the dump shows it as the slot prototype `0x100001B2`'s resolved class; Type-5 `ListBox` is the generic relative but item lists are the specialized `0x10000031`) | **leaf** wrt the importer (it manages its own `UIItem` children procedurally) | a 1-cell (toolbar) or N-cell (inventory) container of `UiItemSlot`s; exposes `AddItem/Flush/IsInList/GetNumUIItems/GetItem`. **Shared.** | -| **`ToolbarController`** (the `gmToolbarUI::PostInit`-style binder) | not a widget — a controller (like `VitalsController`/`ChatWindowController`) | n/a | finds the 18 slots by id, the use/examine buttons, the selected-object meters/name, the stack slider; binds `UseShortcut`/`AddShortcut`/`RemoveShortcut`; restores from `Parsed.Shortcuts`; sends 0x019C/0x019D. | -| **drag-handler seam** | n/a (an interface on `UiItemList` + the controller) | n/a | port of `ItemListDragHandler` — `OnItemListDragOver` / `HandleDropRelease` (slot resolution from §5). The toolkit's `UiRoot` already has drag-drop input plumbing (per the d2b memory: *"UiRoot already has full input (focus/capture/drag-drop/tooltip/click)"*), so this is a binding, not new infra. | - -**Reuses (no new widget needed):** `UiMeter` (Type 7) for the two selected-object bars; `UiText`/`UiField` (Type 12 / the controller-placed editable) for the name + stack-size box; `UiScrollbar` (Type 11) for the stack slider; `UiButton` (Type 1) for Use/Examine/panel-launchers; `UiDatElement` for chrome. The window-manager (open/close/z-order/persist + grip/dragbar drag from D.2b Plan-2) is needed for show/hide + persisting position, same as inventory/paperdoll — it is **not toolbar-specific**. - -## 7. Open questions / UNVERIFIED - -- **`UIElement_UIItem +0x5FC` field name** — confirmed as the bound object id by offset only; the symbolic field name is UNVERIFIED. Cross-check against the spine doc's `UIItem` port if/when it exists, or grep `UIElement_UIItem::SetShortcutNum`/`UIItem_GetState`. -- **Exact use-item opcode `UseObject` sends (0x0035 vs 0x0036)** — `ItemHolder::UseObject` throttle + dispatch CONFIRMED; the precise opcode branch (`DetermineUseResult`) was not traced to the send. acdream's `InteractRequests.cs` already has both (0x0035 UseWithTarget, 0x0036 Use); reconcile when wiring activation. -- **`UseShortcut` target-mode path** — `ClientUISystem::ExecuteTargetModeForItem` (for "use item on a target", e.g. a healing kit) is out of scope for the action-bar widget itself; it depends on the target-mode subsystem (cursor target picking). File as a follow-up. -- **`SetDelayedShortcutNum`** — the "weenie not loaded yet" deferral path (`AddShortcut` decomp 196867) needs a small state machine on the slot to re-bind once `CreateObject` for that guid arrives. Note for the controller port; not yet detailed here. -- **Root element Type value** — the dump prints the root's `Type = 268435463` (=`0x10000007`) for `0x10000191` but some other top-level dump fields print `Type = 268435463` ambiguously; I read it as the panel class id, consistent with `GetUIElementType`. LIKELY; verify with `ElementReader.Merge` when the importer runs over `0x21000016`. -- **Spell-on-toolbar** — declared dead (Chorizite + the toolbar's item-only add paths). If a future server/ACE variant DOES persist a spell shortcut (`spellID_!=0`), the `UiItemSlot` would need a spell-icon branch. Low priority; the wire field exists so parsing already handles it. - -## 8. MEMORY.md index line - -- [Action bar / quick slots (`gmToolbarUI`) deep dive](research/2026-06-16-action-bar-toolbar-deep-dive.md) — 18 item slots (2 rows of 9, ids `0x100001A7-AF`+`0x100006B7-BF`) = `UIElement_ItemList`(0x10000031) of one `UIElement_UIItem`(0x10000032); model `ShortCutManager::shortCuts_[18]` persisted in `PlayerDescription`'s SHORTCUT block (acdream already parses it); live mutate via `AddShortCut 0x019C`/`RemoveShortCut 0x019D` (acdream builders present — fix `BuildAddShortcut` field naming); activation = ordinary use-item (`ItemHolder::UseObject`, no special wire); the 2 Meters + Scrollbar in `0x21000016` are the hidden selected-object Health/Mana bars + the stack-split slider, NOT paging; drag-drop via `gmToolbarUI : ItemListDragHandler::HandleDropRelease` (`CreateShortcutToItem`/`GetFirstEmptyShortcutToTheRightOf`). New toolkit widgets: `UiItemSlot` + `UiItemList` (shared spine) + `ToolbarController`. diff --git a/docs/research/2026-06-16-equipment-paperdoll-deep-dive.md b/docs/research/2026-06-16-equipment-paperdoll-deep-dive.md deleted file mode 100644 index 1d6f5a56..00000000 --- a/docs/research/2026-06-16-equipment-paperdoll-deep-dive.md +++ /dev/null @@ -1,416 +0,0 @@ -# Equipment / Paperdoll panel — retail-faithful deep-dive - -**Date:** 2026-06-16 -**Scope:** D.2b "core panels" research phase, the equipment/paperdoll target from -`docs/research/2026-06-16-action-bar-inventory-equipment-handoff.md` §3 Q1 + Q10/Q11/Q12. -**Status:** REPORT-ONLY. No code changed. The deliverable is this doc. -**Panels:** `gmPaperDollUI` (element class `0x10000024`, LayoutDesc `0x21000024`) and -`gm3DItemsUI` (element class `0x10000021`, LayoutDesc `0x21000021`). - -## 1. Summary + confidence legend - -The retail **paperdoll** (`gmPaperDollUI`) is a **3D character viewport plus ~25 -single-cell equip slots**, NOT a 2D doll image. The window's element `0x100001D5` -(Type `13` = `UIElement_Viewport`) hosts a live `CreatureMode` mini-scene; the -character's `CPhysicsObj` is cloned from the player and re-dressed via the SAME -ObjDesc machinery the in-world renderer uses (`DoObjDescChangesFromDefault`). Every -equip slot is a **single-cell `UIElement_ItemList` (class `0x10000031`)**, one per -`EquipMask` location, mapped element-id → coverage-mask by -`gmPaperDollUI::GetLocationInfoFromElementID`. Equipping is the -`GetAndWieldItem` game action (opcode `0x001A`, `item_guid + EquipMask`); the -server's visible reply is `ObjDescEvent` (`0xF625`) which triggers -`RedressCreature`. **acdream already parses `ObjDescEvent` (0xF625) and the full -ObjDesc/ModelData block, and already has a complete per-instance animated-character -render path** (`EntitySpawnAdapter` → `AnimatedEntityState` with palette/part/hidden- -part overrides). The paperdoll viewport can REUSE that path — the gap is a -**`UiViewport` (Type 0xD) widget** that renders a single entity into a UI rect (a -scissored mini 3D pass), an **equip-slot variant of the item-slot widget** -(`UIElement_ItemList` 0x10000031, single cell), and the **window manager**. -`gm3DItemsUI` (0x21000021) is a SEPARATE "Contents of Backpack" pane (an -`UIElement_ItemList` + a text label + a scrollbar), NOT the doll — it does not host -a viewport. - -`gm3DItemsUI` is misnamed for our purposes: despite "3DItems", its `PostInit` wires -a `m_itemList` (`UIElement_ItemList`) and a `m_contentsText` and sets the text to -"Contents of Backpack". It is an inventory contents list, addressed by the inventory -deep-dive; included here only because the handoff paired it with the paperdoll. - -**Confidence legend:** -- **CONFIRMED** — quoted from a source I opened (decomp line / file:line). -- **LIKELY** — inferred from confirmed facts; the inference is named. -- **UNVERIFIED** — educated guess; flagged loudly. - -**Note on a missing input:** the handoff promised a "spine agent" doc at -`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` and the -START-HERE memory `claude-memory/project_d2b_retail_ui.md`. **Both are NOT FOUND in -this worktree** (`Glob **/project_d2b_retail_ui.md` and `**/*spine*.md` returned -nothing). I therefore re-derived the icon/item-model claims I needed from primary -sources (decomp + acclient.h + ACE + ACViewer + acdream source) rather than citing a -doc I could not open. Where this overlaps the spine's scope (icon decode, the -`UIElement_UIItem` widget, container model) I keep it terse and defer to the spine -doc once it lands. - -## 2. LayoutDesc / element map - -### 2a. Paperdoll `gmPaperDollUI` 0x10000024 → LayoutDesc 0x21000024 (224×214) - -**CONFIRMED** registration: `gmPaperDollUI::Register` (decomp line 174445): -`UIElement::RegisterElementClass(0x10000024, gmPaperDollUI::Create);`. Pre-dump -`.layout-dumps/paperdoll-0x21000024.txt` root `0x100001D4` is 224×214, Type -`268435492 = 0x10000024` (the gmPaperDollUI class). **CONFIRMED.** - -Construction chain: `gmPaperDollUI::gmPaperDollUI` (line 174228) calls -`UIElement_Field::UIElement_Field(this, ...)` — i.e. the paperdoll IS-A Field -subclass (matters for drag-drop: it inherits Field's drop hooks). The slot/viewport -wiring happens in the init routine that calls `GetChildRecursive` per id -(lines 175480-175548) — the analog of a `PostInit`. **CONFIRMED.** - -Key elements in 0x21000024 (from the pre-dump + the init routine): - -| Element id | dump Type | Resolves to | Role | Anchor (cite) | -|---|---|---|---|---| -| `0x100001D4` | 0x10000024 | gmPaperDollUI (root) | window | dump:13 | -| `0x100001D5` | **13** | `UIElement_Viewport` (0xD) | **the 3D character doll** | dump:125; `m_pPaperDoll = GetChildRecursive(this,0x100001d5)->DynamicCast(0xd)` line 175509-175517 | -| `0x100001D6` | 0 → base 0x100002BF/0x21000080 | `m_paperDollDragMask` | doll click/drag mask region (100×214) | dump:157; line 175538 | -| `0x1000046D` | 0 → base | `m_paperDollDragOverlay` | drag overlay sprite (32×32) | dump:173; line 175539 | -| `0x10000595` | 0 → ItemList | `m_sigilOneItem` (SigilOne 0x10000000) | aetheria sigil slot, hidden by default | line 175540-175542 | -| `0x10000596` | 0 → ItemList | `m_sigilTwoItem` (SigilTwo 0x20000000) | sigil slot | line 175543-175545 | -| `0x10000597` | 0 → ItemList | `m_sigilThreeItem` (SigilThree 0x40000000) | sigil slot | line 175546-175548 | -| `0x100005BE` | 0 → Button base 0x21000044 | a `UIElement_Button` | the close/expand button (120×14) | dump:349; line 175549 | -| ~25 more `0x1000xxxx` ids | **0** → base `0x100001E4` | single-cell `UIElement_ItemList` (0x10000031) | the equip slots (§3) | dump:29-476 | - -The shared equip-slot base chain (**CONFIRMED**): -- Each slot element has `Type = 0`, `BaseElement = 268435940 = 0x100001E4`, - `BaseLayoutId = 553648164 = 0x21000024` (dump e.g. lines 33,49,65…). -- Element `0x100001E4` (dump:477) has `Type = 0`, `BaseElement = 268436281 = - 0x10000339`, `BaseLayoutId = 553648189 = 0x2100003D`. -- `0x2100003D` root element `0x10000339` (`.layout-dumps/itemlist-0x2100003D.txt:16`) - has `Type = 268435505 = 0x10000031` = `UIElement_ItemList`, 32×32. - ⇒ **every paperdoll equip slot resolves (via `ElementReader.Merge` zero-wins-base - Type resolution) to `UIElement_ItemList` 0x10000031, a single 32×32 cell.** - -The init routine confirms each is cast to ItemList and registered as a drag target, -e.g. (line 175485-175496): -``` -eax_66 = GetChildRecursive(this, 0x100005b2); // LowerLegArmor slot -eax_67 = eax_66->vtable->DynamicCast(0x10000031); // → UIElement_ItemList -this->m_lowerLegSlot = eax_67; -UIElement_ItemList::RegisterItemListDragHandler(eax_67, &this->vtable); -this->m_lowerLegSlot->vtable->SetVisible(0); // hidden until an item lands -``` -**CONFIRMED.** Slots default invisible and are shown only when occupied (the empty -slot shows the doll body behind it; an occupied slot shows the item icon). - -### 2b. gm3DItemsUI 0x10000021 → LayoutDesc 0x21000021 (234×120) — NOT the doll - -**CONFIRMED** registration: `gm3DItemsUI::Register` (line 176723): -`UIElement::RegisterElementClass(0x10000021, gm3DItemsUI::Create);`. -`gm3DItemsUI::PostInit` (line 176728-176745): -``` -this->m_contentsText = UIElement::GetChildRecursive(this, 0x100001c5); -eax_1 = UIElement::GetChildRecursive(this, 0x100001c6); -this->m_itemList = eax_1->vtable->DynamicCast(0x10000031); // UIElement_ItemList -... UIElement_Text::SetText(this->m_contentsText, u"Contents of Backpack"); -``` -Pre-dump `.layout-dumps/items3d-0x21000021.txt`: root `0x100001C4` (234×120, Type -`268435489 = 0x10000021`), child `0x100001C5` (text, base 0x10000436/0x21000077), -child `0x100001C6` (the ItemList grid, base 0x100002B9/0x2100003D — same ItemList -base as the slots), child `0x100001C7` (a scrollbar-shaped 16×96, base -0x100002C7/0x2100003E). **No Viewport element.** ⇒ gm3DItemsUI is a scrollable -**item-contents list**, not a 3D doll. **CONFIRMED.** (The "3D" in the name is -historical; it has no `UIElement_Viewport` and no `CreatureMode`.) - -## 3. Equip-slot model + the coverage / location enum - -### 3a. The element-id → EquipMask mapping (`GetLocationInfoFromElementID`) - -`gmPaperDollUI::GetLocationInfoFromElementID(elementId, out uint mask, out UI_SLOT_SIDE side)` -(decomp line 173620) is a giant switch. It is the SSOT for which slot is which. The -mask values are exactly ACE's `EquipMask` (`ACE/Source/ACE.Entity/Enum/EquipMask.cs`). -**CONFIRMED** — full table below (decomp line / mask / EquipMask name / SLOT_SIDE): - -| Element id | mask (hex) | EquipMask name | SLOT_SIDE | decomp line | -|---|---|---|---|---| -| `0x100005AB` | `0x1` | HeadWear | NULL | 173723 | -| `0x100001E2` | `0x2` | ChestWear | NULL | 173688 | -| `0x100001E3` | `0x40` | UpperLegWear | NULL | 173694 | -| `0x100005B0` | `0x20` | HandWear | NULL | 173753 | -| `0x100005B3` | `0x100` | FootWear | NULL | 173771 | -| `0x100005AC` | `0x200` | ChestArmor | NULL | 173729 | -| `0x100005AD` | `0x400` | AbdomenArmor | NULL | 173735 | -| `0x100005AE` | `0x800` | UpperArmArmor | NULL | 173741 | -| `0x100005AF` | `0x1000` | LowerArmArmor | NULL | 173747 | -| `0x100005B1` | `0x2000` | UpperLegArmor | NULL | 173759 | -| `0x100005B2` | `0x4000` | LowerLegArmor | NULL | 173765 | -| `0x100001DA` | `0x8000` | NeckWear | NULL | 173640 | -| `0x100001DB` | `0x10000` | WristWearLeft | LEFT | 173646 | -| `0x100001DD` | `0x20000` | WristWearRight | RIGHT | 173658 | -| `0x100001DC` | `0x40000` | FingerWearLeft | LEFT | 173652 | -| `0x100001DE` | `0x80000` | FingerWearRight | RIGHT | 173664 | -| `0x100001E1` | `0x200000` | Shield | NULL | 173682 | -| `0x100001E0` | `0x800000` | MissileAmmo | NULL | 173676* | -| `0x100001DF` | `0x3500000` | (weapon composite — see 3b) | NULL | 173670 | -| `0x100005E9` | `0x8000000` | Cloak | NULL | 173777 | -| `0x10000595` | `0x10000000` | SigilOne | NULL | 173705 | -| `0x10000596` | `0x20000000` | SigilTwo | NULL | 173711 | -| `0x10000597` | `0x40000000` | SigilThree | NULL | 173717 | -| `0x1000058E` | `0x4000000` | TrinketOne | NULL | 173630 | - -\* **`0x100001E0`** — the decomp pseudo-C shows `*arg3 = "activation type (%s)…"` -(a string-pointer artifact where the Binary Ninja lifter lost the immediate). The -preceding/following cases are `0x200000` (Shield) and `0x200000`/`0x40`, and the only -remaining ready-slot mask not otherwise assigned in this switch is `MissileAmmo -(0x00800000)`. So **`0x100001E0` = MissileAmmo `0x800000` (LIKELY** — inferred from -the EquipMask gap + neighbors; the literal value is corrupted in the decomp). - -`UI_SLOT_SIDE` (CONFIRMED `acclient.h:4546`): `SLOT_SIDE_NULL=0, SLOT_SIDE_LEFT=1, -SLOT_SIDE_RIGHT=2`. SIDE distinguishes the paired jewelry slots (left/right -wrist + finger) that share the same wear concept but different physical sides. - -### 3b. The weapon composite slot `0x3500000` - -`0x100001DF → 0x3500000` = `MeleeWeapon(0x100000) | MissileWeapon(0x400000) | -TwoHanded(0x2000000) | Held(0x1000000)` (= `0x3500000`). **CONFIRMED** by bit -decomposition against EquipMask.cs. This is the single "weapon hand" doll slot that -accepts any wieldable weapon. `OnItemListDragOver` has a special case at line 174302: -`if (ecx_3 == 0x200000 && (eax_3 & 0x100000) != 0) eax_3 |= ecx_3;` — i.e. a -melee-capable item may also drop into the Shield(0x200000) slot test. **CONFIRMED.** - -### 3c. How the client knows what is equipped — `GetUpperInvObj(mask)` - -`gmPaperDollUI::GetUpperInvObj(uint coverageMask)` (line 174565) is how the doll -finds the item currently in a slot: -``` -eax = ClientObjMaintSystem::GetWeenieObject(player_id); -eax_3 = ACCWeenieObject::GetInvPlacementList(eax); // PackableList -for (i = eax_3->head; i; i = i->next) { - if (arg2 & i->data.loc_) // coverageMask & placement.loc_ - eax_5 = InventoryPlacement::DetermineHigherPriority(...); -} -return iid; // the equipped item's guid -``` -`InventoryPlacement` (**CONFIRMED** `acclient.h:33178`): -```cpp -struct InventoryPlacement : PackObj { uint iid_; uint loc_; uint priority_; }; -``` -So the player weenie carries a **`PackableList`** where each -node is `{itemGuid, locationMask (EquipMask), priority}`. `loc_` is the EquipMask -slot; `priority_` resolves overlap (e.g. armor over clothing on the same body part — -this is `CoverageMask` priority, `ACE/Source/ACE.Entity/Enum/CoverageMask.cs`). -**CONFIRMED.** The paperdoll reads this list to populate each slot's icon and to -drive part-selection lighting (`GetSelectionMaskFromObject`, line 174762, maps an -item guid back to which doll body parts to highlight, via the same masks). - -**Cross-ref ACE:** `EquipMask` (loc) and `CoverageMask` (priority) are documented in -ACE as "sent as loc / in the priority field of the equipped-items list portion of the -player description event F7B0-0013" (`EquipMask.cs:5-6`, `CoverageMask.cs:6-7`). -**CONFIRMED** — this is the same `InventoryPlacement {iid, loc, priority}` triple the -client stores, populated from PlayerDescription's equipped section. - -**acdream parse status of the placement list:** PARTIAL. `PlayerDescriptionParser` -(0x0013) "walks all sections through enchantments; the trailing options / inventory / -**equipped** sections are partial" (`PlayerDescriptionParser.cs:70-77`). So acdream -does NOT yet surface the equipped `InventoryPlacement` list. The per-item equip -*state* is, however, available from `CreateObject`/`ObjDescEvent` ModelData -(palette/part swaps already applied to the model). **CONFIRMED** (parser comment). - -## 4. Wield / unwield wire + the ObjDesc change - -### 4a. Wire table - -| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream parse status | -|---|---|---|---|---|---|---| -| `0x001A` (GameAction) | GetAndWieldItem | C→S | drop an item onto an equip slot / doll (auto-wield) | `GameActionGetAndWieldItem.Handle` (`Actions/GameActionGetAndWieldItem.cs:7-14`) → `Player.HandleActionGetAndWieldItem(itemGuid, EquipMask)` | `Inventory_GetAndWieldItem` (`C2S/Actions/Inventory_GetAndWieldItem.generated.cs:14-42`: `uint ObjectId; EquipMask Slot`) | **MISSING** (no sender in acdream; `Grep GetAndWieldItem\|0x001A src` finds only the UI font-property 0x1A, unrelated) | -| `0x0019` (GameAction) | PutItemInContainer / move-to-pack (un-wield) | C→S | drag a wielded item back into a pack | ACE `GameActionPutItemInContainer` | `Inventory_PutItemInContainer*` | MISSING (inventory deep-dive scope) | -| `0xF625` | ObjDescEvent | S→C | server applies/removes the wielded item → appearance change | `GameMessageObjDescEvent` ctor → `worldObject.SerializeUpdateModelData` (`Messages/GameMessageObjDescEvent.cs:10-17`) | (ModelData block) | **PARSED** — `ObjDescEvent.cs:33-73` (opcode `0xF625`, `CreateObject.ReadModelData`) | -| `0xF745`/`0x0024` (CreateObject) | CreateObject | S→C | the wielded item object itself arrives | ACE creation message | `Item_CreateObject` | PARSED — `CreateObject.cs` | -| `0xF7B0`/`0x0013` (GameEvent) | PlayerDescription (equipped list) | S→C | full state incl. `InventoryPlacement` equipped section | `GameEventPlayerDescription.WriteEventBody` | `Login_PlayerDescription` | **PARTIAL** — `PlayerDescriptionParser.cs` (equipped section not surfaced) | - -Wire payload of `GetAndWieldItem` (**CONFIRMED** both refs agree): -- ACE reads `uint itemGuid; (EquipMask)int32 location` (`GameActionGetAndWieldItem.cs:10-11`). -- Chorizite writes `uint ObjectId; (uint)EquipMask Slot` (`.generated.cs:38-41`). -- holtburger sends `GetAndWieldItem { item_guid, equip_mask }` - (`holtburger-core/src/client/commands.rs:808-814`): - ```rust - self.send_game_action(GameAction::GetAndWieldItem(Box::new( - GetAndWieldItemActionData { item_guid: item, equip_mask: target_mask }))) - ``` - with `target_mask` resolved by `resolve_and_clear_slots(item, slot)` (line 799) — - i.e. the client picks the EquipMask for the target slot, exactly like the doll's - `GetLocationInfoFromElementID`. **CONFIRMED.** - -`GameActionType.GetAndWieldItem = 0x001A` (**CONFIRMED** -`ACE/Source/ACE.Server/Network/GameAction/GameActionType.cs:14`). - -### 4b. The ObjDesc change on the model (`ObjDescEvent` → `RedressCreature`) - -Server side: equipping changes the creature's `ObjDesc` (clothing base, sub-palettes, -texture changes, anim-part swaps) and broadcasts `ObjDescEvent (0xF625)` carrying the -FULL new appearance (ACE comment: "It contains the entire description of what they're -wearing", `GameMessageObjDescEvent.cs:6-9`). - -Client side: `gmPaperDollUI::RecvNotice_PlayerObjDescChanged` (line 174324) tail-calls -`gmPaperDollUI::RedressCreature` (line 173990). **CONFIRMED.** RedressCreature: -``` -if (m_pInventoryObject == 0 && smartbox->player != 0) { // first time: - eax_5 = CPhysicsObj::makeObject(GetPhysicsObject(player_id)); // clone player obj - this->m_pInventoryObject = eax_5; - CPhysicsObj::set_heading(eax_5, 191.367905f, 1); // face ~191° (toward viewer) - CPhysicsObj::set_sequence_animation(m_pInventoryObject, m_didAnimation.id, 1, 1, 0); - CreatureMode::AddObject(&m_pPaperDoll->creature_mode_objects, m_pInventoryObject); -} -visualDesc = SmartBox::get_player_visualdesc(smartbox); -CPhysicsObj::DoObjDescChangesFromDefault(this->m_pInventoryObject, visualDesc); // re-dress -``` -**CONFIRMED** (lines 173997-174012). So the doll is a CLONE of the player's -`CPhysicsObj`, and re-dressing is `CPhysicsObj::DoObjDescChangesFromDefault` applied -to the cloned object using the player's current `VisualDesc` — **the same ObjDesc -apply used for in-world creatures**. The ObjDesc fields (ACViewer -`Entity/ObjDesc.cs:18-54`): `PaletteID`, `SubPalettes`, `TextureChanges`, -`AnimPartChanges` — **all four already parsed by acdream's `CreateObject.ReadModelData` -/ `ObjDescEvent`** (`CreateObject.cs:652-679`: subPalette/textureChange/animPartChange -counts + entries). **CONFIRMED.** - -## 5. Paperdoll 3D rendering + reuse analysis - -### 5a. It is a 3D viewport, not a 2D image - -**CONFIRMED.** The doll is `UIElement_Viewport` (Type `0xD`), element `0x100001D5`. -`UIElement_Viewport::Create` (line 119029-119037) allocates the element + a -`CreatureMode` sub-object at `+0x5f0`; `PostInit` calls -`CreatureMode::InitializeScene` (line 119084). `SetCamera` forwards to -`CreatureMode::SetCameraPosition/Direction` (line 119089-119094). `Register` ⇒ -`RegisterElementClass(0xd, …)` (line 119126). So a Viewport is a mini 3D scene -embedded in a UI rect, with its own camera, lights, and an object list. - -The paperdoll init (line 175517-175535) does, once: -``` -m_pPaperDoll = GetChildRecursive(this, 0x100001d5)->DynamicCast(0xd); // the viewport -UIElement_Viewport::SetCamera(m_pPaperDoll, &dir, &pos); // pos/dir vec3s -UIElement_Viewport::SetLight(m_pPaperDoll, DISTANT_LIGHT, 2.0, &dir); // one distant light -CreatureMode::UseSharpMode(&m_pPaperDoll->creature_mode_objects); // sharper mip bias -gmPaperDollUI::RedressCreature(this); // build + dress the doll -``` -**CONFIRMED.** `UpdateForRace` (line 174129) re-points the camera per body-type -(case 6/7/8/9/0xC/0xD = the playable races/genders) and swaps `m_didAnimation` (the -idle pose DID) via `DBObj::GetDIDByEnum`. **CONFIRMED.** - -### 5b. The viewport render loop (`CreatureMode::Render`) - -`CreatureMode::Render` (line 91665) is the per-frame doll draw. Walk-through -(**CONFIRMED** lines 91665-91776): -1. Enter "creature mode" (disables world LOD degrade so the doll is full detail). -2. For each object in `creature_mode_objects`: `CPhysicsObj::update_position` (advance - the idle animation). -3. Set ambient color, sunlight, FOV (`Render::SetFOVRad`), push a frame. -4. `Render::update_viewpoint(&creature_view_frame)`, `set_default_view()`. -5. `RenderDevice::DrawObjCellForDummies(creature_cell)` — draw the object's private - cell, then `D3DPolyRender::FlushAlphaList`. - -i.e. the doll lives in its own tiny `creature_cell`, lit by one distant light, drawn -with a dedicated camera into the viewport rect. `CreatureMode::AddObject` (line 94374) -adds the cloned `CPhysicsObj` to that cell: -`CPhysicsObj::AddObjectToSingleCell(obj, creature_cell); SetPlacementFrame(obj,0,1);`. -**CONFIRMED.** - -### 5c. Can acdream REUSE its existing character render path? — YES - -**acdream already renders animated, equipped characters in-world.** The per-instance -path is `EntitySpawnAdapter` (`src/AcDream.App/Rendering/Wb/EntitySpawnAdapter.cs`): -- `OnCreate(WorldEntity)` builds an `AnimatedEntityState(sequencer)` and applies - `entity.HiddenPartsMask`, every `entity.PartOverrides` (`SetPartOverride(partIndex, - gfxObjId)` — weapons/clothing/helmets that replace the Setup default), and - pre-warms per-instance palette/texture decode via - `GetOrUploadWithPaletteOverride(surfaceId, texOverride, paletteOverride)`. - **CONFIRMED** `EntitySpawnAdapter.cs:100-168`. -- `WorldEntity` carries `SourceGfxObjOrSetupId`, `MeshRefs`, `PaletteOverride`, - `PartOverrides` (`record struct PartOverride(byte PartIndex, uint GfxObjId)`), and - `HiddenPartsMask`. **CONFIRMED** `WorldEntity.cs:14,28,37,97,104,213`. - -This is the EXACT data a re-dress produces: ObjDesc → base palette + sub-palettes -(`PaletteOverride`), texture changes (`SurfaceOverrides`), anim-part swaps -(`PartOverrides`). acdream already turns an `ObjDescEvent`/`CreateObject` ModelData -into these fields. **So the paperdoll doll = "take the local player's WorldEntity (or -a clone of it), feed it through the existing animated-character pipeline, and draw it -with a fixed camera + one distant light into a UI rect."** This is the C# analog of -`makeObject(player) + DoObjDescChangesFromDefault + CreatureMode::Render`. - -### 5d. What a `UiViewport` (Type 0xD) widget needs to host the 3D render - -The toolkit's `UiRenderContext` is a **2D** sprite/text submission bucket (see -`UiElement.OnDraw(UiRenderContext)`). A 3D model render cannot go through it. A -`UiViewport` widget therefore needs (LIKELY design — flagged): -1. **A render-into-rect hook.** The widget's screen rect (`ScreenPosition` + - Width/Height) defines a GL scissor + viewport. A 3D pass renders the single entity - there, AFTER the world pass and BEFORE/INTERLEAVED with the 2D UI pass. The cleanest - seam is a dedicated overlay callback the `UiHost`/`GameWindow` invokes for any - `UiViewport` present, NOT a draw inside `OnDraw` (which only has a 2D context). - **UNVERIFIED** — the exact integration point (a new `IUiViewportRenderer` Core - interface implemented in App, per Code-Structure Rule 2) is a design call for the - brainstorm/spec phase, not yet decided. -2. **A private mini-scene** mirroring `CreatureMode`: one entity (`AnimatedEntityState` - for the player clone), a fixed camera (position/direction vec3 like - `SetCamera`, e.g. the retail values `dir.z=0.12, pos=(~-2.4, ~0.88)` floats from - `UpdateForRace` — see the `0x3df5c28f / 0xc019999a / 0x3f6147ae` immediates at line - 175524-175526, which are little-endian floats ≈ 0.12, −2.4, 0.88; **LIKELY** — - I read the hex but did not byte-convert each), one distant light, and an idle - animation playing on the sequencer. -3. **A heading toward the viewer** (`set_heading(191.37°)`, line 174001) and optional - click-drag rotation (the doll spins under the mouse — that's - `m_paperDollDragMask`/`CreateClickMap`, line 174636; **part-selection lighting** for - "which armor piece is this?" highlight uses `ApplyPartSelectionLighting`, line - 174034, but that is a polish feature, not MVP). -4. **Reuse `EntitySpawnAdapter`'s state** — feed it the player's `WorldEntity` so the - doll automatically reflects equip changes when `ObjDescEvent` updates the player's - ModelData. The re-dress is then "rebuild the player WorldEntity's PartOverrides/ - PaletteOverride from the new ObjDesc and refresh the viewport's entity state" — the - C# analog of `RedressCreature`. - -This is the single biggest new piece. The 3D machinery exists; the work is the -**UI↔3D bridge** (a scissored single-entity pass driven by a UI rect). - -## 6. New toolkit widgets this introduces - -| Widget (proposed) | dat Type it registers at | leaf vs container | Purpose | -|---|---|---|---| -| **`UiViewport`** | **0xD** (`UIElement_Viewport`, reg line 119126) | **leaf** (`ConsumesDatChildren => true`) | Hosts a single 3D entity (the paperdoll character clone) rendered into the widget's screen rect via a scissored mini 3D pass. Owns a fixed camera + one distant light + an `AnimatedEntityState`; reuses `EntitySpawnAdapter`/`AnimatedEntityState` for the model. Needs a new render-into-rect seam (a Core `IUiViewportRenderer` interface implemented in App). **The biggest new piece.** | -| **`UiItemSlot`** (equip-slot variant of the shared item-slot) | **0x10000031** (`UIElement_ItemList`, single 32×32 cell) | **leaf** (`ConsumesDatChildren => true`) | One equip slot. Renders the equipped item's icon (from the weenie `IconDataID`), is a drag-drop target keyed to its `EquipMask` (from `GetLocationInfoFromElementID`), shows/hides per occupancy. NOTE: this is the single-cell case of the shared `UIElement_UIItem`/`UIElement_ItemList` spine widget — the equipment panel is a fixed grid of ~25 of these, one per EquipMask, NOT a scrollable list. **Defer the shared icon/drag mechanics to the spine doc** (`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`, NOT FOUND yet); this panel only adds the EquipMask binding + the fixed-position-per-slot layout. | -| **Window manager** (shared, not paperdoll-specific) | n/a (uses Dragbar Type 2 / Resizebar Type 9 already present on chrome) | n/a | Open/close/z-order/persist for the paperdoll window. `UiElement.Draggable/Resizable` already exist; the manager wires them + persistence. Shared with inventory/toolbar — same item the handoff §2 calls "the other deferred Plan-2 piece". | - -`gm3DItemsUI`'s pane reuses `UiItemSlot`/the spine `UiItemList` + a `UiScrollbar` -(Type 0xB, already built) + a `UiText` (already built) — no NEW widget. It is an -inventory-contents list (inventory deep-dive scope), not a doll. - -## 7. Open questions / UNVERIFIED - -- **`0x100001E0` = MissileAmmo `0x800000`** — LIKELY (the decomp immediate is - corrupted to a string pointer at line 173676; inferred from the EquipMask gap + - neighbors). Re-dump element `0x100001E0`'s position vs the ammo doll slot, or - re-decompile `0x004a388a` in Ghidra to recover the real immediate, to confirm. -- **The exact viewport camera/light immediates** (lines 175524-175526, 174144-174146) - — I read the hex but did not byte-convert all of them to floats; the paperdoll - brainstorm should decode `0x3df5c28f≈0.12`, `0xc019999a≈−2.4`, `0xc0400000=−3.0`, - `0xc059999a≈−3.4`, `0x3f6147ae≈0.88`, `0x3f800000=1.0` precisely for a faithful - framing. **UNVERIFIED.** -- **The UI↔3D render seam** (how a UI rect drives a scissored single-entity 3D pass, - and whether it draws after the world pass or as a UI overlay) — DESIGN-OPEN, to be - settled in brainstorm. Code-Structure Rule 2 means the seam is a Core interface - implemented in App. **UNVERIFIED.** -- **acdream's PlayerDescription equipped section** is not surfaced - (`PlayerDescriptionParser.cs:70-77`). To populate slot icons at login (vs only - reacting to later `ObjDescEvent`s), the parser must be extended to read the - `InventoryPlacement` equipped list. Filed as a dependency, not yet an issue. -- **Whether the doll clones the player `WorldEntity` or builds a fresh one** — retail - clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`, line - 173999). acdream has no player `CPhysicsObj`-as-renderable today (the local player - isn't a `WorldEntity` in the per-instance adapter — it's the camera). LIKELY the - paperdoll builds a dedicated `WorldEntity` from the local player's - Setup+ObjDesc and feeds it to a private `EntitySpawnAdapter`-like host. **UNVERIFIED.** -- **`gm3DItemsUI` true role** — its `m_itemList` + "Contents of Backpack" text is - CONFIRMED, but whether retail ever shows 3D item models in it (the name suggests a - historical 3D-preview) — NOT FOUND any Viewport in its layout; treated as a 2D - contents list. If a 3D item preview surfaces elsewhere, revisit. - -## 8. MEMORY.md index line - -- [Equipment/Paperdoll panel deep-dive](research/2026-06-16-equipment-paperdoll-deep-dive.md) — gmPaperDollUI 0x10000024/LayoutDesc 0x21000024: doll = UIElement_Viewport (Type 0xD, elem 0x100001D5) hosting a CreatureMode clone re-dressed via DoObjDescChangesFromDefault; ~25 equip slots are single-cell UIElement_ItemList (0x10000031) mapped element-id→EquipMask by GetLocationInfoFromElementID; wield = GetAndWieldItem (0x001A, item+EquipMask, acdream-MISSING), appearance reply = ObjDescEvent 0xF625 (acdream-PARSED) → RedressCreature; acdream's EntitySpawnAdapter/AnimatedEntityState char path is reusable; new widgets = UiViewport (0xD, the UI↔3D bridge), UiItemSlot (0x10000031), window manager. gm3DItemsUI 0x21000021 is a "Contents of Backpack" list, NOT the doll. diff --git a/docs/research/2026-06-16-inventory-deep-dive.md b/docs/research/2026-06-16-inventory-deep-dive.md deleted file mode 100644 index 614932d4..00000000 --- a/docs/research/2026-06-16-inventory-deep-dive.md +++ /dev/null @@ -1,391 +0,0 @@ -# Inventory panel deep-dive — `gmInventoryUI` + `gmBackpackUI` - -**Date:** 2026-06-16 -**Phase:** D.2b core-panels research (report-only). Sibling of the action-bar -and paperdoll deep-dives; builds on the `UIElement_UIItem` / icon / drag-drop -**spine** research (see §1 note). Answers handoff §3 questions **Q1** (this -panel's `LayoutDesc`), **Q7** (window layout), **Q8** (full inventory -wire-message set), **Q9** (icon rendering states). - -## 1. Summary + confidence legend - -The retail inventory window is two cooperating dat windows. **`gmInventoryUI` -(class `0x10000023`, `LayoutDesc 0x21000023`, 300×362)** is the OUTER frame: a -title bar, a chrome border, and three slots that host CHILD windows — -`gmPaperDollUI` (the equipped-gear doll), `gmBackpackUI` (the pack list), and -`gm3DItemsUI` (the 3D rotating-character viewport). **`gmBackpackUI` (class -`0x10000022`, `LayoutDesc 0x21000022`, 61×339)** is the left strip: a burden -**Meter** (Type 7) + a `%`-burden text label, the main-pack item grid -(`UIElement_ItemList` `0x10000031`), and the side-pack tab column (a second -`UIElement_ItemList`). Every cell in those grids is a `UIElement_UIItem` -(class `0x10000032`) — the shared spine widget. Items are server-spawned -**`ACCWeenieObject`** weenies; the client learns container contents from -`CreateObject (0xF745)` + `PlayerDescription (0x0013)` at login and from the -`0xF7B0` GameEvent family (`ViewContents 0x0196`, `InventoryPutObjInContainer -0x0022`, `WieldObject 0x0023`, …) thereafter; it manipulates them with -`0xF7B1` GameActions (`PutItemInContainer 0x0019`, `DropItem 0x001B`, -`GetAndWieldItem 0x001A`, the `Stackable*` family, `GiveObjectRequest 0x00CD`). - -acdream already has the outbound builders for most actions -(`InventoryActions.cs`, `InteractRequests.cs`) and parsers for most inbound -events (`GameEvents.cs`), plus a live `ItemRepository`. The gaps are concrete -and enumerated in §4: a missing `DropItem`/`GetAndWieldItem`/`ViewContents`/ -`NoLongerViewingContents` parser-or-builder, a 4th field on -`InventoryPutObjInContainer`, and `CreateObject` not yet extracting -`IconId`/`WeenieClassId`/`StackSize`/capacities. - -> **Spine dependency.** The handoff said the SPINE agent's doc would live at -> `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`. -> At the time of writing **that file does NOT exist** (only the handoff -> `2026-06-16-action-bar-inventory-equipment-handoff.md` is present — verified -> by `Glob docs/research/2026-06-16-*.md`). I therefore derived the -> inventory-relevant `UIElement_UIItem` facts FIRST-HAND from the decomp and -> cite them here; where the spine doc later goes deeper (icon DBObj render, -> drag state machine), this doc should be read as the inventory-specific layer -> on top of it. - -**Confidence legend:** -- **CONFIRMED** — quoted from a source I opened (decomp `class::method` + line, - or a real `file:line`). -- **LIKELY** — inferred from a confirmed source; the inference is named. -- **UNVERIFIED** — educated guess, flagged loudly; do not port without checking. - ---- - -## 2. LayoutDesc / element map (Q1, Q7) - -### 2.1 `gmInventoryUI` — outer frame, `LayoutDesc 0x21000023` (300×362) - -**CONFIRMED Q1.** `gmInventoryUI::Register` registers element class `0x10000023`: -> `gmInventoryUI::Register (decomp line 176285): UIElement::RegisterElementClass(0x10000023, gmInventoryUI::Create);` - -The window is built from `LayoutDesc 0x21000023` (pre-dump -`.layout-dumps/inventory-0x21000023.txt`). The root element `0x100001CC` -(Type `268435491 = 0x10000023` = the gmInventoryUI class itself) is 300×362 at -ZLevel 1000. `gmInventoryUI::PostInit` (decomp 176236) resolves its named -children by id — these element ids match the dump 1:1, which is what confirms -the map: - -| Dump element | X,Y,W,H | Type (resolved) | PostInit binds to | Role | -|---|---|---|---|---| -| `0x100001CC` (root) | 0,0 300×362 | `0x10000023` gmInventoryUI | — | window root | -| `0x100001CD` | 0,23 224×214 | `0x10000024` (base `0x21000024`) | `m_paperDollUI` (DynamicCast `0x10000024`) | nested **PaperDoll** window | -| `0x100001CE` | 239,23 61×339 | `0x10000022` (base `0x21000022`) | `m_backpackUI` (DynamicCast `0x10000022`) | nested **Backpack** strip | -| `0x100001CF` | 0,237 234×120 | `0x10000021` (base `0x21000021`) | `m_3DItemsUI` (DynamicCast `0x10000021`) | nested **3D items** viewport | -| `0x100001D3` | 0,0 276×25 | base `0x21000191` | `m_titleText` (`GetChildRecursive`) | title bar ("Inventory of %s") | -| `0x100001D2` | 276,0 24×23 | base `0x10000... 0x21000192` | (button: chrome) | close/X button (states Normal/pressed) | -| `0x100001D1` | 0,361 300×1 | Type 3 (Field/chrome) | — | bottom rule line (sprite `0x06004D0B`) | -| `0x100001D0` | 0,0 300×362 | Type 3 (Field/chrome) | — | full-window backdrop (`0x06004D0A`, Alphablend) ZLevel 100 | - -PostInit excerpt (CONFIRMED): -> `gmInventoryUI::PostInit (176240–176259): m_titleText = GetChildRecursive(this, 0x100001d3); … = GetChildRecursive(this, 0x100001cd)->DynamicCast(0x10000024) [paperdoll]; … 0x100001ce ->DynamicCast(0x10000022) [backpack]; … 0x100001cf ->DynamicCast(0x10000021) [3DItems];` - -**Implication for the toolkit (LIKELY):** the inventory frame is mostly chrome -+ a title `UIElement_Text` + an X button — the real work is delegated to three -NESTED `LayoutDesc` windows. The importer already recurses generic containers, -but it has never instantiated a *nested gm\*UI window* (an element whose Type is -a high `0x10000xxx` game class with its own `BaseLayoutId`). This is the -"sub-window mount" gap (§6). - -### 2.2 `gmBackpackUI` — pack strip, `LayoutDesc 0x21000022` (61×339) - -**CONFIRMED Q1.** `gmBackpackUI::Register` (decomp 176531): -> `UIElement::RegisterElementClass(0x10000022, gmBackpackUI::Create);` - -Built from `LayoutDesc 0x21000022` (pre-dump `.layout-dumps/backpack-0x21000022.txt`). -Root `0x100001C8` (Type `268435490 = 0x10000022`) is 61×339. `gmBackpackUI::PostInit` -(decomp 176596) binds the children — again matching the dump exactly: - -| Dump element | X,Y,W,H | Type | PostInit binds to | Role | -|---|---|---|---|---| -| `0x100001C8` (root) | 0,0 61×339 | `0x10000022` gmBackpackUI | — | window root | -| `0x100001D7` | 0,7 36×15 | base `0x10000376`/`0x2100003F` | — | "Burden" caption text | -| `0x100001D8` | 0,18 36×15 | base `0x10000376`/`0x2100003F` | `m_burdenText` | the `%`-load number text | -| `0x100001D9` | 44,8 11×58 | **7 (Meter)** | `m_burdenMeter` (DynamicCast 7) | **the burden bar** (vertical) | -| `0x100001C9` | 6,32 36×36 | `0x10000031` ItemList | `m_topContainer` (DynamicCast `0x10000031`) | main-pack first cell / list head | -| `0x100001CA` | 6,73 36×252 | `0x10000031` ItemList | `m_containerList` (DynamicCast `0x10000031`) | the **item grid** (main pack) | -| `0x100001CB` | 41,73 16×252 | base `0x10000... 0x2100003E` | — | side-pack tab column / scrollbar gutter | - -PostInit excerpt (CONFIRMED): -> `gmBackpackUI::PostInit (176600–176629): m_burdenText = GetChildRecursive(this, 0x100001d8); m_burdenMeter = GetChildRecursive(0x100001d9)->DynamicCast(7); … m_topContainer = GetChildRecursive(0x100001c9)->DynamicCast(0x10000031); m_containerList = GetChildRecursive(0x100001ca)->DynamicCast(0x10000031);` - -**The burden Meter (Q7 answer).** Element `0x100001D9` is the Type-7 meter the -backpack dump shows with back sprite `0x0600121C` (grandchild `0x00000002`) + -fill sprite `0x0600121D`. It is a VERTICAL 11×58 bar (the only meter in the -window) — confirmed by `gmBackpackUI::SetLoadLevel` writing it: -> `gmBackpackUI::SetLoadLevel (176565–176573): m_burdenMeter; …(float)arg2; var_10 = 0x69; UIElement::SetAttribute_Float();` - -That is the SAME meter-fill mechanism as vitals (property `0x69` = fill ratio, -pushed at runtime — see `2026-06-15-layoutdesc-format.md §3`). The fill value -is `load × 0.3333…` clamped to [0,1] (CONFIRMED 176542: -`x87_r7_1 = arg2 * 0.33333333333333331`), and the text is formatted `%d%%` -from `floor(load × 300)` (CONFIRMED 176576–176583: -`floor(arg2 * 300.0)` → `SetText(m_burdenText, "%d%%")`). So the bar is FULL -at 100% load and the number reads 0–300% (retail's encumbrance scale: 100% = -your computed max burden, you can carry up to 300%). - -> **Where is the VALUE total / coin total?** NOT in `gmBackpackUI` — there is -> no value Meter or value text element in `0x21000022`. The inventory window -> shows BURDEN only; the pyreal/coin total is the player's Coin Value displayed -> elsewhere (UNVERIFIED — likely a separate stat readout; the panel dump has -> no value field). Do not invent a value summary for this window. - -**The side-pack list.** `m_containerList` (`0x100001CA`) is the main item grid; -`0x100001CB` is the narrow 16-wide column to its right (scrollbar gutter / tab -strip). The retail "side packs" (sub-bags) are opened as ADDITIONAL container -views — `gmInventoryUI::RecvNotice_OpenContainedContainer` (decomp 176290) -routes a contained-container open into a second `UIElement_ItemList`: -> `RecvNotice_OpenContainedContainer (176318): UIElement_ItemList::ItemList_OpenContainer(*(…+0x608), arg2, 1);` -> (offset `+0x604` = the main/own list; `+0x608` = the secondary/other-container list) - -The two `UIElement_ItemList`s at member offsets `+0x604` and `+0x608` are the -"my main pack" list and the "currently-open other container" list — CONFIRMED -by the dual flush/open pattern in `RecvNotice_SetDisplayInventory` -(176114/176123/176141) and `RecvNotice_PlayerDescReceived` (176374/176375 -`ItemList_SetChildList(+0x604, …); ItemList_SetChildList(+0x608, …)`). - ---- - -## 3. Container model for this panel (Q3 / cross-cutting, inventory slice) - -**Items are server weenies (`ACCWeenieObject`).** CONFIRMED throughout the -inventory code: `ClientObjMaintSystem::GetWeenieObject(itemID)` is the only way -the panel resolves an item id to its data (e.g. `UIItem_Update` 230235, -`RecvNotice_OpenContainedContainer` 176293). This matches -`claude-memory/feedback_weenie_vs_static.md` (interactable items are -server-spawned weenies). [CONFIRMED] - -**Container hierarchy = 2-deep.** A character has a main pack (capacity ~102) + -N side-packs (sub-bags); a side-pack cannot hold another side-pack. acdream's -`Container` model already encodes this (`ItemInstance.cs:154` `Container` with -`SidePacks` + `IsSidePack => SideCapacity == 0`). [CONFIRMED in acdream; the -2-deep rule is retail-standard and matches ACE] - -**How the client learns contents:** -1. **At login** — `PlayerDescription (0x0013)` carries the player's full - inventory + equipped lists; acdream already registers both into - `ItemRepository` (`GameEventWiring.cs:405–432`). [CONFIRMED] -2. **Per-item spawn** — `CreateObject (0xF745)` for each visible weenie; for an - item in your pack the server sends the weenie (with `IconId`, capacities, - stack size in the WeenieHeader). acdream's `CreateObject.TryParse` extracts - guid/name/itemType but **discards IconId, WeenieClassId, StackSize, Value, - ItemCapacity, ContainerCapacity** (it `_ =`-skips the IconId at - `CreateObject.cs:516` and never reads StackSize/Value). [CONFIRMED gap] -3. **Open a container** — `ViewContents (0x0196)` lists `{guid, containerType}` - per slot; `gmInventoryUI` / `UIElement_ItemList` insert a `UIElement_UIItem` - per entry. [CONFIRMED on ACE/holtburger side; acdream has NO ViewContents - parser] -4. **Live moves** — `InventoryPutObjInContainer (0x0022)`, `WieldObject - (0x0023)`, `InventoryPutObjectIn3D (0x019A)` relocate one weenie; - `gmInventoryUI::RecvNotice_ServerSaysMoveItem` (176175) + the - `UIElement_ItemList` rebuild the affected cells. [CONFIRMED] - -**The notice ids `gmInventoryUI::PostInit` registers (CONFIRMED 176269–176277)** -— these are the internal client notice opcodes (NOT wire opcodes) the window -listens to: `0x4dd1f0, 0x4dd1f1, 0x4dd1f2, 0x4dd1f6, 0x4dd266, 0x186ab, -0x186a8, 0x4dd25b, 0x4dd25d`. They map (via the vftable, 980257–980562) to -`RecvNotice_ItemAttributesChanged / ServerSaysMoveItem / EndPendingInPlayer / -ShowPendingInPlayer / OpenContainedContainer / NewParentContainer / -PlayerDescReceived / SetDisplayInventory / UpdateCharacterInformation`. These -are the controller hooks acdream's `InventoryController` (new, §6) must expose -to drive the live grid. - ---- - -## 4. Wire-message catalog (Q8) - -All client→server ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq; -u32 subOpcode; …`); all server→client item events ride the `0xF7B0` GameEvent -envelope (`u32 0xF7B0; u32 target; u32 seq; u32 eventOpcode; …`). -**ACE handler** = the file under -`ACE/Source/ACE.Server/Network/GameAction/Actions/` (C→S) or -`…/GameEvent/Events/` (S→C). **Chorizite/holtburger** field order verified; -where I cite holtburger it is `inventory/actions.rs` or `inventory/events.rs` -(both opened, with hex pack/unpack fixtures). - -### 4.1 Client → server (GameActions, `0xF7B1`) - -| Opcode | Name | Dir | Trigger | ACE handler | Field order (holtburger/ACE) | acdream parse status | -|---|---|---|---|---|---|---| -| `0x0019` | PutItemInContainer | C→S | drag item into pack / pick up ground item (container = self) | `GameActionPutItemInContainer.Handle` | `u32 itemGuid, u32 containerGuid, i32 placement` | **parsed** — `InteractRequests.BuildPickUp` (`InteractRequests.cs:97`) | -| `0x001A` | GetAndWieldItem | C→S | equip an item from inventory onto the doll | (`GameActionType` 0x001A; handler `Player_Inventory`) | `u32 itemGuid, u32 equipMask` (holtburger `actions.rs:8` `GetAndWieldItemActionData`) | **MISSING** (no builder) | -| `0x001B` | DropItem | C→S | drop an item on the ground | `GameActionDropItem.Handle` | `u32 itemGuid` (holtburger `actions.rs:140`) | **MISSING** (no builder; acdream reuses 0x0019 for moves only) | -| `0x0035` | UseWithTarget | C→S | use src item on target (key→door) | (Interact) | `u32 sourceGuid, u32 targetGuid` | **parsed** — `InteractRequests.BuildUseWithTarget` | -| `0x0036` | UseItem | C→S | use/equip-by-doubleclick a single item | `GameActionUseItem` | `u32 targetGuid` | **parsed** — `InteractRequests.BuildUse` | -| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` | `u32 mergeFromGuid, u32 mergeToGuid, i32 amount` | **parsed** — `InventoryActions.BuildStackableMerge` | -| `0x0055` | StackableSplitToContainer | C→S | split N off a stack into a pack slot | `GameActionStackableSplitToContainer.Handle` | `u32 stackGuid, u32 containerGuid, i32 place, i32 amount` | **parsed** — `InventoryActions.BuildStackableSplitToContainer` | -| `0x0056` | StackableSplitTo3D | C→S | split N off a stack onto the ground | `GameActionStackableSplitTo3D.Handle` | `u32 stackGuid, i32 amount` | **parsed** — `InventoryActions.BuildStackableSplitTo3D` | -| `0x019B` | StackableSplitToWield | C→S | split N off a stack into an equip slot (e.g. arrows) | `GameActionStackableSplitToWield` | `u32 stackGuid, u32 equipMask, i32 amount` | **parsed** — `InventoryActions.BuildStackableSplitToWield` | -| `0x00CD` | GiveObjectRequest | C→S | give item (or N of a stack) to an NPC/player | `GameActionGiveObjectRequest.Handle` | `u32 targetGuid, u32 itemGuid, i32 amount` | **parsed** — `InventoryActions.BuildGiveObjectRequest` | -| `0x0195` | NoLongerViewingContents | C→S | close a side-pack / ground-container view | (`GameActionType` 0x0195) | `u32 containerGuid` (holtburger `actions.rs:280`) | **MISSING** (no builder) | -| `0x019C` | AddShortcut | C→S | pin to quickbar (toolbar phase, listed for completeness) | (`GameActionType`) | `u32 slot, u32 objType, u32 targetId` | **parsed** — `InventoryActions.BuildAddShortcut` | -| `0x019D` | RemoveShortcut | C→S | unpin quickbar slot | (`GameActionType`) | `u32 slot` | **parsed** — `InventoryActions.BuildRemoveShortcut` | - -**Opcode source (CONFIRMED):** `ACE/.../GameAction/GameActionType.cs:13–76` — -`PutItemInContainer=0x0019, GetAndWieldItem=0x001A, DropItem=0x001B, -UseWithTarget=0x0035, StackableMerge=0x0054, StackableSplitToContainer=0x0055, -StackableSplitTo3D=0x0056, GiveObjectRequest=0x00CD, NoLongerViewingContents=0x0195, -StackableSplitToWield=0x019B`. ACE handler field order CONFIRMED by reading each -`GameAction*.Handle` (DropItem reads 1 u32; PutItemInContainer reads 3; -GiveObjectRequest reads 3; StackableMerge reads 3; SplitToContainer reads 4; -SplitTo3D reads 2). holtburger hex fixtures (`actions.rs` test module) -independently confirm every field layout. - -> **acdream byte-order note:** `InteractRequests.BuildPickUp` writes `placement` -> as `i32` (`InteractRequests.cs:106`), matching ACE's `ReadInt32()`. The split -> builders write `amount`/`placement` as `u32` — on the wire identical bytes, -> but ACE reads them as `i32` (negative split amounts can't occur, so this is -> safe). [CONFIRMED, harmless] - -### 4.2 Server → client (GameEvents, `0xF7B0`) - -| Opcode | Name | Dir | Trigger | ACE handler | Field order | acdream parse status | -|---|---|---|---|---|---|---| -| `0x0022` | InventoryPutObjInContainer | S→C | server confirms item now in container at slot | `GameEventItemServerSaysContainId` | `u32 itemGuid, u32 containerGuid, u32 placement, u32 containerType` | **parsed (INCOMPLETE)** — `GameEvents.ParsePutObjInContainer` reads only 3 fields, **drops `containerType`** | -| `0x0023` | WieldObject | S→C | server confirms item equipped to slot | `GameEventWieldItem` | `u32 objectId, i32 equipMask` | **parsed + wired** — `GameEvents.ParseWieldObject`, `GameEventWiring.cs:231` | -| `0x0196` | ViewContents | S→C | full contents list of a container you opened | `GameEventViewContents` | `u32 containerGuid, u32 count, [u32 guid, u32 containerType]×count` | **MISSING** (no parser) | -| `0x019A` | InventoryPutObjectIn3D | S→C | server confirms item dropped to world | `GameEventItemServerSaysMoveItem` | `u32 objectGuid` | **parsed (UNWIRED)** — `GameEvents.ParsePutObjectIn3D` exists, not in `WireAll` | -| `0x00A0` | InventoryServerSaveFailed | S→C | reject a speculative client move (roll back) | `GameEventInventoryServerSaveFailed` | `u32 itemGuid, u32 weenieError` | **parsed (UNWIRED, INCOMPLETE)** — `GameEvents.ParseInventoryServerSaveFailed` reads only the guid, drops error (holtburger reads both: `events.rs:147`) | -| `0x0052` | CloseGroundContainer | S→C | server closed a ground-container view | `GameEventCloseGroundContainer` | `u32 containerGuid` | **parsed (UNWIRED)** — `GameEvents.ParseCloseGroundContainer` exists, not in `WireAll` | -| `0x00C9` | IdentifyObjectResponse | S→C | appraise result (full property bundle) | `GameEventIdentifyObjectResponse` | `u32 guid, u32 flags, u32 success, …property tables…` | **parsed + wired** — `AppraiseInfoParser` via `GameEventWiring.cs:245` | -| `0xF745` | CreateObject (GameMessage, not GameEvent) | S→C | spawn a weenie (incl. an item in your pack) | `GameMessageCreateObject` → `WorldObject.SerializeCreateObject` | weenie header (Name, WeenieClassId, **IconId**, ItemType, …) + ModelData + PhysicsData | **parsed (INCOMPLETE)** — `CreateObject.TryParse` skips IconId/WeenieClassId/StackSize/Value/capacities | -| `SetStackSize` (`0x0197`/UIQueue) | SetStackSize | S→C | update a stack's count + value after merge/split | `GameMessageSetStackSize` | `u32 seq, u32 guid, u32 stackSize, u32 value` | **MISSING** (no parser) | -| `InventoryRemoveObject` (UIQueue) | InventoryRemoveObject | S→C | remove an item from inventory view (given/dropped/destroyed) | `GameMessageInventoryRemoveObject` | `u32 guid` | **MISSING** (no parser) | - -**Opcode + field-order sources (CONFIRMED):** -- `0x0022` four fields: `GameEventItemServerSaysContainId.cs:10–13` writes - `itemGuid, containerGuid, PlacementPosition, ContainerType`; holtburger - `events.rs:65` reads `item_guid, container_guid, slot, container_type` - (+ hex fixture `events.rs:217` slot=3 type=1). acdream's parser - (`GameEvents.cs:352`) stops after 3 u32s — `containerType` is dropped. -- `0x0196` shape: `GameEventViewContents.cs:13–26` writes `Guid, count, {guid, - containerType}×n`; holtburger `events.rs:20` (+ fixture `events.rs:195`). -- `0x0023`: `GameEventWieldItem.cs:11–12` writes `objectId, (int)newLocation`. -- `0x019A`: `GameEventItemServerSaysMoveItem.cs:11` writes only `Guid`. -- `0x00A0`: `GameEventInventoryServerSaveFailed.cs` (error code present; - holtburger reads it). -- `SetStackSize`: `GameMessageSetStackSize.cs:12–15` (`seq, guid, stackSize, - value`). -- `InventoryRemoveObject`: `GameMessageInventoryRemoveObject.cs:11` (`guid`). - -### 4.3 acdream wire gaps (concrete TODO list for the build session) - -- **Add C→S builders:** `DropItem (0x001B)`, `GetAndWieldItem (0x001A)`, - `NoLongerViewingContents (0x0195)`. (Equip + drop are core inventory verbs.) -- **Add S→C parsers:** `ViewContents (0x0196)`, `SetStackSize`, - `InventoryRemoveObject`. -- **Fix `ParsePutObjInContainer`** to read the 4th `containerType` u32. -- **Fix `ParseInventoryServerSaveFailed`** to read the `weenieError` u32. -- **Wire (register in `GameEventWiring.WireAll`):** `ViewContents`, - `InventoryPutObjectIn3D`, `CloseGroundContainer`, `InventoryServerSaveFailed` - (parsers exist or will, but `WireAll` doesn't register them today — - CONFIRMED `GameEventWiring.cs` registers only `WieldObject`, - `InventoryPutObjInContainer`, `IdentifyObjectResponse`, `PlayerDescription`). -- **Extend `CreateObject.TryParse`** to capture `IconId` (already in the wire, - currently `_`-discarded at `CreateObject.cs:516`), `WeenieClassId`, - `StackSize`, `Value`, `ItemCapacity`, `ContainerCapacity` — the inventory - cell needs all of these to draw an icon + quantity + capacity bar. - ---- - -## 5. Drag-drop for inventory (Q5, this panel's slice) - -The drag-drop machinery lives on `UIElement_UIItem` (the spine widget). The -inventory-relevant parts I confirmed first-hand: - -- **A slot accepts a drop** via `UIElement_UIItem::SetDragAcceptState(state)`, - toggling the `m_elem_Icon_DragAccept` sub-element's STATE - (`0x10000040` = reject / `0x10000041` = accept; CONFIRMED - `SetDragAcceptState` 229271–229277, and call sites at 174307/174313, - 201327/201333 flip between the two). [CONFIRMED] -- **A drag in progress** uses `m_dragIcon` (a translucent copy of the icon, - created in `PostInit` 229738–229740 via `UIElementManager::CreateChildElement` - with id `0x10000345`, `SetVisible(0)` until a drag starts). [CONFIRMED] -- **The drop RESULT is a wire action**, chosen by source→destination: - inventory→pack slot = `PutItemInContainer (0x0019)`; inventory→doll = - `GetAndWieldItem (0x001A)`; inventory→ground = `DropItem (0x001B)`; - stack→compatible stack = `StackableMerge (0x0054)`; partial-stack drag = - one of the `StackableSplit*` (the count picker dialog supplies `amount`); - item→NPC = `GiveObjectRequest (0x00CD)`. [LIKELY — inferred from the action - set in §4 + the ACE handler names; the exact source/dest→opcode table is the - spine doc's job, but these are the inventory verbs] -- **Speculative-then-confirm:** the client may move the cell locally and wait; - if the server rejects, `InventoryServerSaveFailed (0x00A0)` rolls it back - (the slot's pending/ghost state is `SetWaitingState` → `m_elem_Icon_Ghosted` - greys it; CONFIRMED `SetWaitingState` 229190–229208 toggles - `m_elem_Icon_Ghosted` visibility). acdream's `ItemRepository` already - documents this revert path (`ItemRepository.cs:30`). [CONFIRMED mechanism] - -For acdream's toolkit, the drop target is a `UiItemSlot` (§6) that reports a -drop to the `InventoryController`, which picks the opcode and sends it via -`LiveCommandBus` + the builders in §4 — mirroring the existing interaction -pipeline (`claude-memory/project_interaction_pipeline.md`, B.4 -WorldPicker→Use). The `UiRoot` already has drag-drop input plumbing -(per `project_d2b_retail_ui.md`: "UiRoot already has full input -(focus/capture/drag-drop/tooltip/click) — dormant until wired"). - ---- - -## 6. New toolkit widgets this introduces - -The inventory panel needs four new pieces beyond the shipped spine widgets -(Button/Menu/Meter/Scrollbar/Text/Field/UiDatElement): - -| Widget | dat Type it registers at | Leaf or container | Purpose | -|---|---|---|---| -| **`UiItemSlot`** (port of `UIElement_UIItem`) | **`0x10000032`** (`UIElement_UIItem::Register` line 229339); resolves to a `UIElement_Field` subclass ⇒ underlying **Type 3** | **leaf** (`ConsumesDatChildren=>true`) — it owns the icon + all overlay sub-elements (`m_elem_Icon` `0x1000033b`, `m_elem_Icon_Overlays` `…33c`, `m_elem_Icon_Selected` `…342`, `m_elem_Icon_Ghosted` `…349`, `m_elem_Icon_Quantity` `…4f5`, `m_elem_Icon_CapacityBar` `…347`/`StructureBar` `…348` Type-7 meters, cooldown ring `…54f–558`) and reproduces them procedurally | one item-in-a-slot: icon + quantity + capacity/structure bars + selection/ghost/drag-accept/open-container overlays. **Shared by all 3 panels.** *(This is the spine widget; named here for the inventory's needs.)* | -| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`) | **`0x10000031`** (`UIElement_ItemList`; the backpack root element is itself this class) | **container** of `UiItemSlot`s (it lays out an N-column grid + scroll) | the main-pack grid + the side-pack list. Methods to port: `ItemList_AddItem`, `ItemList_InsertItem`, `ItemList_Flush`, `ItemList_OpenContainer`, `ItemList_SetChildList`, `ItemList_SetParentContainer`, `ItemList_OpenFirstContainer` (all CONFIRMED as called from `gmInventoryUI`/`gmBackpackUI`). Two instances per backpack (own list `+0x604`, other-container list `+0x608`). | -| **Sub-window mount** (importer capability, not a widget per se) | element whose Type is a high `0x10000xxx` game class WITH a non-zero `BaseLayoutId` (e.g. `0x100001CD`→paperdoll `0x21000024`) | container | lets `LayoutImporter` instantiate a NESTED `LayoutDesc` window inside a parent slot (paperdoll + backpack + 3DItems inside the inventory frame). The importer recurses generic children today but has never mounted another gm\*UI window. | -| **Window manager** (the deferred Plan-2 piece) | drives Dragbar (Type 2) + Resizebar (Type 9) + open/close/z-order/persist | infra | inventory/paperdoll/toolbar are pop-up windows; needs the faithful grip/dragbar drag (today vitals/chat use whole-window drag, accepted IA-12 approximation). | - -Plus a thin **`InventoryController`** (the `gmInventoryUI::PostInit` analogue): -find-by-id binds `m_titleText`/`m_paperDollUI`/`m_backpackUI`/`m_3DItemsUI`, -subscribes to `ItemRepository` events, and exposes the notice hooks -(`ServerSaysMoveItem`, `SetDisplayInventory`, `OpenContainedContainer`, -`PlayerDescReceived`) — exactly mirroring `VitalsController`/`ChatWindowController`. - ---- - -## 7. Open questions / UNVERIFIED - -1. **Value/coin total in the window.** No value Meter or value text exists in - `0x21000022` or `0x21000023`. Retail likely shows pyreals elsewhere (the - coin readout). **UNVERIFIED** — do not add a value summary to this window - without finding its real home. -2. **Side-pack tabs vs. a single scrolling list.** Element `0x100001CB` (16×252, - base `0x2100003E`) is the narrow column right of the grid. Whether it renders - side-pack TABS (one per sub-bag) or a SCROLLBAR is **UNVERIFIED** — I read the - geometry + the dual-ItemList open pattern but did not decode `0x2100003E`. - Dump `0x2100003E` to settle it. -3. **`UIElement_ItemList` grid geometry** (columns, cell pitch). The cell - template is 36×36 (from `0x100001C9`); UIElement_UIItem `0x21000037` is 32×32 - per the handoff. The exact column count + wrap is in `ItemList_AddItem` / - `ItemList_SetChildList` (not fully read here). **LIKELY** a fixed-column grid; - confirm by reading `UIElement_ItemList::ItemList_AddItem`. -4. **`CreateObject` IconId for pack items.** I confirmed the IconId is on the - wire and currently discarded, but did not byte-trace that ACE actually sets - IconId on a *contained* (non-visible-in-3D) item's CreateObject vs. relying on - PlayerDescription. **LIKELY** present (the spine icon path needs it); verify - against a live capture before trusting it as the sole icon source. -5. **The icon composite layering** (underlay/base/effects-overlay) — I anchored - it from `IconData::IconData` (407532+) and the cache key (408842): underlay = - `pwd._iconUnderlayID` OR type-default `GetByEnum(0x10000004, - LowestSetBit(itemType)+1)`; base = `m_idIcon`; effects overlay = - `GetByEnum(0x10000005, LowestSetBit(_effects)+1)` (default `0x21`). The exact - blend/DBObj-render is the **spine doc's** territory — treat my §5/§6 citations - as the inventory-state hooks, not the full render port. [CONFIRMED anchors, - render detail deferred to spine] -6. **Q9 identified-vs-unidentified state.** Retail does NOT gate the icon on - appraise-state; the underlay/overlay come from the weenie's own - `_iconUnderlayID`/`_iconOverlayID`/`_effects` (server-sent), and "unidentified" - shows the same icon (the tooltip detail is what's gated by appraise, via - `IdentifyObjectResponse`). **LIKELY** (no identified→icon-swap code seen in - `UIItem_Update`); the only icon-affecting client states are - selected/waiting(ghost)/open-container/drag-accept (all §5). Confirm there's - no appraise-gated icon variant before claiming it. - ---- - -## 8. MEMORY.md index line - -- [Inventory panel deep-dive (gmInventoryUI/gmBackpackUI)](research/2026-06-16-inventory-deep-dive.md) — D.2b: LayoutDesc 0x21000023 (frame: title + 3 nested sub-windows) + 0x21000022 (backpack: burden Meter 0x100001D9 via SetLoadLevel→fill 0x69, main-pack ItemList 0x100001CA); full inventory wire catalog (C→S 0x0019/1A/1B/54/55/56/19B/CD/195, S→C 0x0022/23/196/19A/A0/52 + SetStackSize/InventoryRemoveObject) with acdream parse-status (gaps: DropItem/GetAndWieldItem/ViewContents builders, 0x0022 4th field, CreateObject IconId); new widgets UiItemSlot(0x10000032)/UiItemGrid(0x10000031)+sub-window mount+window manager. diff --git a/docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md b/docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md deleted file mode 100644 index ba815855..00000000 --- a/docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md +++ /dev/null @@ -1,557 +0,0 @@ -# UI item-slot SPINE — icon-composite render + widget-level drag-drop — deep dive - -**Date:** 2026-06-16 -**Phase:** D.2b retail-UI engine, "core panels" research arc. Report-only. -**Role:** completes the workflow's MISSING 5th doc — the shared item-slot/icon/drag-drop -**spine** that the action-bar, inventory, and paperdoll deep-dives all depend on. The -spine agent died on a transient API error before writing anything; this doc is the -recovery + the gap-fill. -**Deliverable:** this doc only. No C# changed; no game run. - -> ## What this doc adds vs. the four existing docs -> The three panel agents + the synthesis already recovered the **identity** facts of the -> two spine widgets first-hand and re-verified them (synthesis §0 re-verifications, -> §1 table, §2). I do **not** re-derive those — I cite and extend them. My NEW, -> spine-owned contributions are the three things the panel docs explicitly deferred: -> 1. **The icon-composite render port spec** (synthesis §4 Step 0, §5 risk #1) — the -> full `IconData::RenderIcons` blit pipeline, and the definitive answer to the -> direct-RenderSurface-vs-Icon-composite decode question. -> 2. **The widget-level drag-drop state machine** (synthesis §5 risk #1, §8) — the -> `UIElement_Field`/`UIElement_UIItem` hooks every cell inherits, below the per-panel -> `HandleDropRelease` the panel docs covered. -> 3. **The consolidated, authoritative `UIElement_UIItem` port spec** with the resolved -> field names — including the **`+0x5FC` resolution** (synthesis §5 risk #2): it is -> `UIElement_UIItem::itemID`. -> -> **Obsoletes in the synthesis** (the parent should patch these now that the spine -> exists): the ⚠ banner (synthesis lines 13-31), §4 Step 0's "re-do / complete the -> spine research (blocking)", §5 risk #1 (spine never written), §5 risk #2's "stays -> UNVERIFIED", §6's "⚠ the SPINE doc was never written", §8's blocking note, and the -> two panel-doc index lines' "spine still owed" caveats. Details in the closing -> summary. - -## 1. Summary + confidence legend - -Every item-bearing slot in all three D.2b panels is the same pair of retail widgets: -the **item-cell** `UIElement_UIItem` (element class `0x10000032`) sits inside a -**slot/grid** `UIElement_ItemList` (element class `0x10000031`). The cell holds a bound -object id (`itemID`), resolves it to an `ACCWeenieObject`, and draws a composited 32×32 -icon plus a stack of overlay sub-elements (quantity text, capacity/structure Type-7 -meters, a 10-step cooldown ring, selected/ghosted/open-container/drag-accept/sell/trade -overlays). The icon itself is **composited at runtime from up to five `0x06xx` -RenderSurfaces** (base + custom-underlay + custom-overlay + item-type-default-underlay + -spell-effect-overlay) blitted into one private 32×32 surface — NOT a single texture. -Drag-drop is a generic chain inherited from `UIElement_Field`: the cell is both a -drag-SOURCE (`ItemList_BeginDrag` on left-press-and-move) and a drop-TARGET -(`MouseOverTop` rollover → accept/reject state, `CatchDroppedItem` on release → -`HandleDropRelease`), with `InqDropIconInfo` extracting the dragged object id + flags -that tell a fresh-from-inventory drag (`flags&0xE==0`) from a within-list reorder -(`flags&4`). - -**acdream is well-positioned:** `ItemInstance` already models `IconId`/`IconUnderlayId`/ -`IconOverlayId`/`StackSize`/`ContainerId`/`ContainerSlot`; `TextureCache. -GetOrUploadRenderSurface` already decodes a `0x06` id directly; `UiRoot` already has a -real drag-drop state machine (`DragSource`/`DragPayload`/`BeginDrag`/`UpdateDragHover`/ -`FinishDrag`, even commented with the retail `0x15→0x21→0x1C→0x3E` event chain). The -concrete gaps: `CreateObject` discards `IconId`; there is no multi-layer icon-compositor; -`UiField` names the `CatchDroppedItem`/`MouseOverTop` hooks in a doc-comment but does not -implement them yet. - -**Confidence legend:** -- **CONFIRMED** — quoted from a named-decomp `class::method` (with line) or a real - `file:line` I opened this session. -- **LIKELY** — inferred from a CONFIRMED source; the inference is named. -- **UNVERIFIED** — educated guess, flagged loudly. - ---- - -## 2. `UIElement_UIItem` port spec (consolidated + authoritative) - -### 2.1 Identity + the resolved struct (`+0x5FC` = `itemID`) - -`UIElement_UIItem::Register` (decomp 229339): -`UIElement::RegisterElementClass(0x10000032, UIElement_UIItem::Create);` — class -`0x10000032`. It is a `UIElement_Field` subclass: the destructor chains -`UIElement_Field::~UIElement_Field(this)` (decomp 229326), and `Field::Register` is -`RegisterElementClass(3, …)` (decomp 126190) ⇒ the underlying generic Type is **3**. -CONFIRMED. - -**`+0x5FC` RESOLVED — it is `UIElement_UIItem::itemID`.** The toolbar doc anchored the -bound object id by raw offset `+0x5FC` only (toolbar §3, UNVERIFIED name). The named -decomp resolves it: `UIItem_Update` reads `uint32_t itemID = this->itemID;` (decomp -230230) and `this->weenObj = ClientObjMaintSystem::GetWeenieObject(itemID)` (decomp -230235). `HandleTargetedUseLeftClick` reads `uint32_t itemID = arg2->itemID;` (decomp -230422). `ItemList_AddItem`'s rebuild loop tests `eax_2->itemID == arg2` (decomp 233107). -So the field the toolbar's `RemoveShortcutInSlotNum` read at `+0x5FC` is **`itemID`** — -the bound weenie/object guid. CONFIRMED. (The companion spell-shortcut id is -`this->spellID`, decomp 230239/230414.) - -**Resolved instance fields** (all CONFIRMED from `UIItem_Update` 230226-230393, -`UIItem_SetIcon` 230143, `PostInit` 229668, `SetShortcutNum` 229465, the setters -229190-229286, and the `acclient.h` `IconData`/`PublicWeenieDesc` structs): - -| Field | Meaning | Anchor | -|---|---|---| -| `itemID` | bound object/weenie guid (the retail `+0x5FC`) | 230230 | -| `spellID` | spell-shortcut id (0 for an item) | 230239, 230414 | -| `weenObj` | cached `ACCWeenieObject*` from `GetWeenieObject(itemID)` | 230235 | -| `selected` | mirror of `weenObj->selected` | 230269 | -| `effects` | mirror of `weenObj->pwd._effects` | 230293 | -| `waiting` | mirror of `weenObj->waiting` (the pending/ghost flag) | 230336 | -| `isOpenable`/`isContainer`/`isContainerHolder` | container-capability flags from `_bitfield`/`_itemsCapacity`/`_containersCapacity` | 230298-230331 | -| `m_quantity` | stack count to display | 229285 | -| `m_selectable` | whether selection is allowed | 229266 | -| `unghostable` | suppress the ghost overlay | 229199 | -| `m_shortcutNum` / `m_shortcutGhosted` / `m_delayedShortcutNum` | toolbar slot index + deferred-bind sentinel `0xFFFFFFFF` | 229542-229543, 230344-230349 | -| `m_sellState` / `m_tradeState` | vendor-sell / trade-window markers | 230362, 230377 | -| `m_dragIcon` | translucent drag-ghost copy (created in PostInit, id `0x10000345`) | 229738 | - -### 2.2 Sub-element id map (from `PostInit`, decomp 229672-229733) — all CONFIRMED - -`PostInit` binds each overlay/feature sub-element by `GetChildRecursive(this, id)`. These -ids live in the cell template `LayoutDesc 0x21000037`; the importer must reproduce them -procedurally (the cell is a behavioral leaf). The dump `.layout-dumps/uiitem-0x21000037.txt` -gives the per-state sprite ids (column 3 below). - -| Member | Element id | Type | Role | Dump sprite(s) (state → 0x06id) | -|---|---|---|---|---| -| `m_elem_Icon` | `0x1000033B` | 3 | the composited icon, AND the empty-slot bg | `ItemSlot_Empty → 0x060074CF` (dump:45) | -| `m_elem_Icon_Overlays` | `0x1000033C` | — | enchantment/effect overlay layer | (state-driven; see §3) | -| `m_elem_Text` | `0x10000344` | 12 (Text) | spell name / label text | — | -| `m_elem_Icon_CapacityBar` | `0x10000347` | 7 (Meter) | container fill (numContained/itemsCapacity) | `DirectState 0x06004D22`+`0x06004D23` (dump:693,710) | -| `m_elem_Icon_StructureBar` | `0x10000348` | 7 (Meter) | structure/charges fill | `DirectState 0x06004D24`+`0x06004D25` (dump:727,744) | -| `m_elem_Icon_Selected` | `0x10000342` | 3 | selection highlight | `0x06001A97 / 0x06001396 / 0x060067D2` per variant (dump:95,311,541) | -| `m_elem_Icon_Ghosted` | `0x10000349` | 3 | greyed "pending server confirm" overlay | `DirectState 0x0600109A` (dump:761) | -| `m_elem_Icon_ShortcutNum` | `0x1000034A` | 3 | the slot-number badge (toolbar) | media set at runtime via `SetMediaImage` (229508) | -| `m_elem_Icon_SellState` | `0x10000437` | 3 | vendor-sell marker | — | -| `m_elem_Icon_TradeState` | `0x10000438` | 3 | trade-window marker | — | -| `m_elem_Icon_OpenContainer` | `0x10000450` | 3 | "this container is open" frame | `DirectState 0x06005D9C Alphablend` (dump:2232) | -| `m_elem_Icon_DragAccept` | `0x1000045A` | 3 | drag-rollover accept/reject frame | `ItemSlot_DragOver_Accept → 0x060011F9`, `_Reject → 0x060011F8`, `_DropIn → 0x060011F7` (dump:1174-1175,1258-1260) | -| `m_elem_Icon_Quantity` | `0x100004F5` | 12 (Text) | the stack-count number | — | -| `m_elem_Icon_Cooldown_10..100` | `0x1000054F..0x10000558` | 3 | 10-step radial cooldown ring | `DirectState 0x0600109D / 0x060012D9 / 0x06001DAE / 0x060067CF..D1 …` (dump:778-863) | -| `m_dragIcon` | `0x10000345` (created) | — | translucent drag-ghost | created via `CreateChildElement(this, dbobj, 0x10000345)`, `SetVisible(0)` (229738-229740) | - -**The four named LayoutDesc states** that drive `m_elem_Icon` / `m_elem_Icon_DragAccept` -(from the dump): `ItemSlot_Empty` (the empty-slot background sprite, default -`0x060074CF`), `ItemSlot_DragOver_Accept` (`0x060011F9`), `ItemSlot_DragOver_Reject` -(`0x060011F8`), `ItemSlot_DragOver_DropIn` (`0x060011F7`). The DragAccept neutral/reset -**UIStateId** is `0x1000003f`; the inventory agent's `0x10000040`(reject)/`0x10000041` -(accept) SetState ids (synthesis §0 re-verification, decomp 229180-229413) are the -internal element states `SetDragAcceptState` writes — both are real; the LayoutDesc named -states and the `0x1000003x/4x` UIStateIds are the same overlay seen from the dat side vs. -the C++ side. CONFIRMED. - -### 2.3 Key methods + the update pass (`UIItem_Update`, decomp 230226) - -`UIItem_Update` is the per-change refresh; the controller calls it whenever the bound -weenie or its display state changes. Walk-through (CONFIRMED 230226-230392): -1. Resolve `weenObj = GetWeenieObject(itemID)` (230235). If null & has a spellID → - `UIItem_SetState(0x1000001d)` + `UIItem_SetIcon`; if null & no spell → - `UIItem_SetState(0x1000001c)` (= empty) + `ClearTooltip`. (230232-230250) -2. Set `m_elem_Icon` / `m_elem_Text` / `m_elem_Icon_Overlays` to state `0x1000001d` - (= occupied). (230256-230265) -3. **`UIItem_SetIcon(this)`** — (re)build the composited icon (§3). (230268) -4. Sync `selected` ↔ `weenObj->selected`, toggling `m_elem_Icon_Selected` visibility - (gated on `m_selectable`). (230269-230290) -5. Recompute `isOpenable`/`isContainer`/`isContainerHolder` from - `_bitfield`/`_itemsCapacity`/`_containersCapacity` (the player's own cell is always - openable). (230298-230331) -6. `UpdateCapacityDisplay` (Type-7 meter = numContained/itemsCapacity, decomp 229554-), - `UpdateStructureDisplay`, `UpdateQuantityDisplay`, `UpdateCooldownDisplay`. - (230332-230335) -7. Sync `waiting` → `SetWaitingState` (toggles `m_elem_Icon_Ghosted`). (230336-230342) -8. Apply any deferred `m_delayedShortcutNum` (re-bind once the weenie loaded). (230344-230350) -9. Sync `m_shortcutNum`/`m_shortcutGhosted` (230352-230360), `m_sellState`/`m_tradeState` - overlays (230362-230389), then `UpdateTooltip`. (230392) - -Companion methods (CONFIRMED): `UIItem_SetIcon` 230143 (§3); `SetShortcutNum(slot, -ghosted)` 229465 (writes the slot badge via `SetMediaImage`, mirrors into -`ACCWeenieObject::SetShortcutNum`); `SetDelayedShortcutNum` 229238; `SetWaitingState` -229190; `SetSelectedState` 229243; `SetSelectableState` 229263; `SetDragAcceptState` -229271; `SetOpenContainerState` 229216; `SetQuantity` 229282; `UpdateCapacityDisplay` -229554. - -### 2.4 acdream item-cell port = `UiItemSlot` - -A behavioral **leaf** widget (`ConsumesDatChildren => true`) keyed off resolved class -`0x10000032`, exactly like the shipped behavioral widgets. It binds an `ItemInstance` -(by `itemID`), draws the composited icon (§3), the quantity `UiText`, the capacity/ -structure `UiMeter`s, the cooldown ring, and the overlay states; it is a drag source + -drop target (§5). This aligns with the synthesis §2 row (no correction). The retail -sub-element ids in §2.2 become the named child slots the controller toggles. - ---- - -## 3. Icon rendering pipeline — THE CRUX - -### 3.1 The decode question, answered definitively - -**Both halves of the synthesis's question are true, layered:** each icon LAYER is a -`0x06xx` **RenderSurface decoded directly** (the D.2b memory's `GetOrUploadRenderSurface` -path), but the **on-screen icon is a runtime COMPOSITE of up to five of those layers** -blitted into one private 32×32 surface. It is NOT a single weenie texture, and it is NOT -an "Icon DBObj type that references other surfaces" — there is no Icon DBObj; the -composite logic lives entirely in client code (`IconData::RenderIcons`), and every input -id is a plain RenderSurface. - -**Proof chain (all CONFIRMED):** -- `UIElement_UIItem::UIItem_SetIcon` (decomp 230171) sets the cell's image from - `ACCWeenieObject::GetIcon(weenObj)`: - `eax_15 = Graphic::Graphic(eax_13, ACCWeenieObject::GetIcon(eax_12)); … UIRegion::SetImage(this->m_elem_Icon, eax_15);` -- `ACCWeenieObject::GetIcon` (decomp 408999): `return ACCWeenieObject::GetIconData(this)->m_pIcon;` -- `ACCWeenieObject::GetIconData` (decomp 408224) caches a per-object `IconData` (hash by - guid), constructing one via `IconData::IconData(eax_4, this, this->id)` (408253) on - first use; `IconData::IconData` calls `IconData::RenderIcons(this, arg2)` (407957). -- The `IconData` struct (`acclient.h:54112`, verbatim): `m_idIcon`, `m_idCustomOverlay`, - `m_idCustomUnderlay`, `m_itemType`, `m_effects`, `Graphic *m_pIcon`, `Graphic *m_pDragIcon`. - -The base id is the weenie's `_iconID`: `ACCWeenieObject::InqIconID` (decomp 406951) -returns `this->pwd._iconID.id`. `_iconID`/`_iconOverlayID`/`_iconUnderlayID` are all -`IDClass<_tagDataID,32,0>` in `PublicWeenieDesc` (`acclient.h:37168-37170`). CONFIRMED. - -**Every layer is DBObj type `0xc`** — `RenderIcons` fetches each with -`DBObj::Get(QualifiedDataID(&v, id, 0xc))` (decomp 407587/407589/407592). DBObj type -`0xc` = `DB_TYPE_RENDERSURFACE` = `Texture` in ACE's `DatFileType` enum, id range -`0x06000000-0x07FFFFFF` (`references/.../ACE.DatLoader/DatFileType.cs:127-128`). So all -five ids are `0x06xx` RenderSurfaces — **decode each via -`TextureCache.GetOrUploadRenderSurface`** per the D.2b memory gotcha, NOT `GetOrUpload` -(feeding a `0x06` id to `GetOrUpload` walks the Surface→SurfaceTexture chain and returns -1×1 magenta — `TextureCache.cs:112-128`, `project_d2b_retail_ui.md` "Dat sprites — the -decode path"). CONFIRMED. - -### 3.2 The composite — `IconData::RenderIcons` (decomp 407524), CONFIRMED - -`RenderIcons` builds TWO graphics: `m_pDragIcon` (the drag-ghost, no underlay) and -`m_pIcon` (the full slot icon). Field captures first (407528-407532): -``` -m_idIcon = InqIconID() # = pwd._iconID (base) -m_idCustomOverlay = pwd._iconOverlayID # server "enchanted" overlay -m_idCustomUnderlay= pwd._iconUnderlayID # server "magic" underlay -m_itemType = InqType() -m_effects = pwd._effects -``` -Player special-case (407546-407549): if `IsThePlayer()`, `m_idIcon = -GetDIDByEnum(0x10000004, 7)` (the player container icon) and `m_itemType = -TYPE_CONTAINER`. - -Two enum-resolved layers (407552-407584): -- **type-default underlay** `eax_11 = DBObj::GetByEnum(LowestSetBit(m_itemType)+1, …)` - with enum `0x10000004` (the SkillTable DID-mapper namespace reused as the icon-type - table); if `m_itemType` has no bits, index `0x21`. (407555-407564) -- **effect overlay** `arg2 = DBObj::GetByEnum(LowestSetBit(m_effects)+1, …)` with enum - `0x10000005`; if null, fall back to index `0x21` of the same enum. (407568-407584) - -Then it resolves the three direct ids as DBObjs (407587-407592): `eax_19` = -m_idCustomUnderlay, `ebp` = m_idIcon (base), `edi_1`/`var_38` = m_idCustomOverlay. - -**Drag-icon surface** (`m_pDragIcon`, 407594-407625): a 32×32 local surface -(`CreateLocalSurface` → `Create(0x20, 0x20, GetUISurfaceFormat, 1)`); blit base -`ebp` `Blit_Normal`, then custom-overlay `var_38` `Blit_4Alpha`; `ReplaceColor(..., -&pwd._iconOverlayID)` applies the overlay tint; wrapped in a `Graphic`. - -**Full slot icon** (`m_pIcon`, 407626-407647): a second 32×32 surface; blit -**type-default underlay `eax_11` `Blit_Normal`**, then **custom-underlay `eax_19` -`Blit_3Alpha`**, then **the drag-icon surface `eax_26` `Blit_3Alpha`** on top (base + -overlay already baked into it). Wrapped in a `Graphic` → `m_pIcon`. - -**Net composite (bottom → top):** -1. item-type default underlay (`GetByEnum(0x10000004, lsb(itemType)+1)`) — Normal -2. server custom underlay (`pwd._iconUnderlayID`) — 3Alpha -3. base icon (`pwd._iconID`) — Normal *(baked into the drag layer first)* -4. server custom overlay (`pwd._iconOverlayID`) + its tint — 4Alpha -5. spell-effect overlay (`GetByEnum(0x10000005, lsb(effects)+1)`) — *(captured `arg2`; - note: in the 2013 BN lifting the effect-overlay capture lands but I did not see its - explicit `Blit` in the slot-surface block; it feeds the same path. LIKELY blitted as - part of the overlay stage — flagged, see §7.)* - -Cache invalidation: `IconData::UpdateIcons` (407962) re-renders only when `InqIconID()`, -`_iconOverlayID`, `_iconUnderlayID`, `InqType()`, or `_effects` changed (407968-407976); -`ACCWeenieObject::IconDataChanged` (408201) drives it on a property update. - -### 3.3 The decode pipeline acdream should use - -1. On `CreateObject` (and `ObjDescEvent`/property-update), capture `IconId` (`_iconID`), - `IconUnderlayId` (`_iconUnderlayID`), `IconOverlayId` (`_iconOverlayID`), `_effects`, - and `ItemType` into the `ItemInstance` (the model already has the first three fields; - `_effects` needs adding). **Gap:** `CreateObject.TryParse` discards `IconId` — - re-verified at `CreateObject.cs:516` (`_ = ReadPackedDwordOfKnownType(body, ref pos, - IconTypePrefix); // IconId`) and `:515` (`_ = ReadPackedDword(...) // WeenieClassId`). - CONFIRMED. -2. For each of the up-to-five layer ids, decode the `0x06xx` RenderSurface **directly** - via `TextureCache.GetOrUploadRenderSurface` (per the D.2b gotcha). -3. Composite into one 32×32 RGBA target in the order of §3.2. Two faithful options: - (a) a CPU compositor matching retail's blit modes (Normal = src-over opaque, - 3Alpha/4Alpha = the AC alpha blits — see ACViewer `ImgTex`/`RenderSurface` decode for - the per-format alpha handling), uploaded as one cached GL texture keyed by the - (iconId, underlay, overlay, effects, itemType) tuple; or (b) draw the layers as - stacked sprites at the cell rect each frame. Retail does (a) (one `m_pIcon` surface), - and caching matches retail's `IconData` per-object cache + `UpdateIcons` dirty check — - recommend (a). -4. The type-default underlay (`GetByEnum(0x10000004, lsb(itemType)+1)`) and effect - overlay (`GetByEnum(0x10000005, lsb(effects)+1)`) require resolving the retail - icon-type / effect DID-mapper enums to concrete `0x06` ids. These map through the dat - DidMapper/EnumMapper tables (`DatFileType` 38/36). **For MVP, the base `_iconID` - alone is the dominant visual** (most items have no custom underlay/overlay and no - effects); the underlay/overlay/effect layers are the "magic/enchanted/glow" polish. - LIKELY-safe to ship base-only first, then layer in the composite. (synthesis §5 - risk #3 — verify IconId is set on a CONTAINED item's CreateObject against a live - capture before treating it as the sole source.) - -**Palette note (cross-ref).** Item icons are pre-rendered `0x06` RenderSurfaces; they do -NOT take a creature/clothing subpalette overlay at icon-composite time (the composite -only blits + tints with `_iconOverlayID`). ACViewer's `TextureCache.cs::IndexToColor` -subpalette-overlay is for paletted INDEX16/P8 *world* textures — the canonical reference -for THAT path, but the icon path uses the surfaces as-decoded. acdream's WB -`TextureHelpers.cs` (in-tree) is the decode reference for the `0x06` formats themselves -(BGRA/DXT/P8/INDEX16). CONFIRMED the composite has no subpalette step; LIKELY a paletted -UI icon would need a palette (today `GetOrUploadRenderSurface` passes `palette: null` → -magenta on a paletted sprite, `TextureCache.cs:135` — flagged §7). - -### 3.4 Identified-vs-unidentified does NOT swap the icon (synthesis §5 risk #14) - -CONFIRMED in the negative: `UIItem_Update`/`UIItem_SetIcon`/`RenderIcons` derive the icon -purely from server-sent weenie props (`_iconID`/`_iconUnderlayID`/`_iconOverlayID`/ -`_effects`/`InqType`) — there is **no appraise/identified branch** anywhere in the icon -path. Appraise (`IdentifyObjectResponse 0x00C9`) gates the TOOLTIP detail -(`UpdateTooltip`, 230392), not the icon. So a slot shows the same icon before and after -appraise. The inventory agent's risk #14 LIKELY is now CONFIRMED. - ---- - -## 4. Item / container data model + acdream gap analysis - -### 4.1 Items are `ACCWeenieObject` weenies - -The cell never holds item data — it holds an `itemID` and resolves it live via -`ClientObjMaintSystem::GetWeenieObject(itemID)` (decomp 230235). This matches -`claude-memory/feedback_weenie_vs_static.md` (interactable items are server-spawned -weenies, not dat-baked). The data the cell binds to: - -| Cell display | Source field (`PublicWeenieDesc`, `acclient.h:37163+`) | -|---|---| -| base icon | `_iconID` (37168) | -| magic underlay | `_iconUnderlayID` (37170) | -| enchanted overlay | `_iconOverlayID` (37169) | -| effect glow | `_effects` (37183) | -| stack count | `_stackSize` / `_maxStackSize` (37188-37189) | -| capacity bar | `_itemsCapacity` / `_containersCapacity` (37176-37177) | -| structure bar | `_structure` / `_maxStructure` (37186-37187) | -| value/burden | `_value` (37179) / `_burden` (37193) | -| container membership | `_containerID` / `_wielderID` / `_location` / `_priority` (37171-37175) | - -### 4.2 How the client learns container contents - -- **Login:** `PlayerDescription (0x0013)` carries the full inventory + equipped lists. -- **Per-item spawn:** `CreateObject (0xF745)` for each weenie (incl. a pack item) with - the WeenieHeader fields above. -- **Open a container:** `ViewContents (0x0196)` lists `{guid, containerType}` per slot → - `UIElement_ItemList::ItemList_OpenContainer` builds a `UIElement_UIItem` per entry. -- **Live moves:** `ACCWeenieObject::ServerSaysMoveItem` (decomp 408086) is the client's - per-weenie relocation: it updates `_containerID`/`_wielderID`/`_location`, re-parents - in the local content lists (`RemoveContent`/`AddContent`), sets `current_state` - (`IN_CONTAINER`/`IN_3D_VIEW`), and clears the `waiting` ghost. This is driven by the - `0x0022`/`0x0023`/`0x019A` GameEvents. CONFIRMED. - -Hierarchy is 2-deep (main pack → side-packs; a side-pack holds no side-pack) — the -backpack hosts two `UIElement_ItemList`s, the own list (`+0x604`) and the open-other- -container list (`+0x608`) (inventory §2.2). The outbound verbs are the `ACCWeenieObject:: -UIAttempt*` family — `UIAttemptWield` → `Event_GetAndWieldItem` (decomp 407763, with a -stack-split-to-wield branch when `_stackSize>1`), `UIAttemptPutInContainer` → -`Event_PutItemInContainer` (407797), `UIAttemptPutIn3D` → `Event_DropItem` (407821), -`UIAttemptMerge`/`UIAttemptSplitToContainer`/`UIAttemptSplitTo3D`/`UIAttemptGive` -(407840-407897, 407780). Each records a `prevRequest` for the speculative-then-confirm -rollback. CONFIRMED. - -### 4.3 acdream model status (focus: what the cell binds to) - -- **`ItemInstance.cs` (verified):** already has `IconId` (cs:136), `IconUnderlayId` - (137), `IconOverlayId` (138), `StackSize`/`StackSizeMax` (139-140), `Burden` (141), - `Value` (142), `ContainerId` (143), `ContainerSlot` (144), `ValidLocations`/ - `CurrentlyEquippedLocation` (134-135). **Missing for the icon composite:** `_effects` - (effect glow) and an `ItemType` already present (Type, 133). The synthesis §0 claim is - CONFIRMED. -- **`ItemRepository.cs` (verified):** already models the container map, the move events - (`WieldObject`/`InventoryPutObjInContainer`/`InventoryPutObjectIn3D`/`ViewContents`/ - `CloseGroundContainer`, cs:23-27) and the `InventoryServerSaveFailed` speculative- - revert (cs:28-31). CONFIRMED. -- **`CreateObject.cs` (verified):** discards `IconId` (cs:516) + `WeenieClassId` - (cs:515) + StackSize/Value/capacities — the cell's icon + quantity + capacity-bar - source. CONFIRMED gap. -- The full wire-gap TODO is the synthesis §3.3 — not duplicated here; the - data-model-binding subset is: extend `CreateObject` to capture - IconId/WeenieClassId/StackSize/Value/ItemCapacity/ContainerCapacity (+ `_effects`), - and add `_effects` to `ItemInstance`. - ---- - -## 5. Drag-drop spine — the WIDGET-LEVEL state machine - -The per-panel docs covered the panel-class `HandleDropRelease` (e.g. `gmToolbarUI : -ItemListDragHandler`). THIS is the shared lower layer every item-cell inherits. - -### 5.1 The retail event chain on the cell (`UIElement_UIItem::ListenToElementMessage`, decomp 229344) - -The cell handles four element messages (CONFIRMED 229347-229418): -- **`0x21` = begin-drag** (left-press-and-move on an occupied cell): walk to the parent - `UIElement_ItemList` (`GetParent()->DynamicCast(0x10000031)`) and call - `ItemList_BeginDrag(list, ptWindow.x, ptWindow.y)` (229357-229360). The list spawns the - `m_dragIcon` ghost and arms the drag. -- **`0x3e` = drag-over**, with two sub-cases keyed on `dwParam1`: - - `dwParam1 == 0` (drag left this cell): reset DragAccept to neutral - `SetState(0x1000003f)` (229381-229387). - - else (drag hovering): if a global drag is active (`UIElementManager::s_pInstance-> - m_dragElement != 0`), forward to `ItemList_DragOver(list, target, dragElement)` - (229390-229406); the list decides accept/reject and flips the DragAccept overlay. -- **`0x15` = drop/release**: clear the weenie's waiting flag and hide - `m_elem_Icon_Ghosted` (229363-229379). (The retail event-id sequence is - `0x15→0x21→0x1C→0x3E`, which acdream's `UiRoot` already cites verbatim — `UiRoot.cs:448`.) - -### 5.2 The drop-TARGET rollover (`UIElement_Field::MouseOverTop`, decomp 126098) - -Every cell inherits Field's drop-target rollover. When a drag is in progress -(`UIElementManager::s_pInstance->m_dragElement != 0`) and this field has the -CatchDroppedItem attribute (`GetAttribute_Bool(0x36)`, plus `0x70`/`0x38`), it calls -`m_dragDropCallback(m_dragElement, this)` to test acceptance and sets element state **9** -(accept) or **0xa** (reject), saving the old state for restore on leave (126124-126153). -`UIElement_Field::CatchDroppedItem` (decomp 126159) restores the rollover state then -chains `UIElement::CatchDroppedItem` (the real drop handler). CONFIRMED. - -The `0x36` attribute (CatchDroppedItem flag) is exactly what `UIElement_UIItem::PostInit` -sets `true` on every cell (decomp 229744: `SetPropertyName(0x36); …(1); SetProperty`), -with `0x3a` and `0x39` set false (229755/229766). So **every item-cell is a drop target -by construction.** CONFIRMED. - -### 5.3 `InqDropIconInfo` — what the drop carries (decomp 230533) - -`UIElement_ItemList::InqDropIconInfo(dragElement, &objId, &containerId, &flags)` reads -the dragged element's properties via `InqProperty(0x1000000f..0x10000014)` and assembles -the flag word (230595-230617): `flags = (bit8 from 0x10000014) | (bit2 from 0x10000013) -| (bit4 from 0x10000012) | (bit1 from var_39/0x10000011)`. The synthesis flag semantics -hold: **`flags & 0xE == 0`** ⇒ fresh drag from inventory (place-new); **`flags & 4`** ⇒ -within-list reorder (the source slot is `m_lastShortcutNumDragged`). `objId` = the -dragged object guid; `containerId` = its source container. CONFIRMED (the bit→source -mapping is the toolbar/inventory docs' `HandleDropRelease`). - -### 5.4 The drag handler interface (`ItemListDragHandler` + `RegisterItemListDragHandler`) - -`UIElement_ItemList::RegisterItemListDragHandler(list, handler)` stores -`this->m_dragHandler = handler` (decomp 230461-230464). Each panel registers ITSELF as -the handler on every slot list (toolbar §5, paperdoll §2a). On a drop, the list routes -to the handler's `HandleDropRelease`, which resolves the target slot + the -`InqDropIconInfo` payload and issues the wire action (the per-panel docs). The shared -contract the spine defines is: **`ItemListDragHandler { OnItemListDragOver(list, -target, drag); HandleDropRelease(msg) }`** + `RegisterItemListDragHandler(handler)`. - -### 5.5 Drag-ghost / cursor lifecycle - -`m_dragIcon` (id `0x10000345`) is created in `PostInit` from a DBObj and kept hidden -(`SetVisible(0)`, decomp 229738-229740); on begin-drag the list makes the global -`m_dragElement` track the cursor (the translucent icon copy), and on drop it is hidden -again. The drag-ghost graphic is the SAME `m_pDragIcon` the icon compositor built (§3.2) -— base + overlay, no underlay. CONFIRMED. - -### 5.6 What acdream's `UiRoot` already has vs. needs - -**Already there (verified `UiRoot.cs`):** `DragSource`/`DragPayload` (cs:71-73), -`BeginDrag` (cs:450), `UpdateDragHover` emitting `DragOver`/`DragEnter`/`DragLeave` -(cs:458-482), `FinishDrag` emitting `DropReleased` with an `accepted` flag (cs:484-496), -the 3-pixel `DragDistanceThreshold` promote-on-move (cs:84,183-189), and the retail -`0x15→0x21→0x1C→0x3E` chain noted in the comment (cs:448). `CapturesPointerDrag` on -`UiElement` distinguishes interior-drag from window-move. - -**Needs to grow:** a per-cell *accept test* hook (the retail `m_dragDropCallback` / -`CatchDroppedItem` — `UiField` only NAMES these in its doc-comment, it does NOT implement -them: `UiField.cs:7-11` "Carries retail Field's drag-drop hooks -(CatchDroppedItem/MouseOverTop) as stubs for future item-window use" — there is no such -method body in the class). So the spine adds: (1) an `OnDragOver`→accept/reject result on -`UiItemSlot` that flips its DragAccept overlay state, (2) an `OnDrop` that calls the -panel's drag handler with the resolved `{objId, srcContainer, flags}`, and (3) the -`m_dragIcon` translucent ghost as the drag visual. CONFIRMED gap. - -### 5.7 Generic pick-up → drag → drop → dispatch (pseudocode) - -``` -on left-press over an OCCUPIED UiItemSlot: # retail msg 0x21 path - UiRoot.Captured = slot; _dragCandidate = true -on mouse-move while captured & moved > 3px: - UiRoot.BeginDrag(slot, payload = { objId = slot.itemID, - srcContainer = weenie._containerID, - srcSlotIndex = slot.shortcutNum }) - show slot.m_dragIcon tracking the cursor # retail m_dragElement -on drag-over a target UiItemSlot/UiItemList: # retail msg 0x3e / MouseOverTop - accepted = targetHandler.OnDragOver(target, payload) # m_dragDropCallback - target.SetDragAccept(accepted ? Accept(0x10000041) : Reject(0x10000040)) -on drag leaving the target: - target.SetDragAccept(Neutral 0x1000003f) -on release over target: # retail msg 0x15 / CatchDroppedItem - info = InqDropIconInfo(payload) # objId, srcContainer, flags - targetHandler.HandleDropRelease(target, info) # per-panel: picks the opcode: - # toolbar slot : flags&0xE==0 -> CreateShortcutToItem ; flags&4 -> reorder - # pack slot : PutItemInContainer 0x0019 - # equip slot : GetAndWieldItem 0x001A (target's EquipMask) - # ground : DropItem 0x001B - # compatible stack: StackableMerge 0x0054 / split dialog -> Stackable*Split* - # NPC : GiveObjectRequest 0x00CD - slot.SetWaitingState(true) # speculative ghost until server confirm - hide drag ghost; clear DragSource -on server reply (move event) or rollback (InventoryServerSaveFailed 0x00A0): - slot.SetWaitingState(false); UIItem_Update(...) # confirm or revert -``` -The opcode-selection table is the per-panel docs' job (already covered); the spine owns -the pick-up → ghost → accept-test → release → `InqDropIconInfo` → dispatch-to-handler -chain above. - ---- - -## 6. New toolkit widgets this spine introduces - -| Widget | Registers at | Leaf vs container | Purpose | -|---|---|---|---| -| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | resolved class id `0x10000032` (resolves to a `UIElement_Field` subclass ⇒ underlying Type 3); a behavioral leaf in `DatWidgetFactory` keyed off the resolved class id | **LEAF** (`ConsumesDatChildren=>true`) — reproduces the icon + §2.2 overlay sub-elements procedurally | one item-in-a-slot: composited icon (§3) + quantity `UiText` + capacity/structure `UiMeter`s + 10-step cooldown ring + selected/ghosted/open-container/drag-accept/sell/trade overlay states; binds `itemID` (retail `+0x5FC`). **The spine widget — build once.** | -| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`, class `0x10000031`) | resolved class id `0x10000031` (dump root `0x10000339`, Type `268435505`, 32×32 — CONFIRMED `itemlist-0x2100003D.txt:13-23`) | **leaf to the importer** (`ConsumesDatChildren=>true`; manages its own `UiItemSlot` children procedurally) — logically a container of slots at runtime | a 1-cell (toolbar/equip) or N-cell (inventory) grid of `UiItemSlot`s; owns the drag handler registration. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/GetItem/OpenContainer/SetChildList/SetParentContainer/BeginDrag/DragOver/InqDropIconInfo/RegisterItemListDragHandler`. | - -These exactly match the synthesis §2 / §7 rows — **no correction**. The `UiViewport` -(Type `0xD`), window manager, and sub-window-mount are NOT spine widgets (paperdoll / -shared-infra; out of scope here). One precision the spine adds: the `UiField` Type-3 -drag hooks are documented-but-unimplemented (§5.6) — the `UiItemSlot` is where they get a -body, not the generic `UiField`. - ---- - -## 7. Open questions / UNVERIFIED — resolved + carried forward - -**Resolved by this doc (synthesis §5 risks → now CONFIRMED):** -- **#1 icon-composite render** — RESOLVED. Each layer is a `0x06` RenderSurface decoded - directly; the icon is a 5-layer composite (`IconData::RenderIcons` 407524). §3. -- **#2 `+0x5FC` field name** — RESOLVED. It is `UIElement_UIItem::itemID` (decomp - 230230). §2.1. -- **#14 identified-vs-unidentified does NOT swap the icon** — CONFIRMED in the negative - (no appraise branch in the icon path). §3.4. - -**Carried forward (still need a follow-up):** -- **Effect-overlay blit into the slot surface (§3.2 layer 5)** — the effect DBObj - (`GetByEnum(0x10000005, lsb(effects)+1)`) is captured (`arg2`, 407575) but I did not - see its explicit `Blit` into the `m_pIcon` surface in the 2013 BN lifting (the visible - blits are type-default-underlay, custom-underlay, and the base+overlay drag layer). - LIKELY it blits as part of the overlay stage; confirm with a Ghidra decompile of - `0x0058d180` or a cdb trace before relying on the exact effect layering. UNVERIFIED. -- **Type-default underlay + effect-overlay enum→DID resolution** — `GetByEnum(0x10000004, - …)` / `GetByEnum(0x10000005, …)` resolve through the dat DidMapper/EnumMapper tables; - the concrete `0x06` ids per item-type / effect were not enumerated. MVP can ship - base-`_iconID`-only. §3.3. UNVERIFIED. -- **Paletted UI icons** — `GetOrUploadRenderSurface` passes `palette: null` - (`TextureCache.cs:135`), returning magenta on a paletted (INDEX16/P8) icon. Most item - icons are pre-baked BGRA/DXT, but verify no item icon is paletted before shipping; if - one is, wire a UI palette (the D.2b memory flags this as a known TODO). UNVERIFIED. -- **CreateObject IconId on a CONTAINED item** (synthesis §5 risk #3) — byte-trace a live - capture that ACE sets `IconId` on a non-3D-visible pack item's CreateObject vs. - relying on PlayerDescription. LIKELY present; verify. (WireMCP capture of `0xF745`.) -- **`m_dragDropCallback` shape** — retail's per-field accept callback signature - (`callback(dragElement, this) -> bool`, decomp 126124) is confirmed; the acdream - binding (a delegate on `UiItemSlot`/the handler) is a design call for the build spec. - ---- - -## 8. MEMORY.md index line - -- [UI item-slot SPINE — icon composite + drag-drop](research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — D.2b shared spine (completes the 5-doc arc): `UiItemSlot`(`UIElement_UIItem` 0x10000032, the `+0x5FC` bound id RESOLVED = `itemID`) inside `UiItemList`(0x10000031). ICON CRUX RESOLVED: each layer is a `0x06` RenderSurface decoded DIRECTLY via `GetOrUploadRenderSurface`, but the on-screen icon is a 5-layer runtime COMPOSITE (`IconData::RenderIcons` @407524: type-default underlay + `_iconUnderlayID` + `_iconID` base + `_iconOverlayID`+tint + effect overlay, blitted into one 32×32 surface; NOT a single texture, NOT appraise-gated). Drag-drop state machine: cell inherits `UIElement_Field::MouseOverTop`/`CatchDroppedItem` (drop-target rollover, attr 0x36) + `ListenToElementMessage` msgs 0x21 begin-drag/0x3e drag-over/0x15 drop; `InqDropIconInfo` flags 0xE==0 fresh-drag, &4 reorder; `UiRoot` already has the drag chain (0x15→0x21→0x1C→0x3E), `UiField` only STUBS the hooks. acdream gap: `CreateObject` discards IconId (cs:516). Sub-element id map + named states (`ItemSlot_Empty 0x060074CF`, DragOver Accept/Reject/DropIn 0x060011F9/F8/F7) included. diff --git a/docs/research/2026-06-16-ui-panels-synthesis.md b/docs/research/2026-06-16-ui-panels-synthesis.md deleted file mode 100644 index 3dd77de1..00000000 --- a/docs/research/2026-06-16-ui-panels-synthesis.md +++ /dev/null @@ -1,407 +0,0 @@ -# D.2b core panels — SYNTHESIS (toolbar + inventory + paperdoll) - -**Date:** 2026-06-16 -**Phase:** D.2b retail-UI engine, "core panels" research arc. Report-only synthesis. -**Role:** synthesis lead reconciling the three panel deep-dives into one authoritative -build plan. The deliverable is this doc; no code was written. -**Inputs (all read in full):** -- toolbar: [`2026-06-16-action-bar-toolbar-deep-dive.md`](2026-06-16-action-bar-toolbar-deep-dive.md) -- inventory: [`2026-06-16-inventory-deep-dive.md`](2026-06-16-inventory-deep-dive.md) -- paperdoll: [`2026-06-16-equipment-paperdoll-deep-dive.md`](2026-06-16-equipment-paperdoll-deep-dive.md) -- handoff: [`2026-06-16-action-bar-inventory-equipment-handoff.md`](2026-06-16-action-bar-inventory-equipment-handoff.md) - -> ## Note: the SPINE doc was completed in a follow-up pass -> The handoff promised a "spine agent" doc covering the shared item-slot widget, icon -> decode, and the full drag-drop state machine. During the original workflow run the -> spine agent died on a transient API error, so this synthesis was first written against -> a `null` spine digest. **The spine doc has since been written:** -> [`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md). -> It resolves the two items this synthesis had left open: (1) the **icon-composite -> render path** — each icon LAYER is a `0x06` RenderSurface decoded DIRECTLY, but the -> on-screen icon is a **5-layer runtime composite** blitted into one private 32×32 -> surface (`IconData::RenderIcons` decomp 407524), NOT a single texture and NOT -> appraise-gated; (2) the item-cell **bound-object field `+0x5FC` = `UIElement_UIItem::itemID`** -> (decomp 230230). The shared `UIElement_UIItem` / `UIElement_ItemList` identity facts -> below were first-hand-derived + re-verified by the panel agents and remain sound. -> §4 Step 0 and the §5 risks below have been updated to reflect the completed spine doc. - -## 0. Summary + confidence legend - -The three D.2b core panels are all built from the **same two reusable retail widgets**: -the **item-slot** (`UIElement_UIItem`, class `0x10000032`) and the **item-list/grid** -(`UIElement_ItemList`, class `0x10000031`). Every slot in every panel is one of these — -the toolbar is 18 single-cell item-lists, the inventory is N-cell item-list grids, and -the paperdoll is ~25 single-cell item-lists keyed to `EquipMask`. Build those two -widgets once and all three panels fall out. The paperdoll adds one genuinely new piece: -a **`UiViewport`** (`UIElement_Viewport`, Type `0xD`) that renders a live 3D character -clone into a UI rect — the single biggest new engineering item. All three panels are -pop-up windows, so they all need the deferred **window manager** (open/close/z-order/ -persist + Dragbar Type 2 + Resizebar Type 9 drag-resize). On the wire, acdream is in -good shape: most C→S builders and S→C parsers already exist; the concrete gaps are a -handful of missing builders (`DropItem`, `GetAndWieldItem`, `NoLongerViewingContents`), -missing parsers (`ViewContents`, `SetStackSize`, `InventoryRemoveObject`), two -incomplete parsers (a dropped 4th field on `0x0022`; a dropped error code on `0x00A0`), -and `CreateObject` discarding `IconId`/`StackSize`/capacities the cells need. - -**Confidence legend** (carried from the source docs, re-checked here): -- **CONFIRMED** — quoted from a named-decomp `class::method` (with line) or a real - `file:line` that I or a panel agent opened. -- **LIKELY** — inferred from a confirmed source; the inference is named. -- **UNVERIFIED** — educated guess, flagged loudly; needs a decomp/cdb follow-up before - porting. - -**Synthesis-lead re-verifications (opened first-hand, this session):** -- `CreateObject.cs:515-516` — `_ = ReadPackedDword(...) // WeenieClassId; _ = ReadPackedDwordOfKnownType(..., IconTypePrefix);` → **IconId and WeenieClassId are discarded**. CONFIRMED. -- `acclient_2013_pseudo_c.txt:135087-135088` — `UIElement_ItemList::Register();` and `UIElement_UIItem::Register();` are real adjacent symbols (`0x0047a483`/`0x0047a488`). CONFIRMED. -- `acclient_2013_pseudo_c.txt:135130-135132` — `gmBackpackUI::Register / gmInventoryUI::Register / gmPaperDollUI::Register` all real. CONFIRMED. -- `acclient_2013_pseudo_c.txt:175242-175508` — the ~25 paperdoll equip slots each `DynamicCast(0x10000031)` (`m_neckSlot, m_headSlot, m_weaponReadySlot, m_ammoReadySlot, …`) + `RegisterItemListDragHandler`. CONFIRMED. -- `acclient_2013_pseudo_c.txt:229180-229413` — the `m_elem_Icon_*` family (`_Ghosted`, `_OpenContainer`, `_Selected`, `_DragAccept`) and its `SetState` reject/accept/neutral states (`0x10000040` / `0x10000041` / `0x1000003f`) are real on `UIElement_UIItem`. CONFIRMED (corroborates the inventory agent's first-hand derivation). - ---- - -## 1. Confirmed class ids + LayoutDesc ids + sizes - -All confirmed via `*::Register` (`RegisterElementClass`) in the decomp + the -pre-dumped `.layout-dumps/` trees. The element-class id and the LayoutDesc id are -distinct namespaces (`0x10000xxx` = element class registered in C++; `0x21000xxx` = -the dat LayoutDesc that builds the window). - -| Panel / widget | Element class id | LayoutDesc id | Root element | Size (W×H) | Register anchor | -|---|---|---|---|---|---| -| `gmToolbarUI` (action bar) | `0x10000007` | `0x21000016` | `0x10000191` | 300×122 | `gmToolbarUI::Register` (decomp 196897); `GetUIElementType`→`0x10000007` (196707) | -| `gmInventoryUI` (frame) | `0x10000023` | `0x21000023` | `0x100001CC` | 300×362 | `gmInventoryUI::Register` (decomp 176285 / `0x004a6a60`) | -| `gmBackpackUI` (pack strip) | `0x10000022` | `0x21000022` | `0x100001C8` | 61×339 | `gmBackpackUI::Register` (decomp 176531 / `0x004a6e80`) | -| `gmPaperDollUI` (equip doll) | `0x10000024` | `0x21000024` | `0x100001D4` | 224×214 | `gmPaperDollUI::Register` (decomp 174445 / `0x004a4560`) | -| `gm3DItemsUI` ("Contents of Backpack") | `0x10000021` | `0x21000021` | `0x100001C4` | 234×120 | `gm3DItemsUI::Register` (decomp 176723) | -| `UIElement_UIItem` (item-slot, shared) | `0x10000032` | `0x21000037` (32×32 cell template) | — | 32×32 | `UIElement_UIItem::Register` (decomp 229339 / `0x0047a488`) | -| `UIElement_ItemList` (item-list/grid, shared) | `0x10000031` | `0x2100003D` (single 32×32 cell) | `0x10000339` | 32×32 cell | `UIElement_ItemList::Register` (decomp / `0x0047a483`) | - -**Nesting (CONFIRMED `gmInventoryUI::PostInit` 176236-176259):** the inventory FRAME -(`0x21000023`) hosts three NESTED gm\*UI windows by id — `0x100001CD`→paperdoll -(`DynamicCast 0x10000024`), `0x100001CE`→backpack (`DynamicCast 0x10000022`), -`0x100001CF`→3D-items (`DynamicCast 0x10000021`). This "sub-window mount" (an element -whose Type is a high `0x10000xxx` game class with its own `BaseLayoutId`) is a -capability the importer does **not** have yet. - -**Note on `gm3DItemsUI`:** despite the "3D" name it is a 2D "Contents of Backpack" -item-list (`gm3DItemsUI::PostInit` 176728 sets `m_contentsText`→"Contents of Backpack", -`m_itemList`→`DynamicCast(0x10000031)`; its layout has NO Viewport). The 3D character -doll is in `gmPaperDollUI`, not here. CONFIRMED. - ---- - -## 2. CONSOLIDATED new toolkit widgets (the single authoritative list) - -This reconciles the four docs into one list. The shipped D.2b toolkit already has -Button(1)/Dragbar(2)/Field(3)/Menu(6)/Meter(7)/Panel(8)/Scrollbar(0xB)/Text(0xC) plus -`UiDatElement` for generic chrome — those are **reused**, not re-listed. - -**Type-registration model:** the shipped numeric Type registry (1=Button … 0x12=Proto) -is the toolkit's generic-widget dispatch. The item-slot / item-list / viewport are NOT -in that numeric table — in retail they are **`UIElement` subclasses registered by a -full class id** via `RegisterElementClass(0x10000xxx, …)`, and in the dat their -elements have `Type=0` and inherit the real class id through the `BaseElement` chain -(resolved by `ElementReader.Merge`'s zero-wins-base rule). So in acdream's -`DatWidgetFactory` they are **new behavioral leaf widgets keyed off the resolved class -id**, exactly the same pattern as the existing behavioral widgets — they just key off -`0x10000031`/`0x10000032`/`0xD` rather than a small numeric Type. (The numeric Type -that `0xD`=Viewport occupies in the confirmed registry IS a generic toolkit Type, so -`UiViewport` can register at Type `0xD` directly; the item-slot/item-list register at -their class ids.) - -| Widget | Registers at | Leaf vs container | Panels that use it | Purpose | -|---|---|---|---|---| -| **`UiItemSlot`** (port of `UIElement_UIItem`, class `0x10000032`) | class id `0x10000032` (resolves to a `UIElement_Field` subclass ⇒ underlying **Type 3**). Behavioral leaf. | **LEAF** (`ConsumesDatChildren=>true`) — reproduces its icon + overlay sub-elements procedurally | **all three** (toolbar slots, inventory cells, paperdoll equip slots) | one item-in-a-slot: icon (underlay/base/effects-overlay) + quantity text + capacity/structure Type-7 bars + cooldown ring; holds the bound object id (retail `+0x5FC`); selection/ghost/drag-accept/open-container overlay states. **The spine widget — build once.** | -| **`UiItemList` / `UiItemGrid`** (port of `UIElement_ItemList`, class `0x10000031`) | class id `0x10000031`. Behavioral widget. | **leaf wrt the importer** (manages its own `UiItemSlot` children procedurally) — but logically a **container** of slots | **all three** (toolbar = 1-cell instances; inventory = N-cell grids; paperdoll = 1-cell equip slots) | a 1-cell or N-cell grid of `UiItemSlot`s. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/GetItem/OpenContainer/SetChildList/SetParentContainer/OpenFirstContainer`. Backpack uses **two** instances (own list `+0x604`, other-container list `+0x608`). | -| **`UiViewport`** (port of `UIElement_Viewport`, Type `0xD`) | numeric Type **`0xD`** (confirmed registry; `UIElement_Viewport::Register`→`RegisterElementClass(0xd,…)` decomp 119126) | **LEAF** (`ConsumesDatChildren=>true`) | **paperdoll only** | hosts a single live 3D entity (the character clone) rendered into the widget's screen rect via a scissored mini 3D pass. Owns a fixed camera + one distant light + an `AnimatedEntityState`. **Needs a new Core→App render-into-rect seam (`IUiViewportRenderer`, Code-Structure Rule 2). The biggest new piece.** | -| **Window manager** (shared infra; drives Dragbar Type 2 + Resizebar Type 9) | not a registered widget — infra that drives existing Type-2/Type-9 chrome + `UiElement.Draggable/Resizable` | n/a | **all three** (plus future pop-ups) | open/close/z-order/persist for pop-up windows + faithful grip/dragbar drag-resize. Today vitals/chat use whole-window drag (accepted IA-12 approximation). This is "the other deferred Plan-2 piece." | -| **Sub-window mount** (LayoutImporter capability, not a widget) | n/a — an element whose Type is a high `0x10000xxx` game class WITH a non-zero `BaseLayoutId` | container | **inventory** (frame nests paperdoll + backpack + 3D-items) | lets `LayoutImporter` instantiate a NESTED `LayoutDesc` window inside a parent slot. The importer recurses generic children today but has never mounted another gm\*UI window. | -| **Per-panel controllers** (`ToolbarController`, `InventoryController`, `PaperDollController`) | not widgets — controllers like `VitalsController`/`ChatWindowController` | n/a | one per panel | find-by-id binding + wire send/receive + model restore. The `gm*UI::PostInit` analogues. (Listed for completeness; each is panel-specific, not a shared widget.) | - -### 2a. Reconciled disagreements between the agents - -The four docs were **consistent** on the big-three widget identities; the differences -were wording, not substance. Reconciled: - -1. **Item-slot Type — no real conflict.** Toolbar + inventory + paperdoll all call it - `UIElement_UIItem`, class **`0x10000032`**, a `UIElement_Field` subclass (underlying - Type 3), built as a behavioral **leaf**. The paperdoll doc's widget table named its - equip-slot variant "`UiItemSlot` registering at `0x10000031`" — that is the *equip - slot* (a single-cell `UIElement_ItemList`), NOT the inner item-cell. **Reconciliation: - `UIElement_ItemList` (`0x10000031`) is the slot/grid container; `UIElement_UIItem` - (`0x10000032`) is the item-cell inside it.** The paperdoll equip slot is a 1-cell - `UIElement_ItemList` that holds at most one `UIElement_UIItem` — same two-widget spine - as everywhere else, just constrained to one cell. (CONFIRMED: every paperdoll slot is - `DynamicCast(0x10000031)`, decomp 175242-175508; the inner cell is the - `UIElement_UIItem` `0x10000032` per the inventory agent's `UIItem_Update`/`m_elem_Icon` - citations, re-verified at 229180-229413.) - -2. **Item-list "leaf vs container".** Toolbar + paperdoll said **leaf** (the importer - doesn't build its dat children; it reproduces cells procedurally); inventory said - **container** (it lays out an N-column grid). **Reconciliation: it is a behavioral - LEAF to the importer** (`ConsumesDatChildren=>true`, the importer must NOT recurse its - dat children) but it is logically a **container of `UiItemSlot`s at runtime** (it - creates/destroys cells procedurally as items arrive). Both descriptions are correct - at different layers; the binding rule for the factory is `ConsumesDatChildren=>true`. - -3. **`UiViewport` Type.** Only the paperdoll doc introduced it; **Type `0xD`**, - confirmed against the registry (`0xD`=Viewport) and `RegisterElementClass(0xd,…)`. - No conflict. - -4. **Window manager.** All three docs named it identically (shared, drives Dragbar - Type 2 + Resizebar Type 9, open/close/z-order/persist). No conflict. - -5. **`+0x5FC` (bound object id on the item-cell).** The toolbar doc anchors this by - OFFSET only (UNVERIFIED symbolic name). The inventory/spine-territory render of the - cell would have named it; since the spine doc is missing, **it stays UNVERIFIED** — - carried to §5. - ---- - -## 3. Cross-panel wire-message catalog (de-duplicated) - -All C→S ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq; u32 subOpcode; …`); -all S→C item events ride the `0xF7B0` GameEvent envelope (`u32 0xF7B0; u32 target; -u32 seq; u32 eventOpcode; …`). De-duplicated across the three panels; the "Panels" -column shows which panel(s) use each. acdream parse-status is the union of what the -three agents found (each cross-checked against `src/AcDream.Core.Net/`). - -### 3.1 Client → server (GameActions) - -| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream status | Panels | -|---|---|---|---|---|---|---|---| -| `0x0019` | PutItemInContainer | C→S | drag item into pack / pick up (container=self) | `GameActionPutItemInContainer.Handle` | `Inventory_PutItemInContainer*` | **parsed** — `InteractRequests.BuildPickUp` (InteractRequests.cs:97) | inv, paperdoll (un-wield) | -| `0x001A` | GetAndWieldItem | C→S | equip item from pack onto doll/equip slot | `GameActionGetAndWieldItem.Handle` (Actions/GameActionGetAndWieldItem.cs:7-14) → `Player.HandleActionGetAndWieldItem(itemGuid, EquipMask)` | `Inventory_GetAndWieldItem` (`uint ObjectId; EquipMask Slot`, generated.cs:14-42) | **MISSING** (no builder) | paperdoll, inv | -| `0x001B` | DropItem | C→S | drop item on the ground | `GameActionDropItem.Handle` (1×u32 guid) | — | **MISSING** (no builder) | inv | -| `0x0035` | UseWithTarget | C→S | use src item on target (toolbar target-mode / key→door) | (Interact) | — | **parsed** — `InteractRequests.BuildUseWithTarget` | toolbar, inv | -| `0x0036` | UseItem | C→S | use/activate a single item (toolbar slot activation) | `GameActionUseItem` | — | **parsed** — `InteractRequests.BuildUse` | toolbar, inv | -| `0x0054` | StackableMerge | C→S | drop stack A onto compatible stack B | `GameActionStackableMerge.Handle` (from,to,amount) | — | **parsed** — `InventoryActions.BuildStackableMerge` | inv | -| `0x0055` | StackableSplitToContainer | C→S | split N off a stack into a pack slot | `GameActionStackableSplitToContainer.Handle` (stack,container,place,amount) | — | **parsed** — `InventoryActions.BuildStackableSplitToContainer` | inv | -| `0x0056` | StackableSplitTo3D | C→S | split N off a stack onto the ground | `GameActionStackableSplitTo3D.Handle` (stack,amount) | — | **parsed** — `InventoryActions.BuildStackableSplitTo3D` | inv | -| `0x019B` | StackableSplitToWield | C→S | split N off a stack into an equip slot (arrows) | `GameActionStackableSplitToWield` (stack,equipMask,amount) | — | **parsed** — `InventoryActions.BuildStackableSplitToWield` | inv, paperdoll | -| `0x00CD` | GiveObjectRequest | C→S | give item/N-of-stack to NPC/player | `GameActionGiveObjectRequest.Handle` (target,item,amount) | — | **parsed** — `InventoryActions.BuildGiveObjectRequest` | inv | -| `0x0195` | NoLongerViewingContents | C→S | close a side-pack / ground-container view | `GameActionType` 0x0195 (containerGuid) | — | **MISSING** (no builder) | inv | -| `0x019C` | AddShortCut | C→S | pin item to toolbar slot (on drag-to-slot / add-selected) | `GameActionAddShortcut.Handle` → `Character.AddOrUpdateShortcut(Index,ObjectId)` | `Character_AddShortCut { ShortCutData Shortcut }` | **builder present** — `InventoryActions.BuildAddShortcut` *(fix field naming → Index/ObjectId/SpellId\|Layer)* | toolbar | -| `0x019D` | RemoveShortCut | C→S | unpin / evict / overwrite a toolbar slot | `GameActionRemoveShortcut.Handle` → `Character.TryRemoveShortcut(index)` | `Character_RemoveShortCut { uint Index }` | **builder present** — `InventoryActions.BuildRemoveShortcut` | toolbar | - -### 3.2 Server → client (GameEvents / GameMessages) - -| Opcode | Name | Dir | Trigger | ACE handler | Chorizite type | acdream status | Panels | -|---|---|---|---|---|---|---|---| -| `0x0022` | InventoryPutObjInContainer | S→C | confirm item in container at slot | `GameEventItemServerSaysContainId` (itemGuid,containerGuid,placement,**containerType**) | — | **parsed INCOMPLETE** — `GameEvents.ParsePutObjInContainer` reads 3 fields, **drops containerType**; wired (GameEventWiring.cs:239) | inv | -| `0x0023` | WieldObject | S→C | confirm item equipped to slot | `GameEventWieldItem` (objectId, i32 equipMask) | — | **parsed + wired** — `GameEvents.ParseWieldObject`, GameEventWiring.cs:231 | paperdoll, inv | -| `0x0052` | CloseGroundContainer | S→C | server closed a ground-container view | `GameEventCloseGroundContainer` (containerGuid) | — | **parsed UNWIRED** — `GameEvents.ParseCloseGroundContainer`, not in WireAll | inv | -| `0x00A0` | InventoryServerSaveFailed | S→C | reject speculative client move (roll back) | `GameEventInventoryServerSaveFailed` (itemGuid, weenieError) | — | **parsed UNWIRED INCOMPLETE** — reads guid only, **drops error**; not in WireAll | inv (+toolbar/paperdoll rollback) | -| `0x00C9` | IdentifyObjectResponse | S→C | appraise result (gates tooltip, not icon) | `GameEventIdentifyObjectResponse` (guid,flags,success,property tables) | — | **parsed + wired** — `AppraiseInfoParser` via GameEventWiring.cs:245 | inv, paperdoll, toolbar (tooltip) | -| `0x0196` | ViewContents | S→C | full contents list of an opened container | `GameEventViewContents` (container,count,{guid,containerType}×n) | — | **MISSING** (no parser) | inv | -| `0x0197` | SetStackSize | S→C | update a stack count+value after merge/split | `GameMessageSetStackSize` (seq,guid,stackSize,value) | — | **MISSING** (no parser) | inv, toolbar | -| `0x019A` | InventoryPutObjectIn3D | S→C | confirm item dropped to world | `GameEventItemServerSaysMoveItem` (objectGuid) | — | **parsed UNWIRED** — `GameEvents.ParsePutObjectIn3D`, not in WireAll | inv | -| `0xF625` | ObjDescEvent | S→C | wield/unwield → full new appearance broadcast → `RedressCreature` | `GameMessageObjDescEvent` → `SerializeUpdateModelData` (GameMessageObjDescEvent.cs:10-17) | (ModelData block) | **parsed** — `ObjDescEvent.cs:33-73` (`CreateObject.ReadModelData`) | paperdoll | -| `0xF745` | CreateObject | S→C | spawn a weenie incl. a pack item (IconId/WeenieClassId/StackSize/Value/capacities) | `GameMessageCreateObject` → `WorldObject.SerializeCreateObject` | `Item_CreateObject` | **parsed INCOMPLETE** — `CreateObject.TryParse` **discards IconId (cs:516), WeenieClassId (cs:515), StackSize, Value, ItemCapacity, ContainerCapacity** | all (icon + quantity source) | -| (UIQueue) | InventoryRemoveObject | S→C | remove item from inventory view (given/dropped/destroyed) | `GameMessageInventoryRemoveObject` (guid) | — | **MISSING** (no parser) | inv, toolbar | -| `PlayerDescription` SHORTCUT block | persisted toolbar shortcut list | S→C | login (part of `0xF7B0`/0x0013 `PlayerDescription`) | `Player_Character.GetShortcuts()` | `ShortCutData` (Index,ObjectId,LayeredSpellId) | **parsed** — `PlayerDescriptionParser.cs:345-356` → `Parsed.Shortcuts` | toolbar | -| `PlayerDescription` equipped `InventoryPlacement` list | persisted equipped-gear list | S→C | login | `GameEventPlayerDescription.WriteEventBody` | `Login_PlayerDescription` | **PARTIAL** — equipped section NOT surfaced (PlayerDescriptionParser.cs:70-77) | paperdoll | - -**Shared-message note:** `CreateObject (0xF745)` and `IdentifyObjectResponse (0x00C9)` -are used by all three panels and de-duplicated above. `GetAndWieldItem (0x001A)` is -shared by inventory (equip-from-grid) and paperdoll (drop-on-doll); `UseItem (0x0036)`/ -`UseWithTarget (0x0035)` are shared by toolbar activation and inventory double-click. - -### 3.3 acdream wire-gap TODO (the build session's concrete list) - -- **Add C→S builders:** `GetAndWieldItem (0x001A)`, `DropItem (0x001B)`, - `NoLongerViewingContents (0x0195)`. -- **Add S→C parsers:** `ViewContents (0x0196)`, `SetStackSize (0x0197)`, - `InventoryRemoveObject`. -- **Fix incomplete parsers:** `ParsePutObjInContainer` (read the 4th `containerType` - u32); `ParseInventoryServerSaveFailed` (read the `weenieError` u32). -- **Wire (register in `GameEventWiring.WireAll`):** `ViewContents`, - `InventoryPutObjectIn3D (0x019A)`, `CloseGroundContainer (0x0052)`, - `InventoryServerSaveFailed (0x00A0)`. -- **Extend `CreateObject.TryParse`** to capture `IconId`, `WeenieClassId`, `StackSize`, - `Value`, `ItemCapacity`, `ContainerCapacity` (cells need icon + quantity + - capacity bar). **Re-verified discarded at `CreateObject.cs:515-516`.** -- **Extend `PlayerDescriptionParser`** to surface the equipped `InventoryPlacement - {iid, loc, priority}` list (paperdoll slot icons at login). -- **Fix `InventoryActions.BuildAddShortcut` field naming** (currently - `slotIndex/objectType/targetId`; wire layout is correct for item shortcuts but - semantics should be `Index/ObjectId/SpellId|Layer`). - ---- - -## 4. Recommended build order - -Ordered by dependency so the next session can go straight to brainstorm → spec → plan. -Each step states why it must come where it does. - -**Step 0 — SPINE research (DONE — see the spine doc).** -The spine doc is complete: -[`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md). -It specs the item-cell widget, the 5-layer icon composite (`IconData::RenderIcons` -decomp 407524 — each layer a `0x06` RenderSurface decoded directly, blitted into one -private 32×32 surface: type-default underlay / custom underlay / base `_iconID` / custom -overlay+tint / effect overlay), `UIElement_UIItem::UIItem_Update`/`UIItem_SetIcon` -(decomp 230226+), the overlay-state machine, and the widget-level drag-drop hooks -(`UIElement_Field::MouseOverTop`/`CatchDroppedItem`). The `+0x5FC` field is resolved -(`UIElement_UIItem::itemID`). Steps 2-7 can now proceed against a finished port spec; -this is **no longer blocking**. (Note: WB `TextureHelpers.cs` / ACViewer `IndexToColor` -are for WORLD textures — item icons take NO subpalette overlay at composite time; see -the spine doc.) - -**Step 1 — Window manager foundation.** -*Why first among code:* all three panels are pop-up windows that must open/close, stack -(z-order), persist position, and (faithfully) drag/resize via Dragbar (Type 2) + -Resizebar (Type 9). Vitals/chat shipped with whole-window drag (accepted IA-12 -approximation); the panels need the real thing. Everything visible in Steps 5-7 mounts -inside a managed window, so the manager is the substrate. It is independent of the wire -work, so it can proceed in parallel with Step 0. - -**Step 2 — `UiItemSlot` widget + icon pipeline (`UIElement_UIItem` 0x10000032).** -*Why here:* it is the atom of all three panels. Depends on Step 0 (icon render) and on -the §3.3 `CreateObject` extension (IconId/StackSize) for real data. Build the leaf -widget: icon composite, quantity text, capacity/structure Type-7 bars, cooldown ring, -and the selection/ghost/drag-accept/open-container overlay states. - -**Step 3 — `UiItemList` / `UiItemGrid` widget (`UIElement_ItemList` 0x10000031).** -*Why here:* it composes `UiItemSlot`s and is used by every panel (1-cell and N-cell). -Depends on Step 2. Port `ItemList_AddItem/InsertItem/Flush/IsInList/GetNumUIItems/ -GetItem/OpenContainer/SetChildList/SetParentContainer`. Register as a behavioral leaf -(`ConsumesDatChildren=>true`). - -**Step 4 — Wire wiring (builders/parsers/wireup from §3.3).** -*Why here:* the controllers in Steps 5-7 need the full send/receive surface, and this is -independent of the widget rendering — it can run in parallel with Steps 1-3. Add the -missing builders/parsers, fix the two incomplete parsers, register the unwired parsers, -extend `CreateObject` + `PlayerDescriptionParser`, fix `BuildAddShortcut` naming. Each -new deviation gets a divergence-register row in the same commit. - -**Step 5 — `ToolbarController` + the action bar (simplest panel).** -*Why before the others:* the toolbar is the simplest consumer (18 single-cell lists, no -nested sub-windows, no viewport) and exercises the full spine + window manager + wire -path end-to-end. acdream already parses the SHORTCUT block and has both shortcut -builders, so it's the fastest path to a working, testable panel. Bind the 18 slots, -the hidden selected-object meters + stack slider, the panel-launcher buttons; restore -from `Parsed.Shortcuts`; wire `UseShortcut`/`AddShortcut`/`RemoveShortcut` + -`HandleDropRelease`. - -**Step 6 — `InventoryController` + the inventory/backpack panels + sub-window mount.** -*Why here:* adds the N-cell grid (Step 3 at scale), the burden Meter (reuses Type-7 -`SetLoadLevel`→fill 0x69), the dual-ItemList container model (own `+0x604` / other -`+0x608`), and the **sub-window mount** importer capability (frame nests paperdoll + -backpack + 3D-items). The hardest 2D panel; depends on Steps 1-4 and the new sub-window -mount. - -**Step 7 — `UiViewport` + `PaperDollController` + the equipment doll (biggest new piece).** -*Why last:* it depends on everything above (window manager, equip-slot `UiItemList` -instances, `GetAndWieldItem` wire, `PlayerDescription` equipped list) AND introduces the -single largest new engineering item: the **UI↔3D render seam** (`IUiViewportRenderer` -Core interface, App impl, per Code-Structure Rule 2) that renders a re-dressed player -clone into a scissored UI rect. It reuses acdream's existing `EntitySpawnAdapter`/ -`AnimatedEntityState` character path, but the rect-scissored single-entity pass is new. -Doing it last lets the 2D panels validate the spine first, so a 3D-render bug is -isolated. - -**Parallelism summary:** Step 0 (spine research) + Step 1 (window manager) + Step 4 -(wire) can all proceed independently; Steps 2→3→5→6→7 are the dependent spine→panels -chain. - ---- - -## 5. Open risks / UNVERIFIED — resolve BEFORE implementation - -Collated from all four docs; each needs a decomp or cdb follow-up before the cited step. - -1. **SPINE doc — RESOLVED (no longer blocking).** Written: - [`2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md). - Icon-composite render (`IconData::RenderIcons` 407524, 5 layers) + the widget-level - drag-drop state machine (`UIElement_Field::MouseOverTop`/`CatchDroppedItem`, cell msgs - `0x21`/`0x3e`/`0x15`, `InqDropIconInfo` flags) are now specced with anchors. -2. **`UIElement_UIItem +0x5FC` bound-object-id field name — RESOLVED = `itemID`.** - `UIElement_UIItem::itemID`, anchored at `UIItem_Update` decomp 230230 - (`uint32_t itemID = this->itemID; … GetWeenieObject(itemID)`), corroborated 230422/233107 - (companion field `spellID`). See the spine doc. -3. **`CreateObject` IconId for CONTAINED (non-3D-visible) pack items — LIKELY.** - Confirmed on the wire + currently discarded (`CreateObject.cs:516`), but not - byte-traced that ACE sets IconId on a *contained* item's CreateObject vs. relying on - PlayerDescription. Verify against a live capture before treating CreateObject as the - sole icon source. -4. **Use-item opcode `ItemHolder::UseObject` sends (0x0035 vs 0x0036) — UNVERIFIED.** - Throttle (0.2 s) + dispatch CONFIRMED (decomp 402923); the precise opcode branch - (`DetermineUseResult`) not traced to the send. Both opcodes exist in acdream - `InteractRequests.cs`; reconcile when wiring toolbar/inventory activation (Step 5/6). -5. **`UseShortcut` target-mode path — out of scope, file follow-up.** - `ClientUISystem::ExecuteTargetModeForItem` (use-item-on-target) depends on the cursor - target-mode subsystem; not part of the action-bar widget itself. -6. **`SetDelayedShortcutNum` deferral — needs a re-bind state machine.** When a slot's - weenie isn't loaded yet (`AddShortcut` decomp 196867), the slot must re-bind once - `CreateObject` for that guid arrives. Detail in the `ToolbarController` port (Step 5). -7. **Paperdoll `0x100001E0` = MissileAmmo `0x800000` — LIKELY only.** The decomp - immediate is corrupted to a string-ptr (line 173676); inferred from the EquipMask gap - + neighbors. Re-decompile `0x004a388a` in Ghidra to recover the real value (Step 7). -8. **Paperdoll viewport camera/light float immediates — UNVERIFIED (not byte-decoded).** - Lines 175524-175526 / 174144-174146; the agent read the hex but did not convert all - floats (`0x3df5c28f≈0.12`, `0xc019999a≈−2.4`, `0xc0400000=−3.0`, `0xc059999a≈−3.4`, - `0x3f6147ae≈0.88`, `0x3f800000=1.0`). Decode precisely for faithful framing (Step 7). -9. **UI↔3D render seam — DESIGN-OPEN.** How a UI rect drives a scissored single-entity - 3D pass (after the world pass vs. as a UI overlay), and the exact - `IUiViewportRenderer` Core-interface shape (Code-Structure Rule 2). Brainstorm before - Step 7. -10. **Does the doll clone the player `WorldEntity` or build a fresh one? — UNVERIFIED.** - Retail clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`, - line 173999); acdream has no player-as-renderable today (player = camera). LIKELY a - dedicated `WorldEntity` from the local player's Setup+ObjDesc fed to a private - viewport host. Settle in Step 7 brainstorm. -11. **Inventory side-pack column `0x100001CB` (16×252, base `0x2100003E`) — UNVERIFIED.** - Tabs (one per sub-bag) or a scrollbar gutter? Dump `0x2100003E` to settle (Step 6). -12. **`UIElement_ItemList` grid geometry (column count, cell pitch) — LIKELY.** Cell - template 36×36 (`0x100001C9`); `UIElement_UIItem` `0x21000037` is 32×32. Confirm the - fixed-column wrap by reading `UIElement_ItemList::ItemList_AddItem` (Step 3). -13. **Value/coin total NOT in the inventory window — UNVERIFIED home.** No value Meter/ - text in `0x21000022`/`0x21000023`; the window shows BURDEN only. Do NOT invent a - value summary; find its real home before adding one. -14. **Identified-vs-unidentified does NOT swap the icon — CONFIRMED (negative).** The - spine doc confirms there is no appraise branch anywhere in the icon path - (`UIItem_SetIcon` → `IconData::RenderIcons`); appraise gates `UpdateTooltip` only. -15. **`InventoryActions.BuildAddShortcut` field-naming bug — CONFIRMED file contents, - LIKELY latent bug.** Wire layout is correct for item shortcuts; the param names - (`slotIndex/objectType/targetId`) are misleading. Fix to `Index/ObjectId/ - SpellId|Layer` + register a divergence row at port time (Step 4/5). - ---- - -## 6. Proposed MEMORY.md index lines (for ALL 5 docs) - -The parent will append these; I do NOT edit MEMORY.md. - -- [UI core-panels SYNTHESIS (toolbar+inventory+paperdoll)](research/2026-06-16-ui-panels-synthesis.md) — D.2b build plan reconciling the 3 panel deep-dives. Big-three new widgets: `UiItemSlot`(`UIElement_UIItem` 0x10000032, shared leaf), `UiItemList/Grid`(`UIElement_ItemList` 0x10000031, shared leaf-to-importer), `UiViewport`(Type 0xD, paperdoll 3D doll), plus the shared **window manager** (Dragbar 2 + Resizebar 9) + sub-window-mount importer capability + per-panel controllers. De-duped cross-panel wire table; build order (window mgr → item-slot+icon → item-list → wire → toolbar → inventory → paperdoll; spine research DONE). -- [UI item-slot SPINE — icon composite + drag-drop](research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — D.2b shared spine (completes the 5-doc arc): `UiItemSlot`(`UIElement_UIItem` 0x10000032, `+0x5FC` RESOLVED = `itemID`) inside `UiItemList`(0x10000031). ICON CRUX: each layer is a `0x06` RenderSurface decoded DIRECTLY, but the icon is a 5-layer runtime COMPOSITE (`IconData::RenderIcons` @407524; NOT one texture, NOT appraise-gated). Drag-drop = `Field::MouseOverTop`/`CatchDroppedItem` + cell msgs 0x21/0x3e/0x15 + `InqDropIconInfo` flags; `UiRoot` already has the chain, `UiField` only stubs the hooks; gap = `CreateObject` discards IconId (cs:516). -- [Action bar / quick slots (`gmToolbarUI`) deep dive](research/2026-06-16-action-bar-toolbar-deep-dive.md) — 18 item slots (2 rows of 9, ids `0x100001A7-AF`+`0x100006B7-BF`) = `UIElement_ItemList`(0x10000031) of one `UIElement_UIItem`(0x10000032); model `ShortCutManager::shortCuts_[18]` persisted in `PlayerDescription`'s SHORTCUT block (acdream already parses it); live mutate via `AddShortCut 0x019C`/`RemoveShortCut 0x019D` (acdream builders present — fix `BuildAddShortcut` field naming); activation = ordinary use-item (`ItemHolder::UseObject`, no special wire); the 2 Meters + Scrollbar in `0x21000016` are the hidden selected-object Health/Mana bars + stack-split slider, NOT paging; drag-drop via `gmToolbarUI : ItemListDragHandler::HandleDropRelease`. New widgets: `UiItemSlot` + `UiItemList` + `ToolbarController`. -- [Inventory panel deep-dive (gmInventoryUI/gmBackpackUI)](research/2026-06-16-inventory-deep-dive.md) — D.2b: LayoutDesc 0x21000023 (frame: title + 3 nested sub-windows) + 0x21000022 (backpack: burden Meter 0x100001D9 via SetLoadLevel→fill 0x69, main-pack ItemList 0x100001CA); full inventory wire catalog (C→S 0x0019/1A/1B/54/55/56/19B/CD/195, S→C 0x0022/23/196/19A/A0/52 + SetStackSize/InventoryRemoveObject) with acdream parse-status (gaps: DropItem/GetAndWieldItem/ViewContents builders, 0x0022 4th field, CreateObject IconId); new widgets UiItemSlot(0x10000032)/UiItemGrid(0x10000031)+sub-window mount+window manager. -- [Equipment/Paperdoll panel deep-dive](research/2026-06-16-equipment-paperdoll-deep-dive.md) — gmPaperDollUI 0x10000024/LayoutDesc 0x21000024: doll = UIElement_Viewport (Type 0xD, elem 0x100001D5) hosting a CreatureMode clone re-dressed via DoObjDescChangesFromDefault; ~25 equip slots are single-cell UIElement_ItemList (0x10000031) mapped element-id→EquipMask by GetLocationInfoFromElementID; wield = GetAndWieldItem (0x001A, item+EquipMask, acdream-MISSING), appearance reply = ObjDescEvent 0xF625 (acdream-PARSED) → RedressCreature; acdream's EntitySpawnAdapter/AnimatedEntityState char path is reusable; new widgets = UiViewport (0xD, the UI↔3D bridge), UiItemSlot (0x10000031), window manager. gm3DItemsUI 0x21000021 is a "Contents of Backpack" list, NOT the doll. -- [Action-bar/inventory/equipment research handoff](research/2026-06-16-action-bar-inventory-equipment-handoff.md) — the §3 question list (Q1-Q12) + agent assignment that drove the toolbar/inventory/paperdoll/spine deep-dives. (All 5 research docs delivered; the spine doc was completed in a follow-up pass after a transient agent failure.) - ---- - -## 7. New toolkit widgets this introduces (recap) - -| Widget | dat Type / class it registers at | leaf vs container | Purpose | -|---|---|---|---| -| `UiItemSlot` | class `0x10000032` (`UIElement_UIItem`) | leaf (`ConsumesDatChildren=>true`) | shared item-cell: icon + quantity + capacity/structure bars + overlay states + bound object id | -| `UiItemList` / `UiItemGrid` | class `0x10000031` (`UIElement_ItemList`) | leaf to importer; container of slots at runtime | shared 1-cell/N-cell grid of `UiItemSlot`s | -| `UiViewport` | numeric Type `0xD` (`UIElement_Viewport`) | leaf (`ConsumesDatChildren=>true`) | paperdoll 3D character doll via a scissored mini 3D pass; needs `IUiViewportRenderer` Core→App seam | -| Window manager | infra (drives Dragbar Type 2 + Resizebar Type 9) | n/a | open/close/z-order/persist + faithful grip/dragbar drag-resize for all pop-up panels | -| Sub-window mount | LayoutImporter capability (element whose Type is a high `0x10000xxx` class with a `BaseLayoutId`) | container | nest a `LayoutDesc` window inside a parent slot (inventory frame → paperdoll/backpack/3D-items) | - -## 8. Open questions / UNVERIFIED (recap) - -See §5 for the full collated list with anchors. The former blocking item — the spine -doc — is now written (icon-composite render path + widget-level drag-drop state machine -specced with anchors; `+0x5FC` = `itemID` resolved). Remaining items are per-step -follow-ups (decode the paperdoll camera floats, recover `0x100001E0`, dump -`0x2100003E`, byte-trace CreateObject IconId for contained items). - ---- - -**Single MEMORY.md index line for THIS doc:** - -- [UI core-panels SYNTHESIS (toolbar+inventory+paperdoll)](research/2026-06-16-ui-panels-synthesis.md) — D.2b build plan reconciling the 3 panel deep-dives: shared `UiItemSlot`(0x10000032)+`UiItemList`(0x10000031) spine, `UiViewport`(Type 0xD) for the paperdoll 3D doll, window manager (Dragbar 2 + Resizebar 9) + sub-window-mount; de-duped cross-panel wire table; build order window-mgr→item-slot+icon→item-list→wire→toolbar→inventory→paperdoll (spine research DONE — see the spine deep-dive). diff --git a/docs/research/2026-06-17-stateful-icon-RESOLVED.md b/docs/research/2026-06-17-stateful-icon-RESOLVED.md deleted file mode 100644 index c1d1023e..00000000 --- a/docs/research/2026-06-17-stateful-icon-RESOLVED.md +++ /dev/null @@ -1,142 +0,0 @@ -# Stateful item-icon system — RESEARCH RESOLVED (the build basis for D.5.2) - -**Date:** 2026-06-17 -**Supersedes the key hypotheses in** `docs/research/2026-06-17-stateful-icon-system-handoff.md`. -**Method:** grep-named → cross-ref (ACE/ACViewer/Chorizite) → clean Ghidra decompile -(MCP, PDB-applied `patchmem.gpr`) → live-dat probe. Each decomp claim adversarially -verified against source. - -This doc records the **definitive** answers. Two handoff hypotheses were **wrong**; both -are corrected here with evidence. - ---- - -## 1. Data-availability — SETTLED (handoff's "DO THIS FIRST" question) - -**The icon ids and the effect bitfield arrive ONLY on `CreateObject`. Appraise carries -NONE of them.** Definitive from the ACE oracle (the user's own server): - -- `references/ACE/.../Enum/Properties/PropertyDataId.cs:5-7` (verbatim): - *"No properties are sent to the client unless they featured an attribute. … AssessmentProperty - gets sent in successful appraisal."* -- `Icon = 8`, `IconOverlay = 50`, `IconUnderlay = 52` — **no `[AssessmentProperty]`** → never in - appraise (nor `[SendOnLogin]` → never in PlayerDescription property tables). -- `PropertyInt.UiEffects = 18` — **no `[AssessmentProperty]`** (`PropertyInt.cs:34`; the - research-agent claim that it has the attribute was a **fabrication**, caught by the verifier). -- `AppraiseInfo.Write` serializes only the attributed `PropertiesInt/PropertiesDID/…` tables + - the profile blobs — **no icon / UiEffects field anywhere**. - -Wire path for every icon input (all on the `CreateObject` weenie header, ACE -`WorldObject_Networking.cs` + `PublicWeenieDesc::Pack` decomp `442421/442489/442628/442631`): - -| Field | weenie-flag gate | acdream status | -|---|---|---| -| `_iconID` | always | captured (D.5.1) | -| `_iconOverlayID` | weenieFlags `0x40000000` | captured (D.5.1) | -| `_iconUnderlayID` | weenieFlags2 `0x01` | captured (D.5.1) | -| `_effects` (UiEffects) | weenieFlags `0x80` | **read + DISCARDED** at `CreateObject.cs:669` | - -**Consequence (corrects handoff §3.3/§3.4 + §5.4):** the pinned scroll shows no overlay because -acdream **discards `UiEffects`** and never builds the effect treatment — NOT because the data is -appraise-gated. **The handoff's "wire appraise → enrichment" item is a no-op**: appraise never -carries this data, and acdream never even *sends* an `AppraiseRequest` (`AppraiseRequest.Build` -exists but has zero call sites). The live "mana vs out-of-mana" re-trigger is a future -`PrivateUpdateInt(UiEffects=18)` (the `0x02CD` property-update block, inventory/M2 phase), feeding -the same re-composition contract — NOT appraise. - ---- - -## 2. The effect overlay is a `ReplaceColor` tint SOURCE, not a blit layer — SETTLED - -Clean Ghidra decompile of `IconData::RenderIcons` (`0x0058d180`) + `SurfaceWindow::ReplaceColor` -(`0x00441530`) resolves the Binary-Ninja register/calling-convention artifacts the handoff and the -spine doc flagged UNVERIFIED. - -**`SurfaceWindow::ReplaceColor(this, RGBAColor src, RGBAColor dest)`** = for each pixel `== -GetColor32(src)`, set it to `GetColor32(dest)`. A flat single-color → single-color replace. - -**`RenderIcons` builds two surfaces (bottom→top):** - -``` -m_pDragIcon (32x32): - Blit base icon (m_idIcon) mode Blit_Normal (opaque) - Blit custom overlay (m_idOverlayID) mode Blit_4Alpha - if (effectTile != null): # effectTile = GetByEnum(0x10000005, …) - ReplaceColor(this, src = WHITE(1,1,1,1), dest = ) - -m_pIcon (32x32): - Blit type-default underlay (GetByEnum 0x10000004, lsb(itemType)+1, fb 0x21) Blit_Normal (opaque) - Blit custom underlay (m_idUnderlayID) Blit_3Alpha - Blit m_pDragIcon Blit_3Alpha -``` - -- The **effect tile is NEVER blitted** (it's the `ReplaceColor` `dest`-color source). The dat probe - confirms why: every `enum 0x10000005` entry is a **32×32 FULLY-OPAQUE** colored tile - (`opaque=1024, transp=0`) — blitting one on top would erase the icon. -- `src` color = `RGBAColor(1,1,1,1)` → `GetColor32` → `0xFFFFFFFF` (pure-white, full alpha). So - **only pure-white-opaque pixels recolor** — the effect is the recolor of the icon/overlay's white - highlights to the effect hue. Subtle, data-dependent. -- **Effect index:** `LowestSetBit(_effects)+1` into `enum 0x10000005`; if the resolved DBObj is null, - fallback index `0x21`. NOTE retail has **no** `lsb==-1 → 0x21` pre-check on the effect path (unlike - the type-underlay path), so `_effects==0` → index 0 → null → fallback `0x21` (the SOLID-BLACK tile). -- **UpdateIcons dirty-check** (`0x0058da…`, decomp `407962`): re-render on change of - `iconID / overlayID / underlayID / itemType / _effects`. acdream's per-tuple icon cache keyed on - exactly these IS the re-composition contract. - -### The one residual ambiguity (decompiler-bounded) -The exact byte `ReplaceColor`'s `dest` color is read from is `effectTile + 0xac` (= the effect tile's -`SurfaceWindow` header) reinterpreted as `RGBAColor` — both BN and Ghidra leave this as a struct -read neither types cleanly. It is NOT pixel data and NOT a clean field either decompiler resolves. -**Faithful resolution:** the effect tiles are purpose-built per-effect colored tiles, so the effect -color = the tile's own representative (mean opaque) color. This is intent-faithful, not a guess about -an unknown constant. Flagged for cdb/visual confirmation. (Register row + visual gate.) - ---- - -## 3. `enum 0x10000005` effect submap — golden values (live dat, MasterMap `0x25000000` → submap `0x25000009`) - -`index = LowestSetBit(UiEffects)+1`; submap has 14 entries (idx 0–12 + `0x21` fallback): - -| UiEffects bit | name | idx | effect tile DID | tile mean RGB | -|---|---|---|---|---| -| 0x0001 | Magical | 1 | `0x060011CA` | blue (53,70,212) | -| 0x0002 | Poisoned | 2 | `0x060011C6` | green (79,204,34) | -| 0x0004 | BoostHealth | 3 | `0x06001B05` | red (213,57,59) | -| 0x0008 | BoostMana | 4 | `0x060011CA` | blue | -| 0x0010 | BoostStamina | 5 | `0x06001B06` | yellow (223,206,21) | -| 0x0020 | Fire | 6 | `0x06001B2E` | orange | -| 0x0040 | Lightning | 7 | `0x06001B2D` | purple | -| 0x0080 | Frost | 8 | `0x06001B2F` | cyan-grey | -| 0x0100 | Acid | 9 | `0x06001B2C` | green | -| 0x0200 | Bludgeoning | 10 | `0x060033C3` | grey | -| 0x0400 | Slashing | 11 | `0x060033C2` | pink-grey | -| 0x0800 | Piercing | 12 | `0x060033C4` | tan | -| 0x1000 | Nether | 13 | *(absent)* → fallback | → `0x060011C5` | -| — | (`_effects==0`) | 0 | *(zero)* → fallback | → `0x060011C5` (SOLID black) | -| — | fallback | 0x21 | `0x060011C5` | SOLID 0xFF000000 | - -(Cross-check, `enum 0x10000004` type-underlay, already shipped + golden-tested: Melee→`0x060011CB`, -Armor→`0x060011CF`, Clothing→`0x060011F3`, Jewelry→`0x060011D5`, fallback `0x21`→`0x060011D4`.) - ---- - -## 4. Build decisions (D.5.2) - -1. **Capture `UiEffects`** from `CreateObject` → `ItemInstance.Effects`; thread through - `EntitySpawn` → `EnrichItem`. -2. **`IconComposer`: faithful 2-stage composite** (drag = base+overlay+recolor; slot = - typeUnderlay+customUnderlay+drag). New `ResolveEffectDid` mirrors the proven `ResolveUnderlayDid`. - `GetIcon` + cache key widened to include `effects`. -3. **Effect recolor** applied only when `_effects != 0` (the meaningful case). Retail nominally runs - the `_effects==0` black-fallback recolor too; we **skip** it — recoloring white→black on every - item is a likely visual no-op (few pure-white pixels) but a real regression risk; documented - divergence pending visual/cdb confirmation. -4. **DROP the appraise-enrichment item** (no-op — §1). The re-composition contract - (`ItemPropertiesUpdated` → widget re-resolve) is already wired; its future trigger is - `PrivateUpdateInt(UiEffects)`, filed for the property-update phase. -5. **Conformance**: golden `ResolveEffectDid` test (the §3 values) + a dat-free recolor test. -6. **Register**: retire `IA-16`; add rows for effect-as-recolor, the `_effects==0` skip, and the - representative-color approximation. - -**MEMORY.md index line:** -- [Research: stateful icon RESOLVED (2026-06-17)](research/2026-06-17-stateful-icon-RESOLVED.md) — definitive basis for D.5.2. Appraise carries NO icon/UiEffects (ACE `[AssessmentProperty]` proof); all icon inputs are CreateObject-only (UiEffects weenieFlags 0x80, discarded at CreateObject.cs:669). Effect overlay (enum 0x10000005) is a `ReplaceColor(white→effectColor)` SOURCE, NOT a blit layer (Ghidra `RenderIcons`@0x0058d180 + `ReplaceColor`@0x00441530). Golden effect-submap values + the 2-stage composite. Corrects the handoff's appraise + blit-layer hypotheses. diff --git a/docs/research/2026-06-17-stateful-icon-system-handoff.md b/docs/research/2026-06-17-stateful-icon-system-handoff.md deleted file mode 100644 index e1c952d5..00000000 --- a/docs/research/2026-06-17-stateful-icon-system-handoff.md +++ /dev/null @@ -1,127 +0,0 @@ -# Handoff — the FULL stateful item-icon system (next session) - -**Date:** 2026-06-17 -**From:** the D.5.1 toolbar session (the action bar shipped; its icon compositor is **partial**). -**Purpose:** build the **complete, retail-faithful, stateful item-icon system** — the multi-layer icon composite that reflects an item's *current state* (charged/enchanted/etc.), driven by both `CreateObject` and `Appraise`. This is **shared infrastructure**: the inventory, equipment/paperdoll, vendor, and trade panels all render item icons, so it must be solved properly once, here, before those panels are built. - -This doc is the entry point. The new-session prompt is at the bottom (§10). - ---- - -## 0. TL;DR - -A retail item icon is **not one sprite** — it's a runtime composite of **up to 5 layers** (`IconData::RenderIcons`, decomp `acclient_2013_pseudo_c.txt:407524` / `0058d180`), and **which layers apply depends on the item's live state** (item type, magic underlay, overlay tint, and the `_effects` bitfield). The D.5.1 toolbar built layers 1–4 of the composite and the `CreateObject` parse for the base/overlay/underlay ids — but the **effect layer (5), the overlay tint, and the appraise-driven state updates are missing**, which is why the user's pinned scroll still shows no overlay. The user is correct: "an item *with* mana vs *out of* mana shows a different icon" — that's exactly the stateful layer system. Build it fully. - ---- - -## 1. The retail icon model (the oracle: `IconData::RenderIcons`) - -`IconData::RenderIcons(IconData* this, ACCWeenieObject* obj)` — decomp `407524` (`0058d180`). It builds the on-screen icon by blitting layers **bottom → top** into one private 32×32 surface: - -| # | Layer | Source | Blit | Driven by | Status | -|---|---|---|---|---|---| -| 1 | **type-default underlay** (the opaque background tile) | `DBObj::GetByEnum(0x10000004, LowestSetBit(itemType)+1)`, fallback index `0x21` | `Blit_Normal` (opaque) | the item's `ItemType` | ✅ **built** (D.5.1) | -| 2 | **custom underlay** ("has magic") | `_iconUnderlayID` | `Blit_3Alpha` | item has an underlay id | ✅ parse+composite built | -| 3 | **base icon** | `_iconID` | `Blit_Normal` | always | ✅ built | -| 4 | **custom overlay** ("enchanted") | `_iconOverlayID` + `SurfaceWindow::ReplaceColor` **tint** | `Blit_3Alpha` | item has an overlay id | ⚠️ overlay sprite composited, **tint NOT applied** | -| 5 | **effect overlay** (the magic glow/state) | `DBObj::GetByEnum(0x10000005, LowestSetBit(_effects)+1)` | blit | the item's **`_effects`** bitfield (Magical/Enchanted/…) | ❌ **NOT built** | - -Plus a special case at `407546` (`0058d1ee`): **`IsThePlayer`** → `m_idIcon = GetDIDByEnum(0x10000004, 7)`, `itemType = TYPE_CONTAINER (0x200)` — the player's own paperdoll icon. Out of scope for the toolbar; **needed for the paperdoll**. - -### The enum-mapper resolve chain (already wired for 0x10000004) -`GetByEnum(enumId, index)` → `DBCache::GetDIDFromEnum` (`0x413940`): `master[enumId] → submapDID`; `submap[index] → the 0x06 RenderSurface DID`. DatReaderWriter exposes the mapper as **`EnumIDMap`** (`DB_TYPE_DID_MAPPER`); the master map DID is `_dats.Portal.Header.MasterMapId` (**= 0x25000000**, confirmed live). For the underlay: `master[0x10000004] = submap 0x25000008` (34 entries). **For the effect layer you need `master[0x10000005]`** (not yet read). `EnumIDMap.ClientEnumToID` is `IReadOnlyDictionary`; each layer DID is a `0x06` RenderSurface decoded directly by `SurfaceDecoder.DecodeRenderSurface`. - ---- - -## 2. What D.5.1 already built (read this code first) - -- **`src/AcDream.App/UI/IconComposer.cs`** — the CPU compositor. `Compose(layers)` = alpha-over, sizes to layer 0. `GetIcon(ItemType, iconId, underlayId, overlayId)` resolves the **type-default underlay** (`ResolveUnderlayDid` + `EnsureUnderlaySubMap`, via `EnumIDMap` master→`0x10000004`→submap), prepends it as the opaque layer 0, then composites custom-underlay + base + custom-overlay, caches by the `(typeUnderlayDid, iconId, underlayId, overlayId)` tuple, uploads via `TextureCache.UploadRgba8`. **Layer order + the underlay are faithful** (golden test `ResolveUnderlayDid_goldenValues_matchDat` passes against the live dat). -- **`src/AcDream.Core.Net/Messages/CreateObject.cs`** — `TryParse` now walks the **full** weenie-header optional tail (in exact ACE order, verified against `references/ACE/.../WorldObject_Networking.cs`) and captures `IconId`, `IconOverlayId` (weenieFlags `0x40000000`), `IconUnderlayId` (weenieFlags2 `0x01`). It reads `UiEffects` (weenieFlags `0x80`) but **discards it** — capturing it is part of this next phase. RestrictionDB skip is length-aware + tested. -- **`src/AcDream.Core/Items/ItemInstance.cs`** — has `IconId`, `IconUnderlayId`, `IconOverlayId`, `Type`. **No `Effects`/`UiEffects` field yet.** -- **`src/AcDream.Core/Items/ItemRepository.cs`** — `EnrichItem(objectId, iconId, name, type, iconOverlayId=0, iconUnderlayId=0)` writes the typed icon ids onto an existing item + fires `ItemPropertiesUpdated`. Threaded from `WorldSession.EntitySpawned` → `GameWindow.OnLiveEntitySpawned`. -- **`src/AcDream.App/UI/Layout/ToolbarController.cs`** — calls `iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId)` per slot, re-runs `Populate()` on `ItemRepository.ItemAdded`/`ItemPropertiesUpdated` (so a late `CreateObject` re-binds the slot's icon). -- **(related, not icon-composite)** the **slot-number** system (`SetShortcutNum`, 3 digit arrays: occupied peace/war `0x10000042`/`0x10000043` from cell composite `0x10000346`, empty/background `0x1000005e` from composite `0x10000341`) is done — it's a separate `UIElement_UIItem` feature, not the icon composite, but lives on the same widget. - ---- - -## 3. What's MISSING (the next session's work) - -1. **Layer 5 — the effect overlay (`_effects`).** Capture the item's `_effects`/`UiEffects` bitfield (CreateObject reads `UiEffects` at weenieFlags `0x80` but discards it — keep it; also it may be the appraise-only `PropertyInt.UiEffects`). Add an `Effects` field to `ItemInstance`. In `IconComposer`, resolve `GetByEnum(0x10000005, LowestSetBit(effects)+1)` (the second enum submap, `master[0x10000005]`) and composite it as the top layer. Widen `GetIcon` + the cache key to include effects. **This is the user's "mana vs out-of-mana" layer** and the most likely cause of the scroll's missing overlay (if its distinctive look is the effect glow, not a static `_iconOverlayID`). -2. **Layer 4 tint — `SurfaceWindow::ReplaceColor`.** The custom overlay is composited as a plain sprite; retail applies a per-pixel palette `ReplaceColor` tint (`407614`). Port the tint (it's a palette-index color replace — see `ACViewer TextureCache.IndexToColor` for the subpalette-overlay technique, though confirm it's the right op for icons). -3. **Appraise-driven enrichment + RE-COMPOSITION.** The icon must update when the item's icon-relevant properties change. `IdentifyObjectResponse` (`0x00C9`, `AppraiseInfoParser` / `GameEventWiring`) currently updates the `PropertyBundle` only — it does **not** update the typed `IconId/Overlay/Underlay/Effects`. Wire appraise → update those typed fields → `ItemPropertiesUpdated` → the bound widget re-resolves the icon (the cache key already changes when an id changes, so a new composite is produced). **This is the other likely cause of the scroll's blank overlay**: the overlay/effects ids may only arrive at appraise, not on the bare `CreateObject`. -4. **Settle the data-availability question (DO THIS FIRST — it's a 10-min capture).** Does ACE send `IconOverlay`/`UiEffects` on a *contained* (in-pack, un-appraised) item's `CreateObject`, or only at appraise? Capture the scroll's `0xF745 CreateObject` **and** its `0x00C9 IdentifyObjectResponse` with WireMCP (`mcp__wiremcp__*`, loopback `127.0.0.1:9000`) and log `CreateObject.Parsed.IconOverlayId/IconUnderlayId` at runtime. The answer decides whether the fix is "just build layer 5" (data already on CreateObject) or "build layer 5 + appraise enrichment" (data is appraise-gated). **Don't guess — capture.** -5. **The `IsThePlayer` container icon** (paperdoll) — `GetDIDByEnum(0x10000004, 7)` + `TYPE_CONTAINER`. Needed when the paperdoll renders the player's own icon. -6. **Identified-vs-unidentified does NOT swap the icon** (confirmed last session): appraise gates *tooltip* detail, not the base icon. So the icon layers come from the item's real props (sent on CreateObject and/or appraise), not an "identified" toggle. Don't add an appraise-gated icon variant. - ---- - -## 4. The user's framing (their words are the spec) - -> "the icon system in AC consists of several icons making up an icon. For example an item with mana has a different icon from the same item that is out of mana." - -Correct, and it maps exactly onto the model above: the **`_effects` bitfield** (and the underlay/overlay ids) reflect the item's current state, and `RenderIcons` composites the corresponding layers. "With mana vs out of mana" = the effect/underlay layers present vs absent → **the icon must re-compose when that state changes** (§3.3). Build the system so the displayed icon is always a function of the item's *current* properties, updated on every relevant property change. - ---- - -## 5. Research questions for the next session - -1. **`_effects` source + layout.** Is the icon effect bitfield the `CreateObject` `UiEffects` (weenieFlags `0x80`), the appraise `PropertyInt.UiEffects`, or both? What are its bit values (Magical/Enchanted/…)? (grep the decomp + ACE `PropertyInt`/`UiEffects` + `IconData::RenderIcons` `_effects` use at `407575`.) -2. **`master[0x10000005]` submap** — read it from the live dat (mirror the confirmed `0x10000004` resolve); enumerate its entries (index → effect-overlay `0x06` DID). Add a golden test like the underlay one. -3. **The `ReplaceColor` tint** — what color/palette does layer 4 tint with, and is it a straight palette-index replace? Cross-ref `SurfaceWindow::ReplaceColor` (decomp) + ACViewer. -4. **Appraise → icon fields** — exactly which `IdentifyObjectResponse` / `AppraiseInfo` fields carry `IconOverlay`/`IconUnderlay`/`UiEffects` (cross-ref ACE `AppraiseInfo` serialization + Chorizite). Wire them to update `ItemInstance` typed fields. -5. **Data-availability capture** (§3.4) — the WireMCP result for the scroll. -6. **Re-composition trigger** — confirm `ItemPropertiesUpdated` → widget re-resolve is sufficient (it is for the toolbar; verify the inventory/paperdoll widgets will subscribe the same way). - ---- - -## 6. References (cross-reference ≥2 per question) - -- **Named decomp** `docs/research/named-retail/acclient_2013_pseudo_c.txt`: `IconData::RenderIcons` (407524), `ACCWeenieObject::GetIconData` (408224), `DBCache::GetDIDFromEnum` (0x413940), `EnumIDMap::EnumToDID` (0x415970), `SurfaceWindow::ReplaceColor` (~407614). Headers: `acclient.h` (IconData / ACCWeenieObject struct). -- **This session's research** (the icon facts are anchored here): `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` (the 5-layer composite, the RenderSurface-direct decode), the D.5.1 spec/plan `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`. -- **ACE** `references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Networking.cs` (CreateObject field order), `.../Network/Structure/AppraiseInfo*.cs` (appraise fields), `ACE.Entity/Enum/PropertyInt.cs` (UiEffects). -- **ACViewer** `references/ACViewer/ACViewer/Render/TextureCache.cs` (IndexToColor / subpalette overlay) — for the layer-4 tint + icon decode. -- **Chorizite.ACProtocol** `.../Messages/` — PublicWeenieDesc + appraise field order. -- **DatReaderWriter** (nuget): `EnumIDMap` (DB_TYPE_DID_MAPPER), `RenderSurface`, `DatHeader.MasterMapId`. -- **D.2b memory crib**: `claude-memory/project_d2b_retail_ui.md` (the toolkit + the RenderSurface-vs-Surface decode gotcha; START-HERE for UI work). - ---- - -## 7. Files involved - -- `src/AcDream.App/UI/IconComposer.cs` — add the effect layer (`0x10000005`), the overlay tint, widen `GetIcon`/cache for effects. -- `src/AcDream.Core/Items/ItemInstance.cs` — add `Effects` (+ any other state fields the icon needs). -- `src/AcDream.Core.Net/Messages/CreateObject.cs` — capture `UiEffects` (already read, currently discarded) onto `Parsed`. -- `src/AcDream.Core.Net/WorldSession.cs` (`EntitySpawn` record) + `src/AcDream.App/Rendering/GameWindow.cs` (`OnLiveEntitySpawned`) — thread `UiEffects` through. -- `src/AcDream.Core/Items/ItemRepository.cs` — `EnrichItem` carry effects; **appraise enrichment** path. -- The appraise handler — `src/AcDream.Core.Net/GameEventWiring.cs` / `AppraiseInfoParser` — update typed icon fields on `0x00C9`. -- `src/AcDream.App/UI/UiItemSlot.cs` / `ToolbarController.cs` — already re-resolve on `ItemPropertiesUpdated`; no change expected (verify). - ---- - -## 8. New toolkit/API shape this introduces - -- **`IconComposer.GetIcon` becomes the single stateful icon entry point** — input is the item's full icon state `(ItemType, iconId, underlayId, overlayId, effects [, isPlayer])`; output is the composited GL texture; cache keyed by the full state tuple. Every item panel calls this. -- **`ItemInstance` carries the full icon state** (`IconId/Underlay/Overlay/Effects/Type`), updated from BOTH `CreateObject` and `Appraise`. -- **One re-composition contract**: any change to an item's icon state → `ItemRepository.ItemPropertiesUpdated` → bound `UiItemSlot` re-calls `GetIcon` (new state tuple → new composite). The toolbar already follows this; inventory/paperdoll reuse it. - ---- - -## 9. Related (separate) next toolbar work — NOT this handoff, but flagged - -The toolbar still needs **interactivity** beyond click-to-use (tracked separately in `docs/ISSUES.md`): -- It is the **selected-object display** — the two hidden meters (`0x100001A1` health / `0x100001A2` mana) + the stack slider (`0x100001A4`) + the object-name line show the object currently **selected in the world** (wire the B.4 `WorldPicker`/selection state → those elements). -- Click-to-use ✅ and peace/war stance indicator + slot-number recolor ✅ are done. -This is a distinct feature from the icon system; do the icon system first (it's the shared dependency). - ---- - -## 10. New-session prompt (paste into a fresh session) - -> Build the **FULL stateful item-icon system** for acdream (shared by inventory/equipment/vendor/trade — needed before those panels). **Read the handoff first: `docs/research/2026-06-17-stateful-icon-system-handoff.md`**, then `claude-memory/project_d2b_retail_ui.md` and `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`. -> -> The D.5.1 toolbar built layers 1–4 of the retail icon composite (`IconData::RenderIcons` @407524) + the `CreateObject` parse for base/overlay/underlay ids. **Missing:** the effect layer (`_effects` → `GetByEnum(0x10000005)`), the layer-4 `ReplaceColor` tint, and — critically — **appraise-driven enrichment + icon re-composition** (the overlay/effects ids likely arrive at `Appraise` (`0x00C9`), not on the bare `CreateObject`, which is why a pinned scroll shows no overlay). **First, settle the data-availability question with a WireMCP capture** of the scroll's CreateObject + IdentifyObjectResponse — don't guess. Then: capture `UiEffects` onto `ItemInstance`, read `master[0x10000005]` (mirror the working `0x10000004` underlay resolve), composite the effect layer + the overlay tint, and wire appraise → update the typed icon fields → re-compose. Follow the mandatory grep-named→cross-ref(ACE/ACViewer/Chorizite)→pseudocode→port workflow; conformance tests with golden dat values like the underlay test. The displayed icon must always be a function of the item's *current* state (the user's "item with mana vs out of mana" requirement). - ---- - -**MEMORY.md index line:** -- [Handoff: stateful item-icon system (2026-06-17)](research/2026-06-17-stateful-icon-system-handoff.md) — the full retail icon composite (`IconData::RenderIcons` @407524, 5 layers). D.5.1 built layers 1–4 + CreateObject parse (IconId/Overlay/Underlay) + the EnumIDMap `0x10000004` underlay resolve; MISSING = effect layer (`_effects`→`GetByEnum 0x10000005`, the "mana vs out-of-mana" layer), the overlay `ReplaceColor` tint, and appraise-driven enrichment+re-composition (overlay/effects likely arrive at Appraise 0x00C9, not bare CreateObject — capture with WireMCP first). Shared by inventory/equipment/vendor. diff --git a/docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md b/docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md deleted file mode 100644 index 7695a1b0..00000000 --- a/docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md +++ /dev/null @@ -1,239 +0,0 @@ -# Handoff — finish the action bar + start the inventory/paperdoll window - -**Date:** 2026-06-18 -**From:** the D.5.4 object/item-model session (SHIPPED `b506f53..6eb0fbde`, 2672 tests green, visually -confirmed on Barris/Coldeve). The data model is now solid — every server object lives in -`ClientObjectTable`, resolvable by guid. This handoff frames the NEXT work on the D.2b retail-UI track. -**Branch:** `claude/hopeful-maxwell-214a12` (kept, unmerged — carries D.5.2 + D.5.4). -**Line numbers below are as of HEAD `6eb0fbde` and WILL drift — grep the symbol, don't trust the line.** - ---- - -## 0. Scope (settled with the user) - -Three work streams. **The spell bar is explicitly DEFERRED** (it is a separate feature — a dedicated -spell-casting bar — NOT the action-bar spell *shortcuts*; do not build spell-glyph rendering/casting here). - -| Stream | What | Roadmap | -|---|---|---| -| **A. Selected-object meter** | The action bar's bottom strip: the player's currently-**selected** world object's Health/Mana meter + name (+ stack slider, deferred). Currently hidden. | D.5.3 (issue #140) | -| **B. Shortcut drag / add / reorder / remove** | Drag an item from the inventory window onto a hotbar slot; reorder slots; remove. The `AddShortcut`/`RemoveShortcut` wire. Item shortcuts already RENDER + click-to-use (D.5.1/D.5.4); this is the interactive management. | D.5.3 / D.5.5 | -| **C. Paperdoll + inventory window** | One combined window (`gmInventoryUI` nests paperdoll + backpack + 3D-items). It is the **drag SOURCE** that Stream B needs. | D.5.5 | - -**Out of scope:** the spell bar; the stack-split UI (entry box `0x100001A3` + slider `0x100001A4`); -the faithful Dragbar/Resizebar window resize (the IA-12 whole-window-drag approximation stays for now). - -**Dependency reality:** Stream B's drag-*from-inventory* needs Stream C (the inventory window) as the -drag source, and both B and C need the **drag-drop spine completed** (shared infra, §B.1). So this is -really 2-3 sub-phases — see the build order in §4. Each gets its own brainstorm → spec → plan. - ---- - -## 1. Read first - -- This doc. -- `docs/research/2026-06-16-ui-panels-synthesis.md` — **the build plan** for the core panels (build order, widget list, cross-panel wire table). Stream C follows it. -- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` — the drag-drop spine design (§5 pseudocode is the spec for Stream B's widget hooks). -- `docs/research/2026-06-16-inventory-deep-dive.md` + `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — the two panels' LayoutDesc maps + wire catalog. -- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` — `gmToolbarUI` shortcut model + the `HandleDropRelease` drag flags. -- `claude-memory/project_object_item_model.md` (D.5.4) + `claude-memory/project_d2b_retail_ui.md` (D.2b/D.5.1/D.5.2 toolkit). - -**Mandatory workflow** (CLAUDE.md): grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by -`class::method` → cross-ref ACE/holtburger → pseudocode → port. Conformance tests throughout. -The named-decomp anchors for each stream are inline below. - ---- - -## 2. Stream A — selected-object meter (the smallest, mostly self-contained) - -**Goal:** when the player selects a world object (LMB pick or Tab/Q combat-target), the action bar's -bottom strip shows that object's **Health meter** + **name**; **Mana meter** for owned items. - -**Retail lifecycle** (the oracle): `gmToolbarUI::HandleSelectionChanged` -(`acclient_2013_pseudo_c.txt:198635`) — on selection it `SetVisible(1)`s the right meter and fires -`CM_Combat::Event_QueryHealth(guid)` (creatures/players) or `CM_Item::Event_QueryItemMana(guid)` -(owned items). The server replies `UpdateHealth (0x01C0)` / `UpdateItemMana`, and -`RecvNotice_UpdateObjectHealth` (`:196213`) / `RecvNotice_UpdateItemMana` (`:196188`) call -`SetAttribute_Float(meter, 0x69, pct)` — **property `0x69` is the fill ratio**. `UIElement_Meter`'s -fill element is child id `2` (`UIElement_Meter::Initialize :123328`; `OnSetAttribute :123712`). -Mana is gated on `IsOwnedByPlayer` (`:198763`). - -**LayoutDesc elements** (toolbar `0x21000016`, `.layout-dumps/toolbar-0x21000016.txt:621-811`): -container `0x1000019E`; name text `0x1000019F` (Type 0) + state overlay `0x100001A0` -(states `ObjectSelected 0x06001937` / `StackedItemSelected 0x06004CF4`); **health meter `0x100001A1`** -(Type 7); **mana meter `0x100001A2`** (Type 7); stack entry `0x100001A3`; stack slider `0x100001A4` -(Type 11). All currently in `ToolbarController` `HiddenIds` (~`ToolbarController.cs:41`), -`SetVisible(false)` at Bind (~`:100`). - -**Work items:** -1. **Fix the meter render bug** (the launch-log `meter 0x100001A1/A2: 1 Type-3 slice container - (expected 2)` warning). `DatWidgetFactory.BuildMeter` (~`DatWidgetFactory.cs:135-154`) assumes 2 - Type-3 slice containers (back + fill). The toolbar meters have **1** container (the fill, child - id `0x00000002`); the **back-track sprite is on the meter element's own DirectState** - (e.g. health `0x0600193E`). Fix `BuildMeter` to detect the 1-container case and read the back - track from the element's `StateMedia[""]`, fill from the child. (Vitals meters `0x2100006C` have 2 - containers and work — use them as the contrast.) -2. **`SelectedObjectController`** (analogue of `VitalsController` — see the working bind pattern at - `VitalsController.cs:61-97`): on selection-change, `SetVisible(true)` on `0x100001A1`(/`A2` for owned - items), bind `UiMeter.Fill` to `() => combat.GetHealthPercent(selGuid)`, bind the name text - `0x1000019F` to `ClientObjectTable.Get(selGuid)?.Name`, set the `0x100001A0` overlay state; on - deselect `SetVisible(false)`. -3. **Selection notification:** there is no `SelectionChanged` event today — `_selectedGuid` is a raw - `uint?` on `GameWindow` (~`GameWindow.cs:844`), written by `PickAndStoreSelection` (LMB) and - `SelectClosestCombatTarget` (Tab/Q), cleared on despawn. Either add an event or poll-and-diff a - `Func` (the `TargetIndicatorPanel` pattern). **Brainstorm: event vs poll.** -4. **Health is ready:** `CombatState.GetHealthPercent(guid)` + `CombatState.HealthChanged` - (`CombatState.cs:92,45`), wired from `UpdateHealth 0x01C0` (`GameEventWiring.cs:155`). - To force a fresh value on selection, retail sends `QueryHealth` — `SocialActions.BuildQueryHealth` - (0x01BF) already exists (`SocialActions.cs:49`). **Brainstorm: send QueryHealth on select, or rely - on server broadcasts for now?** -5. **Mana is NOT ready** (the harder half): no remote-target mana anywhere (`CombatState` is - health-only; `LocalPlayerState.ManaPercent` is self-only). `QueryItemManaResponse (0x0264)` is - *parsed* (`GameEvents.cs:416`) but **unregistered** in `GameEventWiring`, and there is **no - outbound `QueryItemMana` builder** (its C→S opcode is unknown — `0x0264` is the reply). - **Brainstorm/decide: defer mana entirely for D.5.3 (health-only, matching that mana is owned-item-only - anyway), or do the full mana path?** Recommend deferring mana → ship health-meter + name first. -6. **Stack slider/entry (`0x100001A3/A4`):** deferred (stack-split UI). - -**Why A is mostly standalone:** it doesn't need the drag-drop spine, the window manager, or the -inventory window. It's the quickest win and finishes the bar's *display*. Good first chunk. - ---- - -## 3. Stream B — shortcut drag / add / reorder / remove - -**Item shortcuts already render + click-to-use** (D.5.1 + D.5.4). This stream is the interactive -management: drag an item from inventory onto a slot, reorder, remove. - -### B.1 — the drag-drop spine (SHARED infra, also needed by Stream C) -`UiRoot` has the **complete** retail drag state machine, LIVE-wired to Silk.NET input: -`BeginDrag`/`UpdateDragHover`/`FinishDrag` firing `DragBegin 0x15`/`DragEnter 0x21`/`DragOver 0x1C`/ -`DropReleased 0x3E` (`UiRoot.cs:450-496`), promoted on >3px move, bridged via `UiHost.WireMouse` -(`UiHost.cs:78-88`, called at `GameWindow.cs:1769`). **But:** -- `BeginDrag` always passes `payload: null` (`UiRoot.cs:188`); `DragPayload` has a private setter - (`UiRoot.cs:73`) → needs a `SetDragPayload(object)` escape hatch (or a source-payload callback). -- `UiItemSlot.OnEvent` handles only `MouseDown→Clicked` (`UiItemSlot.cs:101-105`) — **no - DragBegin/DragEnter/DragOver/DropReleased cases**. (`UiItemSlot.ItemId` `:19` is the payload source.) -- `UiField`'s `CatchDroppedItem`/`MouseOverTop` are **doc-comment only** (`UiField.cs:10-11`) — the - bodies belong on `UiItemSlot`, per the spine doc §5.6. -- No `IItemListDragHandler` interface exists; no drag ghost renderer; no `InqDropIconInfo` helper. - -**Build (spine doc §5.7 is the spec):** (1) payload injection in `UiItemSlot` on DragBegin -(`{objId=ItemId, srcContainer, srcSlot}`); (2) a cursor-following **drag ghost** (the icon is already -in `UiItemSlot.IconTexture`); (3) drop-target hooks on `UiItemSlot` (DragEnter/Over→accept/reject -overlay `0x10000041`/`0x10000040`/`0x1000003f`; DropReleased→`HandleDropRelease`); (4) -`IItemListDragHandler { bool OnDragOver(...); void HandleDropRelease(...) }` that panels implement + -register on their `UiItemList`. - -### B.2 — the shortcut model + wire -- **Mutable store missing.** Shortcuts are a **read-only** `IReadOnlyList` - (`GameWindow.Shortcuts ~:600`, set once from PlayerDescription via `onShortcuts` at - `GameEventWiring.cs:415`). Port retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`) as a - small mutable `ShortcutStore` (18 slots; `Load`/`AddOrReplace(slot,guid)→displaced`/`Remove(slot)`). -- **Wire builders exist with a naming bug.** `InventoryActions.BuildAddShortcut` (0x019C, - `InventoryActions.cs:99`) — param `objectType` should be `objectGuid`; the trailing field is packed - `spellId(u16)|layer(u16)` (0 for items). Byte layout is already correct for item-only callers; **fix - the names before wiring.** Field order confirmed by ACE `Shortcut.cs:33`, holtburger - `shortcuts.rs:37`, retail `ShortCutData` `acclient.h:36484`. `BuildRemoveShortcut` (0x019D) is fine. -- **No `SendAddShortcut`/`SendRemoveShortcut` on `WorldSession`** — wrap the builders (pattern = - `SendChangeCombatMode`: `NextGameActionSequence()` + `Build*()` + `SendGameAction()`, `:1064`). -- **Drop flow** (retail `gmToolbarUI::HandleDropRelease :197971`): `InqDropIconInfo` flags - `&0xE==0` = fresh-from-inventory (place), `&4` = reorder. On drop: remove target if occupied (0x019D) - → update store → add (0x019C) → `Populate()`. Reorder also puts the displaced item back in the source - slot. `ToolbarController` implements `IItemListDragHandler` + gets `Action`s for the two sends. - -**Reorder-within-bar needs no inventory; drag-from-inventory needs Stream C.** - ---- - -## 4. Stream C — paperdoll + inventory window (one window) - -**The design is already written — follow `2026-06-16-ui-panels-synthesis.md` §4.** This section is the -**current-code readiness** + what's missing. Don't re-derive the design. - -**READY (post-D.5.1/D.5.4):** `UiItemSlot` + `UiItemList` + `IconComposer` (`src/AcDream.App/UI/`), -`DatWidgetFactory` registers `0x10000031→UiItemList` (`:70`); the data path is -`ClientObjectTable.GetContents(containerGuid)` → ordered guids → `Get(guid)` → full icon fields -(`ClientObjectTable.cs:273,188`). The toolkit + data model are in place. - -**MISSING (the build, in synthesis order):** -1. **Window manager** (deferred Plan-2): open/close/z-order/persist. Today every window is **always-on - at a hardcoded position** (`ACDREAM_RETAIL_UI=1`, `GameWindow.cs:1906`); `UiHost` has no - open/close API (`UiHost.cs:37`). Needs at minimum an **`I`-key toggle** to open/close the inventory - window. (Faithful Dragbar/Resizebar resize stays deferred — IA-12 whole-window-drag is fine.) -2. **`UiItemList` N-cell grid mode** — currently single-cell (`UiItemList.cs:12`, only sizes - `_cells[0]`); `Flush`/`AddItem` skeleton exists but no column-count/pitch/wrap (LIKELY 6 cols × 36px; - confirm from `UIElement_ItemList::ItemList_AddItem`). -3. **Sub-window mount in `LayoutImporter`** — `gmInventoryUI` (`0x21000023`) nests paperdoll - (`0x21000024`), backpack (`0x21000022`), 3D-items (`0x21000021`) as child elements whose class id - has its own `BaseLayoutId`. The importer only does TEMPLATE inheritance today - (`LayoutImporter.cs:196-228`) — it has never instantiated a nested `gm*UI` window. New capability. -4. **Wire gaps** (inventory deep-dive §4.3): builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`, - `NoLongerViewingContents 0x0195` (all absent); parsers `ViewContents 0x0196`, `SetStackSize 0x0197`, - `InventoryRemoveObject` (all absent); fix `ParsePutObjInContainer` (drops the 4th `containerType`, - `GameEvents.cs:352`) + `ParseInventoryServerSaveFailed` (drops `weenieError`, `:377`); register - `ViewContents`/`0x019A`/`0x0052`/`0x00A0` in `GameEventWiring`. -5. **`UiViewport` (Type 0xD)** for the paperdoll 3D doll — **the single biggest new piece.** No widget, - no factory registration, no renderer. Needs an `IUiViewportRenderer` **Core→App seam** (Rule 2) for a - scissored single-entity GL pass. The doll is the local player's ObjDesc-dressed entity in a fixed - viewport. **Heavy — brainstorm separately (see §5 open questions).** -6. **`InventoryController` + `PaperDollController`** (the `gm*UI::PostInit` find-by-id pattern): - backpack burden Meter (`SetLoadLevel`→fill `0x69`), own-pack list + side-pack list, the - element-id→`EquipMask` map for paperdoll slots, `ObjDescEvent 0xF625` → re-dress. - ---- - -## 5. Recommended build order + the dependency graph - -This spans **2-3 sub-phases**. Suggested sequence (each its own brainstorm → spec → plan): - -1. **D.5.3a — selected-object meter** (Stream A). Standalone, quickest, finishes the bar's display. - No spine/window-manager dependency. Recommend health-meter + name first; defer mana. -2. **Drag-drop spine completion** (§B.1) — shared infra for B and C. Build once. -3. **Window manager (open/close)** (§C.1) — enough to toggle the inventory window open. -4. **D.5.5 — inventory window** (§C, grid + sub-window mount + wire gaps + `InventoryController`). - This gives the drag **source**. -5. **D.5.3b — shortcut drag-to-add/reorder/remove** (Stream B) — now that the spine + inventory source - + `ShortcutStore` + the `BuildAddShortcut` fix are in place. (Reorder-within-bar could land earlier - with just steps 2 + the store.) -6. **Paperdoll** (`UiViewport` + `PaperDollController`, §C.5/6) — the 3D doll, the heaviest piece. - -**Critical-path note:** the drag-drop spine (step 2) is the lynchpin — both shortcut drag and inventory -drag depend on it. Do it early and well (it has its own spine deep-dive as the spec). - ---- - -## 6. Open questions for the brainstorm(s) - -- **A:** SelectionChanged event vs poll-and-diff? Send `QueryHealth (0x01BF)` on select, or rely on - server broadcasts? Defer mana (health-only) for D.5.3 — confirm. The meter render-bug fix: - back-track from the element's own DirectState — verify the sprite ids (`0x0600193E` health) against the - dump. -- **B:** `DragPayload` shape (a `record ItemDragPayload(objId, srcContainer, srcSlot, flags)` vs the - slot itself)? Where does the drag ghost render (UiRoot.OnDraw vs UiItemSlot overlay)? Is `UiItemList` - or `UiItemSlot` the drop-target unit? Fire-and-forget vs optimistic-then-confirm for the shortcut wire? -- **C:** Sub-window mount — recursive `Import()` in `LayoutImporter`, or external stitch by the - controller? Inventory grid column count (confirm 6 from decomp)? Does the paperdoll doll clone the - player `WorldEntity` or build a fresh ObjDesc-dressed `AnimatedEntityState` (player = camera, so there's - no player-as-renderable today)? `IUiViewportRenderer` timing (post-world pass vs pre-pass)? Open the - inventory by `I`-key only, or also the toolbar's inventory button? - ---- - -## 7. ⚠ Corrections to the grounding research (verify against source) - -- **`_liveEntityInfoByGuid` is GONE** (retired in D.5.4 Task 10, `a9d40ad`). A research agent's notes - reference it as the selected-object name source at `GameWindow.cs:835/2559/12129` — **stale.** - Post-D.5.4 the name resolves via `ClientObjectTable.Get(guid)?.Name`, or the `GameWindow.LiveName(guid)` - / `DescribeLiveEntity(guid)` helpers (which now read the table). Likewise "`ClientObjectTable` does not - exist yet" is wrong — it shipped in D.5.4. Trust the table, not the dict. -- **Line numbers throughout drift** (D.5.4 removed ~75 lines from `GameWindow`). Grep the symbol. - ---- - -## 8. New-session prompt (paste into a fresh session) - -> Continue acdream's D.2b retail-UI track. **Read `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` first**, then the 2026-06-16 UI deep-dives it references. Three work streams (spell bar DEFERRED — it is a separate feature, not the action-bar spell shortcuts): **(A)** the action bar's selected-object meter (Health + name; mana deferred — issue #140); **(B)** shortcut drag/add/reorder/remove (the `AddShortcut 0x019C`/`RemoveShortcut 0x019D` wire + the drag-drop spine completion; item shortcuts already render+click); **(C)** start the paperdoll+inventory window (one window — `gmInventoryUI` nests paperdoll/backpack/3D-items). The drag-drop spine (UiRoot has the machine; UiItemSlot lacks the hooks) is shared infra for B and C — build it early. Suggested order: A (standalone quick win) → drag-drop spine → window manager (open/close) → inventory window → shortcut drag → paperdoll (UiViewport). Use the full brainstorm → spec → plan → subagent-driven flow per stream; mandatory grep-named→cross-ref→pseudocode→port for any wire format; conformance tests throughout. Data model is solid post-D.5.4: resolve every object via `ClientObjectTable.Get(guid)` / `GetContents(containerGuid)`. Branch `claude/hopeful-maxwell-214a12` (kept, unmerged). - -**MEMORY.md index line:** -- [Handoff: finish the bar + inventory/paperdoll window (2026-06-18)](research/2026-06-18-d53-bar-finish-and-inventory-handoff.md) — next D.2b-UI work after D.5.4. 3 streams (spell bar DEFERRED): (A) selected-object meter (health+name, mana deferred; fix DatWidgetFactory 1-slice-container meter bug; SelectedObjectController like VitalsController), (B) shortcut drag/add/reorder/remove (UiRoot has the drag machine, UiItemSlot lacks hooks; mutable ShortcutStore missing; BuildAddShortcut naming bug), (C) inventory+paperdoll window (needs window-manager open/close + UiItemList grid mode + sub-window mount + wire gaps + UiViewport). Build order + per-stream anchors + brainstorm questions inside. ⚠ _liveEntityInfoByGuid is GONE (D.5.4) — name via ClientObjectTable.Get. diff --git a/docs/research/2026-06-18-item-object-model-handoff.md b/docs/research/2026-06-18-item-object-model-handoff.md deleted file mode 100644 index 63fd4ce6..00000000 --- a/docs/research/2026-06-18-item-object-model-handoff.md +++ /dev/null @@ -1,120 +0,0 @@ -# Handoff — the client object/item data model (next phase, post-D.5.2) - -**Date:** 2026-06-18 -**From:** the D.5.2 stateful-icon session (icon system SHIPPED + visually confirmed on a -live Coldeve server). This handoff frames the NEXT phase: the real item/object data model. -**Status of this work:** branch `claude/hopeful-maxwell-214a12` (kept, not merged). D.5.2 is -complete: `52306d9..fb288ad`. - ---- - -## 0. Why this phase exists (the root cause we uncovered) - -Visual-verifying D.5.2 on a live server (character **Barris** on Coldeve) showed **4 of 6 -hotbar items render no icon**. The diagnostic (`icon-dump.txt`, since removed) proved the -cause: those items are **`NOT-ENRICHED`** — `ItemRepository.GetItem(guid)` returns null -because their `CreateObject` was **dropped**. - -The mechanism is acdream's **scaffold item model**: -- `EnrichItem` is **enrich-existing-only**: it updates an item ONLY if it was already seeded - as a stub (from `PlayerDescription` at login). A `CreateObject` for an item with no - pre-existing stub is silently discarded (the toolbar handoff called this out: - *"new-item ingestion is the inventory phase"*). -- So only items in the login seed set get icons; everything else (most pack contents) falls - on the floor. The 2 that showed (Energy Crystal, Revenant's Scythe) are wielded items the - server announces up front. - -This is **NOT a D.5.2 bug** (the icon composite is correct for every item that reaches it — -confirmed: Energy Crystal's Magical gradient tint + the no-mana scroll's black edges both -match retail). It is the **item/object data model** being a placeholder. - -## 1. The retail model to port (the oracle) - -Retail has **one master object table** — `ClientObjMaintSystem` — and **`CreateObject` is the -canonical create/update for every object** (item, creature, player). The UI never owns item -data: a hotbar slot, an inventory cell, a paperdoll slot, a vendor cell all hold a `guid` and -resolve it live via `ClientObjMaintSystem::GetWeenieObject(guid)`. (Confirmed in our spine -research: *"the cell never holds item data — it holds an itemID and resolves it live."*) - -acdream **inverted** this: login snapshot = source of truth, `CreateObject` = second-class -enrich. The fix is to flip it: **`CreateObject` is the authoritative ingestion**; -`PlayerDescription` / `ViewContents (0x0196)` / shortcuts become **references + supplementary -data**, not the primary seed. Every object the server tells us about is tracked; the UI -resolves by guid. - -## 2. THE crux design question (settle this FIRST in the brainstorm) - -acdream currently has **two object tracks**: -- the **WorldEntity** system (3D creatures / players / world items, fed by `CreateObject` → - `GameWindow.OnLiveEntitySpawned` → `WorldEntity`), and -- the **ItemRepository** (inventory items, `src/AcDream.Core/Items/`). - -Retail unifies these under one `ClientObjMaintSystem` (every object is an `ACCWeenieObject`). -**Decision to make:** unify acdream into ONE object table (retail shape), or keep the two -tracks with a shared ingestion seam? This choice drives everything downstream (inventory, -equipment/paperdoll, vendor, trade all resolve items from whatever table wins). Think it -through up front — don't discover it halfway in. - -## 3. Sources that feed the model (the ingestion surface to design around) - -| Wire message | Role | -|---|---| -| `CreateObject (0xF745)` | **canonical** object create/update (full weenie: icon/name/type/stack/container/wielder/effects/…) | -| `DeleteObject (0xF747)` | remove | -| `PlayerDescription (0x0013)` | login snapshot: inventory + equipped + shortcuts (references; some props) | -| `ViewContents (0x0196)` | a container's `{guid, slot}` list when opened | -| move events `0x0019/1A/1B`, `0x0022/23`, `0x019A` | re-parent (container/wield/3D) | -| `Public/PrivateUpdateProperty* (0x02CD–0x02DA)` | per-property live updates (D.5.2 wired `0x02CE` UiEffects → icon) | -| `InventoryServerSaveFailed (0x00A0)` | speculative-move rollback | - -## 4. Grounding research (already written — read before the brainstorm) - -- `docs/research/2026-06-16-inventory-deep-dive.md` — inventory panel + the wire catalog -- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` — `ClientObjMaintSystem`, - `ServerSaysMoveItem`, the resolve-by-guid model -- `docs/research/deepdives/r06-items-inventory.md` — the item/container property model -- `docs/research/2026-06-16-ui-panels-synthesis.md` — core-panels build order (item-model is - the prerequisite for the inventory panel) -- `claude-memory/project_d2b_retail_ui.md` — D.2b/D.5.1/D.5.2 state -- `claude-memory/feedback_weenie_vs_static.md` — items are server weenies, not dat-baked - -## 5. Recommended approach - -Full process (the user values it): **brainstorm → spec → plan → subagent implementation.** -Open the brainstorm on **the unify-vs-separate question (§2) first**, then the ingestion -lifecycle (§3), then how the UI (toolbar/inventory/paperdoll) binds by guid. This is the -foundation the remaining D.5 core panels sit on — get it solid. - -NOTE the user's standing constraint for this phase: *"No quick fixes — needs to be -architecturally solid and thought through."* Do not band-aid `EnrichItem` to add new items; -design the model properly. - -## 6. New-session prompt (paste into a fresh session) - -> Design and build acdream's **client object/item data model** — the foundation under the D.5 -> core panels (inventory, equipment/paperdoll, vendor, trade). This is roadmap **D.5.4** and it -> blocks D.5.5+. **Read this handoff first: `docs/research/2026-06-18-item-object-model-handoff.md`**, -> then `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` and -> `docs/research/2026-06-16-inventory-deep-dive.md`. -> -> The problem (confirmed live on Coldeve, character Barris): acdream's item model is -> **enrich-existing-only** — `ItemRepository.EnrichItem` only fills items pre-seeded as stubs -> from `PlayerDescription`, and DROPS `CreateObject`s for anything else, so most hotbar/pack -> items render no icon (4 of 6 hotbar slots were blank). Port retail's `ClientObjMaintSystem`: -> **`CreateObject` is the canonical object create/update**, `PlayerDescription`/`ViewContents`/ -> shortcuts become references, and the UI resolves items by guid. This is NOT a D.5.2 icon bug -> (the composite is correct for every item that reaches it). -> -> **Do this as a proper design — the user's standing constraint is "architecturally solid, no -> quick fixes" (do NOT band-aid `EnrichItem` to add new items).** Use the full -> brainstorm → spec → plan → subagent-driven-development flow. **Open the brainstorm by settling -> the crux FIRST (§2): unify acdream's two object tracks — the `WorldEntity` 3D system (fed by -> `GameWindow.OnLiveEntitySpawned`) and `ItemRepository` — into ONE object table like retail, or -> keep them separate with a shared ingestion seam?** Then the ingestion lifecycle (§3 wire -> surface) and how the toolbar/inventory/paperdoll bind by guid. Follow the mandatory -> grep-named→cross-ref→pseudocode→port workflow for any AC-specific wire format; conformance -> tests throughout. Work continues on branch `claude/hopeful-maxwell-214a12` (kept, unmerged; -> D.5.2 = `52306d9..fb288ad`). - -**MEMORY.md index line:** -- [Handoff: client object/item data model (2026-06-18)](research/2026-06-18-item-object-model-handoff.md) — next phase after D.5.2. Root cause of the live-Coldeve "4/6 hotbar items missing": acdream's item model is enrich-existing-only (drops CreateObjects without a pre-seeded stub). Fix = port retail's `ClientObjMaintSystem` (CreateObject = canonical ingestion; UI resolves by guid). CRUX to settle first: unify the WorldEntity + ItemRepository tracks into one object table, or keep separate w/ shared ingestion? Grounding research + ingestion surface listed. User constraint: architecturally solid, no quick fixes. diff --git a/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md b/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md deleted file mode 100644 index 381860de..00000000 --- a/docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md +++ /dev/null @@ -1,140 +0,0 @@ -# A7 Lighting — Fix A/B/C SHIPPED, Fix D (object torch over-brightness) HANDOFF - -**Date:** 2026-06-18 **Branch:** claude/thirsty-goldberg-51bb9b (merged to main) -**Companion memory:** `claude-memory/reference_retail_ambient_values.md` (all captured -values + cdb recipes) and `reference_retail_chat_colors.md` (cdb method). - -This session made acdream's outdoor + ambient lighting retail-faithful by grounding -everything in **live cdb on the retail client** (no guessing). Three fixes shipped; -a fourth (Fix D — outdoor objects too bright near torches) is fully grounded but -**deliberately NOT implemented** because the math contradicts the observed result — -one more capture is needed first. - -## SHIPPED this session (all on `main`) - -| Fix | Commit | What | Result | -|---|---|---|---| -| **A** | `aa94ced` | point-light SHAPE: per-vertex Gouraud + faithful `calc_point_light` (wrap + norm), per-channel cap | killed the "spotlight" disc — user "way better" | -| **B** | `4345e77` | per-OBJECT light selection (`minimize_object_lighting`): each object picks its own ≤8 lights by its AABB sphere, camera-independent | killed "building lights up as you approach"; a Holtburg view has **129** point lights vs the old global cap of 8 | -| **C** | `57c1135` | sun-vector magnitude: ambient + sun were **~32% too bright** | ambient now matches retail within ~2%; user "general ambient better outside" | - -**Fix B mechanism** (for context): two new SSBOs in `mesh_modern.vert` — binding=4 -GLOBAL light array (`LightManager.PointSnapshot`), binding=5 per-instance 8-int -light set (mirrors the U.3 clip-slot SSBO). `LightManager.SelectForObject` + -`BuildPointLightSnapshot` (pure, TDD). `WbDrawDispatcher` computes each entity's -light set once per entity (like `_currentEntitySlot`), threads it parallel to the -matrices. - -**Fix C mechanism:** `SkyStateProvider.RetailSunVector` had `y = cos(P)` (≈1) — the -PRE-transform value `SkyDesc::GetLighting` writes to its arg5 (0x00500ac9), before -`LScape::set_sky_position`'s world transform. cdb read retail's actual -`LScape::sunlight = (0.2238, ~0, 0.00352)`, magnitude = DirBright. Corrected to the -world-space spherical form `DirBright × (cos P·sin H, cos P·cos H, sin P)`, -`|sunVec| == DirBright`. Feeds BOTH the ambient boost AND the sun colour, so it -dims **terrain + objects + sky** (all read the shared SceneLighting UBO). 18/18 sky -tests green (old tests pinned the inflated magnitude — updated to cdb-verified). - -## KEY LESSON: the "too purple" was NEVER a bug - -The user's side-by-side ("acdream too purple, retail neutral") was a comparison -**across different times of day**. Live cdb at the SAME game time + DayGroup proved -acdream's time, weather (DayGroup selection), AND ambient COLOR all match retail -exactly — the purple `AmbColor=(200,100,255)` is authored per-time-of-day in the -sky dat (twilight = purple, midday = neutral `(230,230,255)`). Only the *brightness* -was wrong (Fix C). Don't re-investigate the purple. - ---- - -## RESOLVED — Fix D: outdoor walls too bright near torches (contradiction settled 2026-06-18) - -**Symptom (user):** Holtburg meeting-hall walls blow out **warm**/bright in acdream -vs dim in retail. The contradiction ("D3D-FF math says color×100 should blow WHITE, -yet retail is DIM") is **resolved**: the D3D-FF model was the WRONG ORACLE for these -walls. Settled by a 5-thread decomp workflow (`wf_f660eb88`) + adversarial verify + -4 live cdb captures. **⚠ The "DO NOT port the D3D-FF model" warning still stands** — -not because it'd be too bright, but because it's the wrong path entirely. - -### Render path (Ghidra xrefs — unambiguous, two SEPARATE light systems) -- **STATIC lights → CPU vertex BAKE.** `RenderDeviceD3D::DrawEnvCell` (0x0059F170) → - `D3DPolyRender::SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light` - (0x0059C8B0, its SOLE caller). Wall torches are STATIC objects → baked into vertex - colours. AC town buildings are EnvCell structures, so their walls take this path. -- **DYNAMIC lights → D3D hardware FF.** `add_dynamic_light` → `insert_light` (0x0054D1B0) - → `config_hardware_light` (0x0059AD30); `minimize_envcell_lighting` (0x0054C170) - enables ONLY the dynamic subset (class 2) for the cell — statics are NEVER hardware- - enabled for the cell. (`minimize_object_lighting` 0x0054D480 enables both, for free - GfxObjs.) So `config_hardware_light` — where last session's `intensity=100` was seen — - carries DYNAMIC lights for cells, not the wall torches. - -### Why retail stays warm-but-DIM (the bake is triple-clamped — `calc_point_light`) -Per light: `range = falloff×1.3` hard gate; half-Lambert wrap `(1/1.5)(N·D + 0.5·d)`; -`norm = (distsq>1)? distsq·d : d` (~1/d²); `scale = (1−d/range)·intensity·(wrap/norm)`; -then the **decisive per-channel cap `result = min(scale·color, color)`** — one light adds -**at most its own (sub-1.0, warm) colour**, however large intensity is. Caller sums from -**BLACK** (no ambient/sun in the accumulator) over all static lights, then **clamps the -sum to [0,1]** per channel before packing `vertex.diffuse`. White needs many in-range -lights stacking past 1.0; a hall has a handful, each warm-capped. - -### Live cdb ground truth (4 captures; scripts in `tools/cdb/a7-fixd-*.cdb`) -`Render::world_lights` @ **0x008672a0** (LightParms): `num_static_lights` @ +0x104, -`sorted_static_lights[]` (RenderLight*, info @ RL+0x70) @ +0x3498, `num_dynamic_lights` -@ +0x3588. Captured standing in Holtburg: -- **num_static_lights = 38**, **num_dynamic_lights = 2.** -- **2 DYNAMIC** (`add_dynamic_light`, d3dIdx 1–2): viewer light `intensity=2.25 falloff=10 - color=(1,1,1)` white; **PORTAL** `intensity=100 falloff=6 color=(0.784,0,0.784)` MAGENTA. - → **the `intensity=100` light is the purple PORTAL (dynamic/hardware), NOT a wall torch.** -- **38 STATIC** wall torches, all `type=0 intensity=100`, **WARM**: orange - `(1.0, 0.588, 0.314)` falloff 4, and cream `(0.980, 0.843, 0.612)` falloff 3–5 - (→ bake range ~3.9–6.5 m). Torches DO carry intensity=100, but the per-channel cap - pins each to its warm colour ⇒ retail walls go warm, not white. - -### acdream's actual bug — TWO real causes (both verified in source) -- **D-1 (math, primary): unclamped accumulator folding ambient+sun+torches.** - `mesh_modern.vert` `accumulateLights` starts `lit = uCellAmbient.xyz` (:184), ADDS - sun (:196), ADDS each capped torch (:206), returns UNCLAMPED (:208); the ONLY clamp is - one `min(lit,1.0)` in `mesh_modern.frag:92` AFTER a lightning bump (:89). The per-light - cap (:180) IS faithful. But pouring ambient + sun + up-to-8 intensity-100 WARM torches - into ONE bucket and trimming only at the end overflows to warm-white. Retail clamps the - torch sum on its OWN (from black); ambient/sun are a separate term. -- **D-2 (state, compounding): EnvCell shell SSBO binding leak.** - `EnvCellRenderer.cs:1225-1230` (RenderModernMDIInternal) binds SSBO 0/1/2/3 only, NEVER - 4 (`gLights`) or 5 (`instanceLightIdx`) — which the shared `mesh_modern.vert` reads at - :204-206. Only `WbDrawDispatcher` binds 4/5. Indoor DrawInside interleaves the two, so a - cell shell reads whatever LEAKED light set the last WbDrawDispatcher draw left bound — - a different entity's torches, wrong per-instance indices ⇒ wrong/over-bright walls. -- `LightBake.cs` (verbatim CPU port) exists but is UNWIRED (zero callers); the live path is - the in-shader version missing the clamp shape. - -### Fix plan (REPORT-ONLY — implement in a separate session, with the no-workaround rule) -- **D-1:** accumulate point/spot into a LOCAL `pointAcc`, `saturate` it to [0,1] BEFORE - adding ambient + sun — mirrors `SetStaticLightingVertexColors` (sum-from-black, clamp the - point sum). Keep the per-light `min(scale·baseCol, baseCol)` (vert:180). Files: - `mesh_modern.vert` (split accumulator + clamp), `mesh_modern.frag` (reorder/drop the - single clamp). Conformance golden: a wall ≤~5 m from an orange `(1,0.588,0.314)` torch - bakes warm-but-≤[0,1], NOT white. -- **D-2:** EnvCell shell must bind binding 4 (global lights) + 5 (per-instance light set) - for ITS OWN instances — compute a per-shell set like `WbDrawDispatcher.ComputeEntityLightSet` - (LightManager.SelectForObject); option (b) all-(-1) fallback = NO point lights is a STOPGAP - (needs approval + a divergence-register row). File: `EnvCellRenderer.cs` RenderModernMDIInternal. -- **Stale doc to fix in the D-1 commit:** divergence-register `AP-35` still describes the - point-light path as per-pixel `mesh_modern.frag:52` with the wrap "NOT ported"; Fix A - (`aa94ced`) moved it to per-vertex `mesh_modern.vert:163` WITH the wrap. -- **Do NOT port the D3D-FF hardware model for the walls** (config_hardware_light's - color×intensity / (0,1,0)=1/d / Range=falloff×1.5) — it lights GfxObjs/dynamics, not the - baked walls. - ---- - -## cdb cheat-sheet (all verified this session; binary MATCHES refs/acclient.pdb) -- `bp acclient!SmartBox::SetWorldAmbientLight` (0x004530a0) — arg2=level `[esp+4]`, arg3=color32 `[esp+8]` -- `bp acclient!SkyDesc::GetLighting` (0x00500a80) — arg2=dayFraction `[esp+4]`; `dt acclient!SkyDesc @ecx present_day_group` -- `LScape::sunlight` global @ **0x00841940** (Vector3); `LScape::ambient_level` @ 0x00841770 -- `bp acclient!PrimD3DRender::config_hardware_light` (0x0059ad30) — arg4=LIGHTINFO `[esp+0x10]`; `dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color` -- `rangeAdjust = 1.5` @ 0x00820cc4; `D3DPolyRender::SetStaticLightingVertexColors` @ 0x0059cfe0 -- Pattern: `.formats poi()` for floats, `dwo()` for dwords, `qd` after N hits to auto-detach (keeps retail alive). User must have retail in-world first. -- acdream probes: `ACDREAM_PROBE_LIGHT=1` (`[light]` ambient+sun line), `ACDREAM_DUMP_SKY=1` (keyframes + dayFraction + DayGroup). - -## Build / run -`dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` (green). Standard -`ACDREAM_LIVE` launch env in CLAUDE.md. Close the client before rebuilding (it locks -the DLLs). 18/18 sky tests + 17/17 LightManager + 36/36 dispatcher clip-slot green. diff --git a/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md b/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md deleted file mode 100644 index e221a636..00000000 --- a/docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md +++ /dev/null @@ -1,152 +0,0 @@ -# A7 Fix D round 2 — REAL cause found (object sun+ambient + torch REACH), CHECKPOINT - -**Date:** 2026-06-19 **Branch:** `claude/thirsty-goldberg-51bb9b` (NOT merged — held at the visual gate) -**Predecessor:** `docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md` -**Status:** checkpointed at user request after pinning the root cause. D-1..D-4 are committed + -correct but **did NOT fix the visible symptom** — they were the wrong subsystem. - ---- - -## ✅ RESOLVED 2026-06-19 (second session) — the "torch REACH" theory was WRONG; real cause = retail does NOT torch-light OUTDOOR objects at all - -**The open question is settled, and it overturns this checkpoint's own hypothesis. The fix is NOT -"shorten torch reach" — it is "outdoor objects receive NO torches."** - -**Empirical (acdream side, headless dat dump `HoltburgTorchFalloffProbeTests`):** the Holtburg -neighbourhood has **27 static lights, raw dat Falloff ∈ {3,5,6}** — the dominant orange entrance -torch (setup `0x020005D8`, colour `(1,0.588,0.314)`) is **Falloff 6** (17 of 27). acdream reads -this **faithfully** — `LightInfoLoader` just copies `info.Falloff`, no stray ×1.5. There is **NO -Falloff-4 torch anywhere in Holtburg**, so the predecessor's "retail orange = falloff 4" could not -be a *different* falloff-4 torch. Both clients read the same dat float → acdream's reach is NOT -inflated. So "acdream 6 vs retail 4" was a red herring. - -**Decomp (retail side, read verbatim + corroborated by an independent adversarial workflow -`wf_07289ba4`):** retail's per-object torch binder `minimize_object_lighting` (0x0054d480) is -**gated** in `RenderDeviceD3D::DrawMeshInternal` (0x0059f398) by `if (Render::useSunlight == 0)`. -The OUTDOOR landscape stage runs `Render::useSunlightSet(1)` (`PView::DrawCells` 0x005a485a, right -before `LScape::draw`), so when the building EXTERIOR shell is drawn -(`LScape::draw → DrawBlock 0x005a17c0 → DrawSortCell 0x0059f140 → DrawBuilding 0x0059f2a0 → -CPhysicsPart::Draw → DrawMeshInternal`), torches are **SKIPPED** — the only active light is the -**sun** (`useSunlightSet(1)` enables `add_active_light(0xffffffff, 0)` = sun + ambient only). The -static vertex bake (`SetStaticLightingVertexColors` 0x0059cfe0) is **EnvCell-only** (sole caller -`DrawEnvCell` 0x0059f1f6). **So retail lights outdoor objects with SUN + ambient ONLY — never the -wall torches.** This exactly explains the checkpoint's own isolation result ("object point lights -OFF → building matches retail"): retail's outdoor facade gets ZERO torch energy. (Confirming the -non-bug nature of reach: retail's free-object *hardware* path `config_hardware_light` 0x0059ad30 -uses `Range = falloff × rangeAdjust(1.5)` = LONGER than acdream's ×1.3, with `Diffuse = color×100` -and att `1/d` — that would blow the facade WHITE if enabled, which is further proof retail never -enables it outdoors.) - -**The three retail lighting regimes (now all mapped):** -1. **EnvCell walls** → static bake (`calc_point_light`, range `falloff×1.3`, wrap, capped), no sun. - → acdream mode 1 (EnvCell). ✓ already correct. -2. **Indoor objects** (`useSunlight==0`) → torches (hardware, no sun). → acdream mode 0 **indoor**. -3. **Outdoor objects** (`useSunlight==1`) → sun + ambient, **NO torches**. → acdream mode 0 **outdoor**. - acdream's mode-0 path applied sun **AND** torches to ALL objects — wrong for both 2 and 3. - -**THE FIX (shipped this session):** in `WbDrawDispatcher.ComputeEntityLightSet`, gate per-object -torch selection on the object being INDOOR (`ParentCellId` is an EnvCell, `(id&0xFFFF)>=0x0100`) -via the pure predicate `IndoorObjectReceivesTorches`. Outdoor objects (building shells — ParentCellId -null; outdoor scenery; outdoor creatures) keep the all-(-1) light set ⇒ sun + ambient only = retail. -The indoor "no sun" half is already handled by the global sun-kill when the player is inside a cell -(`UpdateSunFromSky`, intensity 0). Divergence-register row **AP-43** added (documents the residual: -acdream keys sun/torch on the object's own cell + a per-frame player-inside sun-kill, vs retail's -per-draw-STAGE `useSunlight` — only matters for through-doorway look-ins). Tests: -`WbDrawDispatcherTorchGateTests` (7✓), `HoltburgTorchFalloffProbeTests` (dat dump). Build green; -App 280✓/1skip, Core 1486✓/2skip. **Awaiting the user visual side-by-side at Holtburg before merge.** - -**DO-NOT-RETRY (this session's corrections to the checkpoint below):** do NOT shorten torch reach / -change `Falloff×1.3` — acdream reads the dat faithfully and the bake reach is correct for EnvCells. -The building is an OUTDOOR object; retail gives it no torches. The original checkpoint's "tighten reach -to ~5m, keep torches ON" plan (below) is SUPERSEDED — keeping torches ON for the outdoor shell at any -reach is the bug. - ---- - -## TL;DR — what the visible bug actually is (and is NOT) - -The user's symptom (Holtburg meeting-hall facade too bright/warm/washed-out + character backs -lit) is **NOT** the EnvCell bake, the per-channel clamp, the half-Lambert wrap, or the SSBO leak. -Those are the D-1..D-4 path. **The visible surfaces are mode-0 OBJECTS**, and the cause is: - -1. **Building facade over-bright** = the **torch REACH is too long** (acdream ~7.8 m vs retail - ~5.2 m), so each entrance torch floods the whole small facade instead of a tight pool. - **CONFIRMED by isolation**: gating object (mode-0) point lights OFF made the building match - retail ("looks much better", user 2026-06-19). -2. **Character backs / slight object over-bright** = the **sun + ambient on objects** (mode 0 - runs both). Ambient is NOT the culprit (it MATCHES retail exactly — see values). The residual - is small for the character (it ~matches retail), so the dominant visible bug is #1 (torches). - -## Render-path facts (source-verified, workflow `wf_c4ad8cf8`) - -- **Building EXTERIOR** = a flat-mesh `WorldEntity` with `IsBuildingShell=true`, `ParentCellId=null`, - built from `BuildingInfo.ModelId` (`LandblockLoader.cs:79-90`), drawn by **WbDrawDispatcher** - which hard-sets `uLightingMode=0` (`WbDrawDispatcher.cs:898`). It is **NOT an EnvCell** — so - **D-4 (EnvCell walls get no sun) never touched it**. -- **Characters/creatures/players** = ordinary `WorldEntity` dynamics, also drawn by - WbDrawDispatcher at `uLightingMode=0` (plain Lambert + sun). The mode plumbing is CORRECT - (mode-0 plain Lambert already zeroes a torch behind a back-face — that part of D-3 works). -- **EnvCellRenderer** (`uLightingMode=1`, no-sun, wrap) only ever draws **interior** cell shells - from the dat EnvCell list — never `info.Buildings`, never characters. -- Render loop: in-world frames go through `RetailPViewRenderer.DrawInside`; the bare - `WbDrawDispatcher.Draw` (GameWindow.cs:8230) is the no-viewer-cell fallback. Both share the - ONE `_meshShader` (mesh_modern) program (GameWindow.cs:1845-1857), so `uLightingMode` is one - shared uniform; each renderer re-sets it before its draws. - -## Ground truth (live cdb retail + acdream probe, SAME-INSTANT) - -- **Ambient MATCHES exactly**: acdream `(0.447,0.447,0.495)` == retail `(0.4465,0.4465,0.4951)`. - → same sky keyframe → **same time of day; NO time desync** (the earlier "retail 0.3 / acdream - purple" was sequential-capture drift + acdream's un-synced spawn frame; ignore it). -- **retail sun** (`world_lights.sunlight` @ 0x008672a0+0x18) = `(0.573, ~0, 0.445)`, magnitude - **0.725**, colour `(0.98,0.84,0.59)` warm. acdream `sun=1` (active, derived from the same sky - state via Fix C `|sunVec|=DirBright`). Sun is NOT zero — retail DOES sun-light objects. -- **retail torches** (golden, a7-fixd-golden2): static, `intensity=100`, `falloff 3/4/5`, warm - `(1,0.588,0.314)` orange + `(0.98,0.843,0.612)` cream. `calc_point_light` makes a BRIGHT TIGHT - pool (saturates to full warm to ~4 m, gone by ~5.2 m). Faithful in acdream (LightBake.cs). -- **acdream torches** ([light-detail]): `range=7.8` (Falloff 6×1.3) and `range=6.5` (Falloff 5). - acdream `Range = info.Falloff * 1.3f` (`LightInfoLoader.cs:90`) — the 1.3 is correct, NO stray 1.5. - -## The OPEN question to resolve FIRST on resume (don't guess) - -acdream's orange torch reads **Falloff 6** (range 7.8); retail's orange torch was captured at -**Falloff 4** (range 5.2). `6 = 4 × 1.5` (smells like rangeAdjust) BUT they **might be different -torches** (38 static torches, several orange). **Resolve by comparing the SAME torch's Falloff in -acdream vs retail, matched by world position** (one focused capture): break/dump acdream's torch -Falloff for a specific Holtburg torch and the retail `world_lights.static_lights[i].info.falloff` -for the same one. Then: -- If acdream reads a **too-large Falloff** for the same torch → fix the dat read / conversion - (the DatReaderWriter `LightInfo.Falloff` path) so acdream's reach == retail's. -- If the Falloff matches and reach is genuinely ~7.8 → the building-shell-as-one-object spill is - the issue; tighten how building shells receive torches (the per-vertex range gate already - localises, so this is unlikely — favour the Falloff hypothesis). - -## Proposed fix (after the falloff is confirmed) - -Tighten acdream's torch reach to match retail (≈5 m), keep torches ON. Building facade then shows -a tight warm pool by each flame + dark stone elsewhere (retail-faithful). Files: `LightInfoLoader.cs` -(the Falloff→Range conversion), possibly the DatReaderWriter light read. Add a divergence-register -row if any conversion deviates. Re-verify visually (the diagnostic that confirmed the cause: -object point lights OFF == retail-match). - -## State of the committed work (KEEP — all correct, just off-target for the visible bug) - -| Commit | What | Verdict | -|---|---|---| -| `180b4af` | D-1 clamp point sum on its own | faithful; keep | -| `39c70f0` | D-2 prep — LightBake conformance test | keep | -| `cf62793` | D-1 shader clamp | keep | -| `c62da82` | D-2 EnvCell shell binds own light set (real leak fix) | keep | -| `b57a53e`/`156dc45` | register AP-35/AP-16 corrections | keep | -| `0980bea` | D-3 objects plain-Lambert / D-4 EnvCell no-sun | keep; correct but doesn't touch the building (it's an object) | - -`tools/cdb/a7-fixd-*.cdb` capture scripts are committed. **Diagnostic shader hack reverted** -(working tree clean). Branch NOT merged — finish the torch-reach fix, visual-verify, then merge. - -## DO-NOT-RETRY (cost a lot this session) - -- Don't re-tune the EnvCell bake / per-channel clamp / wrap / SSBO binding for the building — the - building is a mode-0 OBJECT, none of that path lights it. -- Don't chase a time-of-day / ambient desync — ambient + time MATCH retail exactly (0.446). -- Don't "remove the sun" globally — retail DOES sun-light objects (sun 0.725). -- The visible building bug is the **torch REACH** (confirmed by isolation); start there. diff --git a/docs/research/2026-06-20-indoor-lighting-done-next-portal-reuse-handoff.md b/docs/research/2026-06-20-indoor-lighting-done-next-portal-reuse-handoff.md deleted file mode 100644 index 7386cecb..00000000 --- a/docs/research/2026-06-20-indoor-lighting-done-next-portal-reuse-handoff.md +++ /dev/null @@ -1,94 +0,0 @@ -# Handoff — indoor lighting DONE; NEXT: #145 portal re-use, then #144 dungeon dimness - -**Date:** 2026-06-20 -**Branch:** `claude/thirsty-goldberg-51bb9b` — **committed, NOT merged to main, NOT pushed** (user's call) -**Milestone:** M1.5 "Indoor world feels right" - ---- - -## What shipped this session (on the branch, not pushed) - -The entire indoor-lighting saga. **#142 + #143 CLOSED, user-confirmed.** - -| Commit | What | -|---|---| -| `ef5049f` | #142 per-instance sun gate (indoor objects skip the sun, per-draw like retail) | -| `0d8b827` | **THE root-cause fix** — EnvCellRenderer landblock-key bug + viewer light + weenie fixture lights | -| `57c2ab7` | #143 dynamic-light D3D `1/d` attenuation (portal + viewer spread softly) | -| `653e7f3` | docs: close #142/#143, file #144 | -| (this) | docs: file #145 + this handoff | - -**One-line story:** interiors were dark NOT because of the sun/ambient (those were -already retail-faithful — the 0.2-flat/sky split == `CellManager::ChangePosition` -0x004559B0). The real bug was a landblock-key lookup in -`EnvCellRenderer.GetCellLightSet`: it keyed `_landblocks` by `cellId & 0xFFFF0000` -(0xXXYY**0000**) instead of the streaming key 0xXXYY**FFFF**, so the lookup missed -for EVERY cell → `SelectForObject` never ran → every interior wall got ZERO point -lights, always. Fix: `(cellId & 0xFFFF0000) | 0xFFFF`. Found by a `[cell-light]` -probe (inBounds=False selected=0 → True selected=3-4) after 3+ speculative fixes. - -Retail mechanisms ported alongside it (all faithful, decomp-cited): -- **Viewer light** — `SmartBox::set_viewer` 0x00452c40: white fill pinned to the - player every frame; `1/d` att, range 15. `LightManager.UpdateViewerLight`. -- **Weenie fixture lights** (AP-44) — server-object `Setup.Lights` registered on - spawn (`OnLiveEntitySpawnedLocked`); the meeting-hall portal's magenta light - rides this → #143 lit "for free." -- **Dynamic vs static attenuation** — `config_hardware_light` 0x0059ad30: - dynamic = `1/d` + range×1.5; static = `1/d³` + range×1.3. Per-light `IsDynamic` - flag packed into `GlobalLight.coneAngleEtc.y`. - -Register: AP-43 (#142 sun gate), AP-44 (weenie lights), AP-47 (acdream's always-lit -interiors vs retail's 40-light pop-in — INTENTIONAL). Full lighting detail: -memory `reference_retail_ambient_values.md` (2026-06-20 resolution note). - -**Suites:** Core 1505 / App 476 green. - ---- - -## NEXT SESSION — in this order - -### 1. FIRST — #145: portals only work once per session (HIGH) - -User wants: **run into a portal, run out, re-enter — repeatedly.** Currently only the -FIRST teleport works; the second portal use does nothing. - -- **Machinery:** `WorldSession.cs:999` (0xF751 PlayerTeleport) → `PlayerMovementController` - `PlayerState.PortalSpace` (`:75`/`:840`) → `TeleportArrivalController` (`Phase` - Idle/Holding; the placement delegate flips PortalSpace back to InWorld) → - `GameWindow.cs:4903/5332/5362` (PortalSpace observer + arrival placement) → - streaming collapse↔expand (#133/#135 machinery). -- **First step (don't guess):** instrument a SECOND teleport and find WHERE it stalls — - does the 2nd 0xF751 arrive? does PortalSpace toggle back to InWorld? does - `TeleportArrivalController.Tick` place via `PlaceTeleportArrival`? does the streaming - window re-expand? One probe across those four points pins it. -- **Likely overlaps #138** (teleport OUT of a dungeon loads the outdoor world - incompletely) — may be the same root or a sibling; check together. - -### 2. THEN — #144: dungeon interiors still too dim vs retail (MEDIUM) - -Dungeons improved (torch cells light up now — the landblock-key fix + viewer fill), but -torch-sparse stretches + overall brightness still trail retail (user: "better but not -good enough"). - -- **Approach:** side-by-side **cdb capture of retail's dungeon** — `world_lights` - active set + `world_lights.ambient_color` + the viewer-light intensity at the SAME - spot — vs acdream's `[light]`/`[light-detail]` there. Do NOT guess. -- **Candidates:** per-vertex Gouraud bake under-lights low-poly dungeon walls (AP-35 - residual); the sealed-dungeon flat 0.2 ambient too dark; retail leans harder on the - viewer light / carries more dynamics. - ---- - -## Launch / probes - -`testaccount2` / `testpassword2` (character Horan, spawns near Holtburg; reaches the -0x0007 Town Network dungeon). Canonical launch + `ACDREAM_PROBE_LIGHT=1` + -`ACDREAM_PROBE_CELL=1` → `[light]` / `[light-detail]` / `[cell-transit]`. The -`[cell-light]` per-cell probe was STRIPPED (the bug it found is fixed) — re-add a -per-cell light probe in `EnvCellRenderer.GetCellLightSet` if #144 needs it. - -## Pointers -- memory `reference_retail_ambient_values.md` — lighting resolution + the cdb toolchain. -- `docs/architecture/retail-divergence-register.md` — AP-43 / AP-44 / AP-47. -- `docs/ISSUES.md` — #144, #145, #138 (related), #137 (dungeon collision). -- cdb toolchain: CLAUDE.md "Retail debugger toolchain" (binary matches `refs/acclient.pdb`). diff --git a/docs/research/2026-06-20-indoor-lighting-regime-handoff.md b/docs/research/2026-06-20-indoor-lighting-regime-handoff.md deleted file mode 100644 index 6864f4a7..00000000 --- a/docs/research/2026-06-20-indoor-lighting-regime-handoff.md +++ /dev/null @@ -1,188 +0,0 @@ -# Indoor lighting regime — HANDOFF (#142 windowed-interior regime, #143 portal dynamic light) - -**Date:** 2026-06-20 **Base:** `main` @ `31d7ffd` (A7 #140 + all D.5 work; pushed to both remotes) -**Milestone:** M1.5 "Indoor world feels right" **Start with: #142 (issue #1).** -**Predecessor:** `docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md` -(RESOLVED banner — the #140 outdoor fix). Companion: `claude-memory/reference_retail_ambient_values.md`. - -## Where we are - -`#140` (outdoor building over-bright near torches) is **SHIPPED + user-confirmed + merged + pushed.** -Real cause: retail lights outdoor objects with SUN + ambient only, never torches (the `useSunlight` -gate); fix = gate per-object torch selection on the object being indoor (`IndoorObjectReceivesTorches`, -`WbDrawDispatcher.cs`). Register row **AP-43**. - -At the #140 visual gate the user spotted two INDOOR-lighting gaps (the opposite problem — interiors -too DARK / "like outdoors"). Both are this handoff. **Neither is a regression from #140** — that fix -only *subtracts* torch light from *outdoor* objects. - -## The unifying insight (read this first) - -acdream's lighting **REGIME** (sun on/off + which ambient) is a **per-FRAME global** keyed on whether -the PLAYER is in a sealed cell. Retail's is **per-DRAW-STAGE**: the outdoor stage runs with the sun -on, the interior-cell stage runs with the sun off + torches on. `#140` fixed the **torch** half of -this mismatch *per-object* (AP-43). **#142 is the SUN + AMBIENT half — i.e. the AP-43 residual, now -surfaced as a visible bug.** Finishing #142 lets us delete/narrow AP-43. - ---- - -# #142 (issue #1) — windowed-building interiors read "like outdoors" [PRIMARY] - -### Symptom (user, 2026-06-19, at the #140 gate) -> "Agent of Arcanum house — in retail it is much brighter indoors; when looking into the house it is -> lit, same light when you walk in. In acdream it is NOT lit — looking in and when inside it feels the -> same like it is outdoors." - -The **meeting hall** (a more sealed interior) looked OK — the user only flagged its portal (#143), -not its walls. That contrast is the key clue (see "the three gaps"). - -### Retail mechanism (VERIFIED — read verbatim this session) -`PView::DrawCells` (0x005a4840) draws a frame in two ordered stages: -1. **Outside stage:** `useSunlightSet(1)` (0x005a485a) → `LScape::draw` → outdoor terrain/buildings/ - objects, **sun on, torches skipped** (the #140 mechanism). -2. **Interior stage:** `useSunlightSet(0)` (0x005a49f3) → `restore_all_lighting` → loop over **every** - EnvCell in `cell_draw_list` → `DrawEnvCell` (0x0059f170): walls baked - (`SetStaticLightingVertexColors` 0x0059cfe0), objects torch-lit (`minimize_object_lighting` - 0x0054d480, enabled because `useSunlight==0` per `DrawMeshInternal` 0x0059f398), **NO sun.** -3. `useSunlightSet(1)` (0x005a4b5d) restores outdoor mode at the very end. - -`useSunlightSet(arg)` (0x0054d450): sets `useSunlight=arg`; `arg==1` enables the SUN as the active -hardware light, `arg==0` enables none (sun off). - -**KEY FACT:** `cell_draw_list` holds ALL visible EnvCells — windowed (`SeenOutside`) **and** sealed. -Retail draws every interior in the `useSunlight==0` stage. The regime is **per-stage, never per- -building / per-SeenOutside.** So retail torch-lights *every* building interior, including windowed -ones and look-ins viewed from outside. - -### acdream current state (per-FRAME global) — current line refs (@31d7ffd) -- `GameWindow.cs:8061` `playerSeenOutside = playerRoot?.SeenOutside ?? true` — the PLAYER cell's flag. -- `GameWindow.cs:8107` `playerInsideCell = playerRoot is not null && !playerSeenOutside`. -- `GameWindow.cs:8122` `UpdateSunFromSky(kf, playerInsideCell)` → (`:10786`) sets the **global** sun + - ambient: inside → sun `Intensity=0` + flat `(0.2,0.2,0.2)` ambient; outside → keyframe sun + outdoor - ambient. -- That ambient is uploaded ONCE per frame to the SceneLighting UBO (`CurrentAmbient.AmbientColor`, - `:8171`) and read by BOTH mode-0 (objects) and mode-1 (EnvCell shells) in `mesh_modern.vert`. -- **Torches are ALREADY per-cell** (AP-43: `IndoorObjectReceivesTorches` `WbDrawDispatcher.cs:2076`, - used at `:2057`; plus `EnvCellRenderer` `SelectForObject`) — independent of `playerInsideCell`. So - the torch half is fine; **only the SUN + AMBIENT are still per-frame-global.** - -### The three gaps (all one root: per-frame-global vs per-stage) -1. **Player OUTSIDE, looking INTO any building (look-in):** `playerSeenOutside=true` → outdoor regime - → the look-in interior gets sun + outdoor ambient. Retail draws look-in cells in the `useSunlight=0` - stage (torch-lit). → "when looking in, not lit." -2. **Player INSIDE a WINDOWED building** (`SeenOutside=true` cells, e.g. Agent of Arcanum): - `playerInsideCell=false` → outdoor regime → interior gets sun + outdoor ambient. Retail: - `useSunlight=0`, torch-lit. → "when inside, feels like outdoors." -3. **Player INSIDE a SEALED building / dungeon** (`SeenOutside=false`): `playerInsideCell=true` → - indoor regime → MATCHES retail. ✓ (the meeting hall + dungeons — why they looked right.) - -### Cheap validation FIRST (before any code) -- **Confirm the windowed-vs-sealed split is the discriminator.** Verify the Agent of Arcanum is a - WINDOWED building (its EnvCells' `SeenOutside=true`) and the meeting hall is sealed. Dat flag: - `EnvCellFlags.SeenOutside` (hydrated to `ObjCell.SeenOutside`; see `EnvCell.cs` / `PhysicsDataCache.cs`). - We did NOT pin the Agent of Arcanum's landblock this session — either have the user point at it in - game (`[B.4b] pick` line names clicked objects), or extend `HoltburgTorchFalloffProbeTests` to dump - `SeenOutside` per EnvCell across the Holtburg landblocks and find the windowed buildings. -- **`ACDREAM_PROBE_LIGHT=1`** ([light] line logs `insideCell` / ambient / sun) while standing inside - the Agent of Arcanum vs the meeting hall — confirms each gets the regime predicted above. - -### Fix direction (BRAINSTORM this — it is a design fork, not a mechanical port) -Make the SUN + AMBIENT **per-draw-context**, mirroring AP-43's per-object torch decision. The renderer -is batched bindless-MDI, so a per-stage global won't work across mixed batches — per-object is the -natural fit (exact same reasoning that put AP-43 per-object; see the #140 explanation). An object/cell -is "indoor" iff its `ParentCellId` is an EnvCell (reuse `IndoorObjectReceivesTorches`). Then: -- **Indoor draws** (mode-1 EnvCell shells; mode-0 objects with EnvCell `ParentCellId`): SKIP the sun + - use the **indoor** ambient (flat `(0.2,0.2,0.2)` / retail indoor). (mode-1 already skips the sun; - it just needs the indoor ambient. mode-0 indoor objects currently ADD the sun — gate it off.) -- **Outdoor draws:** sun + outdoor ambient (as today). - -Open design questions for the brainstorm: -- The shader needs BOTH ambients (indoor + outdoor) + a per-instance "indoor" selector. Options: - (a) add an `indoorAmbient` to the SceneLighting UBO + a per-instance indoor bit (a tiny SSBO like - the light-set, or pack into an existing per-instance field); (b) add a third `uLightingMode` (e.g. - `2 = indoor object`: no sun, indoor ambient, torches); (c) compute both and select. -- `UpdateSunFromSky` must stop branching on `playerInsideCell` and instead provide BOTH regimes every - frame (outdoor sun + outdoor ambient AND the indoor flat ambient), so the shader picks per object. -- **Verify retail's indoor ambient** (the `restore_all_lighting` path + the per-EnvCell ambient): is it - the flat `(0.2,0.2,0.2)` we use, or the cell's own authored ambient? Cross-check before locking it. - -**This work RESOLVES the AP-43 residual** (regime becomes per-draw → no doorway/look-in mismatch). -Update/delete AP-43 in the same commit. - -### Files -- `GameWindow.cs`: `:8061`/`:8107` (`playerInsideCell`), `:8122` + `:10786` `UpdateSunFromSky` (the - regime source), `:8171` (ambient → UBO). -- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert`: `accumulateLights` (sun loop under - `if (uLightingMode==0)` ~`:193`; ambient `uCellAmbient.xyz` ~`:188`). The sun gate + ambient - selection live here. -- `WbDrawDispatcher.cs`: `IndoorObjectReceivesTorches` (`:2076`) — the indoor predicate to reuse; - `ComputeEntityLightSet` (`:2057`). -- `EnvCellRenderer.cs`: mode-1 draws (`uLightingMode=1`) — need the indoor ambient. -- `LightManager` / the SceneLighting UBO layout (`GlobalLightPacker` is the binding-4 helper) — where a - second ambient + the indoor selector would go. - ---- - -# #143 (issue #2) — portal swirl doesn't light the room [SECONDARY] - -### Symptom -Inside the meeting hall, retail's portal swirl visibly tints/lights the room; acdream's portal lights -nothing. - -### Retail mechanism -The portal swirl is a **DYNAMIC** light. `add_dynamic_light` (0x0054d420) → `insert_light` -(0x0054d1b0) → `world_lights.dynamic_lights`. `minimize_envcell_lighting` (0x0054c170) enables the -cell's DYNAMIC subset (class 2) as hardware lights → tints the EnvCell walls; `minimize_object_lighting` -(0x0054d480) enables dynamics for objects in the cell too. **Captured params** (predecessor cdb, -`tools/cdb/a7-fixd-*.cdb`): the Holtburg portal dynamic light = `intensity=100, falloff=6, -color=(0.784, 0, 0.784)` (magenta/purple). - -### acdream gap -acdream registers ONLY static `Setup.Lights` (`GameWindow.cs` ~`:6404` `RegisterOwnedLight`). It -registers **no dynamic lights** — the portal entity casts no light. (`GpuWorldState.cs:101` even -mentions "unregistering dynamic lights" but none are ever registered.) - -### Fix approach -Register a dynamic `LightSource` for portal-swirl entities at their world position with the retail -params (or read the portal model's own dat `Setup.Lights` if it carries one — check the portal GfxObj/ -Setup first). It then flows through the existing point-light path (`LightManager.PointSnapshot` → -`SelectForObject` → shader), lighting nearby EnvCell walls + indoor objects. It is a POINT light, lives -INSIDE a cell → it must light via the indoor path (the EnvCell bake `SelectForObject` already picks any -registered point light near a cell, so registering it may "just work" once it has a `LightSource`). -Find where portal swirls spawn in acdream (the particle/portal emitter spawn path) and attach the light -there; unregister on despawn (`UnregisterByOwner`). Keep it OUT of the AP-43 outdoor-object gate (it's -indoor). Decomp anchors: `add_dynamic_light` 0x0054d420, `minimize_envcell_lighting` 0x0054c170, -`insert_light` 0x0054d1b0. - ---- - -## Decomp anchors (quick reference) -`useSunlightSet` 0x0054d450 · `useSunlight` gate `DrawMeshInternal` 0x0059f398 · `PView::DrawCells` -0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3 / `useSunlightSet(1)` -0x005a4b5d) · `DrawEnvCell` 0x0059f170 · `SetStaticLightingVertexColors` 0x0059cfe0 · `calc_point_light` -0x0059c8b0 (range = falloff × `static_light_factor` 1.3 @ 0x00820e24) · `minimize_object_lighting` -0x0054d480 · `minimize_envcell_lighting` 0x0054c170 · `add_dynamic_light` 0x0054d420 · `insert_light` -0x0054d1b0 · `config_hardware_light` 0x0059ad30 (`rangeAdjust` 1.5 @ 0x00820cc4 — the dynamic/object -hardware path). - -## DO-NOT-RETRY / gotchas -- The OUTDOOR torch gate (#140 / AP-43) is correct + user-confirmed — don't touch it. -- Don't shorten `Falloff × 1.3` — acdream reads the dat falloffs faithfully (the reach is correct). -- The regime is a per-FRAME global; the fix is to make sun+ambient **per-DRAW** (per-object/cell), - mirroring AP-43's torch decision — **NOT** to split into separate render passes (fights the batched - MDI; the per-object route is why AP-43 exists). -- Line numbers above are @`31d7ffd` and WILL drift — re-grep `playerInsideCell` / `UpdateSunFromSky` / - `IndoorObjectReceivesTorches` before editing. - -## Verification (the acceptance gate) -Visual side-by-side vs retail at the **Agent of Arcanum** (looking IN from outside + walking IN) and -the **meeting-hall portal**. Expected after #142: interiors are torch-lit/warm both looking-in and -inside; windowed buildings no longer "feel like outdoors." After #143: the portal swirl tints the room. - -## Pointers -- Register: **AP-43** (`docs/architecture/retail-divergence-register.md`) — the residual this work - resolves. -- `claude-memory/reference_retail_ambient_values.md` — cdb values incl. the portal dynamic-light - capture + the indoor/outdoor ambient numbers. -- `claude-memory/project_render_pipeline_digest.md` — per-cell light + look-in (#124) + flap context. -- #140 CHECKPOINT (above) — the full outdoor-torch story + the verified `useSunlight` decomp. diff --git a/docs/research/2026-06-20-indoor-torch-lantern-lighting-investigation.md b/docs/research/2026-06-20-indoor-torch-lantern-lighting-investigation.md deleted file mode 100644 index e79a2fdc..00000000 --- a/docs/research/2026-06-20-indoor-torch-lantern-lighting-investigation.md +++ /dev/null @@ -1,75 +0,0 @@ -# A7 indoor torch/lantern lighting — why acdream interiors read dark (investigation) - -**Date:** 2026-06-20 **Mode:** report-only investigation (no code changed) -**Trigger:** after #142 sun-gate (`ef5049f`) the Agent of Arcanum interior -(`0xA9B40171/0172`) still reads dark; user: "retail uses torches **and lanterns** -for lighting indoors." - -## Retail oracle — the static-light bake (decomp, confirmed) - -`SetStaticLightingVertexColors` (0x0059cfe0), per EnvCell mesh, per vertex: - -``` -for each vertex V: - acc = (0,0,0) // from BLACK; no ambient/sun here - for i in 0 .. world_lights.num_static_lights: // ALL lights — NO per-vertex/cell cap - L = sorted_static_lights[i] - if L.type==0: calc_point_light(V, acc, L) // 0x0059c8b0 - else: acc += dot(N,dir)*scale*L.color - acc = clamp(acc, 0, 1) // per-channel, AFTER summing all - V.diffuse = encode(acc) -``` - -`calc_point_light` (0x0059c8b0): `range = falloff × static_light_factor(1.3)`; -back-face/range gated; `angular = dot_n/dist` (<1 m) else `dot_n/(dist²·dist)`; -`scale = angular·(1−dist/range)·intensity`; per-channel cap `min(scale·c, c)`. - -**Global cap:** `max_static_lights = 0x28 = 40` (`insert_light` 0x0054d1b0 keeps a -distance-sorted, replace-farthest list). So the bake sees up to **40 nearest static -lights — and sums ALL of them per vertex.** There is no 8-light cap on the static -wall bake. (The 8-light cap is the D3D *hardware* path for *dynamic objects*, -`minimize_object_lighting` 0x0054d480 — a different path.) - -## acdream's torch path — three real divergences - -| # | Divergence | Site | Effect | -|---|---|---|---| -| Bug A | **Weenie fixture lights never registered.** `RegisterOwnedLight` is called ONLY from `ApplyLoadedTerrainLocked` for dat-baked landblock + EnvCell statics (Setup `0x02xxxxxx`, `Lights.Count>0`). Server `CreateObject` weenies (`OnLiveEntitySpawnedLocked`) never register lights. | `GameWindow.cs:6733-6758` (registers); `GameWindow.cs:2696/3275` (weenie path — no register) | If a lantern/torch is a server weenie, its light is **entirely absent**. | -| Bug B | **Per-cell 8-light cap.** `GetCellLightSet`→`SelectForObject` keeps the **8 nearest-to-cell-center** lights (`MaxLightsPerObject=8`); 9th+ dropped. Retail sums up to 40. | `EnvCellRenderer.cs:1044`; `LightManager.cs:234-278` | Dense cells (dungeon corridors, fixture clusters) lose lights. **Not binding** for the Arcanum (only ~3 in range). | -| Bug C | **128 global camera pre-filter.** `BuildPointLightSnapshot` keeps the 128 nearest-**to-camera** lights (`MaxGlobalLights=128`), drops the rest before per-cell selection. Retail has no global per-frame cap on statics. | `LightManager.cs:179,208-211` | `registeredLights=129` ⇒ already at the cap; drops farthest-from-camera. Hurts far lights, not the near interior. | - -Bug A·partial: the per-vertex bake is also approximated as **one 8-light set per -whole cell** (uniform across all the cell's vertices), vs retail's true per-vertex -bake — a fidelity gap (bright cluster smears across the cell), secondary to the -source-count gaps above. - -The per-light shader formula (`pointContribution`) + the `min(pointAcc,1.0)` torch -clamp **match** retail's `calc_point_light` + final saturate — magnitude per light -is faithful. The gap is **missing / capped light SOURCES**, not per-light math. - -## Ranked hypotheses (for the Agent of Arcanum specifically) - -1. **H1 — the lanterns are server weenies ⇒ Bug A drops their light** (best fit - for the "lanterns" hint). The cell shows only ~3 *dat* torches; lanterns absent. - *Falsify:* pick a lantern in-game — guid `0x80…`/`0x7…` = weenie ⇒ confirmed. -2. **H4 — per-cell (not per-vertex) bake / magnitude** — if the Arcanum has no - weenie fixtures, the 3 dat torches are all there is and the per-cell-uniform bake - under-fills vs retail's per-vertex bake. -3. **H2/H3 (8-cap / 128-cap)** — real retail divergences, but **not binding** for - this building (≤3 lights in range, near camera). They matter for dense dungeons. - -## Fix plan (faithful, per hypothesis) - -- **H1 (likely):** register `Setup.Lights` for light-bearing weenies in - `OnLiveEntitySpawnedLocked`, mirroring `ApplyLoadedTerrainLocked:6742-6758` - (guard `setup.Lights.Count>0`); unregister on despawn (`UnregisterByOwner`). - Faithful: retail registers object lights regardless of static/dynamic origin. -- **H2:** raise the per-cell static bake cap from 8 toward retail's 40 (the cap is - retail-correct only for the *dynamic-object hardware* path, not the wall bake). -- **H3:** make the per-cell selection independent of the 128 camera pre-filter for - statics (retail's bake has no global count cap). -- **H4:** move the cell bake toward per-vertex (longer-horizon A7 LightBake work). - -## What this is NOT -NOT a #142 sun-gate regression and NOT an ambient bug (ambient matches retail; -it's just dim rainy-twilight). It is missing / capped light **sources**. diff --git a/docs/research/2026-06-20-window-manager-inventory-handoff.md b/docs/research/2026-06-20-window-manager-inventory-handoff.md deleted file mode 100644 index d2b7b237..00000000 --- a/docs/research/2026-06-20-window-manager-inventory-handoff.md +++ /dev/null @@ -1,200 +0,0 @@ -# Handoff — window manager → inventory window → paperdoll (the next D.2b core-panels arc) - -**Date:** 2026-06-20 -**From:** the D.5.3 session that shipped the drag-drop spine (B.1), toolbar shortcut reorder/remove + -wire (B.2), and toolbar collapse-to-one-row — all **merged to `main`** (merge commit `abbd97b`; -branch `claude/hopeful-maxwell-214a12`, tip `14443e5`). Full suite green (2752) at merge. -**Line numbers below WILL drift — grep the symbol, don't trust the line.** - ---- - -## 0. What just shipped (the foundation the next work builds on) - -This is now on `main` and visually confirmed: - -- **Drag-drop spine (B.1)** — the shared widget-level drag machine. `UiRoot` holds the device-level - drag (BeginDrag/UpdateDragHover/FinishDrag, ghost snapshotted at begin, drop delivered only on a real - hit). `UiElement` has `GetDragPayload()`/`GetDragGhost()`/`IsDragSource` virtuals (UiRoot stays - item-agnostic). `UiItemSlot` is drag source + drop target + accept/reject overlay; `UiItemList` owns - a registered `IItemListDragHandler { OnDragLift; OnDragOver; HandleDropRelease }`. Payload = - `ItemDragPayload(ObjId, SourceKind, SourceSlot, SourceCell)`. Spec/plan: - `docs/superpowers/{specs,plans}/2026-06-20-d2b-drag-drop-spine*.md`. -- **Toolbar shortcut drag (B.2)** — retail **remove-on-lift / place-on-drop / no-restore**. - `ToolbarController : IItemListDragHandler` drives a `ShortcutStore` (`AcDream.Core.Items`, 18 slots) - + the wire: `InventoryActions.BuildAddShortcut(seq,index,objectGuid,spellId,layer)` / - `BuildRemoveShortcut`, sent via `WorldSession.SendAddShortcut`/`SendRemoveShortcut`. Green-cross - accept sprite `0x060011FA`. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-20-d2b-toolbar-shortcut-drag*.md`. -- **Toolbar collapse** — `UiCollapsibleFrame` bottom-edge snap resize (1-row ↔ 2-row). Spec: - `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md`. - -**The one B-stream piece still owed:** *drag FROM inventory onto the bar.* B.2 did the within-bar -reorder/remove (`SourceKind == ShortcutBar`). The fresh-from-inventory branch (retail `flags & 0xE == 0` -→ `CreateShortcutToItem`) needs the inventory window as a drag SOURCE — so it lands WITH Stream C -below, not before it. - ---- - -## 1. Read first - -- This doc. -- `docs/research/2026-06-16-ui-panels-synthesis.md` §4 — **the build plan** for the core panels - (build order, widget list, cross-panel wire table). Stream C follows it. -- `docs/research/2026-06-16-inventory-deep-dive.md` — `gmInventoryUI 0x21000023` nests paperdoll - (`0x21000024`) + backpack (`0x21000022`) + 3D-items (`0x21000021`); backpack burden Meter - (`SetLoadLevel`→fill `0x69`); full inventory wire catalog + acdream parse-status. -- `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — the doll = `UIElement_Viewport` - (Type `0xD`) hosting a re-dressed player clone; ~25 equip slots; wield = `GetAndWieldItem 0x001A` - (builder MISSING); needs a Core→App `IUiViewportRenderer` seam. -- `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §C — the predecessor's - current-code readiness for the inventory window (most still accurate; deltas noted below). -- `claude-memory/project_d2b_retail_ui.md` (the toolkit, now incl. B.1/B.2/collapse) + - `claude-memory/project_object_item_model.md` (the D.5.4 `ClientObjectTable`). - -**Mandatory workflow** (CLAUDE.md): grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by -`class::method` → cross-ref ACE/holtburger → pseudocode → port; conformance tests throughout. Each -sub-phase gets its own brainstorm → spec → plan → subagent-driven (the flow this arc has been using). - ---- - -## 2. Scope — three sub-phases, in order - -| # | Sub-phase | One-liner | -|---|---|---| -| **A** | **Window manager (open/close + `I`-key)** | The small shared infra to open/close a window + a keybind to toggle the inventory window. Prerequisite for everything below. | -| **B** | **Inventory window (Stream C / D.5.5)** | `gmInventoryUI` nesting paperdoll + backpack + 3D-items. The drag SOURCE that completes "drag-from-inventory." | -| **C** | **Paperdoll** (`UiViewport`) | The 3D dressed-doll viewport — the heaviest piece, its own Core→App seam. Last. | - -**Out of scope:** the spell bar (a separate spell-casting feature, NOT the action-bar spell shortcuts); -the faithful dragbar/resizebar window resize (IA-12 whole-window-drag + the new bottom-edge collapse -are the accepted approximations). - ---- - -## 3. Sub-phase A — window manager (open/close + `I`-key) — DO THIS FIRST - -**Current state:** every retail-UI window is **always-on at a hardcoded position** — the vitals, -chat, and toolbar are added to `_uiHost.Root` unconditionally at startup under `ACDREAM_RETAIL_UI=1` -(grep `_uiHost.Root.AddChild` in `GameWindow.cs`; the mounts are in the `_options.RetailUi` block). -`UiHost` has **no open/close API** (`src/AcDream.App/UI/UiHost.cs` — it exposes `Root`, `Tick`, -`Draw`, `WireMouse/Keyboard` only). Input flows through `InputDispatcher` (Phase K) — -`keybinds.json` + `KeyBindings`; the `I` key needs an action + binding. - -**Minimum deliverable:** open/close a top-level window + toggle the inventory window with `I`. -Concretely: -- A small open/close concept on `UiHost`/`UiRoot` (e.g. register a named window + Show/Hide/Toggle that - flips `Visible` and brings it to front via `ZOrder`). Today windows are always `Visible=true`; the - manager makes a window default-hidden and toggleable. (z-order/persist can be minimal — `Visible` + - a top-most `ZOrder` bump on open.) -- An `InputAction` (e.g. `ToggleInventory`) + the retail default `I` keybind (cross-check - `docs/research/named-retail/retail-default.keymap.txt`), wired through the existing - `InputDispatcher` (see `claude-memory/project_input_pipeline.md`), gated by `WantsKeyboard` so it - doesn't fire while a chat input is focused. - -**Brainstorm questions (A):** Is the window manager a property on `UiRoot` (a registry of named -top-level windows + Show/Hide/Toggle), or a thin `UiHost` API? How is z-order handled on open (bring -to front)? Does `I` toggle (open↔close) or only open (Esc/close-button to close)? Persist open-state -across the session (in-memory) only — no disk persistence yet (that's the deferred Plan-2)? - -Faithful retail note: retail windows are opened by the radar/menu buttons + hotkeys and managed by -keystone.dll (no decomp) — so this is a toolkit-defined manager (IA-12/IA-15 umbrella), kept minimal. - ---- - -## 4. Sub-phase B — inventory window (Stream C / D.5.5) — follow the synthesis §4 - -**The design is already written — follow `2026-06-16-ui-panels-synthesis.md` §4.** This section is the -**current-code readiness** + what's missing. Don't re-derive the design. - -**READY (and stronger now, post-B.1/B.2):** -- `UiItemSlot` + `UiItemList` + `IconComposer` (`src/AcDream.App/UI/`) — the shared item-cell spine, - now with the full drag-drop machine (B.1). An inventory grid is `UiItemList` cells + an - `IItemListDragHandler` on the controller. -- `DatWidgetFactory` registers `0x10000031 → UiItemList` (grep `0x10000031` in - `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`). -- The data path: `ClientObjectTable.GetContents(containerGuid)` → ordered guids → `Get(guid)` → full - icon fields (`src/AcDream.Core/Items/ClientObjectTable.cs`). The object/container model shipped in - D.5.4 (`project_object_item_model.md`). -- The shortcut wire + `ShortcutStore` (B.2) — so **drag-from-inventory-onto-the-bar** is now a small - addition: the inventory cell is a drag SOURCE (its handler's `OnDragLift` does NOT remove — an - inventory→bar drag creates a shortcut REFERENCE; the item stays in the pack), and `ToolbarController. - HandleDropRelease` gains the `SourceKind == Inventory` branch (place a new shortcut at the target via - `SendAddShortcut`, no source-bump) — retail's `flags & 0xE == 0` `CreateShortcutToItem` path. - -**MISSING (the build, in synthesis order — see the 2026-06-18 handoff §C.1-6 for the detailed list):** -1. **Window manager** — §3 above (do first). -2. **`UiItemList` N-cell grid mode** — currently single-cell (`UiItemList.cs`; `Flush`/`AddItem` - skeleton exists, no column-count/pitch/wrap). LIKELY ~6 cols; confirm from - `UIElement_ItemList::ItemList_AddItem`. -3. **Sub-window mount in `LayoutImporter`** — `gmInventoryUI 0x21000023` nests `gm*UI` children with - their own `BaseLayoutId`; the importer only does TEMPLATE inheritance today - (`src/AcDream.App/UI/Layout/LayoutImporter.cs`) — instantiating a nested `gm*UI` window is new. -4. **Wire gaps** (inventory deep-dive §4.3): builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`, - `NoLongerViewingContents 0x0195`; parsers `ViewContents 0x0196`, `SetStackSize 0x0197`, - `InventoryRemoveObject`; fix `ParsePutObjInContainer` (drops the 4th `containerType`) + - `ParseInventoryServerSaveFailed` (drops `weenieError`); register `ViewContents`/`0x019A`/`0x0052`/ - `0x00A0` in `GameEventWiring`. (Grep these symbols — none landed this session.) -5. **`InventoryController`** (`gm*UI::PostInit` find-by-id pattern): backpack burden Meter - (`SetLoadLevel`→fill `0x69`), own-pack list + side-pack list, `ObjDescEvent 0xF625` → re-dress. - -**Brainstorm questions (B):** Sub-window mount — recursive `Import()` in `LayoutImporter`, or an -external stitch by the controller? Grid column count (confirm 6 from decomp)? Open by `I`-key only, or -also a toolbar inventory button? The inventory cell's drag semantics (lift does NOT remove from the -pack — it's a copy-to-shortcut for the bar, but a real MOVE between packs DOES relocate via -`PutItemInContainer 0x0019`) — pin the SourceKind→action matrix (deep-dive §5.7 opcode table). - ---- - -## 5. Sub-phase C — paperdoll (`UiViewport`, Type 0xD) — heaviest, last - -**The single biggest new piece.** No widget, no factory registration, no renderer today. Needs an -`IUiViewportRenderer` **Core→App seam** (structure Rule 2) for a scissored single-entity GL pass — the -doll is the local player's ObjDesc-dressed entity in a fixed viewport. ~25 equip slots; the -element-id→`EquipMask` map; wield = `GetAndWieldItem 0x001A` (builder still MISSING). Brainstorm -separately (it's substantial). Follow `2026-06-16-equipment-paperdoll-deep-dive.md`. - -**Brainstorm questions (C):** Does the doll clone the player `WorldEntity` or build a fresh -ObjDesc-dressed `AnimatedEntityState` (the player is the camera, so there's no player-as-renderable -today)? `IUiViewportRenderer` timing (post-world pass vs pre-pass)? Scissor infra (the toolkit has no -GL scissor yet — see the collapse spec's clipping discussion). - ---- - -## 6. Build order + dependency graph - -``` -A. window manager (open/close + I-key) ← do first; unblocks B - │ - ▼ -B. inventory window (grid + sub-window mount + wire gaps + InventoryController) - │ └─ also completes B.2's drag-from-inventory (inventory cell = drag source; - │ ToolbarController.HandleDropRelease gains the SourceKind==Inventory branch) - ▼ -C. paperdoll (UiViewport + IUiViewportRenderer seam + PaperDollController) ← heaviest, last -``` - -Critical-path note: A is small and unblocks B; the inventory window (B) is the big one and the drag -*source* that closes the B-stream; the paperdoll (C) is the heaviest and independent enough to come -last. - ---- - -## 7. ⚠ State notes for the fresh session - -- **Start from `main`** (merge `abbd97b`) — it has all of D.5.2 + D.5.4 + this session's D.5.3 B.1/B.2 - + the collapse, plus the indoor-lighting handoff (`f7f3e08`, issues `#142`/`#143` are LIGHTING, not - UI). Create a new worktree off `main`. -- **ISSUES:** `#144` is the (LOW, latent) empty-item-slot click note from B.1; `#141` is the toolbar - selected-object display (D.5.3a done; mana/stack deferred). `#142`/`#143` are the lighting issues. -- The drag spine's `IItemListDragHandler` + `ItemDragPayload` + `ShortcutStore` + the shortcut wire all - exist now — reuse them; don't rebuild. -- Don't re-port what WorldBuilder/the toolkit already gives (read `worldbuilder-inventory.md` first for - any dat/render work). - ---- - -## 8. New-session prompt (paste into a fresh session) - -> Continue acdream's D.2b retail-UI track. **Read `docs/research/2026-06-20-window-manager-inventory-handoff.md` first**, then the 2026-06-16 UI deep-dives + synthesis §4 it references. The drag-drop spine (B.1), toolbar shortcut reorder/remove + wire (B.2), and toolbar collapse all shipped + merged to `main` (`abbd97b`). Next, in order: **(A)** a minimal **window manager** — open/close + an `I`-key toggle for the inventory window (today every retail-UI window is always-on at a hardcoded position; `UiHost` has no open/close API); **(B)** the **inventory window** (`gmInventoryUI` nesting paperdoll/backpack/3D-items — `UiItemList` N-cell grid + sub-window mount in `LayoutImporter` + the inventory wire gaps + `InventoryController`; it's the drag SOURCE that completes B.2's drag-from-inventory via a `SourceKind==Inventory` branch in `ToolbarController.HandleDropRelease`); **(C)** the **paperdoll** `UiViewport` (Type 0xD) 3D doll, with a Core→App `IUiViewportRenderer` seam — heaviest, last. Spell bar DEFERRED. Use the full brainstorm → spec → plan → subagent-driven flow per sub-phase; mandatory grep-named→cross-ref→pseudocode→port for any wire format; conformance tests throughout. Reuse the shipped spine (`IItemListDragHandler`/`ItemDragPayload`/`ShortcutStore`/the AddShortcut/RemoveShortcut wire) — don't rebuild it. Resolve objects via `ClientObjectTable.Get(guid)` / `GetContents(containerGuid)`. Start a new worktree off `main`. - -**MEMORY.md index line:** -- [Handoff: window manager → inventory → paperdoll (2026-06-20)](research/2026-06-20-window-manager-inventory-handoff.md) — next D.2b-UI arc after B.1/B.2/collapse (all merged to main `abbd97b`). 3 sub-phases: (A) window manager (open/close + I-key — UiHost has no open/close API today; windows are always-on); (B) inventory window (Stream C, synthesis §4 — UiItemList grid + sub-window mount + wire gaps DropItem 0x1B/GetAndWieldItem 0x1A/ViewContents 0x196 + InventoryController; the drag SOURCE that closes B.2's drag-from-inventory via a SourceKind==Inventory branch); (C) paperdoll UiViewport Type 0xD + Core→App IUiViewportRenderer seam (heaviest). Reuse the shipped drag spine + ShortcutStore + shortcut wire. Spell bar DEFERRED. diff --git a/docs/research/2026-06-21-138-entity-redelivery-handoff.md b/docs/research/2026-06-21-138-entity-redelivery-handoff.md deleted file mode 100644 index 90b12d95..00000000 --- a/docs/research/2026-06-21-138-entity-redelivery-handoff.md +++ /dev/null @@ -1,191 +0,0 @@ -# Handoff — #138: server objects + own avatar don't come back after a teleport (entity RE-DELIVERY) - -**Date:** 2026-06-21 -**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, **NOT merged to main, NOT pushed** (user's call) -**Milestone:** M1.5 "Indoor world feels right" -**HEAD:** `c0b2cf2` (docs) on top of `a15bd3b` (the #145 fix). Tree is clean. - ---- - -> **✅ RESOLVED 2026-06-21 (`bf66fb4`+`0a5f91b`+`aa4a04d`, branch `claude/thirsty-goldberg-51bb9b`; -> pending user visual gate).** The re-delivery diagnosis below was CONFIRMED by cross-reference -> (ACE never clears `KnownObjects` on a teleport so it won't re-send known objects; retail/holtburger -> keep the client object table and re-render from it). **BUT this handoff's recommended fix source -> was WRONG:** `ClientObjectTable` is the INVENTORY model (`ClientObject` has no world -> position/cell/Setup — see `src/AcDream.Core/Items/ClientObject.cs`) and cannot rebuild a render -> entity. The real retained world-object table is **`GameWindow._lastSpawnByGuid`** (parsed -> `WorldSession.EntitySpawn` records with position + Setup + appearance); it survives the collapse -> and is what we re-project from. Shipped fix: `LandblockEntityRehydrator` + `StreamingController. -> onLandblockLoaded` + `GameWindow.RehydrateServerEntitiesForLandblock`, plus a pending-bucket -> persistent-entity rescue for the player-vanish. See `docs/ISSUES.md` #138 + register AP-48. The -> rest of this doc is the (accurate) investigation trail; ignore its `ClientObjectTable` fix advice. - -## What shipped this session - -| Commit | What | -|---|---| -| `a15bd3b` | **#145 FIXED + user-verified** — portals work repeatedly (run in / out / re-enter). Server-authoritative teleport placement. | -| `c0b2cf2` | docs: #138 re-scoped to entity re-delivery + this handoff. | - -**#145 one-liner (context, DONE):** teleport-OUT of a dungeon mis-rooted the player into the -SOURCE dungeon's coordinate frame (stale physics-landblock world-offset overlap after the -streaming recenter), so acdream sent dungeon-framed positions and ACE rejected every move -("failed transition"). Fix = (1) drop the stale source center landblock from physics at the -recenter so `Resolve` falls through to the server position (NO-LANDBLOCK verbatim), (2) place -outdoor teleports immediately (streaming can't progress during a PortalSpace hold), (3) clear a -dangling `CellGraph.CurrCell` when its landblock is removed (else the dungeon-streaming gate -keeps streaming collapsed → only skybox). Detail: ISSUES `#145`, digest -`claude-memory/project_physics_collision_digest.md` (2026-06-20 entry). - ---- - -## THE NEXT WORK — #138: entity re-delivery across a teleport - -**Symptom (user):** after a portal OUT of the 0x0007 dungeon back to Holtburg, the -server-spawned objects (doors, NPCs, portals, chests) don't appear; and after a couple of -round-trips the player's OWN avatar stops rendering too. **Other clients (retail) DO see the -acdream player** — so the server has correct player state; acdream's LOCAL world is incomplete. - -### ✅ ROOT CAUSE (confirmed this session): RE-DELIVERY, not render, not cache - -acdream **unloads** the landblock's server-spawned objects on teleport-IN (the dungeon collapse -unloads neighbours, including Holtburg → `GpuWorldState.RemoveLandblock`). On the way back, -**nothing restores them**, and ACE does **not reliably re-broadcast** them. So they are simply -absent from acdream's world. - -Decisive run (`notan`/`+Je`, walked around 20 s after returning): after teleport-OUT, -`live: spawn` doors = **0**, `[ent] +` appends = **0**, `[ent-flat] server=1` (only the player), -`[dyn] dyn=1`. The server delivered **zero** Holtburg objects on return. (An earlier -`testaccount2` run got ~15 re-sent — but the user confirmed they *still* stayed missing — so -re-delivery is **partial AND unreliable** across accounts/sessions, possibly worsened by the -session's rapid-relaunch churn confusing ACE's per-player known-set.) - -### 🚫 DO-NOT-RETRY (eliminated this session — don't re-investigate) - -| Hypothesis | Why it's dead | -|---|---| -| Tier-1 classification cache (`EntityClassificationCache`) | Re-created live entities get a **fresh monotonic `Id = _liveEntityIdCounter++`** (`GameWindow` ~:3251). The cache is keyed on `Id`, so it's **always a miss** for them. `ACDREAM_DISABLE_TIER1_CACHE=1` is irrelevant. | -| Render-side cull (WALK / `EntityPassesVisibleCellGate` / `DrawDynamicsLast`) | The render path is **fine when entities are present**: at login `[dyn] rootOutdoor=True dyn=54 drawn=33`. The dynamics partition + `DrawDynamicsLast` draw every `ServerGuid!=0` entity (with `MeshRefs>0`). After teleport `dyn=1` only because the flat view itself has only 1 server entity (the player). | -| Storage drop in `GpuWorldState` (AddLandblock record-replace, pending-merge) | The append/pending/flat-view path is correct; when objects ARE received they reach the flat view fine. The gap is that they're **never received** after the teleport. | - -### 🔧 FIX DIRECTION (recommended) - -**Re-hydrate `GpuWorldState` from acdream's OWN retained object table on landblock reload**, -instead of depending on an ACE re-broadcast that doesn't reliably arrive. - -- `ClientObjectTable` (`src/AcDream.Core/Items/ClientObjectTable.cs`, class :41; accessed as - `GameWindow.Objects` :598; populated via `Ingest` on every CreateObject) is the retail - `weenie_object_table` equivalent — it keeps **ALL** objects with their positions/appearance - (see memory `project_object_item_model`: "CreateObject = canonical merge-upsert into - ClientObjectTable"). It is NOT cleared on landblock unload. -- So when `GpuWorldState.AddLandblock(lb)` runs (landblock streams in / reloads), walk - `ClientObjectTable` for objects whose position falls in `lb` and re-build their render - entities (the same `OnLiveEntitySpawnedLocked` mesh-ref build at `GameWindow` ~:3161-3215), - appending via `AppendLiveEntity`. That makes re-delivery independent of ACE. -- **Alternative** (simpler but heavier on memory): treat all in-range server objects as - PERSISTENT across the collapse (like the player — `GpuWorldState.MarkPersistent` :242 + - the rescue/re-inject at `GameWindow` ~:7419-7427), so they survive the unload entirely. The - collapse exists for FPS (don't keep the ~129 ocean-grid neighbour dungeons' objects), so this - must be scoped to the CURRENT landblock's objects only. -- **Cross-check first** (don't guess the protocol): `references/holtburger` - (`client/messages.rs`, the post-teleport handlers + object visibility) and `references/ACE` - (`Source/ACE.Server/...` ObjMaint / landblock visibility / what triggers a re-CreateObject on - a landblock change). Question to answer: **does retail's server release + re-send objects on a - teleport landblock change, and does the client need to signal anything?** If ACE *should* - re-send and isn't, the bug may be acdream not triggering ACE's awareness update — but the - client-side re-hydrate above is robust regardless and is the retail-faithful "client keeps its - object table" model. - -### Key file:line pointers - -- **Entity storage / lifecycle:** `src/AcDream.App/Streaming/GpuWorldState.cs` — - `AppendLiveEntity` :413 (loaded vs `_pendingByLandblock`), `AddLandblock` :199 (pending-merge - :205-215 — **the re-hydrate hook goes here**), `RemoveLandblock` :285 (unload; rescue of - `_persistentGuids` :294-302), `RemoveEntitiesFromLandblock` :464 (near→far demote; - `_onLandblockUnloaded` :495), `DrainRescued` :331, `RelocateEntity` :254, flat view - `RebuildFlatView` :550 / `Entities` :93, `LandblockEntries` :139. -- **Live spawn → WorldEntity build:** `src/AcDream.App/Rendering/GameWindow.cs` - `OnLiveEntitySpawnedLocked` :2696 (dedup `RemoveLiveEntityByServerGuid` :2711; MeshRef build - :3161-3215 — drops the entity if 0 mesh refs :3208; `Id = _liveEntityIdCounter++` :3251; - `AppendLiveEntity` call :3304), rescued re-inject :7419-7427. -- **Object table (the fix's source):** `src/AcDream.Core/Items/ClientObjectTable.cs`, - `src/AcDream.Core/Items/ClientObject.cs` (`Ingest` merge-upsert). -- **Render path (CONFIRMED FINE — for reference only):** `RetailPViewRenderer.DrawInside` :59 → - `InteriorEntityPartition.Partition` (`src/AcDream.App/Rendering/InteriorEntityPartition.cs` - :40 — `ServerGuid!=0 && MeshRefs>0` → `Dynamics` :51-58) → `RetailPViewRenderer.DrawDynamicsLast` - :680 → `WbDrawDispatcher` (`WalkEntitiesInto` :657, `EntityPassesVisibleCellGate` :2227). - `EntitySpawnAdapter` (`OnCreate` :100 / `OnRemove` :176), `ObjectMeshManager` refcount/LRU - (`IncrementRefCount` :333, `DecrementRefCount` :368 — moves to LRU, doesn't free). - -### Side-finding (separate minor bug, NOT #138 — fix later WITH verification) - -The Tier-1 cache has a **demote-vs-unload invalidation asymmetry**: -`RemoveEntitiesFromLandblock` fires `_onLandblockUnloaded` (`GpuWorldState` :495) but -`RemoveLandblock` does NOT — violating the cache's documented "demote OR unload" intent -(`EntityClassificationCache.cs` docstring + the `GpuWorldState._onLandblockUnloaded` comment). -For LIVE entities it's harmless (fresh Ids → always a miss), but for DAT-hydrated scenery -(deterministic Ids that can recur on landblock reload) it could serve stale batches. One-line -fix (add the `_onLandblockUnloaded?.Invoke((lbId & 0xFFFF0000)|0xFFFF)` to `RemoveLandblock`) -but needs its own repro + verification; was tried this session and reverted because it does NOT -fix #138. - ---- - -## Launch / probes / accounts - -Canonical launch (PowerShell — the apostrophe in "Asheron's Call" breaks bash): -```powershell -$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" -$env:ACDREAM_LIVE = "1" -$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000" -$env:ACDREAM_TEST_USER = "notan"; $env:ACDREAM_TEST_PASS = 'MittSnus81!' # single-quote the '!' -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log -``` - -**Accounts / characters used (all spawn near Holtburg, reach the 0x0007 Town Network dungeon):** -- `notan` / `MittSnus81!` → **`+Je`** (guid `0x50000001`). Freshest; what the user prefers now. -- `testaccount` / `testpassword` → `+Acdream` (guid `0x5000000A`). -- `testaccount2` / `testpassword2` → `Horan` (guid `0x5000000B`). - -**Existing diagnostic env vars (already in the build):** -- `ACDREAM_DUMP_LIVE_SPAWNS=1` → `live: spawn guid=… name="Door"/"Chest"/… @0xLLLLcccc` per - CreateObject **received**. The smoking gun for "did the server re-deliver?" (0 lines after a - teleport = not re-delivered). -- `ACDREAM_DISABLE_TIER1_CACHE=1` (A/B; irrelevant to #138 per above). -- `ACDREAM_DUMP_ENTITY=` → `[dump-entity] WALK-REJECT/DRAW` — matches `entity.Id` - (the **counter**, not the guid — hard to target for re-created entities). - -**Throwaway probes used this session (re-add if needed — were reverted):** -- `[ent-flat] server=N total=M player=Y/n lbs=K` — in `GpuWorldState.RebuildFlatView`: count of - `ServerGuid!=0` in the rendered flat view, logged on change. (Player = `_persistentGuids`.) -- `[ent] +0xGUID -> 0xLB loaded|PENDING` / `[ent] rmlb …` — in `AppendLiveEntity` / - `RemoveLandblock`, for `ServerGuid!=0`. -- `[dyn] rootOutdoor=B dyn=N skip-outdoor=K cone-cull=C drawn=D` — in - `RetailPViewRenderer.DrawDynamicsLast` (throttled 1/60 frames). - ---- - -## ⚠️ GOTCHAS (cost real time this session) - -1. **Re-broadcast latency masks the layer.** ACE re-sends objects over **several seconds** after - a teleport (if at all). Closing the window ~1 s after returning shows "no objects" REGARDLESS - of the bug. Always **wait/walk ~15-20 s** after returning before judging. This caused ~4 - inconclusive runs. The reliable signal is the `live: spawn` count, not eyeballing. -2. **Stale-session login failures.** Rapid relaunches leave ACE holding the prior session → - `live: session failed: CharacterList not received` (exit 29) on the next launch. No admin kick - available; wait it out (~minutes) or switch accounts. A *lingering client process* also holds - the slot. -3. **The user does NOT want clients killed/closed by the agent.** Do not `Stop-Process` / - `CloseMainWindow` acdream. Just `dotnet run` when asked; read logs (reading ≠ killing). -4. **`entity.Id != entity.ServerGuid`.** `Id` is a per-session monotonic counter - (`_liveEntityIdCounter++`); `ServerGuid` is the wire guid (e.g. a door `0x7A9B40xx`). Filter - probes by `ServerGuid`. -5. **Visual confirmation requires the user** — they run the in-world actions; the agent reads the - logs. This is the one genuine stop point. - -## Pointers -- ISSUES `#138` (full re-scope writeup + acceptance), `#145` (DONE), `#137` (dungeon collision, OPEN). -- memory `project_object_item_model` (ClientObjectTable model), `project_render_pipeline_digest.md` - (render SSOT — confirms render is fine), `project_physics_collision_digest.md` (#145 detail). -- references: `holtburger` (client behaviour — what a real client sends/expects post-teleport), - `ACE` (server expectations — object visibility on landblock change). diff --git a/docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md b/docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md deleted file mode 100644 index a0c6b543..00000000 --- a/docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md +++ /dev/null @@ -1,198 +0,0 @@ -# Handoff — #145-residual: port the physics frame to CELL-RELATIVE (retail-faithful), retire the streaming center from physics - -**Date:** 2026-06-21 -**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, **NOT merged to main** (user's call: "we can merge later") -**Milestone:** M1.5 "Indoor world feels right" (this is the last blocker for far-town/dungeon round-trips) -**HEAD at handoff:** `a4f0b51` (the #145 reopen) + an uncommitted ISSUES.md #145 root-cause update (committed alongside this doc). -**DECISION (user, 2026-06-21):** Go with **Option B — the architectural fix.** Make the physics position -**cell-relative like retail** and retire the streaming `_liveCenterX/_liveCenterY` from the physics path -(render-only). The user explicitly rejected the targeted patch (Option A) in favor of "done right, never -again." Quote: *"It should be agnostic."* **Start with a brainstorming gate** (this is a multi-commit -physics-frame phase touching the physics↔streaming seam broadly). - ---- - -## What shipped this session (context — already committed) - -| Commit | What | -|---|---| -| `bf66fb4` | **#138 FIXED + user-verified** — server objects re-hydrate from the retained spawn table (`_lastSpawnByGuid`) on landblock reload; ACE won't re-send known objects, so we re-project. Register AP-48. | -| `0a5f91b` | #138 secondary — rescue persistent entities from the pending bucket on unload (player-vanish candidate). | -| `aa4a04d`/`b07825c` | #138 docs + handoff correction (the prior handoff's `ClientObjectTable` advice was WRONG — it's inventory-only; `_lastSpawnByGuid` is the world-object table). | -| `a4f0b51` | **#145 REOPENED** — far-town teleport resolver runaway (residual of the 2026-06-20 source-drop fix). | - -**#138 is DONE and independent of this work** — the re-hydrate (objects come back) is user-confirmed and -the capture proved it is NOT involved in the runaway. It's mergeable as-is. - ---- - -## THE BUG (verified — multi-agent workflow `wf_87607d15-c43` + 3 adversarial verifiers, all `holdsUp=true`, confidence HIGH) - -**Full report:** `…/tasks/w8wm5fln4.output` (synthesis + 3 verdicts) and the workflow transcript dir. -Distilled: - -**It is a cell-membership LABEL cascade, NOT a real free-fall.** Teleport to a FAR town (e.g. -(201,91)=`0xC95B`) places the player CORRECTLY at `0xC95B0001` local `(14.8, 0.3, 12)` (the #145 verbatim -path). Then the per-frame resolve **marches the cell id one landblock south per physics quantum (~33 ms)** -while the **physics body barely moves** (capture: max|Y|≈86 m, Z falls to ≈−135 then walk-back; settles at -the TRUE rest `(-6.124, -30.186, 12.000)`, og=True). The cell-id `lbY` byte counts `0x5B`(91)→`0x00`. The -**`17410`** ACE rejects is a **wire-conversion artifact** — once `lbY` marched to 0, the outbound -`localY = Position.Y − (lbY − _liveCenterY)·192` back-adds 91×192 = 17472. The body never went there. - -**Proximate cause (single source line):** `src/AcDream.Core/Physics/CellTransit.cs:736` -```csharp -cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin); // bool DISCARDED -``` -For a far town the player is placed at the southern landblock edge (world-Y≈0.3) with **stale southward -running velocity** (the teleport never idled the motion state). The first tick crosses Y=0 into landblock -`0xC95A`, which **has not streamed in yet** → `TryGetTerrainOrigin` returns **`false` with -`origin=Vector3.Zero`** (`CellGraph.cs:47-56`; the comment at `CellTransit.cs:732-735` literally -self-documents this "legacy anchor-frame assumption (world frame == block-local frame)"). `(0,0)` flows into -`AddAllOutsideCells` (`:758`) → `GetOutsideLcoord` (`LandDefs.cs:110-111`) does -`ly += floor(worldY/24)` = `floor(-0.088/24) = -1` → cell marches one block south. The CORRECT origin for -`0xC95A` is `(0,-192)`, which would give `floor(191.9/24)=+7` (right region). **The missing −192 rebase is -the bug.** Every next neighbor is also unstreamed → the `(0,0)` cascade self-perpetuates 91×. - -**Why FAR-TOWN only:** Holtburg is the streaming startup center *only because +Je spawns there* (the center -recenters to the login landblock at `GameWindow ~:2879` and on every teleport at `~:5447` — it is NOT -hardcoded to Holtburg). Holtburg works because its neighbors are already streamed (registered), so the -`(0,0)` fallback never fires, and its spawns are mid-block (no Y=0 crossing to arm the cascade). The bug -fires at the EDGE of the streamed region for ANY fresh teleport. **This is the non-agnostic assumption the -user flagged: `(0,0)` = "this landblock is home" — only true at the center.** - -**#138 re-hydrate EXONERATED:** it fired only for Holtburg + the dungeon (log lines 412/498/710), never for -the far town during the runaway (746+); it adds only RENDER entities, never the physics `_landblocks` the -resolver iterates. - ---- - -## THE FIX — Option B: cell-relative physics `Position` (port retail) - -**The retail design (decomp-verified, addresses re-confirmed against live Ghidra patchmem :8081):** -retail stores EVERY physics position **cell-relative** and re-bases the stored origin **in place** on every -placement and every boundary crossing, so the (cell, local) pair can never drift apart. There is **no -streaming "center" anywhere in retail's physics.** Make acdream's physics match this; `_liveCenterX/Y` -becomes a **render-only** concern (the `(lb − _liveCenter)·192` math is correct for placing meshes around -the camera, but must NOT be the physics authority). - -### Retail functions to port (named decomp + Ghidra — verified addresses) - -| Retail symbol | Address | Role | -|---|---|---| -| `struct Position { uint objcell_id; Frame frame }` | `acclient.h:30659` | The position IS the (cell, local) pair. `Frame.m_fOrigin` (`acclient.h:30654`) is LOCAL to the cell, bounded `[0,192)` for outdoor. **No global/world position field exists.** | -| `Position::adjust_to_outside` | `0x00504A40` | Rebases the ACTUAL `this->frame.m_fOrigin` + `this->objcell_id` in place (not a temp). | -| `LandDefs::adjust_to_outside` | `0x005A9BC0` | Recomputes global landcell via `get_outside_lcoord`, rebuilds cell id via `lcoord_to_gid`, **wraps origin into [0,192)** (`origin -= floor(origin/192)*192`). The canonicalizer. | -| `LandDefs::get_outside_lcoord` | `0x005A9B00` | `gX = blockLcoordX + floor(localX/24)`. (acdream `LandDefs.cs:105-112` is a faithful port — but the per-frame pick feeds it a RAW world-frame Y via the `(0,0)` fallback instead of a wrapped local.) | -| `LandDefs::get_block_offset` | `0x0043E630` | The ONLY cross-cell translation: `(dX−sX)·24, (dY−sY)·24` from the two cell ids' landblock bytes; **returns ZeroVector when both cells share a landblock**. A delta of two NAMED cells — never an accumulation vs a moving center. | -| `LandDefs::blockid_to_lcoord` / `lcoord_to_gid` | `0x0043D680` / `0x004A19A0` | Absolute global landcell grid (lcoord 0..0x7f8). No `_liveCenter` subtraction anywhere — the grid origin is the world origin, fixed. | -| `CTransition::validate_transition` | `0x0050AA70` | On each accepted step adopts `curr_pos.objcell_id = check_pos.objcell_id` AND `curr_pos.frame = check_pos.frame` **together** (lockstep), then `cache_global_curr_center`. The local frame is re-expressed into the destination cell during the sweep. | -| `CPhysicsObj::AdjustPosition` / `SetPositionInternal` | `0x00511D80` / `0x00515BD0` | Every placement/teleport runs `adjust_to_outside` → an inconsistent (lbY=0, localY=17410) pair cannot persist. | -| `SPHEREPATH::cache_global_curr_center` | `0x0050C740` | Builds the transient working "global" sphere center from `frame.m_fl2gv·local + frame.m_fOrigin` — cell-local only, NO landblock-world offset, rebuilt from `curr_pos` each step. | - -acdream **already ports** `AdjustToOutside`/`GetOutsideLcoord`/`LcoordToGid`/`BlockidToLcoord` in -`src/AcDream.Core/Physics/LandDefs.cs` (from the #106 work) — WITH the [0,192) wrap (`LandDefs.cs:130-145`). -The gap is that the per-frame outdoor resolve (`CellTransit.BuildCellSetAndPickContaining`) does NOT run the -wrap+rebase; it re-derives `lbPrefix` from a world-XY scan against a `(0,0)`-or-baked-offset origin. - -### acdream divergence sites (the streaming-relative physics frame — what to change) - -- **The frame itself:** physics position is a streaming-relative world `Vector3` - (`worldXY = (lb − _liveCenter)·192 + local`). Sites: `PhysicsEngine.cs:603-614` (the `foreach(_landblocks)` - outdoor membership scan), `:624`, `:858-860` (returns `lbPrefix | (targetCellId & 0xFFFF)`). -- **Baked per-landblock WorldOffset** computed against the MOVING center at load: `GameWindow.cs` origin - computation `~:4992` (and the WorldOffset inverse used outbound at `~:2930-2931` / `~:4992-4993` — note the - synthesis cited `:7763-7765` for the outbound `localY` conversion but a verifier could not locate that - exact line; **re-locate the outbound (cell, local) builder** — it's where `localY = Position.Y − (lbY − - _liveCenterY)·192` happens). -- **The `(0,0)` fallback** (proximate trigger): `CellTransit.cs:736` (discarded bool) → consumed at the seed - `AddAllOutsideCells` (`:758` → `:242`), the containing-pick (`:845`), and the other seed (`:484`). - `CellGraph.cs:47-56` (`TryGetTerrainOrigin` returns `(Zero,false)` for unregistered terrain). -- **The teleport that leaves running velocity live:** `src/AcDream.App/Input/PlayerMovementController.cs` - (NOTE: App/Input, not Core/Physics) — `SetPosition` zeros velocity `~:803` but the same-frame motion path - re-applies the running vector `~:943-980`. A teleport arrival should call the motion-idle path. -- `TransitionTypes.cs ~:2315` (`SetCheckPos` commits the marched cell id without moving position). -- **The recenter:** `GameWindow.OnLivePositionUpdated ~:5447` (drop-source + `_liveCenterX/Y=lbX/lbY`) and the - login recenter `~:2879-2909`. These STAY (render frame), but physics must stop depending on them. - -### Shape of the port (brainstorm this — do not just start coding) - -The end state: a physics `Position { uint ObjCellId; Frame{ Vector3 LocalOrigin∈[0,192) for outdoor; Quaternion } }`, -carried by the player/physics body instead of a streaming-relative world `Vector3`. Cross-cell offsets come -ONLY from `get_block_offset(cellA, cellB)` (port `0x0043E630`). Every `SetPosition`/teleport/cross-landblock -move runs `AdjustToOutside` to re-wrap. `_liveCenterX/Y` is used by the RENDERER (and the outbound wire -conversion derives the wire (cell, local) directly from the cell-relative Position — no center math needed, -which kills the 17410 leak at the source). The streaming `WorldOffset` baked per-landblock can stay for the -RENDER mesh placement but the physics resolve must not read it. - -**This is a large, seam-crossing change.** It touches `PhysicsEngine`, `CellTransit`, `TransitionTypes`, -`CellGraph`, the player controller, the outbound wire builder, and every test that constructs a physics -position. Scope it via `superpowers:brainstorming` FIRST, then write a plan, then execute in reviewable -slices (likely: (1) introduce cell-relative `Position` alongside the world frame; (2) port `get_block_offset` -+ route the resolve through it; (3) make `AdjustToOutside` run on the per-frame path; (4) flip the player -body to cell-relative; (5) make the outbound wire derive from it; (6) make `_liveCenter` render-only; (7) -delete the baked-WorldOffset physics reads). Conformance: replay the captured runaway + the existing -`CellarUp`/`DoorBug`/membership harnesses must stay green. - ---- - -## Apparatus (use this — do NOT rebuild) - -- **The captured runaway:** `desync-capture.jsonl` (worktree root, 72,401 `ResolveWithTransition` records: - `input{currentPos,targetPos,cellId,sphere*,stepUp/Down,isOnGround,moverFlags,movingEntityId}`, - `bodyBefore/After{position,orientation,velocity}`, `result`). Runaway records: cellId `0xC95B0001`→`0xC9000008` - (decimal 3372220424). Velocity at first far-town record = `(3.637,−11.276,0)` (the stale run). -- **Probes (env vars, no rebuild):** `ACDREAM_PROBE_CELL=1` → `[cell-transit] old->new pos=(x,y,z) reason=`; - `ACDREAM_CAPTURE_RESOLVE=` → the JSON capture; `ACDREAM_PROBE_RESOLVE=1` → per-resolve `[resolve]` - lines; `ACDREAM_DUMP_LIVE_SPAWNS=1` → spawn + re-hydrate + recenter lines. -- **The session log of the repro:** `…/tasks/bu932mwj3.output` (cell-transit trail + `[snap]` + teleport - arrivals + the healthy Holtburg cell-transits for comparison). -- **Test harness template:** build `tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs` from the - capture (apparatus = `PhysicsResolveCapture` + the `CellarUpTrajectoryReplayTests` `LiveCompare_*` pattern). - Assert: with `0xC95A` unregistered, the cell-relative resolve keeps cell+local consistent (no march). Include - a non-south case (east-edge spawn) — the cascade is direction-agnostic. - -## Open items / risks / DO-NOT-RETRY - -- **Verifier corrections to the synthesis prose (use the corrected facts):** march rate is ~1 landblock per - physics QUANTUM (~33 ms), not per render frame (~40 capture records per crossing); `max|Position.Y|≈85.67` - (not 61.9); the true settle is `(-6.124,-30.186,12.000)` og=True (not `(21.87,-21.62,-6.50)` — that's a - mid-fall entry); `PlayerMovementController.cs` is under `src/AcDream.App/Input/`. The MECHANISM is unchanged. -- **DO-NOT-RETRY:** the physics digest DO-NOT-RETRY table has NO conflict (it covers cellar-up/door/lip - wedges — different code path). But **do NOT attempt a membership "stickiness"/hysteresis fix** — the table - explicitly disproves stickiness for the lip path; the correct shape is cell-relative storage, not hysteresis. - Also the digest already names this seam: `TryGetTerrainOrigin` "Zero fallback = legacy anchor-frame" + the - [[feedback_latent_bug_masked_by_fallback]] lesson ("a fallback masked a production bug") — this is that - pattern recurring. Read the physics digest first. -- **Map-edge towns:** confirm a destination at the world edge (a needed neighbor landblock genuinely doesn't - exist) resolves sanely under the cell-relative frame (retail handles this via the absolute lcoord grid — - `get_outside_lcoord` clamps; verify the acdream port matches). -- **Outbound wire:** after the port, the wire (cell, local) must derive from the cell-relative Position - directly. Re-locate the current outbound builder (the synthesis's `:7763-7765` was unverified). - ---- - -## Launch / probes / accounts (unchanged) - -```powershell -$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" -$env:ACDREAM_LIVE = "1" -$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000" -$env:ACDREAM_TEST_USER = "notan"; $env:ACDREAM_TEST_PASS = 'MittSnus81!' # single-quote the '!' -$env:ACDREAM_PROBE_CELL = "1" -$env:ACDREAM_CAPTURE_RESOLVE = "\desync-capture2.jsonl" -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log -``` -Account `notan`/`MittSnus81!` → `+Je` (guid `0x50000001`), spawns Holtburg, reaches the 0x0007 Town Network -hub → portals to far towns (the repro: hub → any far town → the runaway arms when you arrive still running). -**The user drives the client lifecycle** — do NOT kill/close clients; `dotnet run` when asked, read logs. -**Visual confirmation requires the user** (the one genuine stop point). - -## Pointers -- ISSUES `#145` (reopened — full verified root cause + the A-vs-B decision), `#138` (DONE). -- Physics digest (SSOT + DO-NOT-RETRY): `claude-memory/project_physics_collision_digest.md` — read FIRST. -- Workflow output (full synthesis + 3 verdicts): `…/tasks/w8wm5fln4.output`; transcripts under - `…/subagents/workflows/wf_87607d15-c43/` (the retail-oracle agent's report is the decomp goldmine). -- Register: AP-36 (the streaming-center gate) + AP-48 (#138 re-hydrate) — the B port will retire/rewrite the - physics half of the streaming-relative-frame divergence; add/update register rows in the porting commits. -- References: `named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` + Ghidra patchmem :8081 (the oracle); - `references/ACE` + `references/holtburger` (the wire/client frame cross-check). diff --git a/docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md b/docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md deleted file mode 100644 index 8ad39e1d..00000000 --- a/docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md +++ /dev/null @@ -1,167 +0,0 @@ -# Handoff — D.2b inventory: B-Controller shipped + visually confirmed; B-Wire next - -**Date:** 2026-06-21 (later than the B-Grid handoff of the same date) -**From:** the session that shipped **B-Controller** (inventory population + burden meter + -captions) and root-caused/fixed the **backdrop wash-out** (a #145 continuation) at the visual gate. -**Predecessor:** `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md` -(window manager + B-Grid). This doc supersedes it for "what's next." -**Branch:** `claude/hopeful-maxwell-214a12`, tip **`c38f098`**. `main` is a clean -fast-forward ancestor — ff `main` + branch fresh, or continue this branch. -**Line numbers drift — grep the symbol.** - ---- - -## 0. What shipped this session (all committed, build + tests green, VISUALLY CONFIRMED) - -**B-Controller** — `InventoryController` (the `gm*UI::PostInit` analogue) binds the imported -`gmInventoryUI 0x21000023` tree by id and populates it from `ClientObjectTable`. F12 (with -`ACDREAM_RETAIL_UI=1`) now shows your live inventory on the dark backdrop: the "Contents of -Backpack" grid, the pack-selector strip, the vertical burden bar ("Burden 17%"), and the captions. - -Commits (after the input handoff's `4e23a7b`): -- `5875ac8` `BurdenMath` encumbrance ports (Core) -- `fb050ae` `ClientObjectTable.SumCarriedBurden` (Core) -- `5e75d2a` `UiMeter` vertical fill (`Vertical`/`FillFromBottom`/`DrawVBar`) -- `89c640a` `InventoryController` bind + populate + burden + captions -- `383e8b7` follow-up (Populate uses `GetContents` only — dropped a subagent's index-fallback workaround) -- `03fbf44` GameWindow wiring (the `InventoryController.Bind` in the inventory-init block) -- `7bb4bd3` divergence rows AP-48..51 + ISSUES/roadmap -- `1ccf07b` Opus phase-boundary review fixes (burden % saturation + equipped-item filter) -- `417b137` **render fixes** (backdrop wash-out [#145 continuation] + captions) — the visual-gate fix -- `c38f098` docs (ISSUES/roadmap: visually confirmed + #145 continuation + overflow follow-up) - -Spec: `docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md`. -Plan: `docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md`. -Tests: **App 532 / Core 1526 green.** `InventoryFrameImportProbe` is a real-dat smoke test -(gated on `ACDREAM_DAT_DIR`) that **locks the ZLevel fix** (asserts each mounted panel's -ZOrder > the backdrop's). - ---- - -## 1. Read first - -- This doc. -- `claude-memory/project_d2b_retail_ui.md` — the B-Controller SHIPPED entry + DO-NOT-RETRY (updated this session). -- `docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md` — the B-Controller spec. -- `docs/research/2026-06-16-inventory-deep-dive.md` §4 — the inventory wire catalog (for **B-Wire**). -- `docs/research/2026-06-16-ui-panels-synthesis.md` §3.3 — the de-duped wire-gap TODO. -- `docs/ISSUES.md` — the **#145** entry (now closed via the continuation), the **D.2b-B SHIPPED** entry, and the new **contents-grid overflow** issue. - ---- - -## 2. Key discoveries (durable, non-obvious — DO-NOT-RETRY) - -1. **THE BIG ONE — a mounted sub-window slot must keep its OWN frame ZLevel, NOT inherit the - base layout root's.** The gm*UI sub-window roots (`0x100001C8` backpack, `0x100001C4` - 3D-items) carry **ZLevel 1000**. `ElementReader.Merge`'s zero-wins-base rule (`ElementReader.cs:161`, - `ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel`) made the mounted slots - (own ZLevel 0) inherit that 1000, and the #145 ZOrder fold (`ReadOrder − ZLevel·10000`) - turned 1000 into ZOrder ≈ −10,000,000 — *behind* the frame's Alphablend backdrop - (`0x100001D0`, ZLevel 100 → ≈ −1,000,000). The backdrop then **overpainted/washed out** the - panels' captions + burden meter + item cells. The **paperdoll** root happens to be ZLevel 0, - so it survived — which is why the B-Grid session believed #145 was "done." Fix - (`417b137`): `LayoutImporter.Resolve` sets `result.ZLevel = self.ZLevel` inside the - `ShouldMountBaseChildren` block. **Root-caused via a one-shot `TextRenderer` sprite-segment-order - dump** (the backdrop's segment was drawing AFTER the panel content) + a live ZLevel probe — when - "drawn but invisible," dump the actual paint order, don't keep theorizing z-math. - -2. **Burden formula (ported into `Core.Items.BurdenMath`):** - `capacity = Str × (150 + clamp(aug×30, 0, 150))` (`EncumbranceSystem::EncumbranceCapacity` - `0x004fcc00`); `load = burden / capacity` (`EncumbranceSystem::Load` `0x004fcc40` — the - decompiler MANGLED the FP divide to `return burden`; cross-checked vs the existing `ComputeMax` - + the r06 doc); `gmBackpackUI::SetLoadLevel` (`0x004a6ea0`) → fill = `clamp(load/3, 0, 1)`, - %text = `floor(load × 100)` **SATURATING at 300%** (computed from the CLAMPED fill, not - unclamped — caught in review). `CACQualities::InqLoad` `0x0058f130`. **acdream has no wire - `EncumbranceVal` yet** → client-side `SumCarriedBurden` fallback (divergence **AP-48**; - B-Wire pins the real wire value). - -3. **`DatWidgetFactory.BuildMeter`'s single-image path is ALREADY correct for the burden meter** - (meter-own DirectState `0x0600121D` = back/track; its one Type-3 child `0x0600121C` = fill) - per `UIElement_Meter::DrawChildren` `0x0046fbd0` (the child = `m_pcChildImage = GetChildRecursive(this, 2)`). - The inventory deep-dive's "0x0600121C = back" label was backwards re: draw role. The only - meter change was a **vertical** fill path (`UiMeter.Vertical`/`FillFromBottom`/`DrawVBar`). - A near-empty fill at low load is CORRECT (Str 290 → 17% → ~3px of a 58px bar), not a bug. - -4. **Captions: drive the HOST `UiText` directly.** The caption elements (`0x100001D7` "Burden", - `0x100001D8` %, `0x100001C5` "Contents of Backpack") resolve to `UiText`. A nested *child* - `UiText` does NOT paint — `AttachCaption` now sets the host UiText's `LinesProvider`/`Centered`/`DatFont`. - -5. **Equipped items excluded** from the pack grid + selector (`CurrentlyEquippedLocation != None`) - — a mid-session self-wield routes them through `MoveItem(item, WielderGuid=player)` into - `GetContents(player)`; retail's gm3DItemsUI shows pack contents only. - ---- - -## 3. Current visual state (F12, `ACDREAM_RETAIL_UI=1`) - -Renders correctly: dark backdrop BEHIND; paperdoll equip slots (generic blue border); backpack -strip ("Burden" + % + vertical bar + side-bag cell + main-pack cell); 3D-items "Contents of -Backpack" grid populated (6-col). **Expected gaps (all are the follow-ups below):** -- Contents grid OVERFLOWS the 96px panel (no scroll) — "part hanging off the window." -- Paperdoll equip slots show a generic blue `UiDatElement` border, not per-slot silhouettes → Sub-phase C. -- The inventory window starts HIDDEN; F12 toggles it (the auto-open debug hack was reverted). - ---- - -## 4. What's next (build order; each gets its own brainstorm → spec → plan → subagent flow) - -**(a) B-Wire — RECOMMENDED NEXT.** Parse the wire `EncumbranceVal` (ACE `PropertyInt 5`) for the -player so the burden bar reads the server value (retires **AP-48**, the client-side sum). Plus the -other inventory wire gaps catalogued in `2026-06-16-inventory-deep-dive.md §4` + the de-duped TODO -in `2026-06-16-ui-panels-synthesis.md §3.3`: builders `DropItem 0x001B`, `GetAndWieldItem 0x001A`, -`NoLongerViewingContents 0x0195`; parsers `ViewContents 0x0196`, `SetStackSize`, `InventoryRemoveObject`; -fix the dropped 4th field on `0x0022` + the dropped error on `0x00A0`; register the unwired parsers; -extend `CreateObject` if any icon/stack fields are still discarded. **Mandatory grep-named → -cross-ref → pseudocode → port** for each wire format; ACE handlers + holtburger fixtures are the oracles. - -**(b) Contents-grid scroll polish.** The 6-col grid shows ALL loose items, overflowing the 96px -panel. Wire the gutter `UiScrollbar` (`0x100001C7`) to the grid + clip the grid to the panel + -scroll the overflow. Filed as an OPEN issue in `docs/ISSUES.md`; was out-of-scope for B-Controller -(spec §10). Small, self-contained. - -**(c) B-Drag.** Inventory cell as a drag SOURCE (reuse the shipped spine — `UiItemSlot` is already a -full drag source/target) + the `SourceKind == Inventory` branch in `ToolbarController.HandleDropRelease`. - -**(d) Sub-phase C — the biggest piece.** Paperdoll: register `0x10000032` (`UiItemSlot`) so equip -slots draw per-slot silhouettes (fixes the generic-blue-slot art) + the `UiViewport` (Type `0xD`) -3D character doll, which needs a new Core→App `IUiViewportRenderer` seam (Code-Structure Rule 2). -Research: `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`. - -(Then D.5.3-remaining: the toolbar mana meter + stack slider + spell shortcuts; vendor/trade/spellbook.) - ---- - -## 5. Caveats / open items - -- **Chat + toolbar #145 z-order eyeball (the input handoff's first ask):** analytically cleared - this session — chat's only non-zero-ZLevel element (`0x1000000E`, ZLevel 900) was already the - backmost top-level sibling, so the #145 fold can't change its order; the toolbar now honors - ZLevel (retail-faithful). No regression seen across ~10 launches. Not separately user-confirmed - in isolation, but low-risk — glance once if convenient. -- The `InventoryFrameImportProbe` real-dat test SKIPS in CI (no dat). It only asserts when - `ACDREAM_DAT_DIR` (or the default `~/Documents/Asheron's Call`) exists. -- `ClientObjectTable.GetContents(playerGuid)` returns `IReadOnlyList` (guids), NOT - `ClientObject`s — a prior research agent's claim was wrong; trust the source. -- The main-pack cell (`m_topContainer 0x100001C9`) uses a placeholder icon (the main pack ≡ the - player, no item IconId) — AP-51. Pin a backpack RenderSurface DID if it bothers you. - ---- - -## 6. New-session prompt (paste into a fresh session) - -> Continue acdream's D.2b retail-UI inventory arc. **Read -> `docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md` first.** B-Controller -> (inventory population + burden meter + captions) shipped + visually confirmed on -> `claude/hopeful-maxwell-214a12` (tip `c38f098`; `main` is a clean ff ancestor — ff + branch -> fresh, or continue). The backdrop wash-out (a #145 continuation: mounted sub-window slots were -> inheriting the base root's ZLevel 1000 → behind the backdrop) is fixed. **Next: B-Wire** — parse -> the player's wire `EncumbranceVal` (PropertyInt 5) to retire the client-side burden-sum fallback -> (AP-48), plus the inventory wire gaps in `2026-06-16-inventory-deep-dive.md §4` / -> `2026-06-16-ui-panels-synthesis.md §3.3`. Use the full brainstorm → spec → plan → subagent-driven -> flow; mandatory grep-named → cross-ref → pseudocode → port for every wire format; conformance -> tests throughout. After B-Wire: the contents-grid scroll polish (wire gutter `0x100001C7`), -> B-Drag (inventory drag SOURCE), then Sub-phase C (paperdoll `UiViewport` doll + `0x10000032` -> UiItemSlot per-slot art). - -**MEMORY.md index line** (already added this session): -- [Project: D.2b retail UI engine](project_d2b_retail_ui.md) — … Inventory B-Controller SHIPPED 2026-06-21. DO-NOT-RETRY: a mounted sub-window slot must keep its OWN frame ZLevel — inheriting the base root's ZLevel 1000 sinks the panel behind the Alphablend backdrop (the #145-continuation wash-out). diff --git a/docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md b/docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md deleted file mode 100644 index 785859ba..00000000 --- a/docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md +++ /dev/null @@ -1,173 +0,0 @@ -# Handoff — D.2b inventory: A + B-Grid shipped, #145 fixed, B-Controller next - -**Date:** 2026-06-21 -**From:** the session that shipped the **window manager (Sub-phase A)**, **B-Grid** (inventory -sub-window mount + grid), and fixed **#145** (ZLevel z-order). All committed on branch -`claude/hopeful-maxwell-214a12`, tip `4904ff4`. Full suite green (2767). -**Predecessor handoff:** `docs/research/2026-06-20-window-manager-inventory-handoff.md` (the -A→B→C arc). This doc supersedes it for "what's next." -**Line numbers drift — grep the symbol.** - ---- - -## 0. What shipped this session - -- **Sub-phase A — window manager.** `UiRoot` named-window registry (`RegisterWindow` / - `ShowWindow` / `HideWindow` / `ToggleWindow` / `BringToFront`), raise-on-click, and F12 → - toggle via the **existing** `InputAction.ToggleInventoryPanel`. `UiHost` forwarders. Spec - `docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md`; plan - `…/plans/2026-06-20-d2b-window-manager.md`. Commits `6409038`/`036db8b`/`882b4dd`/`e3152ad`. - Visually confirmed (F12 toggles, click raises, chat-focus gate). - - **Cross-check correction:** retail's inventory key is **F12** (`ToggleInventoryPanel`), - NOT `I` (`I` = the `Laugh` emote per `retail-default.keymap.txt:246`). The handoff guessed I. - -- **Sub-phase B-Grid — inventory frame.** `UiItemList` N-cell **grid mode** - (`Columns`/`CellWidth`/`CellHeight`/`CellOffset`/`LayoutCells`; single-cell toolbar preserved) - + the **sub-window mount** in `LayoutImporter.Resolve` + the `GameWindow` swap of the A - placeholder for `LayoutImporter.Import(0x21000023)`. Spec - `…/specs/2026-06-20-d2b-inventory-grid-mount-design.md`; plan - `…/plans/2026-06-20-d2b-inventory-grid-mount.md`. Commits `4fd4b09`/`85098f5`/`7d971a2`. - -- **#145 — ZLevel z-order (DONE `45a5cc5`).** The importer mapped `ZOrder ← ReadOrder` only, so - the gmInventoryUI full-window backdrop (`0x100001D0`, ReadOrder 4) painted over the panels - (ReadOrder 1-3). Now `ZOrder = ReadOrder − ZLevel·10000` (higher ZLevel = further back). See §3. - ---- - -## 1. Read first - -- This doc + the two B-Grid spec/plan files above. -- `docs/research/2026-06-16-ui-panels-synthesis.md` §4 (the core-panels build plan). -- `docs/research/2026-06-16-inventory-deep-dive.md` (gmInventoryUI nesting + wire catalog). -- `claude-memory/project_d2b_retail_ui.md` (toolkit crib — updated this session). -- `claude-memory/project_object_item_model.md` (the `ClientObjectTable` — B-Controller's data source). - ---- - -## 2. Decomposition + status - -Sub-phase B was decomposed into four steps (approved this session): - -| Step | Status | -|---|---| -| **B-Grid** — UiItemList grid + sub-window mount | ✅ **SHIPPED** (mount works for all 3 panels; paperdoll equip-slot positions visually confirmed) | -| **B-Controller** — populate grids + burden meter + captions | ⬜ **NEXT** (see §5) | -| **B-Wire** — wire gaps (DropItem/ViewContents/…) | ⬜ pending | -| **B-Drag** — inventory cell as drag SOURCE (reuse the shipped spine) | ⬜ pending | -| **Sub-phase C** — paperdoll doll + per-slot equip art | ⬜ pending (the wrong-slot-graphics fix lives here) | - ---- - -## 3. Key discoveries (durable, non-obvious) - -1. **The nested panels are Type-0 inheritors, not game-class Types.** `gmInventoryUI 0x21000023`'s - three panels — paperdoll `0x100001CD`, backpack `0x100001CE`, 3D-items `0x100001CF` — are - **Type-0, media-less, childless leaves** that nest via `BaseElement`+`BaseLayoutId` - (BaseLayoutId → `0x21000024`/`0x21000022`/`0x21000021`). The research agent's "game-class Type - + recursive Import" model was WRONG — confirmed by dumping `0x21000023`. - -2. **The mount = inheritance carrying base children.** `ElementReader.Merge` dropped the base's - children. The mount (`LayoutImporter.ShouldMountBaseChildren` + the `Resolve` tail) attaches a - base's resolved subtree when the derived element is a pure container (no own children, no own - media) inheriting from a base WITH children. Inert for media-bearing inheritors (close - button/title) and childless style prototypes (vitals/chat/toolbar). - -3. **ZLevel z-order: higher = further back.** `DatWidgetFactory` folds `ZLevel` into `ZOrder` - (`ReadOrder − ZLevel·10000`). ZLevels observed: vitals all **0**; toolbar **0,1,2**; chat - **0,900**; inventory **0,10,50,100** (backdrop 100). Vitals unchanged; **chat + toolbar shifted - to their dat layering — needs a visual regression confirm (see §6).** - -4. **The paperdoll equip slots need `0x10000032` (UiItemSlot) registration.** They resolve to type - `0x10000032`, which the factory does NOT register, so they fall back to a generic `UiDatElement` - sprite (the uniform blue border seen on screen) instead of per-slot silhouettes. This + the - dressed doll is **Sub-phase C**. - ---- - -## 4. Current visual state (F12 with ACDREAM_RETAIL_UI=1) - -The inventory window opens and renders the nested frame: outer chrome + title + the **paperdoll -equip slots correctly positioned** over the backdrop. **Gaps (all expected — next sub-steps):** -- Equip slots draw a generic border, not per-slot silhouettes → **C**. -- Backpack strip + 3D-items contents look empty (the panels mounted; their content is unpopulated) - → **B-Controller**. -- Item cells empty everywhere (no items bound) → **B-Controller**. - ---- - -## 5. B-Controller — the next step - -**Goal:** make the inventory show your live contents — populate the backpack/3D-items grids from -`ClientObjectTable`, drive the burden meter, render the captions. The `gm*UI::PostInit` analogue. - -**READY:** -- `ClientObjectTable.GetContents(containerGuid)` → ordered guids; `Get(guid)` → icon fields - (`src/AcDream.Core/Items/ClientObjectTable.cs`). The player's own pack is already in the table - from the CreateObject stream — **no new wire needed for the own-pack path** (B-Wire is for other - containers + live deltas). -- `UiItemList` grid mode (B-Grid) — set `Columns`/`CellWidth`/`CellHeight` (read the cell pitch - from the nested layout's item template) then `Flush()` + `AddItem()` per item. -- `IconComposer` for the cell icons; `UiItemSlot.SetItem(guid, iconTex)`. -- `ImportedLayout.FindElement(id)` to bind by id (the burden meter, the lists). -- The burden meter is element `0x100001D9` (Type 7 → `UiMeter`); drive fill via - `SetAttribute_Float`-equivalent (`UiMeter.Fill`); load ratio = burden/maxBurden. - -**MISSING / to build:** -- `InventoryController`: find-by-id bind of the burden meter + the backpack/3D-items lists from - `invLayout` (the `ImportedLayout` built in `GameWindow`); subscribe to `ClientObjectTable` - `ObjectAdded/Moved/Removed`; rebuild cells. -- **Grid params:** dump `0x21000022` (backpack) + `0x21000021` (3D-items) to read the item-list - element sizes + cell template (geometry suggests backpack ~1 col, 3D-items ~6 cols of 36×36). -- **Type-0 text captions** ("Burden", "Contents of Backpack") don't render today — `UiDatElement` - doesn't draw text. Decide: promote those Type-0 elements to `UiText`, or a caption pass. -- Burden meter: verify `BuildMeter` produces a valid meter for the backpack meter's child shape - (it may differ from the vitals 3-slice shape). - -**Brainstorm Qs (B-Controller):** Where does `InventoryController` get `invLayout` (expose the -`ImportedLayout` from `GameWindow`, or import inside the controller)? Cell pitch source (dump vs -hardcode 36)? Type-0 caption rendering (UiText promotion vs a caption helper)? - ---- - -## 6. Open items / caveats - -- **⚠ Confirm chat + toolbar didn't regress from the #145 ZLevel fix** (chat ZLevel 900, toolbar - 1,2). The user looked but didn't explicitly confirm; first thing to eyeball next session. -- Type-0 text captions don't render (see §5). -- The B-Drag step reuses the **already-shipped** drag spine (`UiItemSlot` is a full drag source + - drop target; `IItemListDragHandler`/`ItemDragPayload`/`ShortcutStore`/the AddShortcut wire all - exist) — do NOT rebuild it. It needs the inventory cell as a SOURCE + the `SourceKind==Inventory` - branch in `ToolbarController.HandleDropRelease`. - ---- - -## 7. Branch state - -All work is on `claude/hopeful-maxwell-214a12` (tip `4904ff4`), which is `main` (`a391b86`) + -this session's A + B-Grid + #145 commits (a clean fast-forward chain — `main` is an ancestor). -Decide at the start of the next session: **fast-forward `main` to `4904ff4` and start a fresh -worktree off `main`** (matches the prior-arc pattern), or continue this branch. - ---- - -## 8. New-session prompt (paste into a fresh session) - -> Continue acdream's D.2b retail-UI inventory arc. **Read -> `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md` first.** Sub-phase A -> (window manager, F12) and B-Grid (inventory sub-window mount + UiItemList grid) shipped, and #145 -> (ZLevel z-order — the backdrop now sits behind the panels) is fixed — all on -> `claude/hopeful-maxwell-214a12` (tip `4904ff4`; `main` is an ancestor, so ff `main` and branch -> fresh, or continue). F12 now shows the nested inventory frame with the paperdoll equip slots -> correctly positioned, but the backpack/3D-items panels are empty and the equip slots show a -> generic border. **Next: B-Controller** — bind the imported `gmInventoryUI` tree by id and -> populate the backpack/3D-items grids from `ClientObjectTable.GetContents(player)` + drive the -> burden meter (`0x100001D9`) + render the Type-0 captions. Use the full brainstorm → spec → plan → -> subagent-driven flow; mandatory grep-named→cross-ref→pseudocode→port for any wire format; -> conformance tests throughout. Reuse the shipped grid + window manager + drag spine — don't -> rebuild. **First, eyeball chat + toolbar for any z-order regression from the #145 ZLevel fix.** -> After B-Controller: B-Wire (wire gaps), B-Drag (inventory drag SOURCE), then Sub-phase C -> (paperdoll doll + per-slot equip art — the wrong-graphics fix; needs `0x10000032` UiItemSlot -> registration). - -**MEMORY.md index line:** -- [Handoff: D.2b A+B-Grid shipped, #145 fixed, B-Controller next (2026-06-21)](research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md) — window manager (F12) + inventory sub-window mount (inheritance-children, NOT game-class Type) + UiItemList grid all shipped; #145 ZLevel z-order fixed (backdrop behind panels). Next B-Controller (populate grids + burden meter from ClientObjectTable). Paperdoll equip slots need 0x10000032; chat/toolbar ZLevel-regression unconfirmed. diff --git a/docs/research/2026-06-21-d2b-inventory-finish-handoff.md b/docs/research/2026-06-21-d2b-inventory-finish-handoff.md deleted file mode 100644 index fbd03774..00000000 --- a/docs/research/2026-06-21-d2b-inventory-finish-handoff.md +++ /dev/null @@ -1,130 +0,0 @@ -# Handoff — D.2b inventory window finish (Stage 1) shipped + visually confirmed; slot-art + paperdoll next - -**Date:** 2026-06-21 (after B-Wire) -**From:** the session that shipped **B-Wire** (inventory wire layer) **and Stage 1 of the -inventory window finish** (scroll + frame + vertical resize + 102-slot grid), both visually -confirmed at the live gate. -**Branch:** `claude/hopeful-maxwell-214a12`, tip **`1be7e65`** (+ this docs commit). `main` is a -clean ff ancestor — ff `main` + branch fresh, or continue this branch. -**Line numbers drift — grep the symbol.** - ---- - -## 0. What shipped this session (all committed, build + full suite green, VISUALLY CONFIRMED) - -Full suite green (`--no-build`, with the client running): **Core.Net 334 / App 543 / UI 425 / -Core 1530**, 0 failures. - -### B-Wire — inventory wire layer (`b56087b`→`7c006d1`) -The burden bar now reads the server's wire `EncumbranceVal` (PropertyInt 5). Root cause was -**delivery, not binding**: login PD dropped the player's int table, live `0x02CD` was unparsed, and -`ObjectTableWiring` gated non-UiEffects ints out. Plus the full inventory wire pass (DropItem 0x001B -/ GetAndWieldItem 0x001A / NoLongerViewingContents 0x0195 builders; ViewContents 0x0196 / -SetStackSize 0x0197 / InventoryRemoveObject 0x0024 parsers; 0x0022 + 0x00A0 field fixes). Spec/plan -`docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-wire*.md`. Detail: the B-Wire SHIPPED entry -in `claude-memory/project_d2b_retail_ui.md`. - -### Inventory window finish Stage 1 (`366af0c`→`1be7e65`) -Spec/plan `docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-window-finish*.md`. -- **Scroll** — `UiItemList` clip+scroll via the shared `UiScrollable`; gutter scrollbar `0x100001C7` - bound like `ChatWindowController`; mouse-wheel. -- **Frame** — wrapped in the 8-piece bevel chrome (`UiNineSlicePanel`), like vitals/chat/toolbar. -- **Vertical resize** — bottom-edge only (horizontal blocked); the grid + sub-window + scrollbar + - backdrop stretch, paperdoll + side-bags pinned; scrollbar thumb reflects view/content. -- **102-slot grid** — contents grid pads empty frames to the main-pack capacity (default 102); - side-bag column pads to 7. - ---- - -## 1. Read first - -- This doc. -- `claude-memory/project_d2b_retail_ui.md` — the **Stage 1 SHIPPED** entry + DO-NOT-RETRY (updated this session). -- `docs/ISSUES.md` — the **OPEN** "Inventory + equipment slots show the wrong empty-slot background art" issue (the immediate next task) + the two **SHIPPED** entries. -- `docs/architecture/retail-divergence-register.md` — AP-52 (side-bag 7 fallback), AP-53 (main-pack 102 fallback), AP-54 (resize approximation), AP-51 (main-pack icon, still open). -- `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` — for Stage 2 (paperdoll). -- `.layout-dumps/uiitem-0x21000037.txt` — the UIItem cell template states (for the correct empty-slot sprite). - ---- - -## 2. Key discoveries (durable, non-obvious — DO-NOT-RETRY) - -1. **Grid cells must be `Anchors = None`.** `UiElement.DrawSelfAndChildren` runs `ApplyAnchor` on - every child AFTER `OnDraw`; with default `Left|Top` anchors it captured each cell's scroll-0 - position and reset `Top` to it every frame, fighting `UiItemList.LayoutCells`' scroll offset → - the grid "escaped the window" when scrolled. `AddItem` now sets `cell.Anchors = None` so - `LayoutCells` is the sole authority. **The unit tests missed it by calling `LayoutCells` in - isolation, never the draw traversal** — the regression test now drives the `ApplyAnchor` - interaction. (Fix `14ea938`.) -2. **Scroll reuses `UiScrollable` + a whole-row logical clip (no GL scissor)** — mirrors - `UiText.cs:198` (`cell.Visible = top >= 0 && top + h <= Height`). The gutter scrollbar `0x100001C7` - is a factory Type-11 `UiScrollbar` inheriting base `0x2100003E`; bind its `Model` to `grid.Scroll` - + the same scrollbar sprite ids the chat scrollbar uses. -3. **Vertical-resize cascade via anchors.** Frame: `Resizable=true; ResizeX=false; - ResizableEdges=Bottom`. The stretch elements (grid `0x100001C6`, its sub-window `0x100001CF`, - scrollbar `0x100001C7`, backdrop `0x100001D0`, + the content root) get `Anchors=Left|Top|Bottom` - (fixed width, height tracks parent); paperdoll `0x100001CD` + side-bag `0x100001CE` pinned - `Left|Top`. `ComputeAnchoredRect`: Top|Bottom ⇒ `h = parentH − mB − mT`. -4. **Capacity-padding pattern.** The contents grid + side-bag column pad empty `UiItemSlot`s up to - the pack capacity (player `ItemsCapacity` default 102; `ContainersCapacity` default 7) after the - loose-item loop in `InventoryController.Populate`. Padding changed several existing tests' counts - (intended — update, don't revert). -5. **DO-NOT auto-kill the client.** The user manages client lifecycle: launch with a **plain - `dotnet run --no-build`** (no `Get-Process … CloseMainWindow/Stop-Process` preamble). If a rebuild - is blocked by a running client (`MSB3021`/`MSB3027` "locked by AcDream.App"), **ASK the user to - close it** — do not kill it. (`feedback_dont_kill_clients_before_launch`.) This overrides the - CLAUDE.md graceful-close snippet. - ---- - -## 3. Current visual state (F12, `ACDREAM_RETAIL_UI=1`) - -Renders correctly + confirmed: 8-piece bevel frame; vertical bottom-edge resize (expand-only, -372..560 px); contents grid clips to its panel, scrolls (wheel + gutter scrollbar), shows the full -**102-slot** main pack (items + empty frames); side-bag column with bags + empty slots; vertical -burden bar; backdrop covers the window. **Known-wrong (next task):** -- **Empty-slot background art is wrong** — inventory + side-bag empty cells use the TOOLBAR empty - sprite (`UiItemSlot.EmptySprite = 0x060074CF`); paperdoll equip slots show a generic blue - `UiDatElement` border. (User-flagged.) -- **Main-pack cell has no backpack icon** (AP-51, placeholder `tex=0`). - ---- - -## 4. What's next (build order) - -**(a) FIX the empty-slot background art — RECOMMENDED NEXT (the OPEN ISSUE).** Inventory + side-bag -empty cells should use the retail pack-slot frame, not the toolbar's `0x060074CF`. Find the correct -sprite from the UIItem cell template `0x21000037` (`.layout-dumps/uiitem-0x21000037.txt`) empty -state; set it on the cells `InventoryController` creates (an `EmptySprite` override per slot kind, or -a controller-set default distinct from the toolbar's). The paperdoll equip slots' per-slot -silhouettes are Sub-phase C (below). Small, self-contained; brainstorm → spec → plan as usual. - -**(b) Main-pack backpack icon (AP-51).** The main-pack cell (`m_topContainer 0x100001C9`) should show -the equipped-backpack art. Pin the backpack RenderSurface DID (or the equipped-pack `CreateObject` -icon path). - -**(c) Sub-phase C — paperdoll.** The 3D character doll via a Core→App `IUiViewportRenderer` seam -(`UiViewport`, Type `0xD`) + the `0x10000032` `UiItemSlot` registration so equip slots draw per-slot -silhouettes. Research: `2026-06-16-equipment-paperdoll-deep-dive.md`. The heaviest piece. - -**(d) Deferred polish:** container-switching (select a side pack → show its 24-slot contents via the -already-parsed `ViewContents 0x0196`); the side-bag column scrollbar (`0x100001CB`, inert — 7 fit); -shrink-below-default resize (currently expand-only); the selected-target bar + stack split slider -(per the wiki notes the user pasted). B-Drag (drag items between slots) also remains. - -Each gets the full brainstorm → spec → plan → execute flow. - ---- - -## 5. New-session prompt (paste into a fresh session) - -> Continue acdream's D.2b retail-UI inventory arc. **Read -> `docs/research/2026-06-21-d2b-inventory-finish-handoff.md` first.** B-Wire + the inventory window -> finish Stage 1 (scroll, 8-piece frame, vertical resize, 102-slot grid) shipped + visually confirmed -> on `claude/hopeful-maxwell-214a12` (tip `1be7e65`; `main` is a clean ff ancestor). **Next: fix the -> wrong empty-slot background art** — inventory + side-bag empty cells use the toolbar empty sprite -> `0x060074CF`; they need the retail pack-slot frame from the UIItem cell template `0x21000037` (see -> `.layout-dumps/uiitem-0x21000037.txt`). Then the main-pack backpack icon (AP-51), then Sub-phase C -> (paperdoll `UiViewport` doll + `0x10000032` per-slot equip silhouettes). Use the full brainstorm → -> spec → plan → subagent/inline flow. **DO NOT auto-kill the running client** — launch with plain -> `dotnet run --no-build`; if a rebuild is locked by a running client, ask the user to close it. diff --git a/docs/research/2026-06-21-teleport-foundation-handoff.md b/docs/research/2026-06-21-teleport-foundation-handoff.md deleted file mode 100644 index 13478573..00000000 --- a/docs/research/2026-06-21-teleport-foundation-handoff.md +++ /dev/null @@ -1,176 +0,0 @@ -# Handoff — teleport foundation (fresh-look investigation) — 2026-06-21 - -**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, NOT merged. -**Clean baseline commit:** `dd2eb8b` (the Slice 2 revert). Build + tests green here. -**Account for repro:** `notan` / `MittSnus81!` → `+Je`. The character is currently left at a far -outdoor town (landblock ~125,100 = `0x7D64`); portal via the Town Network hub (`0x0007`, an indoor cell) -to reach far towns. - -> **Read this with fresh eyes.** The previous session built a "hold the player until the destination -> loads" teleport feature, hit a wall, and reverted it. This doc gives you the **evidence and the open -> questions** — NOT a prescription. You are explicitly invited to question every assumption below, -> including whether a "hold" is the right model at all. Investigate first (read-only), report, then -> brainstorm the fix with the user. **No band-aids** (the user's words: "feels shaky and bandaid"). - ---- - -## TL;DR - -Teleporting in acdream feels bad: **long transitions, the character STOPS at the portal instead of -running through, FPS sags after portaling, and a building lost its collision after a death→lifestone -teleport.** The previous session traced the "long transition" to a real root: **the destination -landblock does not stream in fast (or completely) during/around a teleport** (the #138 streaming gap), -likely amplified by **`_datLock` contention** from ACE's CreateObject flood on arrival. A "hold until -loaded" gate just made every teleport wait ~10 s for a load that wasn't happening — so it was reverted. -**The real work is the foundation: make teleport-destination streaming fast AND complete, and fix the -post-teleport FPS leak.** Then (and only then) a retail-style visual cover is polish, not a crutch. - ---- - -## What was built, and what was reverted - -The original goal was **Work item B: the retail teleport flow** (fade → portal-tunnel → hold-until-loaded -→ place; covers login/logout/death/portal). Design + plan are committed and still valid as *aspiration*: -- Spec: `docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md` -- Plan: `docs/superpowers/plans/2026-06-21-retail-teleport-flow.md` (22 TDD tasks, 5 slices) -- Prior research: `docs/research/2026-06-21-teleport-issues-handoff.md` (the TAS state-machine decomp). - -Shipped, then status: -- **Slice 1 — `TeleportAnimSequencer`** (pure 7-state TAS machine, `src/AcDream.Core/World/`): **KEPT.** - Dormant, unwired, fully unit-tested (29 tests). Harmless. Reusable later. -- **Slice 2 — the readiness-gate hold** (outdoor teleport holds until the physics landblock is loaded): - **REVERTED** (`dd2eb8b`, reverting `ad8c24e..c880973` back to `00ef47e`). Outdoor teleports now place - **immediately** again (fast). See "Why reverted" below. -- **Slices 3–5 (the TAS animation/controller, login/logout/death unification): PARKED** (never started). - -### Why Slice 2 was reverted (the key learning) -Slice 2 gated outdoor arrival on "is the destination physics landblock resident?" and held otherwise. -Live testing showed **every outdoor teleport froze ~10 s then force-placed**, because the destination -landblock **never became resident during the hold** (`lbs=0` the whole time). The hold was band-aiding a -foundation problem (slow/incomplete streaming) instead of fixing it — and it didn't even prevent the -#145 edge cascade it was designed for (it force-snapped onto NO-LANDBLOCK after the timeout regardless). - ---- - -## Root-cause findings so far (verify, don't trust) - -A 3-agent read-only investigation (workflow `wf_8b67a9d1-35c`, all high-confidence) + manual -source-verification found **two distinct problems** behind the 10 s freeze: - -### 1. A real (now-reverted) bug: `IsLandblockLoaded` key mismatch -The Slice 2 gate queried `IsLandblockLoaded(destCell & 0xFFFF0000)` (e.g. `0x7D640000`), but streaming -stores landblocks under the `EncodeLandblockId` form — low 16 bits = `0xFFFF` (e.g. `0x7D64FFFF`) -(`src/AcDream.App/Streaming/StreamingRegion.cs:98-99`). A raw `ContainsKey` never matched → the gate was -permanently `NotReady`. This was **fixed** (`c880973`, normalize the key + the regression test the -original missed) then **reverted along with the rest of Slice 2**. *If a future hold is reintroduced, the -predicate must normalize the key (the fix is preserved in history at `c880973`).* - -### 2. The actual foundation problem: the destination doesn't stream in fast/complete -Even with the key bug fixed, `lbs=0` (zero landblocks resident) for the entire hold — the destination -genuinely was not loaded. Evidence + leads: -- The streaming machinery is *commanded* correctly: on a dungeon-OUT teleport the gate logs - `streaming: dungeon EXIT-expand -> (Lx,Ly)` and the observer is correctly pinned to the destination - during the hold (`src/AcDream.App/Rendering/GameWindow.cs` ~7441-7459 PortalSpace observer-pin; - `DungeonStreamingGate.cs:44-45` returns `InsideDungeon=false` for a teleport hold; - `StreamingController.cs` ~130-163 Tick dispatch, ~279-297 `ExitDungeonExpand`, ~161-163 `DrainAndApply` - runs unconditionally). So the *command* path is not obviously gated on `InWorld`. -- **Yet nothing loads during the hold, and the landblock appears the instant the player flips to - `InWorld`.** The leading hypothesis (MEDIUM confidence — NOT proven) is **`_datLock` starvation**: ACE - floods CreateObjects on arrival; `WorldSession.Tick` drains them in a tight loop - (`src/AcDream.Core.Net/WorldSession.cs` ~598-607, no yield), each under `lock(_datLock)` - (`GameWindow.cs` ~2679-2682), and the streaming worker also needs `_datLock` to build a landblock - (`BuildLandblockForStreaming` — confirm the exact site; the investigation cited a `_datLock` acquire on - the build path). So the render-thread CreateObject flood may block the background streamer for the whole - arrival window. **This is the #1 thing to confirm or refute.** -- `AddLandblock` registers terrain + cells + portals **atomically** (`PhysicsEngine.cs` ~66-74), and the - spatial lookups (`SampleTerrainZ`, `TryGetLandblockContext`) find landblocks by world-XY **bounds**, not - by dictionary key — which is why a force-placed player grounds correctly the instant the LB appears. - -### 3. The lost-collision symptom (death→lifestone → building has no collision) -Not yet root-caused. Hypothesis: the destination loaded **incompletely** — terrain present but the -building/cell **collision** (`ShadowObjects` / the per-cell shadow list) not registered. See -`memory/feedback_retail_per_cell_shadow_list.md` (retail collision = per-cell list with portal-aware -registration). Likely the same root as #2 (incomplete async load), but **verify independently** — it may -be a distinct registration-order bug. - -### 4. FPS sag after portaling (Work item C) -Separate known issue (a cumulative leak). Candidates from the prior research: streaming/entity state not -freed on teleport round-trips, `_lastSpawnByGuid` / `GpuWorldState` accumulation, dungeon collapse/expand -not cleaning up. The `_datLock` contention also contributes to per-frame hitching during the flood. -**Note:** `ACDREAM_PROBE_RESOLVE=1` is itself a massive FPS killer (it `Console.WriteLine`s every physics -resolve — a 561k-line log). Do NOT measure FPS with it on; it confounds everything. - ---- - -## The investigation to run (the new session's job) - -Root-cause, read-only, then report + brainstorm. Core questions: -1. **Why is the teleport destination not resident fast?** Confirm/refute the `_datLock` starvation - hypothesis: instrument (lightly) the streamer worker's load completions and the `_datLock` hold time - during an arrival, and see whether the worker is blocked by the CreateObject flood. Is the async - load→apply pipeline gated, starved, or just slow? Does the dungeon collapse→EXIT-expand transition add - latency? -2. **Why does collision get dropped** after a teleport (the lost-building-collision)? Trace where cell / - ShadowObject collision is registered during streaming and what order vs terrain; is it skipped or - raced on a teleport arrival? -3. **What leaks** (Work item C) across teleport round-trips? Capture landblock + entity counts across N - teleports; find what grows unbounded. -4. **What does retail actually do?** The decomp (`docs/research/named-retail/`) + the prior TAS research - show retail plays a tunnel animation that *covers a synchronous-feeling load*, gated on - `position_update_complete` (the DDD interrogation). Retail's load is fast within the tunnel. So the - question isn't "hold vs no-hold" in the abstract — it's **"how do we make the load fast + complete,"** - after which either a short hold-with-cover OR place-immediately becomes viable. - -## Open DESIGN questions to reconsider with fresh eyes (don't assume the prior answers) -- **Hold vs place-immediately.** The reverted baseline places immediately (fast, but risks pop-in / - the #145 edge cascade on a not-yet-streamed edge arrival). Retail holds behind a tunnel anim but loads - fast. Which model fits once streaming is fast? (AC2D, a reference, sends movement keys + trusts server - Z and does no client-side hold — worth weighing.) -- **The `PortalSpace` input-freeze.** Today entering a teleport freezes input (`OnTeleportStarted` sets - `PlayerState.PortalSpace`), so the **character STOPS at the portal** instead of running through. The - user explicitly wants "run through the portal." Is freezing input on portal-enter even correct? What - does retail do (keep momentum? freeze?)? This is independent of streaming and may be its own fix. -- **The #145 edge cascade.** With place-immediately restored, a teleport that arrives near a landblock - EDGE onto an unstreamed neighbour can still march the cell label + free-fall Z (the original #145 - residual). The hold never actually fixed this. The real fix is fast/complete streaming so the arrival - lands on a loaded world; failing that, the cell-relative physics frame (#145 Slices 1–3, already - shipped) limits it. - ---- - -## Apparatus / how to reproduce + diagnose - -- **Launch** (PowerShell; client lifecycle is the USER's — launch with plain `dotnet run`, do NOT - pre-close clients; if a rebuild is locked by a running client, ASK): - ```powershell - $env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" - $env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" - $env:ACDREAM_TEST_USER="notan"; $env:ACDREAM_TEST_PASS="MittSnus81!" - dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log - ``` - Build green FIRST (`dotnet build src\AcDream.App\AcDream.App.csproj -c Debug`); launch uses `--no-build`. -- **Repro the bad teleport:** in-world, portal via the hub (`0x0007`) to a far town, or recall to - lifestone. Watch the `live: teleport started` → `teleport complete` gap and `[snap] ... NO-LANDBLOCK - (lbs=0)`. -- **Diagnostics:** `ACDREAM_PROBE_RESOLVE=1` exists but is FPS-toxic + confounding — prefer a *new, light* - probe (e.g. log streamer load-completions + `_datLock` hold-time during an arrival). The prior apparatus - logs (`launch-gate-retest*.log`, `launch-clean-noprobe*.log`) are in the worktree root but transient — - the evidence above is the durable summary. -- **It's a Debug build** — FPS is not representative; a `Release` build is far faster. Build Release if you - want a real frame-rate read. - -## Clean baseline — what NOT to touch -- `dd2eb8b` is the clean baseline: outdoor teleports place immediately, Slice 1 sequencer dormant. -- Do NOT re-introduce the Slice 2 hold without fixing the streaming foundation first (that's the band-aid - we removed). -- Frozen/shipped phases per the milestones doc remain off-limits. - -## Key references -- Render/physics digests: `claude-memory/project_render_pipeline_digest.md`, - `claude-memory/project_physics_collision_digest.md` (DO-NOT-RETRY tables). -- Streaming refs: `memory/reference_two_tier_streaming.md`, `memory/reference_indoor_cell_tracking.md`. -- `memory/feedback_apparatus_for_physics_bugs.md`, `memory/feedback_retail_per_cell_shadow_list.md`, - `memory/feedback_verify_subagent_claims_against_source.md` (read cited code yourself; agents fabricate). -- ISSUES: #138 (teleport-OUT EXPAND gap), #145 (edge cascade), Work item C (FPS leak) — update - `docs/ISSUES.md` with the new symptoms (lost-collision-after-teleport; char-stops-at-portal) as part of - the investigation. diff --git a/docs/research/2026-06-21-teleport-issues-handoff.md b/docs/research/2026-06-21-teleport-issues-handoff.md deleted file mode 100644 index 468506c3..00000000 --- a/docs/research/2026-06-21-teleport-issues-handoff.md +++ /dev/null @@ -1,136 +0,0 @@ -# Handoff — teleport issues cluster (2026-06-21) - -**Branch:** `claude/thirsty-goldberg-51bb9b` — committed, NOT merged ("merge later"). -**Account:** `notan` / `MittSnus81!` → `+Je`. Portal via the Town Network hub (`0x0007`) to far towns. - ---- - -## What shipped this session (context) - -#145 **cell-relative physics frame (Option B)**, Slices 1–3 + 7 (`438bb68`→`403a338`). The carried-anchor -fix closes the far-town cascade **for a STREAMED-terrain arrival** — live-verified across ~10 far-town -landblocks over 2 sessions, zero march. Design + plan: -`docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md` + the matching plan. -Adversarial review passed (2 flagged issues were verified false positives). Slices **4–6** (contact-plane / -walkable cell-relative + `_liveCenter` fully render-only — invisible architectural completeness) remain. - ---- - -## Work item A — #145 RESIDUAL (reopened): unstreamed-arrival-near-edge cascade - -**Trigger:** a teleport that arrives onto a **NOT-YET-STREAMED** landblock **near an edge** → the cascade -recurs (+ a Z free-fall). **Evidence** (`launch5.log`): -- `[snap] claim=0xC98C0028 pos=(113.666,190.259,22.010) branch=NO-LANDBLOCK (lbs=0) -> verbatim` — arrival - local Y=190.3, **1.7 m from the 192 edge**, destination not streamed → cell marches `0xC98C → 0xC9FE` - (+2 lbY/tick), Z free-falls 22→−19; outbound wire sends the marched cell `C9FE0031` + compensating garbage - `localY=−21684` → ACE `MOVEMENT SPEED` / `failed transition` → **player stuck**. -- Same session, **mid-block** unstreamed arrival `0x977B000C` (Y=73.8) → Z free-fall only, **no march**. -- Hub `0x00070133` **VALIDATED** (cells resident) → fine. - -**Root (HYPOTHESIS — not apparatus-confirmed):** outdoor teleport places immediately but the destination -streams a few ticks later (`streaming: dungeon EXIT-expand -> (201,140)` logs just after placement). During -the gap the resolve runs against an empty world; at an edge the player crosses into the **unstreamed -neighbour**, where the carried anchor (which computes to `(0,0,0)` at the recentered center) gives no -protection. **Same root as the Z free-fall** (`#135`/`#138` placed-before-streamed gap). - -**⚠️ DO NOT guess-patch** (original #145 burned 5 attempts). **Apparatus first:** add a diagnostic that logs -the **anchor value + the engagement-guard state** in `PhysicsEngine.ResolveWithTransition` at the crossing -tick (behind a probe flag), reproduce a `0xC98C`-style arrival once, and confirm whether the anchor -**disengages** (CellId/CellPosition landblock divergence) or the **body is repositioned into a marched frame -first**. *Then* the fix is a known quantity. - -**Likely fix:** a **streaming-gap HOLD** — freeze the per-tick resolve (no integration/gravity, no membership -march) while the player's landblock is unloaded, until it streams in (a few ticks). The async-streaming -equivalent of retail's synchronous load; would fix **both** the cascade and the Z plunge. -**Note:** this may be **subsumed by Work item B** — the retail teleport flow holds until the world loads, so -there's no unstreamed gap to begin with. Decide A-vs-B ordering at brainstorm. - ---- - -## Work item B — KEYSTONE FEATURE: the retail teleport flow - -**Symptoms (user-reported; all ONE root):** -1. Camera **floats** to the destination on teleport (disliked). -2. Teleport **takes too long** / feels off vs retail. -3. Player **input is not locked** during teleport. - -**Retail behaviour (user = oracle, treat as axiom):** teleport plays an **animation** (portal effect), holds -input + camera, **waits for the world to load**, then exits/places the player. Used for **ALL** teleportation: -**login, logout, death, portal.** - -**Insight:** acdream places **immediately** (raw placement → the floating camera + the unstreamed gap). A -faithful retail teleport flow (anim + input/camera lock + **hold-until-loaded** + single exit point) fixes the -float, the timing, the input-lock, **and very likely Work item A.** This is the keystone. - -**This is a FEATURE → resume `superpowers:brainstorming`** (user is the retail oracle on look/feel). The -3-agent research (workflow `wf_61501a7a-1d4`) is DONE — findings below. **Extend acdream's machinery, don't -rebuild.** - -### RETAIL flow (decomp-verified) — `gmSmartBoxUI` teleport **animation state machine** - -`TeleportAnimState` enum (`acclient.h:6871`): the client plays a **fade + portal-tunnel animation that COVERS -the world-load wait** — used by ALL of login / logout / death / portal: - -``` -TAS_OFF(0) → WORLD_FADE_OUT(1, ~1s) → TUNNEL_FADE_IN(2, ~1s) → TUNNEL(3 = HOLD for world load) - → TUNNEL_CONTINUE(4, min ~2s) → TUNNEL_FADE_OUT(5, ~1s) → WORLD_FADE_IN(6, ~1s) → OFF -``` -- State machine: `gmSmartBoxUI::UseTime` (pc 219400); begin `BeginTeleportAnimation` (218888); end `EndTeleportAnimation` (218994). -- **The world-load HOLD** is `TAS_TUNNEL(3)`, gated on `SmartBox::teleport_in_progress` (pc 90815) = `(player != 0) && (position_update_complete == 0)`; `position_update_complete` flips to 1 when the **DDD interrogation / position update** completes → animation advances. **This is the "exit when the world has loaded" the user wants.** -- **Input lock:** `PlayerModule::SetLockUI` sets bit `0x1000000` in `options2_` (pc 486713) for the whole animation; cleared at `TAS_OFF`. -- **Unification:** **death + logout share** the flow via `logOffRequested`; **login** enters via `RecvNotice_BeginEnterWorld`; **portal** starts directly at `TAS_TUNNEL` (skips the fade-out). Sounds: `Sound_UI_EnterPortal`(0x6A) / `ExitPortal`(0x6B). Exit callback `SendLoginCompleteNotification` (pc 367516). -- Missing from decomp: exact `TELEPORT_ANIM_FADE_TIME` / `MIN_CONTINUE_TIME` constants; the DDD code that sets `position_update_complete`. - -### acdream TODAY — what exists, what's missing - -acdream ALREADY has the *skeleton*: `PlayerState.PortalSpace` **input lock** (`PlayerMovementController.cs:840-854`) -+ a **hold-until-readiness** controller (`TeleportArrivalController.cs:56-105`; `GameWindow.OnTeleportStarted` -:5487, `OnLivePositionUpdated`/`BeginArrival` :5394, `PlaceTeleportArrival` :5443-5478, readiness :5419-5439). - -**The gaps (the actual work):** -1. **NO animation / visual cover.** During the hold the camera is frozen at the OLD spot, then `PlaceTeleportArrival` **snaps** both cameras (`:5464-5469`, no fade/lerp). That snap + the world streaming in *is* the "floating" the user dislikes. → **Build a client teleport-anim state machine (TAS-shaped: fade-out → tunnel/hold → fade-in) that covers the wait.** -2. **Readiness gate is too weak → this is the #145 residual.** Outdoor readiness gates on `SampleTerrainZ != null` (`:5436`) — terrain heightmap can sample BEFORE the collision **landblock cells** load, so the player is placed onto a `branch=NO-LANDBLOCK` world → the unstreamed-arrival cascade + Z free-fall (Work item A). → **Gate on the full landblock being loaded** (`teleport_in_progress`-equivalent). **Fixing this likely closes Work item A.** -3. **`yaw` still updates during the hold** (`MouseLook` not frozen, gotcha) — lock it too. -4. **Login / logout / death NOT unified.** Login uses inline auto-entry guards (`:1036-1065`) that DUPLICATE teleport's readiness (`:5419-5439`) + recenter (`:2874-2881` vs `:5364-5370`). **Logout does not exist; death only feeds `Chat.OnPlayerKilled` (`:2569`), no game effect.** → Route all four through one arrival/anim controller; de-dupe readiness + recenter. - -### Design direction (for the brainstorm) - -Build a **client-side teleport animation + arrival controller** (port the TAS state machine shape) layered on -the existing PortalSpace lock + TeleportArrivalController hold: (a) a fade/tunnel **visual cover** over the -world-load wait (kills the float); (b) **strengthen the readiness gate** to the full landblock (kills the #145 -residual + the Z free-fall); (c) **unify** login/logout/death/portal entry points + de-dupe the readiness/recenter -logic; (d) tune timing so it doesn't feel longer than retail (the current 600-frame/10s timeout is the worst case). - ---- - -## Work item C — FPS leak after teleports (perf bug, SEPARATE) - -**Symptom:** FPS drops low after a couple of teleports (cumulative). -**Hypothesis:** a streaming/entity **leak** — landblocks or render/physics entities not freed on teleport, -accumulating across round-trips (candidates: dungeon collapse/expand not cleaning up; `RehydrateServerEntities` -/ `_lastSpawnByGuid` accumulating; `GpuWorldState` entities not pruned). **NEXT:** capture FPS + landblock / -entity counts across N teleports (`ACDREAM_DUMP_LIVE_SPAWNS=1` + streaming logs + the DebugPanel counts), find -what grows unbounded. Independent of A and B. - ---- - -## Pointers / apparatus - -- **Logs (worktree root):** `launch5.log` (#145 residual capture — the `0xC98C` stuck-at-`−21684` run), - `launch4.log` (Z free-fall + a 3-far-town round-trip), `launch3`/`launch2` (clean streamed-arrival runs). -- **#145 record:** `docs/ISSUES.md` #145 (reopened, with the trigger table) + the physics digest banner - (`claude-memory/project_physics_collision_digest.md`). -- **Probes (no rebuild):** `ACDREAM_PROBE_CELL=1` (`[cell-transit]`), `ACDREAM_PROBE_RESOLVE=1`, - `ACDREAM_CAPTURE_RESOLVE=`, `ACDREAM_DUMP_LIVE_SPAWNS=1`. -- **Decomp oracle:** `docs/research/named-retail/` (grep `class::method`); for the teleport flow, look at the - portal/teleport + DDD-loading + animation paths. -- **Wire cross-check:** `references/holtburger` (client teleport flow) + `references/ACE` (server teleport / - `MOVEMENT SPEED` validation — the `failed transition` rejects). - -## Suggested order (brainstorm to confirm) - -1. **B first** (retail teleport flow) — it's the keystone and likely subsumes **A**. Brainstorm → spec → build. -2. Re-test **A**: if the hold-until-loaded flow eliminates the unstreamed gap, the #145 residual is gone; if a - narrow edge case survives, apply the diagnostic + targeted hold. -3. **C** (FPS leak) — independent; can run in parallel as its own investigation. -4. (Background) Slices **4–6** — the invisible Option-B completeness, unrelated to this cluster. diff --git a/docs/research/2026-06-22-container-switching-handoff.md b/docs/research/2026-06-22-container-switching-handoff.md deleted file mode 100644 index 168e6709..00000000 --- a/docs/research/2026-06-22-container-switching-handoff.md +++ /dev/null @@ -1,54 +0,0 @@ -# Handoff — D.2b inventory container-switching (next clean win) - -**Date:** 2026-06-22 (banked at the end of the empty-slot-art session, per [[feedback_session_handoff]]) -**Branch:** `claude/hopeful-maxwell-214a12`, tip `1f8dd7a` (+ this docs commit). `main` is a clean ff ancestor (68 commits behind). -**Status:** SCOPED, not started. Brainstorm explored the wire + controller; this captures the design sketch so a fresh session executes it with the live-wire round-trip getting proper attention. Run the full brainstorm → spec → plan → execute flow. -**Line numbers drift — grep the symbol.** - ---- - -## Why this is the next pick - -After the empty-slot-art arc (inventory contents/side-bag/main-pack empty cells now dat-resolve their art — see [[project_d2b_retail_ui]] + the empty-slot handoff), the side-bag column is **rendered but inert**: clicking a bag does nothing. Container-switching makes it functional AND closes the **AP-56** deferral (the selected-container triangle + green/yellow selection square live here). The user picked this over the paperdoll (Sub-phase C) because the paperdoll's empty equip slots are entangled with the heavy 3D doll viewport (retail empties show the doll), whereas container-switching is self-contained. - -This is the **first inventory feature with live two-way wire interaction** (a `Use` → `ViewContents` round-trip), a step up from the read-only display work so far — hence executing it fresh rather than tired. - -## Read first - -- This doc. -- `claude-memory/project_d2b_retail_ui.md` — the D.2b SSOT + the empty-slot-art SHIPPED entry (the resolver + AP-55/AP-56). -- `claude-memory/reference_retail_inventory_paperdoll.md` — the player-facing spec: the "preferred pack" auto-fit rules + the 7-side-bag / 102-main-pack capacities. -- `docs/architecture/retail-divergence-register.md` — **AP-56** (the selected-container indicator deferred — this phase retires it) + AP-53 (container-switch noted as deferred). - -## What's already in place (the wire layer is DONE) - -- **`InteractRequests.BuildUse` (`0x0036`)** — open/use a container by guid (`src/AcDream.Core.Net/Messages/InteractRequests.cs:38`). This is how retail opens a container. -- **`GameEvents.ParseViewContents` (`0x0196`)** — the server's full contents list `(ContainerGuid, [guid, containerType]×N)` (`src/AcDream.Core.Net/Messages/GameEvents.cs:372`). Handler at `GameEventWiring.cs:260`. -- **`WorldSession.SendNoLongerViewingContents` (`0x0195`)** — close a container view (`WorldSession.cs:1212`). -- **`ClientObjectTable.GetContents(containerId)`** — the per-container item index (`src/AcDream.Core/Items/ClientObjectTable.cs:313`). -- **The standing TODO** at `GameEventWiring.cs:256`: `ViewContents` currently does an **additive merge**; container-open must treat it as a **full REPLACE** (flush the container's prior membership first) so server-side removals don't linger. **This phase consumes that TODO.** - -## Design sketch (to be confirmed in the brainstorm) - -1. **Open-container state in `InventoryController`.** Add an `_openContainer` field (default = the player guid / main pack). `Populate` shows `GetContents(_openContainer)` instead of always the player. Repaint when the open container's membership changes (extend `Concerns`). -2. **Click a side-bag cell → open it.** The side-bag `UiItemSlot`s already carry the bag guid (bound in `Populate`). On click: set `_openContainer = bagGuid`; `SendUse(bagGuid)` (opens it server-side); when the previous container was a side bag, `SendNoLongerViewingContents(prevGuid)`. Click the main-pack cell (`0x100001C9`) → reset `_openContainer` to the player. -3. **`ViewContents 0x0196` → full REPLACE.** Flush the container's membership in `ClientObjectTable` before recording the entries (the TODO). Then `InventoryController` repaints the grid for `_openContainer`. -4. **Selected-container indicator (retires AP-56).** Draw the open-container triangle `0x06005D9C` + the green/yellow selection square on the selected side-bag cell — a new `UiItemSlot` "selected" overlay (model it on the existing `DragAccept` overlay in `UiItemSlot.OnDraw`; guard `id != 0` per [[feedback_ui_resolve_zero_magenta]]). -5. **Caption.** "Contents of Backpack" → the open container's name when a side bag is selected (the caption host is already driven by `InventoryController.AttachCaption`). - -## Open questions for the brainstorm - -- **The green/yellow selection-square sprite id** — the triangle is `0x06005D9C` (from the container prototype `0x1000033F` child `0x10000450`); the selection square's id is NOT yet found. Dump `0x21000037` / the gmBackpackUI layout (`0x21000022`) for the selected-container state media, or cdb a live retail client with a side bag open. -- **Are a side bag's contents pre-known, or do they arrive only on open?** Likely the latter (retail sends contents on open via `ViewContents`, not upfront) — so the `Use` round-trip is required, and `GetContents(bagGuid)` is empty until the reply lands. Confirm against a live capture (WireMCP on `127.0.0.1:9000`, or watch the `ViewContents` handler at the live gate). -- **Preferred-pack semantics** (from `reference_retail_inventory_paperdoll`): opening a pack makes it the "preferred" pack for auto-fit. Out of scope for the first cut (display-only switching), but note it. -- **Empty-container display** — a side bag with no contents shows an empty 24-slot grid (its `ItemsCapacity`, default 24 per AP-52/the wiki). Pad like the main pack does. - -## Acceptance / scope - -- First cut: click a side bag → its contents show in the grid; the selected bag shows the triangle + selection square; click the main pack → back. `ViewContents` is a full replace. Build + full suite green; **visual gate** (F12, the user clicks bags and confirms the grid switches + the selected indicator). -- Retires **AP-56** (selected-container indicator) in the same commit that draws it. -- Out of scope: preferred-pack auto-fit, drag-INTO a side bag (that's B-Drag), the side-bag column scrollbar (`0x100001CB`, inert — 7 fit). - -## New-session prompt - -> Continue acdream's D.2b retail-UI inventory arc. **Read `docs/research/2026-06-22-container-switching-handoff.md` first.** The empty-slot art shipped + was visually confirmed (`claude/hopeful-maxwell-214a12`, tip `1f8dd7a`). **Next: inventory container-switching** — click a side bag → show its contents in the grid (via `Use 0x0036` → `ViewContents 0x0196` full-replace), draw the selected-container triangle `0x06005D9C` + green/yellow selection square (retires AP-56), click the main pack to go back. The wire builders/parsers all exist (`BuildUse`, `ParseViewContents`, `SendNoLongerViewingContents`); consume the `GameEventWiring.cs:256` full-REPLACE TODO. Run the full brainstorm → spec → plan → subagent-driven → visual-gate flow. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it. diff --git a/docs/research/2026-06-22-paperdoll-handoff.md b/docs/research/2026-06-22-paperdoll-handoff.md deleted file mode 100644 index 028572d8..00000000 --- a/docs/research/2026-06-22-paperdoll-handoff.md +++ /dev/null @@ -1,91 +0,0 @@ -# Handoff — D.2b Sub-phase C: the paperdoll (equip slots + 3D doll) - -**Date:** 2026-06-22 (banked at the end of the inventory drag-drop session, per [[feedback_session_handoff]]) -**Branch:** `claude/hopeful-maxwell-214a12`, tip `82ab0e0`. `main` is a clean fast-forward ancestor. -**Status:** SCOPED, not started. The decomposition + corrections + reuse map below are the design sketch; a fresh session runs the full brainstorm → spec → plan → subagent-driven → visual-gate flow **per slice**. -**Line numbers drift — grep the symbol.** - -> **⚠ SUPERSEDED / CORRECTED 2026-06-23 — Slice 1 SHIPPED (`a4c6852`).** This handoff's "⚠ Correction" -> section below ("empty equip slots are TRANSPARENT, no silhouettes") **was WRONG** and cost a visual-gate -> iteration. The user-axiom truth (his retail screenshots): the paperdoll has a **"Slots" toggle button -> (`0x100005BE` = `m_SlotCheckbox`)**. Button OFF (default) = the **live 3-D character** (the doll — can be -> naked) + the non-armor slots (weapon/shield/wand/jewelry/under-clothes); button ON = the doll **hides** and -> the **armor slots** become visible. **The "figure"/"silhouette" IS the doll (live render), NOT per-slot -> silhouette sprites — there are none.** So the doll `UiViewport` (Type 0xD, `0x100001D5`) + the Slots toggle -> = **Slice 2**. Slice 1 (equip-slot bind + wield/unwield + **visible empty-slot frames** + the `EquipMask` -> correction + the `PickupEvent` unwield fix) SHIPPED. **The corrected model + the full DO-NOT-RETRY list live -> in `claude-memory/project_d2b_retail_ui.md` (the Slice 1 entry) — read that, not the §"⚠ Correction" below.** - ---- - -## Read first (in this order) - -1. **This doc.** -2. **`docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`** — the AUTHORITATIVE research. Full element-id → `EquipMask` table (§3a, ~25 slots), the viewport mechanism (§5), the wield wire (§4), the reuse analysis (§5c), and the open `UNVERIFIED` list (§7). **Do NOT re-derive what's already CONFIRMED there — cite it.** -3. **`claude-memory/project_d2b_retail_ui.md`** — the D.2b SSOT (the shipped log: container-switching, icons, capacity bars, drag-drop; the `UiItemSlot`/drag-spine/importer facts you'll reuse). -4. **`docs/architecture/retail-divergence-register.md`** — for the AP rows you'll add; and `docs/architecture/code-structure.md` Rule 2 (the Core→App seam constraint that governs the doll viewport). - -## Why this is next - -The inventory window is functionally complete in 2D (browse/switch bags, icons, capacity bars, drag-drop move). The **last placeholder** is the paperdoll: the ~25 equip slots render as generic blue `UiDatElement` borders, and there's no character doll. It's the standing roadmap NEXT and the most visible remaining gap when F12 is open. - -## ⚠ Correction to carry in (the deep-dive settles this — don't repeat the old assumption) - -The earlier brainstorm framed Slice 1 as "per-slot body-part **silhouettes**." **That is wrong.** Per the deep-dive §2a (decomp lines 99–102, CONFIRMED): retail equip slots **default INVISIBLE and show only when occupied** — an **empty** slot is **transparent and the doll body shows through it**; an **occupied** slot shows the item icon. There are no per-slot silhouette sprites. acdream's current blue border is simply the wrong rendering of an empty slot. - -Consequence: the doll is the visual backdrop the empty slots reveal. Slice 1 (slots) and Slice 2 (doll) are therefore **interdependent** for the empty-slot look — decide the empty-slot treatment for a doll-less Slice 1 in the brainstorm (transparent-to-panel vs a temporary placeholder). - -## The decomposition — TWO slices (each its own spec → plan → implement) - -### Slice 1 — equip slots (functional; the lighter, independently-valuable first piece) - -Bind the ~25 equip slots to live equipped-item data + make them drag-drop equip/unequip targets. **No 3D doll yet.** Delivers: you SEE your equipped gear (icons in the right slots) and can drag gear onto a slot to wield it / off to unwield. - -- **The slots already exist + are positioned** (the inventory frame mounts gmPaperDollUI at `0x100001CD`; `DatWidgetFactory` builds `0x10000031` → `UiItemList`; B-Grid confirmed the positions). The work is a new **`PaperdollController`** (the `gmPaperDollUI::PostInit` analogue) that: - - Maps each slot **element-id → `EquipMask`** via the deep-dive §3a table (port `GetLocationInfoFromElementID`, decomp 173620 — it's a static switch; transcribe the table, don't call the decomp at runtime). - - Populates each slot's icon from the item whose `ClientObject.CurrentlyEquippedLocation == that slot's EquipMask` (acdream ALREADY tracks `CurrentlyEquippedLocation` per item via `WieldObject 0x0023` + `CreateObject`; `ClientObjectTable`). So the equipped-state data is already there — no new parse needed for Slice 1 MVP. - - Makes each slot a `UiItemSlot` (the shipped shared widget) — single 32×32 cell, occupancy-gated icon, drag accept/reject overlay. Empty-slot rendering per the correction above. - - Registers the controller as the `IItemListDragHandler` for the equip slots (reuse the shipped drag-drop spine): `OnDragOver` accepts iff the dragged item's `ValidLocations` includes the slot's `EquipMask`; `HandleDropRelease` = **wield** (`GetAndWieldItem 0x001A`) optimistically + via wire; dragging a slot's item OUT to the grid = un-wield (`PutItemInContainer 0x0019`, the SHIPPED path). -- **THE WIRE GAP (build it):** `GetAndWieldItem` (`0x001A`, payload `uint itemGuid; (uint)EquipMask`) has **no builder/sender in acdream** (deep-dive §4a — only the unrelated UI font property `0x1A` exists). Add `InteractRequests.BuildGetAndWieldItem(seq, itemGuid, equipMask)` (mirror `BuildPickUp`) + `WorldSession.SendGetAndWieldItem(...)` (mirror `SendPutItemInContainer`). ACE: `GameActionGetAndWieldItem.Handle` (item + EquipMask). holtburger `commands.rs:808` confirms the shape. -- **The appearance reply** (`ObjDescEvent 0xF625`) is already PARSED (deep-dive §4a); it matters for the DOLL (Slice 2), not Slice 1's icons. - -**Slice 1 scope cuts:** no doll, no part-selection highlight, no left/right-side disambiguation polish (the paired wrist/finger slots — handle via the SIDE column in §3a only if trivial), no Aetheria sigil slots (hidden by default). - -### Slice 2 — the 3D doll viewport (the heavy piece: the UI↔3D bridge) - -The `UIElement_Viewport` (Type `0xD`, element `0x100001D5` in gmPaperDollUI) hosting a re-dressed clone of the local player, drawn into the slot region behind the equip slots. **This is the single biggest new piece** — see deep-dive §5 (CONFIRMED viewport mechanism) + §5d (the design-open seam). - -- **Reuse the existing char path** (deep-dive §5c, CONFIRMED): acdream already renders animated, equipped characters in-world via `EntitySpawnAdapter` → `AnimatedEntityState` (palette/part/hidden-part overrides from ObjDesc). The doll = "build a `WorldEntity` from the local player's Setup + current ObjDesc, feed it through that pipeline, draw it with a fixed camera + one distant light into a UI rect." Re-dress on `ObjDescEvent 0xF625` = rebuild the entity's overrides. -- **The new widget = `UiViewport`** (registers at dat Type `0xD`, leaf). It needs a **render-into-rect seam**: a Core `IUiViewportRenderer` interface implemented in App (Code-Structure Rule 2 — Core defines, App implements; never the reverse), invoked as a scissored single-entity 3D pass keyed to the widget's screen rect. The exact integration (after the world pass vs a UI overlay) is **DESIGN-OPEN — settle in the Slice 2 brainstorm** (deep-dive §7). -- Camera/light immediates, heading-toward-viewer (191.37°), idle animation, and the player-clone-vs-fresh-WorldEntity question are all in deep-dive §5d + §7 (some `UNVERIFIED` — decode the float immediates there). - -## What's already in place (reuse, don't rebuild) - -- **The mount:** `GameWindow.cs:~2208` pins `0x100001CD` (paperdoll) in the inventory frame; the importer pulls in the nested gmPaperDollUI (`0x21000024`) subtree (`InventoryFrameImportProbe.cs:25` = `PaperdollPanel`). The equip-slot elements are already imported + positioned. -- **`UiItemSlot`** (shared item widget) — occupancy-gated icon, `SpriteResolve`, drag payload/ghost, `DragAcceptSprite`/`DragRejectSprite`, `IsOpenContainer`/`Selected`/`CapacityFill` overlays. The equip slot is the single-cell case. -- **The drag-drop spine** (`IItemListDragHandler`, `UiRoot` BeginDrag/FinishDrag, `UiItemSlot` drag events) + the **optimistic-move machinery** (`ClientObjectTable.MoveItemOptimistic`/`ConfirmMove`/`RollbackMove` + the gapless-insert + the pending-count) — wield/unwield reuses this (wield = an equip-target drop handler; unwield = the shipped `PutItemInContainer` path). -- **`ClientObjectTable.CurrentlyEquippedLocation`** per item — the equipped-state source for Slice 1 (no new parse needed for MVP). -- **`IconComposer`** — item icons (incl. the `IsThePlayer` constant-backpack special; deep-dive notes the paperdoll icon path). -- **`EntitySpawnAdapter` / `AnimatedEntityState`** — the char render path Slice 2 reuses. -- **`ObjDescEvent 0xF625`** parse + `CreateObject.ReadModelData` (palette/part/texture/anim-part) — Slice 2's re-dress input. - -## Open questions for the brainstorm(s) - -- **Slice 1 empty-slot look without the doll** — transparent-to-panel vs a temporary placeholder (the doll is the real backdrop). The correction above means there's no silhouette sprite to use. -- **Equipped-icon source** — `CurrentlyEquippedLocation` (have it) is enough for MVP; the deep-dive §3c/§7 notes the richer `InventoryPlacement` equipped list (PlayerDescription equipped section) is **not surfaced** by `PlayerDescriptionParser` (partial). Only extend the parser if MVP needs priority/coverage resolution (overlapping armor/clothing) — likely Slice-2-or-later. -- **`GetAndWieldItem` placement/auto-slot** — does the EquipMask come purely from the drop-target slot (like the doll's `GetLocationInfoFromElementID`), and how does auto-wield (drop on the doll body, not a specific slot) pick the slot? holtburger `resolve_and_clear_slots` (§4a) is the reference. -- **The whole of Slice 2's UI↔3D seam** (deep-dive §7) — the heaviest design call; its own brainstorm. -- **`0x100001E0 = MissileAmmo 0x800000`** is LIKELY (corrupted decomp immediate, §7) — confirm by dump if the ammo slot matters for MVP. - -## Acceptance / scope per slice - -- **Slice 1:** equip slots show your equipped gear's icons in the right slots; drag a wieldable item onto a slot → it wields (`GetAndWieldItem 0x001A`, optimistic + rollback); drag a slot's item to the pack → it unwields. Build + full suite green; **visual gate** (F12 → equipped gear shows in the doll slots, drag-to-equip works). Divergence rows for any approximation. Retires the "generic blue border" gap. -- **Slice 2:** the 3D doll renders the re-dressed player clone behind the slots; empty slots show the doll body through them; equipping updates the doll live (via `ObjDescEvent`). Its own spec/plan; the `IUiViewportRenderer` seam is the crux. - -## New-session prompt - -> Continue acdream's D.2b retail-UI inventory arc on branch `claude/hopeful-maxwell-214a12` (tip `82ab0e0`; `main` is a clean ff ancestor). **READ FIRST:** `docs/research/2026-06-22-paperdoll-handoff.md`, then its "Read first" list (esp. the deep-dive `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` and `claude-memory/project_d2b_retail_ui.md`). -> -> **NEXT: Sub-phase C — the paperdoll, Slice 1 (equip slots), first.** Bind the ~25 mounted equip slots (under `0x100001CD` / gmPaperDollUI `0x21000024`) to live equipped-item data and make them drag-drop wield/unwield targets — a new `PaperdollController` (the `gmPaperDollUI::PostInit` analogue) that maps element-id → `EquipMask` (deep-dive §3a table), populates each slot's icon from the item whose `CurrentlyEquippedLocation == that slot's EquipMask`, and registers as the equip-slots' `IItemListDragHandler`. **Build the wire gap:** `GetAndWieldItem 0x001A` (`itemGuid + EquipMask`) — `InteractRequests.BuildGetAndWieldItem` + `WorldSession.SendGetAndWieldItem` (mirror `BuildPickUp`/`SendPutItemInContainer`); wield = optimistic + rollback (reuse `MoveItemOptimistic`); unwield = the shipped `PutItemInContainer 0x0019` path. **Carry the correction:** retail empty equip slots are TRANSPARENT (the doll shows through), NOT silhouettes — decide the doll-less empty-slot look in the brainstorm. **Slice 2 (the 3D doll `UiViewport` Type 0xD + the Core→App `IUiViewportRenderer` seam) is a SEPARATE, heavier cycle — do not start it in Slice 1.** -> -> Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow for Slice 1. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it. Reuse the shipped `UiItemSlot` + drag-drop spine + optimistic-move machinery; don't rebuild them. diff --git a/docs/research/2026-06-23-dense-town-fps-attribution-report.md b/docs/research/2026-06-23-dense-town-fps-attribution-report.md deleted file mode 100644 index 1aa574ea..00000000 --- a/docs/research/2026-06-23-dense-town-fps-attribution-report.md +++ /dev/null @@ -1,390 +0,0 @@ -# Report — dense-town FPS attribution (Arwic, post-cells-fix) — 2026-06-23 - -**Mode:** report-only investigation (no edits, no diagnostic drops). Deliverable -is this doc + the chat verdict. Continues the handoff -`2026-06-23-dense-town-fps-deepdive-handoff.md`. The cells fix (29→75 fps) is -KEPT and confirmed intact. - -**Method:** 7 parallel read-only subsystem readers (orchestration, punch/seal, -particles, portal-vis CPU, alloc/GC, pview-master, apparatus) + independent -verification reads of the 6 core files by the lead. Every claim below is cited -to `file:line` from source read in full. **No live measurement was taken** — the -decisive runs are listed in §6 for the user to drive. - ---- - -## OUTCOME (FINAL — shipped 2026-06-24) - -Dense Arwic: **75 → ~165 fps** (steady; ~130 at the absolute densest standing-still -view — over the 144 target except that one extreme). Two isolated, verified, -pixels-identical fixes (render-perf is not faithfulness-gated): - -1. **Cell-object draw batching** (`290e731`) — `DrawCellObjectLists` collapsed N - per-cell `WbDrawDispatcher.Draw` calls into one cross-cell batched draw - (`cellobjects` 3.5 → ~0.4 ms). -2. **Cell-particle consolidation** (`9f51a4d`) — the per-cell `DrawCellParticles` - re-walk (O(cells×particles)) collapsed into one union pass; also fixed a latent - additive **double-draw** in multi-aperture cells. - -Throwaway profiling apparatus stripped (`a9d06a6`). Build + full suite green. - -**The handoff's framing was wrong, and is corrected here:** the dense town was -NEVER GPU-bound. The "~12 ms GPU" was a **glFinish self-measurement artifact** (the -profiler's own pipeline flushes inflated its number); the true GPU is ~0.5 ms -standing still. The frame is **~96 % CPU-bound**, and the cost scales with how many -buildings are in view. - -**Remaining headroom — deliberately NOT pursued (user-approved finalize):** the last -~2.1 ms CPU is `WbDrawDispatcher`'s per-frame static-scenery rebuild (`ls.scenery`); -~2.1 ms GPU is terrain rasterization (`ls.terrain`). Both live in FROZEN, -load-bearing subsystems. The only way to fully cut scenery-CPU is a cross-frame -cache of the entity dispatcher — judged HIGH risk (regresses ALL entity rendering if -mis-keyed; the #53/#119/#128 bug class; thrashes on the eye's rest-jitter) for ~15 -fps at one extreme view already over target. The safe micro-opts net only ~0.3 ms. -Full analysis: §0b + the dispatcher-investigation workflow. - ---- - -## 0a. MEASURED VERDICT — clean split, `ACDREAM_FPS_PROF=2` (2026-06-23, live Arwic) - -The hypothesis below was **confirmed by direct measurement.** Added a diagnostic -`ACDREAM_FPS_PROF=2` mode (whole-frame `TimeElapsed` query, per-pass `glFinish` -DISABLED) and ran it live in Arwic: - -| view | wall | cpuRender | **gpu** | present(wait) | vsync/msaa | -|---|---|---|---|---|---| -| facing AWAY from town | 6.7 ms (~149 fps) | 6.6 ms | **0.5 ms** | 0.1 ms | off / 4× | -| facing INTO town | ~13 ms (~70 fps) | ~13 ms | **0.5 ms** | 0.1 ms | off / 4× | - -**The frame is ~96% CPU-render-bound. The GPU renders the entire dense town in -0.5 ms and is idle the rest of the frame.** `cpuRender ≈ wall`, `present ≈ 0.1 ms`, -`gpu = 0.5 ms` regardless of view. The handoff's "~12 ms GPU" was **entirely a -glFinish-serialization artifact** (§1). Turning the camera into the town doubles -`cpuRender` (6.6 → 13 ms) while `gpu` never moves — the cost scales with the number -of buildings in frustum, i.e. the per-building CPU work, not pixels. - -**DEAD for good (GPU is 0.5 ms):** MSAA, fill, overdraw, fragment shaders, far draw -distance, any GPU-side lever. An MSAA=0 test is moot. **All fixes target CPU.** - -The spikes (cpuRender max 30–43 ms → dips to ~25 fps) are consistent with gen-0 GC -from the ~5–8 k allocations/frame (§4); confirm with a gen-0 GC counter if needed. - ---- - -## 0b. CPU SUB-PHASE BREAKDOWN — `[CPU-PHASE]`, live Arwic facing town (2026-06-23) - -Added `[CPU-PHASE]` timers around each `DrawInside` phase (run under `=2`, so draw -phases = pure CPU submission). Steady-state, facing town (`update≈0`, `cpuRender` -~8 ms — the absolute varies 8–13 ms with view density; the RANKING is stable): - -| phase | ms/frame | what | -|---|---|---| -| **cellobjects** | **3.3–4.5** | `DrawCellObjectLists` — per-cell entity/static draw + per-cell particle pass | -| **landscape** | **2.1–2.9** | sky (per-submesh) + terrain + scenery + late dynamics/particles/weather | -| **partition** | **0.7–3.2** | `InteriorEntityPartition.Partition` + `ViewconeCuller.Build` | -| **dynamics** | **0.6–1.3** | `DrawDynamicsLast` — dynamics draw + dynamics particles | -| prepare | 0.04–1.3 | `PrepareRenderBatches` | -| shells | 0.07–0.21 | the cells fix (cheap — working) | -| flood | 0.08–0.27 | the 48 portal floods | -| assemble | 0.03–0.24 | `ClipFrameAssembler` | -| portalmask | 0.06–0.09 | the 31 punch/seal fans | - -**MEASUREMENT OVERTURNED THE STATIC RANKING (§2/§4/§5):** -- **Portal floods are NOT the cost** (`flood = 0.1 ms`). **H1's "48 floods" emphasis and - Tier C flood-caching are DEAD** — caching saves ~0.1 ms. -- **Punch/seal is NOT the cost** (`portalmask = 0.08 ms`). Tier B #5 batching saves nothing. -- **The clip-math alloc storm is NOT the steady cost** (flood+assemble ≈ 0.2 ms). It may - still drive the *spikes* via GC (separate), but it is not the 8–13 ms steady cost. -- **The cells fix works** (`shells = 0.1 ms`). - -**REAL TARGET — per-cell / per-entity DRAW SUBMISSION:** `cellobjects + landscape + -dynamics ≈ 6.5 ms` + `partition ≈ 2 ms`. Common thread: `WbDrawDispatcher.Draw` is -called **once per visible cell** in `DrawCellObjectLists` (each orphaning 6 SSBOs via -`glBufferData` — `WbDrawDispatcher.cs:1521-1558`), plus per-cell `DrawCellParticles` -re-walks, plus un-batched per-submesh sky. Scales with visible cells/entities ⇒ the -facing-the-town cost. **Lesson: allocation COUNT ≠ CPU TIME; the per-phase CPU timer -is what found the real hotspot.** - -**Revised fix priority (supersedes §5):** -1. **Batch per-cell entity/static draws** across cells into few `WbDrawDispatcher.Draw` - calls (the cell-shell fix, applied to cell OBJECTS) — targets `cellobjects` (~3.5 ms). - Pre-cull per-entity by viewcone, then one batched draw. -2. **`WbDrawDispatcher`: persistent SSBOs + `BufferSubData`** instead of 6 `glBufferData` - orphans per call — compounds with #1 (helps `cellobjects`/`landscape`/`dynamics`). -3. **Consolidate particle `Draw` calls** (per-cell `DrawCellParticles` re-walks all - particles) — targets part of `cellobjects` + `landscape`. -4. **Batch sky submeshes** — targets `landscape`. -5. **Optimize `partition`** (`InteriorEntityPartition.Partition` + `ViewconeCuller.Build`, - ~2 ms CPU/frame) — possibly cache/incremental. - -Spikes (cpuRender max 30–43 ms; correlated `gpu`-max spikes to 300+ ms) coincide with -`landscape`/`cellobjects` jumps + GPU upload — likely streaming mesh upload hitches -(`_wbMeshAdapter.Tick`) and/or GC. Diagnose separately from the steady cost. - ---- - -## 0. Verdict (TL;DR) — original static prediction, now MEASURED-CONFIRMED above - -The handoff frames the remaining ~12 ms as "diffuse GPU cost." The static -evidence says that frame is **mis-attributed**: the 12 ms is a **glFinish- -serialized** number, and the actual GPU *rasterization* is tiny (terrain 0.3, -cells 0.2, entities 0.2 ms — the geometry genuinely is cheap, exactly your -intuition). The remaining cost is overwhelmingly **CPU-submission / OpenGL -driver-overhead / GC**, not GPU fill: - -- **48 uncached portal floods per frame** (1 root + ~47 buildings), recomputed - from scratch every frame even when standing still, with **no caching**. -- **~5,000–8,000 short-lived heap allocations per frame** (the Sutherland-Hodgman - clip math alone is ~3–5 k) → gen-0 GC → the periodic spikes to 30–40 fps. -- **Hundreds of redundant GL state calls per frame**: 31 punch/seal fans each - doing a full state set+restore (~450–650 GL calls), `WbDrawDispatcher` - orphaning 6 SSBOs via `glBufferData` on every one of its 3–5 calls (~66–110 - buffer reallocs), per-submesh sky draws, ~80–128 `glEnable/Disable(ClipDistance)` - toggles. -- The particle system is **re-walked ~22× per frame** (once per cell), each walk - allocating and each `Draw` doing `glBufferData` orphans — draw-count bound, - **not fill** (consistent with the resolution-independence observation). - -**This is the classic OpenGL CPU-bound profile**, and it explains all three of -your observations at once: resolution-independent, scales with town density, and -a fast GPU can't help. - -**The single blocking gap:** we cannot prove CPU-vs-GPU today because the whole- -frame `TimeElapsed` query and the per-pass `glFinish` are gated on the *same* -`ACDREAM_FPS_PROF=1` flag (`FrameProfiler.cs:72-73`). The decisive next step is to -decouple them (a ~5-line apparatus change) **or** capture a RenderDoc frame — both -give the honest split. - ---- - -## 1. The measurement-trust problem (read this first) - -`cpuRender` is a wall-clock stopwatch around the *entire* `OnRender` -(`FrameProfiler.cs:99,110`), so it **absorbs every glFinish stall**. When -`ACDREAM_FPS_PROF=1`: - -- Each instrumented renderer calls `gl.Finish()` **twice** (before + after): - `EnvCellRenderer.cs:845,1050`; `ParticleRenderer.cs:128,181`; - `PortalDepthMaskRenderer.cs:214,319`; terrain hooks `GameWindow.cs:10799,10803`. -- Counting call frequency: ~62 finishes from punch/seal (31 fans × 2) + ~44 from - particles (22 batches × 2) + cells + terrain ≈ **150+ full pipeline flushes per - frame.** -- Each `glFinish` drains the GPU and blocks the CPU. The GPU then sits **idle** - between passes waiting for the CPU to issue the next finish-bracketed batch — - and that idle time falls *inside* the whole-frame `TimeElapsed` window. - -**Consequence:** the `gpu ≈ 12 ms` total is *serialized* GPU time (rasterization + -inter-pass idle), not *pipelined* GPU time. The per-pass `[PASS-GPU]` absolutes -(particles 3.1, punchseal 2.9) are inflated by their own finishes and are upper -bounds, not real costs. **No honest CPU/GPU split exists in the current -apparatus.** (Independently re-derived by two of the seven readers.) - -`vsync` is off by default (`GameWindow.cs:998`), so vsync-quantization is unlikely -but must be confirmed from the printed `vsync=` field. - ---- - -## 2. Ranked hypotheses - -### H1 (leading) — The frame is CPU-submission / driver-overhead bound, not GPU-fill bound -- **For:** attributed GPU rasterization is tiny (terrain 0.31, cells 0.23, - entities 0.22 ms — all measured in the handoff). Resolution-independent (resize - didn't move FPS). Scales with building count. The CPU side has clear O(buildings) - + O(cells) structure issuing hundreds of small state-changing GL calls. The - cells fix worked precisely because it collapsed 94 heavy state-setting `Render` - submissions into 1 — a *submission* win. -- **Against:** not yet measured cleanly (glFinish contamination, §1). The resize - test was on a tainted `FAR_RADIUS=4` build (handoff caveat). -- **Falsify:** RUN 1 (§6) with glFinish decoupled — if `gpu p50 ≈ wall p50` and - `cpuRender ≪ wall`, H1 is **wrong** and it's genuinely GPU-bound. If - `cpuRender ≈ wall` and `gpu ≪ wall`, H1 confirmed. - -### H2 — Frame spikes (cpuRender p95 ~30 ms, dips to 30–40 fps) are gen-0 GC pauses -- **For:** ~5,000–8,000 short-lived heap objects/frame, almost all in the - portal-flood + clip + assemble path (§4). At 75 fps that's 375k–600k allocs/s → - frequent gen-0 STW collections. Retail uses fixed static scratch buffers; this - alloc storm is "purely a .NET port artifact with no retail equivalent." -- **Against:** could partly be the streaming/upload hitch (`_wbMeshAdapter.Tick`, - `GameWindow.cs:8427`) on cell load. Both can be true. -- **Falsify:** watch the .NET gen-0 GC counter / allocation rate during a stutter, - or correlate spikes with `dotnet-counters`. If allocs/frame is low, GC isn't it. - -### H3 — Particles are draw-count/state bound (~22 fragmented batches), not fill -- **For:** `ParticleRenderer.Draw` is called from **~11 call sites**, including - **per-cell** `DrawCellParticles` (~31×); each call re-walks the *entire* live - particle set via `EnumerateLive` (`ParticleRenderer.cs:196`), sorts it, and each - batch does a `glBufferData` orphan (`:278`). Trivial fragment shader, depth-write - off. Resolution-independent ⇒ not fill. -- **Against:** additive emitters have real overdraw (no depth write); secondary. -- **Falsify:** RUN 3 resolution sweep — if particle GPU is flat with resolution, - not fill. RenderDoc quad-overdraw overlay. - -### H4 — Punch/seal (31 fans) is CPU state-churn, NOT 2.9 ms of GPU -- **For:** each `DrawDepthFan` is a depth-only, color-masked, ≤32-vert fan — GPU - cost is negligible. The 2.9 ms is 62 glFinish stalls. The *real* cost is the - full per-fan state set+restore (`PortalDepthMaskRenderer.cs:229-313`) — ~450–650 - GL calls/frame, including a `UseProgram`/`UseProgram(0)` round-trip and a - `uViewProjection` re-upload per fan even though all fans share one matrix. -- **Against:** none material. -- **Falsify:** RUN 2 `[PASS-GPU]` with per-pass `TimeElapsed` (no glFinish) — fan - GPU will read ≪ 2.9 ms. RenderDoc per-draw time. - -### H5 — `WbDrawDispatcher` SSBO orphaning is a hidden chunk of the "unattributed 5.5 ms" -- **For:** every `Draw()` re-uploads 6 SSBOs via `glBufferData(DynamicDraw)` - (orphan+realloc, not `BufferSubData`) — `WbDrawDispatcher.cs:1521-1558` — ×3–5 - calls/frame = ~66–110 buffer reallocs. Plus per-transparent-cell `EnvCell.Render` - repeats all 6 SSBO uploads for a 1-cell instance set (`EnvCellRenderer.cs:1281-1367`). -- **Against:** the dispatcher has its own `ACDREAM_WB_DIAG` GPU timer; cross-check - it (run separately — it nests illegally with FPS_PROF, `FrameProfiler.cs:31`). -- **Falsify:** RenderDoc; or `ACDREAM_WB_DIAG=1` in isolation. - ---- - -## 3. What's ruled out (do not re-chase) - -- **Distance-degrade / LOD / triangle count** — dead (handoff §3, entity GPU 0.22 ms). -- **MSAA / fill / overdraw of opaque geometry** — resolution-independent; re-verify - cleanly in RUN 3 but the static evidence agrees (cheap fragment work everywhere). -- **Update thread** — `update = 0.1 ms`. -- **Terrain per-slice redraw** — the apparatus reader flagged `_terrain.Draw` being - inside a per-slice loop (`GameWindow.cs:10795-10803`) as a HIGH suspect, **but - the handoff already measured terrain at 1 slice / 0.31 ms.** At *outdoor* Arwic - there is one full-screen outside slice, so terrain draws once. The per-slice - multiplier only bites for **interior roots with multiple doorway slices** — a - separate, non-Arwic concern. **NOT an Arwic FPS lever.** -- **The cells fix** — intact and correct (`RetailPViewRenderer.cs:664-701`). Do not - touch. - ---- - -## 4. Per-subsystem cost ledger (static, cited) - -**Portal visibility (CPU + GC — the biggest structural cost)** -- 48 BFS floods/frame, **no frame-to-frame caching**, recomputed when stationary - (`RetailPViewRenderer.cs:64-83,228-235`; rebuilt `GameWindow.cs:8752`). -- Per flood allocates `PortalVisibilityFrame` + 2 `HashSet` + `Dictionary` + - `uint[128]` before processing a portal (`PortalVisibilityBuilder.cs:126-227`). -- **`PortalProjection` clip math is the #1 GC source: ~3,000–5,000 short-lived - `List`/`List`/`ToArray()` per frame** — `ProjectToClip:95`, - `ClipToRegion:127`, `ClipHomogeneousEdge:216`, `MergeSubPixelVertices:184`. - Retail used a **static two-buffer swap on a fixed vertex array** — so pooling is - *more* retail-faithful, not less. -- `CellTodoList.Insert` is O(N) (`:1006-1016`); `GetRange` allocates (`:254,583`); - `CanonicalKey` allocates a `StringBuilder` per polygon (`PortalView.cs:255`). - -**ClipFrameAssembler (CPU + GC)** — ~800–1,200 heap objects/frame (4 dicts + 95 -lists + 94 `CellView` + 94 `int[]` + per-polygon `Vector4[]`), all discarded each -frame (`ClipFrameAssembler.cs:85-219`). No retail counterpart. - -**Punch/seal (CPU state churn)** — 31 fans × full state set+restore = ~450–650 GL -calls/frame; per-fan `UseProgram` round-trip + `uViewProjection` re-upload -(`PortalDepthMaskRenderer.cs:229-313`). Retail does **not** restore state per -polygon (it leaves state installed for the next poly); our self-contained-state -contract is the overhead. The two-pass stencil is an acdream-only #117 addition -(retail relied on painter's order). Zero managed alloc inside `DrawDepthFan` -(good — `_scratch` field + `stackalloc`). - -**Particles (CPU draw-count + GC)** — ~11 `Draw` call sites, per-cell -`DrawCellParticles` re-walks all live particles ×~31 (`ParticleRenderer.cs:196`); -`new List` (`:195`) + `new List(64)` (`:152`) per -call; per-batch `glBufferData` orphan (`:278`). Draw-count bound. - -**EnvCellRenderer (CPU + GC + redundant uploads)** — opaque batched (the fix), but -**transparent stays per-cell**: each transparent cell repeats all 6 SSBO uploads -for a 1-cell set (`EnvCellRenderer.cs:1281-1367`). `_cellLightSetCache.Clear()` at -`:1141` forces **188–282 `int[]` re-allocations/frame** (94 cells × 2–3 passes); -the light sets are camera-independent and stable within a frame. `Render` allocates -4 collections per call (`:904,905,927,928`). `CellHasTransparent` is an O(gfx×batch) -walk with no cached result. - -**Orchestration (CPU driver overhead)** — sky drawn per-submesh, no batching -(~8–15 `DrawElements` + per-submesh `BlendFunc`/4×`SetFloat`, ×2 passes/frame, -`SkyRenderer.cs:219-429`); ~80–128 `glEnable/Disable(ClipDistance0..7)` toggles/frame -(`MaxPlanes=8`); `PrepareRenderBatches` runs `Parallel.ForEach` **on the render -thread**, blocking `OnRender` (`EnvCellRenderer.cs:642`); `ParseEnvFloat` → -`Environment.GetEnvironmentVariable` ×2/frame in the hot path -(`GameWindow.cs:8639-8641`); `new[]{entry}` per `DrawEntityBucket` (~50–100/frame, -`RetailPViewRenderer.cs:943`); `new[]{NoClipSlice}` per slot-less cell -(~125/frame, `:918` — trivially a static readonly array). - ---- - -## 5. Fix leads, ranked by (impact × retail-faithfulness) - -Brainstorm before any render change (handoff lesson — rushed render changes were -reverted). These are *sketches for the implementation session*, not commitments. - -**Tier A — strictly retail-faithful (matches retail's static-scratch model), high impact, low risk:** -1. **Pool the `PortalProjection` clip buffers** (`ArrayPool`/double-buffer swap). - Kills the single largest GC source (~3–5 k allocs/frame). Output contract - unchanged. → directly targets H2 spikes. -2. **Pool `ClipFrameAssembler` + `PortalVisibilityBuilder` BFS scratch** (Reset/ - Clear instead of `new`). ~1 k allocs/frame gone. -3. **Move `_cellLightSetCache.Clear()` out of `RenderModernMDIInternal`** to once- - per-frame. Removes 188–282 `int[]`/frame. Light sets are frame-stable. -4. **Static readonly `NoClipSlice[]`** + pool `DrawEntityBucket`'s `new[]{entry}`. - -**Tier B — internal GL batching (the cells-fix pattern, no visual change), high impact:** -5. **Batch punch/seal**: set state ONCE before the fan loop, upload `uViewProjection` - ONCE, merge all fan vertices into one VBO + draw with offsets. Drop the per-fan - `UseProgram(0)`. (Retail also drew per-poly but with cheap D3D state blocks; our - GL program-rebind/uniform-upload per fan is the cost.) -6. **Batch transparent EnvCell shells** like opaque (sort instances far→near, one - `Render`). Removes N_transparent × 6-SSBO re-uploads. -7. **`WbDrawDispatcher`: persistent SSBOs + `BufferSubData`** instead of - `glBufferData(DynamicDraw)` orphan per call. -8. **Consolidate particle `Draw` to ≤3 calls/frame** (one per `RenderPass`), - pre-partition emitters by pass, single `BufferSubData` per `Draw`; additive in a - separate order-independent group. -9. **Batch sky submeshes** (shared VBO / sort by blend mode). - -**Tier C — acceleration that diverges from retail (needs explicit brainstorm + a -divergence-register row):** -10. **Cache per-building portal floods** keyed on (buildingId, quantized camera - pose). Eliminates ~47 of 48 floods/frame when stationary. **But retail - recomputes per frame from the BSP walk** — this is a memoization acdream adds, - not a faithful match. Pure-function caching is defensible, but it changes the - "recompute every frame" structure and must be invalidated on camera move + cell - load/unload. Lower priority than Tier A (which kills the GC without diverging). -11. **Move `PrepareRenderBatches` off the render thread** (double-buffer in - `OnUpdate`). One-frame visibility latency. - ---- - -## 6. Decisive measurement plan (user-driven; report-only until approved) - -**Apparatus gap to fix first (one small change, then measure):** add a second flag -(e.g. `ACDREAM_FPS_PROF=2` or `ACDREAM_FPS_NOFINISH=1`) that keeps the whole-frame -`TimeElapsed` query but **disables the per-pass `glFinish`** — and/or convert the -per-pass timers to **non-nested per-pass `TimeElapsed` queries** (the -`WbDrawDispatcher` `ACDREAM_WB_DIAG` queries at `WbDrawDispatcher.cs:1642,1670` are -the working template — no glFinish, no inflation). This is diagnostic-only. - -- **RUN 1 — honest split (glFinish off, frame query on), Arwic, stand still 10 s.** - `gpu≈wall` ⇒ GPU-bound (go to RUN 4). `cpuRender≈wall, gpu≪wall` ⇒ **CPU-bound - (H1)**. `present(wait)≈wall` ⇒ vsync/swap (check `vsync=`). -- **RUN 2 — per-pass `[PASS-GPU]` via TimeElapsed (no glFinish).** Honest - particles / punchseal / cells / terrain ms + calls/frame. -- **RUN 3 — resolution sweep (720p → 1080p) on a CLEAN build.** GPU scales ~2.25× ⇒ - fill-bound; flat ⇒ vertex/CPU-bound. (Re-verifies the tainted resize test.) -- **RUN 4 — RenderDoc Arwic frame capture.** Exact per-draw GPU, total draw count - (>200/frame at 720p = batching problem), quad-overdraw overlay (punch fans over - the viewport show as red), per-pass timeline, MSAA-resolve cost. - -Confirm/refute: H1 ⇐ RUN 1; H3/H4 ⇐ RUN 2/3/4; H5 ⇐ `ACDREAM_WB_DIAG=1` solo + -RenderDoc; H2 ⇐ gen-0 GC counter during a stutter. - ---- - -## 7. What this is NOT - -- **NOT a GPU fill / shading / MSAA problem** — the GPU rasterizes this geometry in - a couple of ms; the geometry is cheap (your intuition is correct). -- **NOT a terrain per-slice redraw at Arwic** — outdoor = 1 slice, terrain draws - once (0.31 ms measured). That lead applies only to multi-slice interior roots. -- **NOT a single 24 ms lever** — it's death by a thousand CPU/driver/GC cuts; the - win is the *sum* of the Tier A+B fixes. -- **NOT fixed by the cells batching alone** — that solved the one big GPU - submission sink; the CPU flood + GC + state churn remained underneath. -- **The 12 ms `gpu` number is NOT trustworthy as "GPU rasterization time"** — it's - glFinish-serialized. Re-measure honestly before drawing conclusions. diff --git a/docs/research/2026-06-23-dense-town-fps-deepdive-handoff.md b/docs/research/2026-06-23-dense-town-fps-deepdive-handoff.md deleted file mode 100644 index 1d5f8645..00000000 --- a/docs/research/2026-06-23-dense-town-fps-deepdive-handoff.md +++ /dev/null @@ -1,193 +0,0 @@ -# Handoff — dense-town FPS deep-dive (push past 75 fps) — 2026-06-23 - -**Branch:** `claude/thirsty-goldberg-51bb9b` (continue in THIS worktree: -`C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`). -**References / named-retail / WorldBuilder** live in the MAIN repo -`C:\Users\erikn\source\repos\acdream\` (read via absolute path; NOT in the worktree). - -> **Read with fresh eyes.** This session **shipped a real, verified fix** (EnvCell -> shell-draw batching: dense-town Arwic **29 → 75 fps, 2.6×**) — KEEP IT. But the -> user finds 75 fps in a dense town on modern hardware **unacceptable** ("a modern -> GPU + OpenGL should chew through this") and wants a **deep-dive to find the next -> root cause and push FPS much higher.** This is not "ship it"; this is "keep -> hunting." The remaining cost is **diffuse** (no single big lever left) — so the -> deep-dive needs a *better* instrument than this session's glFinish hack. **Strong -> recommendation: capture a frame with RenderDoc** (the right tool for "where does -> the GPU time actually go, per draw"). - ---- - -## 0. TL;DR - -1. **SHIPPED + committed (do NOT revert):** EnvCell shell batching. Root cause was - `EnvCellRenderer.Render` called **~94×/frame** (once per visible/look-in cell × - opaque+transparent) = **24.75 ms = 75 % of the GPU frame** at Arwic. Fixed by - batching the opaque pass into ONE `Render(Opaque, allCells)` + skipping the - transparent `Render` for opaque-only cells. cells: **94 calls/24.75 ms → 1 call/ - 0.37 ms**. Frame **34 ms/29 fps → ~13 ms/75 fps**. Visually verified (no missing - walls, transparency + look-ins correct). -2. **STILL OPEN (the user's ask):** 75 fps is unacceptable. The remaining ~12–13 ms - is **diffuse** — particles ~3 ms, aperture punch/seal ~3 ms, ~5.5 ms scattered - (sky/entities/clear/weather), plus periodic frame **spikes** to 30–40 ms. No - second 24 ms sink. **Deep-dive to find why a modern GPU needs ~12 ms for a dense - AC town at 720p, and push FPS much higher.** -3. **The handoff's original theory (distance-degrade / LOD) is DEAD** — refuted by - measurement (entity-draw GPU is 0.22 ms; it was never triangle/detail-bound). - Do not re-pursue distance-degrade. - ---- - -## 1. What SHIPPED this session (commits on the branch, newest→oldest) - -| SHA | What | -|---|---| -| `376f4e6` | chore(diag): particle + punch/seal glFinish timers [throwaway] | -| `8067d3b` | **perf(pview): batch EnvCell look-in shell opaque pass** (interior roots) | -| `3af7d00` | **perf(pview): batch EnvCell shell opaque + skip empty transparent** (the Arwic win) | -| `f72f7ce` | feat(envcell): `CellHasTransparent` predicate (part of the fix, NOT apparatus) | -| `e27923b` | chore(diag): FPS profiling apparatus [throwaway] | -| `fd9354f` | docs: implementation plan | -| `fcf60d8` | docs: design spec | - -(Prior session, KEEP: `536f1c0` `_datLock` fix.) - -**The fix (KEEP):** `RetailPViewRenderer.DrawEnvCellShells` + `DrawBuildingLookIns` -no longer call `EnvCellRenderer.Render` per-cell. Opaque shells batch into one -`Render(Opaque, allCells)` (z-buffer order; lighting is per-instance, `CellId`-keyed -SSBO at `EnvCellRenderer.cs` `RenderModernMDIInternal` ~`:1324` → safe to batch). -Transparent stays per-cell far→near but skips opaque-only cells via -`EnvCellRenderer.CellHasTransparent`. Spec/plan: `docs/superpowers/specs|plans/ -2026-06-23-envcell-shell-batching-*.md`. **Plan Task 4 (final verify) + Task 5 -(strip apparatus) are NOT done** — apparatus kept for the deep-dive. - ---- - -## 2. The apparatus (`ACDREAM_FPS_PROF=1`) — committed, throwaway, STRIP at the end - -- **`src/AcDream.App/Rendering/FrameProfiler.cs`** — per-frame split, printed `[FPS-PROF]` - every ~1 s: `wall` (real frame period, p50/p95/min/max), `update` (OnUpdate CPU), - `cpuRender` (OnRender CPU), `present(wait)` (= wall − update − cpuRender), `gpu` - (whole-frame `TimeElapsed` query — **the reliable total**). Plus `[PASS-GPU]` — - per-renderer **glFinish-bracketed** GPU via `AddRendererGpu(name, ms)`: `cells`, - `terrain`, `particles`, `punchseal`, each with `ms/frame` + `calls/frame`. -- **Hooks:** `GameWindow.OnRender` BeginFrame/EndFrame + `OnUpdate` `MarkUpdateStart` - + terrain glFinish (in `DrawRetailPViewLandscapeSlice`); `EnvCellRenderer.Render` - glFinish try/finally; `ParticleRenderer.Draw` glFinish; `PortalDepthMaskRenderer. - DrawDepthFan` glFinish. All gated on `FrameProfiler.PassGpuEnabled`. -- **`tests/AcDream.Core.Tests/Conformance/DegradeCoverageProbeTests.cs`** — throwaway - dat probe (degrade-coverage; was for the dead distance-degrade theory). -- **⚠ glFinish CAVEAT:** the per-renderer glFinish **serializes CPU↔GPU and inflates** - the per-pass absolutes (and folds GPU wait into `cpuRender`). Use `[PASS-GPU]` for - **relative** attribution only; trust the whole-frame `gpu` for the total. For - accurate per-draw GPU, use **RenderDoc** (see §5). -- **STRIP everything** (plan Task 5) when the FPS work fully lands: `FrameProfiler.cs`, - the GameWindow hooks/fields (`_fpsProf`/`_frameProfiler`/`_msaaSamples`), the terrain - glFinish, the EnvCell/Particle/PortalDepthMask glFinish brackets (KEEP - `CellHasTransparent` — it's the fix), `DegradeCoverageProbeTests.cs`, and the - worktree-root `fps-*.log` / `wbdiag.log` / `degrade-probe.log`. - ---- - -## 3. The investigation — what's RULED OUT (do not re-investigate) - -Measured live in Arwic (the dense-town test bed). Frame was 29 fps / 34 ms pre-fix. - -| Suspect | Verdict + evidence | -|---|---| -| **Distance-degrade / LOD / triangle count** (the prior handoff's theory) | ❌ DEAD — entity-draw GPU is **0.22 ms**; never detail-bound. (Degrade-coverage data: 92–96 % of scenery HAS degrade tables, but trees hide at 200 m+ and degrade only saves triangles, which aren't the cost.) | -| **MSAA / fill / overdraw / shading** | ❌ **Resolution-independent** — resizing the window tiny did NOT change FPS; MSAA-off (`ACDREAM_MSAA_SAMPLES=0`) didn't help. ⚠ the resize test was run on a `FAR_RADIUS=4` build that had side-effects — **RE-VERIFY resolution-dependence on a CLEAN build in the deep-dive** before fully trusting "not fill". | -| **Update thread** (physics/anim/net/apply) | ❌ `update = 0.1 ms`. | -| **Far terrain draw distance** | ❌ `ACDREAM_FAR_RADIUS=4` didn't help (and slowed things — streaming side-effects; that build's numbers are suspect). | -| **Terrain draw** | ❌ 1 slice / 0.3 ms. | -| **Entity leak** | ❌ modest (`esg≈288`), entity GPU 0.22 ms regardless. | -| **EnvCell per-cell Render (94×)** | ✅ WAS the cause — **FIXED this session.** | - ---- - -## 4. The diffuse remainder — the deep-dive target - -Arwic, **after** the cells fix, whole-frame `gpu ≈ 12 ms` (profiler on; ~13 ms / -75 fps profiler-off). glFinish-inflated per-pass (upper bounds): - -| Pass | GPU (inflated) | calls/frame | Notes | -|---|---|---|---| -| cells | 0.23 ms | 1 | ✅ fixed | -| terrain | 0.31 ms | 1 | cheap | -| **particles** | ~3.1 ms | **22 batches** | additive blend; overdraw? | -| **punch/seal** (`DrawDepthFan`) | ~2.9 ms | **31 fans** | per-fan full GL-state setup (smells like the cells issue) | -| **UNATTRIBUTED** | **~5.5 ms** | — | sky, entities (0.22), depth clear, weather, MDI-dispatch overhead, + glFinish inflation | - -Plus **frame SPIKES** (separate from the steady cost): `cpuRender` p95 ~30 ms, -`present(wait)` p95 ~18 ms → periodic dips to "30 low" fps (stutter). Likely GC or a -periodic streaming/upload hitch. May matter more for *perceived* smoothness. - -**Open questions for the deep-dive (ranked):** -1. **Attribute the ~5.5 ms unattributed GPU.** Instrument sky / weather / the depth - clear / the entity MDI, OR (better) RenderDoc. Is one of them secretly big? -2. **Separate CPU from GPU cleanly.** The glFinish folds GPU into `cpuRender`. Run - with the per-renderer glFinish **disabled** (whole-frame `gpu` query only) to get - the honest `cpuRender` vs `gpu` vs `present` split. If `cpuRender` is high, suspect - the **CPU side**: `PortalVisibilityBuilder` flooding ~47 nearby buildings in - `MergeNearbyBuildingFloods` (the per-frame flood/walk for a dense town could be - real CPU). That was never isolated. -3. **particles (~3 ms, 22 batches):** additive overdraw vs draw-count. Reconcile with - "resolution-independent" (re-verify the resize test cleanly). If overdraw, reducing - particle fill / count helps; if draw-count, batch the 22. -4. **punch/seal (31 fans):** are we punching/sealing too many apertures for an outdoor - root? Each `DrawDepthFan` does a full GL-state set/restore (like the per-cell - Render that we just fixed) — likely **batchable** (set state once, draw N fans). - Check what retail does (decomp: `DrawPortalPolyInternal`, the seal/punch order). -5. **Frame spikes:** GC (allocations per frame — the pview path allocates - `new CellView()`/`new Vector4[]` per aperture in `DrawBuildingLookIns`, `new[]` - tuples per slice) or streaming. A GC/alloc audit of the per-frame render path is - worthwhile. -6. **The meta-question:** even fully attributed, why ~12 ms GPU for this geometry on - modern HW? Look for state thrashing / many small draws / the 2-pass translucency / - redundant per-frame uploads. The user's "something is structurally off" thesis - still stands for the remaining 12 ms. - ---- - -## 5. RECOMMENDED next-session method - -- **RenderDoc frame capture in Arwic** is the right tool (CLAUDE.md: rendering perf → - RenderDoc). It gives exact per-draw GPU time, draw count, overdraw, and state - changes WITHOUT the glFinish distortion. This will attribute the 12 ms far better - than the apparatus. The user is on Windows with a modern GPU. Start here. -- Keep the `[FPS-PROF]`/`[PASS-GPU]` apparatus as a cheap secondary check, but trust - RenderDoc for absolutes. Consider switching the per-pass timers from glFinish to - **non-nested per-pass `TimeElapsed` queries** (no glFinish → no inflation; sequential - passes don't nest) if you want accurate in-engine per-pass numbers without RenderDoc. -- Measurement discipline (this session's lessons): profiler-OFF for the true FPS; - constant-view (stand still) for stable per-pass reads; re-verify the resize/ - resolution test on a CLEAN build; the user drives the client lifecycle (graceful - `CloseMainWindow` before any rebuild — the DLL is locked while it runs). - ---- - -## 6. Repro / launch - -User: `notan` / `MittSnus81!`. Test bed: **Arwic** surface town (the user can get -there; dense buildings → the cost). Build Release; the user runs + watches FPS. - -```powershell -$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" -$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" -$env:ACDREAM_TEST_USER="notan"; $env:ACDREAM_TEST_PASS="MittSnus81!" -$env:ACDREAM_FPS_PROF="1" # [FPS-PROF]+[PASS-GPU]; OMIT for the true FPS the user sees -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release 2>&1 | Tee-Object -FilePath "fps.log" -``` -Title bar shows `fps | ms` (vsync off by default). `[FPS-PROF]`/`[PASS-GPU]` flush -~1 s. Logs are UTF-16 (Tee-Object) — read via PowerShell `Get-Content | Select-String`. - ---- - -## 7. Do-not-retry / lessons -- The cells fix is correct + verified — do NOT revert. Both phases shipped. -- Distance-degrade is NOT the cause — dead lead, don't re-pursue. -- glFinish per-call inflates per-pass absolutes — relative only; whole-frame `gpu` - query is the reliable total; RenderDoc for true per-draw. -- The `FAR_RADIUS=4` build had streaming side-effects — its numbers (incl. the resize - test) are suspect; re-verify on a clean build. -- Don't declare FPS "fixed" on a sub-metric — only on the total frame time the user - sees + user eyes (the prior session's mistake; honored this session). diff --git a/docs/research/2026-06-23-fps-distance-degrade-handoff.md b/docs/research/2026-06-23-fps-distance-degrade-handoff.md deleted file mode 100644 index 7806ceae..00000000 --- a/docs/research/2026-06-23-fps-distance-degrade-handoff.md +++ /dev/null @@ -1,282 +0,0 @@ -# Handoff — FPS in dense towns = MISSING DISTANCE-DEGRADE (port it) — 2026-06-23 - -**Branch:** `claude/thirsty-goldberg-51bb9b` (continue in THIS worktree: -`C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`). -**Working tree:** clean. Everything below is committed on the branch. -**References / named-retail / WorldBuilder** live in the MAIN repo -`C:\Users\erikn\source\repos\acdream\` (read via absolute path; they are NOT in the worktree). - -> **Read with fresh eyes.** This session SHIPPED a real, verified fix (the `_datLock` -> contention that caused the 30↔200 *swing*) — keep it, do not revisit it. But that fix -> did NOT solve the user's actual pain: **sustained ~30 FPS in dense towns (Fort Tethana), -> and it changes with view direction.** That second symptom was then root-caused, end to -> end, to a different cause: **acdream has no distance-based LOD/degrade — it draws every -> object in the frustum at full detail at any distance.** Retail degrades then hides distant -> objects. **The next session's job: port retail's per-frame distance-degrade.** The data is -> already in our dat tables; only the per-frame distance decision is missing. - ---- - -## 0. TL;DR - -1. **DONE + committed (do not touch):** the `_datLock`-contention fix. The streaming worker - now pre-reads the terrain-apply's dats into a `PhysicsDatBundle` so `ApplyLoadedTerrainLocked` - makes zero `DatCollection` calls and its `lock(_datLock)` is removed. **Measured: apply - lock-wait 24 ms median / 88 ms p95 → 0.2 µs.** This killed the streaming-induced *spikes*. -2. **STILL OPEN (the real FPS pain):** sustained ~30 FPS in dense towns, **view-direction- - dependent** ⇒ GPU/render-bound. Root cause **verified**: no distance LOD/degrade; every - frustum-visible object (statics + the huge volume of procedural scenery) is drawn at full - detail regardless of distance. ~1,400 draw calls / ~24,000 instances per frame in dense town. -3. **THE FIX (next session):** port retail `CPhysicsPart::UpdateViewerDistance` → - `GfxObjDegradeInfo::get_degrade` — per-frame, per-object: pick a lower-detail degrade slot - by camera distance, and hide past max distance. **Render-pipeline change** (objects bake - their close-mesh at spawn today; the draw must start carrying degrade variants + selecting - per frame). It's a faithful port, not a redesign — but render changes have been reverted - here when rushed, so do it via the grep→pseudocode→port→verify workflow with a fresh head. - ---- - -## 1. What this session SHIPPED — the `_datLock` fix (committed; DO NOT REVERT) - -**Symptom it fixed:** the wild 30↔200 *swing* while moving/streaming/portal-hopping. - -**Root cause (measured + confirmed by the code's own comment):** `DatCollection` is not -thread-safe, so a single global `_datLock` serializes ALL `DatCollection.Get`. The streaming -worker holds that lock for the ENTIRE per-landblock build (`BuildLandblockForStreaming`, -`GameWindow.cs:~5910`; comment at `:~5885` literally predicted this and named the fix). The -update thread's `ApplyLoadedTerrain` also took `_datLock` (for its own dat reads), so it BLOCKED -on the worker — measured **24 ms median / 88 ms p95** of pure lock-wait. A 43 ms apply = a 23-fps -frame; idle = 200 fps. That asymmetry was invisible to the title-bar ms (apply runs in `OnUpdate`, -FPS counter is `OnRender`). - -**Fix (design A1, spec + plan committed):** the worker (already under `_datLock`) pre-reads the six -dats the apply needs into a `PhysicsDatBundle` carried on `LoadedLandblock`. The apply reads from -the bundle, makes zero `Get` calls, and its `lock(_datLock)` is deleted. Safe because `_datLock`'s -only cross-thread job is serializing `DatCollection`; the apply's other writes are update-thread-only -(`_physicsEngine`, `ShadowObjects`, renderer, `_worldState`, `_buildingRegistries`) or already -concurrent-safe (`PhysicsDataCache` is all `ConcurrentDictionary`). - -**Commits (newest→oldest, prior HEAD was `92e95be`):** -| SHA | What | -|---|---| -| `b3925f4` | chore(diag): [FRAME-DIAG] StreamingController counters (apparatus) | -| `536f1c0` | **fix(streaming): drop `_datLock` from the terrain apply** (the fix) | -| `81a5605` | refactor(apply): read dats from the bundle, not DatCollection | -| `4a99b55` | feat(streaming): worker pre-reads the apply's dats into the bundle | -| `3a0e349` | feat(streaming): `PhysicsDatBundle` on `LoadedLandblock` | -| `5ab5d39` | docs: implementation plan | -| `c715e55` | docs: design spec | - -**Verified (empirical, full session town-walk + 4 teleports):** `lockwait` = 0.1 µs median / -0.2–0.3 µs p95 on EVERY line (was 24 ms / 88 ms). apply town p95 37 ms → 11 ms. Build + 1568 -Core tests green. **The lock fix is correct and real. Keep it.** (It only addressed the *swing*, -not the dense-town floor — see §2.) - -**Files the fix touched:** -- `src/AcDream.Core/World/PhysicsDatBundle.cs` (NEW) — the bundle record; `.Empty` for far tier. - Note: `Environment` aliased to `DatEnvironment` (collides with `System.Environment`). -- `src/AcDream.Core/World/LoadedLandblock.cs` — added `PhysicsDatBundle? PhysicsDats = null` - (trailing default → back-compatible). -- `src/AcDream.App/Rendering/GameWindow.cs` — `BuildPhysicsDatBundle(landblockId, entities)` - (mirrors the apply's 6 read sites; called from `BuildLandblockForStreamingLocked` near return); - the 6 apply sites rewritten `_dats.Get(id)` → `datBundle.X[id]` via `TryGetValue`; the - `lock(_datLock)` removed from `ApplyLoadedTerrain`. -- `src/AcDream.App/Streaming/StreamingController.cs` — only diag counters (apparatus). -- Spec: `docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md`. -- Plan: `docs/superpowers/plans/2026-06-23-datlock-contention-fix.md` (Task 5 — apparatus strip + - spec "verified" mark — is NOT done; see §4). - ---- - -## 2. STILL OPEN — the REAL FPS pain (why Fort Tethana is still ~30 FPS) - -**User's report (the axiom):** "When I portaled in it was high, as soon as I enter the town it -drops, also depending on what direction I look." → portal-in over empty terrain = high; turn toward -the dense town = drop. - -**Diagnosis (locked):** FPS that changes with **view direction** can only be the cost of drawing -what's in the frustum — i.e. **GPU/render-bound**. This is NOT the apply, the streaming, or the lock -(those are view-independent). The lock fix cannot touch it. - -**Root cause (VERIFIED against source):** there is **no distance-based LOD or degrade** on entities -or scenery. The draw path culls by frustum-AABB only, then draws everything in view at full detail: -- `WbDrawDispatcher.WalkEntitiesInto` (`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:~744-747`): - per-entity cull is `FrustumCuller.IsAabbVisible(...)` — **no distance check**. -- `:~779-780`: for any in-frustum entity, **every `MeshRef` is emitted as an instance, every - frame**, regardless of distance. -- `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs` resolves each object to its CLOSE-detail mesh - (`Degrades[0]`) and its own comment (`~line 57-59`) says: *"We don't yet have distance-based LOD - plumbing, so this resolver always returns slot 0."* **We ported the degrade DATA, not the per-frame - distance DECISION.** (It already uses `Degrades[i].MaxDist` in `IsRuntimeHiddenMarker` for the #136 - "red cone" — so the per-slot distances ARE available to us.) -- Scenery (trees/bushes/rocks) is procedurally generated per landblock (`SceneryGenerator`), is the - BULK of dense-town entity count, and goes through the same no-distance path. -- (Co-factors, lower priority: MSAA 4× on the High preset amplifies fill-rate; 2-pass translucency - for foliage. Don't chase these first — the missing distance-degrade is the structural cause.) - -**Measurement caveat carried forward:** the existing `[WB-DIAG] gpu_us` timer brackets ONLY the -entity MDI dispatch (opaque+transparent) — it does NOT measure total GPU frame time (terrain shading, -sky, particles, MSAA resolve, fill-rate stalls). So "gpu_us≈400µs" is NOT the frame cost. **The next -session needs a real total-frame-time / GPU-frame-time meter** (swap-to-swap delta, or a glFinish- -bracketed full-frame timer) to (a) confirm GPU-bound and (b) prove the fix. Do not trust the existing -gpu_us as the frame budget. - ---- - -## 3. THE FIX (next session) — port retail's per-frame distance-degrade - -**Retail mechanism (named-retail decomp; addresses verified this session):** -- `CPhysicsObj::UpdateViewerDistance` (`0x0050f340` / `0x00510b30`) → `CPartArray::UpdateViewerDistance` - → per-part `CPhysicsPart::UpdateViewerDistance` (`0x0050e030`, named-retail line ~275517). Per - frame, per part: compute distance to viewer, call `get_degrade(distance / gfxobj_scale.z, ...)`. -- `GfxObjDegradeInfo::get_degrade` (`0x0051e4b0`, named-retail line ~293086). Algorithm: - - if `degrades_disabled` → slot 0. - - `Render::force_level` → debug override (skip). - - **`effective_dist = |distance| − Render::s_rDegradeDistance`** (the user's "Degrade Distance" - setting, a 0–100 slider; `Render_DegradeDistance`, named-retail line ~3270-3272). - - bias = `Render::auto_update_deg_mul ? Render::deg_mul : Render::s_rUserSuppliedDegradeBias`. - - walk degrade slots; each slot has **`ideal_dist`** and **`max_dist`**; threshold per slot = - `ideal_dist − (ideal_dist − max_dist) * bias`. Pick the slot whose band contains `effective_dist`; - past the last band, use the last slot (whose `gfxobj_id` may be **0 = hidden**). -- `GfxObjDegradeInfo::get_max_degrade_distance` (`0x0051e2d0`, line ~292918) — the hide-past-this distance. -- `CPhysicsPart::Draw` (`0x0050d7a0`) draws `gfxobj[deg_level]`. -- **READ THESE FULLY before porting** (this handoff only summarizes): `get_degrade` body at - named-retail `acclient_2013_pseudo_c.txt:293086+`, `UpdateViewerDistance` at `:275517+`. - Cross-check the degrade-slot struct fields (`ideal_dist`, `max_dist`, `degrade_mode`, `gfxobj_id`) - against `docs/research/named-retail/acclient.h` and acdream's `GfxObjDegradeInfo` / `GfxObjInfo` - dat structs (we already expose `Degrades[i].Id` + `Degrades[i].MaxDist`; confirm whether `ideal_dist` - is also exposed or must be added). - -**The render-pipeline change (the hard part — design it first):** today an object resolves to -`Degrades[0]` at SPAWN and bakes `MeshRef`s. Distance-degrade needs the **draw** to select the slot -**per frame** by camera distance. Options to brainstorm: -- (a) carry ALL degrade-slot GfxObj variants per part on the entity/`MeshRef`, and have - `WbDrawDispatcher` pick the slot per-frame (true retail; most work; per-frame `get_degrade` per part). -- (b) **hide-only first cut**: keep slot-0 mesh, but in `WalkEntitiesInto` skip emitting instances for - entities beyond their `get_max_degrade_distance` (scaled by the DegradeDistance setting). This is the - big, low-risk win (distant scenery/buildings stop drawing) WITHOUT per-frame mesh-swapping. Plumb a - per-entity max-draw-distance (read from the degrade table at spawn) + a per-entity camera-distance - check in the walk. **Recommended phase 1.** -- (c) full LOD-mesh selection (slot swap at intermediate distances) as phase 2 on top of (b). - -**Open design questions for the brainstorm:** -- Where to evaluate distance: per-entity (cheap, approximate) vs per-part (retail-exact)? Start - per-entity. -- The `Render_DegradeDistance` setting: pick the retail default; wire through `QualitySettings` / - `RuntimeOptions` (per CLAUDE.md rule 4). Don't hardcode a magic distance — derive from the degrade - table + the setting. -- Hysteresis/popping: retail's bands prevent thrash; a naive per-frame threshold can pop. Use the - ideal/max band, not a single cutoff. -- Animated entities currently BYPASS the per-entity cull (they're landblock-tracked). Decide whether - they also distance-degrade (retail does, but be careful with the player + nearby NPCs — they should - stay slot 0 / never hide). -- **Divergence register:** the "no distance-degrade" deviation is NOT yet a row in - `docs/architecture/retail-divergence-register.md`. Add the row when the deviation is formally - acknowledged, and DELETE it in the commit that lands the port (per the register rules). - -**Acceptance:** user confirms Fort Tethana / Holtburg FPS no longer drops to 30 when facing the dense -town. Prove it with the new total-frame-time meter (before/after) AND user eyes. NO "fixed" claim on a -sub-metric again (see §6). - ---- - -## 4. Apparatus — `[FRAME-DIAG]` (committed; needs strip; build a NEW meter for the next phase) - -- **`[FRAME-DIAG]`** is committed across `GameWindow.cs` (fields, `OnUpdate` flush, the apply timing + - cell/bsp/shadow/upload/lockwait sub-spans, the `MaybeFlushTerrainDiag` print) and - `StreamingController.cs` (the counters). Gated on `ACDREAM_WB_DIAG=1`, flushes every ~5 s on outdoor - frames. Line format: - `[FRAME-DIAG] apply_us=Xm/Yp95 lockwait=… [cell=… bsp=… shadow=…] (upl=…) entUpl_us=… applies_max/upd=N deferred=N forceReload=N(drop=N) esg=N spawn=N resident=N walked=N` -- **It measures the APPLY (update) side — now PROVEN fixed (lockwait→0).** It does NOT measure the - total render frame time, which is what the distance-degrade work needs. **Next phase: add a - total-frame-time + GPU-frame-time meter** (the missing measurement; §2 caveat). -- **STRIP all of `[FRAME-DIAG]` + any new meter** when the FPS work fully lands (mirrors `92e95be`). - Plan Task 5 (apparatus strip + spec "verified" mark) was deliberately NOT done so the before/after - stays measurable. - ---- - -## 5. Verified file:line map (carry forward; lines drift — match by symbol) - -| Thing | Location | -|---|---| -| Worker holds `_datLock` for full build (the old contention) | `GameWindow.cs:~5910` `BuildLandblockForStreaming`; comment `:~5885` | -| Apply wrapper (lock now REMOVED) | `GameWindow.cs:~6608` `ApplyLoadedTerrain` | -| Apply body (now dat-free; reads `datBundle`) | `GameWindow.cs:~6786` `ApplyLoadedTerrainLocked` | -| Bundle gather (worker) | `GameWindow.cs` `BuildPhysicsDatBundle` | -| **No-distance-cull (the open bug)** | `WbDrawDispatcher.cs:~744-747` (frustum only), `:~779-780` (emit all meshrefs) | -| **Degrade resolver stub ("always slot 0")** | `GfxObjDegradeResolver.cs:~57-59`; `IsRuntimeHiddenMarker` uses `MaxDist` | -| Entity store (drawn set) | `GpuWorldState.cs` `_flatEntities` / `LandblockEntries` / `Entities` | -| `[WB-DIAG]` entity-draw CPU+GPU timer (partial — entity MDI only) | `WbDrawDispatcher.cs:~301-329` | -| Retail `get_degrade` | named-retail `acclient_2013_pseudo_c.txt:293086` (`0x0051e4b0`) | -| Retail per-part `UpdateViewerDistance` | named-retail `:275517` (`0x0050e030`) | -| Retail `get_max_degrade_distance` | named-retail `:292918` (`0x0051e2d0`) | -| Retail `Render_DegradeDistance` setting (0–100) | named-retail `:3270-3272`, `:169441` | -| WorldBuilder reference (no distance LOD either — we must add it) | `references/WorldBuilder/.../SceneryRenderManager.cs`, `VisibilityManager.cs` (MAIN repo) | - ---- - -## 6. Repro + capture - -Launch (Release; user drives + watches FPS; user manages client lifecycle): -```powershell -$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" -$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" -$env:ACDREAM_TEST_USER="notan"; $env:ACDREAM_TEST_PASS="MittSnus81!" -$env:ACDREAM_WB_DIAG="1" # turn OFF for the clean FPS number -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release 2>&1 | Tee-Object -FilePath "fps.log" -``` -Character `+Je` (`notan` / `MittSnus81!`). Repro: portal in (high FPS) → enter the dense town → **look -toward the town vs away** (FPS drops facing the dense geometry). Fort Tethana / Holtburg are the test -beds. Build Release for any FPS read (Debug is not representative). The old probe logs from this -session are in the worktree root (`frame-diag*.log`, `fps.log` — untracked; ignore/clean). - ---- - -## 7. DO-NOT-RETRY / lessons (read before you act) - -- **"lock-wait = 0" is NOT "FPS fixed."** This session declared the FPS fixed on the lockwait sub-metric - and was wrong — the user was still at 30 in Fort Tethana. **Never claim FPS fixed without measuring the - TOTAL frame time the user actually sees, plus user-eyes confirmation.** -- **Verify subagent claims against source.** Two Explore agents this session drifted line numbers and one - fabricated a "rehydrate replays the player" race that source refuted (`SelectGuidsToRehydrate` excludes - the player). Read the cited code yourself before designing on it. -- **Render changes get reverted when rushed/speculative** (AP-48 cull deleted `_lastSpawnByGuid` → missing - walls; the sky-fog shader edits). The distance-degrade port is retail-faithful, but do it via - grep→pseudocode→port→verify, brainstorm the draw-path approach first, and measure before/after. -- **The `_datLock` fix is correct — do not re-investigate or revert it.** lockwait 88ms→0 is proven. -- **The existing `[WB-DIAG] gpu_us` under-reports** (entity MDI only) — don't use it as the frame budget. -- **DatCollection is not thread-safe**; only the worker may `Get` off the update thread (under `_datLock`). - The apply is now dat-free — keep it that way (the bundle is how it gets dats). - ---- - -## 8. Other OPEN follow-ups (deferred this session — lower priority than §3) - -- **H3 — unbounded entity leak / GPU heat-up over hops.** `_entitiesByServerGuid` + `_lastSpawnByGuid` - never evict across teleports (`esg`/`spawn` climbed 1→528 over hops; ACE doesn't DeleteObject across - teleport). Inflates the in-frustum drawn set, so it COMPOUNDS the §3 cost. Fix = the SAFE render-only - eviction (drop far entities' render/GPU state, KEEP `_lastSpawnByGuid`, re-materialize on re-entry) — - NOT the naive AP-48 cull. Pruner: `RemoveLiveEntityByServerGuid` (`GameWindow.cs:~3831`, only caller = - DeleteObject handler + respawn de-dup). Distance-degrade (§3) may make this less urgent. -- **Invisible char on outdoor portal (intermittent).** VERIFIED mechanism: `ForceReloadWindow` (sky-arcs - fix `9945d46`) tears down the whole window on every outdoor teleport; the player is persistent-rescued - (`GpuWorldState.RemoveLandblock:~298`) → re-injected via `DrainRescued`/`AppendLiveEntity`, but lands in - `_pendingByLandblock` (not drawn) until its landblock re-loads. The fade can lift before the player is - flushed into the drawn set → invisible. (The Explore agent's "rehydrate replays player" theory was - WRONG — player is excluded from rehydrate at `LandblockEntityRehydrator.cs:79`.) Likely fix: gate the - teleport fade-lift on "player is in the drawn set," not just terrain residency. Needs a player-draw- - membership probe to pin "fade-too-early" vs "stuck-in-pending." - ---- - -## 9. References (read FIRST per the relevant thread) -- `docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md` — the shipped fix's design. -- `docs/research/2026-06-23-fps-gpu-churn-handoff.md` (MAIN repo) — the handoff that STARTED this session. -- `docs/research/2026-06-22-world-load-fps-rootcause-report.md` (MAIN repo) — prior FPS deep-dive. -- named-retail `docs/research/named-retail/acclient_2013_pseudo_c.txt` — `get_degrade` (:293086), - `UpdateViewerDistance` (:275517). `acclient.h` for the degrade structs. -- `references/WorldBuilder/` (MAIN repo) — scenery/object render path acdream extracted (no distance LOD; - read `docs/architecture/worldbuilder-inventory.md` first). -- `claude-memory/project_render_pipeline_digest.md` + `project_physics_collision_digest.md` (DO-NOT-RETRY tables). -- CLAUDE.md "Development workflow: grep named → decompile → verify → port" — MANDATORY for the distance-degrade port. diff --git a/docs/research/2026-06-23-paperdoll-slice2-handoff.md b/docs/research/2026-06-23-paperdoll-slice2-handoff.md deleted file mode 100644 index 952a6910..00000000 --- a/docs/research/2026-06-23-paperdoll-slice2-handoff.md +++ /dev/null @@ -1,89 +0,0 @@ -# Handoff — D.2b Sub-phase C, Slice 2: the paperdoll 3-D doll + the "Slots" toggle - -**Date:** 2026-06-23 (end of the Slice 1 session) -**Branch:** `claude/hopeful-maxwell-214a12`. **Slice 1 SHIPPED + merged** — `main` is fast-forwarded to the branch tip (`main == branch` at `6897148`). Slice 2 continues on this branch. -**Status:** SCOPED, not started. Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow. -**Line numbers drift — grep the symbol.** - ---- - -## Read first (in this order) - -1. **This doc.** -2. **`claude-memory/project_d2b_retail_ui.md`** — the D.2b SSOT. The **"SLICE 1 … SHIPPED 2026-06-23"** entry holds the CORRECTED paperdoll model + the full DO-NOT-RETRY. **It supersedes the visual/look claims in the older docs below.** -3. **`docs/research/2026-06-16-equipment-paperdoll-deep-dive.md`** — the AUTHORITATIVE element-id / wire / viewport research: §5 (the viewport mechanism, CONFIRMED), §5c (reuse analysis), §5d + §7 (camera/light immediates + the UNVERIFIED list). **⚠ Caveat:** its §2a "empty slot shows the doll body behind it" and the 2026-06-22 handoff's "transparent / per-slot silhouettes" framing were **WRONG about the LOOK** — see the corrected model below. The element ids, opcodes, and viewport plumbing in it are still good. -4. **`docs/architecture/code-structure.md` Rule 2** — Core defines interfaces, App implements; never the reverse. Governs the `IUiViewportRenderer` seam. -5. **`src/AcDream.App/UI/Layout/PaperdollController.cs`** — Slice 1's controller (the 21 slot bindings + wield/unwield drag handler you'll extend, not rewrite). -6. **`memory/reference_modern_rendering_pipeline.md`** + `EntitySpawnAdapter`/`AnimatedEntityState` — the in-world animated-character path the doll reuses. - -## The CORRECTED paperdoll model (USER AXIOM — confirmed against his retail screenshots, 2026-06-23) - -**There are NO per-slot silhouette sprites.** The "figure" in the paperdoll **IS the live 3-D character** (the doll — it can be naked if nothing is equipped). The panel has **two views toggled by the "Slots" button** (`0x100005BE` = `m_SlotCheckbox`, a `UIElement_Button`): - -- **Button OFF (default) — "doll view":** show the **3-D character** + the **non-armor** slots (under-clothes / weapon / wand / shield / jewelry). The armor slots are hidden. -- **Button ON — "slot view":** the **character disappears** and the **armor slots become visible** (the grid). - -It is a **TOGGLE (mutually exclusive: doll XOR armor-slots)**, NOT slots-overlaid-on-the-doll. Verify the exact show/hide split against the decomp (below) + the user's screenshots before coding — do not re-derive the look from the old deep-dive prose. - -**What Slice 1 already did (don't redo):** bound all ~21 equip slots (`PaperdollController`, element-id→`EquipMask` map), wield (`GetAndWieldItem 0x001A`, optimistic + rollback) + unwield (the inventory-grid path + the `PickupEvent` two-table fix), and gave empty slots a **visible frame** (`emptySlotSprite = 0x06004D20`) so all positions are usable. Slice 1 shows **all** slots at once (no toggle, no doll). Slice 2 adds the doll + the toggle on top. - -## Slice 2 — two pieces (each its own spec → plan → implement; the viewport is the crux) - -### A. The "Slots" toggle (the lighter piece — do FIRST, it de-risks the layout) - -Wire the `m_SlotCheckbox` button (`0x100005BE`) to switch between doll-view and slot-view: -- **Map each slot as ARMOR vs NON-ARMOR.** Retail's `RemakeCharacterInventory` (decomp `0x004a65c0`) builds `clothingPriorityMask` from the mask **`0x8007fff`** (line 176016) — clothing/jewelry/misc; the armor slots are the `*Armor` `EquipMask` bits (`ChestArmor 0x200 … LowerLegArmor 0x4000`). Confirm the exact partition from `GetLocationInfoFromElementID` (the §3a table) + the toggle handler. -- **READ THE TOGGLE HANDLER FIRST:** `gmPaperDollUI::ListenToElementMessage` (decomp `0x004a5c30`, ~line 175593) — the `idMessage == 1` branch (button click, ~line 175628+, NOT yet read this session) is the show/hide logic. The init does `SetAttribute_Bool(m_SlotCheckbox, 0xe, 0)` (line 175585) = start unchecked (doll view). Port the exact `SetVisible` toggling it does on the armor slots + the viewport. -- In acdream: `PaperdollController` keeps refs to the armor slots (toggle-hidden in doll view) + the viewport widget; the button's click flips their `Visible`. The non-armor slots stay visible in both views (verify). -- **Slice-1 caveat to revisit:** AP-66 (empty slots show a frame) — decide whether the frame stays in slot-view (likely yes) and what an empty NON-ARMOR slot shows in doll-view. The "frames flip to transparent / doll-through" idea in AP-66 was written under the overlay assumption; the TOGGLE model means there is no doll behind the armor slots in slot-view, so the frame likely **stays**. Re-word AP-66 once the toggle behavior is confirmed. - -### B. The 3-D doll `UiViewport` (the heavy piece — the UI↔3D bridge, the real crux) - -A `UiViewport` widget (registers at dat **Type `0xD`**, element **`0x100001D5`**) that renders a re-dressed clone of the local player into the widget's screen rect (a scissored single-entity 3-D pass), shown in doll-view. - -- **The Core→App seam (Code-Structure Rule 2):** define `IUiViewportRenderer` in `AcDream.Core` (or the UI.Abstractions layer), implement in `AcDream.App` (it has GL + `EntitySpawnAdapter`). The widget's `OnDraw` only has a 2-D `UiRenderContext`; the 3-D pass needs a dedicated overlay hook the `UiHost`/`GameWindow` invokes for any `UiViewport` present (NOT inside `OnDraw`). **The exact integration point (after the world pass vs a UI overlay) is DESIGN-OPEN — settle it in the brainstorm** (deep-dive §5d/§7). -- **Reuse the existing char path (deep-dive §5c, CONFIRMED):** build a `WorldEntity` from the local player's Setup + current ObjDesc, feed it through `EntitySpawnAdapter`/`AnimatedEntityState` (palette/part/hidden-part overrides), draw it with a fixed camera + one distant light into the rect. Re-dress on `ObjDescEvent 0xF625` (already PARSED) = rebuild the entity's overrides — the C# analog of retail `RedressCreature` (decomp `0x004a5c90`?/line 173990 / 175535). -- **Camera/light/heading immediates** (retail `gmPaperDollUI::PostInit`, decomp ~175509-535; raw hex read this session, **decode + VERIFY**): - - `SetCamera(viewport, &dir, &pos)` with `dir ≈ (0x3df5c28f=0.12, 0xc019999a=-2.4, 0x3f6147ae=0.88)`, `pos = (0,0,0)`. (Arg order pos-vs-dir UNVERIFIED — `UIElement_Viewport::SetCamera` → `CreatureMode::SetCameraPosition/Direction`.) - - `SetLight(viewport, DISTANT_LIGHT, 2.0f, &dir)` with `dir ≈ (0x3e99999a=0.3, 0x3ff33333=1.9, <3rd component is a strncpy/lifter artifact — recover from Ghidra/re-decompile>)`. - - `set_heading(191.367905°)` so the doll faces the viewer; idle animation via `m_didAnimation` (`UpdateForRace` `0x004a…`/line 174129 swaps the idle DID per body-type via `DBObj::GetDIDByEnum`); `CreatureMode::UseSharpMode` (sharper mip bias). -- **Player-clone vs fresh WorldEntity (UNVERIFIED, deep-dive §7):** retail clones the player `CPhysicsObj` (`makeObject(GetPhysicsObject(player_id))`). acdream's local player is NOT a renderable `WorldEntity` (it's the camera), so LIKELY build a dedicated `WorldEntity` from the player's Setup + ObjDesc and host it in a private viewport scene. Confirm the player's Setup id + current ObjDesc are available client-side (PlayerDescription / CreateObject / the equipped ObjDescEvents). -- **Polish, NOT MVP:** part-selection lighting (`ApplyPartSelectionLighting` / `GetSelectionMaskFromObject` / `CreateClickMap`, lines 174034/174762/174636) — the "which armor piece is this?" highlight + the doll click-map; the Aetheria sigil slots (`0x10000595/96/97`, `SetVisible(0)` by default). Defer. - -## What's already in place (reuse, don't rebuild) - -- **`PaperdollController`** — the slot bindings + wield/unwield + the element-id→`EquipMask` map. Extend it with the armor/non-armor partition + the viewport ref + the toggle. -- **The mount + import** — the gmPaperDollUI subtree (`0x21000024`) is imported under `0x100001CD` in the inventory frame; the viewport element `0x100001D5` is in the tree (currently skipped — `DatWidgetFactory` Type 12→skip; Type `0xD` is NOT registered yet → register it to `UiViewport`). -- **`EntitySpawnAdapter` / `AnimatedEntityState` / `WorldEntity`** — the per-instance animated-character render path (palette/part/hidden-part overrides). The doll's model pipeline. -- **`ObjDescEvent 0xF625`** parse + `CreateObject.ReadModelData` (palette/sub-palette/texture/anim-part) — the re-dress input. -- **`ClientObjectTable` + the wield wire** (`GetAndWieldItem`, `WieldObject 0x0023` + `ConfirmMove`, `PickupEvent` two-table fix) — all shipped in Slice 1. - -## Open questions / UNVERIFIED (settle in the brainstorm) - -1. **Overlay vs replace** — confirmed REPLACE (toggle) per the user, but verify the exact `SetVisible` set the `ListenToElementMessage idMessage==1` handler toggles (armor slots + viewport; do the non-armor slots stay shown in slot-view?). -2. **The `IUiViewportRenderer` integration point** — after the world pass, or a dedicated UI-overlay 3-D pass? Scissor + viewport from the widget's screen rect. Code-Structure Rule 2 = Core interface, App impl. -3. **Camera/light immediates** — decode + verify the floats above; recover the corrupted 3rd light component from Ghidra. -4. **Player-clone vs fresh `WorldEntity`** — acdream has no player `WorldEntity`; build one from Setup+ObjDesc. Confirm the data is available client-side. -5. **AP-66 fate** — does the empty-slot frame stay in slot-view (likely) or change? Re-word the register row once the toggle behavior is confirmed. -6. **`0x100001E0 = MissileAmmo 0x800000`** still LIKELY (AP-62) — gate-verify the ammo slot. - -## Decomp anchors (named-retail `acclient_2013_pseudo_c.txt`) - -- `gmPaperDollUI::PostInit` (~175232; the slot/viewport/button init I read this session — `SetVisible(0)` per slot, `SetCamera`/`SetLight`/`UseSharpMode`/`RedressCreature` at 175509-535, `m_SlotCheckbox = 0x100005BE` at 175557). -- `gmPaperDollUI::ListenToElementMessage` (~175593; **read the `idMessage==1` branch ~175628+ for the toggle**). -- `gmPaperDollUI::RedressCreature` (173990 / 175535; clone + `set_heading(191.37°)` + `set_sequence_animation` + `DoObjDescChangesFromDefault`). -- `gmPaperDollUI::UpdateForRace` (174129; per-body-type camera + idle DID). -- `gmPaperDollUI::RemakeCharacterInventory` (175983) + `SetUIItemIntoLocation` (175713/175950; populate equipped items — Slice 1 does the equivalent). -- `UIElement_Viewport::Create/SetCamera/SetLight/PostInit` + `CreatureMode::Render` (deep-dive §5a/§5b, lines 119029/91665). - -## Acceptance (Slice 2) - -- The "Slots" button toggles: doll-view (3-D character + non-armor slots) ↔ slot-view (armor slots). Matches the user's two screenshots. -- The doll renders the re-dressed local player (correct race/gender/equipped gear; naked if nothing equipped), faces the viewer, idles; updates live on equip/unequip via `ObjDescEvent 0xF625`. -- Build + full suite green; **visual gate** (user compares to retail). Divergence rows for any approximation; the `IUiViewportRenderer` seam respects Code-Structure Rule 2. - -## New-session prompt - -> Continue acdream's D.2b retail-UI inventory arc on branch `claude/hopeful-maxwell-214a12` (Slice 1 shipped; `main` is ff-merged to the tip). **READ FIRST:** `docs/research/2026-06-23-paperdoll-slice2-handoff.md`, then its "Read first" list — especially the **Slice 1 entry in `claude-memory/project_d2b_retail_ui.md`** (the CORRECTED paperdoll model) and the deep-dive `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` §5. -> -> **NEXT: Sub-phase C — the paperdoll, Slice 2.** The corrected model (user axiom): the paperdoll "figure" IS the **live 3-D character** (not silhouettes); the **"Slots" button (`0x100005BE`)** TOGGLES between **doll-view** (3-D character + non-armor slots) and **slot-view** (armor slots) — mutually exclusive, NOT overlaid. Build (A) the **toggle** first — read `gmPaperDollUI::ListenToElementMessage`'s `idMessage==1` branch (~decomp 175628+) for the exact `SetVisible` show/hide of the armor slots + viewport; partition armor vs non-armor slots (armor = the `*Armor` EquipMask bits; clothing/jewelry/weapon = non-armor, cf. `clothingPriorityMask` `0x8007fff`). Then (B) the **3-D doll `UiViewport`** (register dat **Type `0xD`**, element `0x100001D5`): a Core `IUiViewportRenderer` seam (Code-Structure Rule 2 — Core defines, App implements) driving a scissored single-entity 3-D pass keyed to the widget rect; reuse `EntitySpawnAdapter`/`AnimatedEntityState` by building a `WorldEntity` from the local player's Setup + current ObjDesc, re-dressed on `ObjDescEvent 0xF625` (the C# analog of `RedressCreature`); fixed camera + one distant light + `set_heading(191.37°)` + idle anim (decode the `PostInit` immediates ~175509-535 + verify). **Extend the shipped `PaperdollController` — do NOT rewrite the slot bindings/wield/unwield.** The UI↔3-D integration point is DESIGN-OPEN — settle it in the brainstorm. Run the full **brainstorm → spec → plan → subagent-driven → visual-gate** flow. **DO NOT auto-kill the running client** — launch with plain `dotnet run`; if a rebuild is locked, ask the user to close it. diff --git a/docs/research/2026-06-24-collision-inclusion-audit.md b/docs/research/2026-06-24-collision-inclusion-audit.md deleted file mode 100644 index 6130ce15..00000000 --- a/docs/research/2026-06-24-collision-inclusion-audit.md +++ /dev/null @@ -1,189 +0,0 @@ -# Collision-inclusion audit — retail vs acdream ("1 fix for all collision") — 2026-06-24 - -**Mode:** report-first / brainstorm-gated (DO-NOT-RETRY collision area). No code written. -**Method:** 10 parallel named-retail readers (5 retail-model + 5 acdream-channel) → synthesis → **adversarial verification of every claimed deviation against the actual cited code** (the [[feedback_verify_subagent_claims_against_source]] discipline). Plus orchestrator first-hand reads of `CollisionExemption.cs`, `EntityCollisionFlags.cs`, `PhysicsDataCache.cs`, `TransitionTypes.cs`, `GameWindow.cs:7388-7565`, and the divergence register. -**Branch:** `claude/thirsty-goldberg-51bb9b`. Named-retail + `references/{ACE,ACViewer,holtburger}` + `symbols.json` live in the MAIN repo. - ---- - -## TL;DR — the unifying insight - -Retail's collision model is **two clean layers**: - -- **Layer B (registration / broad phase)** — every object with a *DAT physics shape* is written **unconditionally** into the per-cell `shadow_object_list` of every cell its sphere overlaps. **No attribute is tested at registration** (not STATIC, not ETHEREAL, not HIDDEN — only `PARTICLE_EMITTER_PS 0x1000` diverts to a particle list). The only thing that decides "is this registerable" is **does it have a shape** (`physics_bsp` from DAT bit-0, or a `CSetup` cylsphere/sphere). Source: `CObjCell::find_cell_list@0x0052b4e0`, `CPhysicsObj::add_shadows_to_cells@0x00514ae0`. -- **Layer A (inclusion predicate / narrow phase)** — `CPhysicsObj::FindObjCollisions@0x0050f050` decides per-query whether the registered object actually blocks, via a flag predicate (ETHEREAL+IGNORE, viewer-vs-creature, IGNORE_CREATURES, PvP pool), then dispatches **one** shape branch (BSP-only iff `HAS_PHYSICS_BSP_PS 0x10000`, else CylSphere-then-Sphere). - -**acdream already has this two-layer shape** (per-cell `ShadowObjectRegistry` + query-time `CollisionExemption`). The user's intuition is correct, and the divergences are now **narrow and enumerable** — they are NOT a scattered mess of per-channel filters anymore (the #147 portal-count skip was the last of that family and is already deleted in `9743537`). What remains: - -1. **The shape-source rule is impure** — acdream fabricates collision from the **render-mesh AABB** for scenery + outdoor meshes (D1, the prime deviation; retail *never* consults render bounds). This is the one place acdream **over-includes** vs retail. -2. **The query predicate is incomplete** — ETHEREAL-alone short-circuit (D2), no true sphere primitive (D3), PvP/missile terms hardcoded false (watch-item W1). -3. **One cached collision transform can go stale** — EnvCell cell-struct cache lacks the per-apply rebase that buildings got in #146 (D8). Plus two indoor-channel omissions that are latent today (D4 entry-restrictions, D5 obstruction_ethereal). - -So **"1 fix for all collision" = make the shape-source rule pure (DAT-only) + complete the query predicate + give every cached transform the same per-apply rebase.** It is a *unification of three things*, not one line — but it collapses to **one `ObjectCollides(...)` predicate + one registration rule + one rebase rule** that every channel funnels through. - ---- - -## Verdict table (6 confirmed deviations, 2 refuted false-positives) - -| ID | Sev | Layer | Title | Verdict | Register | -|----|-----|-------|-------|---------|----------| -| **D1** | **HIGH** | shape-source | Render-mesh-AABB → synthetic collision cylinder for scenery (#101 residual) | **CONFIRMED** (high) | extend **AP-2** (currently cites only the Setup case at `PhysicsDataCache.cs:96`; the scenery `0x8…` site at `GameWindow.cs:7408` is uncovered) | -| **D2** | MED | predicate | ETHEREAL-alone instant-skip; retail needs ETHEREAL **and** IGNORE_COLLISIONS | **CONFIRMED** (high) | **AD-7** exists | -| **D3** | LOW | shape-source | Setup `Sphere`s coerced to short cylinders (no true sphere primitive) | **CONFIRMED** (high) | **NEW ROW** needed | -| **D4** | LOW | predicate | EnvCell path omits `check_entry_restrictions` (access-locked cells) | **CONFIRMED** (high) | **NEW ROW** (no effect in dev content) | -| **D5** | MED | predicate/state | EnvCell never clears `obstruction_ethereal` — **field doesn't even exist** in acdream `SpherePath` | **CONFIRMED** (high) | **NEW ROW** (couples with D2) | -| **D8** | MED | stale-frame | EnvCell cell-struct `WorldTransform` never evicted/rebased (asymmetric with buildings #146) | **CONFIRMED** (med) | **NEW ROW** (latent; masked by single-block dungeon collapse) | -| ~~D6~~ | — | dispatch | "EnvCell never calls `placement_insert`" | **REFUTED** | acdream *does* branch — inside `BSPQuery.FindCollisions` (`BSPQuery.cs:1717`), not at the call site. Functionally identical to retail. **No fix.** | -| ~~D7~~ | — | terrain | "Terrain silently passes through when no landblock registered" | **REFUTED** | **Retail does the same** — `LScape::get_landcell@0x00505f10` returns null for an unloaded block, `transitional_insert@0x0050b6f0` returns OK_TS on null cell. This is the **#135/#138 streaming-gap free-fall**, a streaming-foundation issue, NOT collision-inclusion. | - ---- - -## The retail model (the oracle table) - -### Layer A — the inclusion predicate (`CPhysicsObj::FindObjCollisions@0x0050f050`) - -PhysicsState flags and whether each gates collision (verified against `acclient.h` + the decomp body, lines 276776-276996, cross-checked vs ACE `PhysicsObj.cs:381-454`): - -| Flag | Value | Gates collision? | Mechanism | -|---|---|---|---| -| `ETHEREAL_PS` | 0x4 | **Only with 0x10** | Instant-skip (return OK_TS, no shape test) requires **both** 0x4 AND 0x10 (`276782`). 0x4 alone → set `sphere_path.obstruction_ethereal=1` and **still run shape test** (`276795-276806`); downstream step-down passes through but contact is recorded. | -| `IGNORE_COLLISIONS_PS` | 0x10 | with 0x4 | (above) | -| `HAS_PHYSICS_BSP_PS` | 0x10000 | **dispatch** | Set (and not missile-ignored, not PvP-exempt `ebp_1==0`) → **BSP-only** via `CPartArray::FindObjCollisions` (`276858-276961`). Clear → CylSphere loop then Sphere branch. **Mutually exclusive** — never both. | -| `STATIC_PS` | 0x1 | no (response only) | Decides hit is reported as `collided_with_environment` vs object collision (`276969-276981`); does not skip. | -| `REPORT_COLLISIONS_PS` | 0x8 | no | Gates `DoCollisionEnd/Begin` *callback delivery* only (`278614`), not geometry. | -| `NODRAW_PS` | 0x20 | **no** | Not read anywhere in the collision path. Render-only. (Confirms handoff hypothesis.) | -| `MISSILE_PS` | 0x40 | no | Marks target `isCreature=true` for sphere dispatch; `missile_ignore` (`274390`) makes a missile mover skip missile targets. | -| `HIDDEN_PS` | 0x4000 | indirect | `set_hidden` *also* sets 0x10 + clears 0x8 as a side-effect (`282992`,`283029`); but HIDDEN alone doesn't set ETHEREAL, so Gate-1 still needs 0x4. | -| `SCRIPTED_COLLISION_PS` | 0x8000 | **no** | Never read in the physics collision path. | -| `FROZEN_PS` | 0x1000000 | no | Gates `update_object` (sim skip) only (`283955`). | - -Plus the non-flag gates: mover≠self, not-creature-when-viewer/IGNORE_CREATURES mover, PvP pool (both-players → skip unless target Impenetrable OR both PK OR both PKLite). - -### Layer B — registration / broad phase - -`shadow_object_list` membership predicate (`CObjCell::find_cell_list@0x0052b4e0`): -- **(1)** object is NOT a pure particle emitter (`PARTICLE_EMITTER_PS 0x1000`), **(2)** at least one collision sphere overlaps the cell volume (indoor: `sphere_intersects_cell != OUTSIDE`; outdoor: sphere center maps to the cell grid square + boundary neighbours). **No other attribute tested at registration.** A single object lands in **multiple** cells' lists (the doorway-door-in-both-cells answer that the A6.P4/BR-7 flood already ports). - -### The four collision channels (per-cell dispatch order: env → building → objects) - -| Channel | Function | Inclusion rule | -|---|---|---| -| **Terrain** | `CLandCell::find_env_collisions@0x00532f20` | `check_entry_restrictions` → `find_terrain_poly` (always 2 triangles) → water-skip clause → `validate_walkable`. Always solid where the landblock is loaded. | -| **EnvCell (indoor)** | `CEnvCell::find_env_collisions@0x0052c130` | `check_entry_restrictions` → **zero `obstruction_ethereal`** → if `structure->physics_bsp != null`: `find_collisions` (or `placement_insert` for INITIAL_PLACEMENT). **No building leg.** | -| **Building shell** | `CBuildingObj::find_building_collisions@0x006b5300` via `CSortCell::find_collisions@0x005340a0` | In `CLandBlockInfo.buildings` for an outdoor block AND `makeBuilding` succeeded AND origin landcell non-null. **Portal count / portal list / model id NOT examined.** BSP on `part_array->parts[0]`. (This is the #147 truth.) | -| **Object shadows** | `CObjCell::find_obj_collisions@0x0052b750` → Layer A | Per-cell shadow list iteration. | - -### Shape assignment (the rule D1 violates) - -A `CPhysicsObj` gets a collision shape from **DAT only**: a part's `CGfxObj.physics_bsp` (deserialized by `CGfxObj::Serialize@0x00534970` *only* when serialized-flags bit-0 / `num_physics_polygons>0`), or `CSetup` cylsphere/sphere arrays. `CPartArray::InitParts@0x00517F40` builds parts from the Setup list only. When none exist, `CPhysicsPart::find_obj_collisions@0x0050D8D0` returns OK (passable) — **no synthesis from drawing geometry.** The render-mesh `gfx_bound_box` is used for cell-registration + frustum culling, **never** collision. - ---- - -## The acdream model (per-channel de-facto predicate) - -| Channel | acdream predicate (de-facto) | Code | Verdict vs retail | -|---|---|---|---| -| **Terrain** | foot-XY inside a registered landblock's `[0,192)²` AND outdoor cell. null landblock → pass through. | `PhysicsEngine.SampleTerrainWalkable` `:261`; `TransitionTypes.cs:2229-2247` | **Faithful** (D7 refuted — retail passes through unloaded blocks too). | -| **Building** | outdoor cell AND `GetBuilding != null` AND `ModelId != 0` AND `GetGfxObj(ModelId).BSP.Root != null` | `TransitionTypes.cs:2805-2819` | **Faithful** post-#146/#147. `ModelId==0 → inert` ≈ retail "no parts[0]". Per-apply rebase via `RemoveBuildingsForLandblock` (#146). | -| **Shadow objects** | source hi-byte 0x01/0x02 AND not building shell AND not phantom Setup/GfxObj AND ≥1 non-zero-radius shape **OR scenery mesh-AABB fallback** | `GameWindow.cs:7185-7565`; `ShadowObjectRegistry.cs`; `ShadowShapeBuilder.cs` | **D1 (mesh-AABB), D3 (sphere-as-cyl)** + watch-items. | -| **EnvCell** | cell in BFS shadow-set AND `CellPhysics.BSP.Root != null` | `TransitionTypes.cs:2062-2207`; `CellTransit.cs` | **D4, D5, D8.** | -| **Query exemption** | `CollisionExemption.ShouldSkip` — ETHEREAL(0x4) alone, viewer/creature, IGNORE_CREATURES, PvP pool | `CollisionExemption.cs:59` | **Faithful except D2** (ETHEREAL-alone). PvP block is a clean port (cross-checked vs ACE). | - ---- - -## Confirmed deviations — detail - -### D1 — Render-mesh-AABB synthetic collision cylinder (HIGH, the prime deviation) -- **Retail:** shape from DAT `physics_bsp`/`CSetup` only; no shape ⇒ passable (`CPhysicsPart::find_obj_collisions@0x0050D8D0` returns OK when `physics_bsp==null`; `CGfxObj::Serialize@0x00534970` gates `physics_bsp` on flags bit-0; `CPartArray::InitParts@0x00517F40`). -- **acdream:** for scenery (`0x80000000` IDs) with no CylSphere/BSP, synthesizes a clamped `[0.30,1.50]m` cylinder from the **world-space render-mesh AABB** and registers it. `GameWindow.cs:7421-7425` gate: `!isPhantomSetup && !isPhantomGfxObj && !_isLandblockStab && (_isOutdoorMesh || (entityBsp==0 && entityCyl==0))`. For scenery `_isOutdoorMesh` is always true (`:7151`); the #101 phantom gates only cover `0x01…` GfxObjs (`IsPhantomGfxObjSource`, `PhysicsDataCache.cs:399-403`) and zero-radius Setups — a Setup-sourced scenery with `Radius>0.0001` and no `physics_bsp` still gets the synthesized cylinder. -- **Why it exists:** the comment admits a *gameplay* motive — "catches trees whose BSP is only on the canopy (player walks under)". This is exactly the attribute/shape category-error the user wants gone: acdream invents collision retail doesn't have. -- **Register:** **AP-2** must be widened to cover `GameWindow.cs:7408` (it currently cites only `PhysicsDataCache.cs:96`). - -### D2 — ETHEREAL-alone instant-skip (MED) -- **Retail:** Gate-1 needs **both** 0x4 AND 0x10 (`276782`); 0x4 alone → `obstruction_ethereal=1` + still run shape test (`276795-276806`), consumed by BSP solid-containment gates (`321692`,`323742`,`324573`). -- **acdream:** `CollisionExemption.cs:78` skips on 0x4 alone. Documented shim (**AD-7**) because ACE `Door.Open()` broadcasts `0x0001000C` (ETHEREAL only). The `obstruction_ethereal` set-and-continue path is unported. - -### D3 — No true sphere primitive (LOW) -- **Retail:** `HAS_PHYSICS_BSP_PS` clear → CylSphere then **Sphere via `CSphere::intersects_sphere`** (`276917`) — a real sphere test. -- **acdream:** `ShadowShapeBuilder.cs:68-81` coerces `Setup.Spheres` → short cylinders (`height = radius*2`); `ShadowCollisionType` enum (`ShadowObjectRegistry.cs:537`) has only `{BSP, Cylinder}` — no Sphere. **New register row.** - -### D4 — EnvCell omits `check_entry_restrictions` (LOW, no current effect) -- **Retail:** `CEnvCell::find_env_collisions@0x0052c130` calls `check_entry_restrictions@0x0052b6d0` first; returns COLLIDED_TS when an access-locked cell's `restriction_obj` rejects the mover. -- **acdream:** `TransitionTypes.cs:2062-2207` has no such gate. No access-locked cells in dev content ⇒ no observable effect, but structurally absent. **New register row.** - -### D5 — `obstruction_ethereal` never cleared — field absent entirely (MED) -- **Retail:** zeros `sphere_path.obstruction_ethereal` on every `find_env_collisions` call (`0x0052c144`) before BSP dispatch. -- **acdream:** the field **does not exist** in `SpherePath` (`TransitionTypes.cs:315-435`) — so the retail BSP solid-weakening gate keyed on it is simply not modelled. Couples with D2 (port the set/clear/consume sites together). **New register row.** - -### D8 — EnvCell cell-struct transform never evicted/rebased (MED, latent) -- **Retail:** no persistent client-side streaming-relative transform cache; cell geometry rebuilt from live cell pointers (eviction analog: `CEnvCell::release`). -- **acdream:** `PhysicsDataCache._cellStruct` (`:179`) is first-wins `ContainsKey` with **no eviction** — contrast `RemoveBuildingsForLandblock` (`:472`, the #146 fix). `PhysicsEngine.RemoveLandblock` (`:121-141`) clears `_landblocks`/`ShadowObjects`/`CellGraph` but never cells. A dungeon streamed out→in after a `_liveCenter` recenter would hold a stale `WorldTransform` (off by `(oldCenter−newCenter)·192m`). Masked today by the single-block collapse+recenter dungeon pattern. **New register row.** This is the #146 stale-frame class the handoff predicted "almost certainly more of these" — and there is exactly one more. - ---- - -## Refuted (do NOT chase — anti-rabbit-hole) - -- **D6 (placement_insert):** acdream **does** route INITIAL_PLACEMENT to `PlacementInsert` — the branch is inside `BSPQuery.FindCollisions` (`BSPQuery.cs:1717`, `if (path.InsertType == Placement || obj.Ethereal)`), not at the `FindEnvCollisions` call site. Identical functional result. -- **D7 (terrain pass-through):** retail `LScape::get_landcell@0x00505f10` returns null for an unloaded block and `transitional_insert@0x0050b6f0` returns OK_TS on a null cell — **retail passes through unloaded terrain too.** The free-fall is the **streaming-gap** (#135/#138), owned by the cell-relative-frame work, not collision-inclusion. -- **Building `ModelId==0` filter** (`TransitionTypes.cs:2813`): **checked, faithful** — equals retail's "no `parts[0]` ⇒ no shell BSP". -- **#147 portal-count skip:** already deleted (`9743537`). The last scattered attribute-as-filter is gone. -- **Registration-time attribute filters:** acdream's registration is already overlap-based with filtering deferred to query time (`CollisionExemption`) — **architecturally already retail-shaped.** The remaining divergences are shape-source + predicate-detail + one stale cache, not a registration-filter mess. - ---- - -## Watch-items (sub-deviations not promoted — note, don't necessarily fix now) - -- **W1 — PvP/missile dispatch terms hardcoded false** (`TransitionTypes.cs:2629`): the cyl-vs-BSP dispatch assumes `pvpExempt=false && missile_ignore=false`. Harmless in M1.5 (no PK/missiles), but a missile/PK mover would wrongly take BSP-only against all BSP targets. Belongs in the unified predicate when PK/missiles ship. -- **W2 — `BldPortalInfo.ExactMatch`** decoded but unconsumed (`BuildingPhysics.cs:74`, `CellTransit.CheckBuildingTransit`): a *transit-feature*, not collision-inclusion — explicitly out of scope for this phase. -- **W3 — `ShadowShapeBuilder` default radius** `2f` (`:97`) vs `GameWindow` stab fallback `1f` (`:7215`) when `BoundingSphere` is null — minor inconsistency. -- **W4 — `CLandCell` ENTIRELY_WATER skip clause** (state 0x04 / 0x40) may be unimplemented in the terrain path — verify before deep-water content. -- **W5 — static-prune `VisibleCellIds` vs retail `do_not_load` stab_list** (`CellTransit.cs:549-562`): approximation; verify against the CEnvCell dat stab_list field. - ---- - -## The unified target — "1 fix for all collision" (PROPOSAL — brainstorm-gate before coding) - -One **`ObjectCollides(targetState, moverState, targetFlags, shape)`** predicate + one **registration rule** + one **rebase rule**, that every channel funnels through: - -**(a) Pure shape resolution (one helper, DAT-only).** Shape comes ONLY from `physics_bsp` (DAT bit-0) or `CSetup` cylsphere/sphere; an object with no DAT shape registers **no shape** and is passable. *Delete the mesh-AABB synthesis (D1).* - -**(b) The query predicate matching `FindObjCollisions@0x0050f050`:** instant-skip iff (0x4 AND 0x10); 0x4-alone → set `ObstructionEthereal` + continue (D2/D5); viewer/creature; IGNORE_CREATURES/creature; PvP pool; then dispatch BSP-only iff `HAS_PHYSICS_BSP_PS && !pvpExempt && !missileIgnore` else CylSphere→**Sphere** (D3). Wire the PvP/missile terms (W1). - -**(c) One overlap-based per-cell registration** (already mostly present via `ShadowObjectRegistry` + the A6.P4/BR-7 flood) — **no attribute filter at registration**; everything funnels through it (statics, scenery, weenies, building shell, cell BSP). - -**(d) One per-apply rebase** — every cached collision transform (cells, buildings, shadow positions) rebased to the current streaming center on each per-landblock apply via a symmetric `Remove*ForLandblock` pre-clear. *Add `RemoveCellsForLandblock` (D8).* (Or fold into the #145 cell-relative-frame port so collision frames can never go stale — the handoff's preferred long-term home.) - -### Deletions -- `GameWindow.cs:7408-7565` mesh-AABB synthesized-cylinder fallback (D1). -- `CollisionExemption.cs:78` ETHEREAL-alone instant-skip → replace with both-bits Gate-1 + `ObstructionEthereal`-set-and-continue (D2); move ACE door-open compat to the **wire/adaptation layer**, not the collision predicate. -- `ShadowShapeBuilder.cs:68-81` sphere→cylinder coercion → true Sphere shape + primitive (D3). -- `TransitionTypes.cs:2629` hardcoded `pvpExempt/missileIgnore=false` → wire real terms (W1). - -### Risks (each needs a user visual gate) -- **D1 removal makes shape-less scenery walk-through** — retail-faithful, but a *visible behavior change* (trees you currently bump may become passable; trees with real canopy-only BSP regain retail behavior). **Highest-risk item; gate carefully.** -- **D2 re-solidifies ACE-opened doors** unless the door-open wire path sends `ETHEREAL|IGNORE_COLLISIONS` in the **same** change — must land together or doors become impassable. -- **D2/D5 obstruction_ethereal** touches BSP solid-containment gates — port set+clear+consume **together**; a partial port hardens/weakens walls unpredictably. -- **W1 PvP/missile wiring** has no live M1.5 test path — risk of regressing the A6.P7 BSP-only door dispatch; gate + conformance-test the door case. -- **D3 sphere primitive** is new physics code — conformance-test vs `CSphere::intersects_sphere` golden values, not eyeballed. -- **D8 eviction** interacts with #135/#138 dungeon collapse/recenter — adding eviction could surface a re-stream ordering bug currently masked by "never evict"; verify against teleport-OUT. - -### Suggested slicing (for the brainstorm) -1. **Shape-source purity (D1)** — delete mesh-AABB synthesis; the highest-value, most-isolated, retail-clarifying change. Visual gate: scenery + Holtburg. -2. **Sphere primitive (D3)** — unblocks faithful shape dispatch; conformance-tested. -3. **ETHEREAL/obstruction_ethereal (D2+D5)** — predicate + state field + door wire compat, landed together. -4. **Cell-transform rebase (D8)** — or fold into the #145 cell-relative-frame phase. -5. **Predicate wiring + entry-restrictions (W1+D4)** — lowest urgency (no M1.5 effect); wire as conservative no-ops now, activate when PK/missiles/locked-cells ship. - ---- - -## Divergence-register bookkeeping required -- **Widen AP-2** to cite `GameWindow.cs:7408` (scenery mesh-AABB), not only the Setup case (D1). -- **AD-7** already covers D2 — keep; retire it when the obstruction_ethereal port lands. -- **AP-6** already covers the analytic-cylinder-vs-retail-CylSphere dispatch — relevant to D3/W1. -- **New rows:** D3 (sphere-as-cylinder), D4 (entry-restrictions omitted), D5 (obstruction_ethereal absent from SpherePath), D8 (cell-struct transform never evicted). Optional: a terrain-pass-through note for the D7 streaming-gap (clarify it's #135/#138, not a collision bug) so nobody re-files it. - -## Apparatus for the implementation phase (reuse, don't rebuild) -`ACDREAM_PROBE_BUILDING` (`[entity-source]` BSP-vs-Cylinder classification per static — directly shows which objects got the D1 synthetic cylinder), `ACDREAM_CAPTURE_RESOLVE`, `Issue147ArwicBuildingsDumpTests` (dat-dump pattern), the `CellarUp*`/`HouseExitWalk*`/`CornerFlood*` replay harnesses, and a new `CSphere::intersects_sphere` conformance fixture for D3. - ---- -*Generated by a 19-agent verified workflow (`wf_b2f8da74-9fa`): 10 readers → synthesis → 8 adversarial verifiers. 6/8 candidate deviations survived adversarial verification; 2 were refuted (D6, D7) with the retail evidence corrected.* diff --git a/docs/research/2026-06-24-collision-object-detection-vs-retail-deepdive-handoff.md b/docs/research/2026-06-24-collision-object-detection-vs-retail-deepdive-handoff.md deleted file mode 100644 index 501532bc..00000000 --- a/docs/research/2026-06-24-collision-object-detection-vs-retail-deepdive-handoff.md +++ /dev/null @@ -1,161 +0,0 @@ -# Handoff — collision-object-detection vs retail: a unifying audit ("1 fix for all collision") — 2026-06-24 - -**Branch/worktree:** `claude/thirsty-goldberg-51bb9b`. Named-retail + references live in the MAIN repo -`C:\Users\erikn\source\repos\acdream\`. - -## The ask (user, 2026-06-24) - -> "Deep-dive in how we detect what objects have collision compared to retail. I expect/know there are -> deviations vs retail. Thorough checking of how retail checks what objects have collision and how it -> deviates from our implementation. This should be **1 fix for all collision**." - -So: **NOT another whack-a-mole symptom fix.** Build the retail model of *"which objects collide, and how -the engine decides that"*, map acdream's model against it, enumerate every deviation, and design **one -unified, retail-faithful collision-inclusion mechanism** that replaces acdream's scattered per-channel -hand-rolled filters. This is a research → brainstorm → multi-commit phase. **Brainstorm-gate it** -(`superpowers:brainstorming`) before writing the port. **No guess-patches** — this is the DO-NOT-RETRY -collision area; grep named-retail FIRST, decompile, pseudocode, port, conformance-test. - -## Why now — the pattern the 2026-06-24 session exposed - -This session fixed three collision bugs, and **two of them were the *same shape*: an ad-hoc per-channel -filter that diverges from retail's unified model.** - -| Commit | Issue | The deviation it revealed | -|---|---|---| -| `afd5f2a` | #138-B | Avatar relocated into a dead landblock during PortalSpace (lifecycle, not inclusion) | -| `49d743f` | #146 | **Building collision shell cached at a stale `_liveCenter` frame** — terrain re-bases per apply, the building cache didn't. Streaming-relative-frame staleness. | -| `9743537` | #147 | **Portal-less buildings (city/perimeter walls) skipped** by `if (building.Portals.Count == 0) continue;` — a *transit-feature* filter that wrongly gated *collision*. We used "has a doorway" to decide "is solid". | - -#147 is the smoking gun for the user's intuition: **acdream decides "does this object collide" with -per-channel attributes that have nothing to do with retail's actual predicate.** There are almost -certainly more of these. The goal is to replace the lot with retail's real rule. - -## The retail model — what to research (the oracle) - -Retail (`docs/research/named-retail/acclient_2013_pseudo_c.txt` — grep by `class::method`) decides -collision in two layers. Map BOTH precisely: - -### A. The inclusion predicate — "does this object participate in collision at all?" -- **`PhysicsState` flags** on `CPhysicsObj` (grep `acclient.h` for the `PhysicsState` enum). The - load-bearing ones: - - **`ETHEREAL`** → no collision (pass-through). Confirm acdream honors this on EVERY channel, not just - interaction-pick. (See `[[project_interaction_pipeline]]` ETHEREAL-alone exemption — that's - *selection*, not *collision*; verify the collision side.) - - **`HAS_PHYSICS_BSP` / `HAS_PHYSICS_BSP_PS`** → the object collides via its BSP; otherwise via a - cylinder/sphere. This is the binary dispatch (`[[feedback_retail_binary_dispatch]]`, - `BspOnlyDispatch` flag `0x00010000`). - - Other gates to check: `STATIC`, `REPORT_COLLISIONS`, `IGNORE_COLLISIONS`, `MISSILE`, `NODRAW` (NODRAW - is render-only — confirm it does NOT gate collision), `SCRIPTED_COLLISION`. -- **The object's physics shape**: does it have a `CPhysicsBSP` (collision BSP) vs a `CylSphere`? Retail - uses whatever the dat/weenie says — it does NOT fabricate collision from a render mesh AABB. (acdream's - `IsPhantomGfxObjSource` / mesh-AABB-fallback is a known divergence — #101.) - -### B. The registration / dispatch model — "where does the engine look for colliders?" -- **`CPhysicsObj::FindObjCollisions`** (the per-tick collider) — picks BSP-only vs cyl+sphere on the - flag above, iterates the **per-cell `shadow_object_list`**. -- **`CObjCell::add_shadows_to_cells` / `find_cell_list` / `calc_cross_cells`** — an object registers a - *shadow* into every cell its sphere overlaps; the per-cell list is the broad phase. (acdream ported - this as the A6.P4/BR-7 `ShadowObjectRegistry` flood — see `[[project_physics_collision_digest]]`.) -- **`CBuildingObj::find_building_collisions`** (`0x006b5300`) — the building **shell** BSP, tested on - `part_array->parts[0]`, **independent of the portal list**. (This session's #147 fix aligned acdream - to this.) `CLandBlock::init_buildings` (`0x0052fd80`) builds one building per origin landcell. -- **Terrain**: `CLandCell::find_env_collisions` (terrain polys) + the eye-side cull. -- **EnvCell (indoor)**: `CEnvCell::find_env_collisions` (`0x0052c100`) — the cell's own BSP; **no - building leg** (acdream matches this at `TransitionTypes.cs:2807`). - -**Deliverable from this layer:** a single table — for each object class (terrain, building shell, -EnvCell shell, static stab, scenery, server weenie/door/NPC/item, missile) — *what makes it collide in -retail* (which flags, which shape, which registration), with the named-retail function + address. - -## The acdream model — the code map to audit against the table - -acdream has **separate channels with different inclusion filters**. Walk each and record its de-facto -"does it collide" predicate, then diff against retail's: - -1. **Terrain** — `PhysicsEngine.SampleTerrainWalkable` / `TransitionTypes.cs:2229` (`// no terrain loaded - → allow pass-through`, `:2247`). Re-bases per apply via `AddLandblock(origin)` (so no #146-style - staleness). Filter: terrain residency only. -2. **Buildings** — `TransitionTypes.FindBuildingCollisions` (`:2805`), `PhysicsDataCache.CacheBuilding` - (`:441`), the cache loop (`GameWindow.cs` ~`:6958`). Post-session: re-bases per apply (#146) + - includes portal-less (#147). Remaining filter: `ModelId == 0 → inert` (`:2813`); the `[bldg-channel]` - probe (`ACDREAM_PROBE_BUILDING`, logs `bldOrigin`) is the live lens. -3. **Shadow objects** (statics, scenery, server weenies) — `Core/Physics/ShadowObjectRegistry.cs` - (per-cell flood, BR-7). Registration sites: `GameWindow.cs` `ShadowObjects.Register*` (~`:7196–7526`), - `RefloodLandblock` (`:7595`), `RegisterMultiPart` (`:3835`). Server weenies register via - `OnLiveEntitySpawnedLocked` → Register. **Filters to scrutinize for divergence:** - - `LandblockLoader.IsSupported` (GfxObj/Setup mask only) — does it match retail's "has a physics - shape"? What about `0x0D`/other types? - - **mesh-AABB fallback** / `IsPhantomGfxObjSource` (#101) — acdream fabricates a collision box from - the render mesh when no physics BSP exists. **Retail does not.** Prime deviation. - - cyl-vs-BSP dispatch (A6.P7, `Transition.BspOnlyDispatch`) — verify it matches `HAS_PHYSICS_BSP_PS`. - - editor-marker degrade-hide (#136) — those are render-hidden; confirm collision treatment matches. - - ETHEREAL: does the live-spawn path exclude ETHEREAL weenies from shadow registration? -4. **EnvCell (indoor)** — cell BSP via the transition; `CellTransit` per-cell iteration. -5. **The streaming-relative frame** — #146 proved a cached collision transform can go stale on recenter. - **Audit every cached collision transform** (shadow objects? EnvCell? portal planes?) for the same - staleness; or fold into the #145 cell-relative-frame port so collision frames can NEVER go stale. - -## Suspected deviations to confirm/refute (seed list) - -- **Attribute-as-filter category errors** (the #147 pattern): grep the registration/collision paths for - any `continue`/skip gated on a NON-collision property (portals, name, render flags, degrade, item - type). Each is a candidate divergence. -- **mesh-AABB phantom collision** (#101) — retail-absent; should it exist at all? -- **ETHEREAL not honored on the collision side** of one or more channels. -- **`IsSupported`/type filters** excluding objects retail collides with (or including ones it doesn't). -- **Stale cached collision transforms** beyond buildings (#146 class). -- **Static vs dynamic registration mismatch** — landblock stabs/scenery vs server weenies taking - different inclusion paths with different rules. - -## The unifying target ("1 fix for all collision") - -One retail-faithful **collision descriptor per object**, derived once from its `PhysicsState` + physics -shape (BSP vs cyl, or none = ethereal/no-collide), and **one registration rule** (overlap-based per-cell -shadow lists) used by **every** channel — replacing the per-channel ad-hoc filters. Concretely the -output is likely: (a) a single `ObjectCollides(state, shape)` predicate matching retail's flag logic, -(b) a single registration path all object sources funnel through, (c) deletion of the mesh-AABB phantom -fallback and the attribute-as-filter skips. Expect multi-commit; gate with brainstorming; conformance- -test against named-retail golden values + the existing replay harnesses (`CellarUp*`, `HouseExitWalk*`, -`CornerFlood*`, the new `Issue147ArwicBuildingsDumpTests` fixture). - -## Method (mandatory — CLAUDE.md workflow) - -1. `superpowers:brainstorming` to scope the phase + agree the unified model BEFORE code. -2. **Grep named-retail FIRST** for every function in the retail-model table; decompile via Ghidra MCP - (`patchmem.gpr`, port 8081) for any single function that's noisy in the bulk text. -3. Cross-reference **ACE** `Physics/Common` (PhysicsObj, ObjCell, shadow lists) + **ACViewer** - `Physics/Common` + **holtburger** `spatial/physics.rs` for the client-side read. -4. Write pseudocode (`docs/research/*_pseudocode.md`), port faithfully, conformance-test, then one - comprehensive user visual gate. - -## Apparatus already in place (use it, don't rebuild) -- `ACDREAM_PROBE_BUILDING` → `[bldg-channel]` (now logs `bldOrigin`) + `[entity-source]` (BSP vs Cylinder - classification per static — directly shows what collision shape each object got). -- `ACDREAM_CAPTURE_RESOLVE=` → per-player-resolve JSONL (grounded / contactPlane / collisionNormal - per frame). Heavy (FPS) — short targeted runs only; the `analyze_*` streaming-tally pattern works - (255k records in seconds). -- `ACDREAM_PROBE_ENT` / `EntityVanishProbe` → avatar draw-set lifecycle (#138-B). -- `Issue147ArwicBuildingsDumpTests` → the dat-dump pattern for `LandBlockInfo.Buildings`/`.Objects` - (portal counts, model ids); clone it for any landblock/object-class inventory. -- Live client: user `notan` / `MittSnus81!`, ACE `127.0.0.1:9000`; graceful close before rebuild. - -## Context — what shipped this session (the motivation) -`afd5f2a` #138-B avatar-vanish, `49d743f` #146 building-frame re-base, `9743537` #147 portal-less-wall -collision. ISSUES #146 DONE, #147 walls DONE (terrain-3%-grounded residual re-scoped LOW — verify it's -even real before acting; see #147). The deep #145 cell-relative-frame port is still its own future phase -and is the natural home for "collision frames can never go stale." - -## Paste-ready prompt for the next session - -> Deep-dive + unifying fix: **how retail decides which objects have collision, vs acdream.** Read this -> handoff (`docs/research/2026-06-24-collision-object-detection-vs-retail-deepdive-handoff.md`) and the -> physics digest (`claude-memory/project_physics_collision_digest.md`) FIRST. Goal: build retail's -> collision-inclusion model (the `PhysicsState`/ETHEREAL/HAS_PHYSICS_BSP predicate + the per-cell -> shadow-list registration + the building-shell/terrain/EnvCell channels — grep named-retail, cite -> addresses), map acdream's per-channel filters against it (terrain / FindBuildingCollisions / -> ShadowObjectRegistry / EnvCell / mesh-AABB phantom / IsSupported / the cyl-BSP dispatch), enumerate -> every deviation, and propose ONE unified retail-faithful collision-inclusion mechanism to replace the -> ad-hoc filters. **Report-first / brainstorm-gated, no guess-patches** (DO-NOT-RETRY collision area). -> Recent precedent (the per-channel filter divergences this is meant to end): #146 (`49d743f`), -> #147 (`9743537`). diff --git a/docs/research/2026-06-24-obstruction-ethereal-pseudocode.md b/docs/research/2026-06-24-obstruction-ethereal-pseudocode.md deleted file mode 100644 index 6f43a3c5..00000000 --- a/docs/research/2026-06-24-obstruction-ethereal-pseudocode.md +++ /dev/null @@ -1,222 +0,0 @@ -# obstruction_ethereal — set / clear / consume contract - -**Research date:** 2026-06-24 -**Oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt` -**Phase:** Task 3 of the collision-inclusion verbatim-retail port - ---- - -## 1. Gate 1 — `CPhysicsObj::FindObjCollisions` (pc:276782 / 0x0050f050) - -``` -// pc:276782 -if (state & 4) AND (state & 0x10): // ETHEREAL_PS | IGNORE_COLLISIONS_PS - return 1 // OK_TS — instant-skip, no further work -// else fall through: ETHEREAL-alone goes into the block below - -// pc:276802 -int32_t var_c; -if ((state_2 & 4) != 0 // target ETHEREAL - || (ebx->object_info.ethereal != 0 // mover is ethereal - && (state_2 & 1) == 0)): // AND target not REPORTS_COLLISIONS - var_c = 1 - if (sphere_path.step_down == 0): - goto label_50f0c9 // set obstruction_ethereal = 1 and continue -else: - var_c = 0 -// label_50f0c9: -ebx->sphere_path.obstruction_ethereal = var_c // pc:276806 -// ... continue with shape dispatch (FindObjCollisions body: BSP / Sphere / Cyl) ... - -// At the END of the FindObjCollisions body (after all shape tests): -// pc:276989 / 0x0050f31e: -ebx->sphere_path.obstruction_ethereal = 0 // clear after each object's test -``` - -**Translation (C#):** -- `ShouldSkip` returns `true` ONLY when `(targetState & 0x4) != 0 && (targetState & 0x10) != 0`. -- ETHEREAL-alone falls through. Before the shape dispatch (BSP/Sphere/Cyl call), set - `sp.ObstructionEthereal = true` when `(targetState & 0x4) != 0`. -- After the shape dispatch, retail clears the flag (`= 0`). In acdream we clear it at the - END of the per-target loop iteration (mirrors the per-object clear at pc:276989). - -**Note on `step_down` gate:** retail's `var_c` assignment also checks `step_down == 0` -before jumping to `label_50f0c9`. When `step_down != 0`, var_c stays 1 but hits the -`else var_c = 0` branch at pc:276804 (which sets var_c = 0). Reading the full -branching tree: the ONLY path that sets `obstruction_ethereal = 1` is when -`(state_2 & 4) != 0` (target ETHEREAL) AND `step_down == 0`. For acdream's -transitional player insert, `step_down` is false at the outer loop entry, so the flag -fires when target is ETHEREAL. We match retail exactly by gating on target ETHEREAL -(the `(obj.State & 0x4) != 0` check in the loop). - ---- - -## 2. D5 clear — `CEnvCell::find_env_collisions` (pc:309580 / 0x0052c144) - -``` -// 0x0052c144: -arg2->sphere_path.obstruction_ethereal = 0; -// ... then: BSP dispatch for the ENV cell walls (not objects) ... -``` - -**Translation (C#):** At the top of `FindEnvCollisions`, clear `sp.ObstructionEthereal = false` -before any BSP dispatch. The ENV path clears it because ENV walls are always solid — the -flag only applies to object (CPhysicsObj) tests. This prevents a stale flag from a -prior object loop from weakening ENV wall tests. - ---- - -## 3. Consume site 1 — `CSphere::intersects_sphere` (pc:321692 / 0x00537ae4) - -``` -// pc:321692: -if (obstruction_ethereal != 0 || insert_type == PLACEMENT_INSERT): - // sphere_intersects_solid test — allows passage if sphere is NOT inside solid - if (collides_with_sphere(center, pos, radius_sum)): - return 2 // COLLIDED — solid containment - // else: sphere is NOT inside solid, passage allowed — no push-back -else if (step_down == 0): - // normal walkable / check_walkable / slide path (the BLOCKING path) - ... -``` - -**What this means:** when `obstruction_ethereal` (target is passable-ethereal) or -`PLACEMENT_INSERT`, the test only fails if the sphere FULLY OVERLAPS the solid region -of the sphere target. In practice for a Sphere-type entity with no BSP this means -you can walk through it as long as your sphere center isn't inside its solid — which -is almost never true during normal movement. The blocker path (slide/push-back) is -bypassed entirely. - -**acdream scope note:** `SphereCollision` in `TransitionTypes.cs` does NOT currently -implement this gate; it always does the slide path. That is a separate task (Task 5 or -equivalent). For Task 3 scope: BSP objects are the priority for opened doors; the -Sphere path is a separate port. - ---- - -## 4. Consume site 2 — `BSPTREE::find_collisions` (pc:323742 / 0x0053a496) - -``` -// pc:323742: -if (insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0): - // sphere_intersects_solid with bldg_check center_solid flag - ebp_4 = 1 - if (bldg_check != 0): - ebp_4 = (hits_interior_cell == 0) // center_solid weakens inside buildings - if (root_node->sphere_intersects_solid(localspace_sphere, ebp_4)): - return 2 // COLLIDED - if (num_sphere > 1): - if (root_node->sphere_intersects_solid(localspace_sphere[1], ebp_4)): - return 2 -else: - // check_walkable / step_down / normal blocking path - ... -``` - -**This is the BSPQuery Path 1 at `BSPQuery.cs:1717`.** The existing code: -```csharp -if (path.InsertType == InsertType.Placement || obj.Ethereal) -``` -`obj.Ethereal` is `ObjectInfo.Ethereal` (the MOVER's ethereal flag) which is NEVER -set — it's always false. The correct translation of `obstruction_ethereal` is -`path.ObstructionEthereal` (on `SpherePath`). - -**Fix:** change to: -```csharp -if (path.InsertType == InsertType.Placement || path.ObstructionEthereal) -``` - -This is the ONLY consume site for Task 3 scope (BSP objects). For the BSP path: -when the target is ETHEREAL, `path.ObstructionEthereal` is set before calling -`BSPQuery.FindCollisions`, so Path 1 fires instead of the normal blocking path. -Path 1 uses `sphere_intersects_solid` which only returns COLLIDED if the player -is inside a BSP solid leaf — during forward movement toward a door (which has no -solid wall when open), the BSP finds no solid intersection, returns OK, player passes -through. - ---- - -## 5. Consume site 3 — `CCylSphere::intersects_sphere` (pc:324573 / 0x0053b4a0) - -``` -// pc:324573: -if (insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0): - // collides_with_sphere test (3D overlap only) - if (CCylSphere::collides_with_sphere(this, global_sphere, ...)): - return // early-out, no Collided return — void return here - // else: no collision, return (void — passable) -else: - // step_down / check_walkable / normal blocking path - ... -``` - -**acdream implementation (Task 3 follow-up, 2026-06-24):** `CylinderCollision` in -`TransitionTypes.cs` now implements this gate as `if (sp.ObstructionEthereal) return OK` -at the top of the method — mirroring exactly how `SphereCollision` (consume site 1) is -handled. The retail function is `void __thiscall`, so all returns in the ethereal branch -are void (= OK). An ethereal Cylinder (e.g. an NPC ghost) is now fully passable. - ---- - -## 3b. Consume site 1 — `CSphere::intersects_sphere` (pc:321692) — full contract - -The full decomp at pc:321692 / 0x537ae4 confirms: - -``` -// pc:321692: -if (obstruction_ethereal != 0 || insert_type == PLACEMENT_INSERT): - // distSq check — if sphere NOT overlapping: return (void = passable) - if (distSq < radiusSumSq): // overlapping: - // calls collides_with_sphere on sphere[1] if num_sphere > 1 - // returns void — no COLLIDED - return // void = passable in all cases -else: - // step_down / check_walkable / slide blocking paths -``` - -**Key:** the `CSphere::intersects_sphere` function is `void __thiscall` — there is NO -`return 2` (COLLIDED) from the ethereal branch. The inner `collides_with_sphere` call -(for the penetrating case) also produces no blocking result (it may contribute to a -side-channel contact; it does NOT stop the player). The player is passable in ALL -sub-cases of the ethereal branch. - -**acdream implementation (Task 3 follow-up, 2026-06-24):** `SphereCollision` implements -`if (sp.ObstructionEthereal) return OK` at the top. The inner overlap/slide path is -bypassed — matches the void-return semantics of the retail ethereal branch. - ---- - -## Summary: set / clear / consume flow (complete after Task 3 follow-up) - -``` -FindObjCollisionsLoop(): - for each obj in cell.shadow_entries: - // Gate 1: instant-skip needs BOTH bits - if obj.State has ETHEREAL AND IGNORE_COLLISIONS: - continue // pass through, no obstruction_ethereal - - // Set flag for ETHEREAL-alone - sp.ObstructionEthereal = (obj.State & 0x4) != 0 - - // Shape dispatch — ALL three shapes now consume the flag: - // BSP: BSPQuery.FindCollisions Path 1 → sphere_intersects_solid (passable) - // Sphere: SphereCollision → if (ObstructionEthereal) return OK - // Cylinder: CylinderCollision → if (ObstructionEthereal) return OK - - // Clear after per-object test (retail pc:276989) - sp.ObstructionEthereal = false - -FindEnvCollisions(): - sp.ObstructionEthereal = false // D5 clear — ENV walls are always solid - // BSP dispatch against cell walls ... -``` - -**Contracts (complete):** -- ETHEREAL-alone BSP target (e.g. open door): `ShouldSkip` = false → flag set → BSP - Path 1 fires → `sphere_intersects_solid` → player walks through open door. Passable. -- ETHEREAL-alone Sphere target (e.g. ghost NPC with sphere shape): flag set → - `SphereCollision` returns OK immediately. Passable. -- ETHEREAL-alone Cylinder target (e.g. ghost NPC with cyl shape): flag set → - `CylinderCollision` returns OK immediately. Passable. -- Non-ethereal wall/object: flag = false → normal blocking paths. Blocked. -- ENV cell walls: flag cleared before BSP dispatch → ENV BSP always blocking. Blocked. diff --git a/docs/research/2026-06-24-sphere-intersects-pseudocode.md b/docs/research/2026-06-24-sphere-intersects-pseudocode.md deleted file mode 100644 index a9556524..00000000 --- a/docs/research/2026-06-24-sphere-intersects-pseudocode.md +++ /dev/null @@ -1,130 +0,0 @@ -# CSphere::intersects_sphere — swept-sphere-vs-sphere pseudocode - -**Date:** 2026-06-24 -**Task:** Task 2 — true sphere collision primitive (collision-inclusion phase) - ---- - -## Oracles consulted - -1. **Named-retail decomp** `acclient_2013_pseudo_c.txt`: - - `CSphere::collides_with_sphere` @ `0x005369E0` — static overlap test - - `CSphere::intersects_sphere` (primary) @ `0x00537A80` — the full 6-path dispatcher - - `CSphere::intersects_sphere` (Position variant) @ `0x00537FD0` - -2. **ACE C# port** `references/ACE/Source/ACE.Server/Physics/Sphere.cs`: - - `CollidesWithSphere(Vector3 otherSphere, float radsum)` — static overlap - - `FindTimeOfCollision(Vector3 movement, Vector3 spherePos, float radSum)` — swept solve - - `IntersectsSphere(Vector3 center, float radius, Transition transition, bool isCreature)` — 6-path dispatcher - ---- - -## CSphere::collides_with_sphere (static overlap test) - -Retail @ `0x005369E0`: - -``` -collides_with_sphere(this, disp_vec3, radsum_float): - lenSq = disp_vec3.x² + disp_vec3.y² + disp_vec3.z² - if radsum² > lenSq: // i.e. lenSq < radsum² - return 1 (true — overlapping) - return 0 (false) -``` - -ACE equivalent: `disp.LengthSquared() <= radsum * radsum` -Note: retail uses `>` (strictly greater-than radsum²), ACE uses `<=`. These are the same predicate — the -retail FPU instruction emits "collides" when radsum² is NOT less than lenSq, which is `lenSq <= radsum²`. - ---- - -## FindTimeOfCollision (swept quadratic, from ACE) - -ACE `Sphere.FindTimeOfCollision(Vector3 movement, Vector3 spherePos, float radSum)`: - -Interprets "mover starts at origin, travels by `movement`; target is at `spherePos` relative to mover". - -``` -distSq = |movement|² // if < EPSILON: no sweep (degenerate), return -1 -nonCollide = |spherePos|² - radSum² // if < EPSILON: already overlapping → no forward collision needed, return -1 -similar = -dot(spherePos, movement) // projection of separation onto movement direction -disc = similar² - nonCollide * distSq // discriminant of quadratic -if disc < 0: return -1 // no real intersection -cDist = sqrt(disc) -if similar - cDist < 0: - return -(cDist + similar) / distSq -else: - return -(similar - cDist) / distSq -``` - -This returns a time in the range [0, 1] for the first contact. -A return of -1 means no hit (miss or already overlapping). -Values > 1 mean the sweep doesn't reach the target within the movement step. - ---- - -## SweptSphereHitsSphere — our primitive (pure function) - -Wraps `FindTimeOfCollision` with a clean bool/out API for the narrow-phase dispatch: - -``` -SweptSphereHitsSphere(moverCenter, moverRadius, sweepDelta, targetCenter, targetRadius, out float t): - movement = sweepDelta // vector the mover travels - spherePos = targetCenter - moverCenter // target relative to mover's start - radSum = moverRadius + targetRadius - t = (float) FindTimeOfCollision(movement, spherePos, radSum) - return t > 0 && t <= 1 -``` - -`t` is the parametric fraction of `sweepDelta` at which surfaces first touch. -`t <= 0`: target is behind or already overlapping (use static test separately). -`t > 1`: sweep misses (target too far in this step). - ---- - -## Retail dispatch order for Sphere objects - -From `CSphere::intersects_sphere @ 0x00537A80` — the same 6-path structure as for CylSpheres: - -1. `obstruction_ethereal || insert_type == PLACEMENT_INSERT`: - Static overlap test only (`collides_with_sphere`). Return Collided or OK. - -2. `step_down != 0`: - Delegates to `step_sphere_down` (for non-creature movers). - -3. `check_walkable != 0`: - Static overlap test. Return Collided or OK. - -4. `collide == 0`: - Sub-dispatch on `object_info.state & 3` (Contact/OnWalkable): - - Contact: step_sphere_up or slide_sphere - - PathClipped: collide_with_point - - Default: land_on_sphere or collide_with_point - -5. `collide != 0` + `isCreature`: - Return OK (creatures don't block each other via sphere-sphere in this path). - -6. `collide != 0` + not creature: - Full swept quadratic. Set contact plane, adjust check_pos. - -For our narrow-phase dispatch in `FindObjCollisionsInCell`, the "narrow-phase Sphere branch" -maps directly to ACE's `IntersectsSphere` — which acdream already implements for Cylinder objects -via `CylinderCollision`. The sphere primitive just provides the swept check without the cylinder's -height clipping. - ---- - -## Acdream adaptation note - -The `SweptSphereHitsSphere` primitive is PURE (no Transition state). The actual 6-path dispatch -(step-up, land-on, slide, etc.) is handled by the existing `CylinderCollision` infrastructure — -for Sphere-typed shadow entries we call through the same dispatcher after the overlap check, -using 3-D distance for the broad-phase (not XY-only cylinder distance). - -The primitive's narrow phase: `static overlap` (`CollidesWithSphere`) is the gate; the swept -quadratic from `FindTimeOfCollision` resolves the time-of-contact for the walkable landing path. - -For the initial ship (Task 2), we implement the static overlap test in the dispatch -(matching the `obstruction_ethereal`/`check_walkable`/`Contact` paths that don't use the swept -form), plus `SweptSphereHitsSphere` for the swept narrow-phase. The full 6-path wiring for -sphere objects mirrors the cylinder path already in `CylinderCollision`, extended to use 3-D -distance instead of XY-only. diff --git a/docs/research/2026-06-24-teleport-out-issues-handoff.md b/docs/research/2026-06-24-teleport-out-issues-handoff.md deleted file mode 100644 index c202f64a..00000000 --- a/docs/research/2026-06-24-teleport-out-issues-handoff.md +++ /dev/null @@ -1,96 +0,0 @@ -# Handoff — two teleport-OUT issues (no-collision-after-death, char-missing-after-portal) — 2026-06-24 - -**Branch/worktree:** observed on `claude/thirsty-goldberg-51bb9b` -(`C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b`). -References / named-retail live in the MAIN repo `C:\Users\erikn\source\repos\acdream\`. - -> **These are NOT regressions from the dense-town FPS work.** That work (commits -> `290e731` cell-object batching, `9f51a4d` cell-particle consolidation, `a9d06a6` -> apparatus strip, `02578dd` docs) is **finalized + committed** and touched -> **render files ONLY** — zero physics / collision / streaming / movement / -> membership code (verified: `git show --name-only 290e731 9f51a4d a9d06a6`). Do -> NOT suspect those commits. The only indirect link: higher FPS makes the same -> few-tick transient render across more frames, so it's more *noticeable*; the -> underlying bug predates the FPS work (and collision is FPS-independent — physics -> ticks at a fixed rate). - ---- - -## The two issues (user-observed, 2026-06-24) - -Both are on the **teleport-OUT-to-outdoor** path and are almost certainly the same -known **placed-but-unstreamed streaming gap** (#135/#138 family). ISSUES §51 says a -streaming-gap fix "would fix **both** symptoms." They're split into two prompts only -so they can be tackled independently — whoever picks one should read both. - -### Issue A — no collision after death → portaled to Holtburg -- **Symptom:** after dying and being teleported to Holtburg, the player has **no - collision** (falls through / clips). -- **Root (known, NOT this session's work):** the *placed-but-unstreamed gap* — on a - teleport the player is placed immediately, but the destination landblock streams - in a few ticks later; during that gap the physics resolve runs against an **empty - world** (no terrain/collision meshes loaded), so collision finds nothing. -- **ISSUES refs:** [`docs/ISSUES.md:51`](../ISSUES.md) (the far-town runaway/march + - Z free-fall, "same root as #135/#138 placed-but-unstreamed gap"), and - [`docs/ISSUES.md:138`](../ISSUES.md) (acceptance: "the outdoor world fully - streamed, **collision working**"). Whether the death lands in a dungeon-exit - (#138 proper) or an outdoor respawn-to-lifestone, the placement→stream→resolve - timing is the same. - -### Issue B — char missing sometimes after portal to outside -- **Symptom:** after portaling outside, the **character (and other entities) are - missing for a moment**, then pop in. -- **Root (known = #138):** teleport-OUT re-hydrates world entities with **fresh - monotonic Ids** a few ticks after arrival (`GameWindow` ~`:3251`, - `Id = _liveEntityIdCounter++`); during that gap they aren't in the draw set, so - they appear late ("objects come back"). The player is one of them. -- **ISSUES refs:** [`docs/ISSUES.md:265`](../ISSUES.md) (#138 — "Teleport OUT of a - dungeon loads the outdoor world incompletely + position desync"), - [`docs/ISSUES.md:299`](../ISSUES.md) (the fresh-Id re-hydration detail + the - cache-invalidation side-finding). - ---- - -## ⚠ This is the DELICATE area — read before touching - -1. **DO NOT guess-patch.** The teleport/streaming runaway burned **~5 failed - attempts** (#145). Build the apparatus FIRST (capture → replay), per - `memory/feedback_apparatus_for_physics_bugs.md`. -2. **A "streaming HOLD" was tried and REVERTED.** Freezing the per-tick resolve - until the landblock loads produced a ~10 s freeze; the user called it "shaky and - bandaid" (`feedback_no_holds_for_slow_foundation` in memory). The real fix is - **fix the foundation** — make the destination stream *with* placement (the async - equivalent of retail's synchronous load), not a gate that masks a slow stream. -3. **Capture first.** Reproduce a real teleport and capture the - placement→stream→resolve sequence before proposing anything. - -## Where to start reading (in order) -- `claude-memory/project_physics_collision_digest.md` — physics / collision / - cell-membership SSOT + the DO-NOT-RETRY table. **START HERE.** -- `docs/ISSUES.md` #135, #138, #107, #145 (the teleport/streaming family). -- `memory/reference_two_tier_streaming.md` — the A.5 streaming architecture - (Region → Controller → LandblockStreamer → GpuWorldState; near/far radii). -- The teleport/placement + streaming code: grep `GameWindow` for the snap/placement - path (`[snap]`, `EXIT-expand`, `_liveEntityIdCounter`), the streaming - `dungeon EXIT-expand → (x,y)` logs, and `PlayerMovementController` / `PhysicsEngine` - arrival resolve. - -## Capture apparatus (already in the tree) -- `ACDREAM_CAPTURE_RESOLVE=` — JSON-Lines of every player-side - `PhysicsEngine.ResolveWithTransition` (inputs + body before/after + result). - Run it across a death→Holtburg teleport to see the resolve running against the - empty world during the gap. -- `ACDREAM_PROBE_CELL=1` — one `[cell-transit]` line per player cell change - (old→new, reason). Low volume; shows the post-teleport cell march. -- `ACDREAM_PROBE_RESOLVE=1` — per-resolve line (input/target/output, grounded, - contact-plane, responsible entity). Heavy. -- Launch (PowerShell): user `notan` / `MittSnus81!`, local ACE `127.0.0.1:9000`. - See CLAUDE.md "Running the client against the live server". - -## Likely fix shape (hypothesis, NOT confirmed — capture first) -Make the teleport destination's near landblocks **stream synchronously (or -placement waits on the first stream tick)** so the arrival resolve never runs -against an empty world, AND the entity re-hydration completes before/with the first -drawn frame — rather than a per-tick HOLD. Both symptoms (no-collision, char-missing) -collapse to "the world isn't there yet when we place + resolve + draw." Confirm the -exact gap timing from a capture before committing to a shape. diff --git a/docs/research/2026-06-25-character-window-faithful-spec.md b/docs/research/2026-06-25-character-window-faithful-spec.md deleted file mode 100644 index 9f19e56a..00000000 --- a/docs/research/2026-06-25-character-window-faithful-spec.md +++ /dev/null @@ -1,82 +0,0 @@ -# Character window — faithful element spec (LayoutDesc 0x2100002E) - -Decomp-grounded blueprint for the Character window, produced to REDO the guessed pilot. -Sources: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (PDB-named decomp), -the user UI dump `docs/research/2026-06-25-retail-ui-layout-dump.json`, and acdream's own -importer-resolved tree (dumped live in the studio). **No guessing** — every row cites a -decomp address or a confirmed `FindElement` result. - -## What 0x2100002E actually is - -A **tabbed Attributes / Skills / Titles window** (300×600, root panel `0x10000227`, Type 8). -It is NOT a text report. Three tab-content slots each mount a sub-layout via -`BaseElement`+`BaseLayoutId` inheritance (acdream's `ShouldMountBaseChildren` path): - -| Slot | BaseElement | BaseLayoutId | gm*UI | Register | -|---|---|---|---|---| -| `0x1000022B` (Attributes) | `0x10000225` | `0x2100002C` | gmAttributeUI (root type 0x1000002A) | 0x0049db50 | -| `0x1000022C` (Skills) | `0x1000022E` | `0x2100002D` | gmSkillUI | 0x0049adb0 | -| `0x10000539` (Titles) | `0x1000052D` | `0x2100005E` | gmCharacterTitleUI | 0x0049aba0 | - -Both Attributes + Skills tabs share a header strip filled by the base class -**`gmStatManagementUI`** (`UpdateCharacterInfo` 0x004f0770, `UpdateExperience` 0x004f0a70, -`UpdatePKStatus` 0x004f00a0). The tab sub-layout `0x2100002C` nests `0x10000226` which -chains again into `0x21000045` — the real header/list content is **two inheritance levels deep**. - -The **text report** ("birth/age/deaths, innate attributes, augmentations, load") the earlier -pilot guessed is a SEPARATE sub-panel: LayoutDesc `0x2100001A`, `gmCharacterInfoUI` -(register 0x004b8c90), whose `m_pMainText` (`0x1000011d`) is created at RUNTIME. That window -is handled by `CharacterController` (create-the-element path) — different layout, not 0x2100002E. - -## Importer reality (dumped live, 2026-06-25) - -- `dump-vitals-layout` prints the RAW dat (no inheritance) → the content elements look "missing". - That is an artifact. The **importer** mounts them. Confirmed by `FindElement` on the studio's - imported 0x2100002E: - - `0x10000231` name → `UiText` ✓ - - `0x10000232` heritage → `UiText` ✓ - - `0x10000233` PK status → `UiText` ✓ - - `0x1000023B` level → `UiText` ✓ - - `0x10000235` total XP → `UiText` ✓ - - `0x10000236` XP-to-level meter → `UiMeter` ✓ - - `0x1000023D` list box → `UiDatElement` (generic container) ✓ - - `0x10000238` XP-to-level text → **NULL** (nested variant; not mounted — minor) -- `UiElement.EventId` is NOT the dat element id (root shows 0x10000001, not 0x10000227). The dat - id lives in `ImportedLayout._byId` (set from `info.Id`, LayoutImporter:107). Bind via - `FindElement(id)`, never by reading `widget.EventId`. - -## Header element map (gmStatManagementUI) - -| Id | Field | Content source | Rect (in sub-layout) | -|---|---|---|---| -| `0x10000231` | m_pNameText | player full name | 0,25,230,20 | -| `0x10000232` | m_pHeritageText | gender+heritage display (InqGenderHeritageDisplay) | 0,45,230,15 | -| `0x10000233` | m_pPKStatusText | PK status string (StringTable) | 0,60,230,15 | -| `0x1000023B` | m_pLevelText | PropertyInt 0x19 (level) | 235,60,65,50 | -| `0x10000234` | (label) | "Total Experience:" | 0,95,130,18 | -| `0x10000235` | m_pTotalXPText | PropertyInt64 1 | 130,95,100,18 | -| `0x10000236` | m_pXPToLevelMeter | (cur−base)/(cap−base), img 0x060011A6 | 0,113,230,17 | -| `0x10000237` | (label) | "To next level:" | 0,113,130,17 | -| `0x10000238` | m_pXPToLevelText | XP remaining | 130,113,100,17 | -| `0x1000023D` | m_pListBox | attribute/skill rows (runtime) | 0,137,300,398 | - -Attribute list rows: gmAttributeUI builds `AttributeInfoRegion` rows (name + base/buffed value + -raise button). Canonical AC display order: Strength, Endurance, Coordination, Quickness, Focus, Self. - -## StringTable - -`DatReaderWriter` CAN read `StringTable` (CLI uses `GetAllIdsOfType`). The decomp's -`StringInfo::SetStringIDandTableEnum(…, stringId, 0x10000001)` references table enum 0x10000001 → -the game string table dat (0x31000001 family); StringId=0 is a placeholder overwritten by a -preceding `compute_str_hash("ID_…")`. To reproduce exact wording: read the StringTable + a -StringInfo helper (hash → id → lookup, `AddVariable_*` substitution). NOT yet ported — current -controller uses canonical AC labels. - -## Status (this session) - -- **DONE (commit 0e644b5):** `CharacterStatController` binds name/heritage/PK/level/total-XP + - the XP meter fill + the six attributes into the real elements. Studio renders the real panel. -- **Follow-ups (known sources):** (1) tab-button active/inactive STATE so the 3 tabs draw their - sprites (0x06005D92-97); (2) StringTable wording; (3) full AttributeInfoRegion row template - (column-aligned values + per-attribute raise buttons); (4) wire `0x10000234`/`0x10000237` - labels + the right-side level area placement. diff --git a/docs/research/2026-06-25-retail-ui-layout-dump.json b/docs/research/2026-06-25-retail-ui-layout-dump.json deleted file mode 100644 index 49adab54..00000000 --- a/docs/research/2026-06-25-retail-ui-layout-dump.json +++ /dev/null @@ -1,116869 +0,0 @@ -{ - "version": 1, - "panels": [ - { - "id": 268436741, - "slug": "floating_chat_1", - "title": "Floating Chat 1", - "bucket": "Chat", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436727, - "layout_id": 553648219, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 80.0, - "width": 250.0, - "height": 108.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268436732, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 80.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268435471, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 80.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268436734, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 80.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268436690, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 85.0, - "width": 5.0, - "height": 98.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268436737, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 183.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268436692, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 183.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268436739, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 183.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268436691, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 85.0, - "width": 5.0, - "height": 98.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268436697, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 85.0, - "width": 240.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268436776, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 100.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268436777, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 85.0, - "width": 240.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 12, - "element_id": 268436778, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 230.0, - "y": 86.0, - "width": 14.0, - "height": 14.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100688405, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100688405, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100688406, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268435472, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 105.0, - "width": 224.0, - "height": 60.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268435473, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 105.0, - "width": 224.0, - "height": 60.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 15, - "element_id": 268436620, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 169.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687630, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100687630, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100687630, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268435474, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 60.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 1, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 18, - "element_id": 268436324, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268436325, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 108.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268436326, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 118.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268435569, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268435570, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 149.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268436745, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 240.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268435478, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 25, - "element_id": 268436779, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 26, - "element_id": 268436149, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268435977, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 1.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268435978, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 165.0, - "width": 1.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268435979, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 182.0, - "width": 202.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268435481, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 207.0, - "y": 165.0, - "width": 38.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268436750, - "slug": "floating_chat_2", - "title": "Floating Chat 2", - "bucket": "Chat", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436727, - "layout_id": 553648219, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 80.0, - "width": 250.0, - "height": 108.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268436732, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 80.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268435471, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 80.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268436734, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 80.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268436690, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 85.0, - "width": 5.0, - "height": 98.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268436737, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 183.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268436692, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 183.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268436739, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 183.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268436691, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 85.0, - "width": 5.0, - "height": 98.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268436697, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 85.0, - "width": 240.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268436776, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 100.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268436777, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 85.0, - "width": 240.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 12, - "element_id": 268436778, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 230.0, - "y": 86.0, - "width": 14.0, - "height": 14.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100688405, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100688405, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100688406, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268435472, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 105.0, - "width": 224.0, - "height": 60.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268435473, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 105.0, - "width": 224.0, - "height": 60.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 15, - "element_id": 268436620, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 169.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687630, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100687630, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100687630, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268435474, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 60.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 1, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 18, - "element_id": 268436324, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268436325, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 108.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268436326, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 118.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268435569, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268435570, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 149.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268436745, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 240.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268435478, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 25, - "element_id": 268436779, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 26, - "element_id": 268436149, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268435977, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 1.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268435978, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 165.0, - "width": 1.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268435979, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 182.0, - "width": 202.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268435481, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 207.0, - "y": 165.0, - "width": 38.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268436751, - "slug": "floating_chat_3", - "title": "Floating Chat 3", - "bucket": "Chat", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436727, - "layout_id": 553648219, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 80.0, - "width": 250.0, - "height": 108.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268436732, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 80.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268435471, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 80.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268436734, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 80.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268436690, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 85.0, - "width": 5.0, - "height": 98.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268436737, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 183.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268436692, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 183.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268436739, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 183.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268436691, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 85.0, - "width": 5.0, - "height": 98.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268436697, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 85.0, - "width": 240.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268436776, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 100.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268436777, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 85.0, - "width": 240.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 12, - "element_id": 268436778, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 230.0, - "y": 86.0, - "width": 14.0, - "height": 14.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100688405, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100688405, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100688406, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268435472, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 105.0, - "width": 224.0, - "height": 60.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268435473, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 105.0, - "width": 224.0, - "height": 60.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 15, - "element_id": 268436620, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 169.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687630, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100687630, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100687630, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268435474, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 60.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 1, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 18, - "element_id": 268436324, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268436325, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 108.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268436326, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 118.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268435569, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268435570, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 149.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268436745, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 240.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268435478, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 25, - "element_id": 268436779, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 26, - "element_id": 268436149, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268435977, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 1.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268435978, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 165.0, - "width": 1.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268435979, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 182.0, - "width": 202.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268435481, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 207.0, - "y": 165.0, - "width": 38.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268436752, - "slug": "floating_chat_4", - "title": "Floating Chat 4", - "bucket": "Chat", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436727, - "layout_id": 553648219, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 80.0, - "width": 250.0, - "height": 108.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268436732, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 80.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268435471, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 80.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268436734, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 80.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268436690, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 85.0, - "width": 5.0, - "height": 98.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268436737, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 183.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268436692, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 183.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268436739, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 183.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268436691, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 85.0, - "width": 5.0, - "height": 98.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268436697, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 85.0, - "width": 240.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268436776, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 100.0, - "width": 240.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268436777, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 85.0, - "width": 240.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 12, - "element_id": 268436778, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 230.0, - "y": 86.0, - "width": 14.0, - "height": 14.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100688405, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100688405, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100688406, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268435472, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 105.0, - "width": 224.0, - "height": 60.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268435473, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 105.0, - "width": 224.0, - "height": 60.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 15, - "element_id": 268436620, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 169.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687630, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100687630, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100687630, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268435474, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 60.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 1, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 18, - "element_id": 268436324, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268436325, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 108.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268436326, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 118.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268435569, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 105.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268435570, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 229.0, - "y": 149.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268436745, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 240.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268435478, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 25, - "element_id": 268436779, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 26, - "element_id": 268436149, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 202.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268435977, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 165.0, - "width": 1.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268435978, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 165.0, - "width": 1.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268435979, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 182.0, - "width": 202.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268435481, - "layout_id": 553648219, - "parent_layout_id": 553648219, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 207.0, - "y": 165.0, - "width": 38.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268436993, - "slug": "main_chat", - "title": "Main Chat", - "bucket": "Chat", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436992, - "layout_id": 553648239, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 410.0, - "height": 100.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268437139, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693187, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693187, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268437140, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 400.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693183, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693183, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268437141, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 405.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693188, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693188, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268437142, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693184, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693184, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268437143, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 95.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693189, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693189, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268437144, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 95.0, - "width": 400.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693185, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693185, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268437145, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 405.0, - "y": 95.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693190, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693190, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268437146, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 405.0, - "y": 5.0, - "width": 5.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268437147, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268437148, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 400.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268437149, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 405.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268437150, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268437151, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 95.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268437152, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 95.0, - "width": 400.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268437153, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 405.0, - "y": 95.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268437154, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 405.0, - "y": 5.0, - "width": 5.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435472, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 400.0, - "height": 73.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268435473, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 5.0, - "width": 368.0, - "height": 73.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 19, - "element_id": 268436620, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 62.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687630, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100687630, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100687630, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435474, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 389.0, - "y": 5.0, - "width": 16.0, - "height": 73.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 1, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 389.0, - "y": 5.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 22, - "element_id": 268436324, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 389.0, - "y": 5.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268436325, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 389.0, - "y": 8.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268436326, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 389.0, - "y": 18.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 268435569, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 389.0, - "y": 5.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 268435570, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 389.0, - "y": 62.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268436591, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 368.0, - "y": 5.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687461, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435527, - "image": { - "image_id": 100687460, - "alpha_image_id": null - } - }, - { - "state_id": 268435528, - "image": { - "image_id": 100687461, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268436770, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100688408, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100688408, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100688409, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268436771, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 22.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100688408, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100688408, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100688409, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268436772, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 39.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100688408, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100688408, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100688409, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 31, - "element_id": 268436773, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 56.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100688408, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100688408, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100688409, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268435475, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 78.0, - "width": 400.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667706, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667706, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 33, - "element_id": 268435476, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 78.0, - "width": 46.0, - "height": 17.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100683109, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683109, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683110, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 34, - "element_id": 268435477, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 33, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 78.0, - "width": 46.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 35, - "element_id": 268435478, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 51.0, - "y": 78.0, - "width": 306.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667819, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 4, - "image": { - "image_id": 100667819, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 36, - "element_id": 268435479, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 51.0, - "y": 78.0, - "width": 1.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683111, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 4, - "image": { - "image_id": 100683111, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 37, - "element_id": 268435480, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 448.0, - "y": 78.0, - "width": 1.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683111, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 4, - "image": { - "image_id": 100683111, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268435481, - "layout_id": 553648239, - "parent_layout_id": 553648239, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 359.0, - "y": 78.0, - "width": 46.0, - "height": 17.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100669717, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100669717, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100669718, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100669748, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268437173, - "slug": "combat", - "title": "Combat", - "bucket": "GameplayHud", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268437156, - "layout_id": 553648243, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 610.0, - "height": 90.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268437157, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693187, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693187, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268437158, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693183, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693183, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268437159, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693188, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693188, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268437160, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 80.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693184, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693184, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268437161, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 85.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693189, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693189, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268437162, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 85.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693185, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693185, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268437163, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 85.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693190, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693190, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268437164, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 5.0, - "width": 5.0, - "height": 80.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268437165, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268437166, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268437167, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268437168, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 80.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268437169, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 85.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268437170, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 85.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268437171, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 85.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268437172, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 5.0, - "width": 5.0, - "height": 80.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435548, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 80.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 18, - "element_id": 268435532, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 10.0, - "height": 80.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268435533, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 5.0, - "width": 490.0, - "height": 80.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435534, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 505.0, - "y": 5.0, - "width": 100.0, - "height": 80.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268435535, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 14.0, - "width": 507.0, - "height": 14.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693194, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693194, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 4, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 14.0, - "width": 0.0, - "height": 0.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100669721, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100669721, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268435536, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 14.0, - "width": 507.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 24, - "element_id": 268436975, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 63.0, - "y": 14.0, - "width": 407.0, - "height": 14.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100692318, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100692318, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 2, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 14.0, - "width": 507.0, - "height": 14.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667904, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667904, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 1, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 14.0, - "width": 12.0, - "height": 14.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100669731, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100669731, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268435537, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 14.0, - "width": 100.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 28, - "element_id": 268435538, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 416.0, - "y": 14.0, - "width": 100.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 29, - "element_id": 268435539, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 34.0, - "width": 100.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 30, - "element_id": 268436264, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 29, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 35.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 31, - "element_id": 268435540, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 115.0, - "y": 34.0, - "width": 80.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 32, - "element_id": 268436264, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 115.0, - "y": 35.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 33, - "element_id": 268435541, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 200.0, - "y": 34.0, - "width": 100.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 34, - "element_id": 268436264, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 33, - "base_layout_id": 0, - "rect": { - "x": 200.0, - "y": 35.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 35, - "element_id": 268435542, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 527.0, - "y": 9.0, - "width": 72.0, - "height": 57.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 36, - "element_id": 268435543, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 527.0, - "y": 9.0, - "width": 72.0, - "height": 19.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100683036, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683036, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683037, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683038, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683039, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 37, - "element_id": 268435544, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 527.0, - "y": 28.0, - "width": 72.0, - "height": 19.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100683036, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683036, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683037, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683038, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683039, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268435545, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 527.0, - "y": 47.0, - "width": 72.0, - "height": 19.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100683036, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683036, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683037, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683038, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683039, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268435553, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 80.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 40, - "element_id": 268435616, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 39, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 25.0, - "width": 600.0, - "height": 60.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 41, - "element_id": 268436619, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 65.0, - "width": 450.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 42, - "element_id": 268435617, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 39, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 43, - "element_id": 268435618, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 39, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 60.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 44, - "element_id": 268435619, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 47.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687545, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687545, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687544, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 268435620, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 52.0, - "y": 5.0, - "width": 47.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687545, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687545, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687544, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 46, - "element_id": 268435621, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 99.0, - "y": 5.0, - "width": 47.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687545, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687545, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687544, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 47, - "element_id": 268435622, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 5.0, - "width": 47.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687545, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687545, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687544, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 48, - "element_id": 268435623, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 193.0, - "y": 5.0, - "width": 47.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687545, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687545, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687544, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 268435624, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 240.0, - "y": 5.0, - "width": 47.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687545, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687545, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687544, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 50, - "element_id": 268435625, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 287.0, - "y": 5.0, - "width": 47.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687545, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687545, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687544, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268436930, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 334.0, - "y": 5.0, - "width": 47.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687545, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687545, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687544, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 52, - "element_id": 268435626, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 53, - "element_id": 268435637, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 52, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 54, - "element_id": 268435569, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 507.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682972, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682973, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 55, - "element_id": 268435570, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 444.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682974, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682975, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 56, - "element_id": 268435638, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 52, - "base_layout_id": 0, - "rect": { - "x": 68.0, - "y": 29.0, - "width": 439.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 57, - "element_id": 268435627, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 58, - "element_id": 268435637, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 57, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 59, - "element_id": 268435569, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 507.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682972, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682973, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 60, - "element_id": 268435570, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 444.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682974, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682975, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 61, - "element_id": 268435638, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 57, - "base_layout_id": 0, - "rect": { - "x": 68.0, - "y": 29.0, - "width": 439.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 62, - "element_id": 268435628, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 63, - "element_id": 268435637, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 62, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 64, - "element_id": 268435569, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 63, - "base_layout_id": 0, - "rect": { - "x": 507.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682972, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682973, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 65, - "element_id": 268435570, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 63, - "base_layout_id": 0, - "rect": { - "x": 444.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682974, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682975, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 66, - "element_id": 268435638, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 62, - "base_layout_id": 0, - "rect": { - "x": 68.0, - "y": 29.0, - "width": 439.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 67, - "element_id": 268435629, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 68, - "element_id": 268435637, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 69, - "element_id": 268435569, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 68, - "base_layout_id": 0, - "rect": { - "x": 507.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682972, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682973, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 70, - "element_id": 268435570, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 68, - "base_layout_id": 0, - "rect": { - "x": 444.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682974, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682975, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 71, - "element_id": 268435638, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 68.0, - "y": 29.0, - "width": 439.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 72, - "element_id": 268435630, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 73, - "element_id": 268435637, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 72, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 74, - "element_id": 268435569, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 73, - "base_layout_id": 0, - "rect": { - "x": 507.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682972, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682973, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 75, - "element_id": 268435570, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 73, - "base_layout_id": 0, - "rect": { - "x": 444.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682974, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682975, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 76, - "element_id": 268435638, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 72, - "base_layout_id": 0, - "rect": { - "x": 68.0, - "y": 29.0, - "width": 439.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 77, - "element_id": 268435631, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 78, - "element_id": 268435637, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 77, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 79, - "element_id": 268435569, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 78, - "base_layout_id": 0, - "rect": { - "x": 507.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682972, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682973, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 80, - "element_id": 268435570, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 78, - "base_layout_id": 0, - "rect": { - "x": 444.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682974, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682975, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 81, - "element_id": 268435638, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 77, - "base_layout_id": 0, - "rect": { - "x": 68.0, - "y": 29.0, - "width": 439.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 82, - "element_id": 268435632, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 83, - "element_id": 268435637, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 84, - "element_id": 268435569, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 83, - "base_layout_id": 0, - "rect": { - "x": 507.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682972, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682973, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 85, - "element_id": 268435570, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 83, - "base_layout_id": 0, - "rect": { - "x": 444.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682974, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682975, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 86, - "element_id": 268435638, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 68.0, - "y": 29.0, - "width": 439.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 87, - "element_id": 268436931, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 88, - "element_id": 268435637, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 87, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 27.0, - "width": 485.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 89, - "element_id": 268435569, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 88, - "base_layout_id": 0, - "rect": { - "x": 507.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682972, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682973, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 90, - "element_id": 268435570, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 88, - "base_layout_id": 0, - "rect": { - "x": 444.0, - "y": 27.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682974, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682975, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 91, - "element_id": 268435638, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 87, - "base_layout_id": 0, - "rect": { - "x": 68.0, - "y": 29.0, - "width": 439.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 92, - "element_id": 268435633, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 39, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 29.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 93, - "element_id": 268436562, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 92, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 29.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 94, - "element_id": 268436563, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 92, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 29.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 95, - "element_id": 268436564, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 92, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 29.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683017, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683017, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 96, - "element_id": 268435634, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 39, - "base_layout_id": 0, - "rect": { - "x": 530.0, - "y": 29.0, - "width": 75.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 97, - "element_id": 268436174, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 96, - "base_layout_id": 0, - "rect": { - "x": 530.0, - "y": 29.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 98, - "element_id": 268436175, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 96, - "base_layout_id": 0, - "rect": { - "x": 562.0, - "y": 29.0, - "width": 11.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 99, - "element_id": 268436176, - "layout_id": 553648243, - "parent_layout_id": 553648243, - "parent_traversal_index": 96, - "base_layout_id": 0, - "rect": { - "x": 573.0, - "y": 29.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268436989, - "slug": "environment_panel", - "title": "Environment Panel", - "bucket": "GameplayHud", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436987, - "layout_id": 553648237, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 610.0, - "height": 120.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268437091, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693187, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693187, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268437092, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693183, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693183, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268437093, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693188, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693188, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268437094, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 110.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693184, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693184, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268437095, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 115.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693189, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693189, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268437096, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 115.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693185, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693185, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268437097, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 115.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693190, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693190, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268437098, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 5.0, - "width": 5.0, - "height": 110.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268437099, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268437100, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268437101, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268437102, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 110.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268437103, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 115.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268437104, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 115.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268437105, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 115.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268437106, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 5.0, - "width": 5.0, - "height": 110.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435549, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 110.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268435556, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 10.0, - "width": 36.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 19, - "element_id": 268435557, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 49.0, - "y": 10.0, - "width": 526.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 20, - "element_id": 268435558, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 49.0, - "y": 10.0, - "width": 526.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 21, - "element_id": 268435569, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 552.0, - "y": 10.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682972, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682973, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682972, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268435570, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 489.0, - "y": 10.0, - "width": 23.0, - "height": 36.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682974, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682975, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682974, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268435559, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 72.0, - "y": 10.0, - "width": 480.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 24, - "element_id": 268435560, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 575.0, - "y": 6.0, - "width": 25.0, - "height": 23.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100667837, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100667837, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100667836, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 268435561, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 49.0, - "width": 590.0, - "height": 58.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 26, - "element_id": 268435562, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 52.0, - "width": 584.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 27, - "element_id": 268435563, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 86.0, - "width": 584.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682879, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682879, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 1, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 27, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 86.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 29, - "element_id": 268436329, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 28, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 86.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682880, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682880, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682881, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682882, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268436325, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 28, - "base_layout_id": 0, - "rect": { - "x": 16.0, - "y": 86.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682883, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682883, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682884, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682885, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 31, - "element_id": 268436330, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 28, - "base_layout_id": 0, - "rect": { - "x": 26.0, - "y": 86.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682886, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682886, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682887, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682888, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268436331, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 27, - "base_layout_id": 0, - "rect": { - "x": 581.0, - "y": 86.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682889, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682889, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682890, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682891, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 33, - "element_id": 268436332, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 27, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 86.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682892, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682892, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682893, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682894, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 34, - "element_id": 268435564, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 49.0, - "width": 3.0, - "height": 58.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667834, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667834, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 35, - "element_id": 268435565, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 597.0, - "y": 49.0, - "width": 3.0, - "height": 58.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667834, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667834, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 36, - "element_id": 268435566, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 49.0, - "width": 584.0, - "height": 3.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667833, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667833, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 37, - "element_id": 268435567, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 104.0, - "width": 584.0, - "height": 3.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667833, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667833, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268435550, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 110.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268435572, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 45.0, - "width": 500.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 40, - "element_id": 268435573, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 78.0, - "width": 500.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682879, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682879, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 41, - "element_id": 1, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 78.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 42, - "element_id": 268436329, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 78.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682880, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682880, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682881, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682882, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 43, - "element_id": 268436325, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 78.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682883, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682883, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682884, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682885, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 44, - "element_id": 268436330, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 78.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682886, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682886, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682887, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682888, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 268436331, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 499.0, - "y": 78.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682889, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682889, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682890, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682891, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 46, - "element_id": 268436332, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 78.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682892, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682892, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682893, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682894, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 47, - "element_id": 268435574, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 537.0, - "y": 49.0, - "width": 64.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682828, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682828, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682829, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682830, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 48, - "element_id": 268435575, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 19.0, - "width": 500.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 49, - "element_id": 268435576, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 581.0, - "y": 5.0, - "width": 22.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668074, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668074, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668073, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 50, - "element_id": 268435551, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 110.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 51, - "element_id": 268435579, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 50, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 110.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 52, - "element_id": 268435581, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 301.0, - "y": 5.0, - "width": 3.0, - "height": 110.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682936, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682936, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 53, - "element_id": 268435582, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 80.0, - "y": 8.0, - "width": 150.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 54, - "element_id": 268435583, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 267.0, - "y": 8.0, - "width": 35.0, - "height": 27.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682938, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 6, - "image": { - "image_id": 100682938, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682937, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 55, - "element_id": 268435584, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 80.0, - "y": 40.0, - "width": 150.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 56, - "element_id": 268435585, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 62.0, - "width": 295.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 57, - "element_id": 268435586, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 94.0, - "width": 295.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682879, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682879, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 58, - "element_id": 1, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 57, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 59, - "element_id": 268436329, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 94.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682880, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682880, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682881, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682882, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 60, - "element_id": 268436325, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 94.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682883, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682883, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682884, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682885, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 61, - "element_id": 268436330, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 94.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682886, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682886, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682887, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682888, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 62, - "element_id": 268436331, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 57, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682889, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682889, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682890, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682891, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 63, - "element_id": 268436332, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 57, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682892, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682892, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682893, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682894, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 64, - "element_id": 268435587, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 50, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 5.0, - "width": 300.0, - "height": 110.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 65, - "element_id": 268435589, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 380.0, - "y": 8.0, - "width": 150.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 66, - "element_id": 268435590, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 8.0, - "width": 46.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682940, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682940, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100682941, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682939, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 67, - "element_id": 268435591, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 380.0, - "y": 40.0, - "width": 150.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 68, - "element_id": 268435592, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 307.0, - "y": 62.0, - "width": 295.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 69, - "element_id": 268435593, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 307.0, - "y": 94.0, - "width": 295.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682879, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682879, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 70, - "element_id": 1, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 69, - "base_layout_id": 0, - "rect": { - "x": 307.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 71, - "element_id": 268436329, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 70, - "base_layout_id": 0, - "rect": { - "x": 307.0, - "y": 94.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682880, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682880, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682881, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682882, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 72, - "element_id": 268436325, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 70, - "base_layout_id": 0, - "rect": { - "x": 310.0, - "y": 94.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682883, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682883, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682884, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682885, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 73, - "element_id": 268436330, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 70, - "base_layout_id": 0, - "rect": { - "x": 320.0, - "y": 94.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682886, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682886, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682887, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682888, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 74, - "element_id": 268436331, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 69, - "base_layout_id": 0, - "rect": { - "x": 586.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682889, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682889, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682890, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682891, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 75, - "element_id": 268436332, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 69, - "base_layout_id": 0, - "rect": { - "x": 307.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682892, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682892, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682893, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682894, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 76, - "element_id": 268435594, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 50, - "base_layout_id": 0, - "rect": { - "x": 275.0, - "y": 45.0, - "width": 60.0, - "height": 14.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100670918, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100670918, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100670917, - "alpha_image_id": null - } - }, - { - "state_id": 268435461, - "image": { - "image_id": 100670916, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 77, - "element_id": 268435595, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 50, - "base_layout_id": 0, - "rect": { - "x": 583.0, - "y": 5.0, - "width": 22.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668074, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668074, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668073, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 78, - "element_id": 268435552, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 110.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 79, - "element_id": 268435597, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 78, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668072, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668072, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 80, - "element_id": 268435598, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 78, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 92.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682943, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100682943, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100682942, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 81, - "element_id": 268435599, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 78, - "base_layout_id": 0, - "rect": { - "x": 97.0, - "y": 5.0, - "width": 92.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682943, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100682943, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100682942, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 82, - "element_id": 268435600, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 78, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 25.0, - "width": 600.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 83, - "element_id": 268435601, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 28.0, - "width": 245.0, - "height": 87.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 84, - "element_id": 268435602, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 28.0, - "width": 355.0, - "height": 87.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 85, - "element_id": 268435603, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 84, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 43.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 86, - "element_id": 268435604, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 84, - "base_layout_id": 0, - "rect": { - "x": 505.0, - "y": 35.0, - "width": 94.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100672324, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100672324, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100672325, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100672326, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 87, - "element_id": 268435605, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 84, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 62.0, - "width": 355.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 88, - "element_id": 268435606, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 84, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 94.0, - "width": 355.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682879, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682879, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 89, - "element_id": 1, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 88, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 90, - "element_id": 268436329, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 89, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 94.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682880, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682880, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682881, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682882, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 91, - "element_id": 268436325, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 89, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 94.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682883, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682883, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682884, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682885, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 92, - "element_id": 268436330, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 89, - "base_layout_id": 0, - "rect": { - "x": 263.0, - "y": 94.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682886, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682886, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682887, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682888, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 93, - "element_id": 268436331, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 88, - "base_layout_id": 0, - "rect": { - "x": 589.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682889, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682889, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682890, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682891, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 94, - "element_id": 268436332, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 88, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682892, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682892, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682893, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682894, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 95, - "element_id": 268435607, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 78, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 25.0, - "width": 600.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 96, - "element_id": 268435608, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 95, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 28.0, - "width": 245.0, - "height": 87.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 97, - "element_id": 268435609, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 95, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 28.0, - "width": 355.0, - "height": 87.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 98, - "element_id": 268435610, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 97, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 43.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 99, - "element_id": 268435611, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 97, - "base_layout_id": 0, - "rect": { - "x": 505.0, - "y": 35.0, - "width": 94.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100672327, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100672327, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100672328, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100672329, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 100, - "element_id": 268435612, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 97, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 62.0, - "width": 355.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 101, - "element_id": 268435613, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 97, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 94.0, - "width": 355.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682879, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682879, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 102, - "element_id": 1, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 101, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 103, - "element_id": 268436329, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 102, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 94.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682880, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682880, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682881, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682882, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 104, - "element_id": 268436325, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 102, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 94.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682883, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682883, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682884, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682885, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 105, - "element_id": 268436330, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 102, - "base_layout_id": 0, - "rect": { - "x": 263.0, - "y": 94.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682886, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682886, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682887, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682888, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 106, - "element_id": 268436331, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 101, - "base_layout_id": 0, - "rect": { - "x": 589.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682889, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682889, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682890, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682891, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 107, - "element_id": 268436332, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 101, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 94.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682892, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682892, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682893, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682894, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 108, - "element_id": 268435614, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 78, - "base_layout_id": 0, - "rect": { - "x": 581.0, - "y": 5.0, - "width": 22.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668074, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668074, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668073, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 109, - "element_id": 268435554, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 110.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 110, - "element_id": 268435640, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 109, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 110.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 111, - "element_id": 268435641, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 110, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 92.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687634, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687634, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687633, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 112, - "element_id": 268435642, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 110, - "base_layout_id": 0, - "rect": { - "x": 97.0, - "y": 5.0, - "width": 92.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687634, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687634, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687633, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 113, - "element_id": 268435643, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 110, - "base_layout_id": 0, - "rect": { - "x": 189.0, - "y": 5.0, - "width": 92.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687634, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687634, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687633, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 114, - "element_id": 268435644, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 110, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 25.0, - "width": 600.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 115, - "element_id": 268435645, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 114, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 55.0, - "width": 510.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 116, - "element_id": 268435646, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 114, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 510.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682879, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682879, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 117, - "element_id": 1, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 118, - "element_id": 268436329, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682880, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682880, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682881, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682882, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 119, - "element_id": 268436325, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 88.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682883, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682883, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682884, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682885, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 120, - "element_id": 268436330, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 88.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682886, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682886, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682887, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682888, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 121, - "element_id": 268436331, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 509.0, - "y": 88.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682889, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682889, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682890, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682891, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 122, - "element_id": 268436332, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682892, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682892, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682893, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682894, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 123, - "element_id": 268435647, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 114, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 29.0, - "width": 117.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 124, - "element_id": 268436301, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 123, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 29.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668083, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668083, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 125, - "element_id": 268436302, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 123, - "base_layout_id": 0, - "rect": { - "x": 109.0, - "y": 29.0, - "width": 17.0, - "height": 19.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668081, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668081, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100668081, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100668082, - "alpha_image_id": null - } - }, - { - "state_id": 7, - "image": { - "image_id": 100668082, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 126, - "element_id": 268435648, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 114, - "base_layout_id": 0, - "rect": { - "x": 130.0, - "y": 25.0, - "width": 390.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 127, - "element_id": 268435649, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 114, - "base_layout_id": 0, - "rect": { - "x": 130.0, - "y": 40.0, - "width": 390.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 128, - "element_id": 268435650, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 114, - "base_layout_id": 0, - "rect": { - "x": 537.0, - "y": 29.0, - "width": 64.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682828, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682828, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682829, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682830, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 129, - "element_id": 268435651, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 114, - "base_layout_id": 0, - "rect": { - "x": 537.0, - "y": 55.0, - "width": 64.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682828, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682828, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682829, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682830, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 130, - "element_id": 268435652, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 110, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 25.0, - "width": 600.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 131, - "element_id": 268435653, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 55.0, - "width": 510.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 132, - "element_id": 268435654, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 510.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682879, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682879, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 133, - "element_id": 1, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 132, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 134, - "element_id": 268436329, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 133, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682880, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682880, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682881, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682882, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 135, - "element_id": 268436325, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 133, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 88.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682883, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682883, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682884, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682885, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 136, - "element_id": 268436330, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 133, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 88.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682886, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682886, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682887, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682888, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 137, - "element_id": 268436331, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 132, - "base_layout_id": 0, - "rect": { - "x": 509.0, - "y": 88.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682889, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682889, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682890, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682891, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 138, - "element_id": 268436332, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 132, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682892, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682892, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682893, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682894, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 139, - "element_id": 268435655, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 130.0, - "y": 25.0, - "width": 390.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 140, - "element_id": 268435656, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 130.0, - "y": 40.0, - "width": 390.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 141, - "element_id": 268435657, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 537.0, - "y": 29.0, - "width": 64.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682828, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682828, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682829, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682830, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 142, - "element_id": 268435658, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 537.0, - "y": 55.0, - "width": 64.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682828, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682828, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682829, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682830, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 143, - "element_id": 268435659, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 26.0, - "width": 65.0, - "height": 14.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668090, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668090, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668089, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100668088, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 144, - "element_id": 268435660, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 40.0, - "width": 65.0, - "height": 14.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668090, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668090, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668089, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100668088, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 145, - "element_id": 268435661, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 110, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 25.0, - "width": 600.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 146, - "element_id": 268435662, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 145, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 55.0, - "width": 510.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 147, - "element_id": 268435663, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 145, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 510.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682879, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682879, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 148, - "element_id": 1, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 147, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 149, - "element_id": 268436329, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 148, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682880, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682880, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682881, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682882, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 150, - "element_id": 268436325, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 148, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 88.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682883, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682883, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682884, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682885, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 151, - "element_id": 268436330, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 148, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 88.0, - "width": 3.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682886, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682886, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682887, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682888, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 152, - "element_id": 268436331, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 147, - "base_layout_id": 0, - "rect": { - "x": 509.0, - "y": 88.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682889, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682889, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682890, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682891, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 153, - "element_id": 268436332, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 147, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 88.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682892, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682892, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682893, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682894, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 154, - "element_id": 268435664, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 145, - "base_layout_id": 0, - "rect": { - "x": 130.0, - "y": 25.0, - "width": 390.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 155, - "element_id": 268435665, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 145, - "base_layout_id": 0, - "rect": { - "x": 130.0, - "y": 40.0, - "width": 390.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 156, - "element_id": 268435666, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 145, - "base_layout_id": 0, - "rect": { - "x": 537.0, - "y": 29.0, - "width": 64.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682828, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682828, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682829, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682830, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 157, - "element_id": 268435667, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 145, - "base_layout_id": 0, - "rect": { - "x": 537.0, - "y": 55.0, - "width": 64.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682828, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682828, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682829, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682830, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 158, - "element_id": 268435668, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 145, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 26.0, - "width": 65.0, - "height": 14.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100669735, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100669735, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100669736, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100668088, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 159, - "element_id": 268435669, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 145, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 40.0, - "width": 65.0, - "height": 14.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100669735, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100669735, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100669736, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100668088, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 160, - "element_id": 268435597, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 109, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100687632, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100687632, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 161, - "element_id": 268435670, - "layout_id": 553648237, - "parent_layout_id": 553648237, - "parent_traversal_index": 109, - "base_layout_id": 0, - "rect": { - "x": 581.0, - "y": 5.0, - "width": 22.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668074, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668074, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668073, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268437009, - "slug": "indicators", - "title": "Indicators", - "bucket": "GameplayHud", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268437008, - "layout_id": 553648241, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 150.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268437059, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693187, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693187, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268437060, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 140.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693183, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693183, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268437061, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693188, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693188, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268437062, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693184, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693184, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268437063, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693189, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693189, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268437064, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 25.0, - "width": 140.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693185, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693185, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268437065, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 25.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693190, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693190, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268437066, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 5.0, - "width": 5.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268437067, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268437068, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 140.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268437069, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268437070, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268437071, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268437072, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 25.0, - "width": 140.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268437073, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 25.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268437074, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 5.0, - "width": 5.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435704, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693144, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 17, - "image": { - "image_id": 100693144, - "alpha_image_id": null - } - }, - { - "state_id": 18, - "image": { - "image_id": 100693145, - "alpha_image_id": null - } - }, - { - "state_id": 19, - "image": { - "image_id": 100693146, - "alpha_image_id": null - } - }, - { - "state_id": 20, - "image": { - "image_id": 100693146, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268435698, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693147, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 3, - "image": { - "image_id": 100693147, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268435701, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693149, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693148, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693148, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693149, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435698, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693147, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 3, - "image": { - "image_id": 100693147, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268435702, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693151, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693150, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693150, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693151, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268435698, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 45.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693147, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 3, - "image": { - "image_id": 100693147, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268435700, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693153, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693152, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693152, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693153, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268435698, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693147, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 3, - "image": { - "image_id": 100693147, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 268435703, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 85.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693154, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 14, - "image": { - "image_id": 100693154, - "alpha_image_id": null - } - }, - { - "state_id": 15, - "image": { - "image_id": 100693155, - "alpha_image_id": null - } - }, - { - "state_id": 16, - "image": { - "image_id": 100693156, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 268435698, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 85.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693147, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 3, - "image": { - "image_id": 100693147, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268435699, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 105.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693158, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693157, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693157, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693158, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268435698, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 27, - "base_layout_id": 0, - "rect": { - "x": 105.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682984, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 3, - "image": { - "image_id": 100682984, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268435706, - "layout_id": 553648241, - "parent_layout_id": 553648241, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 125.0, - "y": 5.0, - "width": 20.0, - "height": 20.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693169, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693170, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268437011, - "slug": "powerbar", - "title": "Power Bar", - "bucket": "GameplayHud", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268437010, - "layout_id": 553648242, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 610.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268437123, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693187, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693187, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268437124, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693183, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693183, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268437125, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693188, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693188, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268437126, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693184, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693184, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268437127, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 20.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693189, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693189, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268437128, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 20.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693185, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693185, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268437129, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 20.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693190, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693190, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268437130, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 5.0, - "width": 5.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268437131, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268437132, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268437133, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268437134, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268437135, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 20.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268437136, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 20.0, - "width": 600.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268437137, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 20.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268437138, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 605.0, - "y": 5.0, - "width": 5.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435508, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268436974, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 5.0, - "width": 480.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100692318, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100692318, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 2, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 15.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668244, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435522, - "image": { - "image_id": 100668244, - "alpha_image_id": null - } - }, - { - "state_id": 268435523, - "image": { - "image_id": 100667904, - "alpha_image_id": null - } - }, - { - "state_id": 268435524, - "image": { - "image_id": 100667904, - "alpha_image_id": null - } - }, - { - "state_id": 268435525, - "image": { - "image_id": 100669787, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435509, - "layout_id": 553648242, - "parent_layout_id": 553648242, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 600.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268437202, - "slug": "radar", - "title": "Radar", - "bucket": "GameplayHud", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268437203, - "layout_id": 553648244, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 120.0, - "height": 140.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268435518, - "layout_id": 553648244, - "parent_layout_id": 553648244, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 120.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682944, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682944, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268435519, - "layout_id": 553648244, - "parent_layout_id": 553648244, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 120.0, - "height": 120.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682945, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682945, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268437017, - "layout_id": 553648244, - "parent_layout_id": 553648244, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 6.0, - "width": 27.0, - "height": 27.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693175, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435555, - "image": { - "image_id": 100693175, - "alpha_image_id": null - } - }, - { - "state_id": 268435556, - "image": { - "image_id": 100693176, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268437155, - "layout_id": 553648244, - "parent_layout_id": 553648244, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 87.0, - "y": 6.0, - "width": 27.0, - "height": 27.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693193, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693193, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268435520, - "layout_id": 553648244, - "parent_layout_id": 553648244, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 1.0, - "width": 10.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667899, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667899, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268435521, - "layout_id": 553648244, - "parent_layout_id": 553648244, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 110.0, - "y": 55.0, - "width": 10.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100669752, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100669752, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268435522, - "layout_id": 553648244, - "parent_layout_id": 553648244, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 110.0, - "width": 10.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100669754, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100669754, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268435523, - "layout_id": 553648244, - "parent_layout_id": 553648244, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 55.0, - "width": 10.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100669756, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100669756, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268436995, - "slug": "toolbar", - "title": "Toolbar", - "bucket": "GameplayHud", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436994, - "layout_id": 553648240, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 310.0, - "height": 100.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268435483, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 122.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 2, - "element_id": 268435858, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 55.0, - "height": 58.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682988, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682988, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682989, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268435859, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 55.0, - "height": 58.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682990, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682990, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682991, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268435860, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 55.0, - "height": 58.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682992, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682992, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682993, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268435861, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 55.0, - "height": 58.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682994, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682994, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682995, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268435862, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 5.0, - "width": 7.0, - "height": 27.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667691, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667691, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268435863, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 5.0, - "width": 35.0, - "height": 27.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100667679, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100667679, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100667681, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268435864, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 90.0, - "y": 5.0, - "width": 34.0, - "height": 27.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100667673, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100667673, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100667675, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268435865, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 120.0, - "y": 5.0, - "width": 34.0, - "height": 27.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100667682, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100667682, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100667684, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268436826, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 150.0, - "y": 5.0, - "width": 34.0, - "height": 27.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100690350, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100690350, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100690351, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268435866, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 180.0, - "y": 5.0, - "width": 34.0, - "height": 27.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100667670, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100667670, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100667672, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268435867, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 209.0, - "y": 5.0, - "width": 34.0, - "height": 27.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100667676, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100667676, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100667678, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268435868, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 241.0, - "y": 5.0, - "width": 10.0, - "height": 27.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667692, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667692, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268435869, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 32.0, - "width": 23.0, - "height": 31.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100667689, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100667689, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100667690, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100667918, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268435870, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 83.0, - "y": 32.0, - "width": 140.0, - "height": 31.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667686, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667686, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268435871, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 83.0, - "y": 32.0, - "width": 140.0, - "height": 31.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 17, - "element_id": 268435872, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 83.0, - "y": 32.0, - "width": 140.0, - "height": 31.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100669751, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435467, - "image": { - "image_id": 100669751, - "alpha_image_id": null - } - }, - { - "state_id": 268435468, - "image": { - "image_id": 100682996, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268435873, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 83.0, - "y": 32.0, - "width": 140.0, - "height": 31.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100669758, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100669758, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 2, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 83.0, - "y": 32.0, - "width": 140.0, - "height": 31.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100669759, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100669759, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435874, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 83.0, - "y": 32.0, - "width": 140.0, - "height": 31.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100672213, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100672213, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 2, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 83.0, - "y": 32.0, - "width": 146.0, - "height": 31.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100672214, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100672214, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268435875, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 83.0, - "y": 45.0, - "width": 50.0, - "height": 14.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682997, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682997, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268435876, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 133.0, - "y": 45.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 24, - "element_id": 4, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 133.0, - "y": 45.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682998, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682998, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 1, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 133.0, - "y": 45.0, - "width": 16.0, - "height": 14.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100687299, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100687299, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 268435877, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 223.0, - "y": 32.0, - "width": 22.0, - "height": 31.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100667687, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100667687, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100667688, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268435878, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 63.0, - "width": 6.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268435879, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 11.0, - "y": 63.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 29, - "element_id": 268435880, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 43.0, - "y": 63.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 30, - "element_id": 268435881, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 75.0, - "y": 63.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 31, - "element_id": 268435882, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 107.0, - "y": 63.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 32, - "element_id": 268435883, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 139.0, - "y": 63.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 33, - "element_id": 268435884, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 171.0, - "y": 63.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 34, - "element_id": 268435885, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 63.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 35, - "element_id": 268435886, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 63.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 36, - "element_id": 268435887, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 267.0, - "y": 63.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 37, - "element_id": 268435888, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 299.0, - "y": 63.0, - "width": 6.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268437174, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 95.0, - "width": 6.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268437175, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 11.0, - "y": 95.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 40, - "element_id": 268437176, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 43.0, - "y": 95.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 41, - "element_id": 268437177, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 75.0, - "y": 95.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 42, - "element_id": 268437178, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 107.0, - "y": 95.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 43, - "element_id": 268437179, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 139.0, - "y": 95.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 44, - "element_id": 268437180, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 171.0, - "y": 95.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 45, - "element_id": 268437181, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 95.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 46, - "element_id": 268437182, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 95.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 47, - "element_id": 268437183, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 267.0, - "y": 95.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 48, - "element_id": 268437184, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 299.0, - "y": 95.0, - "width": 8.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 268435889, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 243.0, - "y": 5.0, - "width": 63.0, - "height": 58.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682999, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682999, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683000, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 50, - "element_id": 268436588, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 49, - "base_layout_id": 0, - "rect": { - "x": 274.0, - "y": 5.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100667897, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435520, - "image": { - "image_id": 100667897, - "alpha_image_id": null - } - }, - { - "state_id": 268435521, - "image": { - "image_id": 100667896, - "alpha_image_id": null - } - }, - { - "state_id": 268435526, - "image": { - "image_id": 100667895, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268437027, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693187, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693187, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 52, - "element_id": 268437028, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693183, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693183, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 53, - "element_id": 268437029, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693188, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693188, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 54, - "element_id": 268437030, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693184, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693184, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 55, - "element_id": 268437031, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 95.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693189, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693189, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 56, - "element_id": 268437032, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 95.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693185, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693185, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 57, - "element_id": 268437033, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 95.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693190, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693190, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 58, - "element_id": 268437034, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 5.0, - "width": 5.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 59, - "element_id": 268437035, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 60, - "element_id": 268437036, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 61, - "element_id": 268437037, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 62, - "element_id": 268437038, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 63, - "element_id": 268437039, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 95.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 64, - "element_id": 268437040, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 95.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 65, - "element_id": 268437041, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 95.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 66, - "element_id": 268437042, - "layout_id": 553648240, - "parent_layout_id": 553648240, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 5.0, - "width": 5.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268436007, - "slug": "character", - "title": "Character", - "bucket": "MainPanel", - "parent_slug": "main_panel", - "width": 640, - "height": 600, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436007, - "layout_id": 553648174, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 600.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268436008, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 2, - "element_id": 268436537, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268435689, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 0.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268435989, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 75.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268436009, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 92.0, - "y": 0.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 6, - "element_id": 268436537, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 92.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268435689, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 109.0, - "y": 0.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268435989, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 167.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268436792, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 184.0, - "y": 0.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 10, - "element_id": 268436537, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 184.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268435689, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 201.0, - "y": 0.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268435989, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 259.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268436010, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268436011, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 15, - "element_id": 268436006, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268436016, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 110.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 17, - "element_id": 268436017, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 230.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 18, - "element_id": 268436018, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 45.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 19, - "element_id": 268436019, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 60.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 20, - "element_id": 268436933, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 77.0, - "width": 110.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 21, - "element_id": 268436934, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 110.0, - "y": 77.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 22, - "element_id": 268436020, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 95.0, - "width": 130.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 23, - "element_id": 268436021, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 130.0, - "y": 95.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 24, - "element_id": 268436022, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 113.0, - "width": 230.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667814, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667814, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 2, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 114.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667813, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667813, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 268436023, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 113.0, - "width": 130.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 27, - "element_id": 268436024, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 130.0, - "y": 113.0, - "width": 100.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 28, - "element_id": 268436025, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 230.0, - "y": 25.0, - "width": 5.0, - "height": 105.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682936, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682936, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268436026, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 25.0, - "width": 65.0, - "height": 35.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 30, - "element_id": 268436027, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 60.0, - "width": 65.0, - "height": 50.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 31, - "element_id": 268436028, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 130.0, - "width": 300.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268436029, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 137.0, - "width": 300.0, - "height": 398.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 33, - "element_id": 268436030, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 137.0, - "width": 16.0, - "height": 398.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 34, - "element_id": 1, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 33, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 137.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 35, - "element_id": 268436324, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 137.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 36, - "element_id": 268436325, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 140.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 37, - "element_id": 268436326, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 150.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268435569, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 33, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 137.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268435570, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 33, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 519.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 40, - "element_id": 268436031, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 535.0, - "width": 300.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 41, - "element_id": 268436032, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 42, - "element_id": 268436046, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 43, - "element_id": 268436034, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 565.0, - "width": 195.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 44, - "element_id": 268436035, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 200.0, - "y": 565.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 45, - "element_id": 268436036, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 582.0, - "width": 195.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 46, - "element_id": 268436037, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 200.0, - "y": 582.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 47, - "element_id": 268436033, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 48, - "element_id": 268436046, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 49, - "element_id": 268436034, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 565.0, - "width": 145.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 50, - "element_id": 268436035, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 150.0, - "y": 565.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 51, - "element_id": 268436036, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 582.0, - "width": 145.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 52, - "element_id": 268436037, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 150.0, - "y": 582.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 53, - "element_id": 268436038, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 260.0, - "y": 571.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682935, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682934, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682935, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 54, - "element_id": 268436971, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 260.0, - "y": 545.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100692268, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100692267, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100692268, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 55, - "element_id": 268436039, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 56, - "element_id": 268436046, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 55, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 57, - "element_id": 268436034, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 55, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 565.0, - "width": 145.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 58, - "element_id": 268436039, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 55, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 565.0, - "width": 240.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667814, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667814, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 59, - "element_id": 2, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 566.0, - "width": 240.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667274, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667274, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 60, - "element_id": 268436035, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 55, - "base_layout_id": 0, - "rect": { - "x": 150.0, - "y": 565.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 61, - "element_id": 268436036, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 55, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 582.0, - "width": 145.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 62, - "element_id": 268436037, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 55, - "base_layout_id": 0, - "rect": { - "x": 150.0, - "y": 582.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 63, - "element_id": 268436038, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 55, - "base_layout_id": 0, - "rect": { - "x": 260.0, - "y": 571.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682935, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682934, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682935, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 64, - "element_id": 268436971, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 55, - "base_layout_id": 0, - "rect": { - "x": 260.0, - "y": 545.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100692268, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100692267, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100692268, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 65, - "element_id": 268436012, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 66, - "element_id": 268436006, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 65, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 67, - "element_id": 268436016, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 66, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 110.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 68, - "element_id": 268436017, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 230.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 69, - "element_id": 268436018, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 45.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 70, - "element_id": 268436019, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 60.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 71, - "element_id": 268436933, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 77.0, - "width": 110.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 72, - "element_id": 268436934, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 110.0, - "y": 77.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 73, - "element_id": 268436020, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 95.0, - "width": 130.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 74, - "element_id": 268436021, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 130.0, - "y": 95.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 75, - "element_id": 268436022, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 113.0, - "width": 230.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667814, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667814, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 76, - "element_id": 2, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 114.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667813, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667813, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 77, - "element_id": 268436023, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 113.0, - "width": 130.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 78, - "element_id": 268436024, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 130.0, - "y": 113.0, - "width": 100.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 79, - "element_id": 268436025, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 230.0, - "y": 25.0, - "width": 5.0, - "height": 105.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682936, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682936, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 80, - "element_id": 268436026, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 25.0, - "width": 65.0, - "height": 35.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 81, - "element_id": 268436027, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 60.0, - "width": 65.0, - "height": 50.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 82, - "element_id": 268436028, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 66, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 130.0, - "width": 300.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 83, - "element_id": 268436029, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 66, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 137.0, - "width": 300.0, - "height": 398.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 84, - "element_id": 268436030, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 66, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 137.0, - "width": 16.0, - "height": 398.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 85, - "element_id": 1, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 84, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 137.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 86, - "element_id": 268436324, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 85, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 137.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 87, - "element_id": 268436325, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 85, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 140.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 88, - "element_id": 268436326, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 85, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 150.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 89, - "element_id": 268435569, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 84, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 137.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 90, - "element_id": 268435570, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 84, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 519.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 91, - "element_id": 268436031, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 66, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 535.0, - "width": 300.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 92, - "element_id": 268436032, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 66, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 93, - "element_id": 268436046, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 92, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 94, - "element_id": 268436034, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 92, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 565.0, - "width": 195.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 95, - "element_id": 268436035, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 92, - "base_layout_id": 0, - "rect": { - "x": 200.0, - "y": 565.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 96, - "element_id": 268436036, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 92, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 582.0, - "width": 195.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 97, - "element_id": 268436037, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 92, - "base_layout_id": 0, - "rect": { - "x": 200.0, - "y": 582.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 98, - "element_id": 268436033, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 66, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 99, - "element_id": 268436046, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 98, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 100, - "element_id": 268436034, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 98, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 565.0, - "width": 145.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 101, - "element_id": 268436035, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 98, - "base_layout_id": 0, - "rect": { - "x": 150.0, - "y": 565.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 102, - "element_id": 268436036, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 98, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 582.0, - "width": 145.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 103, - "element_id": 268436037, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 98, - "base_layout_id": 0, - "rect": { - "x": 150.0, - "y": 582.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 104, - "element_id": 268436038, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 98, - "base_layout_id": 0, - "rect": { - "x": 260.0, - "y": 571.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682935, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682934, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682935, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 105, - "element_id": 268436971, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 98, - "base_layout_id": 0, - "rect": { - "x": 260.0, - "y": 545.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100692268, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100692267, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100692268, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 106, - "element_id": 268436039, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 66, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 107, - "element_id": 268436046, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 106, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 545.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 108, - "element_id": 268436034, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 106, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 565.0, - "width": 145.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 109, - "element_id": 268436039, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 106, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 565.0, - "width": 240.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667814, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667814, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 110, - "element_id": 2, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 109, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 566.0, - "width": 240.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667274, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667274, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 111, - "element_id": 268436035, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 106, - "base_layout_id": 0, - "rect": { - "x": 150.0, - "y": 565.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 112, - "element_id": 268436036, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 106, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 582.0, - "width": 145.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 113, - "element_id": 268436037, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 106, - "base_layout_id": 0, - "rect": { - "x": 150.0, - "y": 582.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 114, - "element_id": 268436038, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 106, - "base_layout_id": 0, - "rect": { - "x": 260.0, - "y": 571.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682935, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682934, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682935, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 115, - "element_id": 268436971, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 106, - "base_layout_id": 0, - "rect": { - "x": 260.0, - "y": 545.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100692268, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100692267, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100692268, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 116, - "element_id": 268436793, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 117, - "element_id": 268436782, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 45.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 118, - "element_id": 268436783, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 65.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 119, - "element_id": 268436784, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 60.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 120, - "element_id": 268436785, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 95.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 121, - "element_id": 268436786, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 115.0, - "width": 270.0, - "height": 430.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 122, - "element_id": 268436787, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 115.0, - "width": 16.0, - "height": 430.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 123, - "element_id": 1, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 122, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 115.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 124, - "element_id": 268436324, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 123, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 115.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 125, - "element_id": 268436325, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 123, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 118.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 126, - "element_id": 268436326, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 123, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 128.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 127, - "element_id": 268435569, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 122, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 115.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 128, - "element_id": 268435570, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 122, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 529.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 129, - "element_id": 268436788, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 550.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 130, - "element_id": 268436789, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 53.0, - "y": 560.0, - "width": 200.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 131, - "element_id": 268436174, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 53.0, - "y": 560.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 132, - "element_id": 268436175, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 85.0, - "y": 560.0, - "width": 136.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 133, - "element_id": 268436176, - "layout_id": 553648174, - "parent_layout_id": 553648174, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 221.0, - "y": 560.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268435916, - "slug": "inventory", - "title": "Inventory", - "bucket": "MainPanel", - "parent_slug": "main_panel", - "width": 800, - "height": 500, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268435916, - "layout_id": 553648163, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 500.0, - "y": 138.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268435917, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 500.0, - "y": 161.0, - "width": 224.0, - "height": 214.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 2, - "element_id": 268435925, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 500.0, - "y": 161.0, - "width": 224.0, - "height": 214.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 3, - "element_id": 268435926, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 551.0, - "y": 161.0, - "width": 100.0, - "height": 214.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 4, - "element_id": 268436589, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 3, - "base_layout_id": 0, - "rect": { - "x": 584.0, - "y": 229.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100667897, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435520, - "image": { - "image_id": 100667897, - "alpha_image_id": null - } - }, - { - "state_id": 268435521, - "image": { - "image_id": 100667896, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268436926, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 542.0, - "y": 351.0, - "width": 120.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 6, - "element_id": 268436264, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 542.0, - "y": 352.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268435930, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 508.0, - "y": 169.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 8, - "element_id": 268436878, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 508.0, - "y": 205.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 9, - "element_id": 268436969, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 692.0, - "y": 205.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 10, - "element_id": 268436885, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 626.0, - "y": 169.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 11, - "element_id": 268436886, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 658.0, - "y": 169.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 12, - "element_id": 268436887, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 690.0, - "y": 169.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 13, - "element_id": 268435931, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 656.0, - "y": 241.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 14, - "element_id": 268435932, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 656.0, - "y": 277.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 15, - "element_id": 268435933, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 508.0, - "y": 241.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 16, - "element_id": 268435934, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 508.0, - "y": 277.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 17, - "element_id": 268435935, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 656.0, - "y": 333.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 18, - "element_id": 268435936, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 690.0, - "y": 333.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 19, - "element_id": 268435937, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 508.0, - "y": 333.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 20, - "element_id": 268435938, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 692.0, - "y": 241.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 21, - "element_id": 268435939, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 692.0, - "y": 277.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 22, - "element_id": 268436907, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 584.0, - "y": 189.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 23, - "element_id": 268436908, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 584.0, - "y": 225.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 24, - "element_id": 268436909, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 584.0, - "y": 261.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 25, - "element_id": 268436910, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 548.0, - "y": 225.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 26, - "element_id": 268436911, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 548.0, - "y": 261.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 27, - "element_id": 268436912, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 548.0, - "y": 297.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 28, - "element_id": 268436913, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 620.0, - "y": 261.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 29, - "element_id": 268436914, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 620.0, - "y": 297.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 30, - "element_id": 268436915, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 620.0, - "y": 333.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 31, - "element_id": 268435918, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 739.0, - "y": 161.0, - "width": 61.0, - "height": 339.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 32, - "element_id": 268435927, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 739.0, - "y": 168.0, - "width": 36.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 33, - "element_id": 268435928, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 739.0, - "y": 179.0, - "width": 36.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 34, - "element_id": 268435929, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 783.0, - "y": 169.0, - "width": 11.0, - "height": 58.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667933, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667933, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 35, - "element_id": 2, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 783.0, - "y": 169.0, - "width": 11.0, - "height": 61.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667932, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667932, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 36, - "element_id": 268435913, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 745.0, - "y": 193.0, - "width": 36.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 37, - "element_id": 268435914, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 745.0, - "y": 234.0, - "width": 36.0, - "height": 252.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 38, - "element_id": 268435915, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 780.0, - "y": 234.0, - "width": 16.0, - "height": 252.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 1, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 780.0, - "y": 234.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 40, - "element_id": 268436324, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 39, - "base_layout_id": 0, - "rect": { - "x": 780.0, - "y": 234.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 41, - "element_id": 268436325, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 39, - "base_layout_id": 0, - "rect": { - "x": 780.0, - "y": 237.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 42, - "element_id": 268436326, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 39, - "base_layout_id": 0, - "rect": { - "x": 780.0, - "y": 247.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 43, - "element_id": 268435569, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 780.0, - "y": 234.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 44, - "element_id": 268435570, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 780.0, - "y": 470.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 268435919, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 500.0, - "y": 375.0, - "width": 234.0, - "height": 120.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 46, - "element_id": 268435909, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 515.0, - "y": 375.0, - "width": 192.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 47, - "element_id": 268435910, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 515.0, - "y": 395.0, - "width": 192.0, - "height": 96.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 48, - "element_id": 268435911, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 707.0, - "y": 395.0, - "width": 16.0, - "height": 96.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 1, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 48, - "base_layout_id": 0, - "rect": { - "x": 707.0, - "y": 395.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 50, - "element_id": 268436324, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 49, - "base_layout_id": 0, - "rect": { - "x": 707.0, - "y": 395.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268436325, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 49, - "base_layout_id": 0, - "rect": { - "x": 707.0, - "y": 398.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 52, - "element_id": 268436326, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 49, - "base_layout_id": 0, - "rect": { - "x": 707.0, - "y": 408.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 53, - "element_id": 268435569, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 48, - "base_layout_id": 0, - "rect": { - "x": 707.0, - "y": 395.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 54, - "element_id": 268435570, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 48, - "base_layout_id": 0, - "rect": { - "x": 707.0, - "y": 475.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 55, - "element_id": 268435920, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 500.0, - "y": 138.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683018, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683018, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 56, - "element_id": 268435921, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 500.0, - "y": 499.0, - "width": 300.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 57, - "element_id": 268435922, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 776.0, - "y": 138.0, - "width": 24.0, - "height": 23.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - }, - { - "state_id": 1, - "image": { - "image_id": 100683020, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683021, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 58, - "element_id": 268435923, - "layout_id": 553648163, - "parent_layout_id": 553648163, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 500.0, - "y": 138.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268436991, - "slug": "main_panel", - "title": "Main Panel Host", - "bucket": "MainPanel", - "parent_slug": null, - "width": 640, - "height": 625, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436990, - "layout_id": 553648238, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 310.0, - "height": 372.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 1, - "element_id": 268437075, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693187, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693187, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268437076, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693183, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693183, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268437077, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693188, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693188, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268437078, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 362.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693184, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693184, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268437079, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 367.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693189, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693189, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268437080, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 367.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693185, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693185, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268437081, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 367.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693190, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693190, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268437082, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 5.0, - "width": 5.0, - "height": 362.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268437083, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268437084, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268437085, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268437086, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 362.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268437087, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 367.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268437088, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 367.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268437089, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 367.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268437090, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 5.0, - "width": 5.0, - "height": 362.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435840, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 18, - "element_id": 268435841, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 19, - "element_id": 268435708, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668035, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668035, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668034, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435709, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268435710, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 22, - "element_id": 268435711, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668042, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668042, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268435712, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 22, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 24, - "element_id": 268435713, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 22, - "base_layout_id": 0, - "rect": { - "x": 169.0, - "y": 318.0, - "width": 120.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 25, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 169.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 201.0, - "y": 318.0, - "width": 56.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 257.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268435714, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 22, - "base_layout_id": 0, - "rect": { - "x": 40.0, - "y": 318.0, - "width": 120.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 29, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 28, - "base_layout_id": 0, - "rect": { - "x": 40.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 28, - "base_layout_id": 0, - "rect": { - "x": 72.0, - "y": 318.0, - "width": 56.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 31, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 28, - "base_layout_id": 0, - "rect": { - "x": 128.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268435715, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668042, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668042, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 33, - "element_id": 268435716, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 50.0, - "width": 300.0, - "height": 40.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 34, - "element_id": 268435717, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 90.0, - "width": 268.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 35, - "element_id": 268435718, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 130.0, - "width": 300.0, - "height": 80.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 36, - "element_id": 268435719, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 210.0, - "width": 268.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 37, - "element_id": 268435720, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 212.0, - "width": 16.0, - "height": 90.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 212.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 39, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 212.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 40, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 215.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 41, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 225.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 42, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 212.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 43, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 286.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 44, - "element_id": 268435713, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 169.0, - "y": 318.0, - "width": 120.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 45, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 44, - "base_layout_id": 0, - "rect": { - "x": 169.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 46, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 44, - "base_layout_id": 0, - "rect": { - "x": 201.0, - "y": 318.0, - "width": 56.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 47, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 44, - "base_layout_id": 0, - "rect": { - "x": 257.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 48, - "element_id": 268435721, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 40.0, - "y": 318.0, - "width": 120.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 49, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 48, - "base_layout_id": 0, - "rect": { - "x": 40.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 50, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 48, - "base_layout_id": 0, - "rect": { - "x": 72.0, - "y": 318.0, - "width": 56.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 48, - "base_layout_id": 0, - "rect": { - "x": 128.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 52, - "element_id": 268435722, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668042, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668042, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 53, - "element_id": 268435723, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 52, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 54, - "element_id": 268435724, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 52, - "base_layout_id": 0, - "rect": { - "x": 37.0, - "y": 318.0, - "width": 236.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 55, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 37.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 56, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 69.0, - "y": 318.0, - "width": 172.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 57, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 241.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 58, - "element_id": 268435842, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 59, - "element_id": 268435726, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668012, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668012, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668013, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 60, - "element_id": 268435709, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 30.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668025, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668025, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 61, - "element_id": 268435727, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 62, - "element_id": 268435728, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 35.0, - "width": 300.0, - "height": 306.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668015, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668015, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 63, - "element_id": 268435735, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 62, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 35.0, - "width": 300.0, - "height": 33.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 64, - "element_id": 268435736, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 62, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 68.0, - "width": 22.0, - "height": 241.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668017, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668017, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 65, - "element_id": 268435728, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 62, - "base_layout_id": 0, - "rect": { - "x": 27.0, - "y": 68.0, - "width": 257.0, - "height": 241.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668015, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668015, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 66, - "element_id": 268435737, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 62, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 68.0, - "width": 21.0, - "height": 241.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668018, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668018, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 67, - "element_id": 268435738, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 62, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 309.0, - "width": 300.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668016, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668016, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 68, - "element_id": 268435732, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 62, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 35.0, - "width": 54.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668009, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668009, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668010, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100668011, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 69, - "element_id": 268435733, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 62, - "base_layout_id": 0, - "rect": { - "x": 240.0, - "y": 309.0, - "width": 65.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668006, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668006, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668007, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100668008, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 70, - "element_id": 268435729, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 62, - "base_layout_id": 0, - "rect": { - "x": 27.0, - "y": 61.0, - "width": 262.0, - "height": 240.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 71, - "element_id": 268436618, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 62, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 61.0, - "width": 16.0, - "height": 240.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 72, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 71, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 61.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 73, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 72, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 61.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 74, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 72, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 64.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 75, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 72, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 74.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 76, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 71, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 61.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 77, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 71, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 285.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 78, - "element_id": 268435730, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 341.0, - "width": 8.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668014, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668014, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 79, - "element_id": 268435731, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 361.0, - "width": 300.0, - "height": 6.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668005, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668005, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 80, - "element_id": 268436592, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 58, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 342.0, - "width": 292.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 81, - "element_id": 268436595, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 80, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 342.0, - "width": 273.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668022, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668022, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 82, - "element_id": 268436603, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 342.0, - "width": 273.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 83, - "element_id": 268436596, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 80, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 342.0, - "width": 19.0, - "height": 19.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668020, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668020, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100668020, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100668021, - "alpha_image_id": null - } - }, - { - "state_id": 7, - "image": { - "image_id": 100668021, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 84, - "element_id": 268435843, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 85, - "element_id": 268435708, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 84, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 86, - "element_id": 268435710, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 84, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 87, - "element_id": 268435740, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 84, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 88, - "element_id": 268435741, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 87, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 30.0, - "width": 262.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 89, - "element_id": 268435742, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 87, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 30.0, - "width": 16.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 90, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 89, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 91, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 90, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 30.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 92, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 90, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 33.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 93, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 90, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 43.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 94, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 89, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 95, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 89, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 351.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 96, - "element_id": 268435844, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 97, - "element_id": 268435708, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 96, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 98, - "element_id": 268435744, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 96, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 99, - "element_id": 268435747, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 96, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 249.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 100, - "element_id": 268435748, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 96, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 32.0, - "width": 16.0, - "height": 249.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 101, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 100, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 32.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 102, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 101, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 32.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 103, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 101, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 35.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 104, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 101, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 45.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 105, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 100, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 32.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 106, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 100, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 265.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 107, - "element_id": 268435749, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 96, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 279.0, - "width": 300.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668309, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668309, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 108, - "element_id": 268435750, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 107, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 287.0, - "width": 280.0, - "height": 78.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 109, - "element_id": 268435751, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 107, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 279.0, - "width": 16.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 110, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 109, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 279.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 111, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 110, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 279.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 112, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 110, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 282.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 113, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 110, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 292.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 114, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 109, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 279.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 115, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 109, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 351.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 116, - "element_id": 268435845, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 117, - "element_id": 268435708, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 118, - "element_id": 268435744, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 119, - "element_id": 268435747, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 249.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 120, - "element_id": 268435748, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 32.0, - "width": 16.0, - "height": 249.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 121, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 120, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 32.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 122, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 121, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 32.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 123, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 121, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 35.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 124, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 121, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 45.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 125, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 120, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 32.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 126, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 120, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 265.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 127, - "element_id": 268435749, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 279.0, - "width": 300.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668309, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668309, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 128, - "element_id": 268435750, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 127, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 287.0, - "width": 280.0, - "height": 78.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 129, - "element_id": 268435751, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 127, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 279.0, - "width": 16.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 130, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 129, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 279.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 131, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 279.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 132, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 282.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 133, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 130, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 292.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 134, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 129, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 279.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 135, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 129, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 351.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 136, - "element_id": 268435847, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 137, - "element_id": 268435708, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 136, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 138, - "element_id": 268435709, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 136, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 139, - "element_id": 268435710, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 138, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 140, - "element_id": 268435816, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 136, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 141, - "element_id": 268435817, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 140, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 30.0, - "width": 284.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 142, - "element_id": 268435848, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 143, - "element_id": 268435819, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 142, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 144, - "element_id": 268435820, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 142, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 145, - "element_id": 268435821, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 144, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 146, - "element_id": 268435822, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 142, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668312, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668312, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 147, - "element_id": 268435823, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 146, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 40.0, - "width": 280.0, - "height": 280.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 148, - "element_id": 268435824, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 147, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 40.0, - "width": 280.0, - "height": 12.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100671446, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100671446, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 149, - "element_id": 268435825, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 147, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 308.0, - "width": 280.0, - "height": 12.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100671447, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100671447, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 150, - "element_id": 268435826, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 147, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 52.0, - "width": 12.0, - "height": 256.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100671448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100671448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 151, - "element_id": 268435827, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 147, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 52.0, - "width": 12.0, - "height": 256.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100671449, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100671449, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 152, - "element_id": 268435828, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 147, - "base_layout_id": 0, - "rect": { - "x": 27.0, - "y": 52.0, - "width": 256.0, - "height": 256.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100671445, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100671445, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 153, - "element_id": 268435829, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 146, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 330.0, - "width": 94.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100672324, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100672324, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100672325, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100672326, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 154, - "element_id": 268435830, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 146, - "base_layout_id": 0, - "rect": { - "x": 108.0, - "y": 330.0, - "width": 94.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100672324, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100672324, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100672325, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100672326, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 155, - "element_id": 268435831, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 146, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 330.0, - "width": 94.0, - "height": 22.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100672324, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100672324, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100672325, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100672326, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 156, - "element_id": 268435849, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 157, - "element_id": 268435708, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 156, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668035, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668035, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668034, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 158, - "element_id": 268435709, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 156, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 159, - "element_id": 268435710, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 158, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 160, - "element_id": 268435892, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 156, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668042, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668042, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 161, - "element_id": 268435893, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 160, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 162, - "element_id": 268435894, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 160, - "base_layout_id": 0, - "rect": { - "x": 169.0, - "y": 318.0, - "width": 120.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 163, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 162, - "base_layout_id": 0, - "rect": { - "x": 169.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 164, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 162, - "base_layout_id": 0, - "rect": { - "x": 201.0, - "y": 318.0, - "width": 56.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 165, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 162, - "base_layout_id": 0, - "rect": { - "x": 257.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 166, - "element_id": 268435895, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 160, - "base_layout_id": 0, - "rect": { - "x": 40.0, - "y": 318.0, - "width": 120.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 167, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 166, - "base_layout_id": 0, - "rect": { - "x": 40.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 168, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 166, - "base_layout_id": 0, - "rect": { - "x": 72.0, - "y": 318.0, - "width": 56.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 169, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 166, - "base_layout_id": 0, - "rect": { - "x": 128.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 170, - "element_id": 268435896, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 156, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668042, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668042, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 171, - "element_id": 268435897, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 170, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 40.0, - "width": 300.0, - "height": 40.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 172, - "element_id": 268435898, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 170, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 80.0, - "width": 268.0, - "height": 54.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 173, - "element_id": 268435899, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 170, - "base_layout_id": 0, - "rect": { - "x": 291.0, - "y": 82.0, - "width": 16.0, - "height": 54.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 174, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 173, - "base_layout_id": 0, - "rect": { - "x": 291.0, - "y": 82.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 175, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 174, - "base_layout_id": 0, - "rect": { - "x": 291.0, - "y": 82.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 176, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 174, - "base_layout_id": 0, - "rect": { - "x": 291.0, - "y": 85.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 177, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 174, - "base_layout_id": 0, - "rect": { - "x": 291.0, - "y": 95.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 178, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 173, - "base_layout_id": 0, - "rect": { - "x": 291.0, - "y": 82.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 179, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 173, - "base_layout_id": 0, - "rect": { - "x": 291.0, - "y": 120.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 180, - "element_id": 268435900, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 170, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 134.0, - "width": 300.0, - "height": 194.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 181, - "element_id": 268435894, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 170, - "base_layout_id": 0, - "rect": { - "x": 169.0, - "y": 318.0, - "width": 120.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 182, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 181, - "base_layout_id": 0, - "rect": { - "x": 169.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 183, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 181, - "base_layout_id": 0, - "rect": { - "x": 201.0, - "y": 318.0, - "width": 56.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 184, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 181, - "base_layout_id": 0, - "rect": { - "x": 257.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 185, - "element_id": 268435901, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 170, - "base_layout_id": 0, - "rect": { - "x": 40.0, - "y": 318.0, - "width": 120.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 186, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 185, - "base_layout_id": 0, - "rect": { - "x": 40.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 187, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 185, - "base_layout_id": 0, - "rect": { - "x": 72.0, - "y": 318.0, - "width": 56.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 188, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 185, - "base_layout_id": 0, - "rect": { - "x": 128.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 189, - "element_id": 268435902, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 156, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668042, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668042, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 190, - "element_id": 268435903, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 189, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 191, - "element_id": 268435904, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 189, - "base_layout_id": 0, - "rect": { - "x": 37.0, - "y": 318.0, - "width": 236.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 192, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 191, - "base_layout_id": 0, - "rect": { - "x": 37.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 193, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 191, - "base_layout_id": 0, - "rect": { - "x": 69.0, - "y": 318.0, - "width": 172.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 194, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 191, - "base_layout_id": 0, - "rect": { - "x": 241.0, - "y": 318.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 195, - "element_id": 268435850, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 196, - "element_id": 268435708, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 195, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 197, - "element_id": 268435709, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 195, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 198, - "element_id": 268435710, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 197, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 199, - "element_id": 268435906, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 195, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 200, - "element_id": 268435907, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 199, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 30.0, - "width": 284.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 201, - "element_id": 268435851, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 202, - "element_id": 268435917, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 201, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 28.0, - "width": 224.0, - "height": 214.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 203, - "element_id": 268435925, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 28.0, - "width": 224.0, - "height": 214.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 204, - "element_id": 268435926, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 56.0, - "y": 28.0, - "width": 100.0, - "height": 214.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 205, - "element_id": 268436589, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 204, - "base_layout_id": 0, - "rect": { - "x": 89.0, - "y": 96.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100667897, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435520, - "image": { - "image_id": 100667897, - "alpha_image_id": null - } - }, - { - "state_id": 268435521, - "image": { - "image_id": 100667896, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 206, - "element_id": 268436926, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 47.0, - "y": 218.0, - "width": 120.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 207, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 206, - "base_layout_id": 0, - "rect": { - "x": 47.0, - "y": 219.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 208, - "element_id": 268435930, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 36.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 209, - "element_id": 268436878, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 72.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 210, - "element_id": 268436969, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 197.0, - "y": 72.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 211, - "element_id": 268436885, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 131.0, - "y": 36.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 212, - "element_id": 268436886, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 163.0, - "y": 36.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 213, - "element_id": 268436887, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 195.0, - "y": 36.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 214, - "element_id": 268435931, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 161.0, - "y": 108.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 215, - "element_id": 268435932, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 161.0, - "y": 144.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 216, - "element_id": 268435933, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 108.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 217, - "element_id": 268435934, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 144.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 218, - "element_id": 268435935, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 161.0, - "y": 200.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 219, - "element_id": 268435936, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 195.0, - "y": 200.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 220, - "element_id": 268435937, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 200.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 221, - "element_id": 268435938, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 197.0, - "y": 108.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 222, - "element_id": 268435939, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 197.0, - "y": 144.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 223, - "element_id": 268436907, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 89.0, - "y": 56.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 224, - "element_id": 268436908, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 89.0, - "y": 92.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 225, - "element_id": 268436909, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 89.0, - "y": 128.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 226, - "element_id": 268436910, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 53.0, - "y": 92.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 227, - "element_id": 268436911, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 53.0, - "y": 128.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 228, - "element_id": 268436912, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 53.0, - "y": 164.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 229, - "element_id": 268436913, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 125.0, - "y": 128.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 230, - "element_id": 268436914, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 125.0, - "y": 164.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 231, - "element_id": 268436915, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 202, - "base_layout_id": 0, - "rect": { - "x": 125.0, - "y": 200.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 232, - "element_id": 268435918, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 201, - "base_layout_id": 0, - "rect": { - "x": 244.0, - "y": 28.0, - "width": 61.0, - "height": 339.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 233, - "element_id": 268435927, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 232, - "base_layout_id": 0, - "rect": { - "x": 244.0, - "y": 35.0, - "width": 36.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 234, - "element_id": 268435928, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 232, - "base_layout_id": 0, - "rect": { - "x": 244.0, - "y": 46.0, - "width": 36.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 235, - "element_id": 268435929, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 232, - "base_layout_id": 0, - "rect": { - "x": 288.0, - "y": 36.0, - "width": 11.0, - "height": 58.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667933, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667933, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 236, - "element_id": 2, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 235, - "base_layout_id": 0, - "rect": { - "x": 288.0, - "y": 36.0, - "width": 11.0, - "height": 61.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667932, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667932, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 237, - "element_id": 268435913, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 232, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 60.0, - "width": 36.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 238, - "element_id": 268435914, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 232, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 101.0, - "width": 36.0, - "height": 252.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 239, - "element_id": 268435915, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 232, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 101.0, - "width": 16.0, - "height": 252.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 240, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 239, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 101.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 241, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 240, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 101.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 242, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 240, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 104.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 243, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 240, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 114.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 244, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 239, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 101.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 245, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 239, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 337.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 246, - "element_id": 268435919, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 201, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 242.0, - "width": 234.0, - "height": 120.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 247, - "element_id": 268435909, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 246, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 242.0, - "width": 192.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 248, - "element_id": 268435910, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 246, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 262.0, - "width": 192.0, - "height": 96.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 249, - "element_id": 268435911, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 246, - "base_layout_id": 0, - "rect": { - "x": 212.0, - "y": 262.0, - "width": 16.0, - "height": 96.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 250, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 249, - "base_layout_id": 0, - "rect": { - "x": 212.0, - "y": 262.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 251, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 250, - "base_layout_id": 0, - "rect": { - "x": 212.0, - "y": 262.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 252, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 250, - "base_layout_id": 0, - "rect": { - "x": 212.0, - "y": 265.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 253, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 250, - "base_layout_id": 0, - "rect": { - "x": 212.0, - "y": 275.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 254, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 249, - "base_layout_id": 0, - "rect": { - "x": 212.0, - "y": 262.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 255, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 249, - "base_layout_id": 0, - "rect": { - "x": 212.0, - "y": 342.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 256, - "element_id": 268435920, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 201, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683018, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683018, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 257, - "element_id": 268435921, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 201, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 366.0, - "width": 300.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 258, - "element_id": 268435922, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 201, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 23.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - }, - { - "state_id": 1, - "image": { - "image_id": 100683020, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683021, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 259, - "element_id": 268435923, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 201, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 260, - "element_id": 268435852, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 261, - "element_id": 268435955, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 260, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 138.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 262, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 261, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 263, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 261, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 5.0, - "width": 104.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 264, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 261, - "base_layout_id": 0, - "rect": { - "x": 126.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 265, - "element_id": 268435956, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 260, - "base_layout_id": 0, - "rect": { - "x": 143.0, - "y": 5.0, - "width": 138.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 266, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 265, - "base_layout_id": 0, - "rect": { - "x": 143.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 267, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 265, - "base_layout_id": 0, - "rect": { - "x": 160.0, - "y": 5.0, - "width": 104.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 268, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 265, - "base_layout_id": 0, - "rect": { - "x": 264.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 269, - "element_id": 268435957, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 260, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 270, - "element_id": 268435958, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 260, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 271, - "element_id": 268435945, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 270, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 272, - "element_id": 268435735, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 271, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 33.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 273, - "element_id": 268435736, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 271, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 63.0, - "width": 22.0, - "height": 272.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668017, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668017, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 274, - "element_id": 268435728, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 271, - "base_layout_id": 0, - "rect": { - "x": 27.0, - "y": 63.0, - "width": 257.0, - "height": 272.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668015, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668015, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 275, - "element_id": 268435737, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 271, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 63.0, - "width": 21.0, - "height": 272.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668018, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668018, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 276, - "element_id": 268435738, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 271, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 335.0, - "width": 300.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668016, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668016, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 277, - "element_id": 268435946, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 271, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 33.0, - "width": 300.0, - "height": 330.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 278, - "element_id": 268435947, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 277, - "base_layout_id": 0, - "rect": { - "x": 26.0, - "y": 37.0, - "width": 227.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 279, - "element_id": 268435948, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 277, - "base_layout_id": 0, - "rect": { - "x": 26.0, - "y": 69.0, - "width": 257.0, - "height": 267.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668029, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 280, - "element_id": 268435949, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 279, - "base_layout_id": 0, - "rect": { - "x": 26.0, - "y": 69.0, - "width": 17.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683024, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683024, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 281, - "element_id": 268435950, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 279, - "base_layout_id": 0, - "rect": { - "x": 26.0, - "y": 69.0, - "width": 8.0, - "height": 8.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683025, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683025, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 282, - "element_id": 268435953, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 281, - "base_layout_id": 0, - "rect": { - "x": 26.0, - "y": 69.0, - "width": 8.0, - "height": 8.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 283, - "element_id": 268436149, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 282, - "base_layout_id": 0, - "rect": { - "x": 26.0, - "y": 69.0, - "width": 8.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 284, - "element_id": 268435977, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 282, - "base_layout_id": 0, - "rect": { - "x": 26.0, - "y": 69.0, - "width": 1.0, - "height": 8.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 285, - "element_id": 268435978, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 282, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 69.0, - "width": 1.0, - "height": 8.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 286, - "element_id": 268435979, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 282, - "base_layout_id": 0, - "rect": { - "x": 26.0, - "y": 76.0, - "width": 8.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 287, - "element_id": 268435951, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 277, - "base_layout_id": 0, - "rect": { - "x": 26.0, - "y": 336.0, - "width": 257.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 288, - "element_id": 268435959, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 260, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 289, - "element_id": 268435942, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 288, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 35.0, - "width": 290.0, - "height": 590.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 290, - "element_id": 268435853, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 291, - "element_id": 268435981, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 290, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 106.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 292, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 291, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 293, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 291, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 5.0, - "width": 72.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 294, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 291, - "base_layout_id": 0, - "rect": { - "x": 94.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 295, - "element_id": 268435982, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 290, - "base_layout_id": 0, - "rect": { - "x": 111.0, - "y": 5.0, - "width": 64.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 296, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 295, - "base_layout_id": 0, - "rect": { - "x": 111.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 297, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 295, - "base_layout_id": 0, - "rect": { - "x": 128.0, - "y": 5.0, - "width": 30.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 298, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 295, - "base_layout_id": 0, - "rect": { - "x": 158.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 299, - "element_id": 268436747, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 290, - "base_layout_id": 0, - "rect": { - "x": 177.0, - "y": 5.0, - "width": 50.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 300, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 299, - "base_layout_id": 0, - "rect": { - "x": 177.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 301, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 299, - "base_layout_id": 0, - "rect": { - "x": 194.0, - "y": 5.0, - "width": 16.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 302, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 299, - "base_layout_id": 0, - "rect": { - "x": 210.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 303, - "element_id": 268435983, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 290, - "base_layout_id": 0, - "rect": { - "x": 227.0, - "y": 5.0, - "width": 56.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 304, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 303, - "base_layout_id": 0, - "rect": { - "x": 227.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 305, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 303, - "base_layout_id": 0, - "rect": { - "x": 244.0, - "y": 5.0, - "width": 22.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 306, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 303, - "base_layout_id": 0, - "rect": { - "x": 266.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 307, - "element_id": 268435984, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 290, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668035, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668035, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668034, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 308, - "element_id": 268435977, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 290, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 2.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668092, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668092, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 309, - "element_id": 268435985, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 290, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 30.0, - "width": 298.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 310, - "element_id": 268435962, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 309, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 30.0, - "width": 282.0, - "height": 297.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 311, - "element_id": 268435963, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 309, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 297.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 312, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 311, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 313, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 312, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 314, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 312, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 33.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 315, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 312, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 43.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 316, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 311, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 317, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 311, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 311.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 318, - "element_id": 268435964, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 309, - "base_layout_id": 0, - "rect": { - "x": 23.0, - "y": 331.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 319, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 318, - "base_layout_id": 0, - "rect": { - "x": 23.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 320, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 318, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 331.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 321, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 318, - "base_layout_id": 0, - "rect": { - "x": 71.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 322, - "element_id": 268435965, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 309, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 331.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 323, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 322, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 324, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 322, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 331.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 325, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 322, - "base_layout_id": 0, - "rect": { - "x": 161.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 326, - "element_id": 268435966, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 309, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 331.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 327, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 326, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 328, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 326, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 331.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 329, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 326, - "base_layout_id": 0, - "rect": { - "x": 251.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 330, - "element_id": 268435986, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 290, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 30.0, - "width": 298.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 331, - "element_id": 268435971, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 330, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 50.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 332, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 331, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 50.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 333, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 331, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 50.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 334, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 331, - "base_layout_id": 0, - "rect": { - "x": 239.0, - "y": 50.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 335, - "element_id": 268437015, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 330, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 90.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 336, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 335, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 90.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 337, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 335, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 90.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 338, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 335, - "base_layout_id": 0, - "rect": { - "x": 239.0, - "y": 90.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 339, - "element_id": 268435972, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 330, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 140.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 340, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 339, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 140.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 341, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 339, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 140.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 342, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 339, - "base_layout_id": 0, - "rect": { - "x": 239.0, - "y": 140.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 343, - "element_id": 268436940, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 330, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 180.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 344, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 343, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 180.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 345, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 343, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 180.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 346, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 343, - "base_layout_id": 0, - "rect": { - "x": 239.0, - "y": 180.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 347, - "element_id": 268435973, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 330, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 220.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 348, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 347, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 220.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 349, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 347, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 220.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 350, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 347, - "base_layout_id": 0, - "rect": { - "x": 239.0, - "y": 220.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 351, - "element_id": 268435974, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 330, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 270.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 352, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 351, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 270.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 353, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 351, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 270.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 354, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 351, - "base_layout_id": 0, - "rect": { - "x": 239.0, - "y": 270.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 355, - "element_id": 268435975, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 330, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 310.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 356, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 355, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 310.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 357, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 355, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 310.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 358, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 355, - "base_layout_id": 0, - "rect": { - "x": 239.0, - "y": 310.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 359, - "element_id": 268436748, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 290, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 30.0, - "width": 298.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 360, - "element_id": 268436749, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 359, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 30.0, - "width": 282.0, - "height": 297.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 361, - "element_id": 268435969, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 359, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 297.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 362, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 361, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 363, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 362, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 364, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 362, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 33.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 365, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 362, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 43.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 366, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 361, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 367, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 361, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 311.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 368, - "element_id": 268435964, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 359, - "base_layout_id": 0, - "rect": { - "x": 23.0, - "y": 331.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 369, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 368, - "base_layout_id": 0, - "rect": { - "x": 23.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 370, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 368, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 331.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 371, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 368, - "base_layout_id": 0, - "rect": { - "x": 71.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 372, - "element_id": 268435965, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 359, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 331.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 373, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 372, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 374, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 372, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 331.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 375, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 372, - "base_layout_id": 0, - "rect": { - "x": 161.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 376, - "element_id": 268435966, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 359, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 331.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 377, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 376, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 378, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 376, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 331.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 379, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 376, - "base_layout_id": 0, - "rect": { - "x": 251.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 380, - "element_id": 268435987, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 290, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 30.0, - "width": 298.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 381, - "element_id": 268435968, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 380, - "base_layout_id": 0, - "rect": { - "x": 7.0, - "y": 30.0, - "width": 282.0, - "height": 297.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 382, - "element_id": 268435969, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 380, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 297.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 383, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 382, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 384, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 383, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 385, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 383, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 33.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 386, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 383, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 43.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 387, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 382, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 388, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 382, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 311.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 389, - "element_id": 268435964, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 380, - "base_layout_id": 0, - "rect": { - "x": 23.0, - "y": 331.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 390, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 389, - "base_layout_id": 0, - "rect": { - "x": 23.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 391, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 389, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 331.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 392, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 389, - "base_layout_id": 0, - "rect": { - "x": 71.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 393, - "element_id": 268435965, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 380, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 331.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 394, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 393, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 395, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 393, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 331.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 396, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 393, - "base_layout_id": 0, - "rect": { - "x": 161.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 397, - "element_id": 268435966, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 380, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 331.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 398, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 397, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 399, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 397, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 331.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 400, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 397, - "base_layout_id": 0, - "rect": { - "x": 251.0, - "y": 331.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 401, - "element_id": 268435854, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 402, - "element_id": 268436008, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 401, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 403, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 402, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 404, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 402, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 5.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 405, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 402, - "base_layout_id": 0, - "rect": { - "x": 80.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 406, - "element_id": 268436009, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 401, - "base_layout_id": 0, - "rect": { - "x": 97.0, - "y": 5.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 407, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 406, - "base_layout_id": 0, - "rect": { - "x": 97.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 408, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 406, - "base_layout_id": 0, - "rect": { - "x": 114.0, - "y": 5.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 409, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 406, - "base_layout_id": 0, - "rect": { - "x": 172.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 410, - "element_id": 268436792, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 401, - "base_layout_id": 0, - "rect": { - "x": 189.0, - "y": 5.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 411, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 410, - "base_layout_id": 0, - "rect": { - "x": 189.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 412, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 410, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 5.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 413, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 410, - "base_layout_id": 0, - "rect": { - "x": 264.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 414, - "element_id": 268436010, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 401, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 415, - "element_id": 268436011, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 401, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 416, - "element_id": 268436006, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 415, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 417, - "element_id": 268436016, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 416, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 110.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 418, - "element_id": 268436017, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 417, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 230.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 419, - "element_id": 268436018, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 417, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 50.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 420, - "element_id": 268436019, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 417, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 65.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 421, - "element_id": 268436933, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 417, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 82.0, - "width": 110.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 422, - "element_id": 268436934, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 417, - "base_layout_id": 0, - "rect": { - "x": 115.0, - "y": 82.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 423, - "element_id": 268436020, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 417, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 100.0, - "width": 130.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 424, - "element_id": 268436021, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 417, - "base_layout_id": 0, - "rect": { - "x": 135.0, - "y": 100.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 425, - "element_id": 268436022, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 417, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 118.0, - "width": 230.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667814, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667814, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 426, - "element_id": 2, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 425, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 119.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667813, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667813, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 427, - "element_id": 268436023, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 425, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 118.0, - "width": 130.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 428, - "element_id": 268436024, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 425, - "base_layout_id": 0, - "rect": { - "x": 135.0, - "y": 118.0, - "width": 100.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 429, - "element_id": 268436025, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 417, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 30.0, - "width": 5.0, - "height": 105.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682936, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682936, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 430, - "element_id": 268436026, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 417, - "base_layout_id": 0, - "rect": { - "x": 240.0, - "y": 30.0, - "width": 65.0, - "height": 35.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 431, - "element_id": 268436027, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 417, - "base_layout_id": 0, - "rect": { - "x": 240.0, - "y": 65.0, - "width": 65.0, - "height": 50.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 432, - "element_id": 268436028, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 416, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 135.0, - "width": 300.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 433, - "element_id": 268436029, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 416, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 142.0, - "width": 300.0, - "height": 160.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 434, - "element_id": 268436030, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 416, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 142.0, - "width": 16.0, - "height": 160.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 435, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 434, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 142.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 436, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 435, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 142.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 437, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 435, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 145.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 438, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 435, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 155.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 439, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 434, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 142.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 440, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 434, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 286.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 441, - "element_id": 268436031, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 416, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 302.0, - "width": 300.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 442, - "element_id": 268436032, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 416, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 443, - "element_id": 268436046, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 442, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 444, - "element_id": 268436034, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 442, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 332.0, - "width": 195.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 445, - "element_id": 268436035, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 442, - "base_layout_id": 0, - "rect": { - "x": 205.0, - "y": 332.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 446, - "element_id": 268436036, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 442, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 349.0, - "width": 195.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 447, - "element_id": 268436037, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 442, - "base_layout_id": 0, - "rect": { - "x": 205.0, - "y": 349.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 448, - "element_id": 268436033, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 416, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 449, - "element_id": 268436046, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 448, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 450, - "element_id": 268436034, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 448, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 332.0, - "width": 145.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 451, - "element_id": 268436035, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 448, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 332.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 452, - "element_id": 268436036, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 448, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 349.0, - "width": 145.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 453, - "element_id": 268436037, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 448, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 349.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 454, - "element_id": 268436038, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 448, - "base_layout_id": 0, - "rect": { - "x": 265.0, - "y": 338.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682935, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682934, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682935, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 455, - "element_id": 268436971, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 448, - "base_layout_id": 0, - "rect": { - "x": 265.0, - "y": 312.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100692268, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100692267, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100692268, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 456, - "element_id": 268436039, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 416, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 457, - "element_id": 268436046, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 456, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 458, - "element_id": 268436034, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 456, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 332.0, - "width": 145.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 459, - "element_id": 268436039, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 456, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 332.0, - "width": 240.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667814, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667814, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 460, - "element_id": 2, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 459, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 333.0, - "width": 240.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667274, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667274, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 461, - "element_id": 268436035, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 456, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 332.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 462, - "element_id": 268436036, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 456, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 349.0, - "width": 145.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 463, - "element_id": 268436037, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 456, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 349.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 464, - "element_id": 268436038, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 456, - "base_layout_id": 0, - "rect": { - "x": 265.0, - "y": 338.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682935, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682934, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682935, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 465, - "element_id": 268436971, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 456, - "base_layout_id": 0, - "rect": { - "x": 265.0, - "y": 312.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100692268, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100692267, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100692268, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 466, - "element_id": 268436012, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 401, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 467, - "element_id": 268436006, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 466, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 468, - "element_id": 268436016, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 467, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 110.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 469, - "element_id": 268436017, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 468, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 230.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 470, - "element_id": 268436018, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 468, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 50.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 471, - "element_id": 268436019, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 468, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 65.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 472, - "element_id": 268436933, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 468, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 82.0, - "width": 110.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 473, - "element_id": 268436934, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 468, - "base_layout_id": 0, - "rect": { - "x": 115.0, - "y": 82.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 474, - "element_id": 268436020, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 468, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 100.0, - "width": 130.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 475, - "element_id": 268436021, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 468, - "base_layout_id": 0, - "rect": { - "x": 135.0, - "y": 100.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 476, - "element_id": 268436022, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 468, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 118.0, - "width": 230.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667814, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667814, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 477, - "element_id": 2, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 476, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 119.0, - "width": 230.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667813, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667813, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 478, - "element_id": 268436023, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 476, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 118.0, - "width": 130.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 479, - "element_id": 268436024, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 476, - "base_layout_id": 0, - "rect": { - "x": 135.0, - "y": 118.0, - "width": 100.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 480, - "element_id": 268436025, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 468, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 30.0, - "width": 5.0, - "height": 105.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682936, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682936, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 481, - "element_id": 268436026, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 468, - "base_layout_id": 0, - "rect": { - "x": 240.0, - "y": 30.0, - "width": 65.0, - "height": 35.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 482, - "element_id": 268436027, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 468, - "base_layout_id": 0, - "rect": { - "x": 240.0, - "y": 65.0, - "width": 65.0, - "height": 50.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 483, - "element_id": 268436028, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 467, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 135.0, - "width": 300.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 484, - "element_id": 268436029, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 467, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 142.0, - "width": 300.0, - "height": 160.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 485, - "element_id": 268436030, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 467, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 142.0, - "width": 16.0, - "height": 160.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 486, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 485, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 142.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 487, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 486, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 142.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 488, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 486, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 145.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 489, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 486, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 155.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 490, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 485, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 142.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 491, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 485, - "base_layout_id": 0, - "rect": { - "x": 286.0, - "y": 286.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 492, - "element_id": 268436031, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 467, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 302.0, - "width": 300.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 493, - "element_id": 268436032, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 467, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 494, - "element_id": 268436046, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 493, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 495, - "element_id": 268436034, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 493, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 332.0, - "width": 195.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 496, - "element_id": 268436035, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 493, - "base_layout_id": 0, - "rect": { - "x": 205.0, - "y": 332.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 497, - "element_id": 268436036, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 493, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 349.0, - "width": 195.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 498, - "element_id": 268436037, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 493, - "base_layout_id": 0, - "rect": { - "x": 205.0, - "y": 349.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 499, - "element_id": 268436033, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 467, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 500, - "element_id": 268436046, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 499, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 501, - "element_id": 268436034, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 499, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 332.0, - "width": 145.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 502, - "element_id": 268436035, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 499, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 332.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 503, - "element_id": 268436036, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 499, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 349.0, - "width": 145.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 504, - "element_id": 268436037, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 499, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 349.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 505, - "element_id": 268436038, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 499, - "base_layout_id": 0, - "rect": { - "x": 265.0, - "y": 338.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682935, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682934, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682935, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 506, - "element_id": 268436971, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 499, - "base_layout_id": 0, - "rect": { - "x": 265.0, - "y": 312.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100692268, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100692267, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100692268, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 507, - "element_id": 268436039, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 467, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 55.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 508, - "element_id": 268436046, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 507, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 312.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 509, - "element_id": 268436034, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 507, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 332.0, - "width": 145.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 510, - "element_id": 268436039, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 507, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 332.0, - "width": 240.0, - "height": 17.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667814, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667814, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 511, - "element_id": 2, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 510, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 333.0, - "width": 240.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100667274, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100667274, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 512, - "element_id": 268436035, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 507, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 332.0, - "width": 95.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 513, - "element_id": 268436036, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 507, - "base_layout_id": 0, - "rect": { - "x": 10.0, - "y": 349.0, - "width": 145.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 514, - "element_id": 268436037, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 507, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 349.0, - "width": 95.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 515, - "element_id": 268436038, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 507, - "base_layout_id": 0, - "rect": { - "x": 265.0, - "y": 338.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682935, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682934, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682935, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 516, - "element_id": 268436971, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 507, - "base_layout_id": 0, - "rect": { - "x": 265.0, - "y": 312.0, - "width": 30.0, - "height": 26.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100692268, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100692267, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100692268, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 517, - "element_id": 268436793, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 401, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 518, - "element_id": 268436782, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 517, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 50.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 519, - "element_id": 268436783, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 517, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 70.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 520, - "element_id": 268436784, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 517, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 521, - "element_id": 268436785, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 517, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 100.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 522, - "element_id": 268436786, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 517, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 120.0, - "width": 270.0, - "height": 192.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 523, - "element_id": 268436787, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 517, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 120.0, - "width": 16.0, - "height": 192.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 524, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 523, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 120.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 525, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 524, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 120.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 526, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 524, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 123.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 527, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 524, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 133.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 528, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 523, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 120.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 529, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 523, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 296.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 530, - "element_id": 268436788, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 517, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 317.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 531, - "element_id": 268436789, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 517, - "base_layout_id": 0, - "rect": { - "x": 58.0, - "y": 327.0, - "width": 200.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 532, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 531, - "base_layout_id": 0, - "rect": { - "x": 58.0, - "y": 327.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 533, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 531, - "base_layout_id": 0, - "rect": { - "x": 90.0, - "y": 327.0, - "width": 136.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 534, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 531, - "base_layout_id": 0, - "rect": { - "x": 226.0, - "y": 327.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 535, - "element_id": 268436825, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 536, - "element_id": 268436947, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 535, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 537, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 536, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 538, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 536, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 5.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 539, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 536, - "base_layout_id": 0, - "rect": { - "x": 80.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 540, - "element_id": 268436832, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 535, - "base_layout_id": 0, - "rect": { - "x": 97.0, - "y": 5.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 541, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 540, - "base_layout_id": 0, - "rect": { - "x": 97.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 542, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 540, - "base_layout_id": 0, - "rect": { - "x": 114.0, - "y": 5.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 543, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 540, - "base_layout_id": 0, - "rect": { - "x": 172.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 544, - "element_id": 268436833, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 535, - "base_layout_id": 0, - "rect": { - "x": 189.0, - "y": 5.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 545, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 544, - "base_layout_id": 0, - "rect": { - "x": 189.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 546, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 544, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 5.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 547, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 544, - "base_layout_id": 0, - "rect": { - "x": 264.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 548, - "element_id": 268436834, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 535, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 549, - "element_id": 268436948, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 535, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 550, - "element_id": 268436942, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 38.0, - "width": 80.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 551, - "element_id": 268436950, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 165.0, - "y": 38.0, - "width": 80.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 552, - "element_id": 268436943, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 60.0, - "width": 270.0, - "height": 135.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 553, - "element_id": 268436944, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 60.0, - "width": 16.0, - "height": 135.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 554, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 553, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 60.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 555, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 554, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 60.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 556, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 554, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 63.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 557, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 554, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 73.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 558, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 553, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 60.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 559, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 553, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 179.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 560, - "element_id": 268436952, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 199.0, - "width": 56.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 561, - "element_id": 268436959, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 69.0, - "y": 199.0, - "width": 232.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 562, - "element_id": 268436953, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 219.0, - "width": 56.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 563, - "element_id": 268436960, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 69.0, - "y": 219.0, - "width": 232.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 564, - "element_id": 268436954, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 239.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 565, - "element_id": 268436961, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 129.0, - "y": 239.0, - "width": 168.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 566, - "element_id": 268436955, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 259.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 567, - "element_id": 268436962, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 129.0, - "y": 259.0, - "width": 168.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 568, - "element_id": 268436958, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 285.0, - "width": 270.0, - "height": 52.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 569, - "element_id": 268436957, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 335.0, - "width": 56.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 570, - "element_id": 268436963, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 69.0, - "y": 335.0, - "width": 150.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 571, - "element_id": 268436956, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 549, - "base_layout_id": 0, - "rect": { - "x": 237.0, - "y": 335.0, - "width": 60.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 572, - "element_id": 268436835, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 535, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100672186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100672186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 573, - "element_id": 268435728, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668015, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668015, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 574, - "element_id": 268435735, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 573, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 33.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 575, - "element_id": 268435736, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 573, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 63.0, - "width": 22.0, - "height": 272.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668017, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668017, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 576, - "element_id": 268435728, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 573, - "base_layout_id": 0, - "rect": { - "x": 27.0, - "y": 63.0, - "width": 257.0, - "height": 272.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668015, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668015, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 577, - "element_id": 268435737, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 573, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 63.0, - "width": 21.0, - "height": 272.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668018, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668018, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 578, - "element_id": 268435738, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 573, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 335.0, - "width": 300.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668016, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668016, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 579, - "element_id": 268436837, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 54.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668009, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668009, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668010, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100668011, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 580, - "element_id": 268436838, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 240.0, - "y": 335.0, - "width": 65.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668006, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668006, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668007, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100668008, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 581, - "element_id": 268436839, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 44.0, - "width": 60.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 582, - "element_id": 268436840, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 131.0, - "y": 44.0, - "width": 44.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 583, - "element_id": 268436841, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 169.0, - "y": 44.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 584, - "element_id": 268436842, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 70.0, - "width": 40.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 585, - "element_id": 268436843, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 70.0, - "width": 234.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 586, - "element_id": 268436844, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 96.0, - "width": 50.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 587, - "element_id": 268436845, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 114.0, - "width": 256.0, - "height": 167.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 588, - "element_id": 268436846, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 277.0, - "y": 114.0, - "width": 16.0, - "height": 167.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 589, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 588, - "base_layout_id": 0, - "rect": { - "x": 277.0, - "y": 114.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 590, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 589, - "base_layout_id": 0, - "rect": { - "x": 277.0, - "y": 114.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 591, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 589, - "base_layout_id": 0, - "rect": { - "x": 277.0, - "y": 117.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 592, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 589, - "base_layout_id": 0, - "rect": { - "x": 277.0, - "y": 127.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 593, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 588, - "base_layout_id": 0, - "rect": { - "x": 277.0, - "y": 114.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 594, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 588, - "base_layout_id": 0, - "rect": { - "x": 277.0, - "y": 265.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 595, - "element_id": 268436847, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 331.0, - "width": 60.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 596, - "element_id": 268436848, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 105.0, - "y": 331.0, - "width": 100.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 597, - "element_id": 268436849, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 205.0, - "y": 331.0, - "width": 60.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 598, - "element_id": 268436850, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 287.0, - "width": 64.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 599, - "element_id": 268436851, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 89.0, - "y": 287.0, - "width": 150.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 600, - "element_id": 268436852, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 287.0, - "width": 52.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 601, - "element_id": 268436853, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 309.0, - "width": 64.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 602, - "element_id": 268436854, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 89.0, - "y": 309.0, - "width": 20.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 603, - "element_id": 268436855, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 111.0, - "y": 309.0, - "width": 10.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 604, - "element_id": 268436856, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 125.0, - "y": 309.0, - "width": 20.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 605, - "element_id": 268436857, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 147.0, - "y": 309.0, - "width": 10.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 606, - "element_id": 268436858, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 161.0, - "y": 309.0, - "width": 20.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 607, - "element_id": 268436859, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 183.0, - "y": 309.0, - "width": 10.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 608, - "element_id": 268436860, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 89.0, - "y": 309.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 609, - "element_id": 268436861, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 572, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 309.0, - "width": 40.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 610, - "element_id": 268436836, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 535, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 611, - "element_id": 268436863, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 610, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 38.0, - "width": 20.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 612, - "element_id": 268436864, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 610, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 38.0, - "width": 90.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 613, - "element_id": 268436865, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 610, - "base_layout_id": 0, - "rect": { - "x": 123.0, - "y": 38.0, - "width": 90.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 614, - "element_id": 268436866, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 610, - "base_layout_id": 0, - "rect": { - "x": 213.0, - "y": 38.0, - "width": 80.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 615, - "element_id": 268436867, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 610, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 60.0, - "width": 270.0, - "height": 267.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 616, - "element_id": 268436868, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 610, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 60.0, - "width": 16.0, - "height": 267.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 617, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 616, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 60.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 618, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 617, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 60.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 619, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 617, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 63.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 620, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 617, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 73.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 621, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 616, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 60.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 622, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 616, - "base_layout_id": 0, - "rect": { - "x": 283.0, - "y": 311.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 623, - "element_id": 268436869, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 610, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 335.0, - "width": 60.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 624, - "element_id": 268436870, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 610, - "base_layout_id": 0, - "rect": { - "x": 81.0, - "y": 335.0, - "width": 50.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 625, - "element_id": 268436871, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 610, - "base_layout_id": 0, - "rect": { - "x": 135.0, - "y": 335.0, - "width": 118.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 626, - "element_id": 268436872, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 610, - "base_layout_id": 0, - "rect": { - "x": 255.0, - "y": 335.0, - "width": 42.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 627, - "element_id": 268435855, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 628, - "element_id": 268436108, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 627, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 72.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 629, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 628, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 630, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 628, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 5.0, - "width": 38.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 631, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 628, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 632, - "element_id": 268436110, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 627, - "base_layout_id": 0, - "rect": { - "x": 77.0, - "y": 5.0, - "width": 72.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 633, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 632, - "base_layout_id": 0, - "rect": { - "x": 77.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 634, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 632, - "base_layout_id": 0, - "rect": { - "x": 94.0, - "y": 5.0, - "width": 38.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 635, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 632, - "base_layout_id": 0, - "rect": { - "x": 132.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 636, - "element_id": 268436754, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 627, - "base_layout_id": 0, - "rect": { - "x": 149.0, - "y": 5.0, - "width": 62.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 637, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 636, - "base_layout_id": 0, - "rect": { - "x": 149.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 638, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 636, - "base_layout_id": 0, - "rect": { - "x": 166.0, - "y": 5.0, - "width": 28.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 639, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 636, - "base_layout_id": 0, - "rect": { - "x": 194.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 640, - "element_id": 268436795, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 627, - "base_layout_id": 0, - "rect": { - "x": 211.0, - "y": 5.0, - "width": 70.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 641, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 640, - "base_layout_id": 0, - "rect": { - "x": 211.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 642, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 640, - "base_layout_id": 0, - "rect": { - "x": 228.0, - "y": 5.0, - "width": 36.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 643, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 640, - "base_layout_id": 0, - "rect": { - "x": 264.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 644, - "element_id": 268436112, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 627, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668445, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668445, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668446, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 645, - "element_id": 268436113, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 627, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 646, - "element_id": 268436048, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 645, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 45.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 647, - "element_id": 268436049, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 646, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 648, - "element_id": 268436050, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 646, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 48.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 649, - "element_id": 268436051, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 646, - "base_layout_id": 0, - "rect": { - "x": 125.0, - "y": 48.0, - "width": 280.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 650, - "element_id": 268436052, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 646, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 66.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 651, - "element_id": 268436053, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 645, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 75.0, - "width": 300.0, - "height": 63.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 652, - "element_id": 268436054, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 651, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 75.0, - "width": 210.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 653, - "element_id": 268436055, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 651, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 93.0, - "width": 210.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 654, - "element_id": 268436624, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 651, - "base_layout_id": 0, - "rect": { - "x": 215.0, - "y": 75.0, - "width": 90.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 655, - "element_id": 268436625, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 654, - "base_layout_id": 0, - "rect": { - "x": 215.0, - "y": 75.0, - "width": 90.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 656, - "element_id": 268436626, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 654, - "base_layout_id": 0, - "rect": { - "x": 215.0, - "y": 93.0, - "width": 90.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 657, - "element_id": 268436056, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 651, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 111.0, - "width": 280.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 658, - "element_id": 268436057, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 651, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 129.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 659, - "element_id": 268436058, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 645, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 138.0, - "width": 300.0, - "height": 45.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 660, - "element_id": 268436059, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 659, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 138.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 661, - "element_id": 268436060, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 659, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 156.0, - "width": 280.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 662, - "element_id": 268436624, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 659, - "base_layout_id": 0, - "rect": { - "x": 225.0, - "y": 138.0, - "width": 80.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 663, - "element_id": 268436625, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 662, - "base_layout_id": 0, - "rect": { - "x": 225.0, - "y": 138.0, - "width": 80.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 664, - "element_id": 268436626, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 662, - "base_layout_id": 0, - "rect": { - "x": 225.0, - "y": 156.0, - "width": 80.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 665, - "element_id": 268436061, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 659, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 174.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 666, - "element_id": 268436062, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 645, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 183.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 667, - "element_id": 268436063, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 645, - "base_layout_id": 0, - "rect": { - "x": 184.0, - "y": 183.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 668, - "element_id": 268436064, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 645, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 201.0, - "width": 279.0, - "height": 87.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 669, - "element_id": 268436065, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 645, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 201.0, - "width": 16.0, - "height": 87.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 670, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 669, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 201.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 671, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 670, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 201.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 672, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 670, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 204.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 673, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 670, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 214.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 674, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 669, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 201.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 675, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 669, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 272.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 676, - "element_id": 268436066, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 645, - "base_layout_id": 0, - "rect": { - "x": 14.0, - "y": 302.0, - "width": 275.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 677, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 676, - "base_layout_id": 0, - "rect": { - "x": 14.0, - "y": 303.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 678, - "element_id": 268436067, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 645, - "base_layout_id": 0, - "rect": { - "x": 14.0, - "y": 329.0, - "width": 88.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 679, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 678, - "base_layout_id": 0, - "rect": { - "x": 14.0, - "y": 329.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 680, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 678, - "base_layout_id": 0, - "rect": { - "x": 46.0, - "y": 329.0, - "width": 24.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 681, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 678, - "base_layout_id": 0, - "rect": { - "x": 70.0, - "y": 329.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 682, - "element_id": 268436068, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 645, - "base_layout_id": 0, - "rect": { - "x": 111.0, - "y": 329.0, - "width": 88.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 683, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 682, - "base_layout_id": 0, - "rect": { - "x": 111.0, - "y": 329.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 684, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 682, - "base_layout_id": 0, - "rect": { - "x": 143.0, - "y": 329.0, - "width": 24.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 685, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 682, - "base_layout_id": 0, - "rect": { - "x": 167.0, - "y": 329.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 686, - "element_id": 268436069, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 645, - "base_layout_id": 0, - "rect": { - "x": 208.0, - "y": 329.0, - "width": 88.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 687, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 686, - "base_layout_id": 0, - "rect": { - "x": 208.0, - "y": 329.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 688, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 686, - "base_layout_id": 0, - "rect": { - "x": 240.0, - "y": 329.0, - "width": 24.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 689, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 686, - "base_layout_id": 0, - "rect": { - "x": 264.0, - "y": 329.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 690, - "element_id": 268436114, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 627, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 691, - "element_id": 268436075, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 690, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 692, - "element_id": 268436076, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 691, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 48.0, - "width": 284.0, - "height": 120.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 693, - "element_id": 268436077, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 691, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 221.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 694, - "element_id": 268436078, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 691, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 230.0, - "width": 148.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 695, - "element_id": 268436079, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 691, - "base_layout_id": 0, - "rect": { - "x": 153.0, - "y": 230.0, - "width": 147.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 696, - "element_id": 268436080, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 691, - "base_layout_id": 0, - "rect": { - "x": 30.0, - "y": 255.0, - "width": 275.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 697, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 696, - "base_layout_id": 0, - "rect": { - "x": 30.0, - "y": 256.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 698, - "element_id": 268436081, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 691, - "base_layout_id": 0, - "rect": { - "x": 30.0, - "y": 269.0, - "width": 275.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 699, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 698, - "base_layout_id": 0, - "rect": { - "x": 30.0, - "y": 270.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 700, - "element_id": 268436082, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 691, - "base_layout_id": 0, - "rect": { - "x": 30.0, - "y": 283.0, - "width": 275.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 701, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 700, - "base_layout_id": 0, - "rect": { - "x": 30.0, - "y": 284.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 702, - "element_id": 268436083, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 691, - "base_layout_id": 0, - "rect": { - "x": 30.0, - "y": 297.0, - "width": 275.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 703, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 702, - "base_layout_id": 0, - "rect": { - "x": 30.0, - "y": 298.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 704, - "element_id": 268436084, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 691, - "base_layout_id": 0, - "rect": { - "x": 38.0, - "y": 328.0, - "width": 234.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 705, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 704, - "base_layout_id": 0, - "rect": { - "x": 38.0, - "y": 328.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 706, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 704, - "base_layout_id": 0, - "rect": { - "x": 70.0, - "y": 328.0, - "width": 170.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 707, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 704, - "base_layout_id": 0, - "rect": { - "x": 240.0, - "y": 328.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 708, - "element_id": 268436085, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 690, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 709, - "element_id": 268436086, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 708, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 32.0, - "width": 300.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 710, - "element_id": 268436087, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 708, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 52.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 711, - "element_id": 268436088, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 708, - "base_layout_id": 0, - "rect": { - "x": 184.0, - "y": 52.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 712, - "element_id": 268436089, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 708, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 68.0, - "width": 271.0, - "height": 224.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 713, - "element_id": 268436090, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 708, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 68.0, - "width": 16.0, - "height": 224.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 714, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 713, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 68.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 715, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 714, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 68.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 716, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 714, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 71.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 717, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 714, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 81.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 718, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 713, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 68.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 719, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 713, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 276.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 720, - "element_id": 268436091, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 708, - "base_layout_id": 0, - "rect": { - "x": 23.0, - "y": 301.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 721, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 720, - "base_layout_id": 0, - "rect": { - "x": 23.0, - "y": 301.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 722, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 720, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 301.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 723, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 720, - "base_layout_id": 0, - "rect": { - "x": 70.0, - "y": 301.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 724, - "element_id": 268436092, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 708, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 301.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 725, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 724, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 301.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 726, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 724, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 301.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 727, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 724, - "base_layout_id": 0, - "rect": { - "x": 160.0, - "y": 301.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 728, - "element_id": 268436093, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 708, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 301.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 729, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 728, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 301.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 730, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 728, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 301.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 731, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 728, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 301.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 732, - "element_id": 268436094, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 708, - "base_layout_id": 0, - "rect": { - "x": 23.0, - "y": 334.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 733, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 732, - "base_layout_id": 0, - "rect": { - "x": 23.0, - "y": 334.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 734, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 732, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 334.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 735, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 732, - "base_layout_id": 0, - "rect": { - "x": 70.0, - "y": 334.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 736, - "element_id": 268436095, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 708, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 334.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 737, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 736, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 334.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 738, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 736, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 334.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 739, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 736, - "base_layout_id": 0, - "rect": { - "x": 160.0, - "y": 334.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 740, - "element_id": 268436096, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 708, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 334.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 741, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 740, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 334.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 742, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 740, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 334.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 743, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 740, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 334.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 744, - "element_id": 268436810, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 627, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 745, - "element_id": 268436797, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 744, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 50.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 746, - "element_id": 268436766, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 744, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 50.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 747, - "element_id": 268436798, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 744, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 70.0, - "width": 270.0, - "height": 167.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 748, - "element_id": 268436803, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 744, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 70.0, - "width": 16.0, - "height": 167.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 749, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 748, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 70.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 750, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 749, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 70.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 751, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 749, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 73.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 752, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 749, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 83.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 753, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 748, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 70.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 754, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 748, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 221.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 755, - "element_id": 268436807, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 744, - "base_layout_id": 0, - "rect": { - "x": 105.0, - "y": 247.0, - "width": 100.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 756, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 755, - "base_layout_id": 0, - "rect": { - "x": 105.0, - "y": 247.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 757, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 755, - "base_layout_id": 0, - "rect": { - "x": 137.0, - "y": 247.0, - "width": 36.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 758, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 755, - "base_layout_id": 0, - "rect": { - "x": 173.0, - "y": 247.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 759, - "element_id": 268436808, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 744, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 287.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 760, - "element_id": 268436799, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 744, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 303.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 761, - "element_id": 268436800, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 744, - "base_layout_id": 0, - "rect": { - "x": 105.0, - "y": 303.0, - "width": 184.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 762, - "element_id": 268436811, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 744, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 329.0, - "width": 130.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 763, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 762, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 329.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 764, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 762, - "base_layout_id": 0, - "rect": { - "x": 57.0, - "y": 329.0, - "width": 66.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 765, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 762, - "base_layout_id": 0, - "rect": { - "x": 123.0, - "y": 329.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 766, - "element_id": 268436812, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 744, - "base_layout_id": 0, - "rect": { - "x": 160.0, - "y": 329.0, - "width": 130.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 767, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 766, - "base_layout_id": 0, - "rect": { - "x": 160.0, - "y": 329.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 768, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 766, - "base_layout_id": 0, - "rect": { - "x": 192.0, - "y": 329.0, - "width": 66.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 769, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 766, - "base_layout_id": 0, - "rect": { - "x": 258.0, - "y": 329.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 770, - "element_id": 268436755, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 627, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 771, - "element_id": 268436765, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 50.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 772, - "element_id": 268436766, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 50.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 773, - "element_id": 268436759, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 13.0, - "y": 70.0, - "width": 270.0, - "height": 137.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 774, - "element_id": 268436760, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 70.0, - "width": 16.0, - "height": 137.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 775, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 774, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 70.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 776, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 775, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 70.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 777, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 775, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 73.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 778, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 775, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 83.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 779, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 774, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 70.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 780, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 774, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 191.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 781, - "element_id": 268436052, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 212.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 782, - "element_id": 268436780, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 87.0, - "y": 227.0, - "width": 120.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 783, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 782, - "base_layout_id": 0, - "rect": { - "x": 87.0, - "y": 228.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 784, - "element_id": 268436758, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 58.0, - "y": 247.0, - "width": 88.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 785, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 784, - "base_layout_id": 0, - "rect": { - "x": 58.0, - "y": 247.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 786, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 784, - "base_layout_id": 0, - "rect": { - "x": 90.0, - "y": 247.0, - "width": 24.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 787, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 784, - "base_layout_id": 0, - "rect": { - "x": 114.0, - "y": 247.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 788, - "element_id": 268436757, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 247.0, - "width": 88.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 789, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 788, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 247.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 790, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 788, - "base_layout_id": 0, - "rect": { - "x": 187.0, - "y": 247.0, - "width": 24.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 791, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 788, - "base_layout_id": 0, - "rect": { - "x": 211.0, - "y": 247.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 792, - "element_id": 268436061, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 287.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 793, - "element_id": 268436764, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 303.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 794, - "element_id": 268436763, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 105.0, - "y": 303.0, - "width": 184.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 795, - "element_id": 268436756, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 770, - "base_layout_id": 0, - "rect": { - "x": 111.0, - "y": 329.0, - "width": 88.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 796, - "element_id": 268436174, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 795, - "base_layout_id": 0, - "rect": { - "x": 111.0, - "y": 329.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 797, - "element_id": 268436175, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 795, - "base_layout_id": 0, - "rect": { - "x": 143.0, - "y": 329.0, - "width": 24.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 798, - "element_id": 268436176, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 795, - "base_layout_id": 0, - "rect": { - "x": 167.0, - "y": 329.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 799, - "element_id": 268435856, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 800, - "element_id": 268436137, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 799, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 138.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 801, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 800, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 802, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 800, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 5.0, - "width": 104.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 803, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 800, - "base_layout_id": 0, - "rect": { - "x": 126.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 804, - "element_id": 268436138, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 799, - "base_layout_id": 0, - "rect": { - "x": 143.0, - "y": 5.0, - "width": 138.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 805, - "element_id": 268436537, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 804, - "base_layout_id": 0, - "rect": { - "x": 143.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 806, - "element_id": 268435689, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 804, - "base_layout_id": 0, - "rect": { - "x": 160.0, - "y": 5.0, - "width": 104.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 807, - "element_id": 268435989, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 804, - "base_layout_id": 0, - "rect": { - "x": 264.0, - "y": 5.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 808, - "element_id": 268436139, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 799, - "base_layout_id": 0, - "rect": { - "x": 281.0, - "y": 5.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 809, - "element_id": 268436140, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 799, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 810, - "element_id": 268436117, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 809, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 280.0, - "height": 224.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 811, - "element_id": 268436118, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 809, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 30.0, - "width": 16.0, - "height": 224.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 812, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 811, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 813, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 812, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 30.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 814, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 812, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 33.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 815, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 812, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 43.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 816, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 811, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 817, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 811, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 238.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 818, - "element_id": 268436119, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 809, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 254.0, - "width": 300.0, - "height": 113.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100673314, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100673314, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 819, - "element_id": 268436120, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 276.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 820, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 819, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 277.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 821, - "element_id": 268436121, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 92.0, - "y": 276.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 822, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 821, - "base_layout_id": 0, - "rect": { - "x": 92.0, - "y": 277.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 823, - "element_id": 268436122, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 142.0, - "y": 276.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 824, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 823, - "base_layout_id": 0, - "rect": { - "x": 142.0, - "y": 277.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 825, - "element_id": 268436123, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 192.0, - "y": 276.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 826, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 825, - "base_layout_id": 0, - "rect": { - "x": 192.0, - "y": 277.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 827, - "element_id": 268436928, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 242.0, - "y": 276.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 828, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 827, - "base_layout_id": 0, - "rect": { - "x": 242.0, - "y": 277.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 829, - "element_id": 268436124, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 309.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 830, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 829, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 310.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 831, - "element_id": 268436125, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 87.0, - "y": 309.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 832, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 831, - "base_layout_id": 0, - "rect": { - "x": 87.0, - "y": 310.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 833, - "element_id": 268436126, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 152.0, - "y": 309.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 834, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 833, - "base_layout_id": 0, - "rect": { - "x": 152.0, - "y": 310.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 835, - "element_id": 268436127, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 326.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 836, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 835, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 327.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 837, - "element_id": 268436128, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 87.0, - "y": 326.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 838, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 837, - "base_layout_id": 0, - "rect": { - "x": 87.0, - "y": 327.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 839, - "element_id": 268436129, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 152.0, - "y": 326.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 840, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 839, - "base_layout_id": 0, - "rect": { - "x": 152.0, - "y": 327.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 841, - "element_id": 268436130, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 343.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 842, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 841, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 344.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 843, - "element_id": 268436814, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 87.0, - "y": 343.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 844, - "element_id": 268436264, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 843, - "base_layout_id": 0, - "rect": { - "x": 87.0, - "y": 344.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 845, - "element_id": 268436131, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 260.0, - "width": 90.0, - "height": 19.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 846, - "element_id": 268436132, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 293.0, - "width": 90.0, - "height": 19.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 847, - "element_id": 268436133, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 818, - "base_layout_id": 0, - "rect": { - "x": 192.0, - "y": 315.0, - "width": 100.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682970, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682970, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682971, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 848, - "element_id": 268436134, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 847, - "base_layout_id": 0, - "rect": { - "x": 192.0, - "y": 315.0, - "width": 100.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 849, - "element_id": 268436141, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 799, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 850, - "element_id": 268436580, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 849, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 284.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 851, - "element_id": 268436581, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 849, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 852, - "element_id": 1, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 851, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 853, - "element_id": 268436324, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 852, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 854, - "element_id": 268436325, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 852, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 33.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 855, - "element_id": 268436326, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 852, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 43.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 856, - "element_id": 268435569, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 851, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 30.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 857, - "element_id": 268435570, - "layout_id": 553648238, - "parent_layout_id": 553648238, - "parent_traversal_index": 851, - "base_layout_id": 0, - "rect": { - "x": 289.0, - "y": 351.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268435954, - "slug": "map", - "title": "Map", - "bucket": "MainPanel", - "parent_slug": "main_panel", - "width": 640, - "height": 620, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268435954, - "layout_id": 553648167, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 600.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268435955, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 138.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 2, - "element_id": 268436537, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268435689, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 0.0, - "width": 104.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268435989, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 121.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268435956, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 138.0, - "y": 0.0, - "width": 138.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 6, - "element_id": 268436537, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 138.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268435689, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 0.0, - "width": 104.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268435989, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 259.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268435957, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268435958, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 11, - "element_id": 268435945, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 10, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 12, - "element_id": 268435735, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 11, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 33.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268435736, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 11, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 58.0, - "width": 22.0, - "height": 510.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668017, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668017, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268435728, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 11, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 58.0, - "width": 257.0, - "height": 510.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668015, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668015, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268435737, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 11, - "base_layout_id": 0, - "rect": { - "x": 279.0, - "y": 58.0, - "width": 21.0, - "height": 510.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668018, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668018, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268435738, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 11, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 568.0, - "width": 300.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668016, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668016, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435946, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 11, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 147.0, - "width": 300.0, - "height": 330.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 18, - "element_id": 268435947, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 151.0, - "width": 227.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 19, - "element_id": 268435948, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 183.0, - "width": 257.0, - "height": 267.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668029, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435949, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 183.0, - "width": 17.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683024, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683024, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268435950, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 183.0, - "width": 8.0, - "height": 8.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683025, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683025, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268435953, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 183.0, - "width": 8.0, - "height": 8.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 23, - "element_id": 268436149, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 22, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 183.0, - "width": 8.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268435977, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 22, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 183.0, - "width": 1.0, - "height": 8.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 268435978, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 22, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 183.0, - "width": 1.0, - "height": 8.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 268435979, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 22, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 190.0, - "width": 8.0, - "height": 1.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682953, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682953, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268435951, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 450.0, - "width": 257.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 28, - "element_id": 268435959, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268435942, - "layout_id": 553648167, - "parent_layout_id": 553648167, - "parent_traversal_index": 28, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 30.0, - "width": 290.0, - "height": 590.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268435845, - "slug": "negative_effects", - "title": "Negative Effects", - "bucket": "MainPanel", - "parent_slug": "main_panel", - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268435743, - "layout_id": 553648155, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268435708, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268435744, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268435747, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 249.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 4, - "element_id": 268435748, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 249.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 4, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 6, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 30.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 40.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 4, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 4, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 260.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268435749, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 274.0, - "width": 300.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668309, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668309, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268435750, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 11, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 282.0, - "width": 280.0, - "height": 78.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 13, - "element_id": 268435751, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 11, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 15, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 277.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 287.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 346.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435745, - "layout_id": 553648155, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 21, - "element_id": 268435708, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268435744, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268435747, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 249.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 24, - "element_id": 268435748, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 249.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 26, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 30.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 40.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 260.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 31, - "element_id": 268435749, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 274.0, - "width": 300.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668309, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668309, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268435750, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 282.0, - "width": 280.0, - "height": 78.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 33, - "element_id": 268435751, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 34, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 33, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 35, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 36, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 277.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 37, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 287.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 33, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 33, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 346.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 40, - "element_id": 268435746, - "layout_id": 553648155, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 41, - "element_id": 268435708, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 42, - "element_id": 268435744, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 43, - "element_id": 268435747, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 249.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 44, - "element_id": 268435748, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 249.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 44, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 46, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 47, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 30.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 48, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 40.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 44, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 50, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 44, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 260.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268435749, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 274.0, - "width": 300.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668309, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668309, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 52, - "element_id": 268435750, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 282.0, - "width": 280.0, - "height": 78.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 53, - "element_id": 268435751, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 54, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 55, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 56, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 277.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 57, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 287.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 58, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 59, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 346.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 60, - "element_id": 268435752, - "layout_id": 553648155, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668310, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668310, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100668311, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 61, - "element_id": 268435753, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 62, - "element_id": 268435754, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 37.0, - "y": 0.0, - "width": 188.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 63, - "element_id": 268435755, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 225.0, - "y": 0.0, - "width": 50.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268435976, - "slug": "options", - "title": "Options", - "bucket": "MainPanel", - "parent_slug": "main_panel", - "width": 640, - "height": 600, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268435976, - "layout_id": 553648171, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 600.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268435981, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 106.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 2, - "element_id": 268436537, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268435689, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 0.0, - "width": 72.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268435989, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 89.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268435982, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 106.0, - "y": 0.0, - "width": 64.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 6, - "element_id": 268436537, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 106.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268435689, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 123.0, - "y": 0.0, - "width": 30.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268435989, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 153.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268436747, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 172.0, - "y": 0.0, - "width": 50.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 10, - "element_id": 268436537, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 172.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268435689, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 189.0, - "y": 0.0, - "width": 16.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268435989, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 205.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268435983, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 222.0, - "y": 0.0, - "width": 56.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 14, - "element_id": 268436537, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 222.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268435689, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 239.0, - "y": 0.0, - "width": 22.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268435989, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 261.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435984, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668035, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668035, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668034, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268435977, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 2.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668092, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668092, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268435985, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 2.0, - "y": 25.0, - "width": 298.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435962, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 2.0, - "y": 25.0, - "width": 282.0, - "height": 535.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 21, - "element_id": 268435963, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 535.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 1, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 23, - "element_id": 268436324, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 22, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268436325, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 22, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 28.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 268436326, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 22, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 38.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 268435569, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268435570, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 544.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268435964, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 564.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 29, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 28, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 28, - "base_layout_id": 0, - "rect": { - "x": 50.0, - "y": 564.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 31, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 28, - "base_layout_id": 0, - "rect": { - "x": 66.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268435965, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 108.0, - "y": 564.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 33, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 108.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 34, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 140.0, - "y": 564.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 35, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 156.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 36, - "element_id": 268435966, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 198.0, - "y": 564.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 37, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 36, - "base_layout_id": 0, - "rect": { - "x": 198.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 36, - "base_layout_id": 0, - "rect": { - "x": 230.0, - "y": 564.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 36, - "base_layout_id": 0, - "rect": { - "x": 246.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 40, - "element_id": 268435986, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 2.0, - "y": 25.0, - "width": 298.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 41, - "element_id": 268435971, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 45.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 42, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 45.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 43, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 45.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 44, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 234.0, - "y": 45.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 268437015, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 85.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 46, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 85.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 47, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 85.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 48, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 234.0, - "y": 85.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 268435972, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 135.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 50, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 49, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 135.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 49, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 135.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 52, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 49, - "base_layout_id": 0, - "rect": { - "x": 234.0, - "y": 135.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 53, - "element_id": 268436940, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 175.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 54, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 175.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 55, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 175.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 56, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 234.0, - "y": 175.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 57, - "element_id": 268435973, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 215.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 58, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 57, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 215.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 59, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 57, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 215.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 60, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 57, - "base_layout_id": 0, - "rect": { - "x": 234.0, - "y": 215.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 61, - "element_id": 268435974, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 265.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 62, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 61, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 265.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 63, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 61, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 265.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 64, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 61, - "base_layout_id": 0, - "rect": { - "x": 234.0, - "y": 265.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 65, - "element_id": 268435975, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 305.0, - "width": 238.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 66, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 65, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 305.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693161, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693161, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693160, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693159, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 67, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 65, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 305.0, - "width": 174.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693164, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693164, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693163, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693162, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 68, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 65, - "base_layout_id": 0, - "rect": { - "x": 234.0, - "y": 305.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100693167, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100693167, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100693166, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100693165, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 69, - "element_id": 268436748, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 2.0, - "y": 25.0, - "width": 298.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 70, - "element_id": 268436749, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 69, - "base_layout_id": 0, - "rect": { - "x": 2.0, - "y": 25.0, - "width": 282.0, - "height": 535.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 71, - "element_id": 268435969, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 69, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 535.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 72, - "element_id": 1, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 71, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 73, - "element_id": 268436324, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 72, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 74, - "element_id": 268436325, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 72, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 28.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 75, - "element_id": 268436326, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 72, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 38.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 76, - "element_id": 268435569, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 71, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 77, - "element_id": 268435570, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 71, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 544.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 78, - "element_id": 268435964, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 69, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 564.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 79, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 78, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 80, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 78, - "base_layout_id": 0, - "rect": { - "x": 50.0, - "y": 564.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 81, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 78, - "base_layout_id": 0, - "rect": { - "x": 66.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 82, - "element_id": 268435965, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 69, - "base_layout_id": 0, - "rect": { - "x": 108.0, - "y": 564.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 83, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 108.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 84, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 140.0, - "y": 564.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 85, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 156.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 86, - "element_id": 268435966, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 69, - "base_layout_id": 0, - "rect": { - "x": 198.0, - "y": 564.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 87, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 86, - "base_layout_id": 0, - "rect": { - "x": 198.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 88, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 86, - "base_layout_id": 0, - "rect": { - "x": 230.0, - "y": 564.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 89, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 86, - "base_layout_id": 0, - "rect": { - "x": 246.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 90, - "element_id": 268435987, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 2.0, - "y": 25.0, - "width": 298.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 91, - "element_id": 268435968, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 90, - "base_layout_id": 0, - "rect": { - "x": 2.0, - "y": 25.0, - "width": 282.0, - "height": 535.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 92, - "element_id": 268435969, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 90, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 535.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 93, - "element_id": 1, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 92, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 94, - "element_id": 268436324, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 93, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 95, - "element_id": 268436325, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 93, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 28.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 96, - "element_id": 268436326, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 93, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 38.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 97, - "element_id": 268435569, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 92, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 98, - "element_id": 268435570, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 92, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 544.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 99, - "element_id": 268435964, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 90, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 564.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 100, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 99, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 101, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 99, - "base_layout_id": 0, - "rect": { - "x": 50.0, - "y": 564.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 102, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 99, - "base_layout_id": 0, - "rect": { - "x": 66.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 103, - "element_id": 268435965, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 90, - "base_layout_id": 0, - "rect": { - "x": 108.0, - "y": 564.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 104, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 103, - "base_layout_id": 0, - "rect": { - "x": 108.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 105, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 103, - "base_layout_id": 0, - "rect": { - "x": 140.0, - "y": 564.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 106, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 103, - "base_layout_id": 0, - "rect": { - "x": 156.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 107, - "element_id": 268435966, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 90, - "base_layout_id": 0, - "rect": { - "x": 198.0, - "y": 564.0, - "width": 80.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 108, - "element_id": 268436174, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 107, - "base_layout_id": 0, - "rect": { - "x": 198.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 109, - "element_id": 268436175, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 107, - "base_layout_id": 0, - "rect": { - "x": 230.0, - "y": 564.0, - "width": 16.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 110, - "element_id": 268436176, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 107, - "base_layout_id": 0, - "rect": { - "x": 246.0, - "y": 564.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 111, - "element_id": 268435990, - "layout_id": 553648171, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 292.0, - "height": 22.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 112, - "element_id": 268435991, - "layout_id": 553648171, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 292.0, - "height": 8.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668101, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668101, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 113, - "element_id": 268435992, - "layout_id": 553648171, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 272.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 114, - "element_id": 268435993, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 113, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 2.0, - "width": 260.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 115, - "element_id": 268436264, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 114, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 3.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 116, - "element_id": 268435994, - "layout_id": 553648171, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 272.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 117, - "element_id": 268435995, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 2.0, - "width": 130.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 118, - "element_id": 268435996, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 116, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 4.0, - "width": 120.0, - "height": 12.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668037, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668037, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 119, - "element_id": 1, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 118, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 4.0, - "width": 7.0, - "height": 12.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668038, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668038, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 120, - "element_id": 268435997, - "layout_id": 553648171, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 272.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 121, - "element_id": 268435995, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 120, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 2.0, - "width": 130.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 122, - "element_id": 268435996, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 120, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 4.0, - "width": 120.0, - "height": 12.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668037, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668037, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 123, - "element_id": 1, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 122, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 4.0, - "width": 7.0, - "height": 12.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668038, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668038, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 124, - "element_id": 268435998, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 120, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 18.0, - "width": 60.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 125, - "element_id": 268435999, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 120, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 18.0, - "width": 60.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 126, - "element_id": 268436000, - "layout_id": 553648171, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 272.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 127, - "element_id": 268435993, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 126, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 2.0, - "width": 130.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 128, - "element_id": 268436264, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 127, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 3.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 129, - "element_id": 268435996, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 126, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 4.0, - "width": 120.0, - "height": 12.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668037, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668037, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 130, - "element_id": 1, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 129, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 4.0, - "width": 7.0, - "height": 12.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668038, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668038, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 131, - "element_id": 268436001, - "layout_id": 553648171, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 272.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 132, - "element_id": 268435993, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 131, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 2.0, - "width": 130.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 133, - "element_id": 268436264, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 132, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 3.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 134, - "element_id": 268435996, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 131, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 4.0, - "width": 120.0, - "height": 12.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668037, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668037, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 135, - "element_id": 1, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 134, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 4.0, - "width": 7.0, - "height": 12.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668038, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668038, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 136, - "element_id": 268435998, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 131, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 18.0, - "width": 60.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 137, - "element_id": 268435999, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 131, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 18.0, - "width": 60.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 138, - "element_id": 268436002, - "layout_id": 553648171, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 272.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 139, - "element_id": 268436003, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 138, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 2.0, - "width": 130.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 140, - "element_id": 268436004, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 138, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 0.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 141, - "element_id": 268436309, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 140, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 0.0, - "width": 103.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668083, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668083, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 142, - "element_id": 268436310, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 140, - "base_layout_id": 0, - "rect": { - "x": 249.0, - "y": 0.0, - "width": 17.0, - "height": 19.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668081, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668081, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100668081, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100668082, - "alpha_image_id": null - } - }, - { - "state_id": 7, - "image": { - "image_id": 100668082, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 143, - "element_id": 268436768, - "layout_id": 553648171, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 272.0, - "height": 100.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 144, - "element_id": 268436769, - "layout_id": 553648171, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 272.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 145, - "element_id": 268435993, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 144, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 2.0, - "width": 260.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 146, - "element_id": 268436264, - "layout_id": 553648171, - "parent_layout_id": 553648171, - "parent_traversal_index": 145, - "base_layout_id": 0, - "rect": { - "x": 6.0, - "y": 3.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268435844, - "slug": "positive_effects", - "title": "Positive Effects", - "bucket": "MainPanel", - "parent_slug": "main_panel", - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268435743, - "layout_id": 553648155, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268435708, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268435744, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268435747, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 249.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 4, - "element_id": 268435748, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 249.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 4, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 6, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 30.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 40.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 4, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 4, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 260.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268435749, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 274.0, - "width": 300.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668309, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668309, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268435750, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 11, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 282.0, - "width": 280.0, - "height": 78.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 13, - "element_id": 268435751, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 11, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 15, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 277.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 287.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 346.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435745, - "layout_id": 553648155, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 21, - "element_id": 268435708, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268435744, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268435747, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 249.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 24, - "element_id": 268435748, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 249.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 26, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 30.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 40.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 260.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 31, - "element_id": 268435749, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 274.0, - "width": 300.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668309, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668309, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268435750, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 282.0, - "width": 280.0, - "height": 78.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 33, - "element_id": 268435751, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 34, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 33, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 35, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 36, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 277.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 37, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 287.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 33, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 33, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 346.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 40, - "element_id": 268435746, - "layout_id": 553648155, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 41, - "element_id": 268435708, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 42, - "element_id": 268435744, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 43, - "element_id": 268435747, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 249.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 44, - "element_id": 268435748, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 249.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 44, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 46, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 47, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 30.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 48, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 45, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 40.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 44, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 27.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 50, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 44, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 260.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268435749, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 274.0, - "width": 300.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668309, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668309, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 52, - "element_id": 268435750, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 282.0, - "width": 280.0, - "height": 78.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 53, - "element_id": 268435751, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 88.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 54, - "element_id": 1, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 55, - "element_id": 268436324, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 56, - "element_id": 268436325, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 277.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 57, - "element_id": 268436326, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 287.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 58, - "element_id": 268435569, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 274.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 59, - "element_id": 268435570, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 346.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 60, - "element_id": 268435752, - "layout_id": 553648155, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668310, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668310, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100668311, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 61, - "element_id": 268435753, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 62, - "element_id": 268435754, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 37.0, - "y": 0.0, - "width": 188.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 63, - "element_id": 268435755, - "layout_id": 553648155, - "parent_layout_id": 553648155, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 225.0, - "y": 0.0, - "width": 50.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268436831, - "slug": "quests", - "title": "Quests", - "bucket": "MainPanel", - "parent_slug": "main_panel", - "width": 640, - "height": 600, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436831, - "layout_id": 553648227, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 600.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268436947, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 2, - "element_id": 268436537, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268435689, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 0.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268435989, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 75.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268436832, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 92.0, - "y": 0.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 6, - "element_id": 268436537, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 92.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268435689, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 109.0, - "y": 0.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268435989, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 167.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268436833, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 184.0, - "y": 0.0, - "width": 92.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 10, - "element_id": 268436537, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 184.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268435689, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 201.0, - "y": 0.0, - "width": 58.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268435989, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 259.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268436834, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268436948, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268436942, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 33.0, - "width": 80.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 16, - "element_id": 268436950, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 160.0, - "y": 33.0, - "width": 80.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 17, - "element_id": 268436943, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 55.0, - "width": 270.0, - "height": 373.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268436944, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 55.0, - "width": 16.0, - "height": 373.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 1, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 55.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 20, - "element_id": 268436324, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 55.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268436325, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 58.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268436326, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 68.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268435569, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 55.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268435570, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 412.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 268436952, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 432.0, - "width": 56.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 26, - "element_id": 268436959, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 64.0, - "y": 432.0, - "width": 232.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 27, - "element_id": 268436953, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 452.0, - "width": 56.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 28, - "element_id": 268436960, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 64.0, - "y": 452.0, - "width": 232.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 29, - "element_id": 268436954, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 472.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 30, - "element_id": 268436961, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 124.0, - "y": 472.0, - "width": 168.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 31, - "element_id": 268436955, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 492.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 32, - "element_id": 268436962, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 124.0, - "y": 492.0, - "width": 168.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 33, - "element_id": 268436958, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 518.0, - "width": 270.0, - "height": 52.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 34, - "element_id": 268436957, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 568.0, - "width": 56.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 35, - "element_id": 268436963, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 64.0, - "y": 568.0, - "width": 150.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 36, - "element_id": 268436956, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 14, - "base_layout_id": 0, - "rect": { - "x": 232.0, - "y": 568.0, - "width": 60.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 37, - "element_id": 268436835, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100672186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100672186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268435728, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668015, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668015, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268435735, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 33.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 40, - "element_id": 268435736, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 58.0, - "width": 22.0, - "height": 510.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668017, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668017, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 41, - "element_id": 268435728, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 22.0, - "y": 58.0, - "width": 257.0, - "height": 510.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668015, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668015, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 42, - "element_id": 268435737, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 279.0, - "y": 58.0, - "width": 21.0, - "height": 510.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668018, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668018, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 43, - "element_id": 268435738, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 568.0, - "width": 300.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668016, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668016, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 44, - "element_id": 268436837, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 54.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668009, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668009, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668010, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100668011, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 268436838, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 568.0, - "width": 65.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668006, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668006, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668007, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100668008, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 46, - "element_id": 268436839, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 50.0, - "y": 39.0, - "width": 60.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 47, - "element_id": 268436840, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 126.0, - "y": 39.0, - "width": 44.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 48, - "element_id": 268436841, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 164.0, - "y": 39.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 268436842, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 16.0, - "y": 65.0, - "width": 40.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 50, - "element_id": 268436843, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 50.0, - "y": 65.0, - "width": 234.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268436844, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 16.0, - "y": 91.0, - "width": 50.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 52, - "element_id": 268436845, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 16.0, - "y": 109.0, - "width": 256.0, - "height": 405.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 53, - "element_id": 268436846, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 272.0, - "y": 109.0, - "width": 16.0, - "height": 405.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 54, - "element_id": 1, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 272.0, - "y": 109.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 55, - "element_id": 268436324, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 272.0, - "y": 109.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 56, - "element_id": 268436325, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 272.0, - "y": 112.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 57, - "element_id": 268436326, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 272.0, - "y": 122.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 58, - "element_id": 268435569, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 272.0, - "y": 109.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 59, - "element_id": 268435570, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 272.0, - "y": 498.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 60, - "element_id": 268436847, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 564.0, - "width": 60.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 61, - "element_id": 268436848, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 100.0, - "y": 564.0, - "width": 100.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 62, - "element_id": 268436849, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 200.0, - "y": 564.0, - "width": 60.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 63, - "element_id": 268436850, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 16.0, - "y": 520.0, - "width": 64.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 64, - "element_id": 268436851, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 84.0, - "y": 520.0, - "width": 150.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 65, - "element_id": 268436852, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 240.0, - "y": 520.0, - "width": 52.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 66, - "element_id": 268436853, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 16.0, - "y": 542.0, - "width": 64.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 67, - "element_id": 268436854, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 84.0, - "y": 542.0, - "width": 20.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 68, - "element_id": 268436855, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 106.0, - "y": 542.0, - "width": 10.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 69, - "element_id": 268436856, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 120.0, - "y": 542.0, - "width": 20.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 70, - "element_id": 268436857, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 142.0, - "y": 542.0, - "width": 10.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 71, - "element_id": 268436858, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 156.0, - "y": 542.0, - "width": 20.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682954, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682954, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 72, - "element_id": 268436859, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 178.0, - "y": 542.0, - "width": 10.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 73, - "element_id": 268436860, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 84.0, - "y": 542.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 74, - "element_id": 268436861, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 37, - "base_layout_id": 0, - "rect": { - "x": 240.0, - "y": 542.0, - "width": 40.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 75, - "element_id": 268436836, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 76, - "element_id": 268436863, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 33.0, - "width": 20.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 77, - "element_id": 268436864, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 28.0, - "y": 33.0, - "width": 90.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 78, - "element_id": 268436865, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 118.0, - "y": 33.0, - "width": 90.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 79, - "element_id": 268436866, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 208.0, - "y": 33.0, - "width": 80.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 80, - "element_id": 268436867, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 55.0, - "width": 270.0, - "height": 505.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 81, - "element_id": 268436868, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 55.0, - "width": 16.0, - "height": 505.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 82, - "element_id": 1, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 55.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 83, - "element_id": 268436324, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 55.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 84, - "element_id": 268436325, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 58.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 85, - "element_id": 268436326, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 68.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 86, - "element_id": 268435569, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 55.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 87, - "element_id": 268435570, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 278.0, - "y": 544.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 88, - "element_id": 268436869, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 568.0, - "width": 60.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 89, - "element_id": 268436870, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 76.0, - "y": 568.0, - "width": 50.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 90, - "element_id": 268436871, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 130.0, - "y": 568.0, - "width": 118.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 91, - "element_id": 268436872, - "layout_id": 553648227, - "parent_layout_id": 553648227, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 250.0, - "y": 568.0, - "width": 42.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268436107, - "slug": "social", - "title": "Social", - "bucket": "MainPanel", - "parent_slug": "main_panel", - "width": 640, - "height": 600, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436107, - "layout_id": 553648177, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 600.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268436108, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 72.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 2, - "element_id": 268436537, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268435689, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 0.0, - "width": 38.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268435989, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268436110, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 72.0, - "y": 0.0, - "width": 72.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 6, - "element_id": 268436537, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 72.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268435689, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 89.0, - "y": 0.0, - "width": 38.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268435989, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 127.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268436754, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 144.0, - "y": 0.0, - "width": 62.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 10, - "element_id": 268436537, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 144.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268435689, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 161.0, - "y": 0.0, - "width": 28.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268435989, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 9, - "base_layout_id": 0, - "rect": { - "x": 189.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268436795, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 0.0, - "width": 70.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 14, - "element_id": 268436537, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268435689, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 223.0, - "y": 0.0, - "width": 36.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268435989, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 259.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268436112, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668445, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668445, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668446, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268436113, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268436048, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 45.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 20, - "element_id": 268436049, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 21, - "element_id": 268436050, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 43.0, - "width": 120.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 22, - "element_id": 268436051, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 120.0, - "y": 43.0, - "width": 280.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 23, - "element_id": 268436052, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 61.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268436053, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 70.0, - "width": 300.0, - "height": 63.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 25, - "element_id": 268436054, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 70.0, - "width": 210.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 26, - "element_id": 268436055, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 88.0, - "width": 210.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 27, - "element_id": 268436624, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 210.0, - "y": 70.0, - "width": 90.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 28, - "element_id": 268436625, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 27, - "base_layout_id": 0, - "rect": { - "x": 210.0, - "y": 70.0, - "width": 90.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 29, - "element_id": 268436626, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 27, - "base_layout_id": 0, - "rect": { - "x": 210.0, - "y": 88.0, - "width": 90.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 30, - "element_id": 268436056, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 106.0, - "width": 280.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 31, - "element_id": 268436057, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 124.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268436058, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 133.0, - "width": 300.0, - "height": 45.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 33, - "element_id": 268436059, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 133.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 34, - "element_id": 268436060, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 151.0, - "width": 280.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 35, - "element_id": 268436624, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 220.0, - "y": 133.0, - "width": 80.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 36, - "element_id": 268436625, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 220.0, - "y": 133.0, - "width": 80.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 37, - "element_id": 268436626, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 220.0, - "y": 151.0, - "width": 80.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 38, - "element_id": 268436061, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 169.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268436062, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 178.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 40, - "element_id": 268436063, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 179.0, - "y": 178.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 41, - "element_id": 268436064, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 196.0, - "width": 279.0, - "height": 325.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 42, - "element_id": 268436065, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 196.0, - "width": 16.0, - "height": 325.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 43, - "element_id": 1, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 196.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 44, - "element_id": 268436324, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 196.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 268436325, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 199.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 46, - "element_id": 268436326, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 43, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 209.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 47, - "element_id": 268435569, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 196.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 48, - "element_id": 268435570, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 505.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 268436066, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 535.0, - "width": 275.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 50, - "element_id": 268436264, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 49, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 536.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268436067, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 562.0, - "width": 88.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 52, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 9.0, - "y": 562.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 53, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 41.0, - "y": 562.0, - "width": 24.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 54, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 51, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 562.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 55, - "element_id": 268436068, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 106.0, - "y": 562.0, - "width": 88.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 56, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 55, - "base_layout_id": 0, - "rect": { - "x": 106.0, - "y": 562.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 57, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 55, - "base_layout_id": 0, - "rect": { - "x": 138.0, - "y": 562.0, - "width": 24.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 58, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 55, - "base_layout_id": 0, - "rect": { - "x": 162.0, - "y": 562.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 59, - "element_id": 268436069, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 562.0, - "width": 88.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 60, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 59, - "base_layout_id": 0, - "rect": { - "x": 203.0, - "y": 562.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 61, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 59, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 562.0, - "width": 24.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 62, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 59, - "base_layout_id": 0, - "rect": { - "x": 259.0, - "y": 562.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 63, - "element_id": 268436114, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 64, - "element_id": 268436075, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 63, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 65, - "element_id": 268436076, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 43.0, - "width": 284.0, - "height": 120.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 66, - "element_id": 268436077, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 216.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 67, - "element_id": 268436078, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 225.0, - "width": 148.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 68, - "element_id": 268436079, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 148.0, - "y": 225.0, - "width": 147.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 69, - "element_id": 268436080, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 250.0, - "width": 275.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 70, - "element_id": 268436264, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 69, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 251.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 71, - "element_id": 268436081, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 264.0, - "width": 275.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 72, - "element_id": 268436264, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 71, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 265.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 73, - "element_id": 268436082, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 278.0, - "width": 275.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 74, - "element_id": 268436264, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 73, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 279.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 75, - "element_id": 268436083, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 292.0, - "width": 275.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 76, - "element_id": 268436264, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 75, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 293.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 77, - "element_id": 268436084, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 323.0, - "width": 234.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 78, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 77, - "base_layout_id": 0, - "rect": { - "x": 33.0, - "y": 323.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 79, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 77, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 323.0, - "width": 170.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 80, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 77, - "base_layout_id": 0, - "rect": { - "x": 235.0, - "y": 323.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 81, - "element_id": 268436085, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 63, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 82, - "element_id": 268436086, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 27.0, - "width": 300.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 83, - "element_id": 268436087, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 47.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 84, - "element_id": 268436088, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 179.0, - "y": 47.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 85, - "element_id": 268436089, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 63.0, - "width": 271.0, - "height": 462.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 86, - "element_id": 268436090, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 279.0, - "y": 63.0, - "width": 16.0, - "height": 462.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 87, - "element_id": 1, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 86, - "base_layout_id": 0, - "rect": { - "x": 279.0, - "y": 63.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 88, - "element_id": 268436324, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 87, - "base_layout_id": 0, - "rect": { - "x": 279.0, - "y": 63.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 89, - "element_id": 268436325, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 87, - "base_layout_id": 0, - "rect": { - "x": 279.0, - "y": 66.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 90, - "element_id": 268436326, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 87, - "base_layout_id": 0, - "rect": { - "x": 279.0, - "y": 76.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 91, - "element_id": 268435569, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 86, - "base_layout_id": 0, - "rect": { - "x": 279.0, - "y": 63.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 92, - "element_id": 268435570, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 86, - "base_layout_id": 0, - "rect": { - "x": 279.0, - "y": 509.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 93, - "element_id": 268436091, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 534.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 94, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 93, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 534.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 95, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 93, - "base_layout_id": 0, - "rect": { - "x": 50.0, - "y": 534.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 96, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 93, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 534.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 97, - "element_id": 268436092, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 108.0, - "y": 534.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 98, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 97, - "base_layout_id": 0, - "rect": { - "x": 108.0, - "y": 534.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 99, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 97, - "base_layout_id": 0, - "rect": { - "x": 140.0, - "y": 534.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 100, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 97, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 534.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 101, - "element_id": 268436093, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 198.0, - "y": 534.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 102, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 101, - "base_layout_id": 0, - "rect": { - "x": 198.0, - "y": 534.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 103, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 101, - "base_layout_id": 0, - "rect": { - "x": 230.0, - "y": 534.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 104, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 101, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 534.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 105, - "element_id": 268436094, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 567.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 106, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 105, - "base_layout_id": 0, - "rect": { - "x": 18.0, - "y": 567.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 107, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 105, - "base_layout_id": 0, - "rect": { - "x": 50.0, - "y": 567.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 108, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 105, - "base_layout_id": 0, - "rect": { - "x": 65.0, - "y": 567.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 109, - "element_id": 268436095, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 108.0, - "y": 567.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 110, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 109, - "base_layout_id": 0, - "rect": { - "x": 108.0, - "y": 567.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 111, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 109, - "base_layout_id": 0, - "rect": { - "x": 140.0, - "y": 567.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 112, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 109, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 567.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 113, - "element_id": 268436096, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 198.0, - "y": 567.0, - "width": 79.0, - "height": 30.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 114, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 113, - "base_layout_id": 0, - "rect": { - "x": 198.0, - "y": 567.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 115, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 113, - "base_layout_id": 0, - "rect": { - "x": 230.0, - "y": 567.0, - "width": 15.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 116, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 113, - "base_layout_id": 0, - "rect": { - "x": 245.0, - "y": 567.0, - "width": 32.0, - "height": 30.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 117, - "element_id": 268436810, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 118, - "element_id": 268436797, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 45.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 119, - "element_id": 268436766, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 45.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 120, - "element_id": 268436798, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 65.0, - "width": 270.0, - "height": 405.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 121, - "element_id": 268436803, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 65.0, - "width": 16.0, - "height": 405.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 122, - "element_id": 1, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 121, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 65.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 123, - "element_id": 268436324, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 122, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 65.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 124, - "element_id": 268436325, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 122, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 68.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 125, - "element_id": 268436326, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 122, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 78.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 126, - "element_id": 268435569, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 121, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 65.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 127, - "element_id": 268435570, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 121, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 454.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 128, - "element_id": 268436807, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 100.0, - "y": 480.0, - "width": 100.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 129, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 128, - "base_layout_id": 0, - "rect": { - "x": 100.0, - "y": 480.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 130, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 128, - "base_layout_id": 0, - "rect": { - "x": 132.0, - "y": 480.0, - "width": 36.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 131, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 128, - "base_layout_id": 0, - "rect": { - "x": 168.0, - "y": 480.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 132, - "element_id": 268436808, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 520.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 133, - "element_id": 268436799, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 536.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 134, - "element_id": 268436800, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 100.0, - "y": 536.0, - "width": 184.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 135, - "element_id": 268436811, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 562.0, - "width": 130.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 136, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 135, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 562.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 137, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 135, - "base_layout_id": 0, - "rect": { - "x": 52.0, - "y": 562.0, - "width": 66.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 138, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 135, - "base_layout_id": 0, - "rect": { - "x": 118.0, - "y": 562.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 139, - "element_id": 268436812, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 117, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 562.0, - "width": 130.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 140, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 139, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 562.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 141, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 139, - "base_layout_id": 0, - "rect": { - "x": 187.0, - "y": 562.0, - "width": 66.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 142, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 139, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 562.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 143, - "element_id": 268436755, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 144, - "element_id": 268436765, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 45.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 145, - "element_id": 268436766, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 45.0, - "width": 270.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 146, - "element_id": 268436759, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 65.0, - "width": 270.0, - "height": 375.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 147, - "element_id": 268436760, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 65.0, - "width": 16.0, - "height": 375.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 148, - "element_id": 1, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 147, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 65.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 149, - "element_id": 268436324, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 148, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 65.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 150, - "element_id": 268436325, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 148, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 68.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 151, - "element_id": 268436326, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 148, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 78.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 152, - "element_id": 268435569, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 147, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 65.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 153, - "element_id": 268435570, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 147, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 424.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 154, - "element_id": 268436052, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 445.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 155, - "element_id": 268436780, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 82.0, - "y": 460.0, - "width": 120.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 156, - "element_id": 268436264, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 155, - "base_layout_id": 0, - "rect": { - "x": 82.0, - "y": 461.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 157, - "element_id": 268436758, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 53.0, - "y": 480.0, - "width": 88.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 158, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 157, - "base_layout_id": 0, - "rect": { - "x": 53.0, - "y": 480.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 159, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 157, - "base_layout_id": 0, - "rect": { - "x": 85.0, - "y": 480.0, - "width": 24.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 160, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 157, - "base_layout_id": 0, - "rect": { - "x": 109.0, - "y": 480.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 161, - "element_id": 268436757, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 150.0, - "y": 480.0, - "width": 88.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 162, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 161, - "base_layout_id": 0, - "rect": { - "x": 150.0, - "y": 480.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 163, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 161, - "base_layout_id": 0, - "rect": { - "x": 182.0, - "y": 480.0, - "width": 24.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 164, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 161, - "base_layout_id": 0, - "rect": { - "x": 206.0, - "y": 480.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 165, - "element_id": 268436061, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 520.0, - "width": 300.0, - "height": 9.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668448, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668448, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 166, - "element_id": 268436764, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 536.0, - "width": 100.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 167, - "element_id": 268436763, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 100.0, - "y": 536.0, - "width": 184.0, - "height": 18.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 168, - "element_id": 268436756, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 143, - "base_layout_id": 0, - "rect": { - "x": 106.0, - "y": 562.0, - "width": 88.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 169, - "element_id": 268436174, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 168, - "base_layout_id": 0, - "rect": { - "x": 106.0, - "y": 562.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 170, - "element_id": 268436175, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 168, - "base_layout_id": 0, - "rect": { - "x": 138.0, - "y": 562.0, - "width": 24.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 171, - "element_id": 268436176, - "layout_id": 553648177, - "parent_layout_id": 553648177, - "parent_traversal_index": 168, - "base_layout_id": 0, - "rect": { - "x": 162.0, - "y": 562.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268436136, - "slug": "spellbook", - "title": "Spellbook", - "bucket": "MainPanel", - "parent_slug": "main_panel", - "width": 640, - "height": 600, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436136, - "layout_id": 553648180, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 600.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268436137, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 138.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 2, - "element_id": 268436537, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268435689, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 0.0, - "width": 104.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268435989, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 1, - "base_layout_id": 0, - "rect": { - "x": 121.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268436138, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 138.0, - "y": 0.0, - "width": 138.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 6, - "element_id": 268436537, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 138.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687251, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687251, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687250, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268435689, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 0.0, - "width": 104.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687253, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687253, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687252, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268435989, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 5, - "base_layout_id": 0, - "rect": { - "x": 259.0, - "y": 0.0, - "width": 17.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100687255, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 11, - "image": { - "image_id": 100687255, - "alpha_image_id": null - } - }, - { - "state_id": 12, - "image": { - "image_id": 100687254, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268436139, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268436140, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268436117, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 10, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 280.0, - "height": 462.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 12, - "element_id": 268436118, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 10, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 25.0, - "width": 16.0, - "height": 462.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 1, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 12, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 25.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 14, - "element_id": 268436324, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 25.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268436325, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 28.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268436326, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 13, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 38.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435569, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 12, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 25.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268435570, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 12, - "base_layout_id": 0, - "rect": { - "x": 280.0, - "y": 471.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268436119, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 10, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 487.0, - "width": 300.0, - "height": 113.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100673314, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100673314, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268436120, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 509.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 21, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 510.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268436121, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 87.0, - "y": 509.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 23, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 22, - "base_layout_id": 0, - "rect": { - "x": 87.0, - "y": 510.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268436122, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 137.0, - "y": 509.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 25, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 24, - "base_layout_id": 0, - "rect": { - "x": 137.0, - "y": 510.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 268436123, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 187.0, - "y": 509.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 27, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 26, - "base_layout_id": 0, - "rect": { - "x": 187.0, - "y": 510.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268436928, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 237.0, - "y": 509.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 29, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 28, - "base_layout_id": 0, - "rect": { - "x": 237.0, - "y": 510.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268436124, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 542.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 31, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 30, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 543.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268436125, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 82.0, - "y": 542.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 33, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 32, - "base_layout_id": 0, - "rect": { - "x": 82.0, - "y": 543.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 34, - "element_id": 268436126, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 147.0, - "y": 542.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 35, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 147.0, - "y": 543.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 36, - "element_id": 268436127, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 559.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 37, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 36, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 560.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268436128, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 82.0, - "y": 559.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 39, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 38, - "base_layout_id": 0, - "rect": { - "x": 82.0, - "y": 560.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 40, - "element_id": 268436129, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 147.0, - "y": 559.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 41, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 147.0, - "y": 560.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 42, - "element_id": 268436130, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 576.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 43, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 17.0, - "y": 577.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 44, - "element_id": 268436814, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 82.0, - "y": 576.0, - "width": 90.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 45, - "element_id": 268436264, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 44, - "base_layout_id": 0, - "rect": { - "x": 82.0, - "y": 577.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 46, - "element_id": 268436131, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 12.0, - "y": 493.0, - "width": 90.0, - "height": 19.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 47, - "element_id": 268436132, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 12.0, - "y": 526.0, - "width": 90.0, - "height": 19.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 48, - "element_id": 268436133, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 19, - "base_layout_id": 0, - "rect": { - "x": 187.0, - "y": 548.0, - "width": 100.0, - "height": 32.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682970, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682970, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682971, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 268436134, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 48, - "base_layout_id": 0, - "rect": { - "x": 187.0, - "y": 548.0, - "width": 100.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 50, - "element_id": 268436141, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268436580, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 50, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 284.0, - "height": 575.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 52, - "element_id": 268436581, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 50, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 575.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 53, - "element_id": 1, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 52, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 54, - "element_id": 268436324, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 55, - "element_id": 268436325, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 28.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 56, - "element_id": 268436326, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 53, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 38.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 57, - "element_id": 268435569, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 52, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 25.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 58, - "element_id": 268435570, - "layout_id": 553648180, - "parent_layout_id": 553648180, - "parent_traversal_index": 52, - "base_layout_id": 0, - "rect": { - "x": 284.0, - "y": 584.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - } - ] - }, - { - "id": 268435850, - "slug": "vitae", - "title": "Vitae", - "bucket": "MainPanel", - "parent_slug": "main_panel", - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268435905, - "layout_id": 553648160, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 300.0, - "height": 362.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268435708, - "layout_id": 553648160, - "parent_layout_id": 553648160, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 276.0, - "y": 0.0, - "width": 24.0, - "height": 25.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668307, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100668307, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100668308, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268435709, - "layout_id": 553648160, - "parent_layout_id": 553648160, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683002, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683002, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268435710, - "layout_id": 553648160, - "parent_layout_id": 553648160, - "parent_traversal_index": 2, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 276.0, - "height": 25.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 4, - "element_id": 268435906, - "layout_id": 553648160, - "parent_layout_id": 553648160, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 25.0, - "width": 300.0, - "height": 337.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268435907, - "layout_id": 553648160, - "parent_layout_id": 553648160, - "parent_traversal_index": 4, - "base_layout_id": 0, - "rect": { - "x": 8.0, - "y": 25.0, - "width": 284.0, - "height": 337.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268436634, - "slug": "smartbox", - "title": "SmartBox", - "bucket": "SmartBoxContainers", - "parent_slug": null, - "width": 802, - "height": 602, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268435511, - "layout_id": 553648143, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 1.0, - "y": 1.0, - "width": 800.0, - "height": 600.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268437194, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268437195, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 792.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268437196, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 797.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268437197, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 592.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268437198, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 597.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268437199, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 597.0, - "width": 792.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268437200, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 797.0, - "y": 597.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268437201, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 797.0, - "y": 6.0, - "width": 5.0, - "height": 592.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268436534, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 1.0, - "y": 1.0, - "width": 800.0, - "height": 600.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 10, - "element_id": 268435512, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 351.0, - "y": 251.0, - "width": 100.0, - "height": 100.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 11, - "element_id": 268435513, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 10, - "base_layout_id": 0, - "rect": { - "x": 351.0, - "y": 251.0, - "width": 12.0, - "height": 12.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 12, - "element_id": 268435514, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 10, - "base_layout_id": 0, - "rect": { - "x": 439.0, - "y": 251.0, - "width": 12.0, - "height": 12.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 13, - "element_id": 268435515, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 10, - "base_layout_id": 0, - "rect": { - "x": 351.0, - "y": 339.0, - "width": 12.0, - "height": 12.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 14, - "element_id": 268435516, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 10, - "base_layout_id": 0, - "rect": { - "x": 439.0, - "y": 339.0, - "width": 12.0, - "height": 12.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 15, - "element_id": 268435524, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 576.0, - "width": 760.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 16, - "element_id": 268435508, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 15, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 576.0, - "width": 760.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683019, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683019, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268436974, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 97.0, - "y": 576.0, - "width": 608.0, - "height": 15.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100692318, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100692318, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 2, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 576.0, - "width": 760.0, - "height": 15.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100668244, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435522, - "image": { - "image_id": 100668244, - "alpha_image_id": null - } - }, - { - "state_id": 268435523, - "image": { - "image_id": 100667904, - "alpha_image_id": null - } - }, - { - "state_id": 268435524, - "image": { - "image_id": 100667904, - "alpha_image_id": null - } - }, - { - "state_id": 268435525, - "image": { - "image_id": 100669787, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268435509, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 16, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 576.0, - "width": 755.0, - "height": 15.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 20, - "element_id": 268436888, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 21.0, - "width": 500.0, - "height": 440.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 21, - "element_id": 268436890, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 21.0, - "width": 208.0, - "height": 208.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268436190, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 21.0, - "width": 34.0, - "height": 34.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682947, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682947, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268436191, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 427.0, - "y": 21.0, - "width": 34.0, - "height": 34.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682948, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682948, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268436192, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 197.0, - "width": 34.0, - "height": 35.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682949, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682949, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 268436193, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 427.0, - "y": 197.0, - "width": 34.0, - "height": 35.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682950, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682950, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 268436194, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 287.0, - "y": 21.0, - "width": 140.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268436195, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 287.0, - "y": 225.0, - "width": 140.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268435688, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 55.0, - "width": 5.0, - "height": 143.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682936, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682936, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268435690, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 456.0, - "y": 55.0, - "width": 5.0, - "height": 143.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682936, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682936, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268436891, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 257.0, - "y": 25.0, - "width": 200.0, - "height": 200.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 31, - "element_id": 268436904, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 21.0, - "width": 220.0, - "height": 440.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268436190, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 21.0, - "width": 34.0, - "height": 34.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682947, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682947, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 33, - "element_id": 268436191, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 207.0, - "y": 21.0, - "width": 34.0, - "height": 34.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682948, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682948, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 34, - "element_id": 268436192, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 429.0, - "width": 34.0, - "height": 35.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682949, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682949, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 35, - "element_id": 268436193, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 207.0, - "y": 429.0, - "width": 34.0, - "height": 35.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682950, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682950, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 36, - "element_id": 268436194, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 21.0, - "width": 152.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 37, - "element_id": 268436195, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 55.0, - "y": 457.0, - "width": 152.0, - "height": 7.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682951, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682951, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268435688, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 21.0, - "y": 55.0, - "width": 5.0, - "height": 375.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682936, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682936, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268435690, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 236.0, - "y": 55.0, - "width": 5.0, - "height": 375.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682936, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682936, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 40, - "element_id": 268436892, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 23.0, - "y": 249.0, - "width": 220.0, - "height": 220.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 41, - "element_id": 268436237, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 75.0, - "y": 303.0, - "width": 110.0, - "height": 112.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683076, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683076, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 42, - "element_id": 268436238, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 75.0, - "y": 303.0, - "width": 110.0, - "height": 112.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683077, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683077, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 43, - "element_id": 268436239, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 112.0, - "y": 255.0, - "width": 37.0, - "height": 44.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683078, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683078, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 44, - "element_id": 268436240, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 164.0, - "y": 267.0, - "width": 37.0, - "height": 44.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683078, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683078, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 268436241, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 193.0, - "y": 317.0, - "width": 37.0, - "height": 44.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683078, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683078, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 46, - "element_id": 268436242, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 186.0, - "y": 372.0, - "width": 37.0, - "height": 44.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683078, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683078, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 47, - "element_id": 268436243, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 142.0, - "y": 414.0, - "width": 37.0, - "height": 44.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683078, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683078, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 48, - "element_id": 268436244, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 80.0, - "y": 414.0, - "width": 37.0, - "height": 44.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683078, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683078, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 268436245, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 37.0, - "y": 372.0, - "width": 37.0, - "height": 44.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683078, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683078, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 50, - "element_id": 268436246, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 31.0, - "y": 317.0, - "width": 37.0, - "height": 44.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683078, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683078, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268436247, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 60.0, - "y": 267.0, - "width": 37.0, - "height": 44.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683078, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683078, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 52, - "element_id": 268436248, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 114.0, - "y": 288.0, - "width": 33.0, - "height": 29.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683079, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683079, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 53, - "element_id": 268436249, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 146.0, - "y": 298.0, - "width": 31.0, - "height": 30.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683080, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683080, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 54, - "element_id": 268436250, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 168.0, - "y": 330.0, - "width": 34.0, - "height": 34.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683081, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683081, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 55, - "element_id": 268436251, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 165.0, - "y": 369.0, - "width": 31.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683082, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683082, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 56, - "element_id": 268436252, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 131.0, - "y": 397.0, - "width": 36.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683083, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683083, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 57, - "element_id": 268436253, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 93.0, - "y": 398.0, - "width": 35.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683084, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683084, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 58, - "element_id": 268436254, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 63.0, - "y": 367.0, - "width": 29.0, - "height": 36.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683085, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683085, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 59, - "element_id": 268436255, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 59.0, - "y": 327.0, - "width": 30.0, - "height": 35.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683086, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683086, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 60, - "element_id": 268436256, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 79.0, - "y": 299.0, - "width": 32.0, - "height": 31.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683087, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683087, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 61, - "element_id": 268436257, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 40, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 314.0, - "width": 33.0, - "height": 85.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 62, - "element_id": 1, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 61, - "base_layout_id": 0, - "rect": { - "x": 113.0, - "y": 314.0, - "width": 33.0, - "height": 21.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683088, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683088, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 63, - "element_id": 268436893, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 31, - "base_layout_id": 0, - "rect": { - "x": 29.0, - "y": 25.0, - "width": 175.0, - "height": 230.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 64, - "element_id": 268436894, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 63, - "base_layout_id": 0, - "rect": { - "x": 29.0, - "y": 25.0, - "width": 200.0, - "height": 42.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 65, - "element_id": 268436234, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 109.0, - "y": 25.0, - "width": 47.0, - "height": 44.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682895, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682895, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682896, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682897, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 66, - "element_id": 268436235, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 64, - "base_layout_id": 0, - "rect": { - "x": 156.0, - "y": 25.0, - "width": 47.0, - "height": 44.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682898, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682898, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682899, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100687635, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 67, - "element_id": 268436895, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 63, - "base_layout_id": 0, - "rect": { - "x": 29.0, - "y": 69.0, - "width": 200.0, - "height": 42.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 68, - "element_id": 268436234, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 109.0, - "y": 69.0, - "width": 47.0, - "height": 44.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682895, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682895, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682896, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682897, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 69, - "element_id": 268436235, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 67, - "base_layout_id": 0, - "rect": { - "x": 156.0, - "y": 69.0, - "width": 47.0, - "height": 44.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682898, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682898, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682899, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100687635, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 70, - "element_id": 268436896, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 63, - "base_layout_id": 0, - "rect": { - "x": 29.0, - "y": 113.0, - "width": 200.0, - "height": 42.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 71, - "element_id": 268436234, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 70, - "base_layout_id": 0, - "rect": { - "x": 109.0, - "y": 113.0, - "width": 47.0, - "height": 44.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682895, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682895, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682896, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682897, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 72, - "element_id": 268436235, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 70, - "base_layout_id": 0, - "rect": { - "x": 156.0, - "y": 113.0, - "width": 47.0, - "height": 44.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682898, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682898, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682899, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100687635, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 73, - "element_id": 268436897, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 63, - "base_layout_id": 0, - "rect": { - "x": 29.0, - "y": 157.0, - "width": 200.0, - "height": 42.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 74, - "element_id": 268436234, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 73, - "base_layout_id": 0, - "rect": { - "x": 109.0, - "y": 157.0, - "width": 47.0, - "height": 44.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682895, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682895, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682896, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682897, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 75, - "element_id": 268436235, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 73, - "base_layout_id": 0, - "rect": { - "x": 156.0, - "y": 157.0, - "width": 47.0, - "height": 44.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682898, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682898, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682899, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100687635, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 76, - "element_id": 268436898, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 63, - "base_layout_id": 0, - "rect": { - "x": 29.0, - "y": 201.0, - "width": 200.0, - "height": 42.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 77, - "element_id": 268436234, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 76, - "base_layout_id": 0, - "rect": { - "x": 109.0, - "y": 201.0, - "width": 47.0, - "height": 44.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682895, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682895, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682896, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682897, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 78, - "element_id": 268436235, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 76, - "base_layout_id": 0, - "rect": { - "x": 156.0, - "y": 201.0, - "width": 47.0, - "height": 44.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682898, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682898, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682899, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100687635, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 79, - "element_id": 268436900, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 237.0, - "width": 60.0, - "height": 54.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100683089, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683089, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100683090, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100687271, - "alpha_image_id": null - } - }, - { - "state_id": 7, - "image": { - "image_id": 100687271, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 80, - "element_id": 268436901, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 403.0, - "y": 237.0, - "width": 60.0, - "height": 54.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100683091, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683091, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100683092, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100687272, - "alpha_image_id": null - } - }, - { - "state_id": 7, - "image": { - "image_id": 100687272, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 81, - "element_id": 268436937, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 295.0, - "width": 140.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 82, - "element_id": 268436264, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 81, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 296.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 83, - "element_id": 268436938, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 315.0, - "width": 140.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 84, - "element_id": 268436264, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 83, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 316.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 85, - "element_id": 268436939, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 335.0, - "width": 140.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 86, - "element_id": 268436264, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 85, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 336.0, - "width": 13.0, - "height": 13.0 - }, - "widget_kind": "Slider", - "state_set": { - "default_image": { - "image_id": 100683029, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100683029, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100683030, - "alpha_image_id": null - } - }, - { - "state_id": 6, - "image": { - "image_id": 100683031, - "alpha_image_id": null - } - }, - { - "state_id": 8, - "image": { - "image_id": 100683032, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100683033, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 87, - "element_id": 268436902, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 373.0, - "y": 295.0, - "width": 88.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 88, - "element_id": 268436174, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 87, - "base_layout_id": 0, - "rect": { - "x": 373.0, - "y": 295.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 89, - "element_id": 268436175, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 87, - "base_layout_id": 0, - "rect": { - "x": 405.0, - "y": 295.0, - "width": 24.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 90, - "element_id": 268436176, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 87, - "base_layout_id": 0, - "rect": { - "x": 429.0, - "y": 295.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 91, - "element_id": 268436903, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 20, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 295.0, - "width": 88.0, - "height": 33.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 92, - "element_id": 268436174, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 91, - "base_layout_id": 0, - "rect": { - "x": 253.0, - "y": 295.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682902, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682902, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682901, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682900, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 93, - "element_id": 268436175, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 91, - "base_layout_id": 0, - "rect": { - "x": 285.0, - "y": 295.0, - "width": 24.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682905, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682905, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682904, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682903, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 94, - "element_id": 268436176, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 91, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 295.0, - "width": 32.0, - "height": 33.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100682908, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682908, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682907, - "alpha_image_id": null - } - }, - { - "state_id": 13, - "image": { - "image_id": 100682906, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 95, - "element_id": 268435525, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 1.0, - "y": 1.0, - "width": 24.0, - "height": 24.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 96, - "element_id": 268435526, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 1.0, - "y": 21.0, - "width": 450.0, - "height": 72.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 97, - "element_id": 268435529, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 96, - "base_layout_id": 0, - "rect": { - "x": 1.0, - "y": 21.0, - "width": 450.0, - "height": 72.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 98, - "element_id": 268435527, - "layout_id": 553648143, - "parent_layout_id": 553648143, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 1.0, - "y": 1.0, - "width": 128.0, - "height": 36.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 99, - "element_id": 268437007, - "layout_id": 553648143, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 1.0, - "y": 1.0, - "width": 0.0, - "height": 0.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268436983, - "slug": "examine", - "title": "Examine", - "bucket": "VitalsExamine", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436978, - "layout_id": 553648235, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 20.0, - "width": 310.0, - "height": 400.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268437107, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 20.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693187, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693187, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268437108, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 20.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693183, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693183, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268437109, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 325.0, - "y": 20.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693188, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693188, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268437110, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 25.0, - "width": 5.0, - "height": 390.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693184, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693184, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268437111, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 415.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693189, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693189, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268437112, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 415.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693185, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693185, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268437113, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 325.0, - "y": 415.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693190, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693190, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268437114, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 325.0, - "y": 25.0, - "width": 5.0, - "height": 390.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268437115, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 20.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268437116, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 20.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268437117, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 325.0, - "y": 20.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268437118, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 25.0, - "width": 5.0, - "height": 390.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268437119, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 20.0, - "y": 415.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268437120, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 415.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268437121, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 325.0, - "y": 415.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268437122, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 325.0, - "y": 25.0, - "width": 5.0, - "height": 390.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435757, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 25.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 18, - "element_id": 268436776, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 45.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 19, - "element_id": 268436777, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 25.0, - "width": 300.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 20, - "element_id": 268436979, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 304.0, - "y": 28.0, - "width": 14.0, - "height": 14.0 - }, - "widget_kind": "Button", - "state_set": { - "default_image": { - "image_id": 100688405, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100688405, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100688406, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268435758, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 50.0, - "width": 300.0, - "height": 365.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268435765, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 336.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 268435767, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 341.0, - "width": 300.0, - "height": 74.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668015, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668015, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 24, - "element_id": 268435772, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 29.0, - "y": 50.0, - "width": 280.0, - "height": 286.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 25, - "element_id": 268435773, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 50.0, - "width": 16.0, - "height": 286.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 1, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 50.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 27, - "element_id": 268436324, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 26, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 50.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268436325, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 26, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 53.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 29, - "element_id": 268436326, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 26, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 63.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 30, - "element_id": 268435569, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 50.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 31, - "element_id": 268435570, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 25, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 320.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268436984, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 50.0, - "width": 16.0, - "height": 286.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 33, - "element_id": 268435774, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 29.0, - "y": 341.0, - "width": 276.0, - "height": 57.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 34, - "element_id": 268436590, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 341.0, - "width": 16.0, - "height": 57.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 35, - "element_id": 1, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 341.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 36, - "element_id": 268436324, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 341.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 37, - "element_id": 268436325, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 344.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268436326, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 354.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268435569, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 341.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 40, - "element_id": 268435570, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 34, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 382.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 41, - "element_id": 268435775, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 21, - "base_layout_id": 0, - "rect": { - "x": 29.0, - "y": 398.0, - "width": 292.0, - "height": 17.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 42, - "element_id": 268435776, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 50.0, - "width": 300.0, - "height": 365.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 43, - "element_id": 268435777, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 50.0, - "width": 300.0, - "height": 365.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 44, - "element_id": 268435781, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 255.0, - "y": 50.0, - "width": 7.0, - "height": 60.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100668102, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100668102, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 268435782, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 110.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 46, - "element_id": 268436577, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 300.0, - "width": 300.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 47, - "element_id": 268435784, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 118.0, - "width": 300.0, - "height": 265.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 48, - "element_id": 268435785, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 118.0, - "width": 300.0, - "height": 265.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 49, - "element_id": 268436794, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 308.0, - "width": 292.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 50, - "element_id": 268436277, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 328.0, - "width": 292.0, - "height": 87.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 51, - "element_id": 268435786, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 262.0, - "y": 52.0, - "width": 56.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 52, - "element_id": 268435787, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 262.0, - "y": 66.0, - "width": 56.0, - "height": 14.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 53, - "element_id": 268435788, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 262.0, - "y": 80.0, - "width": 56.0, - "height": 28.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 54, - "element_id": 268435789, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 50.0, - "width": 226.0, - "height": 60.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 55, - "element_id": 268435790, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 54, - "base_layout_id": 0, - "rect": { - "x": 31.0, - "y": 52.0, - "width": 222.0, - "height": 54.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 56, - "element_id": 268435791, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 29.0, - "y": 50.0, - "width": 226.0, - "height": 60.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 57, - "element_id": 268435792, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 56, - "base_layout_id": 0, - "rect": { - "x": 35.0, - "y": 52.0, - "width": 222.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 58, - "element_id": 268435793, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 56, - "base_layout_id": 0, - "rect": { - "x": 35.0, - "y": 70.0, - "width": 222.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 59, - "element_id": 268435794, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 56, - "base_layout_id": 0, - "rect": { - "x": 35.0, - "y": 88.0, - "width": 222.0, - "height": 18.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 60, - "element_id": 268435795, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 50.0, - "width": 300.0, - "height": 365.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 61, - "element_id": 268435796, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 50.0, - "width": 300.0, - "height": 365.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 62, - "element_id": 268435800, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 92.0, - "width": 300.0, - "height": 4.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 63, - "element_id": 268435802, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 142.0, - "width": 300.0, - "height": 4.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 64, - "element_id": 268435804, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 25.0, - "y": 318.0, - "width": 300.0, - "height": 4.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 65, - "element_id": 268435806, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 31.0, - "y": 52.0, - "width": 232.0, - "height": 38.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 66, - "element_id": 268435807, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 269.0, - "y": 56.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100683004, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100683004, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 67, - "element_id": 268435808, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 36.0, - "y": 102.0, - "width": 278.0, - "height": 19.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 68, - "element_id": 268435809, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 36.0, - "y": 102.0, - "width": 278.0, - "height": 19.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 69, - "element_id": 268435810, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 36.0, - "y": 121.0, - "width": 278.0, - "height": 19.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 70, - "element_id": 268435811, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 36.0, - "y": 150.0, - "width": 264.0, - "height": 168.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 71, - "element_id": 268435812, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 149.0, - "width": 16.0, - "height": 171.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682847, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682847, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 72, - "element_id": 1, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 71, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 149.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 73, - "element_id": 268436324, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 72, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 149.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682848, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682848, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682849, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682850, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 74, - "element_id": 268436325, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 72, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 152.0, - "width": 16.0, - "height": 10.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682851, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682851, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682852, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682853, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 75, - "element_id": 268436326, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 72, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 162.0, - "width": 16.0, - "height": 3.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682854, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682854, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682855, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682856, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 76, - "element_id": 268435569, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 71, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 149.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682857, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682857, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682858, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682859, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 77, - "element_id": 268435570, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 71, - "base_layout_id": 0, - "rect": { - "x": 309.0, - "y": 304.0, - "width": 16.0, - "height": 16.0 - }, - "widget_kind": "Scrollbar", - "state_set": { - "default_image": { - "image_id": 100682860, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 1, - "image": { - "image_id": 100682860, - "alpha_image_id": null - } - }, - { - "state_id": 2, - "image": { - "image_id": 100682861, - "alpha_image_id": null - } - }, - { - "state_id": 3, - "image": { - "image_id": 100682862, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 78, - "element_id": 268435813, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 36.0, - "y": 326.0, - "width": 278.0, - "height": 19.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 79, - "element_id": 268436269, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 60, - "base_layout_id": 0, - "rect": { - "x": 159.0, - "y": 348.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 80, - "element_id": 268436270, - "layout_id": 553648235, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 81, - "element_id": 268436272, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 80, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 32.0, - "height": 32.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 82, - "element_id": 268435814, - "layout_id": 553648235, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 292.0, - "height": 20.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100682946, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100682946, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 83, - "element_id": 268435754, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 128.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 84, - "element_id": 268435755, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 82, - "base_layout_id": 0, - "rect": { - "x": 27.0, - "y": 0.0, - "width": 256.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 85, - "element_id": 268436279, - "layout_id": 553648235, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 292.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 86, - "element_id": 268435754, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 85, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 125.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 87, - "element_id": 268435755, - "layout_id": 553648235, - "parent_layout_id": 553648235, - "parent_traversal_index": 85, - "base_layout_id": 0, - "rect": { - "x": 128.0, - "y": 0.0, - "width": 162.0, - "height": 20.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268437205, - "slug": "side_by_side_vitals", - "title": "Side-by-Side Vitals", - "bucket": "VitalsExamine", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268437204, - "layout_id": 553648245, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 460.0, - "height": 26.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268437206, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693187, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693187, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268437207, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 450.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693183, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693183, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268437208, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 455.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693188, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693188, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268437209, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693184, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693184, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268437210, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 21.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693189, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693189, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268437211, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 21.0, - "width": 450.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693185, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693185, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268437212, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 455.0, - "y": 21.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693190, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693190, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268437213, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 455.0, - "y": 5.0, - "width": 5.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268437214, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268437215, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 450.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268437216, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 455.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268437217, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268437218, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 21.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268437219, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 21.0, - "width": 450.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268437220, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 455.0, - "y": 21.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268437221, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 455.0, - "y": 5.0, - "width": 5.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435686, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 18, - "element_id": 268435687, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 19, - "element_id": 268435688, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693118, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693118, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435689, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 5.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693119, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693119, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268435690, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693120, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693120, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268436649, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 71.0, - "y": 5.0, - "width": 18.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693136, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693136, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 2, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 24, - "element_id": 268435688, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693121, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693121, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 268435689, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 5.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693122, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693122, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 268435690, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693123, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693123, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268436649, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 71.0, - "y": 5.0, - "width": 18.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693137, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693137, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268435691, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 29, - "element_id": 268435692, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 30, - "element_id": 268435687, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 29, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 31, - "element_id": 268435688, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 30, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693124, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693124, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268435689, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 30, - "base_layout_id": 0, - "rect": { - "x": 165.0, - "y": 5.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693125, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693125, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 33, - "element_id": 268435690, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 30, - "base_layout_id": 0, - "rect": { - "x": 295.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693126, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693126, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 34, - "element_id": 268436649, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 30, - "base_layout_id": 0, - "rect": { - "x": 187.0, - "y": 5.0, - "width": 85.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693138, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693138, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 35, - "element_id": 2, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 29, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 36, - "element_id": 268435688, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693127, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693127, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 37, - "element_id": 268435689, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 165.0, - "y": 5.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693128, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693128, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268435690, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 295.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693129, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693129, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268436649, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 187.0, - "y": 5.0, - "width": 85.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693139, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693139, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 40, - "element_id": 268435693, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 29, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 41, - "element_id": 268435694, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 42, - "element_id": 268435687, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 43, - "element_id": 268435688, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693130, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693130, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 44, - "element_id": 268435689, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 315.0, - "y": 5.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693131, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693131, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 268435690, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 445.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693132, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693132, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 46, - "element_id": 268436649, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 330.0, - "y": 5.0, - "width": 100.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693140, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693140, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 47, - "element_id": 2, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 48, - "element_id": 268435688, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693133, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693133, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 268435689, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 315.0, - "y": 5.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693134, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693134, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 50, - "element_id": 268435690, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 445.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693135, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693135, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268436649, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 330.0, - "y": 5.0, - "width": 100.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693141, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693141, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 52, - "element_id": 268435695, - "layout_id": 553648245, - "parent_layout_id": 553648245, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 305.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - }, - { - "id": 268436986, - "slug": "vitals", - "title": "Vitals", - "bucket": "VitalsExamine", - "parent_slug": null, - "width": 640, - "height": 480, - "nodes": [ - { - "traversal_index": 0, - "element_id": 268436985, - "layout_id": 553648236, - "parent_layout_id": null, - "parent_traversal_index": null, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 160.0, - "height": 58.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 1, - "element_id": 268437043, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693187, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693187, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 2, - "element_id": 268437044, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 150.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693183, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693183, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 3, - "element_id": 268437045, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693188, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693188, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 4, - "element_id": 268437046, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 48.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693184, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693184, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 5, - "element_id": 268437047, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 53.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693189, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693189, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 6, - "element_id": 268437048, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 53.0, - "width": 150.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693185, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693185, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 7, - "element_id": 268437049, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 53.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693190, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693190, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 8, - "element_id": 268437050, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 5.0, - "width": 5.0, - "height": 48.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693186, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693186, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 9, - "element_id": 268437051, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 10, - "element_id": 268437052, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 0.0, - "width": 150.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688170, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688170, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 11, - "element_id": 268437053, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 0.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 12, - "element_id": 268437054, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 5.0, - "width": 5.0, - "height": 48.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688171, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688171, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 13, - "element_id": 268437055, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 0.0, - "y": 53.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 14, - "element_id": 268437056, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 53.0, - "width": 150.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688172, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688172, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 15, - "element_id": 268437057, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 53.0, - "width": 5.0, - "height": 5.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688169, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688169, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 16, - "element_id": 268437058, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 155.0, - "y": 5.0, - "width": 5.0, - "height": 48.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100688173, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100688173, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 17, - "element_id": 268435686, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 18, - "element_id": 268435687, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 19, - "element_id": 268435688, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693118, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693118, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 20, - "element_id": 268435689, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 5.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693119, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693119, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 21, - "element_id": 268435690, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693120, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693120, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 22, - "element_id": 268436649, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 18, - "base_layout_id": 0, - "rect": { - "x": 71.0, - "y": 5.0, - "width": 18.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693136, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693136, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 23, - "element_id": 2, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 24, - "element_id": 268435688, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693121, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693121, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 25, - "element_id": 268435689, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 5.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693122, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693122, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 26, - "element_id": 268435690, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 5.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693123, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693123, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 27, - "element_id": 268436649, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 23, - "base_layout_id": 0, - "rect": { - "x": 71.0, - "y": 5.0, - "width": 18.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693137, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693137, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 28, - "element_id": 268435691, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 17, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 5.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 29, - "element_id": 268435692, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 21.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 30, - "element_id": 268435687, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 29, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 21.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 31, - "element_id": 268435688, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 30, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 21.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693124, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693124, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 32, - "element_id": 268435689, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 30, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 21.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693125, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693125, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 33, - "element_id": 268435690, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 30, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 21.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693126, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693126, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 34, - "element_id": 268436649, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 30, - "base_layout_id": 0, - "rect": { - "x": 37.0, - "y": 21.0, - "width": 85.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693138, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693138, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 35, - "element_id": 2, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 29, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 21.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 36, - "element_id": 268435688, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 21.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693127, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693127, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 37, - "element_id": 268435689, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 21.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693128, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693128, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 38, - "element_id": 268435690, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 21.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693129, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693129, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 39, - "element_id": 268436649, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 35, - "base_layout_id": 0, - "rect": { - "x": 37.0, - "y": 21.0, - "width": 85.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693139, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693139, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 40, - "element_id": 268435693, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 29, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 21.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 41, - "element_id": 268435694, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 0, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 37.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 42, - "element_id": 268435687, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 37.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 43, - "element_id": 268435688, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 37.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693130, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693130, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 44, - "element_id": 268435689, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 37.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693131, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693131, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 45, - "element_id": 268435690, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 37.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693132, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693132, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 46, - "element_id": 268436649, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 42, - "base_layout_id": 0, - "rect": { - "x": 30.0, - "y": 37.0, - "width": 100.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693140, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693140, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 47, - "element_id": 2, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 37.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - }, - { - "traversal_index": 48, - "element_id": 268435688, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 37.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693133, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693133, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 49, - "element_id": 268435689, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 15.0, - "y": 37.0, - "width": 130.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693134, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693134, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 50, - "element_id": 268435690, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 145.0, - "y": 37.0, - "width": 10.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693135, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 0, - "image": { - "image_id": 100693135, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 51, - "element_id": 268436649, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 47, - "base_layout_id": 0, - "rect": { - "x": 30.0, - "y": 37.0, - "width": 100.0, - "height": 16.0 - }, - "widget_kind": "Sprite", - "state_set": { - "default_image": { - "image_id": 100693141, - "alpha_image_id": null - }, - "states": [ - { - "state_id": 268435463, - "image": { - "image_id": 100693141, - "alpha_image_id": null - } - } - ] - } - }, - { - "traversal_index": 52, - "element_id": 268435695, - "layout_id": 553648236, - "parent_layout_id": 553648236, - "parent_traversal_index": 41, - "base_layout_id": 0, - "rect": { - "x": 5.0, - "y": 37.0, - "width": 150.0, - "height": 16.0 - }, - "widget_kind": "Group", - "state_set": { - "default_image": null, - "states": [] - } - } - ] - } - ], - "images": [ - { - "key": { - "image_id": 100667274, - "alpha_image_id": null - }, - "relative_path": "images/0x06000F8A.png", - "width": 153, - "height": 15, - "used_by_panels": [ - "character", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 59, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 59, - "element_id": 2, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 110, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 110, - "element_id": 2, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 460, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 460, - "element_id": 2, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 511, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 511, - "element_id": 2, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100667670, - "alpha_image_id": null - }, - "relative_path": "images/0x06001116.png", - "width": 34, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 11, - "element_id": 268435866, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 11, - "element_id": 268435866, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100667672, - "alpha_image_id": null - }, - "relative_path": "images/0x06001118.png", - "width": 34, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 11, - "element_id": 268435866, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100667673, - "alpha_image_id": null - }, - "relative_path": "images/0x06001119.png", - "width": 34, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 8, - "element_id": 268435864, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 8, - "element_id": 268435864, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100667675, - "alpha_image_id": null - }, - "relative_path": "images/0x0600111B.png", - "width": 34, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 8, - "element_id": 268435864, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100667676, - "alpha_image_id": null - }, - "relative_path": "images/0x0600111C.png", - "width": 39, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 12, - "element_id": 268435867, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 12, - "element_id": 268435867, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100667678, - "alpha_image_id": null - }, - "relative_path": "images/0x0600111E.png", - "width": 39, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 12, - "element_id": 268435867, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100667679, - "alpha_image_id": null - }, - "relative_path": "images/0x0600111F.png", - "width": 35, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 7, - "element_id": 268435863, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 7, - "element_id": 268435863, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100667681, - "alpha_image_id": null - }, - "relative_path": "images/0x06001121.png", - "width": 35, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 7, - "element_id": 268435863, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100667682, - "alpha_image_id": null - }, - "relative_path": "images/0x06001122.png", - "width": 34, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 9, - "element_id": 268435865, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 9, - "element_id": 268435865, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100667684, - "alpha_image_id": null - }, - "relative_path": "images/0x06001124.png", - "width": 34, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 9, - "element_id": 268435865, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100667686, - "alpha_image_id": null - }, - "relative_path": "images/0x06001126.png", - "width": 146, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 15, - "element_id": 268435870, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 15, - "element_id": 268435870, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100667687, - "alpha_image_id": null - }, - "relative_path": "images/0x06001127.png", - "width": 22, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 26, - "element_id": 268435877, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 26, - "element_id": 268435877, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100667688, - "alpha_image_id": null - }, - "relative_path": "images/0x06001128.png", - "width": 22, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 26, - "element_id": 268435877, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100667689, - "alpha_image_id": null - }, - "relative_path": "images/0x06001129.png", - "width": 23, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 14, - "element_id": 268435869, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 14, - "element_id": 268435869, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100667690, - "alpha_image_id": null - }, - "relative_path": "images/0x0600112A.png", - "width": 23, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 14, - "element_id": 268435869, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100667691, - "alpha_image_id": null - }, - "relative_path": "images/0x0600112B.png", - "width": 7, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 6, - "element_id": 268435862, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 6, - "element_id": 268435862, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100667692, - "alpha_image_id": null - }, - "relative_path": "images/0x0600112C.png", - "width": 10, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 13, - "element_id": 268435868, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 13, - "element_id": 268435868, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100667706, - "alpha_image_id": null - }, - "relative_path": "images/0x0600113A.png", - "width": 500, - "height": 17, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 32, - "element_id": 268435475, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 32, - "element_id": 268435475, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100667813, - "alpha_image_id": null - }, - "relative_path": "images/0x060011A5.png", - "width": 153, - "height": 15, - "used_by_panels": [ - "character", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 25, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 25, - "element_id": 2, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 76, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 76, - "element_id": 2, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 426, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 426, - "element_id": 2, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 477, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 477, - "element_id": 2, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100667814, - "alpha_image_id": null - }, - "relative_path": "images/0x060011A6.png", - "width": 155, - "height": 17, - "used_by_panels": [ - "character", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 24, - "element_id": 268436022, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 24, - "element_id": 268436022, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 58, - "element_id": 268436039, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 58, - "element_id": 268436039, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 75, - "element_id": 268436022, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 75, - "element_id": 268436022, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 109, - "element_id": 268436039, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 109, - "element_id": 268436039, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 425, - "element_id": 268436022, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 425, - "element_id": 268436022, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 459, - "element_id": 268436039, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 459, - "element_id": 268436039, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 476, - "element_id": 268436022, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 476, - "element_id": 268436022, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 510, - "element_id": 268436039, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 510, - "element_id": 268436039, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100667819, - "alpha_image_id": null - }, - "relative_path": "images/0x060011AB.png", - "width": 500, - "height": 17, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 35, - "element_id": 268435478, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 35, - "element_id": 268435478, - "state_id": 4 - } - ] - }, - { - "key": { - "image_id": 100667833, - "alpha_image_id": null - }, - "relative_path": "images/0x060011B9.png", - "width": 32, - "height": 3, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 36, - "element_id": 268435566, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 36, - "element_id": 268435566, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 37, - "element_id": 268435567, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 37, - "element_id": 268435567, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100667834, - "alpha_image_id": null - }, - "relative_path": "images/0x060011BA.png", - "width": 3, - "height": 32, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 34, - "element_id": 268435564, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 34, - "element_id": 268435564, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 35, - "element_id": 268435565, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 35, - "element_id": 268435565, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100667836, - "alpha_image_id": null - }, - "relative_path": "images/0x060011BC.png", - "width": 24, - "height": 23, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 24, - "element_id": 268435560, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100667837, - "alpha_image_id": null - }, - "relative_path": "images/0x060011BD.png", - "width": 24, - "height": 23, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 24, - "element_id": 268435560, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 24, - "element_id": 268435560, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100667895, - "alpha_image_id": null - }, - "relative_path": "images/0x060011F7.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 50, - "element_id": 268436588, - "state_id": 268435526 - } - ] - }, - { - "key": { - "image_id": 100667896, - "alpha_image_id": null - }, - "relative_path": "images/0x060011F8.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "inventory", - "main_panel", - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "inventory", - "traversal_index": 4, - "element_id": 268436589, - "state_id": 268435521 - }, - { - "panel_slug": "main_panel", - "traversal_index": 205, - "element_id": 268436589, - "state_id": 268435521 - }, - { - "panel_slug": "toolbar", - "traversal_index": 50, - "element_id": 268436588, - "state_id": 268435521 - } - ] - }, - { - "key": { - "image_id": 100667897, - "alpha_image_id": null - }, - "relative_path": "images/0x060011F9.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "inventory", - "main_panel", - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "inventory", - "traversal_index": 4, - "element_id": 268436589, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 4, - "element_id": 268436589, - "state_id": 268435520 - }, - { - "panel_slug": "main_panel", - "traversal_index": 205, - "element_id": 268436589, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 205, - "element_id": 268436589, - "state_id": 268435520 - }, - { - "panel_slug": "toolbar", - "traversal_index": 50, - "element_id": 268436588, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 50, - "element_id": 268436588, - "state_id": 268435520 - } - ] - }, - { - "key": { - "image_id": 100667899, - "alpha_image_id": null - }, - "relative_path": "images/0x060011FB.png", - "width": 10, - "height": 9, - "used_by_panels": [ - "radar" - ], - "used_by_nodes": [ - { - "panel_slug": "radar", - "traversal_index": 5, - "element_id": 268435520, - "state_id": null - }, - { - "panel_slug": "radar", - "traversal_index": 5, - "element_id": 268435520, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100667904, - "alpha_image_id": null - }, - "relative_path": "images/0x06001200.png", - "width": 60, - "height": 13, - "used_by_panels": [ - "combat", - "powerbar", - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 25, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 25, - "element_id": 2, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 19, - "element_id": 2, - "state_id": 268435523 - }, - { - "panel_slug": "powerbar", - "traversal_index": 19, - "element_id": 2, - "state_id": 268435524 - }, - { - "panel_slug": "smartbox", - "traversal_index": 18, - "element_id": 2, - "state_id": 268435523 - }, - { - "panel_slug": "smartbox", - "traversal_index": 18, - "element_id": 2, - "state_id": 268435524 - } - ] - }, - { - "key": { - "image_id": 100667918, - "alpha_image_id": null - }, - "relative_path": "images/0x0600120E.png", - "width": 23, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 14, - "element_id": 268435869, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100667932, - "alpha_image_id": null - }, - "relative_path": "images/0x0600121C.png", - "width": 11, - "height": 61, - "used_by_panels": [ - "inventory", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "inventory", - "traversal_index": 35, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 35, - "element_id": 2, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 236, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 236, - "element_id": 2, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100667933, - "alpha_image_id": null - }, - "relative_path": "images/0x0600121D.png", - "width": 11, - "height": 61, - "used_by_panels": [ - "inventory", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "inventory", - "traversal_index": 34, - "element_id": 268435929, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 34, - "element_id": 268435929, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 235, - "element_id": 268435929, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 235, - "element_id": 268435929, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668005, - "alpha_image_id": null - }, - "relative_path": "images/0x06001265.png", - "width": 300, - "height": 6, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 79, - "element_id": 268435731, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 79, - "element_id": 268435731, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668006, - "alpha_image_id": null - }, - "relative_path": "images/0x06001266.png", - "width": 65, - "height": 32, - "used_by_panels": [ - "main_panel", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 69, - "element_id": 268435733, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 69, - "element_id": 268435733, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 580, - "element_id": 268436838, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 580, - "element_id": 268436838, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 45, - "element_id": 268436838, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 45, - "element_id": 268436838, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100668007, - "alpha_image_id": null - }, - "relative_path": "images/0x06001267.png", - "width": 65, - "height": 32, - "used_by_panels": [ - "main_panel", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 69, - "element_id": 268435733, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 580, - "element_id": 268436838, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 45, - "element_id": 268436838, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100668008, - "alpha_image_id": null - }, - "relative_path": "images/0x06001268.png", - "width": 65, - "height": 32, - "used_by_panels": [ - "main_panel", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 69, - "element_id": 268435733, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 580, - "element_id": 268436838, - "state_id": 13 - }, - { - "panel_slug": "quests", - "traversal_index": 45, - "element_id": 268436838, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100668009, - "alpha_image_id": null - }, - "relative_path": "images/0x06001269.png", - "width": 54, - "height": 33, - "used_by_panels": [ - "main_panel", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 68, - "element_id": 268435732, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 68, - "element_id": 268435732, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 579, - "element_id": 268436837, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 579, - "element_id": 268436837, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 44, - "element_id": 268436837, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 44, - "element_id": 268436837, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100668010, - "alpha_image_id": null - }, - "relative_path": "images/0x0600126A.png", - "width": 54, - "height": 33, - "used_by_panels": [ - "main_panel", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 68, - "element_id": 268435732, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 579, - "element_id": 268436837, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 44, - "element_id": 268436837, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100668011, - "alpha_image_id": null - }, - "relative_path": "images/0x0600126B.png", - "width": 54, - "height": 33, - "used_by_panels": [ - "main_panel", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 68, - "element_id": 268435732, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 579, - "element_id": 268436837, - "state_id": 13 - }, - { - "panel_slug": "quests", - "traversal_index": 44, - "element_id": 268436837, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100668012, - "alpha_image_id": null - }, - "relative_path": "images/0x0600126C.png", - "width": 24, - "height": 30, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 59, - "element_id": 268435726, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 59, - "element_id": 268435726, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100668013, - "alpha_image_id": null - }, - "relative_path": "images/0x0600126D.png", - "width": 24, - "height": 30, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 59, - "element_id": 268435726, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100668014, - "alpha_image_id": null - }, - "relative_path": "images/0x0600126E.png", - "width": 8, - "height": 18, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 78, - "element_id": 268435730, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 78, - "element_id": 268435730, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668015, - "alpha_image_id": null - }, - "relative_path": "images/0x0600126F.png", - "width": 265, - "height": 100, - "used_by_panels": [ - "examine", - "main_panel", - "map", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "examine", - "traversal_index": 23, - "element_id": 268435767, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 23, - "element_id": 268435767, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 62, - "element_id": 268435728, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 62, - "element_id": 268435728, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 65, - "element_id": 268435728, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 65, - "element_id": 268435728, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 274, - "element_id": 268435728, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 274, - "element_id": 268435728, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 573, - "element_id": 268435728, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 573, - "element_id": 268435728, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 576, - "element_id": 268435728, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 576, - "element_id": 268435728, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 14, - "element_id": 268435728, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 14, - "element_id": 268435728, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 38, - "element_id": 268435728, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 38, - "element_id": 268435728, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 41, - "element_id": 268435728, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 41, - "element_id": 268435728, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668016, - "alpha_image_id": null - }, - "relative_path": "images/0x06001270.png", - "width": 300, - "height": 32, - "used_by_panels": [ - "main_panel", - "map", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 67, - "element_id": 268435738, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 67, - "element_id": 268435738, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 276, - "element_id": 268435738, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 276, - "element_id": 268435738, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 578, - "element_id": 268435738, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 578, - "element_id": 268435738, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 16, - "element_id": 268435738, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 16, - "element_id": 268435738, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 43, - "element_id": 268435738, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 43, - "element_id": 268435738, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668017, - "alpha_image_id": null - }, - "relative_path": "images/0x06001271.png", - "width": 22, - "height": 243, - "used_by_panels": [ - "main_panel", - "map", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 64, - "element_id": 268435736, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 64, - "element_id": 268435736, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 273, - "element_id": 268435736, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 273, - "element_id": 268435736, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 575, - "element_id": 268435736, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 575, - "element_id": 268435736, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 13, - "element_id": 268435736, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 13, - "element_id": 268435736, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 40, - "element_id": 268435736, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 40, - "element_id": 268435736, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668018, - "alpha_image_id": null - }, - "relative_path": "images/0x06001272.png", - "width": 21, - "height": 243, - "used_by_panels": [ - "main_panel", - "map", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 66, - "element_id": 268435737, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 66, - "element_id": 268435737, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 275, - "element_id": 268435737, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 275, - "element_id": 268435737, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 577, - "element_id": 268435737, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 577, - "element_id": 268435737, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 15, - "element_id": 268435737, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 15, - "element_id": 268435737, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 42, - "element_id": 268435737, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 42, - "element_id": 268435737, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668019, - "alpha_image_id": null - }, - "relative_path": "images/0x06001273.png", - "width": 300, - "height": 33, - "used_by_panels": [ - "main_panel", - "map", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 63, - "element_id": 268435735, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 63, - "element_id": 268435735, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 272, - "element_id": 268435735, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 272, - "element_id": 268435735, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 574, - "element_id": 268435735, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 574, - "element_id": 268435735, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 12, - "element_id": 268435735, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 12, - "element_id": 268435735, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 39, - "element_id": 268435735, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 39, - "element_id": 268435735, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668020, - "alpha_image_id": null - }, - "relative_path": "images/0x06001274.png", - "width": 20, - "height": 19, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 83, - "element_id": 268436596, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 83, - "element_id": 268436596, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 83, - "element_id": 268436596, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100668021, - "alpha_image_id": null - }, - "relative_path": "images/0x06001275.png", - "width": 20, - "height": 19, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 83, - "element_id": 268436596, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 83, - "element_id": 268436596, - "state_id": 7 - } - ] - }, - { - "key": { - "image_id": 100668022, - "alpha_image_id": null - }, - "relative_path": "images/0x06001276.png", - "width": 273, - "height": 18, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 81, - "element_id": 268436595, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 81, - "element_id": 268436595, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668025, - "alpha_image_id": null - }, - "relative_path": "images/0x06001279.png", - "width": 276, - "height": 30, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 60, - "element_id": 268435709, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 60, - "element_id": 268435709, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668029, - "alpha_image_id": null - }, - "relative_path": "images/0x0600127D.png", - "width": 257, - "height": 267, - "used_by_panels": [ - "main_panel", - "map" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 279, - "element_id": 268435948, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 279, - "element_id": 268435948, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 19, - "element_id": 268435948, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 19, - "element_id": 268435948, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668034, - "alpha_image_id": null - }, - "relative_path": "images/0x06001282.png", - "width": 24, - "height": 25, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 19, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 157, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 307, - "element_id": 268435984, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 17, - "element_id": 268435984, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100668035, - "alpha_image_id": null - }, - "relative_path": "images/0x06001283.png", - "width": 24, - "height": 25, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 19, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 19, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 157, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 157, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 307, - "element_id": 268435984, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 307, - "element_id": 268435984, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 17, - "element_id": 268435984, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 17, - "element_id": 268435984, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100668037, - "alpha_image_id": null - }, - "relative_path": "images/0x06001285.png", - "width": 120, - "height": 12, - "used_by_panels": [ - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "options", - "traversal_index": 118, - "element_id": 268435996, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 118, - "element_id": 268435996, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 122, - "element_id": 268435996, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 122, - "element_id": 268435996, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 129, - "element_id": 268435996, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 129, - "element_id": 268435996, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 134, - "element_id": 268435996, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 134, - "element_id": 268435996, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668038, - "alpha_image_id": null - }, - "relative_path": "images/0x06001286.png", - "width": 7, - "height": 12, - "used_by_panels": [ - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "options", - "traversal_index": 119, - "element_id": 1, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 119, - "element_id": 1, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 123, - "element_id": 1, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 123, - "element_id": 1, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 130, - "element_id": 1, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 130, - "element_id": 1, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 135, - "element_id": 1, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 135, - "element_id": 1, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668042, - "alpha_image_id": null - }, - "relative_path": "images/0x0600128A.png", - "width": 220, - "height": 77, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 22, - "element_id": 268435711, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 22, - "element_id": 268435711, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 32, - "element_id": 268435715, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 32, - "element_id": 268435715, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 52, - "element_id": 268435722, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 52, - "element_id": 268435722, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 160, - "element_id": 268435892, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 160, - "element_id": 268435892, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 170, - "element_id": 268435896, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 170, - "element_id": 268435896, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 189, - "element_id": 268435902, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 189, - "element_id": 268435902, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668072, - "alpha_image_id": null - }, - "relative_path": "images/0x060012A8.png", - "width": 64, - "height": 20, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 79, - "element_id": 268435597, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 79, - "element_id": 268435597, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668073, - "alpha_image_id": null - }, - "relative_path": "images/0x060012A9.png", - "width": 22, - "height": 20, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 49, - "element_id": 268435576, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 77, - "element_id": 268435595, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 108, - "element_id": 268435614, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 161, - "element_id": 268435670, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100668074, - "alpha_image_id": null - }, - "relative_path": "images/0x060012AA.png", - "width": 22, - "height": 20, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 49, - "element_id": 268435576, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 49, - "element_id": 268435576, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 77, - "element_id": 268435595, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 77, - "element_id": 268435595, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 108, - "element_id": 268435614, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 108, - "element_id": 268435614, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 161, - "element_id": 268435670, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 161, - "element_id": 268435670, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100668081, - "alpha_image_id": null - }, - "relative_path": "images/0x060012B1.png", - "width": 17, - "height": 19, - "used_by_panels": [ - "environment_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 125, - "element_id": 268436302, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 125, - "element_id": 268436302, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 125, - "element_id": 268436302, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 142, - "element_id": 268436310, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 142, - "element_id": 268436310, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 142, - "element_id": 268436310, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100668082, - "alpha_image_id": null - }, - "relative_path": "images/0x060012B2.png", - "width": 17, - "height": 19, - "used_by_panels": [ - "environment_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 125, - "element_id": 268436302, - "state_id": 6 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 125, - "element_id": 268436302, - "state_id": 7 - }, - { - "panel_slug": "options", - "traversal_index": 142, - "element_id": 268436310, - "state_id": 6 - }, - { - "panel_slug": "options", - "traversal_index": 142, - "element_id": 268436310, - "state_id": 7 - } - ] - }, - { - "key": { - "image_id": 100668083, - "alpha_image_id": null - }, - "relative_path": "images/0x060012B3.png", - "width": 100, - "height": 18, - "used_by_panels": [ - "environment_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 124, - "element_id": 268436301, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 124, - "element_id": 268436301, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 141, - "element_id": 268436309, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 141, - "element_id": 268436309, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668088, - "alpha_image_id": null - }, - "relative_path": "images/0x060012B8.png", - "width": 65, - "height": 14, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 143, - "element_id": 268435659, - "state_id": 13 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 144, - "element_id": 268435660, - "state_id": 13 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 158, - "element_id": 268435668, - "state_id": 13 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 159, - "element_id": 268435669, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100668089, - "alpha_image_id": null - }, - "relative_path": "images/0x060012B9.png", - "width": 65, - "height": 14, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 143, - "element_id": 268435659, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 144, - "element_id": 268435660, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100668090, - "alpha_image_id": null - }, - "relative_path": "images/0x060012BA.png", - "width": 65, - "height": 14, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 143, - "element_id": 268435659, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 143, - "element_id": 268435659, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 144, - "element_id": 268435660, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 144, - "element_id": 268435660, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100668092, - "alpha_image_id": null - }, - "relative_path": "images/0x060012BC.png", - "width": 2, - "height": 337, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 308, - "element_id": 268435977, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 308, - "element_id": 268435977, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 18, - "element_id": 268435977, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 18, - "element_id": 268435977, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668101, - "alpha_image_id": null - }, - "relative_path": "images/0x060012C5.png", - "width": 292, - "height": 8, - "used_by_panels": [ - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "options", - "traversal_index": 112, - "element_id": 268435991, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 112, - "element_id": 268435991, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668102, - "alpha_image_id": null - }, - "relative_path": "images/0x060012C6.png", - "width": 7, - "height": 71, - "used_by_panels": [ - "examine" - ], - "used_by_nodes": [ - { - "panel_slug": "examine", - "traversal_index": 44, - "element_id": 268435781, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 44, - "element_id": 268435781, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668244, - "alpha_image_id": null - }, - "relative_path": "images/0x06001354.png", - "width": 60, - "height": 13, - "used_by_panels": [ - "powerbar", - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "powerbar", - "traversal_index": 19, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 19, - "element_id": 2, - "state_id": 268435522 - }, - { - "panel_slug": "smartbox", - "traversal_index": 18, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 18, - "element_id": 2, - "state_id": 268435522 - } - ] - }, - { - "key": { - "image_id": 100668307, - "alpha_image_id": null - }, - "relative_path": "images/0x06001393.png", - "width": 24, - "height": 25, - "used_by_panels": [ - "character", - "main_panel", - "map", - "negative_effects", - "positive_effects", - "quests", - "spellbook", - "vitae" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 13, - "element_id": 268436010, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 13, - "element_id": 268436010, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 85, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 85, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 97, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 97, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 117, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 117, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 137, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 137, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 143, - "element_id": 268435819, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 143, - "element_id": 268435819, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 196, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 196, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 269, - "element_id": 268435957, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 269, - "element_id": 268435957, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 414, - "element_id": 268436010, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 414, - "element_id": 268436010, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 548, - "element_id": 268436834, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 548, - "element_id": 268436834, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 808, - "element_id": 268436139, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 808, - "element_id": 268436139, - "state_id": 1 - }, - { - "panel_slug": "map", - "traversal_index": 9, - "element_id": 268435957, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 9, - "element_id": 268435957, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 1, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 1, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 21, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 21, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 41, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 41, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 1, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 1, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 21, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 21, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 41, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 41, - "element_id": 268435708, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 13, - "element_id": 268436834, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 13, - "element_id": 268436834, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 9, - "element_id": 268436139, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 9, - "element_id": 268436139, - "state_id": 1 - }, - { - "panel_slug": "vitae", - "traversal_index": 1, - "element_id": 268435708, - "state_id": null - }, - { - "panel_slug": "vitae", - "traversal_index": 1, - "element_id": 268435708, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100668308, - "alpha_image_id": null - }, - "relative_path": "images/0x06001394.png", - "width": 24, - "height": 25, - "used_by_panels": [ - "character", - "main_panel", - "map", - "negative_effects", - "positive_effects", - "quests", - "spellbook", - "vitae" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 13, - "element_id": 268436010, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 85, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 97, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 117, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 137, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 143, - "element_id": 268435819, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 196, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 269, - "element_id": 268435957, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 414, - "element_id": 268436010, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 548, - "element_id": 268436834, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 808, - "element_id": 268436139, - "state_id": 3 - }, - { - "panel_slug": "map", - "traversal_index": 9, - "element_id": 268435957, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 1, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 21, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 41, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 1, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 21, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 41, - "element_id": 268435708, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 13, - "element_id": 268436834, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 9, - "element_id": 268436139, - "state_id": 3 - }, - { - "panel_slug": "vitae", - "traversal_index": 1, - "element_id": 268435708, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100668309, - "alpha_image_id": null - }, - "relative_path": "images/0x06001395.png", - "width": 300, - "height": 88, - "used_by_panels": [ - "main_panel", - "negative_effects", - "positive_effects" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 107, - "element_id": 268435749, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 107, - "element_id": 268435749, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 127, - "element_id": 268435749, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 127, - "element_id": 268435749, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 11, - "element_id": 268435749, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 11, - "element_id": 268435749, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 31, - "element_id": 268435749, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 31, - "element_id": 268435749, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 51, - "element_id": 268435749, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 51, - "element_id": 268435749, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 11, - "element_id": 268435749, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 11, - "element_id": 268435749, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 31, - "element_id": 268435749, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 31, - "element_id": 268435749, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 51, - "element_id": 268435749, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 51, - "element_id": 268435749, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668310, - "alpha_image_id": null - }, - "relative_path": "images/0x06001396.png", - "width": 300, - "height": 32, - "used_by_panels": [ - "negative_effects", - "positive_effects" - ], - "used_by_nodes": [ - { - "panel_slug": "negative_effects", - "traversal_index": 60, - "element_id": 268435752, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 60, - "element_id": 268435752, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 60, - "element_id": 268435752, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 60, - "element_id": 268435752, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100668311, - "alpha_image_id": null - }, - "relative_path": "images/0x06001397.png", - "width": 300, - "height": 32, - "used_by_panels": [ - "negative_effects", - "positive_effects" - ], - "used_by_nodes": [ - { - "panel_slug": "negative_effects", - "traversal_index": 60, - "element_id": 268435752, - "state_id": 6 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 60, - "element_id": 268435752, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100668312, - "alpha_image_id": null - }, - "relative_path": "images/0x06001398.png", - "width": 300, - "height": 32, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 146, - "element_id": 268435822, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 146, - "element_id": 268435822, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100668445, - "alpha_image_id": null - }, - "relative_path": "images/0x0600141D.png", - "width": 24, - "height": 25, - "used_by_panels": [ - "main_panel", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 644, - "element_id": 268436112, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 644, - "element_id": 268436112, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 17, - "element_id": 268436112, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 17, - "element_id": 268436112, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100668446, - "alpha_image_id": null - }, - "relative_path": "images/0x0600141E.png", - "width": 24, - "height": 25, - "used_by_panels": [ - "main_panel", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 644, - "element_id": 268436112, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 17, - "element_id": 268436112, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100668448, - "alpha_image_id": null - }, - "relative_path": "images/0x06001420.png", - "width": 300, - "height": 9, - "used_by_panels": [ - "character", - "main_panel", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 119, - "element_id": 268436784, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 119, - "element_id": 268436784, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 129, - "element_id": 268436788, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 129, - "element_id": 268436788, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 520, - "element_id": 268436784, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 520, - "element_id": 268436784, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 530, - "element_id": 268436788, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 530, - "element_id": 268436788, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 650, - "element_id": 268436052, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 650, - "element_id": 268436052, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 658, - "element_id": 268436057, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 658, - "element_id": 268436057, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 665, - "element_id": 268436061, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 665, - "element_id": 268436061, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 693, - "element_id": 268436077, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 693, - "element_id": 268436077, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 759, - "element_id": 268436808, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 759, - "element_id": 268436808, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 781, - "element_id": 268436052, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 781, - "element_id": 268436052, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 792, - "element_id": 268436061, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 792, - "element_id": 268436061, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 23, - "element_id": 268436052, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 23, - "element_id": 268436052, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 31, - "element_id": 268436057, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 31, - "element_id": 268436057, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 38, - "element_id": 268436061, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 38, - "element_id": 268436061, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 66, - "element_id": 268436077, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 66, - "element_id": 268436077, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 132, - "element_id": 268436808, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 132, - "element_id": 268436808, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 154, - "element_id": 268436052, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 154, - "element_id": 268436052, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 165, - "element_id": 268436061, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 165, - "element_id": 268436061, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100669717, - "alpha_image_id": null - }, - "relative_path": "images/0x06001915.png", - "width": 46, - "height": 17, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 38, - "element_id": 268435481, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 38, - "element_id": 268435481, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100669718, - "alpha_image_id": null - }, - "relative_path": "images/0x06001916.png", - "width": 46, - "height": 17, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 38, - "element_id": 268435481, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100669721, - "alpha_image_id": null - }, - "relative_path": "images/0x06001919.png", - "width": 60, - "height": 13, - "used_by_panels": [ - "combat" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 22, - "element_id": 4, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 22, - "element_id": 4, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100669731, - "alpha_image_id": null - }, - "relative_path": "images/0x06001923.png", - "width": 12, - "height": 14, - "used_by_panels": [ - "combat" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 26, - "element_id": 1, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 26, - "element_id": 1, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100669735, - "alpha_image_id": null - }, - "relative_path": "images/0x06001927.png", - "width": 65, - "height": 14, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 158, - "element_id": 268435668, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 158, - "element_id": 268435668, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 159, - "element_id": 268435669, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 159, - "element_id": 268435669, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100669736, - "alpha_image_id": null - }, - "relative_path": "images/0x06001928.png", - "width": 65, - "height": 14, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 158, - "element_id": 268435668, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 159, - "element_id": 268435669, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100669748, - "alpha_image_id": null - }, - "relative_path": "images/0x06001934.png", - "width": 46, - "height": 17, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 38, - "element_id": 268435481, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100669751, - "alpha_image_id": null - }, - "relative_path": "images/0x06001937.png", - "width": 146, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 17, - "element_id": 268435872, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 17, - "element_id": 268435872, - "state_id": 268435467 - } - ] - }, - { - "key": { - "image_id": 100669752, - "alpha_image_id": null - }, - "relative_path": "images/0x06001938.png", - "width": 10, - "height": 9, - "used_by_panels": [ - "radar" - ], - "used_by_nodes": [ - { - "panel_slug": "radar", - "traversal_index": 6, - "element_id": 268435521, - "state_id": null - }, - { - "panel_slug": "radar", - "traversal_index": 6, - "element_id": 268435521, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100669754, - "alpha_image_id": null - }, - "relative_path": "images/0x0600193A.png", - "width": 10, - "height": 9, - "used_by_panels": [ - "radar" - ], - "used_by_nodes": [ - { - "panel_slug": "radar", - "traversal_index": 7, - "element_id": 268435522, - "state_id": null - }, - { - "panel_slug": "radar", - "traversal_index": 7, - "element_id": 268435522, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100669756, - "alpha_image_id": null - }, - "relative_path": "images/0x0600193C.png", - "width": 10, - "height": 9, - "used_by_panels": [ - "radar" - ], - "used_by_nodes": [ - { - "panel_slug": "radar", - "traversal_index": 8, - "element_id": 268435523, - "state_id": null - }, - { - "panel_slug": "radar", - "traversal_index": 8, - "element_id": 268435523, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100669758, - "alpha_image_id": null - }, - "relative_path": "images/0x0600193E.png", - "width": 146, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 18, - "element_id": 268435873, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 18, - "element_id": 268435873, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100669759, - "alpha_image_id": null - }, - "relative_path": "images/0x0600193F.png", - "width": 146, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 19, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 19, - "element_id": 2, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100669787, - "alpha_image_id": null - }, - "relative_path": "images/0x0600195B.png", - "width": 60, - "height": 13, - "used_by_panels": [ - "powerbar", - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "powerbar", - "traversal_index": 19, - "element_id": 2, - "state_id": 268435525 - }, - { - "panel_slug": "smartbox", - "traversal_index": 18, - "element_id": 2, - "state_id": 268435525 - } - ] - }, - { - "key": { - "image_id": 100670916, - "alpha_image_id": null - }, - "relative_path": "images/0x06001DC4.png", - "width": 60, - "height": 14, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 76, - "element_id": 268435594, - "state_id": 268435461 - } - ] - }, - { - "key": { - "image_id": 100670917, - "alpha_image_id": null - }, - "relative_path": "images/0x06001DC5.png", - "width": 60, - "height": 14, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 76, - "element_id": 268435594, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100670918, - "alpha_image_id": null - }, - "relative_path": "images/0x06001DC6.png", - "width": 60, - "height": 14, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 76, - "element_id": 268435594, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 76, - "element_id": 268435594, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100671445, - "alpha_image_id": null - }, - "relative_path": "images/0x06001FD5.png", - "width": 64, - "height": 64, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 152, - "element_id": 268435828, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 152, - "element_id": 268435828, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100671446, - "alpha_image_id": null - }, - "relative_path": "images/0x06001FD6.png", - "width": 280, - "height": 12, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 148, - "element_id": 268435824, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 148, - "element_id": 268435824, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100671447, - "alpha_image_id": null - }, - "relative_path": "images/0x06001FD7.png", - "width": 280, - "height": 12, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 149, - "element_id": 268435825, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 149, - "element_id": 268435825, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100671448, - "alpha_image_id": null - }, - "relative_path": "images/0x06001FD8.png", - "width": 12, - "height": 256, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 150, - "element_id": 268435826, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 150, - "element_id": 268435826, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100671449, - "alpha_image_id": null - }, - "relative_path": "images/0x06001FD9.png", - "width": 12, - "height": 256, - "used_by_panels": [ - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 151, - "element_id": 268435827, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 151, - "element_id": 268435827, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100672186, - "alpha_image_id": null - }, - "relative_path": "images/0x060022BA.png", - "width": 300, - "height": 100, - "used_by_panels": [ - "main_panel", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 572, - "element_id": 268436835, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 572, - "element_id": 268436835, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 37, - "element_id": 268436835, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 37, - "element_id": 268436835, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100672213, - "alpha_image_id": null - }, - "relative_path": "images/0x060022D5.png", - "width": 146, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 20, - "element_id": 268435874, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 20, - "element_id": 268435874, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100672214, - "alpha_image_id": null - }, - "relative_path": "images/0x060022D6.png", - "width": 146, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 21, - "element_id": 2, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 21, - "element_id": 2, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100672324, - "alpha_image_id": null - }, - "relative_path": "images/0x06002344.png", - "width": 94, - "height": 22, - "used_by_panels": [ - "environment_panel", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 86, - "element_id": 268435604, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 86, - "element_id": 268435604, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 153, - "element_id": 268435829, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 153, - "element_id": 268435829, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 154, - "element_id": 268435830, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 154, - "element_id": 268435830, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 155, - "element_id": 268435831, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 155, - "element_id": 268435831, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100672325, - "alpha_image_id": null - }, - "relative_path": "images/0x06002345.png", - "width": 94, - "height": 22, - "used_by_panels": [ - "environment_panel", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 86, - "element_id": 268435604, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 153, - "element_id": 268435829, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 154, - "element_id": 268435830, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 155, - "element_id": 268435831, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100672326, - "alpha_image_id": null - }, - "relative_path": "images/0x06002346.png", - "width": 94, - "height": 22, - "used_by_panels": [ - "environment_panel", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 86, - "element_id": 268435604, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 153, - "element_id": 268435829, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 154, - "element_id": 268435830, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 155, - "element_id": 268435831, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100672327, - "alpha_image_id": null - }, - "relative_path": "images/0x06002347.png", - "width": 94, - "height": 22, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 99, - "element_id": 268435611, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 99, - "element_id": 268435611, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100672328, - "alpha_image_id": null - }, - "relative_path": "images/0x06002348.png", - "width": 94, - "height": 22, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 99, - "element_id": 268435611, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100672329, - "alpha_image_id": null - }, - "relative_path": "images/0x06002349.png", - "width": 94, - "height": 22, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 99, - "element_id": 268435611, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100673314, - "alpha_image_id": null - }, - "relative_path": "images/0x06002722.png", - "width": 300, - "height": 113, - "used_by_panels": [ - "main_panel", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 818, - "element_id": 268436119, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 818, - "element_id": 268436119, - "state_id": 0 - }, - { - "panel_slug": "spellbook", - "traversal_index": 19, - "element_id": 268436119, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 19, - "element_id": 268436119, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682828, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C4C.png", - "width": 64, - "height": 22, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 47, - "element_id": 268435574, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 47, - "element_id": 268435574, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 128, - "element_id": 268435650, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 128, - "element_id": 268435650, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 129, - "element_id": 268435651, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 129, - "element_id": 268435651, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 141, - "element_id": 268435657, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 141, - "element_id": 268435657, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 142, - "element_id": 268435658, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 142, - "element_id": 268435658, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 156, - "element_id": 268435666, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 156, - "element_id": 268435666, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 157, - "element_id": 268435667, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 157, - "element_id": 268435667, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682829, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C4D.png", - "width": 64, - "height": 22, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 47, - "element_id": 268435574, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 128, - "element_id": 268435650, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 129, - "element_id": 268435651, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 141, - "element_id": 268435657, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 142, - "element_id": 268435658, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 156, - "element_id": 268435666, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 157, - "element_id": 268435667, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682830, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C4E.png", - "width": 64, - "height": 22, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 47, - "element_id": 268435574, - "state_id": 13 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 128, - "element_id": 268435650, - "state_id": 13 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 129, - "element_id": 268435651, - "state_id": 13 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 141, - "element_id": 268435657, - "state_id": 13 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 142, - "element_id": 268435658, - "state_id": 13 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 156, - "element_id": 268435666, - "state_id": 13 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 157, - "element_id": 268435667, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100682847, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C5F.png", - "width": 16, - "height": 32, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 33, - "element_id": 268436030, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 33, - "element_id": 268436030, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 84, - "element_id": 268436030, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 84, - "element_id": 268436030, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 122, - "element_id": 268436787, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 122, - "element_id": 268436787, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 25, - "element_id": 268435773, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 25, - "element_id": 268435773, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 34, - "element_id": 268436590, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 34, - "element_id": 268436590, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 71, - "element_id": 268435812, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 71, - "element_id": 268435812, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 16, - "element_id": 268435474, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 16, - "element_id": 268435474, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 16, - "element_id": 268435474, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 16, - "element_id": 268435474, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 16, - "element_id": 268435474, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 16, - "element_id": 268435474, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 16, - "element_id": 268435474, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 16, - "element_id": 268435474, - "state_id": 0 - }, - { - "panel_slug": "inventory", - "traversal_index": 38, - "element_id": 268435915, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 38, - "element_id": 268435915, - "state_id": 0 - }, - { - "panel_slug": "inventory", - "traversal_index": 48, - "element_id": 268435911, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 48, - "element_id": 268435911, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 20, - "element_id": 268435474, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 20, - "element_id": 268435474, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 37, - "element_id": 268435720, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 37, - "element_id": 268435720, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 71, - "element_id": 268436618, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 71, - "element_id": 268436618, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 89, - "element_id": 268435742, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 89, - "element_id": 268435742, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 100, - "element_id": 268435748, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 100, - "element_id": 268435748, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 109, - "element_id": 268435751, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 109, - "element_id": 268435751, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 120, - "element_id": 268435748, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 120, - "element_id": 268435748, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 129, - "element_id": 268435751, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 129, - "element_id": 268435751, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 173, - "element_id": 268435899, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 173, - "element_id": 268435899, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 239, - "element_id": 268435915, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 239, - "element_id": 268435915, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 249, - "element_id": 268435911, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 249, - "element_id": 268435911, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 311, - "element_id": 268435963, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 311, - "element_id": 268435963, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 361, - "element_id": 268435969, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 361, - "element_id": 268435969, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 382, - "element_id": 268435969, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 382, - "element_id": 268435969, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 434, - "element_id": 268436030, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 434, - "element_id": 268436030, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 485, - "element_id": 268436030, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 485, - "element_id": 268436030, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 523, - "element_id": 268436787, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 523, - "element_id": 268436787, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 553, - "element_id": 268436944, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 553, - "element_id": 268436944, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 588, - "element_id": 268436846, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 588, - "element_id": 268436846, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 616, - "element_id": 268436868, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 616, - "element_id": 268436868, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 669, - "element_id": 268436065, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 669, - "element_id": 268436065, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 713, - "element_id": 268436090, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 713, - "element_id": 268436090, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 748, - "element_id": 268436803, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 748, - "element_id": 268436803, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 774, - "element_id": 268436760, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 774, - "element_id": 268436760, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 811, - "element_id": 268436118, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 811, - "element_id": 268436118, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 851, - "element_id": 268436581, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 851, - "element_id": 268436581, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 4, - "element_id": 268435748, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 4, - "element_id": 268435748, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 13, - "element_id": 268435751, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 13, - "element_id": 268435751, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 24, - "element_id": 268435748, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 24, - "element_id": 268435748, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 33, - "element_id": 268435751, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 33, - "element_id": 268435751, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 44, - "element_id": 268435748, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 44, - "element_id": 268435748, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 53, - "element_id": 268435751, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 53, - "element_id": 268435751, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 21, - "element_id": 268435963, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 21, - "element_id": 268435963, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 71, - "element_id": 268435969, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 71, - "element_id": 268435969, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 92, - "element_id": 268435969, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 92, - "element_id": 268435969, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 4, - "element_id": 268435748, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 4, - "element_id": 268435748, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 13, - "element_id": 268435751, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 13, - "element_id": 268435751, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 24, - "element_id": 268435748, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 24, - "element_id": 268435748, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 33, - "element_id": 268435751, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 33, - "element_id": 268435751, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 44, - "element_id": 268435748, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 44, - "element_id": 268435748, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 53, - "element_id": 268435751, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 53, - "element_id": 268435751, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 18, - "element_id": 268436944, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 18, - "element_id": 268436944, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 53, - "element_id": 268436846, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 53, - "element_id": 268436846, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 81, - "element_id": 268436868, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 81, - "element_id": 268436868, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 42, - "element_id": 268436065, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 42, - "element_id": 268436065, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 86, - "element_id": 268436090, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 86, - "element_id": 268436090, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 121, - "element_id": 268436803, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 121, - "element_id": 268436803, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 147, - "element_id": 268436760, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 147, - "element_id": 268436760, - "state_id": 0 - }, - { - "panel_slug": "spellbook", - "traversal_index": 12, - "element_id": 268436118, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 12, - "element_id": 268436118, - "state_id": 0 - }, - { - "panel_slug": "spellbook", - "traversal_index": 52, - "element_id": 268436581, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 52, - "element_id": 268436581, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682848, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C60.png", - "width": 16, - "height": 3, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 35, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 35, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 86, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 86, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 124, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 124, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 27, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 27, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 36, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 36, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 73, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 73, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 18, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 18, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 18, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 18, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "inventory", - "traversal_index": 40, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 40, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "inventory", - "traversal_index": 50, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 50, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_chat", - "traversal_index": 22, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 22, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 39, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 39, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 73, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 73, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 91, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 91, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 102, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 102, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 111, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 111, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 122, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 122, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 131, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 131, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 175, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 175, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 241, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 241, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 251, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 251, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 313, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 313, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 363, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 363, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 384, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 384, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 436, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 436, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 487, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 487, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 525, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 525, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 555, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 555, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 590, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 590, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 618, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 618, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 671, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 671, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 715, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 715, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 750, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 750, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 776, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 776, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 813, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 813, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 853, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 853, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 6, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 6, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 15, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 15, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 26, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 26, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 35, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 35, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 46, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 46, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 55, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 55, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 23, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 23, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 73, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 73, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 94, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 94, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 6, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 6, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 15, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 15, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 26, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 26, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 35, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 35, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 46, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 46, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 55, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 55, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 20, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 20, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 55, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 55, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 83, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 83, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 44, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 44, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 88, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 88, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 123, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 123, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 149, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 149, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 14, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 14, - "element_id": 268436324, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 54, - "element_id": 268436324, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 54, - "element_id": 268436324, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682849, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C61.png", - "width": 16, - "height": 3, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 35, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "character", - "traversal_index": 86, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "character", - "traversal_index": 124, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 27, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 36, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 73, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "inventory", - "traversal_index": 40, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "inventory", - "traversal_index": 50, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_chat", - "traversal_index": 22, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 39, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 73, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 91, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 102, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 111, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 122, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 131, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 175, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 241, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 251, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 313, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 363, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 384, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 436, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 487, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 525, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 555, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 590, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 618, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 671, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 715, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 750, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 776, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 813, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 853, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 6, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 15, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 26, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 35, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 46, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 55, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 23, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 73, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 94, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 6, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 15, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 26, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 35, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 46, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 55, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 20, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 55, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 83, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 44, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 88, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 123, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 149, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "spellbook", - "traversal_index": 14, - "element_id": 268436324, - "state_id": 2 - }, - { - "panel_slug": "spellbook", - "traversal_index": 54, - "element_id": 268436324, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682850, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C62.png", - "width": 16, - "height": 3, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 35, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "character", - "traversal_index": 86, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "character", - "traversal_index": 124, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 27, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 36, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 73, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 18, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "inventory", - "traversal_index": 40, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "inventory", - "traversal_index": 50, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_chat", - "traversal_index": 22, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 39, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 73, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 91, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 102, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 111, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 122, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 131, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 175, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 241, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 251, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 313, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 363, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 384, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 436, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 487, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 525, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 555, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 590, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 618, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 671, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 715, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 750, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 776, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 813, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 853, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 6, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 15, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 26, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 35, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 46, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 55, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 23, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 73, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 94, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 6, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 15, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 26, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 35, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 46, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 55, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 20, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 55, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 83, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 44, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 88, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 123, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 149, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 14, - "element_id": 268436324, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 54, - "element_id": 268436324, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682851, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C63.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 36, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 36, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 87, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 87, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 125, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 125, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 28, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 28, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 37, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 37, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 74, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 74, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 19, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 19, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 19, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 19, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "inventory", - "traversal_index": 41, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 41, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "inventory", - "traversal_index": 51, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 51, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_chat", - "traversal_index": 23, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 23, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 40, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 40, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 74, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 74, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 92, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 92, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 103, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 103, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 112, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 112, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 123, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 123, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 132, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 132, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 176, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 176, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 242, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 242, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 252, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 252, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 314, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 314, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 364, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 364, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 385, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 385, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 437, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 437, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 488, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 488, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 526, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 526, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 556, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 556, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 591, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 591, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 619, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 619, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 672, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 672, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 716, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 716, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 751, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 751, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 777, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 777, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 814, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 814, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 854, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 854, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 7, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 7, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 16, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 16, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 27, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 27, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 36, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 36, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 47, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 47, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 56, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 56, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 24, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 24, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 74, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 74, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 95, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 95, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 7, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 7, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 16, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 16, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 27, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 27, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 36, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 36, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 47, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 47, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 56, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 56, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 21, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 21, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 56, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 56, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 84, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 84, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 45, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 45, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 89, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 89, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 124, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 124, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 150, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 150, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 15, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 15, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 55, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 55, - "element_id": 268436325, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682852, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C64.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 36, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "character", - "traversal_index": 87, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "character", - "traversal_index": 125, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 28, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 37, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 74, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "inventory", - "traversal_index": 41, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "inventory", - "traversal_index": 51, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_chat", - "traversal_index": 23, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 40, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 74, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 92, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 103, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 112, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 123, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 132, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 176, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 242, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 252, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 314, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 364, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 385, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 437, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 488, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 526, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 556, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 591, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 619, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 672, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 716, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 751, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 777, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 814, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 854, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 7, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 16, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 27, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 36, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 47, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 56, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 24, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 74, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 95, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 7, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 16, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 27, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 36, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 47, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 56, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 21, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 56, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 84, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 45, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 89, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 124, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 150, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "spellbook", - "traversal_index": 15, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "spellbook", - "traversal_index": 55, - "element_id": 268436325, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682853, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C65.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 36, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "character", - "traversal_index": 87, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "character", - "traversal_index": 125, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 28, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 37, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 74, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 19, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "inventory", - "traversal_index": 41, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "inventory", - "traversal_index": 51, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_chat", - "traversal_index": 23, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 40, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 74, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 92, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 103, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 112, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 123, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 132, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 176, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 242, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 252, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 314, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 364, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 385, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 437, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 488, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 526, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 556, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 591, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 619, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 672, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 716, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 751, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 777, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 814, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 854, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 7, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 16, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 27, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 36, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 47, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 56, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 24, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 74, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 95, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 7, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 16, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 27, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 36, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 47, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 56, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 21, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 56, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 84, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 45, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 89, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 124, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 150, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 15, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 55, - "element_id": 268436325, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682854, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C66.png", - "width": 16, - "height": 3, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 37, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 37, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 88, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 88, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 126, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 126, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 29, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 29, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 38, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 38, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 75, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 75, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 20, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 20, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 20, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 20, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "inventory", - "traversal_index": 42, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 42, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "inventory", - "traversal_index": 52, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 52, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_chat", - "traversal_index": 24, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 24, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 41, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 41, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 75, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 75, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 93, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 93, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 104, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 104, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 113, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 113, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 124, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 124, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 133, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 133, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 177, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 177, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 243, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 243, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 253, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 253, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 315, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 315, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 365, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 365, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 386, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 386, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 438, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 438, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 489, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 489, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 527, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 527, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 557, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 557, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 592, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 592, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 620, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 620, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 673, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 673, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 717, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 717, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 752, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 752, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 778, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 778, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 815, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 815, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 855, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 855, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 8, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 8, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 17, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 17, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 28, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 28, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 37, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 37, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 48, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 48, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 57, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 57, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 25, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 25, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 75, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 75, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 96, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 96, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 8, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 8, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 17, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 17, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 28, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 28, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 37, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 37, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 48, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 48, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 57, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 57, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 22, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 22, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 57, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 57, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 85, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 85, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 46, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 46, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 90, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 90, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 125, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 125, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 151, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 151, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 16, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 16, - "element_id": 268436326, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 56, - "element_id": 268436326, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 56, - "element_id": 268436326, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682855, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C67.png", - "width": 16, - "height": 3, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 37, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "character", - "traversal_index": 88, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "character", - "traversal_index": 126, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 29, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 38, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 75, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "inventory", - "traversal_index": 42, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "inventory", - "traversal_index": 52, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_chat", - "traversal_index": 24, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 41, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 75, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 93, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 104, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 113, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 124, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 133, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 177, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 243, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 253, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 315, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 365, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 386, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 438, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 489, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 527, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 557, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 592, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 620, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 673, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 717, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 752, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 778, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 815, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 855, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 8, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 17, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 28, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 37, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 48, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 57, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 25, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 75, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 96, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 8, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 17, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 28, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 37, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 48, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 57, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 22, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 57, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 85, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 46, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 90, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 125, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 151, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "spellbook", - "traversal_index": 16, - "element_id": 268436326, - "state_id": 2 - }, - { - "panel_slug": "spellbook", - "traversal_index": 56, - "element_id": 268436326, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682856, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C68.png", - "width": 16, - "height": 3, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 37, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "character", - "traversal_index": 88, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "character", - "traversal_index": 126, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 29, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 38, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 75, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 20, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "inventory", - "traversal_index": 42, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "inventory", - "traversal_index": 52, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_chat", - "traversal_index": 24, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 41, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 75, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 93, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 104, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 113, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 124, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 133, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 177, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 243, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 253, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 315, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 365, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 386, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 438, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 489, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 527, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 557, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 592, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 620, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 673, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 717, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 752, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 778, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 815, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 855, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 8, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 17, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 28, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 37, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 48, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 57, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 25, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 75, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 96, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 8, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 17, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 28, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 37, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 48, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 57, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 22, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 57, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 85, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 46, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 90, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 125, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 151, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 16, - "element_id": 268436326, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 56, - "element_id": 268436326, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682857, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C69.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 38, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 38, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 89, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 89, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 127, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 127, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 30, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 30, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 39, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 39, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 76, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 76, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 21, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 21, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 21, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 21, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "inventory", - "traversal_index": 43, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 43, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "inventory", - "traversal_index": 53, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 53, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_chat", - "traversal_index": 25, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 25, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 42, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 42, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 76, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 76, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 94, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 94, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 105, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 105, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 114, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 114, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 125, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 125, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 134, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 134, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 178, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 178, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 244, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 244, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 254, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 254, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 316, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 316, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 366, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 366, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 387, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 387, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 439, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 439, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 490, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 490, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 528, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 528, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 558, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 558, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 593, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 593, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 621, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 621, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 674, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 674, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 718, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 718, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 753, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 753, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 779, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 779, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 816, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 816, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 856, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 856, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 9, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 9, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 18, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 18, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 29, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 29, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 38, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 38, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 49, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 49, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 58, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 58, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 26, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 26, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 76, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 76, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 97, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 97, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 9, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 9, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 18, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 18, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 29, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 29, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 38, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 38, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 49, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 49, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 58, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 58, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 23, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 23, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 58, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 58, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 86, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 86, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 47, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 47, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 91, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 91, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 126, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 126, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 152, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 152, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 17, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 17, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 57, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 57, - "element_id": 268435569, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682858, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C6A.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 38, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "character", - "traversal_index": 89, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "character", - "traversal_index": 127, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 30, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 39, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 76, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "inventory", - "traversal_index": 43, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "inventory", - "traversal_index": 53, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_chat", - "traversal_index": 25, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 42, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 76, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 94, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 105, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 114, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 125, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 134, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 178, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 244, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 254, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 316, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 366, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 387, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 439, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 490, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 528, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 558, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 593, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 621, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 674, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 718, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 753, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 779, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 816, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 856, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 9, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 18, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 29, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 38, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 49, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 58, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 26, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 76, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 97, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 9, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 18, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 29, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 38, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 49, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 58, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 23, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 58, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 86, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 47, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 91, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 126, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 152, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "spellbook", - "traversal_index": 17, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "spellbook", - "traversal_index": 57, - "element_id": 268435569, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682859, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C6B.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 38, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "character", - "traversal_index": 89, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "character", - "traversal_index": 127, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 30, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 39, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 76, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "inventory", - "traversal_index": 43, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "inventory", - "traversal_index": 53, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_chat", - "traversal_index": 25, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 42, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 76, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 94, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 105, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 114, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 125, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 134, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 178, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 244, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 254, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 316, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 366, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 387, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 439, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 490, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 528, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 558, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 593, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 621, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 674, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 718, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 753, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 779, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 816, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 856, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 9, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 18, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 29, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 38, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 49, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 58, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 26, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 76, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 97, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 9, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 18, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 29, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 38, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 49, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 58, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 23, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 58, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 86, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 47, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 91, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 126, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 152, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 17, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 57, - "element_id": 268435569, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682860, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C6C.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 39, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 39, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 90, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 90, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 128, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 128, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 31, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 31, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 40, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 40, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "examine", - "traversal_index": 77, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 77, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 22, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 22, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 22, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 22, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "inventory", - "traversal_index": 44, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 44, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "inventory", - "traversal_index": 54, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 54, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_chat", - "traversal_index": 26, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 26, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 43, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 43, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 77, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 77, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 95, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 95, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 106, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 106, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 115, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 115, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 126, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 126, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 135, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 135, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 179, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 179, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 245, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 245, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 255, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 255, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 317, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 317, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 367, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 367, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 388, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 388, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 440, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 440, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 491, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 491, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 529, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 529, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 559, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 559, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 594, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 594, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 622, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 622, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 675, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 675, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 719, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 719, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 754, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 754, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 780, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 780, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 817, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 817, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 857, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 857, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 10, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 10, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 19, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 19, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 30, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 30, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 39, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 39, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 50, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 50, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 59, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 59, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 27, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 27, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 77, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 77, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 98, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 98, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 10, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 10, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 19, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 19, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 30, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 30, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 39, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 39, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 50, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 50, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 59, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 59, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 24, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 24, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 59, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 59, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "quests", - "traversal_index": 87, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 87, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 48, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 48, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 92, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 92, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 127, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 127, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 153, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 153, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 18, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 18, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 58, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 58, - "element_id": 268435570, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682861, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C6D.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 39, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "character", - "traversal_index": 90, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "character", - "traversal_index": 128, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 31, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 40, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "examine", - "traversal_index": 77, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "inventory", - "traversal_index": 44, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "inventory", - "traversal_index": 54, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_chat", - "traversal_index": 26, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 43, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 77, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 95, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 106, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 115, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 126, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 135, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 179, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 245, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 255, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 317, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 367, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 388, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 440, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 491, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 529, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 559, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 594, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 622, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 675, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 719, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 754, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 780, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 817, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 857, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 10, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 19, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 30, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 39, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 50, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 59, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 27, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 77, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 98, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 10, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 19, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 30, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 39, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 50, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 59, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 24, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 59, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "quests", - "traversal_index": 87, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 48, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 92, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 127, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 153, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "spellbook", - "traversal_index": 18, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "spellbook", - "traversal_index": 58, - "element_id": 268435570, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682862, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C6E.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "character", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "negative_effects", - "options", - "positive_effects", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 39, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "character", - "traversal_index": 90, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "character", - "traversal_index": 128, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 31, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 40, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "examine", - "traversal_index": 77, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "inventory", - "traversal_index": 44, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "inventory", - "traversal_index": 54, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_chat", - "traversal_index": 26, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 43, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 77, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 95, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 106, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 115, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 126, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 135, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 179, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 245, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 255, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 317, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 367, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 388, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 440, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 491, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 529, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 559, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 594, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 622, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 675, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 719, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 754, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 780, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 817, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 857, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 10, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 19, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 30, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 39, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 50, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 59, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 27, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 77, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 98, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 10, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 19, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 30, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 39, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 50, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 59, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 24, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 59, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "quests", - "traversal_index": 87, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 48, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 92, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 127, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 153, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 18, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 58, - "element_id": 268435570, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682879, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C7F.png", - "width": 32, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 27, - "element_id": 268435563, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 27, - "element_id": 268435563, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 40, - "element_id": 268435573, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 40, - "element_id": 268435573, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 57, - "element_id": 268435586, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 57, - "element_id": 268435586, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 69, - "element_id": 268435593, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 69, - "element_id": 268435593, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 88, - "element_id": 268435606, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 88, - "element_id": 268435606, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 101, - "element_id": 268435613, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 101, - "element_id": 268435613, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 116, - "element_id": 268435646, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 116, - "element_id": 268435646, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 132, - "element_id": 268435654, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 132, - "element_id": 268435654, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 147, - "element_id": 268435663, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 147, - "element_id": 268435663, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682880, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C80.png", - "width": 3, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 29, - "element_id": 268436329, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 29, - "element_id": 268436329, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 42, - "element_id": 268436329, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 42, - "element_id": 268436329, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 59, - "element_id": 268436329, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 59, - "element_id": 268436329, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 71, - "element_id": 268436329, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 71, - "element_id": 268436329, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 90, - "element_id": 268436329, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 90, - "element_id": 268436329, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 103, - "element_id": 268436329, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 103, - "element_id": 268436329, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 118, - "element_id": 268436329, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 118, - "element_id": 268436329, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 134, - "element_id": 268436329, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 134, - "element_id": 268436329, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 149, - "element_id": 268436329, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 149, - "element_id": 268436329, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682881, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C81.png", - "width": 3, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 29, - "element_id": 268436329, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 42, - "element_id": 268436329, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 59, - "element_id": 268436329, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 71, - "element_id": 268436329, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 90, - "element_id": 268436329, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 103, - "element_id": 268436329, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 118, - "element_id": 268436329, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 134, - "element_id": 268436329, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 149, - "element_id": 268436329, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682882, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C82.png", - "width": 3, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 29, - "element_id": 268436329, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 42, - "element_id": 268436329, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 59, - "element_id": 268436329, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 71, - "element_id": 268436329, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 90, - "element_id": 268436329, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 103, - "element_id": 268436329, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 118, - "element_id": 268436329, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 134, - "element_id": 268436329, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 149, - "element_id": 268436329, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682883, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C83.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 30, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 30, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 43, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 43, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 60, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 60, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 72, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 72, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 91, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 91, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 104, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 104, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 119, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 119, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 135, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 135, - "element_id": 268436325, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 150, - "element_id": 268436325, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 150, - "element_id": 268436325, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682884, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C84.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 30, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 43, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 60, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 72, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 91, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 104, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 119, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 135, - "element_id": 268436325, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 150, - "element_id": 268436325, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682885, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C85.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 30, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 43, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 60, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 72, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 91, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 104, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 119, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 135, - "element_id": 268436325, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 150, - "element_id": 268436325, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682886, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C86.png", - "width": 3, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 31, - "element_id": 268436330, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 31, - "element_id": 268436330, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 44, - "element_id": 268436330, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 44, - "element_id": 268436330, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 61, - "element_id": 268436330, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 61, - "element_id": 268436330, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 73, - "element_id": 268436330, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 73, - "element_id": 268436330, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 92, - "element_id": 268436330, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 92, - "element_id": 268436330, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 105, - "element_id": 268436330, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 105, - "element_id": 268436330, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 120, - "element_id": 268436330, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 120, - "element_id": 268436330, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 136, - "element_id": 268436330, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 136, - "element_id": 268436330, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 151, - "element_id": 268436330, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 151, - "element_id": 268436330, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682887, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C87.png", - "width": 3, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 31, - "element_id": 268436330, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 44, - "element_id": 268436330, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 61, - "element_id": 268436330, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 73, - "element_id": 268436330, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 92, - "element_id": 268436330, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 105, - "element_id": 268436330, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 120, - "element_id": 268436330, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 136, - "element_id": 268436330, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 151, - "element_id": 268436330, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682888, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C88.png", - "width": 3, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 31, - "element_id": 268436330, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 44, - "element_id": 268436330, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 61, - "element_id": 268436330, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 73, - "element_id": 268436330, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 92, - "element_id": 268436330, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 105, - "element_id": 268436330, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 120, - "element_id": 268436330, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 136, - "element_id": 268436330, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 151, - "element_id": 268436330, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682889, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C89.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 32, - "element_id": 268436331, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 32, - "element_id": 268436331, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 45, - "element_id": 268436331, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 45, - "element_id": 268436331, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 62, - "element_id": 268436331, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 62, - "element_id": 268436331, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 74, - "element_id": 268436331, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 74, - "element_id": 268436331, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 93, - "element_id": 268436331, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 93, - "element_id": 268436331, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 106, - "element_id": 268436331, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 106, - "element_id": 268436331, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 121, - "element_id": 268436331, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 121, - "element_id": 268436331, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 137, - "element_id": 268436331, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 137, - "element_id": 268436331, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 152, - "element_id": 268436331, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 152, - "element_id": 268436331, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682890, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C8A.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 32, - "element_id": 268436331, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 45, - "element_id": 268436331, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 62, - "element_id": 268436331, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 74, - "element_id": 268436331, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 93, - "element_id": 268436331, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 106, - "element_id": 268436331, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 121, - "element_id": 268436331, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 137, - "element_id": 268436331, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 152, - "element_id": 268436331, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682891, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C8B.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 32, - "element_id": 268436331, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 45, - "element_id": 268436331, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 62, - "element_id": 268436331, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 74, - "element_id": 268436331, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 93, - "element_id": 268436331, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 106, - "element_id": 268436331, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 121, - "element_id": 268436331, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 137, - "element_id": 268436331, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 152, - "element_id": 268436331, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682892, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C8C.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 33, - "element_id": 268436332, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 33, - "element_id": 268436332, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 46, - "element_id": 268436332, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 46, - "element_id": 268436332, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 63, - "element_id": 268436332, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 63, - "element_id": 268436332, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 75, - "element_id": 268436332, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 75, - "element_id": 268436332, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 94, - "element_id": 268436332, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 94, - "element_id": 268436332, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 107, - "element_id": 268436332, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 107, - "element_id": 268436332, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 122, - "element_id": 268436332, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 122, - "element_id": 268436332, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 138, - "element_id": 268436332, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 138, - "element_id": 268436332, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 153, - "element_id": 268436332, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 153, - "element_id": 268436332, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682893, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C8D.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 33, - "element_id": 268436332, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 46, - "element_id": 268436332, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 63, - "element_id": 268436332, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 75, - "element_id": 268436332, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 94, - "element_id": 268436332, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 107, - "element_id": 268436332, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 122, - "element_id": 268436332, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 138, - "element_id": 268436332, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 153, - "element_id": 268436332, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682894, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C8E.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 33, - "element_id": 268436332, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 46, - "element_id": 268436332, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 63, - "element_id": 268436332, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 75, - "element_id": 268436332, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 94, - "element_id": 268436332, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 107, - "element_id": 268436332, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 122, - "element_id": 268436332, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 138, - "element_id": 268436332, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 153, - "element_id": 268436332, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682895, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C8F.png", - "width": 47, - "height": 44, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 65, - "element_id": 268436234, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 65, - "element_id": 268436234, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 68, - "element_id": 268436234, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 68, - "element_id": 268436234, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 71, - "element_id": 268436234, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 71, - "element_id": 268436234, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 74, - "element_id": 268436234, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 74, - "element_id": 268436234, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 77, - "element_id": 268436234, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 77, - "element_id": 268436234, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682896, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C90.png", - "width": 47, - "height": 44, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 65, - "element_id": 268436234, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 68, - "element_id": 268436234, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 71, - "element_id": 268436234, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 74, - "element_id": 268436234, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 77, - "element_id": 268436234, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682897, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C91.png", - "width": 47, - "height": 44, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 65, - "element_id": 268436234, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 68, - "element_id": 268436234, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 71, - "element_id": 268436234, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 74, - "element_id": 268436234, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 77, - "element_id": 268436234, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100682898, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C92.png", - "width": 47, - "height": 44, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 66, - "element_id": 268436235, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 66, - "element_id": 268436235, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 69, - "element_id": 268436235, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 69, - "element_id": 268436235, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 72, - "element_id": 268436235, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 72, - "element_id": 268436235, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 75, - "element_id": 268436235, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 75, - "element_id": 268436235, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 78, - "element_id": 268436235, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 78, - "element_id": 268436235, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682899, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C93.png", - "width": 47, - "height": 44, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 66, - "element_id": 268436235, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 69, - "element_id": 268436235, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 72, - "element_id": 268436235, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 75, - "element_id": 268436235, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 78, - "element_id": 268436235, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682900, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C94.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "character", - "combat", - "main_panel", - "options", - "smartbox", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 131, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "combat", - "traversal_index": 97, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 25, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 29, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 45, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 49, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 55, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 163, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 167, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 182, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 186, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 192, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 319, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 323, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 327, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 369, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 373, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 377, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 390, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 394, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 398, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 532, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 679, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 683, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 687, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 705, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 721, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 725, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 729, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 733, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 737, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 741, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 756, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 763, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 767, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 785, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 789, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 796, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 29, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 33, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 37, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 79, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 83, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 87, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 100, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 104, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 108, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 88, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 92, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 52, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 56, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 60, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 78, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 94, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 98, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 102, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 106, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 110, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 114, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 129, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 136, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 140, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 158, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 162, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 169, - "element_id": 268436174, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100682901, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C95.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "character", - "combat", - "main_panel", - "options", - "smartbox", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 131, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 97, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 25, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 29, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 45, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 49, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 55, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 163, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 167, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 182, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 186, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 192, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 319, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 323, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 327, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 369, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 373, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 377, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 390, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 394, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 398, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 532, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 679, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 683, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 687, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 705, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 721, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 725, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 729, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 733, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 737, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 741, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 756, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 763, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 767, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 785, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 789, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 796, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 29, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 33, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 37, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 79, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 83, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 87, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 100, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 104, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 108, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "smartbox", - "traversal_index": 88, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "smartbox", - "traversal_index": 92, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 52, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 56, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 60, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 78, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 94, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 98, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 102, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 106, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 110, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 114, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 129, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 136, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 140, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 158, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 162, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 169, - "element_id": 268436174, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682902, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C96.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "character", - "combat", - "main_panel", - "options", - "smartbox", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 131, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 131, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 131, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 97, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 97, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 97, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 25, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 25, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 25, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 29, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 29, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 29, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 45, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 45, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 45, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 49, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 49, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 49, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 55, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 55, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 55, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 163, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 163, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 163, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 167, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 167, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 167, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 182, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 182, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 182, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 186, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 186, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 186, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 192, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 192, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 192, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 319, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 319, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 319, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 323, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 323, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 323, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 327, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 327, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 327, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 369, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 369, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 369, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 373, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 373, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 373, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 377, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 377, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 377, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 390, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 390, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 390, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 394, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 394, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 394, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 398, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 398, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 398, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 532, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 532, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 532, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 679, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 679, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 679, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 683, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 683, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 683, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 687, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 687, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 687, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 705, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 705, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 705, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 721, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 721, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 721, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 725, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 725, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 725, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 729, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 729, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 729, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 733, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 733, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 733, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 737, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 737, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 737, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 741, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 741, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 741, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 756, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 756, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 756, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 763, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 763, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 763, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 767, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 767, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 767, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 785, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 785, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 785, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 789, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 789, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 789, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 796, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 796, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 796, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 29, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 29, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 29, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 33, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 33, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 33, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 37, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 37, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 37, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 79, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 79, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 79, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 83, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 83, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 83, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 87, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 87, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 87, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 100, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 100, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 100, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 104, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 104, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 104, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 108, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 108, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 108, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 88, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 88, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 88, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 92, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 92, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 92, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 52, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 52, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 52, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 56, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 56, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 56, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 60, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 60, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 60, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 78, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 78, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 78, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 94, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 94, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 94, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 98, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 98, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 98, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 102, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 102, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 102, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 106, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 106, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 106, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 110, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 110, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 110, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 114, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 114, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 114, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 129, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 129, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 129, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 136, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 136, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 136, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 140, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 140, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 140, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 158, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 158, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 158, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 162, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 162, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 162, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 169, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 169, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 169, - "element_id": 268436174, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682903, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C97.png", - "width": 15, - "height": 32, - "used_by_panels": [ - "character", - "combat", - "main_panel", - "options", - "smartbox", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 132, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "combat", - "traversal_index": 98, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 26, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 30, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 46, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 50, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 56, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 164, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 168, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 183, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 187, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 193, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 320, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 324, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 328, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 370, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 374, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 378, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 391, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 395, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 399, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 533, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 680, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 684, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 688, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 706, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 722, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 726, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 730, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 734, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 738, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 742, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 757, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 764, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 768, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 786, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 790, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 797, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 30, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 34, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 38, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 80, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 84, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 88, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 101, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 105, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 109, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 89, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 93, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 53, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 57, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 61, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 79, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 95, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 99, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 103, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 107, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 111, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 115, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 130, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 137, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 141, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 159, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 163, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 170, - "element_id": 268436175, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100682904, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C98.png", - "width": 15, - "height": 32, - "used_by_panels": [ - "character", - "combat", - "main_panel", - "options", - "smartbox", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 132, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 98, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 26, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 30, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 46, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 50, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 56, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 164, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 168, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 183, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 187, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 193, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 320, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 324, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 328, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 370, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 374, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 378, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 391, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 395, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 399, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 533, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 680, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 684, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 688, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 706, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 722, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 726, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 730, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 734, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 738, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 742, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 757, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 764, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 768, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 786, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 790, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 797, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 30, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 34, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 38, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 80, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 84, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 88, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 101, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 105, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 109, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "smartbox", - "traversal_index": 89, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "smartbox", - "traversal_index": 93, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 53, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 57, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 61, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 79, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 95, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 99, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 103, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 107, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 111, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 115, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 130, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 137, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 141, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 159, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 163, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 170, - "element_id": 268436175, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682905, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C99.png", - "width": 15, - "height": 32, - "used_by_panels": [ - "character", - "combat", - "main_panel", - "options", - "smartbox", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 132, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 132, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 132, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 98, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 98, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 98, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 26, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 26, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 26, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 30, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 30, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 30, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 46, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 46, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 46, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 50, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 50, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 50, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 56, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 56, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 56, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 164, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 164, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 164, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 168, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 168, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 168, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 183, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 183, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 183, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 187, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 187, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 187, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 193, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 193, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 193, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 320, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 320, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 320, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 324, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 324, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 324, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 328, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 328, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 328, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 370, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 370, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 370, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 374, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 374, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 374, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 378, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 378, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 378, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 391, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 391, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 391, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 395, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 395, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 395, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 399, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 399, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 399, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 533, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 533, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 533, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 680, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 680, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 680, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 684, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 684, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 684, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 688, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 688, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 688, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 706, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 706, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 706, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 722, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 722, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 722, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 726, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 726, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 726, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 730, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 730, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 730, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 734, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 734, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 734, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 738, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 738, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 738, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 742, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 742, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 742, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 757, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 757, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 757, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 764, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 764, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 764, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 768, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 768, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 768, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 786, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 786, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 786, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 790, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 790, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 790, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 797, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 797, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 797, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 30, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 30, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 30, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 34, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 34, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 34, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 38, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 38, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 38, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 80, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 80, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 80, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 84, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 84, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 84, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 88, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 88, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 88, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 101, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 101, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 101, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 105, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 105, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 105, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 109, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 109, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 109, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 89, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 89, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 89, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 93, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 93, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 93, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 53, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 53, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 53, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 57, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 57, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 57, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 61, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 61, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 61, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 79, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 79, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 79, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 95, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 95, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 95, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 99, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 99, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 99, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 103, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 103, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 103, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 107, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 107, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 107, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 111, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 111, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 111, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 115, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 115, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 115, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 130, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 130, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 130, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 137, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 137, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 137, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 141, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 141, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 141, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 159, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 159, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 159, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 163, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 163, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 163, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 170, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 170, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 170, - "element_id": 268436175, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682906, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C9A.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "character", - "combat", - "main_panel", - "options", - "smartbox", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 133, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "combat", - "traversal_index": 99, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 27, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 31, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 47, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 51, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 57, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 165, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 169, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 184, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 188, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 194, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 321, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 325, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 329, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 371, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 375, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 379, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 392, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 396, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 400, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 534, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 681, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 685, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 689, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 707, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 723, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 727, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 731, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 735, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 739, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 743, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 758, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 765, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 769, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 787, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 791, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 798, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 31, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 35, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 39, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 81, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 85, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 89, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 102, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 106, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 110, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 90, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 94, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 54, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 58, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 62, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 80, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 96, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 100, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 104, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 108, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 112, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 116, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 131, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 138, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 142, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 160, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 164, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 171, - "element_id": 268436176, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100682907, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C9B.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "character", - "combat", - "main_panel", - "options", - "smartbox", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 133, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 99, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 27, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 31, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 47, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 51, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 57, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 165, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 169, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 184, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 188, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 194, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 321, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 325, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 329, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 371, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 375, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 379, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 392, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 396, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 400, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 534, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 681, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 685, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 689, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 707, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 723, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 727, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 731, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 735, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 739, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 743, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 758, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 765, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 769, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 787, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 791, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 798, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 31, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 35, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 39, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 81, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 85, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 89, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 102, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 106, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 110, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "smartbox", - "traversal_index": 90, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "smartbox", - "traversal_index": 94, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 54, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 58, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 62, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 80, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 96, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 100, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 104, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 108, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 112, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 116, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 131, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 138, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 142, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 160, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 164, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 171, - "element_id": 268436176, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682908, - "alpha_image_id": null - }, - "relative_path": "images/0x06004C9C.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "character", - "combat", - "main_panel", - "options", - "smartbox", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 133, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 133, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 133, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 99, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 99, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 99, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 27, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 27, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 27, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 31, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 31, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 31, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 47, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 47, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 47, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 51, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 51, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 51, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 57, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 57, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 57, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 165, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 165, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 165, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 169, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 169, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 169, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 184, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 184, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 184, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 188, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 188, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 188, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 194, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 194, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 194, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 321, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 321, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 321, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 325, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 325, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 325, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 329, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 329, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 329, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 371, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 371, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 371, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 375, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 375, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 375, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 379, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 379, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 379, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 392, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 392, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 392, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 396, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 396, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 396, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 400, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 400, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 400, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 534, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 534, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 534, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 681, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 681, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 681, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 685, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 685, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 685, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 689, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 689, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 689, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 707, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 707, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 707, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 723, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 723, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 723, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 727, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 727, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 727, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 731, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 731, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 731, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 735, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 735, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 735, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 739, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 739, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 739, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 743, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 743, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 743, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 758, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 758, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 758, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 765, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 765, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 765, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 769, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 769, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 769, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 787, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 787, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 787, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 791, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 791, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 791, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 798, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 798, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 798, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 31, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 31, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 31, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 35, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 35, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 35, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 39, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 39, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 39, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 81, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 81, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 81, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 85, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 85, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 85, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 89, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 89, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 89, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 102, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 102, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 102, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 106, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 106, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 106, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 110, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 110, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 110, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 90, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 90, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 90, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "smartbox", - "traversal_index": 94, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 94, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 94, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 54, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 54, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 54, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 58, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 58, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 58, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 62, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 62, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 62, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 80, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 80, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 80, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 96, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 96, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 96, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 100, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 100, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 100, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 104, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 104, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 104, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 108, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 108, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 108, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 112, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 112, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 112, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 116, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 116, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 116, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 131, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 131, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 131, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 138, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 138, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 138, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 142, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 142, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 142, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 160, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 160, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 160, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 164, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 164, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 164, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "social", - "traversal_index": 171, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 171, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 171, - "element_id": 268436176, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682934, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CB6.png", - "width": 30, - "height": 26, - "used_by_panels": [ - "character", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 53, - "element_id": 268436038, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 63, - "element_id": 268436038, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 104, - "element_id": 268436038, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 114, - "element_id": 268436038, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 454, - "element_id": 268436038, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 464, - "element_id": 268436038, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 505, - "element_id": 268436038, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 515, - "element_id": 268436038, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682935, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CB7.png", - "width": 30, - "height": 26, - "used_by_panels": [ - "character", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 53, - "element_id": 268436038, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 53, - "element_id": 268436038, - "state_id": 13 - }, - { - "panel_slug": "character", - "traversal_index": 63, - "element_id": 268436038, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 63, - "element_id": 268436038, - "state_id": 13 - }, - { - "panel_slug": "character", - "traversal_index": 104, - "element_id": 268436038, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 104, - "element_id": 268436038, - "state_id": 13 - }, - { - "panel_slug": "character", - "traversal_index": 114, - "element_id": 268436038, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 114, - "element_id": 268436038, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 454, - "element_id": 268436038, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 454, - "element_id": 268436038, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 464, - "element_id": 268436038, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 464, - "element_id": 268436038, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 505, - "element_id": 268436038, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 505, - "element_id": 268436038, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 515, - "element_id": 268436038, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 515, - "element_id": 268436038, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100682936, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CB8.png", - "width": 5, - "height": 10, - "used_by_panels": [ - "character", - "environment_panel", - "main_panel", - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 28, - "element_id": 268436025, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 28, - "element_id": 268436025, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 79, - "element_id": 268436025, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 79, - "element_id": 268436025, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 52, - "element_id": 268435581, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 52, - "element_id": 268435581, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 429, - "element_id": 268436025, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 429, - "element_id": 268436025, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 480, - "element_id": 268436025, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 480, - "element_id": 268436025, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 28, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 28, - "element_id": 268435688, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 29, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 29, - "element_id": 268435690, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 38, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 38, - "element_id": 268435688, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 39, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 39, - "element_id": 268435690, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682937, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CB9.png", - "width": 35, - "height": 27, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 54, - "element_id": 268435583, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100682938, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CBA.png", - "width": 35, - "height": 27, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 54, - "element_id": 268435583, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 54, - "element_id": 268435583, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100682939, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CBB.png", - "width": 46, - "height": 30, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 66, - "element_id": 268435590, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100682940, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CBC.png", - "width": 46, - "height": 30, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 66, - "element_id": 268435590, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 66, - "element_id": 268435590, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682941, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CBD.png", - "width": 46, - "height": 30, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 66, - "element_id": 268435590, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100682942, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CBE.png", - "width": 92, - "height": 20, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 80, - "element_id": 268435598, - "state_id": 12 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 81, - "element_id": 268435599, - "state_id": 12 - } - ] - }, - { - "key": { - "image_id": 100682943, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CBF.png", - "width": 92, - "height": 20, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 80, - "element_id": 268435598, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 80, - "element_id": 268435598, - "state_id": 11 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 81, - "element_id": 268435599, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 81, - "element_id": 268435599, - "state_id": 11 - } - ] - }, - { - "key": { - "image_id": 100682944, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CC0.png", - "width": 48, - "height": 48, - "used_by_panels": [ - "radar" - ], - "used_by_nodes": [ - { - "panel_slug": "radar", - "traversal_index": 1, - "element_id": 268435518, - "state_id": null - }, - { - "panel_slug": "radar", - "traversal_index": 1, - "element_id": 268435518, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682945, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CC1.png", - "width": 120, - "height": 120, - "used_by_panels": [ - "radar" - ], - "used_by_nodes": [ - { - "panel_slug": "radar", - "traversal_index": 2, - "element_id": 268435519, - "state_id": null - }, - { - "panel_slug": "radar", - "traversal_index": 2, - "element_id": 268435519, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682946, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CC2.png", - "width": 48, - "height": 48, - "used_by_panels": [ - "character", - "combat", - "environment_panel", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "inventory", - "main_chat", - "main_panel", - "map", - "options", - "quests", - "smartbox", - "social", - "spellbook", - "toolbar", - "vitae" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 15, - "element_id": 268436006, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 15, - "element_id": 268436006, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 66, - "element_id": 268436006, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 66, - "element_id": 268436006, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 116, - "element_id": 268436793, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 116, - "element_id": 268436793, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 121, - "element_id": 268436786, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 121, - "element_id": 268436786, - "state_id": 0 - }, - { - "panel_slug": "combat", - "traversal_index": 18, - "element_id": 268435532, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 18, - "element_id": 268435532, - "state_id": 0 - }, - { - "panel_slug": "combat", - "traversal_index": 19, - "element_id": 268435533, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 19, - "element_id": 268435533, - "state_id": 0 - }, - { - "panel_slug": "combat", - "traversal_index": 20, - "element_id": 268435534, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 20, - "element_id": 268435534, - "state_id": 0 - }, - { - "panel_slug": "combat", - "traversal_index": 40, - "element_id": 268435616, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 40, - "element_id": 268435616, - "state_id": 0 - }, - { - "panel_slug": "combat", - "traversal_index": 42, - "element_id": 268435617, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 42, - "element_id": 268435617, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 17, - "element_id": 268435549, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 17, - "element_id": 268435549, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 38, - "element_id": 268435550, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 38, - "element_id": 268435550, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 51, - "element_id": 268435579, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 51, - "element_id": 268435579, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 64, - "element_id": 268435587, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 64, - "element_id": 268435587, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 78, - "element_id": 268435552, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 78, - "element_id": 268435552, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 82, - "element_id": 268435600, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 82, - "element_id": 268435600, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 95, - "element_id": 268435607, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 95, - "element_id": 268435607, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 114, - "element_id": 268435644, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 114, - "element_id": 268435644, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 130, - "element_id": 268435652, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 130, - "element_id": 268435652, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 145, - "element_id": 268435661, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 145, - "element_id": 268435661, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 17, - "element_id": 268435757, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 17, - "element_id": 268435757, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 21, - "element_id": 268435758, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 21, - "element_id": 268435758, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 32, - "element_id": 268436984, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 32, - "element_id": 268436984, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 43, - "element_id": 268435777, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 43, - "element_id": 268435777, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 61, - "element_id": 268435796, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 61, - "element_id": 268435796, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 81, - "element_id": 268436272, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 81, - "element_id": 268436272, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 82, - "element_id": 268435814, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 82, - "element_id": 268435814, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 9, - "element_id": 268436697, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 9, - "element_id": 268436697, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 13, - "element_id": 268435472, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 13, - "element_id": 268435472, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 23, - "element_id": 268436745, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 23, - "element_id": 268436745, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 9, - "element_id": 268436697, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 9, - "element_id": 268436697, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 13, - "element_id": 268435472, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 13, - "element_id": 268435472, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 23, - "element_id": 268436745, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 23, - "element_id": 268436745, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 9, - "element_id": 268436697, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 9, - "element_id": 268436697, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 13, - "element_id": 268435472, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 13, - "element_id": 268435472, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 23, - "element_id": 268436745, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 23, - "element_id": 268436745, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 9, - "element_id": 268436697, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 9, - "element_id": 268436697, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 13, - "element_id": 268435472, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 13, - "element_id": 268435472, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 23, - "element_id": 268436745, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 23, - "element_id": 268436745, - "state_id": 0 - }, - { - "panel_slug": "inventory", - "traversal_index": 57, - "element_id": 268435922, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 57, - "element_id": 268435922, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 17, - "element_id": 268435472, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 17, - "element_id": 268435472, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 0, - "element_id": 268436990, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 0, - "element_id": 268436990, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 34, - "element_id": 268435717, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 34, - "element_id": 268435717, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 36, - "element_id": 268435719, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 36, - "element_id": 268435719, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 87, - "element_id": 268435740, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 87, - "element_id": 268435740, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 140, - "element_id": 268435816, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 140, - "element_id": 268435816, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 172, - "element_id": 268435898, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 172, - "element_id": 268435898, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 199, - "element_id": 268435906, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 199, - "element_id": 268435906, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 258, - "element_id": 268435922, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 258, - "element_id": 268435922, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 288, - "element_id": 268435959, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 288, - "element_id": 268435959, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 309, - "element_id": 268435985, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 309, - "element_id": 268435985, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 330, - "element_id": 268435986, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 330, - "element_id": 268435986, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 359, - "element_id": 268436748, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 359, - "element_id": 268436748, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 380, - "element_id": 268435987, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 380, - "element_id": 268435987, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 416, - "element_id": 268436006, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 416, - "element_id": 268436006, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 467, - "element_id": 268436006, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 467, - "element_id": 268436006, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 517, - "element_id": 268436793, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 517, - "element_id": 268436793, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 522, - "element_id": 268436786, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 522, - "element_id": 268436786, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 549, - "element_id": 268436948, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 549, - "element_id": 268436948, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 552, - "element_id": 268436943, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 552, - "element_id": 268436943, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 610, - "element_id": 268436836, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 610, - "element_id": 268436836, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 615, - "element_id": 268436867, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 615, - "element_id": 268436867, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 625, - "element_id": 268436871, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 625, - "element_id": 268436871, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 645, - "element_id": 268436113, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 645, - "element_id": 268436113, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 690, - "element_id": 268436114, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 690, - "element_id": 268436114, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 695, - "element_id": 268436079, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 695, - "element_id": 268436079, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 744, - "element_id": 268436810, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 744, - "element_id": 268436810, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 747, - "element_id": 268436798, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 747, - "element_id": 268436798, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 761, - "element_id": 268436800, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 761, - "element_id": 268436800, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 770, - "element_id": 268436755, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 770, - "element_id": 268436755, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 773, - "element_id": 268436759, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 773, - "element_id": 268436759, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 794, - "element_id": 268436763, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 794, - "element_id": 268436763, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 809, - "element_id": 268436140, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 809, - "element_id": 268436140, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 849, - "element_id": 268436141, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 849, - "element_id": 268436141, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 28, - "element_id": 268435959, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 28, - "element_id": 268435959, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 19, - "element_id": 268435985, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 19, - "element_id": 268435985, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 40, - "element_id": 268435986, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 40, - "element_id": 268435986, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 69, - "element_id": 268436748, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 69, - "element_id": 268436748, - "state_id": 0 - }, - { - "panel_slug": "options", - "traversal_index": 90, - "element_id": 268435987, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 90, - "element_id": 268435987, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 14, - "element_id": 268436948, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 14, - "element_id": 268436948, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 17, - "element_id": 268436943, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 17, - "element_id": 268436943, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 75, - "element_id": 268436836, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 75, - "element_id": 268436836, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 80, - "element_id": 268436867, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 80, - "element_id": 268436867, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 90, - "element_id": 268436871, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 90, - "element_id": 268436871, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 21, - "element_id": 268436890, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 21, - "element_id": 268436890, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 31, - "element_id": 268436904, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 31, - "element_id": 268436904, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 18, - "element_id": 268436113, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 18, - "element_id": 268436113, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 63, - "element_id": 268436114, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 63, - "element_id": 268436114, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 68, - "element_id": 268436079, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 68, - "element_id": 268436079, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 117, - "element_id": 268436810, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 117, - "element_id": 268436810, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 120, - "element_id": 268436798, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 120, - "element_id": 268436798, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 134, - "element_id": 268436800, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 134, - "element_id": 268436800, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 143, - "element_id": 268436755, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 143, - "element_id": 268436755, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 146, - "element_id": 268436759, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 146, - "element_id": 268436759, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 167, - "element_id": 268436763, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 167, - "element_id": 268436763, - "state_id": 0 - }, - { - "panel_slug": "spellbook", - "traversal_index": 10, - "element_id": 268436140, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 10, - "element_id": 268436140, - "state_id": 0 - }, - { - "panel_slug": "spellbook", - "traversal_index": 50, - "element_id": 268436141, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 50, - "element_id": 268436141, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 27, - "element_id": 268435878, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 27, - "element_id": 268435878, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 37, - "element_id": 268435888, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 37, - "element_id": 268435888, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 38, - "element_id": 268437174, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 38, - "element_id": 268437174, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 48, - "element_id": 268437184, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 48, - "element_id": 268437184, - "state_id": 0 - }, - { - "panel_slug": "vitae", - "traversal_index": 4, - "element_id": 268435906, - "state_id": null - }, - { - "panel_slug": "vitae", - "traversal_index": 4, - "element_id": 268435906, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682947, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CC3.png", - "width": 34, - "height": 34, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 22, - "element_id": 268436190, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 22, - "element_id": 268436190, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 32, - "element_id": 268436190, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 32, - "element_id": 268436190, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682948, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CC4.png", - "width": 34, - "height": 34, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 23, - "element_id": 268436191, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 23, - "element_id": 268436191, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 33, - "element_id": 268436191, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 33, - "element_id": 268436191, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682949, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CC5.png", - "width": 34, - "height": 35, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 24, - "element_id": 268436192, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 24, - "element_id": 268436192, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 34, - "element_id": 268436192, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 34, - "element_id": 268436192, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682950, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CC6.png", - "width": 34, - "height": 35, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 25, - "element_id": 268436193, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 25, - "element_id": 268436193, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 35, - "element_id": 268436193, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 35, - "element_id": 268436193, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682951, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CC7.png", - "width": 10, - "height": 7, - "used_by_panels": [ - "character", - "main_panel", - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 31, - "element_id": 268436028, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 31, - "element_id": 268436028, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 40, - "element_id": 268436031, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 40, - "element_id": 268436031, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 82, - "element_id": 268436028, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 82, - "element_id": 268436028, - "state_id": 0 - }, - { - "panel_slug": "character", - "traversal_index": 91, - "element_id": 268436031, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 91, - "element_id": 268436031, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 432, - "element_id": 268436028, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 432, - "element_id": 268436028, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 441, - "element_id": 268436031, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 441, - "element_id": 268436031, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 483, - "element_id": 268436028, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 483, - "element_id": 268436028, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 492, - "element_id": 268436031, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 492, - "element_id": 268436031, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 26, - "element_id": 268436194, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 26, - "element_id": 268436194, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 27, - "element_id": 268436195, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 27, - "element_id": 268436195, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 36, - "element_id": 268436194, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 36, - "element_id": 268436194, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 37, - "element_id": 268436195, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 37, - "element_id": 268436195, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682953, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CC9.png", - "width": 2, - "height": 2, - "used_by_panels": [ - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "main_panel", - "map" - ], - "used_by_nodes": [ - { - "panel_slug": "floating_chat_1", - "traversal_index": 26, - "element_id": 268436149, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 26, - "element_id": 268436149, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 27, - "element_id": 268435977, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 27, - "element_id": 268435977, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 28, - "element_id": 268435978, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 28, - "element_id": 268435978, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 29, - "element_id": 268435979, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 29, - "element_id": 268435979, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 26, - "element_id": 268436149, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 26, - "element_id": 268436149, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 27, - "element_id": 268435977, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 27, - "element_id": 268435977, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 28, - "element_id": 268435978, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 28, - "element_id": 268435978, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 29, - "element_id": 268435979, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 29, - "element_id": 268435979, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 26, - "element_id": 268436149, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 26, - "element_id": 268436149, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 27, - "element_id": 268435977, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 27, - "element_id": 268435977, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 28, - "element_id": 268435978, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 28, - "element_id": 268435978, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 29, - "element_id": 268435979, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 29, - "element_id": 268435979, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 26, - "element_id": 268436149, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 26, - "element_id": 268436149, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 27, - "element_id": 268435977, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 27, - "element_id": 268435977, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 28, - "element_id": 268435978, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 28, - "element_id": 268435978, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 29, - "element_id": 268435979, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 29, - "element_id": 268435979, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 283, - "element_id": 268436149, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 283, - "element_id": 268436149, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 284, - "element_id": 268435977, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 284, - "element_id": 268435977, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 285, - "element_id": 268435978, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 285, - "element_id": 268435978, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 286, - "element_id": 268435979, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 286, - "element_id": 268435979, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 23, - "element_id": 268436149, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 23, - "element_id": 268436149, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 24, - "element_id": 268435977, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 24, - "element_id": 268435977, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 25, - "element_id": 268435978, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 25, - "element_id": 268435978, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 26, - "element_id": 268435979, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 26, - "element_id": 268435979, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682954, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CCA.png", - "width": 48, - "height": 48, - "used_by_panels": [ - "main_panel", - "quests" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 583, - "element_id": 268436841, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 583, - "element_id": 268436841, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 585, - "element_id": 268436843, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 585, - "element_id": 268436843, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 587, - "element_id": 268436845, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 587, - "element_id": 268436845, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 599, - "element_id": 268436851, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 599, - "element_id": 268436851, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 602, - "element_id": 268436854, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 602, - "element_id": 268436854, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 604, - "element_id": 268436856, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 604, - "element_id": 268436856, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 606, - "element_id": 268436858, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 606, - "element_id": 268436858, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 48, - "element_id": 268436841, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 48, - "element_id": 268436841, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 50, - "element_id": 268436843, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 50, - "element_id": 268436843, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 52, - "element_id": 268436845, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 52, - "element_id": 268436845, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 64, - "element_id": 268436851, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 64, - "element_id": 268436851, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 67, - "element_id": 268436854, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 67, - "element_id": 268436854, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 69, - "element_id": 268436856, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 69, - "element_id": 268436856, - "state_id": 0 - }, - { - "panel_slug": "quests", - "traversal_index": 71, - "element_id": 268436858, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 71, - "element_id": 268436858, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682970, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CDA.png", - "width": 100, - "height": 32, - "used_by_panels": [ - "main_panel", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 847, - "element_id": 268436133, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 847, - "element_id": 268436133, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 48, - "element_id": 268436133, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 48, - "element_id": 268436133, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682971, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CDB.png", - "width": 100, - "height": 32, - "used_by_panels": [ - "main_panel", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 847, - "element_id": 268436133, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 48, - "element_id": 268436133, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682972, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CDC.png", - "width": 23, - "height": 36, - "used_by_panels": [ - "combat", - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 54, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 54, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 54, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 59, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 59, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 59, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 64, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 64, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 64, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 69, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 69, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 69, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 74, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 74, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 74, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 79, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 79, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 79, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 84, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 84, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 84, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 89, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 89, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 89, - "element_id": 268435569, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 21, - "element_id": 268435569, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682973, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CDD.png", - "width": 23, - "height": 36, - "used_by_panels": [ - "combat", - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 54, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 59, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 64, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 69, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 74, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 79, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 84, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 89, - "element_id": 268435569, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 21, - "element_id": 268435569, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682974, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CDE.png", - "width": 23, - "height": 36, - "used_by_panels": [ - "combat", - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 55, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 55, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 55, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 60, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 60, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 60, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 65, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 65, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 65, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 70, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 70, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 70, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 75, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 75, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 75, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 80, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 80, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 80, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 85, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 85, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 85, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 90, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 90, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 90, - "element_id": 268435570, - "state_id": 3 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 22, - "element_id": 268435570, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 1 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682975, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CDF.png", - "width": 23, - "height": 36, - "used_by_panels": [ - "combat", - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 55, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 60, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 65, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 70, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 75, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 80, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 85, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "combat", - "traversal_index": 90, - "element_id": 268435570, - "state_id": 2 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 22, - "element_id": 268435570, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100682984, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CE8.png", - "width": 20, - "height": 28, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 28, - "element_id": 268435698, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 28, - "element_id": 268435698, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682988, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CEC.png", - "width": 55, - "height": 58, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 2, - "element_id": 268435858, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 2, - "element_id": 268435858, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682989, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CED.png", - "width": 55, - "height": 58, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 2, - "element_id": 268435858, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682990, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CEE.png", - "width": 55, - "height": 58, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 3, - "element_id": 268435859, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 3, - "element_id": 268435859, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682991, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CEF.png", - "width": 55, - "height": 58, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 3, - "element_id": 268435859, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682992, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CF0.png", - "width": 55, - "height": 58, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 4, - "element_id": 268435860, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 4, - "element_id": 268435860, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682993, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CF1.png", - "width": 55, - "height": 58, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 4, - "element_id": 268435860, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682994, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CF2.png", - "width": 55, - "height": 58, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 5, - "element_id": 268435861, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 5, - "element_id": 268435861, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100682995, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CF3.png", - "width": 55, - "height": 58, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 5, - "element_id": 268435861, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100682996, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CF4.png", - "width": 146, - "height": 31, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 17, - "element_id": 268435872, - "state_id": 268435468 - } - ] - }, - { - "key": { - "image_id": 100682997, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CF5.png", - "width": 50, - "height": 14, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 22, - "element_id": 268435875, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 22, - "element_id": 268435875, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682998, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CF6.png", - "width": 140, - "height": 14, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 24, - "element_id": 4, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 24, - "element_id": 4, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100682999, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CF7.png", - "width": 63, - "height": 58, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 49, - "element_id": 268435889, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 49, - "element_id": 268435889, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100683000, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CF8.png", - "width": 63, - "height": 58, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 49, - "element_id": 268435889, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100683002, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CFA.png", - "width": 276, - "height": 25, - "used_by_panels": [ - "inventory", - "main_panel", - "negative_effects", - "positive_effects", - "vitae" - ], - "used_by_nodes": [ - { - "panel_slug": "inventory", - "traversal_index": 58, - "element_id": 268435923, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 58, - "element_id": 268435923, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 20, - "element_id": 268435709, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 20, - "element_id": 268435709, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 86, - "element_id": 268435710, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 86, - "element_id": 268435710, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 98, - "element_id": 268435744, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 98, - "element_id": 268435744, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 118, - "element_id": 268435744, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 118, - "element_id": 268435744, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 138, - "element_id": 268435709, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 138, - "element_id": 268435709, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 144, - "element_id": 268435820, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 144, - "element_id": 268435820, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 158, - "element_id": 268435709, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 158, - "element_id": 268435709, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 197, - "element_id": 268435709, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 197, - "element_id": 268435709, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 259, - "element_id": 268435923, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 259, - "element_id": 268435923, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 2, - "element_id": 268435744, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 2, - "element_id": 268435744, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 22, - "element_id": 268435744, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 22, - "element_id": 268435744, - "state_id": 0 - }, - { - "panel_slug": "negative_effects", - "traversal_index": 42, - "element_id": 268435744, - "state_id": null - }, - { - "panel_slug": "negative_effects", - "traversal_index": 42, - "element_id": 268435744, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 2, - "element_id": 268435744, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 2, - "element_id": 268435744, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 22, - "element_id": 268435744, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 22, - "element_id": 268435744, - "state_id": 0 - }, - { - "panel_slug": "positive_effects", - "traversal_index": 42, - "element_id": 268435744, - "state_id": null - }, - { - "panel_slug": "positive_effects", - "traversal_index": 42, - "element_id": 268435744, - "state_id": 0 - }, - { - "panel_slug": "vitae", - "traversal_index": 2, - "element_id": 268435709, - "state_id": null - }, - { - "panel_slug": "vitae", - "traversal_index": 2, - "element_id": 268435709, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683004, - "alpha_image_id": null - }, - "relative_path": "images/0x06004CFC.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "examine" - ], - "used_by_nodes": [ - { - "panel_slug": "examine", - "traversal_index": 66, - "element_id": 268435807, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 66, - "element_id": 268435807, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683017, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D09.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "combat" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 95, - "element_id": 268436564, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 95, - "element_id": 268436564, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683018, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D0A.png", - "width": 300, - "height": 362, - "used_by_panels": [ - "inventory", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "inventory", - "traversal_index": 55, - "element_id": 268435920, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 55, - "element_id": 268435920, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 256, - "element_id": 268435920, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 256, - "element_id": 268435920, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683019, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D0B.png", - "width": 48, - "height": 48, - "used_by_panels": [ - "inventory", - "main_panel", - "powerbar", - "smartbox", - "social" - ], - "used_by_nodes": [ - { - "panel_slug": "inventory", - "traversal_index": 56, - "element_id": 268435921, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 56, - "element_id": 268435921, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 257, - "element_id": 268435921, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 257, - "element_id": 268435921, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 668, - "element_id": 268436064, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 668, - "element_id": 268436064, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 712, - "element_id": 268436089, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 712, - "element_id": 268436089, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 17, - "element_id": 268435508, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 17, - "element_id": 268435508, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 16, - "element_id": 268435508, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 16, - "element_id": 268435508, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 41, - "element_id": 268436064, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 41, - "element_id": 268436064, - "state_id": 0 - }, - { - "panel_slug": "social", - "traversal_index": 85, - "element_id": 268436089, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 85, - "element_id": 268436089, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683020, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D0C.png", - "width": 24, - "height": 25, - "used_by_panels": [ - "inventory", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "inventory", - "traversal_index": 57, - "element_id": 268435922, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 258, - "element_id": 268435922, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100683021, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D0D.png", - "width": 24, - "height": 25, - "used_by_panels": [ - "inventory", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "inventory", - "traversal_index": 57, - "element_id": 268435922, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 258, - "element_id": 268435922, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100683024, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D10.png", - "width": 17, - "height": 16, - "used_by_panels": [ - "main_panel", - "map" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 280, - "element_id": 268435949, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 280, - "element_id": 268435949, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 20, - "element_id": 268435949, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 20, - "element_id": 268435949, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683025, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D11.png", - "width": 8, - "height": 8, - "used_by_panels": [ - "main_panel", - "map" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 281, - "element_id": 268435950, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 281, - "element_id": 268435950, - "state_id": 0 - }, - { - "panel_slug": "map", - "traversal_index": 21, - "element_id": 268435950, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 21, - "element_id": 268435950, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683029, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D15.png", - "width": 13, - "height": 13, - "used_by_panels": [ - "combat", - "inventory", - "main_panel", - "options", - "smartbox", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 30, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 30, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 32, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 32, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 34, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 34, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "inventory", - "traversal_index": 6, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "inventory", - "traversal_index": 6, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 207, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 207, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 677, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 677, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 697, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 697, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 699, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 699, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 701, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 701, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 703, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 703, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 783, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 783, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 820, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 820, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 822, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 822, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 824, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 824, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 826, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 826, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 828, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 828, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 830, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 830, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 832, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 832, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 834, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 834, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 836, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 836, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 838, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 838, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 840, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 840, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 842, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 842, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 844, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 844, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 115, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 115, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 128, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 128, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 133, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 133, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 146, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 146, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 82, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 82, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 84, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 84, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "smartbox", - "traversal_index": 86, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 86, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 50, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 50, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 70, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 70, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 72, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 72, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 74, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 74, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 76, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 76, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "social", - "traversal_index": 156, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 156, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 21, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 21, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 23, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 23, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 25, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 25, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 27, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 27, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 29, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 29, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 31, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 31, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 33, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 33, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 35, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 35, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 37, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 37, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 39, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 39, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 41, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 41, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 43, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 43, - "element_id": 268436264, - "state_id": 1 - }, - { - "panel_slug": "spellbook", - "traversal_index": 45, - "element_id": 268436264, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 45, - "element_id": 268436264, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100683030, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D16.png", - "width": 13, - "height": 13, - "used_by_panels": [ - "combat", - "inventory", - "main_panel", - "options", - "smartbox", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 30, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 32, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 34, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "inventory", - "traversal_index": 6, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 207, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 677, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 697, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 699, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 701, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 703, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 783, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 820, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 822, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 824, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 826, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 828, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 830, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 832, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 834, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 836, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 838, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 840, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 842, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 844, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 115, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 128, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 133, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 146, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "smartbox", - "traversal_index": 82, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "smartbox", - "traversal_index": 84, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "smartbox", - "traversal_index": 86, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 50, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 70, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 72, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 74, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 76, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "social", - "traversal_index": 156, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 21, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 23, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 25, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 27, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 29, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 31, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 33, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 35, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 37, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 39, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 41, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 43, - "element_id": 268436264, - "state_id": 3 - }, - { - "panel_slug": "spellbook", - "traversal_index": 45, - "element_id": 268436264, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100683031, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D17.png", - "width": 13, - "height": 13, - "used_by_panels": [ - "combat", - "inventory", - "main_panel", - "options", - "smartbox", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 30, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "combat", - "traversal_index": 32, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "combat", - "traversal_index": 34, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "inventory", - "traversal_index": 6, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 207, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 677, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 697, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 699, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 701, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 703, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 783, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 820, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 822, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 824, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 826, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 828, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 830, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 832, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 834, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 836, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 838, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 840, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 842, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "main_panel", - "traversal_index": 844, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "options", - "traversal_index": 115, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "options", - "traversal_index": 128, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "options", - "traversal_index": 133, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "options", - "traversal_index": 146, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "smartbox", - "traversal_index": 82, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "smartbox", - "traversal_index": 84, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "smartbox", - "traversal_index": 86, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "social", - "traversal_index": 50, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "social", - "traversal_index": 70, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "social", - "traversal_index": 72, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "social", - "traversal_index": 74, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "social", - "traversal_index": 76, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "social", - "traversal_index": 156, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 21, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 23, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 25, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 27, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 29, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 31, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 33, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 35, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 37, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 39, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 41, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 43, - "element_id": 268436264, - "state_id": 6 - }, - { - "panel_slug": "spellbook", - "traversal_index": 45, - "element_id": 268436264, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100683032, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D18.png", - "width": 13, - "height": 13, - "used_by_panels": [ - "combat", - "inventory", - "main_panel", - "options", - "smartbox", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 30, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "combat", - "traversal_index": 32, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "combat", - "traversal_index": 34, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "inventory", - "traversal_index": 6, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 207, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 677, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 697, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 699, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 701, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 703, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 783, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 820, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 822, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 824, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 826, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 828, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 830, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 832, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 834, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 836, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 838, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 840, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 842, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "main_panel", - "traversal_index": 844, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "options", - "traversal_index": 115, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "options", - "traversal_index": 128, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "options", - "traversal_index": 133, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "options", - "traversal_index": 146, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "smartbox", - "traversal_index": 82, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "smartbox", - "traversal_index": 84, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "smartbox", - "traversal_index": 86, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "social", - "traversal_index": 50, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "social", - "traversal_index": 70, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "social", - "traversal_index": 72, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "social", - "traversal_index": 74, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "social", - "traversal_index": 76, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "social", - "traversal_index": 156, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 21, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 23, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 25, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 27, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 29, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 31, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 33, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 35, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 37, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 39, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 41, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 43, - "element_id": 268436264, - "state_id": 8 - }, - { - "panel_slug": "spellbook", - "traversal_index": 45, - "element_id": 268436264, - "state_id": 8 - } - ] - }, - { - "key": { - "image_id": 100683033, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D19.png", - "width": 13, - "height": 13, - "used_by_panels": [ - "combat", - "inventory", - "main_panel", - "options", - "smartbox", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 30, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "combat", - "traversal_index": 32, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "combat", - "traversal_index": 34, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "inventory", - "traversal_index": 6, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 207, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 677, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 697, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 699, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 701, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 703, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 783, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 820, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 822, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 824, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 826, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 828, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 830, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 832, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 834, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 836, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 838, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 840, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 842, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 844, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 115, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 128, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 133, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 146, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 82, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 84, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 86, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 50, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 70, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 72, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 74, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 76, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "social", - "traversal_index": 156, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 21, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 23, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 25, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 27, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 29, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 31, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 33, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 35, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 37, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 39, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 41, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 43, - "element_id": 268436264, - "state_id": 13 - }, - { - "panel_slug": "spellbook", - "traversal_index": 45, - "element_id": 268436264, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100683036, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D1C.png", - "width": 72, - "height": 19, - "used_by_panels": [ - "combat" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 36, - "element_id": 268435543, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 36, - "element_id": 268435543, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 37, - "element_id": 268435544, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 37, - "element_id": 268435544, - "state_id": 1 - }, - { - "panel_slug": "combat", - "traversal_index": 38, - "element_id": 268435545, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 38, - "element_id": 268435545, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100683037, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D1D.png", - "width": 72, - "height": 19, - "used_by_panels": [ - "combat" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 36, - "element_id": 268435543, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 37, - "element_id": 268435544, - "state_id": 3 - }, - { - "panel_slug": "combat", - "traversal_index": 38, - "element_id": 268435545, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100683038, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D1E.png", - "width": 72, - "height": 19, - "used_by_panels": [ - "combat" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 36, - "element_id": 268435543, - "state_id": 6 - }, - { - "panel_slug": "combat", - "traversal_index": 37, - "element_id": 268435544, - "state_id": 6 - }, - { - "panel_slug": "combat", - "traversal_index": 38, - "element_id": 268435545, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100683039, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D1F.png", - "width": 72, - "height": 19, - "used_by_panels": [ - "combat" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 36, - "element_id": 268435543, - "state_id": 8 - }, - { - "panel_slug": "combat", - "traversal_index": 37, - "element_id": 268435544, - "state_id": 8 - }, - { - "panel_slug": "combat", - "traversal_index": 38, - "element_id": 268435545, - "state_id": 8 - } - ] - }, - { - "key": { - "image_id": 100683076, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D44.png", - "width": 110, - "height": 112, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 41, - "element_id": 268436237, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 41, - "element_id": 268436237, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683077, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D45.png", - "width": 110, - "height": 112, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 42, - "element_id": 268436238, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 42, - "element_id": 268436238, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683078, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D46.png", - "width": 37, - "height": 44, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 43, - "element_id": 268436239, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 43, - "element_id": 268436239, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 44, - "element_id": 268436240, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 44, - "element_id": 268436240, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 45, - "element_id": 268436241, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 45, - "element_id": 268436241, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 46, - "element_id": 268436242, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 46, - "element_id": 268436242, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 47, - "element_id": 268436243, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 47, - "element_id": 268436243, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 48, - "element_id": 268436244, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 48, - "element_id": 268436244, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 49, - "element_id": 268436245, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 49, - "element_id": 268436245, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 50, - "element_id": 268436246, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 50, - "element_id": 268436246, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 51, - "element_id": 268436247, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 51, - "element_id": 268436247, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683079, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D47.png", - "width": 33, - "height": 29, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 52, - "element_id": 268436248, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 52, - "element_id": 268436248, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683080, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D48.png", - "width": 31, - "height": 30, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 53, - "element_id": 268436249, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 53, - "element_id": 268436249, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683081, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D49.png", - "width": 34, - "height": 34, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 54, - "element_id": 268436250, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 54, - "element_id": 268436250, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683082, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D4A.png", - "width": 31, - "height": 32, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 55, - "element_id": 268436251, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 55, - "element_id": 268436251, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683083, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D4B.png", - "width": 36, - "height": 32, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 56, - "element_id": 268436252, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 56, - "element_id": 268436252, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683084, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D4C.png", - "width": 35, - "height": 32, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 57, - "element_id": 268436253, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 57, - "element_id": 268436253, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683085, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D4D.png", - "width": 29, - "height": 36, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 58, - "element_id": 268436254, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 58, - "element_id": 268436254, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683086, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D4E.png", - "width": 30, - "height": 35, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 59, - "element_id": 268436255, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 59, - "element_id": 268436255, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683087, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D4F.png", - "width": 32, - "height": 31, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 60, - "element_id": 268436256, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 60, - "element_id": 268436256, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683088, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D50.png", - "width": 33, - "height": 21, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 62, - "element_id": 1, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 62, - "element_id": 1, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100683089, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D51.png", - "width": 60, - "height": 54, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 79, - "element_id": 268436900, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 79, - "element_id": 268436900, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100683090, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D52.png", - "width": 60, - "height": 54, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 79, - "element_id": 268436900, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100683091, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D53.png", - "width": 59, - "height": 54, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 80, - "element_id": 268436901, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 80, - "element_id": 268436901, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100683092, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D54.png", - "width": 59, - "height": 54, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 80, - "element_id": 268436901, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100683109, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D65.png", - "width": 46, - "height": 17, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 33, - "element_id": 268435476, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 33, - "element_id": 268435476, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100683110, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D66.png", - "width": 46, - "height": 17, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 33, - "element_id": 268435476, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100683111, - "alpha_image_id": null - }, - "relative_path": "images/0x06004D67.png", - "width": 1, - "height": 17, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 36, - "element_id": 268435479, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 36, - "element_id": 268435479, - "state_id": 4 - }, - { - "panel_slug": "main_chat", - "traversal_index": 37, - "element_id": 268435480, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 37, - "element_id": 268435480, - "state_id": 4 - } - ] - }, - { - "key": { - "image_id": 100687250, - "alpha_image_id": null - }, - "relative_path": "images/0x06005D92.png", - "width": 17, - "height": 25, - "used_by_panels": [ - "character", - "main_panel", - "map", - "options", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "character", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "character", - "traversal_index": 10, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 262, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 266, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 292, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 296, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 300, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 304, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 403, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 407, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 411, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 537, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 541, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 545, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 629, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 633, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 637, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 641, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 801, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 805, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "map", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "map", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 10, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 14, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "quests", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "quests", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "quests", - "traversal_index": 10, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 10, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 14, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "spellbook", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 12 - }, - { - "panel_slug": "spellbook", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 12 - } - ] - }, - { - "key": { - "image_id": 100687251, - "alpha_image_id": null - }, - "relative_path": "images/0x06005D93.png", - "width": 17, - "height": 25, - "used_by_panels": [ - "character", - "main_panel", - "map", - "options", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 2, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "character", - "traversal_index": 6, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "character", - "traversal_index": 10, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 10, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 262, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 262, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 266, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 266, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 292, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 292, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 296, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 296, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 300, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 300, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 304, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 304, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 403, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 403, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 407, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 407, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 411, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 411, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 537, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 537, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 541, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 541, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 545, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 545, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 629, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 629, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 633, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 633, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 637, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 637, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 641, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 641, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 801, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 801, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 805, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 805, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "map", - "traversal_index": 2, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "map", - "traversal_index": 6, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 2, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 6, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 10, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 10, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 14, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 14, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "quests", - "traversal_index": 2, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "quests", - "traversal_index": 6, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "quests", - "traversal_index": 10, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 10, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 2, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 6, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 10, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 10, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 14, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 14, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "spellbook", - "traversal_index": 2, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 2, - "element_id": 268436537, - "state_id": 11 - }, - { - "panel_slug": "spellbook", - "traversal_index": 6, - "element_id": 268436537, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 6, - "element_id": 268436537, - "state_id": 11 - } - ] - }, - { - "key": { - "image_id": 100687252, - "alpha_image_id": null - }, - "relative_path": "images/0x06005D94.png", - "width": 12, - "height": 25, - "used_by_panels": [ - "character", - "main_panel", - "map", - "options", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "character", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "character", - "traversal_index": 11, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 263, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 267, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 293, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 297, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 301, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 305, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 404, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 408, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 412, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 538, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 542, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 546, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 630, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 634, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 638, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 642, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 802, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 806, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "map", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "map", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 11, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 15, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "quests", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "quests", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "quests", - "traversal_index": 11, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 11, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 15, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "spellbook", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 12 - }, - { - "panel_slug": "spellbook", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 12 - } - ] - }, - { - "key": { - "image_id": 100687253, - "alpha_image_id": null - }, - "relative_path": "images/0x06005D95.png", - "width": 12, - "height": 25, - "used_by_panels": [ - "character", - "main_panel", - "map", - "options", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 3, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "character", - "traversal_index": 7, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "character", - "traversal_index": 11, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 11, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 263, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 263, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 267, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 267, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 293, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 293, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 297, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 297, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 301, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 301, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 305, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 305, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 404, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 404, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 408, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 408, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 412, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 412, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 538, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 538, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 542, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 542, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 546, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 546, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 630, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 630, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 634, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 634, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 638, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 638, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 642, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 642, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 802, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 802, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 806, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 806, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "map", - "traversal_index": 3, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "map", - "traversal_index": 7, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 3, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 7, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 11, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 11, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 15, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 15, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "quests", - "traversal_index": 3, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "quests", - "traversal_index": 7, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "quests", - "traversal_index": 11, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 11, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 3, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 7, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 11, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 11, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 15, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 15, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "spellbook", - "traversal_index": 3, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 3, - "element_id": 268435689, - "state_id": 11 - }, - { - "panel_slug": "spellbook", - "traversal_index": 7, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 7, - "element_id": 268435689, - "state_id": 11 - } - ] - }, - { - "key": { - "image_id": 100687254, - "alpha_image_id": null - }, - "relative_path": "images/0x06005D96.png", - "width": 17, - "height": 25, - "used_by_panels": [ - "character", - "main_panel", - "map", - "options", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "character", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "character", - "traversal_index": 12, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 264, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 268, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 294, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 298, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 302, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 306, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 405, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 409, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 413, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 539, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 543, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 547, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 631, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 635, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 639, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 643, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 803, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "main_panel", - "traversal_index": 807, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "map", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "map", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 12, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "options", - "traversal_index": 16, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "quests", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "quests", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "quests", - "traversal_index": 12, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 12, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "social", - "traversal_index": 16, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "spellbook", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 12 - }, - { - "panel_slug": "spellbook", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 12 - } - ] - }, - { - "key": { - "image_id": 100687255, - "alpha_image_id": null - }, - "relative_path": "images/0x06005D97.png", - "width": 17, - "height": 25, - "used_by_panels": [ - "character", - "main_panel", - "map", - "options", - "quests", - "social", - "spellbook" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 4, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "character", - "traversal_index": 8, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "character", - "traversal_index": 12, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 12, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 264, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 264, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 268, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 268, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 294, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 294, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 298, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 298, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 302, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 302, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 306, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 306, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 405, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 405, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 409, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 409, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 413, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 413, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 539, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 539, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 543, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 543, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 547, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 547, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 631, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 631, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 635, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 635, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 639, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 639, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 643, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 643, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 803, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 803, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "main_panel", - "traversal_index": 807, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 807, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "map", - "traversal_index": 4, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "map", - "traversal_index": 8, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "map", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 4, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 8, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 12, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 12, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "options", - "traversal_index": 16, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 16, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "quests", - "traversal_index": 4, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "quests", - "traversal_index": 8, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "quests", - "traversal_index": 12, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "quests", - "traversal_index": 12, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 4, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 8, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 12, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 12, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "social", - "traversal_index": 16, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "social", - "traversal_index": 16, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "spellbook", - "traversal_index": 4, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 4, - "element_id": 268435989, - "state_id": 11 - }, - { - "panel_slug": "spellbook", - "traversal_index": 8, - "element_id": 268435989, - "state_id": null - }, - { - "panel_slug": "spellbook", - "traversal_index": 8, - "element_id": 268435989, - "state_id": 11 - } - ] - }, - { - "key": { - "image_id": 100687271, - "alpha_image_id": null - }, - "relative_path": "images/0x06005DA7.png", - "width": 60, - "height": 54, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 79, - "element_id": 268436900, - "state_id": 6 - }, - { - "panel_slug": "smartbox", - "traversal_index": 79, - "element_id": 268436900, - "state_id": 7 - } - ] - }, - { - "key": { - "image_id": 100687272, - "alpha_image_id": null - }, - "relative_path": "images/0x06005DA8.png", - "width": 59, - "height": 54, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 80, - "element_id": 268436901, - "state_id": 6 - }, - { - "panel_slug": "smartbox", - "traversal_index": 80, - "element_id": 268436901, - "state_id": 7 - } - ] - }, - { - "key": { - "image_id": 100687299, - "alpha_image_id": null - }, - "relative_path": "images/0x06005DC3.png", - "width": 16, - "height": 14, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 25, - "element_id": 1, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 25, - "element_id": 1, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100687460, - "alpha_image_id": null - }, - "relative_path": "images/0x06005E64.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 27, - "element_id": 268436591, - "state_id": 268435527 - } - ] - }, - { - "key": { - "image_id": 100687461, - "alpha_image_id": null - }, - "relative_path": "images/0x06005E65.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 27, - "element_id": 268436591, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 27, - "element_id": 268436591, - "state_id": 268435528 - } - ] - }, - { - "key": { - "image_id": 100687544, - "alpha_image_id": null - }, - "relative_path": "images/0x06005EB8.png", - "width": 47, - "height": 20, - "used_by_panels": [ - "combat" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 44, - "element_id": 268435619, - "state_id": 12 - }, - { - "panel_slug": "combat", - "traversal_index": 45, - "element_id": 268435620, - "state_id": 12 - }, - { - "panel_slug": "combat", - "traversal_index": 46, - "element_id": 268435621, - "state_id": 12 - }, - { - "panel_slug": "combat", - "traversal_index": 47, - "element_id": 268435622, - "state_id": 12 - }, - { - "panel_slug": "combat", - "traversal_index": 48, - "element_id": 268435623, - "state_id": 12 - }, - { - "panel_slug": "combat", - "traversal_index": 49, - "element_id": 268435624, - "state_id": 12 - }, - { - "panel_slug": "combat", - "traversal_index": 50, - "element_id": 268435625, - "state_id": 12 - }, - { - "panel_slug": "combat", - "traversal_index": 51, - "element_id": 268436930, - "state_id": 12 - } - ] - }, - { - "key": { - "image_id": 100687545, - "alpha_image_id": null - }, - "relative_path": "images/0x06005EB9.png", - "width": 47, - "height": 20, - "used_by_panels": [ - "combat" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 44, - "element_id": 268435619, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 44, - "element_id": 268435619, - "state_id": 11 - }, - { - "panel_slug": "combat", - "traversal_index": 45, - "element_id": 268435620, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 45, - "element_id": 268435620, - "state_id": 11 - }, - { - "panel_slug": "combat", - "traversal_index": 46, - "element_id": 268435621, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 46, - "element_id": 268435621, - "state_id": 11 - }, - { - "panel_slug": "combat", - "traversal_index": 47, - "element_id": 268435622, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 47, - "element_id": 268435622, - "state_id": 11 - }, - { - "panel_slug": "combat", - "traversal_index": 48, - "element_id": 268435623, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 48, - "element_id": 268435623, - "state_id": 11 - }, - { - "panel_slug": "combat", - "traversal_index": 49, - "element_id": 268435624, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 49, - "element_id": 268435624, - "state_id": 11 - }, - { - "panel_slug": "combat", - "traversal_index": 50, - "element_id": 268435625, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 50, - "element_id": 268435625, - "state_id": 11 - }, - { - "panel_slug": "combat", - "traversal_index": 51, - "element_id": 268436930, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 51, - "element_id": 268436930, - "state_id": 11 - } - ] - }, - { - "key": { - "image_id": 100687630, - "alpha_image_id": null - }, - "relative_path": "images/0x06005F0E.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "floating_chat_1", - "traversal_index": 15, - "element_id": 268436620, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 15, - "element_id": 268436620, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 15, - "element_id": 268436620, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 15, - "element_id": 268436620, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 15, - "element_id": 268436620, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 15, - "element_id": 268436620, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 15, - "element_id": 268436620, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 15, - "element_id": 268436620, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 15, - "element_id": 268436620, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 15, - "element_id": 268436620, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 15, - "element_id": 268436620, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 15, - "element_id": 268436620, - "state_id": 3 - }, - { - "panel_slug": "main_chat", - "traversal_index": 19, - "element_id": 268436620, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 19, - "element_id": 268436620, - "state_id": 1 - }, - { - "panel_slug": "main_chat", - "traversal_index": 19, - "element_id": 268436620, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100687632, - "alpha_image_id": null - }, - "relative_path": "images/0x06005F10.png", - "width": 64, - "height": 20, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 160, - "element_id": 268435597, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 160, - "element_id": 268435597, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100687633, - "alpha_image_id": null - }, - "relative_path": "images/0x06005F11.png", - "width": 92, - "height": 20, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 111, - "element_id": 268435641, - "state_id": 12 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 112, - "element_id": 268435642, - "state_id": 12 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 113, - "element_id": 268435643, - "state_id": 12 - } - ] - }, - { - "key": { - "image_id": 100687634, - "alpha_image_id": null - }, - "relative_path": "images/0x06005F12.png", - "width": 92, - "height": 20, - "used_by_panels": [ - "environment_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "environment_panel", - "traversal_index": 111, - "element_id": 268435641, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 111, - "element_id": 268435641, - "state_id": 11 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 112, - "element_id": 268435642, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 112, - "element_id": 268435642, - "state_id": 11 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 113, - "element_id": 268435643, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 113, - "element_id": 268435643, - "state_id": 11 - } - ] - }, - { - "key": { - "image_id": 100687635, - "alpha_image_id": null - }, - "relative_path": "images/0x06005F13.png", - "width": 47, - "height": 44, - "used_by_panels": [ - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "smartbox", - "traversal_index": 66, - "element_id": 268436235, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 69, - "element_id": 268436235, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 72, - "element_id": 268436235, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 75, - "element_id": 268436235, - "state_id": 13 - }, - { - "panel_slug": "smartbox", - "traversal_index": 78, - "element_id": 268436235, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100688169, - "alpha_image_id": null - }, - "relative_path": "images/0x06006129.png", - "width": 5, - "height": 5, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "smartbox", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 9, - "element_id": 268437165, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 9, - "element_id": 268437165, - "state_id": 0 - }, - { - "panel_slug": "combat", - "traversal_index": 11, - "element_id": 268437167, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 11, - "element_id": 268437167, - "state_id": 0 - }, - { - "panel_slug": "combat", - "traversal_index": 13, - "element_id": 268437169, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 13, - "element_id": 268437169, - "state_id": 0 - }, - { - "panel_slug": "combat", - "traversal_index": 15, - "element_id": 268437171, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 15, - "element_id": 268437171, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 9, - "element_id": 268437099, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 9, - "element_id": 268437099, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 11, - "element_id": 268437101, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 11, - "element_id": 268437101, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 13, - "element_id": 268437103, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 13, - "element_id": 268437103, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 15, - "element_id": 268437105, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 15, - "element_id": 268437105, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 9, - "element_id": 268437115, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 9, - "element_id": 268437115, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 11, - "element_id": 268437117, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 11, - "element_id": 268437117, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 13, - "element_id": 268437119, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 13, - "element_id": 268437119, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 15, - "element_id": 268437121, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 15, - "element_id": 268437121, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 1, - "element_id": 268436732, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 1, - "element_id": 268436732, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 3, - "element_id": 268436734, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 3, - "element_id": 268436734, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 5, - "element_id": 268436737, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 5, - "element_id": 268436737, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 7, - "element_id": 268436739, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 7, - "element_id": 268436739, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 1, - "element_id": 268436732, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 1, - "element_id": 268436732, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 3, - "element_id": 268436734, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 3, - "element_id": 268436734, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 5, - "element_id": 268436737, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 5, - "element_id": 268436737, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 7, - "element_id": 268436739, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 7, - "element_id": 268436739, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 1, - "element_id": 268436732, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 1, - "element_id": 268436732, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 3, - "element_id": 268436734, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 3, - "element_id": 268436734, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 5, - "element_id": 268436737, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 5, - "element_id": 268436737, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 7, - "element_id": 268436739, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 7, - "element_id": 268436739, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 1, - "element_id": 268436732, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 1, - "element_id": 268436732, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 3, - "element_id": 268436734, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 3, - "element_id": 268436734, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 5, - "element_id": 268436737, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 5, - "element_id": 268436737, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 7, - "element_id": 268436739, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 7, - "element_id": 268436739, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 9, - "element_id": 268437067, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 9, - "element_id": 268437067, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 11, - "element_id": 268437069, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 11, - "element_id": 268437069, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 13, - "element_id": 268437071, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 13, - "element_id": 268437071, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 15, - "element_id": 268437073, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 15, - "element_id": 268437073, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 9, - "element_id": 268437147, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 9, - "element_id": 268437147, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 11, - "element_id": 268437149, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 11, - "element_id": 268437149, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 13, - "element_id": 268437151, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 13, - "element_id": 268437151, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 15, - "element_id": 268437153, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 15, - "element_id": 268437153, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 9, - "element_id": 268437083, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 9, - "element_id": 268437083, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 11, - "element_id": 268437085, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 11, - "element_id": 268437085, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 13, - "element_id": 268437087, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 13, - "element_id": 268437087, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 15, - "element_id": 268437089, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 15, - "element_id": 268437089, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 9, - "element_id": 268437131, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 9, - "element_id": 268437131, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 11, - "element_id": 268437133, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 11, - "element_id": 268437133, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 13, - "element_id": 268437135, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 13, - "element_id": 268437135, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 15, - "element_id": 268437137, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 15, - "element_id": 268437137, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 9, - "element_id": 268437214, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 9, - "element_id": 268437214, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 11, - "element_id": 268437216, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 11, - "element_id": 268437216, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 13, - "element_id": 268437218, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 13, - "element_id": 268437218, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 15, - "element_id": 268437220, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 15, - "element_id": 268437220, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 1, - "element_id": 268437194, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 1, - "element_id": 268437194, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 3, - "element_id": 268437196, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 3, - "element_id": 268437196, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 5, - "element_id": 268437198, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 5, - "element_id": 268437198, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 7, - "element_id": 268437200, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 7, - "element_id": 268437200, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 59, - "element_id": 268437035, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 59, - "element_id": 268437035, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 61, - "element_id": 268437037, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 61, - "element_id": 268437037, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 63, - "element_id": 268437039, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 63, - "element_id": 268437039, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 65, - "element_id": 268437041, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 65, - "element_id": 268437041, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 9, - "element_id": 268437051, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 9, - "element_id": 268437051, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 11, - "element_id": 268437053, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 11, - "element_id": 268437053, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 13, - "element_id": 268437055, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 13, - "element_id": 268437055, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 15, - "element_id": 268437057, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 15, - "element_id": 268437057, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100688170, - "alpha_image_id": null - }, - "relative_path": "images/0x0600612A.png", - "width": 10, - "height": 5, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "smartbox", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 10, - "element_id": 268437166, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 10, - "element_id": 268437166, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 10, - "element_id": 268437100, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 10, - "element_id": 268437100, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 10, - "element_id": 268437116, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 10, - "element_id": 268437116, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 18, - "element_id": 268436776, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 18, - "element_id": 268436776, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 2, - "element_id": 268435471, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 2, - "element_id": 268435471, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 10, - "element_id": 268436776, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 10, - "element_id": 268436776, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 2, - "element_id": 268435471, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 2, - "element_id": 268435471, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 10, - "element_id": 268436776, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 10, - "element_id": 268436776, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 2, - "element_id": 268435471, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 2, - "element_id": 268435471, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 10, - "element_id": 268436776, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 10, - "element_id": 268436776, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 2, - "element_id": 268435471, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 2, - "element_id": 268435471, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 10, - "element_id": 268436776, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 10, - "element_id": 268436776, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 10, - "element_id": 268437068, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 10, - "element_id": 268437068, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 10, - "element_id": 268437148, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 10, - "element_id": 268437148, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 10, - "element_id": 268437084, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 10, - "element_id": 268437084, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 10, - "element_id": 268437132, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 10, - "element_id": 268437132, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 10, - "element_id": 268437215, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 10, - "element_id": 268437215, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 2, - "element_id": 268437195, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 2, - "element_id": 268437195, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 60, - "element_id": 268437036, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 60, - "element_id": 268437036, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 10, - "element_id": 268437052, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 10, - "element_id": 268437052, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100688171, - "alpha_image_id": null - }, - "relative_path": "images/0x0600612B.png", - "width": 5, - "height": 10, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "smartbox", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 12, - "element_id": 268437168, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 12, - "element_id": 268437168, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 12, - "element_id": 268437102, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 12, - "element_id": 268437102, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 12, - "element_id": 268437118, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 12, - "element_id": 268437118, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 4, - "element_id": 268436690, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 4, - "element_id": 268436690, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 4, - "element_id": 268436690, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 4, - "element_id": 268436690, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 4, - "element_id": 268436690, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 4, - "element_id": 268436690, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 4, - "element_id": 268436690, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 4, - "element_id": 268436690, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 12, - "element_id": 268437070, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 12, - "element_id": 268437070, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 12, - "element_id": 268437150, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 12, - "element_id": 268437150, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 12, - "element_id": 268437086, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 12, - "element_id": 268437086, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 12, - "element_id": 268437134, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 12, - "element_id": 268437134, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 12, - "element_id": 268437217, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 12, - "element_id": 268437217, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 4, - "element_id": 268437197, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 4, - "element_id": 268437197, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 62, - "element_id": 268437038, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 62, - "element_id": 268437038, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 12, - "element_id": 268437054, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 12, - "element_id": 268437054, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100688172, - "alpha_image_id": null - }, - "relative_path": "images/0x0600612C.png", - "width": 10, - "height": 5, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "smartbox", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 14, - "element_id": 268437170, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 14, - "element_id": 268437170, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 14, - "element_id": 268437104, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 14, - "element_id": 268437104, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 14, - "element_id": 268437120, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 14, - "element_id": 268437120, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 22, - "element_id": 268435765, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 22, - "element_id": 268435765, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 45, - "element_id": 268435782, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 45, - "element_id": 268435782, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 46, - "element_id": 268436577, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 46, - "element_id": 268436577, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 62, - "element_id": 268435800, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 62, - "element_id": 268435800, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 63, - "element_id": 268435802, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 63, - "element_id": 268435802, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 64, - "element_id": 268435804, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 64, - "element_id": 268435804, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 6, - "element_id": 268436692, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 6, - "element_id": 268436692, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 6, - "element_id": 268436692, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 6, - "element_id": 268436692, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 6, - "element_id": 268436692, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 6, - "element_id": 268436692, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 6, - "element_id": 268436692, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 6, - "element_id": 268436692, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 14, - "element_id": 268437072, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 14, - "element_id": 268437072, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 14, - "element_id": 268437152, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 14, - "element_id": 268437152, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 14, - "element_id": 268437088, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 14, - "element_id": 268437088, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 14, - "element_id": 268437136, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 14, - "element_id": 268437136, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 14, - "element_id": 268437219, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 14, - "element_id": 268437219, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 6, - "element_id": 268437199, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 6, - "element_id": 268437199, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 64, - "element_id": 268437040, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 64, - "element_id": 268437040, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 14, - "element_id": 268437056, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 14, - "element_id": 268437056, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100688173, - "alpha_image_id": null - }, - "relative_path": "images/0x0600612D.png", - "width": 5, - "height": 10, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "smartbox", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 16, - "element_id": 268437172, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 16, - "element_id": 268437172, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 16, - "element_id": 268437106, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 16, - "element_id": 268437106, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 16, - "element_id": 268437122, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 16, - "element_id": 268437122, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 8, - "element_id": 268436691, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 8, - "element_id": 268436691, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 8, - "element_id": 268436691, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 8, - "element_id": 268436691, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 8, - "element_id": 268436691, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 8, - "element_id": 268436691, - "state_id": 0 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 8, - "element_id": 268436691, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 8, - "element_id": 268436691, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 16, - "element_id": 268437074, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 16, - "element_id": 268437074, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 16, - "element_id": 268437154, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 16, - "element_id": 268437154, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 16, - "element_id": 268437090, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 16, - "element_id": 268437090, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 16, - "element_id": 268437138, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 16, - "element_id": 268437138, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 16, - "element_id": 268437221, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 16, - "element_id": 268437221, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 8, - "element_id": 268437201, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 8, - "element_id": 268437201, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 66, - "element_id": 268437042, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 66, - "element_id": 268437042, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 16, - "element_id": 268437058, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 16, - "element_id": 268437058, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100688405, - "alpha_image_id": null - }, - "relative_path": "images/0x06006215.png", - "width": 14, - "height": 14, - "used_by_panels": [ - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4" - ], - "used_by_nodes": [ - { - "panel_slug": "examine", - "traversal_index": 20, - "element_id": 268436979, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 20, - "element_id": 268436979, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 12, - "element_id": 268436778, - "state_id": null - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 12, - "element_id": 268436778, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 12, - "element_id": 268436778, - "state_id": null - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 12, - "element_id": 268436778, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 12, - "element_id": 268436778, - "state_id": null - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 12, - "element_id": 268436778, - "state_id": 1 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 12, - "element_id": 268436778, - "state_id": null - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 12, - "element_id": 268436778, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100688406, - "alpha_image_id": null - }, - "relative_path": "images/0x06006216.png", - "width": 14, - "height": 14, - "used_by_panels": [ - "examine", - "floating_chat_1", - "floating_chat_2", - "floating_chat_3", - "floating_chat_4" - ], - "used_by_nodes": [ - { - "panel_slug": "examine", - "traversal_index": 20, - "element_id": 268436979, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_1", - "traversal_index": 12, - "element_id": 268436778, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_2", - "traversal_index": 12, - "element_id": 268436778, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_3", - "traversal_index": 12, - "element_id": 268436778, - "state_id": 3 - }, - { - "panel_slug": "floating_chat_4", - "traversal_index": 12, - "element_id": 268436778, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100688408, - "alpha_image_id": null - }, - "relative_path": "images/0x06006218.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 28, - "element_id": 268436770, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 28, - "element_id": 268436770, - "state_id": 1 - }, - { - "panel_slug": "main_chat", - "traversal_index": 29, - "element_id": 268436771, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 29, - "element_id": 268436771, - "state_id": 1 - }, - { - "panel_slug": "main_chat", - "traversal_index": 30, - "element_id": 268436772, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 30, - "element_id": 268436772, - "state_id": 1 - }, - { - "panel_slug": "main_chat", - "traversal_index": 31, - "element_id": 268436773, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 31, - "element_id": 268436773, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100688409, - "alpha_image_id": null - }, - "relative_path": "images/0x06006219.png", - "width": 16, - "height": 16, - "used_by_panels": [ - "main_chat" - ], - "used_by_nodes": [ - { - "panel_slug": "main_chat", - "traversal_index": 28, - "element_id": 268436770, - "state_id": 6 - }, - { - "panel_slug": "main_chat", - "traversal_index": 29, - "element_id": 268436771, - "state_id": 6 - }, - { - "panel_slug": "main_chat", - "traversal_index": 30, - "element_id": 268436772, - "state_id": 6 - }, - { - "panel_slug": "main_chat", - "traversal_index": 31, - "element_id": 268436773, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100690350, - "alpha_image_id": null - }, - "relative_path": "images/0x060069AE.png", - "width": 34, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 10, - "element_id": 268436826, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 10, - "element_id": 268436826, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100690351, - "alpha_image_id": null - }, - "relative_path": "images/0x060069AF.png", - "width": 34, - "height": 27, - "used_by_panels": [ - "toolbar" - ], - "used_by_nodes": [ - { - "panel_slug": "toolbar", - "traversal_index": 10, - "element_id": 268436826, - "state_id": 6 - } - ] - }, - { - "key": { - "image_id": 100692267, - "alpha_image_id": null - }, - "relative_path": "images/0x0600712B.png", - "width": 30, - "height": 26, - "used_by_panels": [ - "character", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 54, - "element_id": 268436971, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 64, - "element_id": 268436971, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 105, - "element_id": 268436971, - "state_id": 1 - }, - { - "panel_slug": "character", - "traversal_index": 115, - "element_id": 268436971, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 455, - "element_id": 268436971, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 465, - "element_id": 268436971, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 506, - "element_id": 268436971, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 516, - "element_id": 268436971, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100692268, - "alpha_image_id": null - }, - "relative_path": "images/0x0600712C.png", - "width": 30, - "height": 26, - "used_by_panels": [ - "character", - "main_panel" - ], - "used_by_nodes": [ - { - "panel_slug": "character", - "traversal_index": 54, - "element_id": 268436971, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 54, - "element_id": 268436971, - "state_id": 13 - }, - { - "panel_slug": "character", - "traversal_index": 64, - "element_id": 268436971, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 64, - "element_id": 268436971, - "state_id": 13 - }, - { - "panel_slug": "character", - "traversal_index": 105, - "element_id": 268436971, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 105, - "element_id": 268436971, - "state_id": 13 - }, - { - "panel_slug": "character", - "traversal_index": 115, - "element_id": 268436971, - "state_id": null - }, - { - "panel_slug": "character", - "traversal_index": 115, - "element_id": 268436971, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 455, - "element_id": 268436971, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 455, - "element_id": 268436971, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 465, - "element_id": 268436971, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 465, - "element_id": 268436971, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 506, - "element_id": 268436971, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 506, - "element_id": 268436971, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 516, - "element_id": 268436971, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 516, - "element_id": 268436971, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100692318, - "alpha_image_id": null - }, - "relative_path": "images/0x0600715E.png", - "width": 60, - "height": 13, - "used_by_panels": [ - "combat", - "powerbar", - "smartbox" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 24, - "element_id": 268436975, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 24, - "element_id": 268436975, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 18, - "element_id": 268436974, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 18, - "element_id": 268436974, - "state_id": 0 - }, - { - "panel_slug": "smartbox", - "traversal_index": 17, - "element_id": 268436974, - "state_id": null - }, - { - "panel_slug": "smartbox", - "traversal_index": 17, - "element_id": 268436974, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693118, - "alpha_image_id": null - }, - "relative_path": "images/0x0600747E.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 19, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 19, - "element_id": 268435688, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 19, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 19, - "element_id": 268435688, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693119, - "alpha_image_id": null - }, - "relative_path": "images/0x0600747F.png", - "width": 100, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 20, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 20, - "element_id": 268435689, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 20, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 20, - "element_id": 268435689, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693120, - "alpha_image_id": null - }, - "relative_path": "images/0x06007480.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 21, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 21, - "element_id": 268435690, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 21, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 21, - "element_id": 268435690, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693121, - "alpha_image_id": null - }, - "relative_path": "images/0x06007481.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 24, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 24, - "element_id": 268435688, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 24, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 24, - "element_id": 268435688, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693122, - "alpha_image_id": null - }, - "relative_path": "images/0x06007482.png", - "width": 100, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 25, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 25, - "element_id": 268435689, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 25, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 25, - "element_id": 268435689, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693123, - "alpha_image_id": null - }, - "relative_path": "images/0x06007483.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 26, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 26, - "element_id": 268435690, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 26, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 26, - "element_id": 268435690, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693124, - "alpha_image_id": null - }, - "relative_path": "images/0x06007484.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 31, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 31, - "element_id": 268435688, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 31, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 31, - "element_id": 268435688, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693125, - "alpha_image_id": null - }, - "relative_path": "images/0x06007485.png", - "width": 100, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 32, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 32, - "element_id": 268435689, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 32, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 32, - "element_id": 268435689, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693126, - "alpha_image_id": null - }, - "relative_path": "images/0x06007486.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 33, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 33, - "element_id": 268435690, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 33, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 33, - "element_id": 268435690, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693127, - "alpha_image_id": null - }, - "relative_path": "images/0x06007487.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 36, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 36, - "element_id": 268435688, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 36, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 36, - "element_id": 268435688, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693128, - "alpha_image_id": null - }, - "relative_path": "images/0x06007488.png", - "width": 100, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 37, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 37, - "element_id": 268435689, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 37, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 37, - "element_id": 268435689, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693129, - "alpha_image_id": null - }, - "relative_path": "images/0x06007489.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 38, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 38, - "element_id": 268435690, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 38, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 38, - "element_id": 268435690, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693130, - "alpha_image_id": null - }, - "relative_path": "images/0x0600748A.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 43, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 43, - "element_id": 268435688, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 43, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 43, - "element_id": 268435688, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693131, - "alpha_image_id": null - }, - "relative_path": "images/0x0600748B.png", - "width": 100, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 44, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 44, - "element_id": 268435689, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 44, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 44, - "element_id": 268435689, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693132, - "alpha_image_id": null - }, - "relative_path": "images/0x0600748C.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 45, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 45, - "element_id": 268435690, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 45, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 45, - "element_id": 268435690, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693133, - "alpha_image_id": null - }, - "relative_path": "images/0x0600748D.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 48, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 48, - "element_id": 268435688, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 48, - "element_id": 268435688, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 48, - "element_id": 268435688, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693134, - "alpha_image_id": null - }, - "relative_path": "images/0x0600748E.png", - "width": 100, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 49, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 49, - "element_id": 268435689, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 49, - "element_id": 268435689, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 49, - "element_id": 268435689, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693135, - "alpha_image_id": null - }, - "relative_path": "images/0x0600748F.png", - "width": 10, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 50, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 50, - "element_id": 268435690, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 50, - "element_id": 268435690, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 50, - "element_id": 268435690, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693136, - "alpha_image_id": null - }, - "relative_path": "images/0x06007490.png", - "width": 18, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 22, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 22, - "element_id": 268436649, - "state_id": 268435463 - }, - { - "panel_slug": "vitals", - "traversal_index": 22, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 22, - "element_id": 268436649, - "state_id": 268435463 - } - ] - }, - { - "key": { - "image_id": 100693137, - "alpha_image_id": null - }, - "relative_path": "images/0x06007491.png", - "width": 18, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 27, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 27, - "element_id": 268436649, - "state_id": 268435463 - }, - { - "panel_slug": "vitals", - "traversal_index": 27, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 27, - "element_id": 268436649, - "state_id": 268435463 - } - ] - }, - { - "key": { - "image_id": 100693138, - "alpha_image_id": null - }, - "relative_path": "images/0x06007492.png", - "width": 85, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 34, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 34, - "element_id": 268436649, - "state_id": 268435463 - }, - { - "panel_slug": "vitals", - "traversal_index": 34, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 34, - "element_id": 268436649, - "state_id": 268435463 - } - ] - }, - { - "key": { - "image_id": 100693139, - "alpha_image_id": null - }, - "relative_path": "images/0x06007493.png", - "width": 85, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 39, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 39, - "element_id": 268436649, - "state_id": 268435463 - }, - { - "panel_slug": "vitals", - "traversal_index": 39, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 39, - "element_id": 268436649, - "state_id": 268435463 - } - ] - }, - { - "key": { - "image_id": 100693140, - "alpha_image_id": null - }, - "relative_path": "images/0x06007494.png", - "width": 100, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 46, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 46, - "element_id": 268436649, - "state_id": 268435463 - }, - { - "panel_slug": "vitals", - "traversal_index": 46, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 46, - "element_id": 268436649, - "state_id": 268435463 - } - ] - }, - { - "key": { - "image_id": 100693141, - "alpha_image_id": null - }, - "relative_path": "images/0x06007495.png", - "width": 100, - "height": 16, - "used_by_panels": [ - "side_by_side_vitals", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 51, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 51, - "element_id": 268436649, - "state_id": 268435463 - }, - { - "panel_slug": "vitals", - "traversal_index": 51, - "element_id": 268436649, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 51, - "element_id": 268436649, - "state_id": 268435463 - } - ] - }, - { - "key": { - "image_id": 100693144, - "alpha_image_id": null - }, - "relative_path": "images/0x06007498.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 17, - "element_id": 268435704, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 17, - "element_id": 268435704, - "state_id": 17 - } - ] - }, - { - "key": { - "image_id": 100693145, - "alpha_image_id": null - }, - "relative_path": "images/0x06007499.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 17, - "element_id": 268435704, - "state_id": 18 - } - ] - }, - { - "key": { - "image_id": 100693146, - "alpha_image_id": null - }, - "relative_path": "images/0x0600749A.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 17, - "element_id": 268435704, - "state_id": 19 - }, - { - "panel_slug": "indicators", - "traversal_index": 17, - "element_id": 268435704, - "state_id": 20 - } - ] - }, - { - "key": { - "image_id": 100693147, - "alpha_image_id": null - }, - "relative_path": "images/0x0600749B.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 18, - "element_id": 268435698, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 18, - "element_id": 268435698, - "state_id": 3 - }, - { - "panel_slug": "indicators", - "traversal_index": 20, - "element_id": 268435698, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 20, - "element_id": 268435698, - "state_id": 3 - }, - { - "panel_slug": "indicators", - "traversal_index": 22, - "element_id": 268435698, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 22, - "element_id": 268435698, - "state_id": 3 - }, - { - "panel_slug": "indicators", - "traversal_index": 24, - "element_id": 268435698, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 24, - "element_id": 268435698, - "state_id": 3 - }, - { - "panel_slug": "indicators", - "traversal_index": 26, - "element_id": 268435698, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 26, - "element_id": 268435698, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100693148, - "alpha_image_id": null - }, - "relative_path": "images/0x0600749C.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 19, - "element_id": 268435701, - "state_id": 1 - }, - { - "panel_slug": "indicators", - "traversal_index": 19, - "element_id": 268435701, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100693149, - "alpha_image_id": null - }, - "relative_path": "images/0x0600749D.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 19, - "element_id": 268435701, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 19, - "element_id": 268435701, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100693150, - "alpha_image_id": null - }, - "relative_path": "images/0x0600749E.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 21, - "element_id": 268435702, - "state_id": 1 - }, - { - "panel_slug": "indicators", - "traversal_index": 21, - "element_id": 268435702, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100693151, - "alpha_image_id": null - }, - "relative_path": "images/0x0600749F.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 21, - "element_id": 268435702, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 21, - "element_id": 268435702, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100693152, - "alpha_image_id": null - }, - "relative_path": "images/0x060074A0.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 23, - "element_id": 268435700, - "state_id": 1 - }, - { - "panel_slug": "indicators", - "traversal_index": 23, - "element_id": 268435700, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100693153, - "alpha_image_id": null - }, - "relative_path": "images/0x060074A1.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 23, - "element_id": 268435700, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 23, - "element_id": 268435700, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100693154, - "alpha_image_id": null - }, - "relative_path": "images/0x060074A2.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 25, - "element_id": 268435703, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 25, - "element_id": 268435703, - "state_id": 14 - } - ] - }, - { - "key": { - "image_id": 100693155, - "alpha_image_id": null - }, - "relative_path": "images/0x060074A3.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 25, - "element_id": 268435703, - "state_id": 15 - } - ] - }, - { - "key": { - "image_id": 100693156, - "alpha_image_id": null - }, - "relative_path": "images/0x060074A4.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 25, - "element_id": 268435703, - "state_id": 16 - } - ] - }, - { - "key": { - "image_id": 100693157, - "alpha_image_id": null - }, - "relative_path": "images/0x060074A5.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 27, - "element_id": 268435699, - "state_id": 1 - }, - { - "panel_slug": "indicators", - "traversal_index": 27, - "element_id": 268435699, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100693158, - "alpha_image_id": null - }, - "relative_path": "images/0x060074A6.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 27, - "element_id": 268435699, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 27, - "element_id": 268435699, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100693159, - "alpha_image_id": null - }, - "relative_path": "images/0x060074A7.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 332, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 336, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 340, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 344, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 348, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 352, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 356, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 42, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 46, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 50, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 54, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 58, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 62, - "element_id": 268436174, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 66, - "element_id": 268436174, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100693160, - "alpha_image_id": null - }, - "relative_path": "images/0x060074A8.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 332, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 336, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 340, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 344, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 348, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 352, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 356, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 42, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 46, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 50, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 54, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 58, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 62, - "element_id": 268436174, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 66, - "element_id": 268436174, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100693161, - "alpha_image_id": null - }, - "relative_path": "images/0x060074A9.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 332, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 332, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 332, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 336, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 336, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 336, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 340, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 340, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 340, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 344, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 344, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 344, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 348, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 348, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 348, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 352, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 352, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 352, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 356, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 356, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 356, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 42, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 42, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 42, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 46, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 46, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 46, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 50, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 50, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 50, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 54, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 54, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 54, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 58, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 58, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 58, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 62, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 62, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 62, - "element_id": 268436174, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 66, - "element_id": 268436174, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 66, - "element_id": 268436174, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 66, - "element_id": 268436174, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100693162, - "alpha_image_id": null - }, - "relative_path": "images/0x060074AA.png", - "width": 15, - "height": 32, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 333, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 337, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 341, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 345, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 349, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 353, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 357, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 43, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 47, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 51, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 55, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 59, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 63, - "element_id": 268436175, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 67, - "element_id": 268436175, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100693163, - "alpha_image_id": null - }, - "relative_path": "images/0x060074AB.png", - "width": 15, - "height": 32, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 333, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 337, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 341, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 345, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 349, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 353, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 357, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 43, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 47, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 51, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 55, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 59, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 63, - "element_id": 268436175, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 67, - "element_id": 268436175, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100693164, - "alpha_image_id": null - }, - "relative_path": "images/0x060074AC.png", - "width": 15, - "height": 32, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 333, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 333, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 333, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 337, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 337, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 337, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 341, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 341, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 341, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 345, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 345, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 345, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 349, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 349, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 349, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 353, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 353, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 353, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 357, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 357, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 357, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 43, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 43, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 43, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 47, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 47, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 47, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 51, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 51, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 51, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 55, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 55, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 55, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 59, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 59, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 59, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 63, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 63, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 63, - "element_id": 268436175, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 67, - "element_id": 268436175, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 67, - "element_id": 268436175, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 67, - "element_id": 268436175, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100693165, - "alpha_image_id": null - }, - "relative_path": "images/0x060074AD.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 334, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 338, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 342, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 346, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 350, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 354, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "main_panel", - "traversal_index": 358, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 44, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 48, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 52, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 56, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 60, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 64, - "element_id": 268436176, - "state_id": 13 - }, - { - "panel_slug": "options", - "traversal_index": 68, - "element_id": 268436176, - "state_id": 13 - } - ] - }, - { - "key": { - "image_id": 100693166, - "alpha_image_id": null - }, - "relative_path": "images/0x060074AE.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 334, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 338, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 342, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 346, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 350, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 354, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "main_panel", - "traversal_index": 358, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 44, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 48, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 52, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 56, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 60, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 64, - "element_id": 268436176, - "state_id": 3 - }, - { - "panel_slug": "options", - "traversal_index": 68, - "element_id": 268436176, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100693167, - "alpha_image_id": null - }, - "relative_path": "images/0x060074AF.png", - "width": 32, - "height": 32, - "used_by_panels": [ - "main_panel", - "options" - ], - "used_by_nodes": [ - { - "panel_slug": "main_panel", - "traversal_index": 334, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 334, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 334, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 338, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 338, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 338, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 342, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 342, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 342, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 346, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 346, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 346, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 350, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 350, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 350, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 354, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 354, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 354, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "main_panel", - "traversal_index": 358, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 358, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "main_panel", - "traversal_index": 358, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 44, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 44, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 44, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 48, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 48, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 48, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 52, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 52, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 52, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 56, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 56, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 56, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 60, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 60, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 60, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 64, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 64, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 64, - "element_id": 268436176, - "state_id": 2 - }, - { - "panel_slug": "options", - "traversal_index": 68, - "element_id": 268436176, - "state_id": null - }, - { - "panel_slug": "options", - "traversal_index": 68, - "element_id": 268436176, - "state_id": 1 - }, - { - "panel_slug": "options", - "traversal_index": 68, - "element_id": 268436176, - "state_id": 2 - } - ] - }, - { - "key": { - "image_id": 100693169, - "alpha_image_id": null - }, - "relative_path": "images/0x060074B1.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 29, - "element_id": 268435706, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 29, - "element_id": 268435706, - "state_id": 1 - } - ] - }, - { - "key": { - "image_id": 100693170, - "alpha_image_id": null - }, - "relative_path": "images/0x060074B2.png", - "width": 20, - "height": 20, - "used_by_panels": [ - "indicators" - ], - "used_by_nodes": [ - { - "panel_slug": "indicators", - "traversal_index": 29, - "element_id": 268435706, - "state_id": 3 - } - ] - }, - { - "key": { - "image_id": 100693175, - "alpha_image_id": null - }, - "relative_path": "images/0x060074B7.png", - "width": 27, - "height": 27, - "used_by_panels": [ - "radar" - ], - "used_by_nodes": [ - { - "panel_slug": "radar", - "traversal_index": 3, - "element_id": 268437017, - "state_id": null - }, - { - "panel_slug": "radar", - "traversal_index": 3, - "element_id": 268437017, - "state_id": 268435555 - } - ] - }, - { - "key": { - "image_id": 100693176, - "alpha_image_id": null - }, - "relative_path": "images/0x060074B8.png", - "width": 27, - "height": 27, - "used_by_panels": [ - "radar" - ], - "used_by_nodes": [ - { - "panel_slug": "radar", - "traversal_index": 3, - "element_id": 268437017, - "state_id": 268435556 - } - ] - }, - { - "key": { - "image_id": 100693183, - "alpha_image_id": null - }, - "relative_path": "images/0x060074BF.png", - "width": 10, - "height": 5, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 2, - "element_id": 268437158, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 2, - "element_id": 268437158, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 2, - "element_id": 268437092, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 2, - "element_id": 268437092, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 2, - "element_id": 268437108, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 2, - "element_id": 268437108, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 2, - "element_id": 268437060, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 2, - "element_id": 268437060, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 2, - "element_id": 268437140, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 2, - "element_id": 268437140, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 2, - "element_id": 268437076, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 2, - "element_id": 268437076, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 2, - "element_id": 268437124, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 2, - "element_id": 268437124, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 2, - "element_id": 268437207, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 2, - "element_id": 268437207, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 52, - "element_id": 268437028, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 52, - "element_id": 268437028, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 2, - "element_id": 268437044, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 2, - "element_id": 268437044, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693184, - "alpha_image_id": null - }, - "relative_path": "images/0x060074C0.png", - "width": 5, - "height": 10, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 4, - "element_id": 268437160, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 4, - "element_id": 268437160, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 4, - "element_id": 268437094, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 4, - "element_id": 268437094, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 4, - "element_id": 268437110, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 4, - "element_id": 268437110, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 4, - "element_id": 268437062, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 4, - "element_id": 268437062, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 4, - "element_id": 268437142, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 4, - "element_id": 268437142, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 4, - "element_id": 268437078, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 4, - "element_id": 268437078, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 4, - "element_id": 268437126, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 4, - "element_id": 268437126, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 4, - "element_id": 268437209, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 4, - "element_id": 268437209, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 54, - "element_id": 268437030, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 54, - "element_id": 268437030, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 4, - "element_id": 268437046, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 4, - "element_id": 268437046, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693185, - "alpha_image_id": null - }, - "relative_path": "images/0x060074C1.png", - "width": 10, - "height": 5, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 6, - "element_id": 268437162, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 6, - "element_id": 268437162, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 6, - "element_id": 268437096, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 6, - "element_id": 268437096, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 6, - "element_id": 268437112, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 6, - "element_id": 268437112, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 6, - "element_id": 268437064, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 6, - "element_id": 268437064, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 6, - "element_id": 268437144, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 6, - "element_id": 268437144, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 6, - "element_id": 268437080, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 6, - "element_id": 268437080, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 6, - "element_id": 268437128, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 6, - "element_id": 268437128, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 6, - "element_id": 268437211, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 6, - "element_id": 268437211, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 56, - "element_id": 268437032, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 56, - "element_id": 268437032, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 6, - "element_id": 268437048, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 6, - "element_id": 268437048, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693186, - "alpha_image_id": null - }, - "relative_path": "images/0x060074C2.png", - "width": 5, - "height": 10, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 8, - "element_id": 268437164, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 8, - "element_id": 268437164, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 8, - "element_id": 268437098, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 8, - "element_id": 268437098, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 8, - "element_id": 268437114, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 8, - "element_id": 268437114, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 8, - "element_id": 268437066, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 8, - "element_id": 268437066, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 8, - "element_id": 268437146, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 8, - "element_id": 268437146, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 8, - "element_id": 268437082, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 8, - "element_id": 268437082, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 8, - "element_id": 268437130, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 8, - "element_id": 268437130, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 8, - "element_id": 268437213, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 8, - "element_id": 268437213, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 58, - "element_id": 268437034, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 58, - "element_id": 268437034, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 8, - "element_id": 268437050, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 8, - "element_id": 268437050, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693187, - "alpha_image_id": null - }, - "relative_path": "images/0x060074C3.png", - "width": 5, - "height": 5, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 1, - "element_id": 268437157, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 1, - "element_id": 268437157, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 1, - "element_id": 268437091, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 1, - "element_id": 268437091, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 1, - "element_id": 268437107, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 1, - "element_id": 268437107, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 1, - "element_id": 268437059, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 1, - "element_id": 268437059, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 1, - "element_id": 268437139, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 1, - "element_id": 268437139, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 1, - "element_id": 268437075, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 1, - "element_id": 268437075, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 1, - "element_id": 268437123, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 1, - "element_id": 268437123, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 1, - "element_id": 268437206, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 1, - "element_id": 268437206, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 51, - "element_id": 268437027, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 51, - "element_id": 268437027, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 1, - "element_id": 268437043, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 1, - "element_id": 268437043, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693188, - "alpha_image_id": null - }, - "relative_path": "images/0x060074C4.png", - "width": 5, - "height": 5, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 3, - "element_id": 268437159, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 3, - "element_id": 268437159, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 3, - "element_id": 268437093, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 3, - "element_id": 268437093, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 3, - "element_id": 268437109, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 3, - "element_id": 268437109, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 3, - "element_id": 268437061, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 3, - "element_id": 268437061, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 3, - "element_id": 268437141, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 3, - "element_id": 268437141, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 3, - "element_id": 268437077, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 3, - "element_id": 268437077, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 3, - "element_id": 268437125, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 3, - "element_id": 268437125, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 3, - "element_id": 268437208, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 3, - "element_id": 268437208, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 53, - "element_id": 268437029, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 53, - "element_id": 268437029, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 3, - "element_id": 268437045, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 3, - "element_id": 268437045, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693189, - "alpha_image_id": null - }, - "relative_path": "images/0x060074C5.png", - "width": 5, - "height": 5, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 5, - "element_id": 268437161, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 5, - "element_id": 268437161, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 5, - "element_id": 268437095, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 5, - "element_id": 268437095, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 5, - "element_id": 268437111, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 5, - "element_id": 268437111, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 5, - "element_id": 268437063, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 5, - "element_id": 268437063, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 5, - "element_id": 268437143, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 5, - "element_id": 268437143, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 5, - "element_id": 268437079, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 5, - "element_id": 268437079, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 5, - "element_id": 268437127, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 5, - "element_id": 268437127, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 5, - "element_id": 268437210, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 5, - "element_id": 268437210, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 55, - "element_id": 268437031, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 55, - "element_id": 268437031, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 5, - "element_id": 268437047, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 5, - "element_id": 268437047, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693190, - "alpha_image_id": null - }, - "relative_path": "images/0x060074C6.png", - "width": 5, - "height": 5, - "used_by_panels": [ - "combat", - "environment_panel", - "examine", - "indicators", - "main_chat", - "main_panel", - "powerbar", - "side_by_side_vitals", - "toolbar", - "vitals" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 7, - "element_id": 268437163, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 7, - "element_id": 268437163, - "state_id": 0 - }, - { - "panel_slug": "environment_panel", - "traversal_index": 7, - "element_id": 268437097, - "state_id": null - }, - { - "panel_slug": "environment_panel", - "traversal_index": 7, - "element_id": 268437097, - "state_id": 0 - }, - { - "panel_slug": "examine", - "traversal_index": 7, - "element_id": 268437113, - "state_id": null - }, - { - "panel_slug": "examine", - "traversal_index": 7, - "element_id": 268437113, - "state_id": 0 - }, - { - "panel_slug": "indicators", - "traversal_index": 7, - "element_id": 268437065, - "state_id": null - }, - { - "panel_slug": "indicators", - "traversal_index": 7, - "element_id": 268437065, - "state_id": 0 - }, - { - "panel_slug": "main_chat", - "traversal_index": 7, - "element_id": 268437145, - "state_id": null - }, - { - "panel_slug": "main_chat", - "traversal_index": 7, - "element_id": 268437145, - "state_id": 0 - }, - { - "panel_slug": "main_panel", - "traversal_index": 7, - "element_id": 268437081, - "state_id": null - }, - { - "panel_slug": "main_panel", - "traversal_index": 7, - "element_id": 268437081, - "state_id": 0 - }, - { - "panel_slug": "powerbar", - "traversal_index": 7, - "element_id": 268437129, - "state_id": null - }, - { - "panel_slug": "powerbar", - "traversal_index": 7, - "element_id": 268437129, - "state_id": 0 - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 7, - "element_id": 268437212, - "state_id": null - }, - { - "panel_slug": "side_by_side_vitals", - "traversal_index": 7, - "element_id": 268437212, - "state_id": 0 - }, - { - "panel_slug": "toolbar", - "traversal_index": 57, - "element_id": 268437033, - "state_id": null - }, - { - "panel_slug": "toolbar", - "traversal_index": 57, - "element_id": 268437033, - "state_id": 0 - }, - { - "panel_slug": "vitals", - "traversal_index": 7, - "element_id": 268437049, - "state_id": null - }, - { - "panel_slug": "vitals", - "traversal_index": 7, - "element_id": 268437049, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693193, - "alpha_image_id": null - }, - "relative_path": "images/0x060074C9.png", - "width": 27, - "height": 27, - "used_by_panels": [ - "radar" - ], - "used_by_nodes": [ - { - "panel_slug": "radar", - "traversal_index": 4, - "element_id": 268437155, - "state_id": null - }, - { - "panel_slug": "radar", - "traversal_index": 4, - "element_id": 268437155, - "state_id": 0 - } - ] - }, - { - "key": { - "image_id": 100693194, - "alpha_image_id": null - }, - "relative_path": "images/0x060074CA.png", - "width": 60, - "height": 13, - "used_by_panels": [ - "combat" - ], - "used_by_nodes": [ - { - "panel_slug": "combat", - "traversal_index": 21, - "element_id": 268435535, - "state_id": null - }, - { - "panel_slug": "combat", - "traversal_index": 21, - "element_id": 268435535, - "state_id": 0 - } - ] - } - ], - "skipped_panels": [] -} \ No newline at end of file diff --git a/docs/research/2026-06-25-studio-dump-panel-sweep.md b/docs/research/2026-06-25-studio-dump-panel-sweep.md deleted file mode 100644 index 36b5f0cf..00000000 --- a/docs/research/2026-06-25-studio-dump-panel-sweep.md +++ /dev/null @@ -1,45 +0,0 @@ -# UI Studio — dump-panel render sweep (2026-06-25) - -All 26 windows in `docs/research/2026-06-25-retail-ui-layout-dump.json` were rendered through -the studio's dump LayoutSource (`AcDream.App ui-studio --dump --screenshot `) and -read back as PNGs. Goal: verify the generic dump source renders every retail window, identify -real render bugs vs. expected gaps. - -## Verdict - -The generic dump source renders **all 26 panels with no crash**. Render fidelity tracks how -much of a panel is **static chrome** (present in the dump, so it draws) vs **runtime content** -(slots, stats, appraise text — NOT in the dump, so it can't draw). This is the dump's nature, -not a studio bug. - -## Per-panel status (read from `studio-shots/`) - -| Status | Panels | -|--------|--------| -| **Full / great** (frame + static content) | `vitals` (3 bars + heart/glyphs), `side_by_side_vitals`, `toolbar` (all spell/combat icons + backpack), `map`, `main_panel`, `radar` (disc), `character` (frame+tabs+scrollbar), `combat`, `powerbar`, `negative_effects`, `positive_effects`, `options`, `quests`, `social`, `spellbook`, `main_chat`, `floating_chat_1..4`, `environment_panel` | -| **Partial** (chrome only; runtime content absent) | `inventory` — stone backdrop + top frame render, but the nested sub-window chrome (gm3DItemsUI / gmBackpackUI / paperdoll) is sparse and the slots are runtime-filled. **The one panel with a possible real gap — investigate the nested-layout / sub-window node handling.** | -| **Blank** (runtime-only window, no static sprites in dump) | `examine` (appraise window — populated only on examine), and likely the small `indicators` / `smartbox` / `vitae` | - -## Tooling shipped this sweep - -- `--screenshot ` headless mode (`f778b10`): render the loaded panel (dump or dat) to a - PNG off-screen via `PanelFbo` + `glReadPixels` + ImageSharp, then exit. Hidden window - (`IsVisible=false`). The autonomous self-verification primitive. -- Contact-sheet montage via PowerShell `System.Drawing` (one image, 26 thumbs) — the cheap - per-sweep overview (`studio-shots/_contact.png`, gitignored). - -## Rect basis (confirmed in `DumpLayout`) - -Dump `rect` fields are ABSOLUTE retail screen coords; `DumpLayout` converts to parent-relative -(`child.Left = child.Rect.X - parent.Rect.X`) and places the root at (0,0). Verified correct by -the toolbar (icons land precisely) + vitals. - -## Next (loop) - -1. **Inventory nested chrome** — determine whether the sub-window content is missing because - (a) those nodes are `Group`s with no static sprite (expected), or (b) `DumpLayout` isn't - expanding a `base_layout_id` sub-layout reference (a fixable bug). Dump the inventory panel's - node `widget_kind`/`rect`/`image_id` list and compare to what rendered. -2. Spot-confirm the remaining blanks (`indicators`/`smartbox`/`vitae`) are runtime-only. -3. The dump source is functionally complete as a static previewer; richer (live-data) previews - are the dat-import + fixture path (the parked Task 4 bags/equip), not the dump path. diff --git a/docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md b/docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md deleted file mode 100644 index 00adaeee..00000000 --- a/docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md +++ /dev/null @@ -1,151 +0,0 @@ -# ACE vs 2013 Retail Motion Command Gap - -Date: 2026-06-26 - -## Why this matters - -The movement/animation parity work cannot use one command catalog blindly. - -The 2013 `acclient.exe` decomp has a `command_ids[0x198]` table and a matching command-name table, but ACE's `MotionCommand` enum is based on a later client catalog. The local DAT motion tables also contain later-client command keys. If AcDream reconstructs incoming ACE wire commands through the 2013 table only, some server-triggered animations will disappear. - -The concrete example is lifestone recall: - -- 2013 retail decomp names `LifestoneRecall` as `0x10000150`. -- Current ACE names `LifestoneRecall` as `0x10000153`. -- Local DAT `MotionTable` links include both values, but many later recall/offhand commands only exist under the ACE-shifted value. - -## Sources checked - -- 2013 retail decomp: - - `docs/research/named-retail/acclient_2013_pseudo_c.txt` - - `command_ids[0x198]` at `0x007c73e8` - - command-name table around `0x008041ec..0x0080444c` -- ACE current master: - - `https://raw.githubusercontent.com/ACEmulator/ACE/master/Source/ACE.Entity/Enum/MotionCommand.cs` - - `https://raw.githubusercontent.com/ACEmulator/ACE/master/Source/ACE.Entity/Enum/CommandMasks.cs` -- Local DATs: - - `C:\Users\erikn\Documents\Asheron's Call\client_portal.dat` - - scanned 436 `MotionTable` records with `Chorizite.DatReaderWriter 2.1.7` -- Current AcDream resolver: - - `src/AcDream.Core/Physics/MotionCommandResolver.cs` - -## Catalog mismatch - -Parsed inventory: - -- 2013 retail command names parsed: `406` -- ACE command names parsed: `409` -- Common names: `400` -- Common names with different values: `130` - -The important mismatch is a contiguous low-word `+3` shift beginning after the targeting UI block: - -| Name | 2013 retail | ACE current | -|---|---:|---:| -| `SnowAngelState` | `0x43000115` | `0x43000118` | -| `MeditateState` | `0x43000119` | `0x4300011C` | -| `Pickup5` | `0x40000133` | `0x40000136` | -| `HouseRecall` | `0x10000137` | `0x1000013A` | -| `SitState` | `0x4300013A` | `0x4300013D` | -| `HaveASeat` | `0x1300014F` | `0x13000152` | -| `LifestoneRecall` | `0x10000150` | `0x10000153` | -| `MarketplaceRecall` | `0x10000163` | `0x10000166` | -| `AllegianceHometownRecall` | `0x1000016E` | `0x10000171` | -| `PKArenaRecall` | `0x1000016F` | `0x10000172` | -| `OffhandSlashHigh` | `0x10000170` | `0x10000173` | - -ACE's enum comments show the branch point: - -- `SkillHealSelf = 0x1000010e` -- `SkillHealOther = 0x1000010f` -- duplicate/commented legacy slots around `0x010f..0x0111` -- `SnowAngelState = 0x43000118` - -2013 retail instead has: - -- `SkillHealSelf = 0x0900010E` -- `NextMonster = 0x0900010F` -- `PreviousMonster = 0x09000110` -- `ClosestMonster = 0x09000111` -- `NextPlayer = 0x09000112` -- `PreviousPlayer = 0x09000113` -- `ClosestPlayer = 0x09000114` -- `SnowAngelState = 0x43000115` - -So this is a later-client catalog divergence, not just a single bad enum value. - -## MotionTable availability - -I scanned all 436 local DAT `MotionTable` records for the selected 2013 and ACE values. Counts below are link target hits; recall/action commands are stored in `Links`, not `Cycles`. - -| Command | 2013 value hits | ACE value hits | Interpretation | -|---|---:|---:|---| -| `HouseRecall` | `317` | `24` | both exist; the old value is very common | -| `LifestoneRecall` | `28` | `19` | both exist; ACE `/ls` value can animate | -| `MarketplaceRecall` | `0` | `19` | only ACE-shifted value exists | -| `AllegianceHometownRecall` | `0` | `19` | only ACE-shifted value exists | -| `PKArenaRecall` | `0` | `18` | only ACE-shifted value exists | -| `OffhandSlashHigh` | `0` | `31` | only ACE-shifted value exists | - -This means the local DATs are not pure 2013-command-table data. They include later-client action keys that match ACE/DatReaderWriter. - -## Current AcDream resolver behavior - -Current `MotionCommandResolver.ReconstructFullCommand` results: - -| Wire low | Current full command | -|---:|---:| -| `0x0137` | `0x40000137` | -| `0x013A` | `0x1000013A` | -| `0x0150` | `0x13000150` | -| `0x0153` | `0x10000153` | -| `0x0163` | `0x09000163` | -| `0x0166` | `0x10000166` | -| `0x016E` | `0x1000016E` | -| `0x016F` | `0x1000016F` | -| `0x0170` | `0x10000170` | -| `0x0171` | `0x10000171` | -| `0x0172` | `0x10000172` | -| `0x0173` | `0x10000173` | - -Implications: - -- ACE `/ls` wire `0x0153` currently reconstructs to `0x10000153`, which is good for ACE and local DATs. -- 2013 retail lifestone wire `0x0150` would currently reconstruct as `ScanHorizon` chat emote, not `LifestoneRecall`. -- The override comment in `MotionCommandResolver` is wrong: the mismatch does not start at `AllegianceHometownRecall`; it starts earlier around `SnowAngelState`. -- The override range `0x016E..0x0197` maps `0x016E/0x016F/0x0170` to action-class commands even though ACE names those low words as UI commands. This probably does not hurt normal ACE animation broadcasts, but it is not a clean catalog model. - -## Recommendation - -Do not replace ACE/DatReaderWriter reconstruction with 2013 `command_ids` globally. - -Use two catalogs explicitly: - -1. `Retail2013CommandCatalog` - - For proving decomp behavior and reproducing the 2013 client command table. - - Useful for old-client conformance tests. - -2. `AceModernCommandCatalog` - - For reconstructing ACE `InterpretedMotionState` wire `u16` commands into the full 32-bit motion commands that match local DAT motion tables. - - Should be the runtime default while talking to ACE. - -Then add a resolver mode/test matrix: - -- ACE mode: - - `0x0153 -> 0x10000153` (`LifestoneRecall`) - - `0x0166 -> 0x10000166` (`MarketplaceRecall`) - - `0x0171 -> 0x10000171` (`AllegianceHometownRecall`) - - `0x0173 -> 0x10000173` (`OffhandSlashHigh`) -- 2013 retail mode: - - `0x0150 -> 0x10000150` (`LifestoneRecall`) - - `0x0163 -> 0x10000163` (`MarketplaceRecall`) - - `0x016E -> 0x1000016E` (`AllegianceHometownRecall`) - - `0x0170 -> 0x10000170` (`OffhandSlashHigh`) - -Animation lookup should be tested against the DAT motion tables too, not just enum names. A command is visually usable only if the entity's `MotionTable` has a `Links` or `Modifiers` entry for that full command. - -## Bottom line - -For ACE interop, the lifestone recall animation is not missing from the local DAT motion tables. It is missing only if we force ACE's wire `0x0153` through the 2013 retail command catalog. - -The gap is real and broader than lifestone recall: the later-client/ACE command catalog diverges from the 2013 decomp for 130 common names, and several important animations only exist under the ACE-shifted IDs in the local DATs. diff --git a/docs/research/2026-06-26-character-window-retail-reference.md b/docs/research/2026-06-26-character-window-retail-reference.md deleted file mode 100644 index 8ddb9c2a..00000000 --- a/docs/research/2026-06-26-character-window-retail-reference.md +++ /dev/null @@ -1,51 +0,0 @@ -# Character window (Attributes tab) — retail reference + polish targets - -Transcribed from two user-provided retail screenshots (2026-06-26): the **not-selected** state and the -**Strength-selected** state. The PNGs live in the user's chat; this doc is the durable target spec for -the remaining polish on acdream's `CharacterStatController` (LayoutDesc 0x2100002E). "✗" = acdream gap. - -## State 1 — nothing selected - -- **Tabs:** Attributes (active, gold label), Skills, Titles. -- **Name:** "Horan" — **WHITE** (✗ acdream renders the name gold). -- **Heritage line:** "Female Aluvian Adventurer" — white. -- **PK line:** "Non-Player Killer" — white. -- **Level area (right):** small caption "Character Level" (two lines: "Character" / "Level"), then the - number **"240" as LARGE GOLD SPRITE DIGITS** — a number-sprite font, not plain text (✗ acdream draws - "126" as plain gold text, and the caption truncates to "Character Le"). -- **"Total Experience (XP):"** caption + value "100,000,017,999" (✗ caption missing in acdream). -- **"XP for next level:"** caption + value "99,738,801" with the red fill bar behind it (✗ caption + - value missing — the value element is consumed by the UiMeter at import). -- **Attribute list — 9 rows**, each = icon + name + right-aligned value: - - Row text is **LARGER** (roughly the icon height ~24px) and rows are **TIGHTER** (less vertical gap) - than acdream's current small-text / wide-spacing. - - Order: Strength 200, Endurance 10, Coordination 10, Quickness 200, Focus 10, Self 10, - Health 5/5, Stamina 10/10, Mana 10/10. -- **Footer:** "Select an Attribute to Improve" (title) / "Skill Credits Available: 96" / - "Unassigned Experience: 87,757,321,741". (acdream now matches — confirm the title says **Attribute**, - not skill.) - -## State 2 — Strength selected - -- Same header. -- **Selected row (Strength):** highlighted with a **DARKER background + bars above/below** — retail's - selected-row sprite `0x06001397` (Button state 6). (✗ acdream uses a translucent GOLD tint — replace - with the dark-bar sprite.) -- **Footer flips to:** - - Title: **"Strength: 200"** — **WHITE** text (✗ acdream uses the body/gold color). - - "Experience To Raise:" + **"Infinity!"** (Strength is maxed → cost is infinite; ✗ acdream shows a - numeric/"maxed" placeholder — show "Infinity!" when the attribute is at max). - - "Unassigned Experience:" + "87,757,321,741". -- Raise buttons (≜10 + triangle) shown at the selected row's right. - -## Polish checklist (acdream) - -1. [ ] Name color → white. -2. [ ] Level → gold sprite digits (find the number-sprite font/ids); caption "Character"/"Level" 2-line. -3. [ ] Add "Total Experience (XP):" caption. -4. [ ] Add "XP for next level:" caption + value (un-consume from the meter, or render alongside). -5. [ ] Row text larger (≈icon height) + rows tighter. -6. [ ] Selection highlight → sprite 0x06001397 (dark bars), not gold tint. -7. [ ] Selected footer title → white. -8. [ ] Maxed attribute → "Experience To Raise: Infinity!". -9. [ ] Footer title wording = "Select an Attribute to Improve" (Attribute). diff --git a/docs/research/2026-06-26-mockup-stage-handoff.md b/docs/research/2026-06-26-mockup-stage-handoff.md deleted file mode 100644 index ac345419..00000000 --- a/docs/research/2026-06-26-mockup-stage-handoff.md +++ /dev/null @@ -1,126 +0,0 @@ -# Handoff — the full multi-window UI mockup stage (2026-06-26) - -For the next agent. This session built the **UI Studio**, made the **importer faithful to the dat**, -and brought the **Character window** to a retail look. The next stage is the user's actual goal: turn the -studio into a **full interactive multi-window mockup** — several windows live in one host, draggable + -resizable + clickable — so the whole UI can be tested without the game. - -Read this, then `claude-memory/project_ui_studio.md` (the SSOT) and `project_d2b_retail_ui.md`. - ---- - -## 1. Where we are (what's done + works) - -### The UI Studio (the dev tool) -`AcDream.App ui-studio [--layout 0xNNNN | --dump ] [--screenshot ]` — a standalone -Silk.NET/GL window that renders a UI panel **through the production renderer** (`RenderBootstrap.Create` -builds a `RenderStack` = the subset of GameWindow's render stack the UI needs; **GameWindow is untouched**). -- Code: `src/AcDream.App/Studio/` (`StudioWindow`, `FixtureProvider`, `PanelFbo`, `DumpLayout`, `UiDumpModel`, - `StudioInspector`, `LayoutSource`) + `src/AcDream.App/Rendering/RenderBootstrap.cs`. -- **Two panel sources:** `--layout 0xNNNN` (real dat-import via `LayoutImporter`, populated by the PRODUCTION - controllers through `FixtureProvider` + `SampleData`), and `--dump ` (the 26-window retail dump, - `docs/research/2026-06-25-retail-ui-layout-dump.json`, static structure only). -- **Headless** `--screenshot ` (PanelFbo → glReadPixels → PNG) — the autonomous self-verify primitive. - Use it constantly. -- **Interactive:** click-routing forwards the ImGui canvas mouse → the panel's `UiHost` (coord map = - `mouse − ImGui.GetItemRectMin()`, no extra Y-flip). An **Interact / Inspect** toggle in the toolbar: - Interact = clicks drive the panel; Inspect = clicks select elements in the tree. - -### The faithful importer (the "better way" — the load-bearing win) -The treadmill of hand-tuning each panel's look in C# was because the importer DROPPED the dat's per-element -visual properties. The dat carries them in `StateDesc.Properties`: **0x1A FontDID**, **0x14/0x15** H/V -justification, **0x1B FontColor**. We wired them in, **backward-compatibly** (the importer applies them as -DEFAULTS at build time; a controller that still sets them explicitly overrides → existing panels -byte-unchanged): -- **Fix A** `4143042` — justification → `UiText.Centered/RightAligned/VerticalJustify`. -- **Fix B** `6e0be4b` — FontColor → `UiText.DefaultColor` (character colors proved RUNTIME — the dat carries none). -- **Fix C** `a0d3395` — FontDid → per-element font via a resolver (`Func`) threaded through - `LayoutImporter.Import` → `DatWidgetFactory`. **STUDIO path only**; GameWindow passes null (issue **#157**). -- **Fix 5** `ad4ed51` — `LayoutImporter.BuildWidget` builds a `UiMeter`'s non-slice (text) children while - still consuming the Type-3 slice containers (vitals unaffected). -- **Fix 4** `1b9dd6c` — investigated, NO fix: the dat has no Visible flag; runtime gm*UI is correct. - -**THE BOUNDARY (the key conceptual result):** the importer faithfully carries an element's **LOOK** -(font, justification, color, position) from the dat. **STATE + BEHAVIOR** (which group is visible, tab / -selection activation) is genuinely **runtime gm*UI logic** and belongs in the controller — it is NOT an -importer gap (Fix 4 + the Fix-5 tab-skip proved this). Don't try to push state/visibility into the importer. - -### The Character window (the proof panel) -LayoutDesc `0x2100002E`, `src/AcDream.App/UI/Layout/CharacterStatController.cs` — Attributes tab reads as -retail: 3 tabs, header, big-gold level number (its own dat FontDid), captions, 9-row attribute list -(icons + right-aligned values + Health/Stamina/Mana), click-to-select (top/bottom selection bars + footer -State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered -footer. **User accepted it as good-enough 2026-06-26** ("still needs some polish for later" — see issue for -deferred items). Specs: `docs/research/2026-06-25-character-window-faithful-spec.md`, -`2026-06-25-attributes-tab-interactive-spec.md`, `2026-06-26-character-window-retail-reference.md`. - -### Other panels with production controllers (the mockup's building blocks) -`VitalsController` (0x2100006C), `ToolbarController` (0x21000016), `InventoryController` + `PaperdollController` -(0x21000023), `ChatWindowController`, `CharacterStatController` (0x2100002E). All bind real importer-mounted -elements via `FindElement(datId)` (note: `UiElement.EventId` ≠ the dat id — the dat id lives in -`ImportedLayout._byId`). - ---- - -## 2. The mockup goal + proposed approach - -**Goal:** the studio shows ONE panel today. The mockup shows the **whole UI at once** — multiple windows in -one host, each draggable + resizable + clickable — a real testbed. - -**Why it's reachable:** the production `UiHost` (`src/AcDream.App/UI/UiHost.cs`) ALREADY manages multiple -draggable/resizable windows and routes input — it's the game's actual UI layer. The studio just needs to host -the full thing instead of one FBO'd panel. (Per memory, the game has a window manager: F12 toggles inventory; -there's a `UiHost.ToggleWindow` / `WindowNames` API.) - -**Proposed path (a new studio "mockup mode"):** -1. Load SEVERAL panels into ONE `UiHost` (not one panel per FBO) — a "desktop." Render the host directly to - the studio GL window (not the inspector FBO), so native input/drag/resize work. -2. Window open/close/arrange (reuse the game's window-manager API). -3. Drag + resize via the production frame chrome (already exists for the retail windows). -4. Keep the inspector available as an optional overlay. -The studio is the THIN host; the production `UiHost` is the engine. Don't reimplement window management — -drive the production one. - ---- - -## 3. Must-fix before / during the mockup - -- **#156** — the **inventory window (0x21000023) renders all-black in the studio** (pre-existing fixture gap, - NOT a regression). It's a key window for the mockup; fix the studio's inventory fixture/sub-window setup. -- **#157** — GameWindow doesn't thread the Fix-C font resolver (live game = global font). Turnkey (the issue - has the exact 4 import sites + the `ConcurrentDictionary` + `GetOrAdd` pattern). -- **Character deferred polish** — the user flagged "some polish for later" (level-glyph fidelity, icon - crispness, sample strings). Filed as an issue; the user will enumerate. - -## 4. Patterns + lessons (don't relearn these the hard way) - -- **Faithful-panel pattern:** bind real importer-mounted elements via `FindElement(datId)`; `EventId` ≠ dat id. -- **Importer dat-fidelity:** the look comes from the dat; when re-deriving fonts/colors/positions in code feels - like a treadmill, AUDIT what the dat element carries vs what the importer drops, then wire it backward-compat. -- **The boundary:** look = importer; state/behavior = runtime controller. Don't fight it. -- **Apparatus over speculation:** for a layout bug, DUMP the actual rendered rects (walk the tree, print - abs rect + visible + text). One position dump cracked the footer-overlap after FIVE speculative passes. - cdb is for runtime *behavior* questions, not "why is our element at the wrong place." -- **Watch subagents for scope creep:** the Fix-C subagent finished its task then committed unrequested AP-58 + - #148 (reverted per the user, kept in reflog `46c5cd2`/`82e13b7`). Constrain dispatches to the assigned task. -- **Shared-infra changes:** regression-screenshot vitals + toolbar (the canaries) every time; chat is the one - panel the studio can't easily preview, so never change global `UiText`/`UiMeter` behavior without verifying it. - -## 5. Reference map - -- Studio spec/plan: `docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md`, - `docs/superpowers/plans/2026-06-25-ui-studio-previewer.md`; dump sweep `docs/research/2026-06-25-studio-dump-panel-sweep.md`. -- Character window: the three specs above + the retail reference. -- Importer audit (this session): the dat carries FontDID/justification/FontColor in `StateDesc.Properties`; - `ElementReader`/`LayoutImporter`/`DatWidgetFactory`/`UiText` are the wiring path. -- The retail UI dump: `docs/research/2026-06-25-retail-ui-layout-dump.json` (26 windows, real ids + rects + sprites). -- Deferred studio features (the original plan, Tasks 5-8): live 3-D doll, markup hot-reload, editable props, - camera sliders — secondary to the mockup. -- Memory SSOT: `claude-memory/project_ui_studio.md`, `project_d2b_retail_ui.md`. - -## 6. First concrete steps for the next agent - -1. Fix **#156** (inventory studio render) — you need the inventory window for any real mockup. -2. Prototype **mockup mode**: render the production `UiHost` (with vitals + toolbar + character + chat opened) - directly to the studio GL window, native input. Confirm drag + resize + click work through the production host. -3. Then iterate window open/close/arrange + the desktop layout. diff --git a/docs/research/2026-06-26-movement-animation-retail-parity-audit.md b/docs/research/2026-06-26-movement-animation-retail-parity-audit.md deleted file mode 100644 index bb94aaad..00000000 --- a/docs/research/2026-06-26-movement-animation-retail-parity-audit.md +++ /dev/null @@ -1,301 +0,0 @@ -# Movement and Animation Retail Parity Audit - -Date: 2026-06-26 - -## Verdict on approach - -The approach is good if it stays decomp-first and test-first. For this area, "verbatim" needs to mean: - -- Outbound wire bytes match retail packers for the same input state. -- Incoming movement packets are parsed into the same semantic state retail would unpack. -- Local, remote player, monster, NPC, and force-walk paths flow through retail-equivalent motion state transitions before animation selection. -- Any behavior that cannot be proven from retail decomp, Ghidra, or retail captures is marked unresolved instead of tuned by feel. - -The approach is not good if it begins as a broad rewrite or animation polish pass. The current code has several comments and tests that already encode approximations. Those need to become failing parity tests first, then implementation slices. - -## Sources checked - -Primary oracle: - -- `docs/research/named-retail/acclient_2013_pseudo_c.txt` -- `docs/research/named-retail/acclient.h` -- `docs/research/named-retail/acclient.c` - -Secondary oracle: - -- `docs/research/acclient_decompiled.c` -- Ghidra HTTP bridge verified on `http://127.0.0.1:8081`; `methods?limit=3` responded and decompile-by-address worked during the audit. Codex tool discovery did not expose a first-class Ghidra MCP namespace in this thread, so direct HTTP is the usable path here. - -Current implementation surfaces: - -- `src/AcDream.Core.Net/Messages/MoveToState.cs` -- `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` -- `src/AcDream.Core.Net/Messages/JumpAction.cs` -- `src/AcDream.Core.Net/Messages/UpdateMotion.cs` -- `src/AcDream.Core.Net/Messages/UpdatePosition.cs` -- `src/AcDream.Core.Net/Messages/CreateObject.cs` -- `src/AcDream.Core.Net/WorldSession.cs` -- `src/AcDream.Core/Physics/MotionInterpreter.cs` -- `src/AcDream.Core/Physics/AnimationSequencer.cs` -- `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` -- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` -- `src/AcDream.App/Input/PlayerMovementController.cs` -- `src/AcDream.App/Rendering/GameWindow.cs` - -## Retail source map - -### Outbound movement to ACE - -- `CommandInterpreter::ShouldSendPositionEvent` at `0x006b45e0`, pseudo line around `700233`. -- `CommandInterpreter::SendMovementEvent` at `0x006b4680`, pseudo line around `700274`. -- `CommandInterpreter::SendPositionEvent` at `0x006b4770`, pseudo line around `700316`. -- `CM_Movement::Event_AutonomousPosition` at `0x006af790`, sub-opcode `0xf753`. -- `CM_Movement::Event_Jump` at `0x006afa70`, sub-opcode `0xf61b`. -- `CM_Movement::Event_MoveToState` at `0x006afc00`, sub-opcode `0xf61c`. -- `MoveToStatePack::Pack` at `0x005168f0`, pseudo line around `284694`. -- `AutonomousPositionPack::Pack` at `0x00516af0`, pseudo line around `284795`. -- `JumpPack::Pack` at `0x00516d10`, pseudo line around `284915`. -- `RawMotionState::Pack` at `0x0051ed10`, pseudo line around `293761`. - -Retail `RawMotionState` defaults: - -- `current_holdkey = HoldKey.None` -- `current_style = 0x8000003d` -- `forward_command = 0x41000003` -- `forward_holdkey = HoldKey.Invalid` -- `forward_speed = 1.0` -- `sidestep_command = 0` -- `sidestep_holdkey = HoldKey.Invalid` -- `sidestep_speed = 1.0` -- `turn_command = 0` -- `turn_holdkey = HoldKey.Invalid` -- `turn_speed = 1.0` - -Retail `RawMotionState::Pack` compares against these defaults. It does not set a field bit merely because the caller supplied a value. For example, `forward_speed == 1.0f` is omitted. - -### Incoming movement and force movement - -- `MovementManager::PerformMovement` at `0x005240d0`, pseudo line around `300194`, routes movement types `1-5` to `CMotionInterp` and `6-9` to `MoveToManager`. -- `MovementManager::unpack_movement` at `0x00524440`, pseudo line around `300563`, unpacks interpreted motion plus `MoveToObject`, `MoveToPosition`, `TurnToObject`, and `TurnToHeading`. -- `MovementParameters::UnPackNet` at `0x0052ac70`, pseudo line around `308118`. -- `MovementParameters::get_command` at `0x0052aa00`, pseudo line around `307946`. -- `MoveToManager::_DoMotion`, `MoveToPosition`, and related queue logic are around pseudo lines `306351`, `307187`, and `307521`. - -Retail movement types from `acclient.h`: - -- `0`: interpreted motion in inbound packet context -- `1`: raw motion -- `2`: interpreted motion -- `3`: stop raw motion -- `4`: stop interpreted motion -- `5`: stop completely -- `6`: move to object -- `7`: move to position -- `8`: turn to object -- `9`: turn to heading - -### Animation and motion interpretation - -- `CMotionInterp::apply_run_to_command` at `0x00527be0`, pseudo line around `305062`. -- `CMotionInterp::get_state_velocity` at `0x00527d50`. -- `CMotionInterp::adjust_motion` at `0x00528010`, pseudo line around `305343`. -- `CMotionInterp::apply_raw_movement` at `0x005287e0`. -- `CMotionInterp::DoInterpretedMotion` around pseudo line `305575`. -- `CMotionInterp::apply_current_movement` around pseudo line `305713`. -- `CMotionTable::GetObjectSequence` around pseudo line `298636`. -- `CSequence` append/advance/update logic around pseudo lines `301622`, `301777`, `301839`, and `302425`. - -Key retail normalization: - -- `SideStepLeft` becomes `SideStepRight` with negative speed. -- `SideStepRight` speed becomes `(3.11999989 / 1.25) * 0.5 * speed`. -- `TurnLeft` becomes `TurnRight` with negative speed. -- `WalkBackward` becomes `WalkForward` with `-0.649999976 * speed`. -- Hold-key `Run` upgrades forward walk to run, multiplies turn speed by `1.5`, and multiplies sidestep by run rate with an absolute clamp of `3.0`. - -## Confirmed divergences - -### D1: MoveToState raw flags are not retail - -Current `MoveToState.Build` sets raw-motion flags from nullable/presence inputs. Retail sets flags by comparing the complete `RawMotionState` against default values. This means the current client can over-send: - -- `CurrentHoldKey = None` -- `ForwardSpeed = 1.0` -- `SidestepSpeed = 1.0` -- `TurnSpeed = 1.0` -- likely per-axis hold keys when they are default `Invalid` - -Existing `MoveToStateTests.Build_WalkForward_IncludesForwardCommandInFlags` currently asserts the non-retail behavior by expecting a `ForwardSpeed` flag for speed `1.0`. - -Impact: outbound movement bytes can differ from retail for normal running, walking, sidestepping, and turning. - -### D2: RawMotionState action-list and style packing are incomplete - -Retail bitfield layout uses 11 one-bit flags plus `num_actions : 5` at bits `11-15`. Current comments imply the command list length is broader than retail. Current builder does not cover full action-list packing or current-style scenarios. - -Impact: action/modifier movement states cannot be proven retail-equivalent. - -### D3: MoveToState longjump bit is not modeled - -Retail `MoveToStatePack::Pack` writes one trailing byte: - -- bit `0x01`: contact -- bit `0x02`: `standing_longjump` - -Current `GameWindow` passes only contact `0/1` into a generic byte. The source of `standing_longjump` is not wired as a named state. - -Impact: standing longjump movement-state updates can differ from retail. - -### D4: JumpAction packet layout is retail-incompatible - -Retail `JumpPack` packs: - -- `float extent` -- `Vector3 velocity` -- full `Position` -- four `u16` update timestamps -- alignment - -Current `JumpAction.Build` packs extent and velocity, then timestamps, then extra `objectGuid` and `spellId`, and does not pack `Position`. - -Impact: local jump movement update is a high-confidence wire mismatch. - -### D5: Position heartbeat is close but not fully proven - -Retail `ShouldSendPositionEvent` gates autonomous position updates by active state, autonomy level, player smartbox, 1.0 second interval, cell/frame change, and contact-plane change. Current `PlayerMovementController` has a similar cadence. - -Open proof point: retail `SendMovementEvent` visibly stamps `last_sent_position_time`; retail `SendPositionEvent` stamps time, position, and contact plane. Current `GameWindow` calls `NotePositionSent` after both MTS and AP, stamping all three. - -Impact: the client may suppress or reorder autonomous position sends differently after movement-state sends. - -### D6: MotionInterpreter lacks canonical retail raw-to-interpreted normalization - -Retail `CMotionInterp::apply_raw_movement` copies raw state, calls `adjust_motion` for forward/sidestep/turn, applies run-hold behavior, then applies interpreted movement. Current `MotionInterpreter.DoMotion` stores commands directly and applies velocity directly. - -Specific visible risks: - -- `SideStepLeft` may produce no velocity because velocity code only recognizes `SideStepRight`. -- backward and sidestep speeds are patched elsewhere instead of normalized at the retail source point. -- turn run-speed scaling is not faithfully represented. -- local jump lateral velocity relies on comments that say this is temporary until `adjust_motion` is ported. - -Impact: running, slow walking, slow side walking, backward movement, turning, and jump lateral motion are not retail-proven. - -### D7: Animation application is split away from retail motion flow - -Retail movement application sequences style, forward or falling, sidestep start/stop, turn start/stop, and actions through `CMotionInterp` and `CMotionTable::GetObjectSequence`. Current `AnimationSequencer` implements useful pieces, but `MotionInterpreter.apply_current_movement` is mostly velocity-oriented and does not drive animation state through the same retail order. - -Impact: animations can look plausible while being state-machine divergent. - -### D8: Force-walk and MoveTo are approximations - -Retail `MoveToManager` is a queued command system over `CMotionInterp`, with pre-turn, move, aux turn, final heading, sticky targeting, progress failure, and `MovementParameters::get_command` walk/run/hold-key selection. - -Current `ServerControlledLocomotion`, `RemoteMoveToDriver`, and `PlayerMovementController.BeginServerAutoWalk` approximate visible steering and cycle selection. - -Impact: ACE force walking and monster/NPC MoveTo behavior are not retail-equivalent. - -### D9: Inbound movement types 8 and 9 are dropped - -Retail inbound movement supports: - -- `8`: `TurnToObject` -- `9`: `TurnToHeading` - -Current `UpdateMotion` handles interpreted state and MoveTo types `6/7`, but not `8/9`. - -Impact: incoming server turn commands for characters, monsters, and NPCs are ignored. - -### D10: Spawn-time movement state is weaker than live movement state - -`CreateObject` can detect movement types and some flags, but it does not preserve full MoveTo target/origin/threshold data the way live `UpdateMotion` does. - -Impact: monsters/NPCs spawned while already moving cannot be driven retail-equivalently until a later movement packet arrives. - -### D11: Sequence/autonomy data is parsed then discarded - -Retail carries movement sequence, server-control sequence, autonomous state, motion flags, and position sequence into movement application. Current events expose only a subset. - -Impact: ordering, stale update rejection, and force-control transitions cannot match retail. - -### D12: Jump/falling/contact gates are simplified - -Retail allows specific movement while falling/dead and has separate jump checks for posture, stamina, constraints, pending motion, contact, and leave/hit-ground reapplication. Current code blocks or simplifies several of these paths. - -Impact: airborne movement, falling animation, dead/fallen movement commands, and jump eligibility are not retail-proven. - -## Priority plan - -### Phase 0: Lock the oracle - -1. Create small retail-reference helpers/tests that encode `RawMotionState::Pack`, `MoveToStatePack::Pack`, `AutonomousPositionPack::Pack`, `JumpPack::Pack`, `MovementParameters::UnPackNet`, and `MovementParameters::get_command`. -2. Every helper must cite the decomp function and address in the test name or comments. -3. Any unclear branch gets a TODO with address and blocked status, not an approximation. - -Exit criteria: failing tests describe current divergences without changing runtime behavior yet. - -### Phase 1: Fix outbound wire parity - -1. Change `MoveToState` packing from presence-based to retail default-difference packing. -2. Add explicit raw-state model with defaults, current style, action list count, and per-axis hold keys. -3. Add explicit `contact` and `standingLongjump` parameters for MTS trailing byte. -4. Replace `JumpAction` with retail `JumpPack`: extent, velocity, full position, four timestamps, align; remove extra object/spell fields unless another retail opcode proves they belong elsewhere. -5. Verify timestamp order: instance `[8]`, server-control `[5]`, teleport `[4]`, force-position `[6]`. -6. Audit whether MTS should stamp only last-send time or also last-send position/contact plane. - -Exit criteria: golden byte tests pass for walk, run, sidestep, turn, slow walk/toggle-run, jump, contact, longjump, and AP heartbeat cases. - -### Phase 2: Port retail raw/interpreted motion core - -1. Introduce retail-shaped `RawMotionState` and `InterpretedMotionState` in core physics. -2. Port `adjust_motion`, `apply_run_to_command`, `get_state_velocity`, and `apply_raw_movement`. -3. Route local player input through this path instead of compensating in `PlayerMovementController`. -4. Preserve run rate and hold-key semantics, including toggle-run slow-walk behavior. - -Exit criteria: movement state tests prove backward, sidestep left/right, run turn, run sidestep clamp, forward run, and slow-walk semantics match retail math. - -### Phase 3: Reconnect animation to retail movement application - -1. Port enough of `CMotionInterp::apply_current_movement` to sequence style, forward/falling, sidestep stop/start, turn stop/start, and actions in retail order. -2. Keep existing `AnimationSequencer` where it already matches retail, but move normalization out of sequencer-only code and into motion interpretation. -3. Add parity tests for reverse playback, link fallback, modifier state, action sequencing, and root-motion composition using real retail motion table data. - -Exit criteria: local and remote animation selection flows from the same interpreted state that drives velocity. - -### Phase 4: Port inbound movement and force-walk behavior - -1. Extend `UpdateMotion` to parse and surface types `8` and `9`. -2. Preserve sequence/autonomy/motion flags through `WorldSession` events. -3. Preserve full spawn-time MoveTo state in `CreateObject`. -4. Replace approximate server-driven locomotion with a retail-shaped `MoveToManager` queue over `CMotionInterp`. -5. Port `MovementParameters::get_command`, including walk/run threshold, `can_run`, force-walk, hold-key application, fail distance, sticky/target following, and final heading. - -Exit criteria: incoming interpreted motion, MoveToObject, MoveToPosition, TurnToObject, TurnToHeading, and ACE force-walk packets animate and move through retail-equivalent state transitions. - -### Phase 5: Integration captures and regression guard - -1. Capture retail-equivalent fixtures for normal run, slow walk, sidestep, slow sidestep, backward, turn, jump, forced walk, NPC MoveTo, monster chase/flee, and remote player interpolation. -2. Assert wire bytes for outbound messages. -3. Assert parsed semantic state for inbound messages. -4. Assert animation command sequence and velocity for local and remote entities. -5. Run full test suite plus a targeted in-app smoke pass. - -Exit criteria: no movement or animation path is accepted without a retail-addressed test. - -## Initial test inventory to add or replace - -- `MoveToState` golden tests: default raw state, walk forward speed `1.0`, run forward, backward, sidestep right/left, turn right/left, current style, action count, contact/longjump byte. -- `JumpAction` golden tests: retail `JumpPack` layout with full position and no object/spell fields. -- `AutonomousPosition` tests: timestamp order and contact byte. -- `PlayerMovementController` tests: forward walk/run, slow walk/toggle-run, backward, sidestep, turn-only run, jump release, server auto-walk suppression. -- `ShouldSendPositionEvent` tests: pre-interval cell change, pre-interval contact-plane change, interval frame change, idle no-send, airborne AP suppression. -- `UpdateMotion` tests: interpreted, MoveToObject, MoveToPosition, TurnToObject, TurnToHeading. -- `CreateObject` tests: spawn-time MoveTo preservation. -- `WorldSession` tests: sequence/autonomy propagation. -- `MotionInterpreter` tests: `adjust_motion`, `apply_run_to_command`, `get_state_velocity`, contact gates, falling fallback, jump eligibility. -- `AnimationSequencer` tests: retail sequence order, stop/start transitions, reverse playback, link fallback, modifier combine/subtract behavior. -- `MoveToManager` tests: pre-turn, move, aux turn, final heading, target following, fail distance, force-walk walk/run choice. - -## Implementation rule - -No code path in this system should remain justified by "feels like retail", "ACE-compatible", or "close enough". If the decomp is unclear, use Ghidra or captures to resolve it. If it still cannot be resolved, leave the behavior behind a clearly named unresolved test or blocked TODO rather than guessing. diff --git a/docs/research/2026-07-01-d6-motion-interp-pseudocode.md b/docs/research/2026-07-01-d6-motion-interp-pseudocode.md deleted file mode 100644 index a96957f4..00000000 --- a/docs/research/2026-07-01-d6-motion-interp-pseudocode.md +++ /dev/null @@ -1,227 +0,0 @@ -# D6 — CMotionInterp motion-normalization port: pseudocode + integration map - -Date: 2026-07-01 -Phase: **L.1 / L.2b follow-up (D6)** — port retail's raw→interpreted motion -normalization so local velocity for backward/strafe-left comes from the retail -source instead of hand-mirrored controller code. -Standard: **decomp-verbatim.** Retail decomp is the oracle; ACE -`MotionInterp.cs` is the secondary oracle and was confirmed to match retail -**byte-for-byte** on every constant and branch (understand-phase workflow, -2026-07-01). - -Oracle anchors (all in `docs/research/named-retail/acclient_2013_pseudo_c.txt`): -- `CMotionInterp::adjust_motion` — `0x00528010`, lines 305343-305400 -- `CMotionInterp::apply_run_to_command` — `0x00527be0`, lines 305062-305123 -- `CMotionInterp::get_state_velocity` — `0x00527d50`, lines 305160-305204 -- `CMotionInterp::apply_raw_movement` — `0x005287e0`, lines 305817-305834 -Secondary oracle: `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs` -(constants L26-32; `adjust_motion` L394-428; `apply_run_to_command` L525-562; -`get_state_velocity` L678-700; `get_leave_ground_velocity` L654-663; -`apply_raw_movement` L506-523). - -## Constants (retail = ACE, verified) - -| name | value | retail anchor | -|---|---|---| -| BackwardsFactor | `0.649999976` | `007c8910`; WalkBackwards speed `*= -BackwardsFactor` | -| WalkAnimSpeed | `3.11999989` | `007c891c`; forward `v.Y = WalkAnimSpeed * fwdSpeed` | -| RunAnimSpeed | `4.0` | RunForward `v.Y`; also `maxSpeed = RunAnimSpeed * rate` | -| SidestepAnimSpeed | `1.25` | sidestep `v.X`; adjust_motion divisor | -| SidestepFactor | `0.5` | adjust_motion sidestep scale | -| RunTurnFactor | `1.5` | apply_run_to_command TurnRight | -| MaxSidestepAnimRate | `3.0` | apply_run_to_command SideStepRight clamp | - -MotionCommand hex: `0x45000005`=WalkForward, `0x45000006`=WalkBackwards, -`0x44000007`=RunForward, `0x6500000d`=TurnRight, `0x6500000e`=TurnLeft, -`0x6500000f`=SideStepRight, `0x65000010`=SideStepLeft. - -## The four functions (faithful pseudocode) - -### apply_raw_movement (orchestrator) — 0x005287e0 -``` -if physics_obj == null: return -// copy 7 raw fields into interpreted -interp.current_style = raw.current_style -interp.forward_command = raw.forward_command ; interp.forward_speed = raw.forward_speed -interp.sidestep_command = raw.sidestep_command; interp.sidestep_speed = raw.sidestep_speed -interp.turn_command = raw.turn_command ; interp.turn_speed = raw.turn_speed -// normalize EACH channel with ITS OWN per-channel hold key -adjust_motion(ref interp.forward_command, ref interp.forward_speed, raw.forward_holdkey) -adjust_motion(ref interp.sidestep_command, ref interp.sidestep_speed, raw.sidestep_holdkey) -adjust_motion(ref interp.turn_command, ref interp.turn_speed, raw.turn_holdkey) -apply_interpreted_movement(arg2, arg3) // acdream: apply velocity via get_state_velocity -``` - -### adjust_motion(ref cmd, ref speed, holdKey) — 0x00528010 -``` -if weenie != null and !weenie.IsCreature(): return // non-creature: no adjust -switch cmd: - RunForward: return // already normalized — NO holdkey path - WalkBackwards: cmd = WalkForward; speed *= -BackwardsFactor // -0.649999976 - TurnLeft: cmd = TurnRight; speed *= -1 - SideStepLeft: cmd = SideStepRight; speed *= -1 -// after remap, sidestep anim-rate scale: -if cmd == SideStepRight: - speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed) // (3.12/1.25)*0.5 ≈ 1.24799995 -// hold-key (runs for everything EXCEPT the RunForward early-return): -if holdKey == Invalid: holdKey = raw.current_holdkey -if holdKey == Run: apply_run_to_command(ref cmd, ref speed) -``` -GOTCHA: RunForward early-returns and does NOT run the hold-key path. The -sidestep negate happens BEFORE the ×1.248 scale (so SideStepLeft = -1.248*speed). - -### apply_run_to_command(ref cmd, ref speed) — 0x00527be0 -``` -speedMod = 1.0 -if weenie != null: speedMod = weenie.InqRunRate(out r) ? r : MyRunRate -switch cmd: - WalkForward: - if speed > 0: cmd = RunForward // promote ONLY when moving forward (sign gate) - speed *= speedMod // UNCONDITIONAL — applies to backward too (negative speed) - TurnRight: - speed *= RunTurnFactor // *1.5 - SideStepRight: - speed *= speedMod - if abs(speed) > MaxSidestepAnimRate: // clamp to ±3.0 (ACE resolves the {test ah,0x5} polarity) - speed = speed > 0 ? MaxSidestepAnimRate : -MaxSidestepAnimRate -``` -GOTCHA: WalkForward `speed *= speedMod` is unconditional — so **backward -DOES get run-scaled** (resolves the UN-5 "backward cites nothing" doubt in -acdream's favor). The walk→run promotion is sign-gated: backward keeps a -negative speed so it stays WalkForward (not promoted). - -### get_state_velocity() -> local Vector3 — 0x00527d50 -``` -v = (0,0,0) -if interp.sidestep_command == SideStepRight: v.X = SidestepAnimSpeed * interp.sidestep_speed // 1.25*s -if interp.forward_command == WalkForward: v.Y = WalkAnimSpeed * interp.forward_speed // 3.12*s -else if interp.forward_command == RunForward: v.Y = RunAnimSpeed * interp.forward_speed // 4.0*s -v.Z = 0 -rate = MyRunRate; if weenie != null: weenie.InqRunRate(ref rate) -maxSpeed = RunAnimSpeed * rate // 4.0 * rate -if v.Length() > maxSpeed: v = normalize(v) * maxSpeed // PORT ACE'S FORM — decomp operand - // names are wrong (x87 register aliasing) -``` -GOTCHA: backward/strafe-left produce velocity here ONLY because adjust_motion -already converted them to WalkForward/SideStepRight with negated speed. Z (jump) -is added by the caller `get_leave_ground_velocity`, AFTER this clamp. - -## acdream current state (what D6 replaces) - -Two divergent velocity paths (verified in source): -- **PATH A** — `MotionInterpreter.get_state_velocity` (`MotionInterpreter.cs:599-648`) - is faithful but only handles WalkForward/RunForward/SideStepRight; returns 0 - for WalkBackward + SideStepLeft because `adjust_motion` is **not ported** (the - InterpretedState holds the un-normalized command). -- **PATH B** — the controller bypass (`PlayerMovementController.cs`): grounded - velocity block builds body-local velocity by hand — forward `localY=stateVel.Y` - (`:986`), backward `localY=-(WalkAnimSpeed*0.65*runMul)` (`:988`), strafe - `localX=±SidestepAnimSpeed*runMul` (`:994/:996`); `runMul = InqRunRate if Run - else 1.0` (`:981-983`). The **same formulas are re-copied in the jump block** - (`:1072`, `:1076-1079`) because LeaveGround→get_state_velocity would zero - backward/strafe. This duplication is register rows **TS-22** + **UN-5**. - -Integration seams: -- Input enters at `PlayerMovementController.Update(dt, MovementInput)` (`:838`); - section 2 (`:911-1000`) maps input → `_motion.DoMotion`/`DoInterpretedMotion` - + the hand-built velocity. -- Velocity is consumed by `_body.set_local_velocity` (`:998`, jump `:1090`) → - physics integration (`:1096-1130`) → `ResolveWithTransition` (`:1136-1160`). -- The **outbound wire** (section 6, `:1329-1399` → MovementResult → GameWindow - `:8264-8388`) is a SEPARATE construction (the L.2b RawMotionState); it sends - the RAW commands (WalkForward+HoldKey.Run, backward/strafe @1.0) and ACE/the - observer runs adjust_motion. D6 must NOT change these wire bytes. - -## Behavior changes D6 introduces (retail-faithful; flag for smoke test) - -1. **Strafe ~20% faster.** Current `1.25×runRate`; retail `1.25 × 1.248 - (adjust_motion sidestep scale) × runRate = 1.56×runRate`, then clamped via - `SideStepSpeed ≤ 3.0` (so v.X ≤ 3.75). acdream is currently missing the 1.248 - scale + the 3.0 clamp. -2. **Sidestep ±3.0 clamp becomes active** at high run rates. -3. **Backward unchanged** for typical run rates (already retail-faithful: - `3.12×0.65×runRate`), now sourced from the real pipeline. -4. **Backward/strafe-left no longer zero** out of `get_state_velocity` (the - TS-22 bug class is retired at the source, not hand-patched). - -## Out of scope for D6 (follow-ups) - -- **Turn**: `get_state_velocity` does not produce turn velocity (turn is - angular). acdream drives Yaw directly (`RemoteMoveToDriver.TurnRateFor`, - ~90°/135° per sec). D6 keeps direct-Yaw turn; routing turn through interpreted - angular velocity is a separate slice. `adjust_motion` on the turn channel is - still ported (for completeness + the wire), but it does not change turn feel. -- **RawMotionState unification**: retail uses ONE raw state for both the wire - and the velocity pipeline. D6 keeps the L.2b wire construction separate and - builds/normalizes a velocity-side raw state; unifying the two is a later slice. -- `apply_current_movement` full sequence (style/forward/sidestep/turn/action - ordering, `CMotionTable::GetObjectSequence`) — the understand-phase extractor - for this one hit the structured-output cap; re-extract when the animation - sequencing slice needs it. Not required for the velocity port. - -## Design decisions (locked with the user 2026-07-01) - -1. **Full unification.** ONE `AcDream.Core.Physics.RawMotionState` (the L.2b - type) is built from `MovementInput` and drives BOTH the outbound wire packing - AND the local velocity/turn pipeline (`apply_raw_movement` → - `get_state_velocity` + turn omega). The separate GameWindow wire-construction - is retired; `MovementResult` carries the raw state, GameWindow packs it. -2. **`forward_speed = 1.0` on run — CONFIRMED viable (echo-test 2026-07-01).** - The echo-test settled it: acdream sends `forward_speed=1.0`, ACE broadcasts - back `RunForward @ runRate` (not `1.0`), and a retail observer saw +Acdream run - at full pace — so **ACE recomputes the broadcast run speed from run skill** - (the `MovementData.cs`-citing "ACE relays" assumption was wrong). The wire now - sends the retail-faithful raw `1.0` (D6.2b). Original reasoning below stands: - The raw `forward_speed` - must be `1.0` when running, because `apply_run_to_command` multiplies by - run-rate — carrying `runRate` in the raw state would double-scale to - `runRate²`. So the wire now sends `forward_speed=1.0` (omitted by - default-difference) + `HoldKey.Run`, NOT the current `runRate`. This is the - retail-faithful encoding (observer/server derives the run speed). **Acceptance - = the smoke echo:** send `1.0`, confirm the `UM` echo still shows the player - at `~runRate` (⇒ ACE recomputes from run skill ⇒ correct). If the echo shows - `~1.0`, ACE relays literally and the wire half reverts. The L.2b golden tests - update to the faithful encoding (forward_speed omitted on run). -3. **Turn ported to interpreted, feel unchanged.** Local turn is driven from the - interpreted `turn_speed` (`adjust_motion`: TurnLeft→TurnRight `×-1`, `×1.5` - run) via `omega.Z = ±(π/2) × turn_speed` — the SAME formula the remote path - already uses (`GameWindow.cs:4845`). Numerically identical to today's - `TurnRateFor` for the local player (both `π/2 × RunTurnFactor`), so no - turn-feel change; the fixed `TurnRateFor` direct-Yaw is replaced by the - pipeline. **AP-9 stays** (the `π/2` base rate is still an approximation of the - per-creature TurnSpeed; wiring that is a separate follow-up). -4. **References caveat.** Only `references/WorldBuilder` is checked out in this - worktree; ACE/holtburger are not. The retail decomp - (`docs/research/named-retail/`) is the sole in-repo oracle and was verified - directly for all four functions. ACE values quoted here are training-memory - cross-checks that happen to match the decomp — do not cite ACE file:lines. - -**Retires / touches:** register **TS-22** deleted (adjust_motion ported; -get_state_velocity no longer returns 0 for backward/strafe-left). **UN-5** -resolved (run-scaling backward IS retail — `apply_run_to_command` unconditional -`*speedMod`). **AP-9** stays (turn base rate). A wire-encoding change row is -added if the echo test confirms `forward_speed=1.0`. - -**Implementation slices:** -- **D6.1 (delegated, bounded):** port `adjust_motion` + `apply_run_to_command` - into `MotionInterpreter` + conformance tests, tests-first, against this doc. -- **D6.2 (lead, delicate):** `apply_raw_movement` orchestrator + build the - unified `RawMotionState` in `PlayerMovementController` from `MovementInput`, - route grounded + jump velocity through `get_state_velocity`, drive turn omega - from interpreted `turn_speed`, thread the raw state through `MovementResult` - to the GameWindow packer, delete the hand-mirrors (`:988/:994/:996` + jump - `:1072/:1076-1079`) and the `TurnRateFor` direct-Yaw, update the L.2b golden - tests. Then smoke (echo + strafe/backward/turn/jump feel). - -## Conformance-test targets - -- `adjust_motion`: each of the 7 command transforms + the sidestep 1.248 scale + - the backward -0.65 + the negate-before-scale order + the RunForward no-op + - the non-creature early return + per-channel holdKey inheritance. -- `apply_run_to_command`: WalkForward promote-when-speed>0 + unconditional - `*speedMod`; TurnRight ×1.5; SideStepRight ×speedMod + ±3.0 clamp (test the - boundary at runRate that pushes |speed| past 3.0). -- `get_state_velocity`: backward + strafe-left now non-zero (the TS-22 fix); - the maxSpeed normalize clamp; strafe = 1.56×runRate clamped to ≤3.75. -- `apply_raw_movement`: 3-channel copy + per-channel adjust + the run-promotion - of a running-forward raw state. diff --git a/docs/research/2026-07-02-inbound-motion-deviation-map.md b/docs/research/2026-07-02-inbound-motion-deviation-map.md deleted file mode 100644 index 01877eae..00000000 --- a/docs/research/2026-07-02-inbound-motion-deviation-map.md +++ /dev/null @@ -1,323 +0,0 @@ -# Inbound motion deviation map — remote-entity animation + position vs retail - -Date: 2026-07-02 -Mode: **/investigate report — no code changes made.** This doc + the four mapper -reports under `2026-07-02-inbound-motion-maps/` are the only writes (uncommitted). -Branch: `claude/vigorous-joliot-f0c3ad`. Follows the handoff -`2026-07-02-inbound-motion-verbatim-port-handoff.md`. - -## Symptom (acceptance oracle — user's live observation, axiom) - -Watching a remote player in acdream: walk↔run transitions WITHOUT stopping react -too slowly in the motion interpreter, throwing animation + position off afterward -and compounding through continued running/turning; plus general sliding + position -errors on stop. - -## Headline findings - -1. **The premise behind acdream's current walk↔run mechanism is refuted.** The - #39-era UP-velocity refinement layer (`ApplyPlayerLocomotionRefinement`, - 0.2 s UM-grace + 5–10 Hz UP cadence + 5.5/4.5 m/s hysteresis) was built on the - 2026-05-06 finding "the wire goes silent on Shift-toggle." Both oracles now - contradict that finding: - - **Retail sends.** `CommandInterpreter`'s HoldRun command handler - (`0x85000001`, pseudo-C `acclient_2013_pseudo_c.txt:699322-699328`, addr - 0x006b37a8→0x006b3852) calls `SetHoldRun` then `SendMovementEvent()` - **whenever the player is not standing still** — i.e. a Shift toggle while W - is held emits a fresh MoveToState. (Same for HoldSidestep `0x85000002`.) - - **ACE relays.** `GameActionMoveToState.cs:36` calls - `BroadcastMovement(moveToState)` **unconditionally** for every inbound - MoveToState (`Player_Networking.cs:364-365` → `GameMessageUpdateMotion` - broadcast, with the WalkForward+HoldKey.Run→RunForward upgrade and - run-skill speed recompute). - So an observer **should** receive a UM with ForwardCommand flipping - Walk↔Run on every Shift toggle. Why the 2026-05-06 clean test saw only - Ready↔Run `[FWD_WIRE]` transitions is the one open empirical question — - settle it with probe S0 below before touching the refinement layer. -2. **Retail never adapts animation from observed pace.** Three independent decomp - dives + the ACE port cross-check all converge: the inbound position path - (0xF748/0xF619 → `SmartBox::HandleReceivedPosition` → `MoveOrTeleport` → - `InterpolationManager` chase) has **zero writes into `CMotionInterp`** — it - only *reads* `get_adjusted_max_speed()` (×2.0) as the chase-speed cap. The - wire's velocity field is a **dead parameter** in `MoveOrTeleport` - (pseudo-C:284304 — passed, never dereferenced). Animation changes come only - from motion events (0xF74C → `MovementManager::unpack_movement`). - acdream's pace→cycle inference layer has **no retail equivalent** (DEV-2) - and is an **unregistered deviation**. -3. **acdream's instant DR-velocity snap on walk↔run is retail-correct; the - pose hard-cut is the real deviation.** Adversarial verification refuted the - initial "retail paces velocity through the link clip" claim: in - `GetObjectSequence`'s link-transition branch, `add_motion(new cycle)` is the - LAST synchronous call and `CSequence::set_velocity` is a hard overwrite — - the sequence velocity snaps to the new cycle's value the same tick - (pseudo-C:298437-298468, 300798-300814; ACE `MotionTable.cs:144-168`, - `Sequence.cs:127-130`). What acdream's Fix B loses is only the link **pose** - (plus its authored root-motion frames): retail plays the authored - walk↔run link clip to completion before the new cycle's animation starts. -4. **Retail's remote pipeline is one atomic funnel; acdream's is three loosely - coupled writes.** That structural split (DEV-1) is what lets every gap - (DEV-2/3/5/6) desync pose from velocity with nothing to reconcile them. - -## The retail inbound model (what "verbatim" means here) - -Two decoupled inbound paths, meeting only at the per-tick frame combiner: - -**Motion events (0xF74C / 0xF619-embedded)** — -`ACSmartBox::DispatchSmartBoxEvent` (pseudo-C:357117) → -`CPhysics::SetObjectMovement` (271370, staleness-gated on `update_times[8]`) → -`MovementManager::unpack_movement` (300563, **10-way jump table**: case 0 = -InterpretedMotionState, 6/7 = MoveTo, 8/9 = TurnToObject/TurnToHeading) → -`CMotionInterp::move_to_interpreted_state` (305936: flat `copy_movement_from` -overwrite of `interpreted_state`, caches `my_run_rate` from RunForward's -forward_speed) → `apply_interpreted_movement` (305713: per-slot -`DoInterpretedMotion`/`StopInterpretedMotion` dispatch in retail order — style, -forward, sidestep-or-stop, turn-or-stop, idle-stop enqueue) → -`CMotionTable::GetObjectSequence` (298636), which decides: -- **same substate + same speed sign** → fast path: `change_cycle_speed` - (playback rate × newSpeed/oldSpeed) + `subtract_motion`/`combine_motion` - velocity swap — same node keeps playing, **no restart** (acdream's SCFAST - fast-path is the equivalent); -- **substate change (walk↔run!)** → link path: `get_link` (double-hop through - the style default when no direct link / sign flip), `clear_physics` + - `remove_cyclic_anims` (keeps a mid-playback link, drops the old loop), - `add_motion(link)` + `add_motion(cycle)`, `re_modify` re-applies active - modifiers. Velocity = the **cycle's** (last overwrite, instant); pose = link - first, then cycle. -Bookkeeping: `MotionTableManager::add_to_queue`/`remove_redundant_links`/ -`CheckForCompletedMotions` (290645-290854) + `CMotionInterp::pending_motions`/ -`MotionDone` (305032/305238) reconcile completed commands at anim boundaries. - -**Position events (0xF748/0xF619)** — `SmartBox::HandleReceivedPosition` -(92896): for remotes, `MoveOrTeleport` (284304) only — staleness-gated -(`POSITION_TS`/`TELEPORT_TS`), >96 units from player → plain snap, else -`InterpolationManager::InterpolateTo` node queue (cap 20). Per tick, -`adjust_offset` (353071) moves the full remaining distance capped at -`2 × get_adjusted_max_speed()` (fallback 7.5 m/s), stall detection (<30% -progress per 5 frames → fail counter >3 → hard snap). **acdream's -`InterpolationManager` is already a verbatim port of this (L.3).** - -**Per tick** (`UpdateObjectInternal` 283611): `CPartArray::Update` → -`CSequence::update` fills the frame delta from **the animation frames' baked -per-frame deltas** (anim-node path) or `velocity×dt` (`apply_physics` fallback); -`PositionManager::adjust_offset` **overwrites** it when a chase node is active -(last-writer-wins, NOT additive — matches acdream's `ComputeOffset` REPLACE -dichotomy); then the **full collision sweep** (`transition`) runs for remotes -too (acdream deliberately skips it for grounded player remotes — DEV-10). - -**Stop** — one path for every stop: `CMotionTable::StopSequenceMotion` (298954) -re-enters `GetObjectSequence` targeting the style default with force=1: -`clear_physics` zeroes velocity/omega **at transition start**, the idle-link's -authored deceleration plays, `add_to_queue(READY)` + `RemoveMotion` bookkeeping. -Modifier stop = `subtract_motion` only. No pace-derived stop inference. - -## Ranked deviation map - -All 10 deviations were adversarially verified through two lenses (refute vs -current acdream code / refute vs retail decomp+ACE). 9 CONFIRMED, 1 REFUTED -(DEV-4's velocity half — corrected below). Full verify evidence: -`…/tasks/waqdgyk0m.output` (session temp) — the load-bearing citations were -additionally re-read first-hand in this session. - -| # | Sev | Deviation | Symptom link | -|---|-----|-----------|--------------| -| DEV-1 | HIGH | Remote inbound bypasses the CMotionInterp funnel: bulk-copy + direct `SetCycle` instead of `move_to_interpreted_state`/`apply_interpreted_movement` | Structural root: pose + DR velocity are separate writes; every gap desyncs them with no reconciliation | -| DEV-2 | HIGH | Non-retail grace-window + hysteresis pace→cycle inference for player remotes (`UmGraceSeconds=0.2`, promote 5.5 / demote 4.5 m/s) | The direct "reacts too slowly": ≥0.2 s + UP cadence + hysteresis-crossing before the gait flips; backlog builds meanwhile. **Unregistered deviation**; premise refuted (headline 1) | -| DEV-3 | HIGH | Stop is three unwired mechanisms (UM-Ready overwrite / dead UP-velocity StopCompletely / landing); no `StopSequenceMotion` path; nothing zeroes body velocity or reconciles the queue | Direct cause of stop-slide: pose goes Ready while the body drains pre-stop waypoints at up to 2× maxSpeed | -| DEV-4 | MED (corrected) | Fix B drops the authored walk↔run link **pose** (hard visual cut + lost link root motion). ~~Velocity snap~~ — REFUTED: retail snaps velocity instantly too | Visual-only hard cut at each toggle; NOT a position-error source | -| DEV-5 | HIGH | Inbound movement types 8/9 (TurnToObject/TurnToHeading) dropped → misapplied as `SetCycle(Ready)` | Mid-locomotion turn command becomes a spurious full stop — error injected exactly during "compounding through running/turning" | -| DEV-6 | MED | Sequence-number staleness gate missing (movementSequence/serverControlSequence/isAutonomous parsed-then-discarded; retail gates on `update_times[8]`) | A reordered stale UM re-applies the old gait or un-stops a stop (extends register TS-26 to UM) | -| DEV-7 | MED | `pending_motions`/`pending_animations`/`MotionDone`/`remove_redundant_links` lifecycle absent | Enabler: missing `remove_redundant_links` is what made rapid toggles restart links forever, forcing Fix B; without MotionDone nothing clears per-transition bookkeeping | -| DEV-8 | MED | Remote DR velocity synthesized from hardcoded gait constants at SetCycle time vs retail's MotionData-accumulated CSequence velocity (+ baked per-frame anim deltas) | Any per-gait speed mismatch vs ACE's integration = constant-rate error that fills the interp backlog → discharges as slide/snap | -| DEV-9 | MED | Modifier layer missing: no `re_modify`, single-cycle selection, omega formula-seeded instead of combine/subtract | Turn contribution approximated independently of the motion table → heading-rate error while running+turning | -| DEV-10 | LOW | Bifurcated position-drive paths; grounded player remotes skip the per-tick collision sweep (retail sweeps every entity) | None for this symptom directly; parity + pass-through-geometry hazard | - -### Key anchors per deviation - -- **DEV-1** — retail: pseudo-C 305936 (`move_to_interpreted_state` 0x005289c0), - 305713 (`apply_interpreted_movement` 0x00528600), 298636 (`GetObjectSequence` - 0x00522860), 293301 (`copy_movement_from` 0x0051e750). acdream: - `GameWindow.cs:4590/4598` (bulk-copy), `:4769` (separate SetCycle), `:4517` - (comment-only funnel ref); D6.2 raw-input funnel is local-player-only - (`PlayerMovementController.cs:761-1014`). Nuance (verify correction): the - sidestep/turn axes DO route through `DoInterpretedMotion`/`StopInterpretedMotion` - (`GameWindow.cs:4794-4857`); the forward axis — the crux — is the bulk-copy. -- **DEV-2** — acdream: `GameWindow.cs:5095/5104/5110` (constants), - `:5112-5300` (both methods), `:5128-5134` (the unconfirmed-premise comment). - Retail: no equivalent exists (searched: InterpolationManager/PositionManager - ranges 352000-353400 — zero `CMotionInterp` writes; `MoveOrTeleport` velocity - param dead at 284304). -- **DEV-3** — retail: 298954 (`StopSequenceMotion` 0x00522fc0), 305635 - (`StopInterpretedMotion` 0x00528470), 301194 (`clear_physics` 0x00524d50). - acdream: `GameWindow.cs:4364-4367` (UM-Ready), `:5711-5731` (dead UP-velocity - stop — ACE player UPs carry velocity=null per `:5700-5710`), `:5527-5553` - (landing = only `Interp.Clear` site); plus the acdream-invented 300 ms - stop-detection window (`GameWindow.cs:4862-4890` + TickAnimations) — also - retail-less, part of this cluster. -- **DEV-4** — retail: 298636 link branch; 298552 (`get_link`); 301777 - (`append_animation`); velocity-overwrite refutation: 298437-298468 - (`add_motion` 0x005224b0) + 300798-300814 (`set_velocity`/`set_omega` - overwrites), ACE `MotionTable.cs:144-168` + `Sequence.cs:127-130/221-230`. - acdream: `AnimationSequencer.cs:585-635` (Fix B), `:686-754` (velocity - synthesis — retail-equivalent in timing). -- **DEV-5** — retail: 300563/300707 (jump table cases 8/9 → - `MoveToManager::TurnToObject/TurnToHeading`). acdream: - `UpdateMotion.cs:136-238` (no case 8/9), `GameWindow.cs:4364-4367` - (null-command → Ready misfire). Confirmed live earlier: `mt=0x09` arrives, - acdream plays Ready (2026-06-26 audit D9). -- **DEV-6** — retail: 271370 (`SetObjectMovement` staleness gate), 357224 - (`update_times[8]` compare). acdream: `UpdateMotion.cs:89-106`, - `UpdatePosition.cs:29-31/156` (parsed-then-discarded). -- **DEV-7** — retail: 305238 (`MotionDone` 0x00527ec0), 290854 - (`MotionTableManager::add_to_queue` 0x0051bfe0), 290771 - (`remove_redundant_links` 0x0051bf20), 290645 (`CheckForCompletedMotions`). - acdream: `AnimationSequencer.cs:401` (always-synchronous SetCycle), - `:1128-1129` (`AnimationDoneSentinel` exists, unconsumed by any motion - bookkeeping). -- **DEV-8** — retail: 298437-298492 (`add_motion`/`combine_motion`/ - `subtract_motion`), 302402 (`CSequence::update` — anim-frame deltas are the - primary drive), 305160 (`get_state_velocity` constants 3.12/4.0 — the - *logical* values acdream synthesizes). acdream: `AnimationSequencer.cs:686-754`. -- **DEV-9** — retail: 298636 (`re_modify` at rebuild tail), 298954 (modifier - `subtract_motion` branch), 305713 (per-slot dispatch). acdream: - `GameWindow.cs:4648-4683` (single-cycle pick), `:4841-4847` (ObservedOmega - formula seed), `:10097-10106` (manual omega integration). -- **DEV-10** — retail: 280817/283611 (sweep for all entities). acdream: - `GameWindow.cs:9717-9722` (fork), `:9883-9890` (sweep skipped, player-remote - path), `:10156-10181` (sweep present, NPC path), `:5657` (NPC UP hard-snap). - -## Recommended port campaign (for approval — no code written yet) - -Ordered so each slice is independently landable, tests-first, minimal-surgery -(no sequencer rewrite; `SetCycle` internals stay). Every slice updates -`docs/architecture/retail-divergence-register.md` in the same commit. - -- **S0 — the wire probe (no code).** Live capture, acdream as observer, - actor driven from retail: `ACDREAM_DUMP_MOTION=1 ACDREAM_REMOTE_VEL_DIAG=1`, - structured protocol with the walk↔run Shift toggle as the centerpiece; - optionally WireMCP on loopback :9000 for the raw S→C bytes. Expected per the - oracles: a UM with ForwardCommand flipping Walk↔Run on every toggle - (`[UM_RAW]` is the arbiter — the 2026-05-06 test read `[FWD_WIRE]`, which - only fires on interpreted-command *change* after acdream's own resolution). - Outcome decides DEV-2's disposition. *(Needs the user driving the retail - client — this is the one stop-and-wait step.)* -- **S1 — DEV-6 staleness gate.** ~30 isolated lines: capture the two u16s into - `UpdateMotion.Parsed`, retail wraparound-newer compare per guid, drop stale. - Unit-testable against the 0x7fff wraparound rule. Safe first commit. -- **S2 — DEV-1 funnel (the centerpiece), absorbing DEV-3 + DEV-9 dispatch.** - Port `move_to_interpreted_state` + `apply_interpreted_movement` into - `MotionInterpreter`, with `DoInterpretedMotion` routing to the existing - `AnimationSequencer.SetCycle` as the `GetObjectSequence` backend. - `OnLiveMotionUpdated`'s remote SubState branch collapses to one - `move_to_interpreted_state(ims)` call; stop flows through - `StopInterpretedMotion` (no `Interp.Clear()` band-aid — the queue's final - waypoints ARE the true stop position); sidestep/turn become per-slot retail - dispatch. Parity test first: replay a captured UM stream through old + new - paths, assert identical SetCycle sequence + InterpretedState trace; then a - stop-slide acceptance case. -- **S3 — DEV-7 bookkeeping.** `MotionTableManager` companion (pending_animations, - `add_to_queue`, `remove_redundant_links`, `CheckForCompletedMotions`) wiring - the existing `AnimationDoneSentinel`; `pending_motions`/`MotionDone` in - `MotionInterpreter` second. Additive, decomp-semantics unit tests first. -- **S4 — DEV-4 corrected: retire Fix B.** Restore the link-pose enqueue for - locomotion↔locomotion now that S3's `remove_redundant_links` removes Fix B's - original justification (link-restart spam). Velocity stays instant - (retail-correct). Acceptance: user visual side-by-side. -- **S5 — DEV-2 disposition per S0.** If UMs arrive: delete the refinement layer - (dead weight + latency + feedback loop) and let the S2 funnel drive. If the - wire is genuinely silent: keep a minimal layer as an explicitly registered - ACE-adaptation row (a retail client on the same wire would show the stale - gait too — matching retail display means NOT inferring), and file the gap - against ACE. Either way DEV-2's machinery stops being an unregistered - deviation. -- **S6 — remainder.** DEV-5 (parse types 8/9 → heading target; cross-check field - order vs Chorizite + holtburger first), DEV-8 (evidence first: dump Humanoid - MotionTable Walk/Run `MotionData.Velocity`+Flags from the dat, diff synthesized - magnitudes vs ACE's integration), DEV-10 deferred (fold-in after S2; NPC - hard-snap replacement needs explicit user approval per the reverted-campaign - precedent). - -## What we've ruled out - -- "acdream lacks any transition machinery" — false; `SetCycle` resolves authored - links (`GetLink`) and has the retail same-substate fast path (SCFAST ≈ - `change_cycle_speed`). -- "Retail paces the walk↔run *velocity* change through the link clip" — refuted - (headline 3); instant velocity snap is retail-correct. -- "Retail refines the remote's cycle from UP-derived velocity" (#39's premise) - — refuted; no such mechanism exists in the decomp (two independent dives + - ACE cross-check). -- The `InterpolationManager` chase itself — already a verbatim port (L.3); - constants re-verified this session (2× adjusted max speed, 0.05 desired - distance, 30%/5-frame stall, fail>3 blip, 20-node cap, 96-unit gate upstream). - -## What this is NOT - -This is NOT a "port the whole CSequence/CPartArray stack" campaign — the -sequencer's node/link/queue mechanics are close enough that the funnel (DEV-1), -bookkeeping (DEV-7), and stop path (DEV-3) can be ported around it surgically; -and it is NOT a dead-reckoning-tuning problem — the chase is already retail, -it's the inputs (cycle + velocity source + stop signal) that deviate. - -## S0 wire-probe RESULTS (2026-07-02, post-S1 live capture) - -Observer acdream (+Acdream), actor retail-driven (guid 0x5000000B), structured -protocol incl. Shift toggles. 280 UMs captured (`launch-s0-wireprobe.log`). - -1. **The wire is NOT silent — DEV-2's premise is dead.** Walk↔run toggles - arrive explicitly: `fwd=0x0005 fwdSpd=null(=1.0)` ↔ `fwd=0x0007 - fwdSpd=2.85`. Root cause of the 2026-05-06 misreading: retail's - default-difference packing baselines `forward_command` against **Ready** - (`RawMotionState.Default`, our own D6.2b verbatim port) — W-held is - non-default and always packed, so ACE (`MovementData.cs:104-119`) always - re-emits it with the holdKey upgrade + run-skill speed. -2. **flags=0 "empty" autonomous UMs are frequent (127/209) and LEGITIMATE** - — they are genuine keys-released / heading-only MoveToState relays - (all-default raw state packs nothing; ACE `RawMotionState.Read` leaves - absent = Invalid; `MovementData` emits nothing). Retail semantics - (verified: `InterpretedMotionState` ctor 0x0051e8d0 defaults - `forward_command=Ready`; `MovementManager::unpack_movement` case 0 - UnPacks into a default-constructed state; - `CMotionInterp::move_to_interpreted_state` 0x005289c0 does a FLAT - `copy_movement_from` + `apply_current_movement`): an empty UM = a real - full stop. acdream already maps it to Ready — but the DEV-2 refinement - then re-promotes the cycle from UP pace after the 0.2 s grace, producing - a Ready↔Run thrash against legitimate stop signals. That thrash is the - observed flicker/rubber-band component. -3. **S1 gate validated live**: 0 `[UM_STALE]` drops across 280 UMs; - movementSeq advances +1 per UM; byte-level layout confirmed - (`instanceSeq|movementSeq|serverControlSeq|isAutonomous` exactly where - the parser reads them). -4. Also confirmed in `unpack_movement` (0x00524440) for the S2 port: the - outer style u16 routes through `command_ids[]` and applies via - `DoMotion` ONLY when it differs from the current style; motionFlags - carries sticky-object (0x100 → `stick_to_object`) and standing_longjump - (0x200); the actions list is consumed under a 15-bit - `server_action_stamp` wraparound compare - (`move_to_interpreted_state` 0x005289c0, lines 305953-305989). - -**Disposition: S5 = delete the player pace-inference layer** (executed in the -same session; NPC `PlanFromVelocity` path untouched — its unification is S6). - -## Provenance - -- Workflow `wf_b92a5670-283`: 4 read-only mappers (retail decomp / ACE port / - acdream code / prior-research claims — full reports preserved in - `2026-07-02-inbound-motion-maps/`) → synthesis → 2-lens adversarial verify - per deviation (25 agents, ~3.0 M tokens). The ACE-port mapper crashed on - structured output AFTER writing its report file; synthesis read all four - files. One verify lens (DEV-4 retail side) delivered the REFUTED verdict - incorporated above. -- Targeted agents: retail remote-anim adaptation dive (+2 children: - InterpolationManager decomp dump, ACE InterpolationManager cross-check). -- First-hand session reads: `OnLiveMotionUpdated` (GameWindow 4230-4930), - DR tick path (9700-9920), `AnimationSequencer.SetCycle` (340-790), - `ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement` - (5077-5300), `ServerControlledLocomotion`, `PositionManager`, - `InterpolationManager`, `UpdatePosition` parser, ACE `Player_Tick.cs` / - `Player_Networking.cs` / `GameActionMoveToState.cs`, decomp - `SendMovementEvent`/HoldRun sites (699274-700312), 2026-06-04 deep-dive - divergence list, ISSUES #39, divergence register scan. -- Corrections applied to the raw workflow output: DEV-1 anchor fix - (`MotionInterpreter.cs:399/412/558` reference different retail functions — - the only funnel comment-refs are `GameWindow.cs:4517` + - `PlayerMovementController.cs:593`); DEV-4 rewritten per its refutation. diff --git a/docs/research/2026-07-02-inbound-motion-maps/map-acdream-inbound.md b/docs/research/2026-07-02-inbound-motion-maps/map-acdream-inbound.md deleted file mode 100644 index 9c0b36f8..00000000 --- a/docs/research/2026-07-02-inbound-motion-maps/map-acdream-inbound.md +++ /dev/null @@ -1,208 +0,0 @@ -# acdream CURRENT inbound remote-entity motion/animation pipeline — as-is map - -Repo: `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad` -Scope: REPORT-ONLY. Describes what the code does TODAY, not what it should do. - ---- - -## 0. File inventory (all line numbers verified against current worktree) - -- `src/AcDream.Core.Net/Messages/UpdateMotion.cs` (333 lines) — inbound 0xF74C parser. -- `src/AcDream.Core.Net/Messages/UpdatePosition.cs` (177 lines) — inbound 0xF748 parser. -- `src/AcDream.Core.Net/Messages/MoveToState.cs` (108 lines) — **OUTBOUND ONLY** (0xF61C, client→server). Not part of the inbound path; included by the task's starting-point list but has no inbound role. -- `src/AcDream.App/Rendering/GameWindow.cs` (13,759 lines) — `OnLiveMotionUpdated` (4230–4967), `OnLivePositionUpdated` (5334–~5773), `ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement` (5112–5333), `TickAnimations` (9673–~10267+), `RemoteMotion` class (401–~592). -- `src/AcDream.Core/Physics/AnimationSequencer.cs` (1583 lines) — `SetCycle` (401–788), `Advance` (862–957), helpers. -- `src/AcDream.Core/Physics/MotionInterpreter.cs` (1255 lines) — `DoInterpretedMotion`/`StopInterpretedMotion`/`StopCompletely`/`get_state_velocity`/`adjust_motion`/`apply_raw_movement`/`apply_current_movement`/`GetMaxSpeed`. -- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340 lines) — per-tick steering for MoveToObject/MoveToPosition (movementType 6/7) remotes. -- `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines) — velocity→cycle classifier for non-player remotes (`PlanFromVelocity`) and MoveTo-start seed (`PlanMoveToStart`). -- `src/AcDream.Core/Physics/InterpolationManager.cs` (389 lines) — retail `CPhysicsObj` position-waypoint queue port (FIFO cap 20, catch-up, stall detection, blip-to-tail). -- `src/AcDream.Core/Physics/PositionManager.cs` (108 lines) — per-frame REPLACE combiner (queue-catchup vs anim-root-motion), used only for grounded, non-airborne PLAYER remotes. - -**No `pending_motions` / `MotionDone` lifecycle exists anywhere in the codebase.** `grep -rn "pending_motion|MotionDone|PendingMotion" src/` returns zero matches. Confirmed as a genuine gap (matches the animation-sequencer-deep-dive research doc's "missing pending_motions HIGH" finding). - ---- - -## Q1 — INBOUND ENTRY: wire → motion interpreter, for a REMOTE object - -1. **Wire parse.** `AcDream.Core.Net.Messages.UpdateMotion.TryParse` (`UpdateMotion.cs:70-253`) decodes opcode `0xF74C` into `Parsed(uint Guid, CreateObject.ServerMotionState MotionState)`. Extracts: `Guid`, `currentStyle` (Stance), and — when `movementType == 0` (InterpretedMotionState) — optional `ForwardCommand`/`ForwardSpeed`/`SideStepCommand`/`SideStepSpeed`/`TurnCommand`/`TurnSpeed` plus a `Commands` list (actions/emotes), gated by a 7-bit flags dword (`UpdateMotion.cs:145-226`). When `movementType is 6 or 7` (MoveToObject/MoveToPosition), it instead parses a `MoveToPathData` payload via `TryParseMoveToPayload` (`UpdateMotion.cs:255-331`): target guid (type 6 only), Origin cell+xyz, `MovementParameters` dword, distance/min/fail distances, speed, walkRunThreshold, desiredHeading, runRate. - - **Fields parsed then discarded / not used for pose**: `instanceSequence` (u16, `UpdateMotion.cs:85-87`, "tracked but not used for pose" — actually not even stored, just skipped), `movementSequence`+`serverControlSequence` (u16+u16 inside the 6-byte MovementData header, `UpdateMotion.cs:89-106`, skipped wholesale via `pos += 6`), `isAutonomous` (u8, same 6-byte skip — never separately read), `_motionFlags` (u8, read into a local but only used for the diagnostic dump at `UpdateMotion.cs:111,115-122`, otherwise discarded), `distanceToObject`/`failDistance`/`walkRunThreshold`/`desiredHeading` inside `TryParseMoveToPayload` (parsed at `UpdateMotion.cs:305-306,309,313,315` into locals, never placed on `MoveToPathData` — only `distanceToObject`(as `DistanceToObject`), `minDistance`, `failDistance`(discarded — not passed to the record), `walkRunThreshold` and `desiredHeading` ARE passed to `MoveToPathData` ctor at `UpdateMotion.cs:319-329` but `MoveToPathData` doesn't expose `FailDistance` in the earlier local list — check record fields separately if exact retention matters). - - Similarly, `UpdatePosition.TryParse` (`UpdatePosition.cs:77-175`) decodes 0xF748 into guid, `ServerPosition` (cellId+xyz+quat), optional `Velocity`, optional `PlacementId`, `IsGrounded` flag, and three of four trailing u16 sequence numbers (`instSeq`, `teleSeq`, `forceSeq` — the second one, "positionSequence", is explicitly skipped: `UpdatePosition.cs:156` comment "not tracked by movement"). None of `instSeq`/`teleSeq`/`forceSeq` are consulted anywhere downstream for freshness/ordering (comment at `UpdatePosition.cs:29-31`: "We don't currently check these for freshness but we must consume them to walk the buffer correctly"). - -2. **Session dispatch → GameWindow event.** (Not directly inspected this pass, but referenced via `_liveSession.MotionUpdated += OnLiveMotionUpdated;` at `GameWindow.cs:2633`.) The parsed `UpdateMotion.Parsed` becomes an `AcDream.Core.Net.WorldSession.EntityMotionUpdate` and fires the `MotionUpdated` event. - -3. **`GameWindow.OnLiveMotionUpdated`** (`GameWindow.cs:4230`) is the sole consumer. - - Early-outs: `_dats is null` (4232), entity not found by `update.Guid` in `_entitiesByServerGuid` (4233), or no `AnimatedEntity` for it in `_animatedEntities` (4234). - - Stamps `rmStateForUm.LastUMTime` on the `_remoteDeadReckon[update.Guid]` entry if present (4243-4247) — used later to gate the velocity-refinement grace window (Q7 / `UmGraceSeconds`). - - Reads `stance = update.MotionState.Stance` and `command = update.MotionState.ForwardCommand` (nullable) (4259-4260). - - **Sequencer path** (the only path exercised in practice — `ae.Sequencer is not null` at 4327) reconstructs a full 32-bit `fullMotion` command from the wire's 16-bit `ForwardCommand` via `MotionCommandResolver.ReconstructFullCommand` (4372-4378), OR seeds a MoveTo-derived motion via `ServerControlledLocomotion.PlanMoveToStart` when `IsServerControlledMoveTo` and no forward command (4356-4363), OR falls back to `0x41000003` (Ready) when the command is null/0 and not a MoveTo (4364-4367 — "retail stop signal"). - - **Branches on `update.Guid == _playerServerGuid`**: the LOCAL player's own echoed UM is NOT applied to the sequencer (comment 4401-4416: "UpdatePlayerAnimation is the authoritative driver ... skip the wire-echo SetCycle"). Everything from here on (Q2-Q7) describes the REMOTE (`else` branch at 4493) path. - - For a genuine remote: bulk-copies `fullMotion`/`speedMod` into `remoteMot.Motion.InterpretedState.ForwardCommand`/`ForwardSpeed` UNCONDITIONALLY (4572-4598) — this is the direct feed into `MotionInterpreter.get_state_velocity()` (Q5). Then classifies via `AnimationCommandRouter.Classify(fullMotion)` into `forwardIsOverlay` (Action/Modifier/ChatEmote — routed via `RouteFullCommand`, an overlay call, 4626-4636) vs SubState (the normal walk/run/turn/ready path, 4637 `else` block — this is Q2/Q3/Q4's territory). - ---- - -## Q2 — TRANSITION: walk↔run while already moving (no stop) - -Frame-by-frame, for the SubState (non-overlay) branch at `GameWindow.cs:4637-4859`: - -1. **Cycle selection** (4648-4683): `animCycle = fullMotion`, `animSpeed = speedMod` by default. If the forward command is NOT run/walk/walkback (`fwdIsRunWalk` false), fall back to sidestep or turn cycle. **For a plain walk↔run toggle, `fullMotion` IS run/walk, so this stays the forward cycle** — no fallback triggered. -2. **Airborne guard** (4696): if `remoteIsAirborne`, the cycle swap is SKIPPED entirely (Falling cycle preserved) — not relevant to a grounded walk↔run toggle. -3. **Cycle-existence fallback chain** (4728-4757): if the MotionTable lacks `(fullStyle, cycleToPlay)`, falls back RunForward→WalkForward→Ready→(no-op, leave sequencer alone). Only matters if the creature's table is missing the target cycle; for the player Humanoid table both Walk and Run exist. -4. **`ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed)`** is called (4769) — this is the entry into `AnimationSequencer.SetCycle` (`AnimationSequencer.cs:401`). - -### Inside `SetCycle` (AnimationSequencer.cs:401-788) — what actually happens on Walk→Run or Run→Walk: - -- `adjust_motion` remap (407-423): TurnLeft/SideStepLeft/WalkBackward get mirrored to their positive counterpart with negated speed. RunForward(0x07)/WalkForward(0x05) pass through unchanged (`adjustedMotion = motion`). -- **Fast-path check** (440-468): fires only if `CurrentStyle == style && CurrentMotion == motion` (same exact motion command) AND sign of speedMod matches. **On an actual Walk→Run toggle, `CurrentMotion` (0x45000005 WalkForward) != `motion` (0x44000007 RunForward), so the fast path does NOT fire** — this is a FULL REBUILD, not a framerate-only rescale. (The fast path only fires when the SAME command re-arrives with a different speed, e.g. a run-rate echo while already running.) -- **Full-rebuild path** (470-788): - - `GetLink(style, CurrentMotion=WalkForward, CurrentSpeedMod, adjustedMotion=RunForward, adjustedSpeed)` looks up a transition-link MotionData from the MotionTable's `Links` dict (470-480, method at 1159-1203). Both speeds positive here, so it uses the forward-direction lookup: `Links[(style<<16)|WalkForward][RunForward]`. - - `ClearCyclicTail()` (511) — removes the OLD cycle's looping node(s) from the queue; non-cyclic (link) frames not yet played are left alone. - - `ClearPhysics()` (529) — zeroes `CurrentVelocity`/`CurrentOmega` before rebuild. - - Enqueues the link's MotionData (non-looping) THEN the new cycle's MotionData (looping) via `EnqueueMotionData` (547-554). - - **Direct cyclic→cyclic transition special-case** (585-635, "Fix B" 2026-05-06): if BOTH the previous motion (WalkForward) AND the new motion (RunForward) are recognized locomotion low-bytes (`IsLocomotionCycleLowByte`, checks against 0x05/0x06/0x07/0x0F/0x10 — `AnimationSequencer.cs:1509-1513`), the code DROPS the just-enqueued transition link from the queue entirely (629-632) and forces `_currNode = _firstCyclic` directly onto the new RunForward cycle (633-634), skipping the link animation and its frame-position start entirely. This is explicitly a divergence from the general link-transition mechanism, justified by a live cdb trace (609-618 comment) showing retail's `add_to_queue` sequence for a Walk→Run direct transition never fires `truncate_animation_list` and just appends the new cycle directly — i.e., **acdream's current behavior for walk↔run is: NO visible link/blend animation, instant hard-cut cycle swap to the new cycle's start frame** (`_framePosition = _firstCyclic.Value.GetStartFramePosition()`, 634). - - If NOT both locomotion (e.g. Ready→WalkForward), the link IS played (`_currNode = firstNew` at 636-640) — that's the smooth-transition path used elsewhere, just not for walk↔run. - - `CurrentStyle`/`CurrentMotion`/`CurrentSpeedMod` updated (682-684). - - **Velocity synthesis** (686-754): because retail's Humanoid MotionData ships `Flags=0x00` (no `HasVelocity`) for locomotion cycles, `CurrentVelocity` is unconditionally re-synthesized here from hardcoded constants (`WalkAnimSpeed=3.12f`, `RunAnimSpeed=4.0f`, `SidestepAnimSpeed=1.25f` — 793-795) times `adjustedSpeed`, based on the NEW motion's low byte. **This happens regardless of whether the link or the cycle is what's currently playing** — i.e. `CurrentVelocity` snaps to the new cycle's steady-state speed the instant `SetCycle` returns, even in the general (non-locomotion-direct) case where a link animation is still visually playing. - - Omega synthesis (756-787) similarly, only for turn commands (not relevant to walk/run). - -### Frame-by-frame summary for Walk→Run toggle while moving: -- **Frame N (UM arrives)**: `OnLiveMotionUpdated` fires → `SetCycle(style, RunForward, speedMod)` called synchronously within the same event handler call (not queued/deferred) → full rebuild → cyclic→cyclic special case detected → link dropped, `_currNode` snapped directly to Run cycle's start frame, `CurrentVelocity` synthesized to `RunAnimSpeed × speedMod` — ALL of this happens on the SAME UM-processing call, before the next `Advance()`/tick. -- **Frame N (same tick, later in loop)**: `TickAnimations` (`GameWindow.cs:9673`) runs later that frame (or next frame depending on event/tick ordering) and calls `Advance(dt)` on the sequencer, which now plays from the Run cycle's first frame — no partial-frame blend from the old Walk pose. -- **Is there blending?** NO cross-fade/blend for the walk↔run case specifically — only the (skipped) link animation would have provided a transitional pose, and it's explicitly dropped for this case. -- **Is the new motion appended/queued, or does it replace?** It REPLACES: `ClearCyclicTail()` always removes the old cycle before the new one is enqueued (511), and for the walk↔run case the link itself is also stripped from the queue (629-632) so only the new cycle remains. -- **Speed**: `CurrentVelocity`/animation framerate both change immediately and atomically inside `SetCycle` — no interpolation of speed either (the fast-path's `MultiplyCyclicFramerate` smooth-rescale only applies when `CurrentMotion` is unchanged, i.e. NOT for an actual Walk↔Run motion-command swap, only for a same-command speed update). - ---- - -## Q3 — PENDING/DONE (pending_motions + MotionDone lifecycle) - -**NONE.** There is no `pending_motions` queue and no `MotionDone` callback/event anywhere in the codebase (confirmed via `grep -rn "pending_motion|MotionDone|PendingMotion" src/` — zero hits). - -What DOES exist that's adjacent: -- `AnimationSequencer._queue` (a `LinkedList`, `AnimationSequencer.cs:297`) holds link-then-cycle nodes, but it is not a "pending motion command" queue in the retail sense — it's purely an animation-frame playback queue (link frames + one looping tail node), rebuilt wholesale on every `SetCycle` call. There's no notion of a QUEUED but not-yet-active MOTION COMMAND (e.g. "play attack after current swing finishes") — `SetCycle` always either fast-paths (same command) or fully rebuilds (different command) synchronously the instant the wire event arrives. -- `AnimationSequencer.ConsumePendingHooks()` (964-972) exists and drains `_pendingHooks`, a `List` accumulated during `Advance()` when integer frame boundaries are crossed (`Advance`, 862-957, calls `ExecuteHooks` at 913/939). This is a HOOK dispatch mechanism (audio/VFX/damage triggers baked into animation frames), NOT a motion-command completion signal. -- `AnimationDoneSentinel` (`AnimationSequencer.cs:1128-1129`, a static `AnimationHookDir.Both` hook) is added to `_pendingHooks` when a non-cyclic (link) node drains naturally and the sequence is about to wrap (948-951, inside `Advance`). This is the closest thing to a "link finished" signal, but it's a generic hook fired into the SAME `_pendingHooks` list as animation-authored hooks (footsteps, damage triggers) — there's no separate consumer that reacts specifically to "this MOTION COMMAND completed" the way retail's `pending_motions`/`MotionDone` state machine would (e.g. to know when to pop the next queued attack, or to notify the wire layer that a swing animation finished so the next command can be sent). -- **Consequence for callers**: nobody appends anything to a "next motion" queue and nobody pops one on completion. Every UM (or per-tick refinement call) calls `SetCycle`/`DoInterpretedMotion` directly and unconditionally; the ONLY "which motion wins" arbitration is (a) the SubState-vs-Overlay classification in `OnLiveMotionUpdated` (4626 vs 4637) and (b) `SetCycle`'s own state (`CurrentMotion`/`CurrentStyle` comparison) — there's no notion of "motion command A is still pending completion, defer motion command B." - ---- - -## Q4 — CYCLE SWAP mechanics: does frame index carry over? - -Governed entirely by `AnimationSequencer.SetCycle` (see Q2 for the walk→run specific path) and `AdvanceToNextAnimation` (`AnimationSequencer.cs:1344-1364`): - -- **General case (non-locomotion-direct, e.g. Ready→WalkForward)**: `_currNode` is set to the FIRST NEWLY-ENQUEUED node (`firstNew`, 583, 636-640) — i.e. the link animation, if one exists, plays from ITS `GetStartFramePosition()` (583+639: `_framePosition = _currNode.Value.GetStartFramePosition()`), which for positive framerate is `StartFrame` (frame 0 of the link) — **frame index does NOT carry over from the old cycle; it restarts at the link's (or cycle's, if no link) start frame.** When the link naturally exhausts during subsequent `Advance()` calls, `AdvanceToNextAnimation()` (1344) walks to `_currNode.Next`, or if null, wraps to `_firstCyclic` — again restarting `_framePosition` at `GetStartFramePosition()` of that new node (1362-1363). -- **Locomotion-direct case (Walk↔Run and similar, per the "Fix B" special-case, `AnimationSequencer.cs:619-635`)**: `_currNode` is forced DIRECTLY onto `_firstCyclic` (the new cycle's looping node), with `_framePosition = _firstCyclic.Value.GetStartFramePosition()` (634) — i.e. **frame index is reset to 0 (or `EndFrame` for negative-speed cycles) of the NEW cycle, NOT carried over from the OLD cycle's current frame position.** There is no "keep the current phase, just swap animation data" — every `SetCycle` transition, link or no link, hard-resets `_framePosition` to a start boundary of whichever node becomes current. -- **No "link animation between the two loops" mechanism for locomotion-direct swaps** — the whole point of the "Fix B" branch is to SKIP the link (i.e. skip the transitional pose entirely) for cyclic→cyclic transitions, per the cdb-trace-derived retail-matching behavior documented in the comment (609-618). -- Net: cycle swap between two looping cycles is an **instant hard-cut with no phase continuity and (for locomotion-direct transitions) no transitional/link animation.** This differs from a "true" retail per-limb blend if such a thing exists in the more complete engine — acdream's sequencer is a single active `AnimNode` playback engine, not a blend-tree. - ---- - -## Q5 — POSITION DRIVE between inbound position packets - -Two structurally DIFFERENT paths exist, selected in `TickAnimations` (`GameWindow.cs:9717-9722`) by: -```csharp -if (ae.Sequencer is not null && serverGuid != 0 && serverGuid != _playerServerGuid - && _remoteDeadReckon.TryGetValue(serverGuid, out var rm)) -{ - if (IsPlayerGuid(serverGuid) && !rm.Airborne) // ← Path A: grounded PLAYER remotes - else // ← Path B: NPCs + airborne player remotes (legacy) -} -``` - -### Path A — grounded player remotes (`GameWindow.cs:9722-9920`) -Uses `PositionManager.ComputeOffset` (`PositionManager.cs:51-107`), which is explicitly a **REPLACE, not additive** combiner: -1. `InterpolationManager.AdjustOffset(dt, currentBodyPosition, maxSpeed)` (called at `PositionManager.cs:84`) is tried FIRST. - - If the queue has waypoints AND the body is farther than `DesiredDistance` (0.05 m, `InterpolationManager.cs:79`) from the head waypoint, it returns a non-zero "catch-up" delta: `direction × min(catchUpSpeed × dt, dist)`, where `catchUpSpeed = maxSpeedFromMinterp × MaxInterpolatedVelocityMod (2.0)` or a `MaxInterpolatedVelocity` (7.5 m/s) fallback (`InterpolationManager.cs:262-362`). **This REPLACES animation root motion entirely for that frame** (`PositionManager.cs:84-86`: `if (correction.LengthSquared() > 0f) return correction;`). - - If the queue is empty OR the head has been reached (dist ≤ 0.05m, which pops the head via `NodeCompleted`), `AdjustOffset` returns `Vector3.Zero`, and `PositionManager.ComputeOffset` falls through to animation root motion: `seqVel × dt`, rotated by body orientation, optionally slope-projected (`PositionManager.cs:88-106`). -2. `seqVel` is `ae.Sequencer.CurrentVelocity` (body-local, from the sequencer synthesis in Q2/Q4) — this is the ANIMATION-DERIVED velocity, NOT `MotionInterpreter.get_state_velocity()`. -3. `omegaToApply` is `rm.ObservedOmega` (seeded formulaically from wire TurnCommand+TurnSpeed in `OnLiveMotionUpdated`, `GameWindow.cs:4841-4847`) preferred over `seqOmega` (`GameWindow.cs:9842-9845`). -4. `rm.Body.calc_acceleration()` + `rm.Body.UpdatePhysicsInternal(dt)` still run (9878, 9881) for gravity/vertical integration, but `ResolveWithTransition` (collision sweep) is INTENTIONALLY SKIPPED for this path (comment 9883-9890: "the server has already collision-resolved the broadcast position"). -5. **Dominance**: for grounded player remotes, the InterpolationManager queue DOMINATES whenever it's non-empty and the body hasn't caught up; the sequencer's `CurrentVelocity` (animation root motion) only drives position when the queue is idle/caught-up. - -### Path B — NPCs and airborne player remotes (legacy path, `GameWindow.cs:9933-10267+`) -This is the ORIGINAL / "unchanged until Task 8 cleanup" path (comment 9923): -1. `rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false)` (10074, the general branch) — calls `MotionInterpreter.get_state_velocity()` (`MotionInterpreter.cs:639-688`), which reads `InterpretedState.ForwardCommand`/`ForwardSpeed` (bulk-copied from the wire in `OnLiveMotionUpdated`, Q1/Q2) and returns `WalkAnimSpeed/RunAnimSpeed × ForwardSpeed` UNLESS the sequencer's `CurrentVelocity.Y` is non-zero (`GetCycleVelocity` accessor, wired — see below), in which case it prefers that (`MotionInterpreter.cs:653-668`, "Option B — MotionData-sourced forward velocity"). Result is written directly to `PhysicsBody.set_local_velocity` via `apply_current_movement` (`MotionInterpreter.cs:905-909`, gated on `PhysicsObj.OnWalkable`). -2. Alternative sub-branches at this same call site (`GameWindow.cs:9963-10076`) select DIFFERENT velocity sources depending on state: - - If `rm.HasServerVelocity` and NOT a player guid (9968): uses `rm.ServerVelocity` (synthesized from consecutive UP position deltas, or wire `Velocity` field when present) directly as `rm.Body.Velocity` — BYPASSING `apply_current_movement`/`get_state_velocity` entirely — UNLESS the velocity is stale (`velocityAge > ServerControlledVelocityStaleSeconds = 0.60s`, 926), in which case it's zeroed and `ApplyServerControlledVelocityCycle` is invoked to reset the cycle to Ready. - - If `rm.ServerMoveToActive && rm.HasMoveToDestination` (9987): uses `RemoteMoveToDriver.Drive` to steer orientation, then `apply_current_movement` for velocity magnitude (10042), clamped via `RemoteMoveToDriver.ClampApproachVelocity` (10052-10059) to avoid overshoot. - - Else (general fallback, 10074): `apply_current_movement` as described in step 1. -3. Manual per-tick rotation integration (`GameWindow.cs:10097-10106`) using `rm.ObservedOmega`. -4. `rm.Body.UpdatePhysicsInternal(dt)` (10128) — Euler integration. -5. **Collision sweep IS run for Path B** (`_physicsEngine.ResolveWithTransition`, 10156-10181) — unlike Path A, NPCs/airborne players DO get a client-side sweep between server updates (with a fixed sphere radius/height, step-up/down heights). - -### Which dominates? -- Path A (player remotes, grounded): InterpolationManager queue dominates when active; sequencer's synthesized `CurrentVelocity` (animation root motion) is the fallback when the queue is idle. -- Path B (NPCs / airborne players): `MotionInterpreter.get_state_velocity()` (fed by `InterpretedState.ForwardCommand/ForwardSpeed` set from the wire) is the PRIMARY driver for NPCs without explicit server velocity; when the sequencer's `CurrentVelocity.Y` is non-zero, `get_state_velocity` PREFERS it over the hardcoded WalkAnimSpeed/RunAnimSpeed constants (`MotionInterpreter.cs:646-668` "Option B"). Wire-synthesized `ServerVelocity` from position deltas takes priority over both when present and fresh. -- **Neither `apply_raw_movement` nor the raw `adjust_motion`(ref motion, ref speed, holdKey) entry point is called anywhere in the inbound remote path** (confirmed via `grep -rn "apply_raw_movement|get_state_velocity()"` across `src/` outside `MotionInterpreter.cs` — only `src/AcDream.App/Input/PlayerMovementController.cs` calls them, exclusively for the LOCAL player's own body, lines 761/880/909/958/974/1009-1014). The D6.2 `apply_raw_movement`/`adjust_motion` port (per CLAUDE.md's "D6.2a/D6.2b" commits) is LOCAL-PLAYER-ONLY; remote entities never route through it. Remotes instead get `InterpretedState.ForwardCommand`/`ForwardSpeed` bulk-copied directly (mirroring retail's `copy_movement_from`, per the comment at `GameWindow.cs:4536-4544,4552-4558`), bypassing `adjust_motion`'s left→right/backward-forward normalization and run-hold-key promotion — that normalization already happened SERVER-SIDE (ACE) before the wire command arrived, per the code comments. - ---- - -## Q6 — CORRECTION (how inbound UpdatePosition corrects accumulated error) - -Handled in `OnLivePositionUpdated` (`GameWindow.cs:5334-~5773`). The correction strategy differs by whether the remote is a PLAYER or not, and whether it's airborne: - -### Player remotes (`IsPlayerGuid(update.Guid)` branch, `GameWindow.cs:5460-5635`) -Ports retail's `CPhysicsObj::MoveOrTeleport` (0x00516330): -1. **Airborne no-op** (5512-5521): if `!update.IsGrounded`, body position/velocity are NOT touched by the UP at all — gravity integration continues locally; only the render entity is synced to the current body position (undoing the unconditional top-of-function hard-snap). -2. **Landing transition** (5527-5553): first grounded UP after airborne clears `Airborne`, zeroes velocity, hard-snaps `rmState.Body.Position = worldPos` directly (no queue), clears the interpolation queue, and resets the sequencer cycle from Falling to whatever `InterpretedState.ForwardCommand` implies. -3. **Grounded routing** (5555-5581): distance check against the LOCAL player's position (`MaxPhysicsDistance = 96f`, 5556): - - `dist > 96`: hard `SetPositionSimple`-style snap — `rmState.Interp.Clear(); rmState.Body.Position = worldPos;` (no smoothing at all). - - `dist <= 96`: `rmState.Interp.Enqueue(worldPos, headingFromQuat, isMovingTo:false, currentBodyPosition:rmState.Body.Position)` — feeds `InterpolationManager.Enqueue` (`InterpolationManager.cs:170-231`), which itself has three sub-branches: - - **Far branch** (dist from last-tail/body-position to new target > `AutonomyBlipDistance = 100m`): enqueues AND pre-arms an immediate blip (`_failCount = StallFailCountThreshold + 1 = 4`) so the very next `AdjustOffset` call snaps straight to the tail. - - **Already-close branch** (body-to-target ≤ `DesiredDistance = 0.05m`): wipes the queue entirely, no enqueue (`Clear()`). - - **Near/not-close branch**: tail-prune loop collapses consecutive stale entries within 0.05m of the new target, caps queue at 20 (drops head if full), then appends. - - Every-tick catch-up happens later in `TickAnimations` Path A via `PositionManager.ComputeOffset`→`InterpolationManager.AdjustOffset` (Q5). There is a secondary "stall" detector inside `AdjustOffset` (`InterpolationManager.cs:292-337`): every 5 frames it checks cumulative progress against `MinDistanceToReachPosition = 0.20m` and a secondary ratio check (`cumulative/progressQuantum/dt >= 0.30`); repeated stall failures (`_failCount > StallFailCountThreshold = 3`) trigger a hard SNAP to the tail waypoint (`InterpolationManager.cs:345-350`) and clear the queue. -4. **Velocity-fallback cycle refinement** (5582-5625): synthesizes velocity from consecutive server positions (`(worldPos - PrevServerPos) / dtPos`) and feeds `ApplyServerControlledVelocityCycle` → `ApplyPlayerLocomotionRefinement` (Q7-adjacent — see below) to correct the VISIBLE CYCLE (not just position) when retail's wire is silent on a HoldKey-only Shift toggle. -5. `entity.SetPosition(rmState.Body.Position)` at the end (5633) — the render entity always mirrors the body, never `worldPos` directly, for player remotes within the near-branch (queue chase still in progress). - -### Non-player remotes (`GameWindow.cs:5637-5767`) -NO InterpolationManager/PositionManager usage at all — this is the older "legacy" correction: -1. `serverVelocity` sourced from wire `update.Velocity` if present, else synthesized from position delta if `rmState.LastServerPosTime > 0` (5638-5646). -2. `rmState.Body.Position = worldPos` — **UNCONDITIONAL HARD SNAP, every single UpdatePosition, no soft-lerp, no distance check** (5657). Comment at 5679-5682 confirms: "Retail authoritatively hard-snaps cell membership here too." -3. `rmState.Body.Orientation = rot` — also hard-snapped unconditionally (5693, comment 5685-5692: rotation-rate between UPs instead comes from the formula-seeded `ObservedOmega`, not from UP deltas — an earlier attempt to derive omega from UP deltas caused a "halved observed rate" bug on the first UP after a turn). -4. `rmState.Body.Velocity = svel` if wire `Velocity` present (5711-5713); **`svel.LengthSquared() < 0.04f` is treated as an explicit stop signal** — triggers `rmState.Motion.StopCompletely()` (Q7) PLUS a direct `SetCycle` to Ready (5714-5731). This is the ONLY place `StopCompletely()` is called anywhere in the inbound path. -5. Between UPs (Path B in `TickAnimations`), NPCs get a client-side `ResolveWithTransition` collision sweep — see Q5. - -### Named thresholds used for correction -| Constant | Value | File:line | Purpose | -|---|---|---|---| -| `SnapHardSnapThreshold` | 20.0 m | `GameWindow.cs:614` | (declared, comment references retail `GetAutonomyBlipDistance`; NOT directly read in the reviewed OnLivePositionUpdated flow for non-player remotes — hard snap there is unconditional every UP) | -| `MaxPhysicsDistance` | 96 m | `GameWindow.cs:5556` | Player-remote far-vs-near routing (SetPositionSimple vs Interp.Enqueue) | -| `InterpolationManager.AutonomyBlipDistance` | 100 m | `InterpolationManager.cs:105` | Far-branch pre-arm blip in `Enqueue` | -| `InterpolationManager.DesiredDistance` | 0.05 m | `InterpolationManager.cs:79` | "Reached"/"already close" radius | -| `InterpolationManager.MinDistanceToReachPosition` | 0.20 m | `InterpolationManager.cs:73` | 5-frame stall-check primary threshold | -| `InterpolationManager.StallFailCountThreshold` | 3 (fires on >3, i.e. 4th fail) | `InterpolationManager.cs:98` | Snap-to-tail trigger | -| `ServerControlledVelocityStaleSeconds` | 0.60 s | `GameWindow.cs:926` | NPC server-velocity staleness → zero + Ready | -| `RemoteMoveToDriver.StaleDestinationSeconds` | 1.5 s | `RemoteMoveToDriver.cs:136` | MoveTo destination staleness → stand down | - ---- - -## Q7 — STOP (motion → Ready/stand) - -There are **THREE distinct, independently-triggered stop paths** in the current code, none of which are unified: - -### 1. UM-driven stop (`OnLiveMotionUpdated`, the "retail stop signal") -When the wire's `ForwardCommand` flag is absent or explicitly 0 AND it's not a MoveTo packet (`GameWindow.cs:4347,4364-4367`): `fullMotion = 0x41000003u` (Ready) is used directly as the SetCycle target. This flows through the normal `SetCycle` full-rebuild path (Q2/Q4) — Ready is NOT flagged as a "locomotion" low-byte (`IsLocomotionCycleLowByte` only covers 0x05/0x06/0x07/0x0F/0x10, `AnimationSequencer.cs:1509-1513`), so a Run→Ready transition DOES get the link animation this time (the "Fix B" cyclic-direct bypass only applies when BOTH old and new are locomotion). `remoteMot.Motion.InterpretedState.ForwardCommand`/`ForwardSpeed` are ALSO bulk-copied to `fullMotion`/`speedMod` = Ready/1.0 UNCONDITIONALLY at the same time (4590,4598) — so `MotionInterpreter.get_state_velocity()` will subsequently return `Vector3.Zero` (neither WalkForward nor RunForward matches Ready) for Path B (NPC) velocity. **No explicit `StopCompletely()`/`StopInterpretedMotion()` call happens in this path** — it's purely a state overwrite via the same "InterpretedState = wire's forward/speed" bulk-copy used for every other UM. - -### 2. UP-driven stop for non-player remotes (near-zero wire velocity) -`GameWindow.cs:5711-5731`: only fires when the wire's `UpdatePosition.Velocity` field is explicitly present AND its length² < 0.04 (i.e. |v| < 0.2 m/s). Calls `rmState.Motion.StopCompletely()` (`MotionInterpreter.cs:562-587`) which resets BOTH `RawState` and `InterpretedState` to Ready/1.0/0/0/0/1.0 across all three axes (forward/sidestep/turn) and calls `PhysicsObj.set_velocity(Vector3.Zero)` directly — this IS an explicit velocity zero. Additionally forces `ae.Sequencer.SetCycle(curStyle, readyCmd)` (5729) so the visible cycle snaps to Ready too, independent of whatever UM might arrive later. Comment (5700-5710) notes this UP-absent-velocity path was previously a bug source ("fired StopCompletely every UP → intermittent run") and is now scoped ONLY to the explicit-near-zero case, NOT absent-velocity. - -### 3. Player-remote UM stop (no distinct path — same as #1, but note the render/position split) -For player remotes, `OnLiveMotionUpdated`'s player branch (4417-4492) explicitly SKIPS the sequencer entirely for the LOCAL player's OWN echoed UM. For OTHER players (genuine remotes), the same #1 mechanism applies. **Position, however, is decoupled**: even after a Ready UM zeroes `InterpretedState`/switches the sequencer to Ready, the `InterpolationManager` queue (Path A, Q5) may still hold unconsumed waypoints from the last few UPs sent WHILE the player was still moving (the network is asynchronous — UM and UP for the same "I stopped" event may arrive in either order or with a gap). Since `PositionManager.ComputeOffset` prefers the queue's catch-up delta over `seqVel` whenever the queue is non-empty (`PositionManager.cs:84-86`), **the body can continue sliding toward the last few queued waypoints via `InterpolationManager.AdjustOffset`'s catch-up motion EVEN AFTER the sequencer has already switched to a zero-velocity Ready cycle** — because the queue-driven correction is independent of `CurrentVelocity`. This is a structural potential for residual sliding: the stop signal (UM→Ready) zeroes ANIMATION velocity but does NOT clear `rmState.Interp`'s queue; the queue only self-empties via normal `NodeCompleted` head-pop as the body reaches each remaining waypoint (`InterpolationManager.cs:278-282`), or by explicit `Clear()` calls which only happen at: (a) far-branch enqueue is NOT a clear, (b) already-close enqueue (0.05m gate), (c) `AdjustOffset`'s stall-blip clear, (d) the landing-transition block (5534, airborne case only). **There is no explicit `rm.Interp.Clear()` call anywhere in the UM-driven stop path (#1) or in the near-zero-UP stop path (#2)** — confirmed by reading `GameWindow.cs:4230-4967` (OnLiveMotionUpdated, no `Interp.Clear` call) and `GameWindow.cs:5711-5731` (the UP near-zero-velocity StopCompletely block, no `Interp.Clear` call there either — only the airborne-landing block at 5534 calls `rmState.Interp.Clear()`). - -### Residual-sliding mechanisms explicitly present elsewhere (not stop-specific) -- `SnapResidualDecayRate = 8.0f` (1/sec, `GameWindow.cs:597`) is DECLARED with a comment describing an exponential decay of "soft-snap residual" (100ms half-life) — but this constant is not exercised in any of the code paths read in this pass (`OnLivePositionUpdated`/`OnLiveMotionUpdated`/`TickAnimations` Path A/B); it may be vestigial or used in a renderer-only smoothing pass not covered by this trace. Flagging as unconfirmed — did not find a live call site using this constant during this investigation. - -### Summary answers -- (a) **Where remote DR velocity comes from**: for grounded PLAYER remotes, `AnimationSequencer.CurrentVelocity` (synthesized constants × speedMod, set inside `SetCycle`, NOT from `MotionInterpreter.get_state_velocity()`), used ONLY when the InterpolationManager queue is idle; when the queue is active, DR position instead comes from `InterpolationManager.AdjustOffset`'s catch-up formula (`maxSpeedFromMinterp × 2.0`, itself sourced from `MotionInterpreter.GetMaxSpeed()` = `RunAnimSpeed(4.0) × runRate`). For NPCs (and airborne players), DR velocity comes from `MotionInterpreter.get_state_velocity()` (which itself prefers the sequencer's `CurrentVelocity.Y` when non-zero, else falls back to `WalkAnimSpeed/RunAnimSpeed × InterpretedState.ForwardSpeed`), OR directly from `rm.ServerVelocity` (wire `Velocity` field or position-delta synthesis) when `HasServerVelocity` is true and fresh. -- (b) **When DR velocity changes after a new UM**: SAME FRAME / same synchronous call — `OnLiveMotionUpdated` bulk-copies `InterpretedState.ForwardCommand/ForwardSpeed` immediately (`GameWindow.cs:4590,4598`) and calls `SetCycle` immediately (4769), which synthesizes `CurrentVelocity` immediately inside the same call (`AnimationSequencer.cs:722-754`). There is no queueing/deferral — the very next `TickAnimations`/`Advance()` call (next frame) will observe the new velocity. -- (c) **Can animation phase and DR velocity disagree, for how long**: YES, structurally, in at least two ways: (1) For player remotes, `PositionManager` can be driving position via the InterpolationManager QUEUE (catch-up toward stale waypoints) while the SEQUENCER has already advanced to a new cycle/phase from a fresher UM — these two systems are not coupled; the queue drains independently at its own catch-up rate (up to `GetMaxSpeed()×2.0` or 7.5 m/s fallback) regardless of what the currently-playing animation phase implies. (2) The `SetCycle` cyclic-direct hard-cut (Q2/Q4) resets `_framePosition` to a start boundary instantly on every walk↔run toggle, so the visible LEG PHASE has zero continuity with the body's actual accumulated forward distance at the moment of the toggle — there's no phase-matching/re-sync logic. Duration of disagreement is unbounded in principle (bounded in practice only by how quickly the InterpolationManager queue catches up, or by the next UM/UP hard-snap). -- (d) **How stop zeroes velocity, what can leave residual sliding**: `StopCompletely()` zeroes `PhysicsObj.set_velocity(Vector3.Zero)` directly on the body AND resets `InterpretedState`, but this ONLY fires from the UP near-zero-velocity path (#2 above) — the far more common UM-driven Ready transition (#1) only zeroes the ANIMATION's synthesized `CurrentVelocity` (via `SetCycle`'s Ready branch, which is not a "locomotion low byte" so gets no velocity synthesis at all — `CurrentVelocity` simply stays whatever it was set to by `ClearPhysics()` at the top of `SetCycle`, i.e. `Vector3.Zero`, UNLESS a link with `HasVelocity` overrides it — Ready transitions DO get a link per Q2, and if that link's MotionData has `HasVelocity` set, `CurrentVelocity` would be non-zero for the DURATION of that link before wrapping to the (velocity-less, non-locomotion) Ready cycle). Meanwhile, for player remotes specifically, **NEITHER of the two stop paths clears `rmState.Interp`'s waypoint queue** — so if the queue still holds unreached waypoints from before the stop signal, `PositionManager.ComputeOffset`/`InterpolationManager.AdjustOffset` will continue producing non-zero catch-up deltas each tick (up to the stall-detection kicking in after 5 frames / `_failCount` exceeding 3), producing exactly the "residual sliding after stop" symptom the task is asking about. This is a genuine, code-confirmed gap: stop-signal handling and interpolation-queue clearing are two independent systems that are not wired together outside of the airborne-landing special case. - ---- - -## Cross-cutting notes for synthesis - -- **Two entirely separate motion-drive architectures coexist**: the newer InterpolationManager/PositionManager "queue + REPLACE" model (grounded player remotes only) vs. the older `MotionInterpreter.apply_current_movement`/`get_state_velocity` "continuous velocity from InterpretedState" model (NPCs + airborne remotes). They share almost no code and have different stop/correction semantics. -- **`MotionInterpreter`'s D6.2 `adjust_motion`/`apply_raw_movement` port is entirely unused by the inbound remote path** — remotes get server-pre-normalized commands bulk-copied directly into `InterpretedState`, bypassing the retail-faithful raw→interpreted normalization pipeline that exists in the codebase but is wired to the LOCAL player only (`PlayerMovementController.cs`). -- **No unified "stop" concept**: three independent triggers (UM Ready, UP near-zero-velocity StopCompletely, airborne-landing zero) each partially zero different pieces of state (animation velocity only / full body+interpreted-state velocity / position+velocity+cycle), and none of them touch the InterpolationManager queue except the airborne-landing case. -- **`SetCycle`'s cyclic-locomotion-direct special case (Q2/Q4) is itself a documented, intentional divergence from the general link-transition mechanism**, justified in-code by a cdb trace of retail's `add_to_queue` behavior — i.e. per CLAUDE.md's divergence-register discipline, this specific behavior claims retail-fidelity (not an acknowledged approximation), though this investigation did not independently verify the cdb trace's conclusiveness. diff --git a/docs/research/2026-07-02-inbound-motion-maps/map-ace-port.md b/docs/research/2026-07-02-inbound-motion-maps/map-ace-port.md deleted file mode 100644 index 8fbf4ddc..00000000 --- a/docs/research/2026-07-02-inbound-motion-maps/map-ace-port.md +++ /dev/null @@ -1,610 +0,0 @@ -# Map of ACE's C# port of retail CMotionInterp / CSequence / MovementManager - -Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics` - -Key files (all confirmed to exist): -- `Animation/MotionInterp.cs` — retail `CMotionInterp` (per-object motion-command interpreter; owns `PendingMotions`, `RawState`, `InterpretedState`) -- `Animation/Sequence.cs` — retail `CSequence` (the actual animation cycle/frame player; owns `AnimList`, `CurrAnim`, `FrameNumber`, `Velocity`/`Omega`) -- `Animation/MotionTable.cs` — retail motion-table graph walker (`GetObjectSequence` = the core cycle-swap / link-animation logic) -- `Managers/MotionTableManager.cs` — retail per-object `AnimationCounter`/`PendingAnimations` bookkeeping + redundant-link truncation -- `Managers/MovementManager.cs` — top dispatcher owning `MotionInterpreter` + `MoveToManager` -- `Managers/InterpolationManager.cs` — remote/dead-reckoning position correction (network snap-to-position blending) -- `Managers/PositionManager.cs` — thin composite wrapping InterpolationManager + StickyManager + ConstraintManager, called every tick from `PhysicsObj.UpdatePositionInternal` -- `PartArray.cs` — glue between `PhysicsObj` and `Sequence`/`MotionTableManager` (`DoInterpretedMotion`, `Update`, `StopInterpretedMotion`) -- `PhysicsObj.cs` — top-level per-entity physics object; `update_object`/`UpdateObjectInternal`/`UpdatePositionInternal` drive the whole thing every tick -- `Animation/AnimSequenceNode.cs`, `Animation/MotionState.cs`, `Animation/RawMotionState.cs`, `Animation/InterpretedMotionState.cs`, `Animation/MotionNode.cs`, `Animation/AnimNode.cs`, `Animation/MovementParameters.cs` - -IMPORTANT CAVEAT: ACE is the **server**. There is no "remote entity rendering" concept in ACE — every `PhysicsObj` on the server is locally-simulated and authoritative; ACE broadcasts `UpdatePosition`/`UpdateMotion` wire messages *outward* to clients, it does not consume them for dead-reckoning of other players (the actual client-side interpolation-of-remote-entities logic lives in the retail client, not in ACE). The closest ACE analogs to "a remote object changing walk<->run while moving": -1. **The locally-controlled-by-network-input path**: `Player_Tick.OnMoveToState_ClientMethod` / `OnMoveToState_ServerMethod` — this is literally the server *acting as* the retail client's `CMotionInterp` would for the connected player, driven by inbound `MoveToState` (0xF61C-family) packets. This is the best available proxy for "how does an incoming motion-command change drive the interpreter." -2. **The dead-reckoning/position-correction path** for *other* entities as seen by a given observer would be `PhysicsObj.MoveOrTeleport` + `PositionManager.InterpolateTo` + `InterpolationManager` — this is ACE's port of the retail smartbox/dead-reckoning blend (used e.g. for monsters' `UpdatePosition` broadcasts, and structurally mirrors what a retail *client* does when it receives another player's `UpdatePosition`). - -Both paths are documented below since the questions blend "wire-to-interpreter" (best answered by path 1) and "position correction" (best answered by path 2). - ---- - -## Q1 — INBOUND ENTRY: wire message → motion interpreter - -### Path A: motion-command message (`MoveToState`, retail wire opcode family 0xF61C) → MotionInterp - -1. `ACE.Server/Network/GameAction/Actions/GameActionMoveToState.cs:13` `Handle(ClientMessage, Session)` — the game-action handler for `[GameAction(GameActionType.MoveToState)]`. Parses payload into a `MoveToState` struct (`Network/Structure/MoveToState.cs`, not read in detail here) and stores `session.Player.CurrentMoveToState = moveToState;` (line 20). -2. Line 29: `session.Player.OnMoveToState(moveToState);` — this is the entry into the physics/animation layer. -3. Line 36: `session.Player.BroadcastMovement(moveToState);` (`WorldObjects/Player_Networking.cs:309`) — re-broadcasts the motion as `GameMessageUpdateMotion` to other clients (the wire-out side of this, not traced further — out of scope per the ACE-is-server caveat above). - -4. `WorldObjects/Player_Tick.cs:176` `OnMoveToState(MoveToState moveToState)`: - - Guards on `FastTick` (line 178). - - Line 187-188: if not already moving/animating, resets `PhysicsObj.UpdateTime = PhysicsTimer.CurrentTime` (this re-arms the per-tick delta-time accumulator so a stale UpdateTime doesn't produce a huge first quantum). - - Line 190-193: branches on `client_movement_formula` property + `StandingLongJump`: - - `OnMoveToState_ServerMethod` (retail-authoritative single-shot apply) - - `OnMoveToState_ClientMethod` (retail client-style edge-triggered DoMotion/StopMotion calls) - -5. **`OnMoveToState_ServerMethod`** (`Player_Tick.cs:272-288`) — the retail-CMotionInterp-faithful path: - ```csharp - var minterp = PhysicsObj.get_minterp(); - minterp.RawState.SetState(moveToState.RawMotionState); - if (moveToState.StandingLongJump) { minterp.RawState.ForwardCommand = Ready; minterp.RawState.SideStepCommand = 0; } - var allowJump = minterp.motion_allows_jump(minterp.InterpretedState.ForwardCommand) == WeenieError.None; - minterp.apply_raw_movement(true, allowJump); - ``` - This copies the ENTIRE wire-level `RawMotionState` (`Network/Structure/RawMotionState`) into `MotionInterp.RawState` in one shot via `RawMotionState.SetState` (`Animation/RawMotionState.cs:117-140`), then calls `MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`), which re-derives `InterpretedState` from `RawState` field-by-field and re-issues `DoInterpretedMotion` calls (see Q2 below). **This is the retail `CMotionInterp::apply_raw_movement` — full re-derivation every call, not an incremental diff.** - -6. **`OnMoveToState_ClientMethod`** (`Player_Tick.cs:199-270`) — an edge-triggered alternative (a `client_movement_formula`-flagged, non-default variant) that diffs `rawState` against `prevState = LastMoveToState?.RawMotionState` and calls `PhysicsObj.DoMotion` / `PhysicsObj.StopMotion` only on actual key-press/key-release transitions (ForwardCommand changed / went Invalid, same for Sidestep/Turn). This is closer to how the retail *client itself* processes raw keyboard edges, and is explicitly commented (lines 360-371) as an attempt to fix desync bugs vs. the always-full-reapply server method. - -### Path B: `PhysicsObj.DoMotion` (entry used by both client-edge method and any direct-call site) - -`PhysicsObj.cs:340-346`: -```csharp -public WeenieError DoMotion(uint motion, MovementParameters movementParams) -{ - LastMoveWasAutonomous = true; - if (MovementManager == null) return WeenieError.NoAnimationTable; - var mvs = new MovementStruct(MovementType.RawCommand, motion, movementParams); - return MovementManager.PerformMovement(mvs); -} -``` -→ `MovementManager.PerformMovement` (`Managers/MovementManager.cs:124-157`) lazily constructs `MotionInterpreter` if null, then dispatches `MovementType.RawCommand` to `MotionInterpreter.PerformMovement(mvs)` (`Animation/MotionInterp.cs:236-262`), which for `RawCommand` calls `DoMotion(mvs.Motion, mvs.Params)` (`MotionInterp.cs:112-158`). - -`MotionInterp.DoMotion` (line 112): -- Builds a local `currentParams` copy (`CopySome`, MotionInterp.cs:119). -- `adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply)` (line 129) — this is where WalkForward auto-promotes to RunForward if HoldKey==Run (see Q2). -- Combat-stance gating (lines 131-145): rejects Crouch/Sitting/Sleeping/ChatEmote while `CurrentStyle != NonCombat`. -- Action-count gating (line 147-151): `GetNumActions() >= 6` → `TooManyActions`. -- Calls `DoInterpretedMotion(currentMotion, currentParams)` (line 152) — the actual interpreter entry (shared with Path C below). -- If success and `movementParams.ModifyRawState`, calls `RawState.ApplyMotion(motion, movementParams)` (line 155) to record the **original, un-adjusted** motion into RawState (so Run-promotion doesn't get baked into RawState). - -### Path C: `MotionInterp.DoInterpretedMotion` — the true funnel point (both paths above and the direct-interpreted-command callers converge here) - -`Animation/MotionInterp.cs:51-110`: -1. `contact_allows_move(motion)` gate (line 57, see below) — blocks ground-locked motions while airborne. -2. `StandingLongJump` special case (lines 59-63): if charging a standing long jump and motion is Walk/Run/SideStepRight, just updates `InterpretedState` without touching the physics animation (charge-jump doesn't reanimate). -3. Otherwise (line 64-90): - - `Dead` motion → `PhysicsObj.RemoveLinkAnimations()` first (line 66-67). - - `result = PhysicsObj.DoInterpretedMotion(motion, movementParams)` (line 69) → `PartArray.DoInterpretedMotion` (`PartArray.cs:130-136`) → `MotionTableManager.PerformMovement(mvs, Sequence)` (`Managers/MotionTableManager.cs:116-145`) → `Table.DoObjectMotion` → `GetObjectSequence` (`Animation/MotionTable.cs:55-257`, the actual cycle-graph logic, see Q2/Q4). - - On success: computes `jump_error_code` (lines 73-83, gates jump based on DisableJumpDuringLink / motion_allows_jump), then `add_to_queue(movementParams.ContextID, motion, jump_error_code)` (line 85) — pushes a `MotionNode` onto `MotionInterp.PendingMotions` (see Q3). - - If `movementParams.ModifyInterpretedState`, calls `InterpretedState.ApplyMotion(motion, movementParams)` (line 88) to record the new forward/sidestep/turn/style/action state. -4. If not contact-allowed (airborne): Action commands fail with `YouCantJumpWhileInTheAir` (line 94-95); non-action commands are recorded into InterpretedState only (no animation change) with `ModifyInterpretedState` (line 99-100). -5. Line 106-107: if `PhysicsObj.CurCell == null`, `RemoveLinkAnimations()` — an object that's not in a cell (e.g. being carried, or between transitions) never plays link animations. - -`contact_allows_move` (`MotionInterp.cs:584-602`): -``` -Dead / Falling / TurnRight..TurnLeft range → always true -non-creature WeenieObj → always true -!PhysicsState.Gravity → true -!TransientState.Contact → false // airborne, no ground contact -TransientState.OnWalkable → true // standing on walkable floor -else → false -``` - ---- - -## Q2 — TRANSITION: walk↔run while already moving (no full stop) - -### Frame 0: the wire triggers `apply_raw_movement` (server method) or a direct `DoMotion(RunForward, ...)` (client-edge method / direct caller) - -`MotionInterp.apply_raw_movement` (`Animation/MotionInterp.cs:506-523`): -```csharp -InterpretedState.CurrentStyle = RawState.CurrentStyle; -InterpretedState.ForwardCommand = RawState.ForwardCommand; // e.g. WalkForward (0x45000005) -InterpretedState.ForwardSpeed = RawState.ForwardSpeed; -InterpretedState.SideStepCommand = RawState.SideStepCommand; -... -adjust_motion(ref InterpretedState.ForwardCommand, ref InterpretedState.ForwardSpeed, RawState.ForwardHoldKey); -adjust_motion(ref InterpretedState.SideStepCommand, ref InterpretedState.SideStepSpeed, RawState.SideStepHoldKey); -adjust_motion(ref InterpretedState.TurnCommand, ref InterpretedState.TurnSpeed, RawState.TurnHoldKey); -apply_interpreted_movement(cancelMoveTo, allowJump); -``` - -`adjust_motion` (`MotionInterp.cs:394-428`) is where **Walk→Run promotion actually happens**: -```csharp -public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey) -{ - if (WeenieObj != null && !WeenieObj.IsCreature()) return; - switch (motion) - { - case RunForward: return; // already run, no-op - case WalkBackwards: motion = WalkForward; speed *= -BackwardsFactor; break; // -0.65 - case TurnLeft: motion = TurnRight; speed *= -1.0f; break; - case SideStepLeft: motion = SideStepRight; speed *= -1.0f; break; - } - if (motion == SideStepRight) - speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25) - - if (holdKey == HoldKey.Invalid) holdKey = RawState.CurrentHoldKey; - if (holdKey == HoldKey.Run) apply_run_to_command(ref motion, ref speed); -} -``` -`apply_run_to_command` (`MotionInterp.cs:525-562`): -```csharp -case WalkForward: - if (speed > 0.0f) motion = RunForward; // <-- THE ACTUAL SWAP: WalkForward substate id → RunForward substate id - speed *= speedMod; // speedMod = WeenieObj.InqRunRate() or MyRunRate (server run-skill-derived rate) - break; -case TurnRight: speed *= RunTurnFactor; break; // 1.5 -case SideStepRight: - speed *= speedMod; - if (MaxSidestepAnimRate < Math.Abs(speed)) // clamp to 3.0 - speed = Math.Sign(speed) * MaxSidestepAnimRate; - break; -``` -**So the "walk vs run" decision is NOT a separate motion command on the wire — it's the client's HoldKey (Run) bit combined with WalkForward, and the *interpreter* (not the network layer) decides to swap the motion id from `WalkForward` (0x45000005) to `RunForward` (0x44000007) before it ever reaches the animation graph.** This matches the CLAUDE.md-documented wire quirk: acdream sends `WalkForward + HoldKey.Run`, and ACE relays it as `RunForward` to observers — that relay behavior mirrors exactly this `adjust_motion`/`apply_run_to_command` swap. - -### Frame 1: `apply_interpreted_movement` re-issues `DoInterpretedMotion` for the (possibly swapped) ForwardCommand - -`MotionInterp.apply_interpreted_movement` (`MotionInterp.cs:440-504`): -```csharp -if (InterpretedState.ForwardCommand == RunForward) - MyRunRate = InterpretedState.ForwardSpeed; // cache the run-rate for future adjust_motion calls - -DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams); // re-assert combat/non-combat stance - -if (contact_allows_move(InterpretedState.ForwardCommand)) -{ - if (!StandingLongJump) - { - movementParams.Speed = InterpretedState.ForwardSpeed; - DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams); // <-- e.g. DoInterpretedMotion(RunForward, speed) - ...sidestep, turn similarly... -``` - -### Frame 2: `DoInterpretedMotion(RunForward, ...)` reaches `MotionTable.GetObjectSequence` — THIS is where the cycle actually swaps - -`GetObjectSequence` (`Animation/MotionTable.cs:60-257`). WalkForward (0x45000005) and RunForward (0x44000007) both have the `CommandMask.SubState` bit (0x40000000) set, so both hit the **SubState branch** (line 121 onward), NOT the "same substate, just re-speed" fast path — because `motion` (RunForward id) != `currState.Substate` (currently WalkForward id). The relevant excerpt: - -```csharp -if ((motion & CommandMask.SubState) != 0) -{ - var motionID = motion & 0xFFFFFF; - Cycles.TryGetValue(currState.Style << 16 | motionID, out motionData); // look up the RUN cycle's MotionData - ... - if (is_allowed(motion, motionData, currState)) - { - if (motion == currState.Substate && sequence.HasAnims() && Math.Sign(speedMod) == Math.Sign(currState.SubstateMod)) - { - // FAST PATH — same substate, just a speed change (does NOT apply to Walk<->Run, - // since WalkForward != RunForward numerically) - change_cycle_speed(sequence, motionData, currState.SubstateMod, speedMod); - subtract_motion(sequence, motionData, currState.SubstateMod); - combine_motion(sequence, motionData, speedMod); - currState.SubstateMod = speedMod; - return true; - } - - // GENERAL PATH — actually taken for Walk->Run: - if ((motionData.Bitfield & 1) != 0) currState.clear_modifiers(); - - var link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod); - // link = the WALK-cycle -> RUN-cycle transition/blend animation from the motion table's Links dict, - // keyed by (style<<16 | fromSubstate) -> Dictionary - - if (link == null || Math.Sign(speedMod) != Math.Sign(currState.SubstateMod)) - { - // fallback: go through the style's default substate as an intermediate hop - uint defaultMotion; StyleDefaults.TryGetValue(currState.Style, out defaultMotion); - link = get_link(currState.Style, currState.Substate, currState.SubstateMod, defaultMotion, 1.0f); - motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod); - } - - sequence.clear_physics(); // Sequence.Velocity = Omega = Vector3.Zero - sequence.remove_cyclic_anims(); // truncate AnimList back to the CURRENT (in-flight) anim node — see Q4 - - if (motionData_ != null) - { - add_motion(sequence, link, currState.SubstateMod); // append walk->default link anim - add_motion(sequence, motionData_, speedMod); // append default->run link anim - } - else - { - add_motion(sequence, link, newSpeedMod); // append walk->run direct link anim (typical case) - } - add_motion(sequence, motionData, speedMod); // APPEND the run cycle itself (loops forever) - - currState.SubstateMod = speedMod; - currState.Substate = motion; // RunForward is now the tracked substate - re_modify(sequence, currState); // re-apply any active modifiers on top - - numAnims = (motionData?.Anims.Count ?? 0) + (link?.Anims.Count ?? 0) + (motionData_?.Anims.Count ?? 0) - 1; - return true; - } -} -``` - -**Answer to Q2: the new motion is APPENDED to the sequence's `AnimList`, not a hard replace.** `sequence.remove_cyclic_anims()` first prunes any *already-cyclic* (looping) anim nodes that are BEYOND the currently-playing node — i.e. it removes stale queued loop segments but does NOT touch the in-flight anim — then `add_motion` calls append the link (transition) animation followed by the new cycle (Run) animation onto the tail of `AnimList` (`Sequence.append_animation`, `Sequence.cs:203-216`). The currently-playing Walk frame keeps playing to its natural end (or loop boundary); once `Sequence.update_internal` walks off the end of the current node it advances into the queued link animation, then into the new Run cycle (see Q4 for exact frame semantics). **There IS a blend/link animation** — `get_link(style, fromSubstate, fromSpeed, toSubstate, toSpeed)` looks up a purpose-built transition clip from `MotionTable.Links[(style<<16)|fromSubstate][toSubstate]`; this is retail's canonical "walk-to-run" or "run-to-walk" link/transition motion. **Speed change is NOT instantaneous/immediate** — the running cycle only takes effect once the sequence actually plays through to it; only `Sequence.Velocity`/`Sequence.Omega` (the linear/angular displacement-per-quantum baked into the currently active `MotionData`) change per node, and those are set fresh by each `add_motion` call via `sequence.SetVelocity(motionData.Velocity * speed)` (`MotionTable.cs:362`) which OVERWRITES (not blends) `Sequence.Velocity` — but that overwrite only takes effect for the currently-active node once the sequence has walked into it. - -One exception worth flagging: `SetVelocity`/`SetOmega` inside `add_motion` (line 362-363) is called once per `add_motion(sequence, motionData, speed)` invocation and each call **overwrites** `sequence.Velocity`/`sequence.Omega` — so by the time `GetObjectSequence` returns, `Sequence.Velocity` already reflects the LAST `add_motion` call (the new Run cycle's velocity), even though the currently-playing frame is still mid-Walk-cycle. This means **the per-tick displacement (Q5) can jump to the new cycle's velocity before the visual animation frame has caught up to the new cycle** — a subtlety worth testing for in acdream's port (verify our `Sequence.Update` doesn't apply the "new" velocity to the frames that are still visually inside the "old" walk cycle, or confirm retail genuinely does this). - ---- - -## Q3 — PENDING/DONE lifecycle: `pending_motions` + `MotionDone` - -Two SEPARATE pending-lists exist at two SEPARATE layers, easy to conflate: - -### Layer 1: `MotionInterp.PendingMotions` (`List`, `MotionInterp.cs:24`) - -- **Type**: `LinkedList`. `MotionNode` (`Animation/MotionNode.cs`): `ContextID` (int), `Motion` (uint), `JumpErrorCode` (WeenieError). -- **Appended by**: `MotionInterp.add_to_queue(contextID, motion, jumpErrorCode)` (`MotionInterp.cs:388-392`): - ```csharp - PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode)); - PhysicsObj.IsAnimating = true; - ``` - Called from `DoInterpretedMotion` (line 85), `StopCompletely` (line 321), `StopInterpretedMotion` (line 348), and `apply_interpreted_movement`'s turn-release branch (line 495). -- **Popped by**: `MotionInterp.MotionDone(bool success)` (`MotionInterp.cs:210-234`): - ```csharp - var motionData = PendingMotions.First; - if (motionData != null) - { - var pendingMotion = motionData.Value; - if ((pendingMotion.Motion & CommandMask.Action) != 0) - { - PhysicsObj.unstick_from_object(); - InterpretedState.RemoveAction(); - RawState.RemoveAction(); - } - motionData = PendingMotions.First; // re-fetch (defensive against re-entrancy) - if (motionData != null) - { - PendingMotions.Remove(motionData); - PhysicsObj.IsAnimating = PendingMotions.Count > 0; - } - } - ``` - This is called ONLY from `PhysicsObj.MotionDone(motion, success)` → `MovementManager.MotionDone(motion, success)` → `MotionInterpreter.MotionDone(success)` (note: the `motion` parameter is dropped/ignored at this layer — it always pops `PendingMotions.First`, positionally, not by matching motion id). -- **Who calls `PhysicsObj.MotionDone`**: `MotionTableManager.AnimationDone` (see Layer 2 below) and `MotionTableManager.CheckForCompletedMotions`. -- **`motions_pending()` / `IsAnimating`**: `MotionInterp.motions_pending()` (line 784-787) just checks `PendingMotions.Count > 0`; the faster `PhysicsObj.IsAnimating` bool field is kept in sync at every add/remove (see above) as a cached shortcut — the doc comment explicitly says to prefer `IsAnimating` for perf. -- **`HandleExitWorld`** (`MotionInterp.cs:160-173`): drains `PendingMotions` entirely on world-exit, unsticking any queued Action motions and clearing the list without ever calling MotionDone on each (a hard flush, not a graceful drain). - -### Layer 2: `MotionTableManager.PendingAnimations` (`LinkedList`, `Managers/MotionTableManager.cs:13`) - -- **Type**: `AnimNode` (`Animation/AnimNode.cs`): `Motion` (uint), `NumAnims` (uint) — the count of individual `Animation` sub-clips (as opposed to the higher-level `MotionCommand`) that must finish playing before this queue entry is considered done. -- **Appended by**: `MotionTableManager.add_to_queue(motion, num_anims, sequence)` (`MotionTableManager.cs:163-167`), called from `PerformMovement` (line 127/134/139) with `counter` = the `numAnims` computed by `MotionTable.GetObjectSequence`/`StopSequenceMotion`/`StopObjectCompletely` (i.e. the total sub-clip count of link+cycle animations just appended to `Sequence.AnimList` for this motion). Also called by `initialize_state` (line 176, entry into default Ready state) with the numAnims from `SetDefaultState`. - - Immediately after append: `remove_redundant_links(sequence)` (line 166) — walks `PendingAnimations` from the tail backward and, if it finds a duplicate motion id further up the chain whose animations haven't started playing yet, calls `trancuate_animation_list` to zero out the redundant entries' `NumAnims` and physically remove the corresponding still-unplayed link animations from `Sequence.AnimList` via `sequence.remove_link_animations(totalAnims)` (`Sequence.cs:324-340`). This is the mechanism that prevents animation-graph bloat when motion commands arrive faster than they can finish playing (e.g. rapid walk/run toggling). -- **Popped/completed by TWO different drivers**: - 1. **`AnimationDone(bool success)`** (`MotionTableManager.cs:28-61`) — driven by actual animation-hook completion signals (see `Sequence.update_internal`'s `AnimDoneHook`, Q4). Increments `AnimationCounter`, then while the FRONT node's `NumAnims <= AnimationCounter`: subtracts `entry.NumAnims` back out of the counter, removes an Action-head from `MotionState` if the motion has the Action bit, calls `PhysicsObj.MotionDone(motionID, success)` (→ pops Layer-1 `MotionInterp.PendingMotions`, see above), removes the front `PendingAnimations` node, and fires `PhysicsObj.WeenieObj.OnMotionDone(motionID, success)` (the weenie/game-object-level callback — e.g. for scripted "on landed" or "on emote finished" logic). - 2. **`CheckForCompletedMotions()`** (`MotionTableManager.cs:63-85`) — a POLLING variant, called from `PhysicsObj.CheckForCompletedMotions` (`PhysicsObj.cs:296-300`) → `PartArray.CheckForCompletedMotions` (`PartArray.cs:72-76`), itself invoked once per `MotionInterp.PerformMovement` call (`MotionInterp.cs:260`, right after `PhysicsObj.CheckForCompletedMotions()` unconditionally at the end of every `PerformMovement`). It loops while `PendingAnimations.First.Value.NumAnims == 0` (i.e. entries that were ALREADY reduced to zero, either by construction — an empty-cycle motion — or by a prior `remove_redundant_links`/`trancuate_animation_list` call), popping them exactly like `AnimationDone` does (same Action-head removal, `PhysicsObj.MotionDone`, `OnMotionDone`). -- **Actual per-sub-animation completion signal**: `Sequence.update_internal` (`Animation/Sequence.cs:351-443`), when `animDone` is set true (a frame index walked off the end of the current `AnimSequenceNode`'s HighFrame/LowFrame), fires: - ```csharp - if (HookObj != null) - { - var node = AnimList.First; - if (!node.Equals(FirstCyclic)) - HookObj.add_anim_hook(AnimationHook.AnimDoneHook); - } - ``` - i.e. **only fires the AnimDoneHook while the completing node is a NON-cyclic (link) node** — the looping cycle node itself never signals "done" this way (it loops forever via `advance_to_next_animation`'s wraparound to `FirstCyclic`, see Q4). `HookObj.add_anim_hook` (defined on `PhysicsObj`, not read in detail — queues an `FPHook`/animation hook for later dispatch) is what eventually drives `MotionTableManager.AnimationDone(true)` on the consuming side. - -### Summary of the 3-tier queue relationship -``` -Sequence.AnimList (LinkedList) — the actual playable clips, link+cyclic - ↑ pushed onto tail by add_motion() during GetObjectSequence -MotionTableManager.PendingAnimations (LinkedList) — motion-id + sub-clip COUNT bookkeeping - ↑ pushed by MotionTableManager.add_to_queue(), consumed on AnimDoneHook / by polling -MotionInterp.PendingMotions (LinkedList) — high-level motion-command queue (1:1 per DoInterpretedMotion/Stop call) - ↑ pushed by MotionInterp.add_to_queue(), popped 1-for-1 whenever a PendingAnimations - entry at Layer 2 fully completes (positionally FIFO, NOT id-matched) -``` - ---- - -## Q4 — CYCLE SWAP mechanics: does frame index carry over, restart, or transition via link? - -Answer: **transitions via link animation; NEVER blends frame index/phase between cycles; the incoming cycle always restarts its own frame counter from its own starting frame.** - -### `Sequence.append_animation` (`Sequence.cs:203-216`) -```csharp -public void append_animation(AnimData animData) -{ - var node = new AnimSequenceNode(animData); - if (!node.has_anim()) return; - AnimList.AddLast(node); - FirstCyclic = AnimList.Last; // <-- EVERY append moves the "first cyclic" marker to the newest tail node - if (CurrAnim == null) - { - CurrAnim = AnimList.First; - FrameNumber = CurrAnim.Value.get_starting_frame(); - } -} -``` -Each `add_motion` call in `GetObjectSequence` does one `append_animation` PER sub-`Anim` in the `MotionData.Anims` list (`MotionTable.cs:358-370`, `add_motion`). So for a Walk→Run swap the append order is: [any remaining not-yet-pruned Walk-cycle tail] → link anim clip(s) → new Run cycle clip(s). `FirstCyclic` ends up pointing at the LAST-appended node, i.e. the start of the freshly-appended Run cycle segment — this is the "loop point": once the sequence plays past the tail, `advance_to_next_animation` wraps back to `FirstCyclic`, not to `AnimList.First`. - -### `Sequence.remove_cyclic_anims` (`Sequence.cs:303-322`) — called BEFORE the new link+cycle are appended -```csharp -public void remove_cyclic_anims() -{ - var node = FirstCyclic; - while (node != null) - { - if (CurrAnim.Equals(node)) - { - CurrAnim = node.Previous; - if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame(); - else FrameNumber = 0.0f; - } - var next = node.Next; - AnimList.Remove(node.Value); - node = next; - } - FirstCyclic = AnimList.Last; -} -``` -This walks from the OLD `FirstCyclic` (the start of the previously-active loop segment) to the tail, removing every node in that range. If the currently-playing node (`CurrAnim`) happens to BE the old cyclic loop node being removed, `CurrAnim` is rewound to `node.Previous` with `FrameNumber` snapped to that previous node's `get_ending_frame()` — i.e. if you were mid-loop when the swap request came in, the in-flight loop iteration is truncated at its END boundary (not its current mid-loop position) and the freshly-appended link/cycle nodes play from there. **In practice this means: any partially-played Walk loop iteration doesn't get interrupted mid-stride — the current node finishes to its designated "ending frame" (for forward playback, `HighFrame + 1 - EPSILON`, `AnimSequenceNode.cs:31-37`), THEN the link animation begins from ITS OWN starting frame (`LowFrame` for forward playback, `AnimSequenceNode.cs:72-78`).** There is no frame-phase carry-over from Walk into the link or from the link into Run — every `AnimSequenceNode` transition resets `frameNum` to that node's `get_starting_frame()` inside `advance_to_next_animation` (`Sequence.cs:145-201`, specifically line 165 `frameNum = currAnim.get_starting_frame();` for forward playback, or line 191 `frameNum = currAnim.get_ending_frame();` for the reverse-playback branch). - -### `advance_to_next_animation` (`Sequence.cs:145-201`) — the actual node-to-node walk -Forward-playback branch (`timeElapsed >= 0.0f`): -```csharp -// subtract the outgoing node's pos-frame delta from the running offset frame (undo its contribution) -if (frame != null && currAnim.Framerate < 0.0f) { frame.Subtract(currAnim.get_pos_frame((int)frameNum)); apply_physics(...); } -animNode = animNode.Next ?? FirstCyclic; // <-- walk forward one node, OR wrap to FirstCyclic if at tail -currAnim = animNode.Value; -frameNum = currAnim.get_starting_frame(); // <-- ALWAYS restart at the node's own starting frame — never carries phase -if (frame != null && currAnim.Framerate > 0.0f) { frame = AFrame.Combine(frame, currAnim.get_pos_frame((int)frameNum)); apply_physics(...); } -``` -So the answer is explicit: **frame index restarts per-node** (each `AnimSequenceNode` — link clip or cycle clip — always begins at its own `LowFrame`/`HighFrame` boundary), and the only "carry-over" concept is which NODE plays next, driven by the `AnimList` linked-list order that `GetObjectSequence`/`add_motion` built. The mechanism for reaching Run from Walk is exclusively via inserting the retail motion table's dedicated **link animation** (`MotionTable.get_link`, `MotionTable.cs:395-426`) between them — never a numeric blend/crossfade of frame data. `Sequence.apply_physics` (Sequence.cs:221-230) applies `Velocity`/`Omega` translation+rotation per-quantum on top of whatever pos-frame deltas the current clip has (see Q5), so even the perceived motion "smoothness" across the swap comes only from the animation authoring of the link clip, not from any interpolation logic in `Sequence`/`MotionTable` code. - -### `apricot()` (`Sequence.cs:232-243`) — garbage-collects consumed-but-still-present nodes -```csharp -public void apricot() -{ - var node = AnimList.First; - while (!node.Equals(CurrAnim)) - { - if (node.Equals(FirstCyclic)) break; - AnimList.Remove(node); - node = AnimList.First; - } -} -``` -Called every `Sequence.Update` tick (`Sequence.cs:132-143`, right after `update_internal`) — trims fully-played-past nodes off the FRONT of `AnimList` up to (but never past) `FirstCyclic`, keeping the linked list from growing unbounded as animations complete. (Yes, the function name really is `apricot` in ACE's port — presumably a literal/garbled decompiled symbol name; the CLAUDE.md workflow note about verifying against named-retail symbols applies here if a real name needs to be recovered.) - ---- - -## Q5 — POSITION DRIVE between inbound packets: what advances position? - -**Both, combined additively in one offset frame, gated by ground contact.** Sequence order (`PhysicsObj.UpdatePositionInternal`, `PhysicsObj.cs:1862-1879`): - -```csharp -public void UpdatePositionInternal(double quantum, ref AFrame newFrame) -{ - var offsetFrame = new AFrame(); - if (!State.HasFlag(PhysicsState.Hidden)) - { - if (PartArray != null) PartArray.Update(quantum, ref offsetFrame); // 1. animation-driven delta - if (TransientState.HasFlag(TransientStateFlags.OnWalkable)) - offsetFrame.Origin *= Scale; // 2. scale only applied while grounded - else - offsetFrame.Origin *= 0.0f; // 3. AIRBORNE: animation displacement is ZEROED OUT - } - if (PositionManager != null) - PositionManager.AdjustOffset(offsetFrame, quantum); // 4. network-correction nudge added on top - newFrame = AFrame.Combine(Position.Frame, offsetFrame); - ... -} -``` - -**Step 1 — `PartArray.Update` → `Sequence.Update`** (`PartArray.cs:589-592`, `Sequence.cs:132-143`): -```csharp -public void Update(float quantum, ref AFrame offsetFrame) -{ - if (AnimList.First != null) - { - update_internal(quantum, ref CurrAnim, ref FrameNumber, ref offsetFrame); - apricot(); - } - else if (offsetFrame != null) - apply_physics(offsetFrame, quantum, quantum); // no anims queued: pure Velocity/Omega integration -} -``` -Inside `update_internal` (`Sequence.cs:351-443`), for every whole frame boundary crossed this tick: -```csharp -if (currAnim.Anim.PosFrames != null) - frame = AFrame.Combine(frame, currAnim.get_pos_frame(lastFrame)); // per-frame authored translation/rotation delta (baked into the .anim asset — "embedded per-frame deltas") -if (Math.Abs(framerate) > PhysicsGlobals.EPSILON) - apply_physics(frame, 1.0f / framerate, timeElapsed); // Sequence.Velocity * quantum + Omega rotate (the "velocity from interpreted motion state" contribution) -``` -So **within a single `Sequence.Update` call, BOTH sources are combined**: (a) any authored `PosFrames` deltas baked directly into the `.anim` asset for that specific frame (this is what CLAUDE.md's "per-frame deltas embedded in the animation" refers to), combined via `AFrame.Combine`, AND (b) `Sequence.apply_physics` (`Sequence.cs:221-230`) which does `frame.Origin += Velocity * quantum; frame.Rotate(Omega * quantum);` where `Velocity`/`Omega` are the CURRENT node's `MotionData.Velocity`/`.Omega` (set by `add_motion`'s `sequence.SetVelocity(motionData.Velocity * speed)` — i.e. this IS "velocity from the interpreted motion state", since `speed` traces back to `InterpretedState.ForwardSpeed`/`get_state_velocity()`-derived values). **Which dominates depends entirely on the specific `.anim` asset** — most locomotion cycles (walk/run) in retail author their forward displacement via `Velocity` (constant per-cycle linear speed) rather than per-frame `PosFrames`, while special motions (jump arcs, some emotes) can carry PosFrames deltas. ACE's code treats them uniformly and additively — there's no priority/exclusivity logic; whichever the `.anim` DAT asset defines gets applied. - -**Step 3 is critical**: `offsetFrame.Origin *= 0.0f` when NOT `OnWalkable` — **while airborne, animation-driven translation is completely discarded**; only `PositionManager.AdjustOffset` (network correction) and gravity/velocity integration elsewhere (`UpdatePhysicsInternal`, `PhysicsObj.cs:1832-1860`, a SEPARATE code path used for free-flight/projectile-style objects, not locomotion) contribute. This means grounded creature locomotion is animation-driven displacement (walk/run cycle velocity), NOT physics-integrator velocity — a key retail-faithfulness point: **ground movement speed is whatever the `.anim` asset's baked `Velocity` says for that cycle at that `speed` multiplier, not a free-form physics velocity vector.** - -**Step 4 — `PositionManager.AdjustOffset`** (`PositionManager.cs:20-28`) chains `InterpolationManager.adjust_offset` + `StickyManager.adjust_offset` + `ConstraintManager.adjust_offset` onto the SAME `offsetFrame` that Step 1 already populated — i.e. network position-correction is an ADDITIVE nudge layered on top of animation-driven movement in the same tick, not a replacement. See Q6 for its exact behavior. - ---- - -## Q6 — CORRECTION: how do inbound position updates fix accumulated error? - -Entry point for "another entity's authoritative position arrived": `PhysicsObj.MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity)` (`PhysicsObj.cs:905-934`): -```csharp -public bool MoveOrTeleport(Position pos, int timestamp, bool contact, Vector3 velocity) -{ - ... staleness/sequence-number check against UpdateTimes[4] ... - if (CurCell == null || newer_event(PhysicsTimeStamp.Teleport, timestamp)) - { - teleport_hook(true); - SetPosition(new SetPosition(pos, SetPositionFlags.Teleport | SetPositionFlags.DontCreateCells)); // HARD SNAP — no blending at all - return true; - } - else - { - if (!contact) return false; - if (PlayerDistance < 96.0f) - InterpolateTo(pos, IsMovingTo()); // <-- SOFT correction path - else - { - PositionManager?.StopInterpolating(); - SetPositionSimple(pos, true); // far away: just snap, no interpolation needed (nobody's looking closely) - } - } - return true; -} -``` -`96.0f` (units, ~yards) is the visibility-distance cutoff deciding hard-snap vs. soft-interpolate. - -`PositionManager.InterpolateTo` (`PositionManager.cs:55-61`) lazily creates an `InterpolationManager` and calls `InterpolationManager.InterpolateTo(position, keepHeading)`. - -### `InterpolationManager.InterpolateTo` (`InterpolationManager.cs:36-84`) -```csharp -var dest = (queue has a pending PositionType tail node) ? that node's position : PhysicsObj.Position; -var dist = dest.Distance(position); - -if (PhysicsObj.GetAutonomyBlipDistance() >= dist) // "small enough error to smooth, not blip" -{ - if (PhysicsObj.Position.Distance(position) > 0.05f) // still meaningfully off from CURRENT actual position - { - // dedupe: drop trailing queued nodes that are already close (<0.05) to the new target - while (queue.Count > 0 && last-is-PositionType-and-within-0.05) queue.RemoveLast(); - while (queue.Count >= 20) queue.RemoveFirst(); // cap queue depth at 20 - enqueue new PositionType node (optionally overriding heading to current heading if keepHeading) - } - else - { - if (!keepHeading) PhysicsObj.set_heading(position.Frame.get_heading(), true); - StopInterpolating(); // close enough already — snap heading only, clear queue - } -} -else -{ - // error too large to smooth ("blip") — enqueue anyway but arm NodeFailCounter = 4 - enqueue new PositionType node; NodeFailCounter = 4; -} -``` - -### Per-tick blending: `InterpolationManager.adjust_offset(AFrame frame, double quantum)` (`InterpolationManager.cs:199-258`) — called from `PositionManager.AdjustOffset` every `UpdatePositionInternal` tick - -```csharp -if (queue.Count == 0 || PhysicsObj == null || !TransientState.Contact) return; // only corrects while grounded -var first = queue.First.Value; -if (first.Type is Jump or Velocity) return; // those are handled in UseTime(), not here - -var dist = PhysicsObj.Position.Distance(first.Position); -if (dist < 0.05f) { NodeCompleted(true); return; } // SNAP-DONE THRESHOLD: 0.05 units - -var maxSpeed = minterp.get_adjusted_max_speed() * 2.0f; // (UseAdjustedSpeed==true by default) — 2x the entity's normal run speed -if (maxSpeed < EPSILON) maxSpeed = MaxInterpolatedVelocity; // fallback constant 7.5f - -var delta = OriginalDistance - dist; -ProgressQuantum += quantum; FrameCounter++; - -if (FrameCounter < 5 || (sticky-object-attached || (delta > EPSILON && delta/ProgressQuantum/maxSpeed >= 0.3f))) -{ - // "making good enough progress" — keep smoothing - if (FrameCounter >= 5) { FrameCounter = 0; ProgressQuantum = 0; OriginalDistance = dist; } // reset the progress-rate tracking window every 5 ticks - var offset = first.Position.Subtract(PhysicsObj.Position); - var maxQuantum = maxSpeed * quantum; // this tick's max allowed correction distance - var distance = offset.Origin.Length(); - if (distance <= 0.05f) NodeCompleted(true); - if (distance > maxQuantum) offset.Origin *= maxQuantum / distance; // CLAMP correction speed to maxSpeed*2 - if (KeepHeading) offset.set_heading(0.0f); - frame = offset; // <-- this IS the offsetFrame passed up into UpdatePositionInternal — added on top of animation displacement - return; -} -NodeFailCounter++; -NodeCompleted(false); // giving up smoothing this node — falls through to UseTime()'s blip/snap logic next tick -``` - -Constants (verbatim, `InterpolationManager.cs:18-19`): -``` -LargeDistance = 999999.0f -MaxInterpolatedVelocity = 7.5f -UseAdjustedSpeed = true (static, defaults on) -``` -Snap/completion threshold: **`dist < 0.05f`** appears three times (queue-dedupe threshold at enqueue time, per-tick node-completion check, and the "close enough, just correct heading and stop" branch in `InterpolateTo`) — this is retail's canonical "close enough, stop interpolating" epsilon for position correction, distinct from `PhysicsGlobals.EPSILON` (0.0002f) which is the general floating-point/animation epsilon. - -Progress-rate abandonment rule: if less than **30%** of the max-possible correction speed's worth of distance was closed over the last 5-tick window (`delta / ProgressQuantum / maxSpeed >= 0.3f` failing), `NodeFailCounter` increments; once `NodeFailCounter > 3` the `UseTime()` method (`InterpolationManager.cs:142-197`) takes over and does a **hard `SetPositionSimple` snap** (with fallback velocity-carry logic scanning back through the queue for the last `PositionType` node) instead of continuing to smooth — this is the "blip" behavior (a visible teleport-style correction) that happens when an entity has drifted too far/too fast for smooth catch-up. - -`NodeCompleted(bool success)` (`InterpolationManager.cs:91-126`) pops the front queue node, resets `FrameCounter`/`ProgressQuantum`, and recomputes `OriginalDistance` against the NEXT queued node (or `LargeDistance` if the queue is now empty) — this baseline is used by the 30%-progress-rate check on the following node. - -**No InterpolationNode "Velocity" playback happens inside `adjust_offset`** — `VelocityType`/`JumpType` queue nodes are explicitly skipped there (`InterpolationManager.cs:205`) and are instead consumed synchronously and immediately inside `UseTime()` (`InterpolationManager.cs:185-196`, `case VelocityType: PhysicsObj.set_velocity(first.Velocity, true); NodeCompleted(true); break;`), i.e. velocity-hint queue entries bypass smoothing entirely and are applied as an immediate physics-velocity set. - ---- - -## Q7 — STOP: motion → Ready/stand - -### Two distinct stop mechanisms in `MotionInterp` - -**(a) `StopInterpretedMotion(uint motion, MovementParameters)`** (`MotionInterp.cs:329-365`) — stop ONE specific ongoing motion (e.g. release the forward key while still turning): -```csharp -if (contact_allows_move(motion)) -{ - if (StandingLongJump && motion in {WalkForward, RunForward, SideStepRight}) - InterpretedState.RemoveMotion(motion); // charging-jump special case: state only, no anim change - else - { - result = PhysicsObj.StopInterpretedMotion(motion, movementParams); // -> PartArray -> MotionTableManager.PerformMovement(StopInterpretedCommand) - if (result == WeenieError.None) - { - add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None); // <-- queues a "Ready" MotionNode, NOT the stopped motion's id - if (movementParams.ModifyInterpretedState) InterpretedState.RemoveMotion(motion); - } - } -} -else { if (ModifyInterpretedState) InterpretedState.RemoveMotion(motion); } // airborne: state only - -if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations(); -``` -`MotionTableManager.PerformMovement`'s `StopInterpretedCommand` case (`MotionTableManager.cs:130-135`): -```csharp -if (!Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State, seq, ref counter)) return NoMtableData; -add_to_queue((uint)MotionCommand.Ready, counter, seq); -``` -`MotionTable.StopObjectMotion` → `StopSequenceMotion` (`MotionTable.cs:315-356`): -```csharp -if ((motion & CommandMask.SubState) != 0 && currState.Substate == motion) -{ - uint style; StyleDefaults.TryGetValue(currState.Style, out style); - GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims, true); // <-- re-enters the SAME cycle-swap machinery as Q2/Q4, targeting the STYLE's default substate (e.g. NonCombat's Ready) - return true; -} -if ((motion & CommandMask.Modifier) == 0) return false; -// else: find + subtract the matching Modifier motion's physics contribution and remove it from currState.Modifiers -``` -**So stopping Walk/Run is implemented as "transition to the style's DEFAULT substate" — i.e. the exact same link-animation-append machinery from Q2/Q4, just targeting `Ready`/idle instead of another locomotion cycle.** This means a stop-from-run gets its own dedicated STOP/idle transition link animation (looked up via the same `get_link(style, fromSubstate, fromSpeed, StyleDefaults[style], 1.0f)` call inside the re-entered `GetObjectSequence`), not an instant cut to a standing pose. - -**(b) `StopCompletely()`** (`MotionInterp.cs:301-327`) — full stop, e.g. server-forced idle: -```csharp -PhysicsObj.cancel_moveto(); -var jump = motion_allows_jump(InterpretedState.ForwardCommand); - -RawState.ForwardCommand = Ready; RawState.ForwardSpeed = 1.0f; RawState.SideStepCommand = 0; RawState.TurnCommand = 0; -InterpretedState.ForwardCommand = Ready; InterpretedState.ForwardSpeed = 1.0f; InterpretedState.SideStepCommand = 0; InterpretedState.TurnCommand = 0; - -PhysicsObj.StopCompletely_Internal(); // -> PartArray.StopCompletelyInternal -> MotionTableManager.PerformMovement(StopCompletely) -add_to_queue(0, (uint)MotionCommand.Ready, jump); -if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations(); -``` -`MotionTable.StopObjectCompletely` (`MotionTable.cs:293-313`): -```csharp -// first stop every active Modifier motion (in stack order, e.g. crouch-while-moving compound states) -while (currState.Modifiers.First != null) - StopSequenceMotion(modifier.ID, modifier.SpeedMod, currState, sequence, ref numAnims); -// then stop the current Substate (Walk/Run/etc.) itself -StopSequenceMotion(currState.Substate, currState.SubstateMod, currState, sequence, ref numAnims); -``` - -### Velocity zeroing -`Velocity`/`Omega` on the `Sequence` are NOT explicitly zeroed by the stop call itself — they get overwritten naturally the next time `add_motion` runs during the `GetObjectSequence(style, ..., true)` re-entry inside `StopSequenceMotion` (line 327): `sequence.SetVelocity(motionData.Velocity * speed)` where `motionData` is now the Ready/idle cycle's `MotionData` (`MotionTable.cs:362`, inside `add_motion`), and Ready/idle cycles are authored with zero linear `Velocity` in the DAT motion table (not verified directly here — inferred from the fact that idle poses don't translate the character; flagged as a place to cross-check against `docs/research/named-retail/` if exactness matters). Additionally, `sequence.clear_physics()` (`Sequence.cs:256-260`, sets `Velocity = Omega = Vector3.Zero`) is called unconditionally at the TOP of every `GetObjectSequence` SubState-branch invocation (`MotionTable.cs:152` inside the branch reached from `StopSequenceMotion`'s re-entry) — **so `Sequence.Velocity`/`Omega` ARE explicitly zeroed at the moment of transition, then immediately re-set by the subsequent `add_motion` calls for the link+Ready-cycle**, meaning during the STOP LINK ANIMATION itself, whatever `Velocity`/`Omega` that specific link clip's `MotionData` carries is what plays (some stop/skid animations may carry a decelerating velocity baked in — this is exactly the "residual sliding prevention" mechanism, see below). - -### Residual-slide prevention -There is no separate "clamp velocity to zero over N frames" logic in this code — the retail approach (as ported here) is: (1) the stop transition ALWAYS goes through `get_link(...)` to a dedicated stop/deceleration animation clip whose `MotionData.Velocity` is authored to taper naturally to zero by its final frame (asset-level responsibility, not code-level), and (2) once the sequence reaches the idle/Ready cyclic node, that cycle's `MotionData.Velocity` should be `Vector3.Zero` so continued looping produces zero displacement per Q5's `apply_physics(frame, quantum, quantum)` math. Physical/gravity velocity (`PhysicsObj.Velocity`, used only by the free-flight `UpdatePhysicsInternal` path, `PhysicsObj.cs:1832-1860`) is separately damped via `calc_friction(quantum, velocity_mag2)` (not read in detail; referenced at `PhysicsObj.cs:1849`) plus a hard clamp: -```csharp -if (velocity_mag2 - PhysicsGlobals.SmallVelocitySquared < PhysicsGlobals.EPSILON) Velocity = Vector3.Zero; -``` -`SmallVelocity = 0.25f` (`PhysicsGlobals.cs:34`), `SmallVelocitySquared = 0.0625f` (line 36), `EPSILON = 0.0002f` (line 9) — i.e. once ground-friction has decayed `PhysicsObj.Velocity` to within `sqrt(0.0625 + 0.0002) ≈ 0.2504` units/sec of zero (this branch is really checking `velocity_mag2 <= SmallVelocitySquared + EPSILON`), it's hard-snapped to exactly zero. This is the free-body-motion velocity floor, separate from (but complementary to) the animation-cycle-driven Q5 displacement mechanism that governs actual grounded locomotion stop. - -### `RemoveLinkAnimations` on cell-exit -Both `StopInterpretedMotion` and `StopCompletely` end with `if (PhysicsObj.CurCell == null) PhysicsObj.RemoveLinkAnimations();` (`PhysicsObj.cs:992-996` → `PartArray.HandleEnterWorld` → `MotionTableManager.HandleEnterWorld` (`MotionTableManager.cs:103-108`) → `sequence.remove_all_link_animations()` (`Sequence.cs:289-301`) + drains `PendingAnimations` via repeated `AnimationDone(false)` calls). `remove_all_link_animations` differs from `remove_cyclic_anims` (Q4) — it strips every node BEFORE `FirstCyclic` (i.e. the non-looping link/transition segment), snapping `CurrAnim` straight to `FirstCyclic` (the cyclic/looping node) if the currently-playing node was one of the removed link nodes. This is the "an object was pulled out of the world mid-transition — skip straight to the loop, don't leave a dangling half-played link clip" cleanup, and per the code it applies specifically when `CurCell == null`, i.e. anytime the object is not actually placed in the world (in transit between cells, being carried, etc.) — matching the general "no link animations while not resident in a cell" rule already seen at the end of `DoInterpretedMotion`/`StopInterpretedMotion` (Q1/Q3). diff --git a/docs/research/2026-07-02-inbound-motion-maps/map-research-claims.md b/docs/research/2026-07-02-inbound-motion-maps/map-research-claims.md deleted file mode 100644 index ac968df3..00000000 --- a/docs/research/2026-07-02-inbound-motion-maps/map-research-claims.md +++ /dev/null @@ -1,92 +0,0 @@ -# Claim map: inbound motion/animation research vs current code - -Date: 2026-07-02. Repo (worktree): `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad` -(branch `claude/vigorous-joliot-f0c3ad`, HEAD `b60b9b4a`). - -**Path note:** `docs/research/2026-06-04-animation-sequencer-deep-dive.md` does NOT -exist in this worktree or in git history at all (it is an *untracked* file only -in the main checkout `C:/Users/erikn/source/repos/acdream/docs/research/...`). -It was never committed. Read directly from that absolute path for this audit. -The other two docs (`2026-06-26-movement-animation-retail-parity-audit.md`, -`2026-07-01-d6-motion-interp-pseudocode.md`) exist and are committed in this -worktree. - ---- - -## Doc 1: 2026-06-04-animation-sequencer-deep-dive.md (8 ranked divergences) - -| # | Claim (doc anchor) | Current-code check | Status | Evidence (file:line) | -|---|---|---|---|---| -| 1 | Missing `pending_motions`/`MotionDone` chain (MotionInterp, HIGH). `add_to_queue` + `CheckForCompletedMotions` unimplemented. Affects multi-step action sequences, combo attacks, `UpdateMotion Commands[]` action chains. (doc anchor: "Divergences ranked" #1, L1242-1247) | Grepped `MotionInterpreter.cs`/`AnimationSequencer.cs` for `pending_motions`/`MotionDone`/`CheckForCompletedMotions`/`add_to_queue` — only comment references (`FUN_00510900 — CheckForCompletedMotions (animation flush, not simulated here)`, `FUN_00528790(…) — add_to_queue`). No queue data structure, no `MotionDone` event. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:399,412,558` (comments only, no impl) | -| 2 | `contact_allows_move` level mismatch (MotionInterp, HIGH). acdream blocks Fallen/Dead/Crouch-range inside `contact_allows_move` itself; retail does this at `DoMotion` level with distinct error codes. (doc anchor: "Divergences ranked" #2, L1249-1253) | Read `contact_allows_move` body: still explicitly checks `fwd == MotionCommand.Fallen \|\| fwd == MotionCommand.Dead` and the crouch/sit/sleep range, returning `false` (blocked) directly from this function — exactly the divergence described. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:1081-1107` (see lines 1092-1099 for the Fallen/Dead/Crouch block) | -| 3 | `re_modify` not implemented after cycle rebuild (CSequence, MEDIUM). Stacked Modifier motions (combat stance overlays) lose velocity/omega contribution on cycle switch. (doc anchor: "Divergences ranked" #3, L1255-1260) | Grepped `AnimationSequencer.cs` for `re_modify`/`ReModify`/`ModifierList`/`_activeModifiers` — zero hits. | **STILL-TRUE** | `src/AcDream.Core/Physics/AnimationSequencer.cs` (no match found for the term at all) | -| 4 | `TransparentHook` fires as instant snap vs smooth lerp (anim-hooks, MEDIUM). No `FPHook` time-interpolation equivalent. (doc anchor: "Divergences ranked" #4, L1262-1266) | Not directly re-verified this session (out of scope for walk/run + stop-slide focus — this is a fade/translucency concern, not motion/position). | **COULD-NOT-VERIFY** (not checked; low relevance to task's (a)/(b)/(c) scope beyond "(c) inbound animation machinery generally") | n/a | -| 5 | `direction_` default `0xFFFFFFFE` vs `AnimationHookDir.Both` (anim-hooks, LOW). Dat-baked hooks with missing/zero direction may fire when retail would never fire them. (doc anchor: "Divergences ranked" #5, L1268-1272) | Not re-verified this session — orthogonal to motion/position, low priority. | **COULD-NOT-VERIFY** | n/a | -| 6 | Legacy entity path has no hook dispatch (anim-hooks, LOW-MEDIUM), at old `GameWindow.cs:8563-8573`. (doc anchor: "Divergences ranked" #6, L1274-1277) | Not re-verified — line numbers are 2026-06-04-vintage and GameWindow.cs has grown/shifted substantially (now 13,759 lines vs whatever it was then); the cited range is very likely stale. | **STALE-ANCHOR** (line numbers unverified/likely shifted; concept not re-checked) | n/a | -| 7 | `enter_default_state` not called on spawn (MotionInterp, LOW). No `InitializeMotionTables`/pending_motions Ready-node seeding at spawn. (doc anchor: "Divergences ranked" #7, L1279-1282) | Grepped `MotionInterpreter.cs` for `enter_default_state`/`EnterDefaultState`/`InitializeMotionTables` — zero hits. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs` (no match) | -| 8 | `StandingLongJump` path not called per-tick (MotionInterp, LOW). The standing-long-jump pre-jump pose sub-state exists as a flag but `apply_interpreted_movement`'s per-tick branch (Ready + StopSideStep) is not wired. (doc anchor: "Divergences ranked" #8, L1284-1287) | `StandingLongJump` field exists and is set/cleared in a few places (`MotionInterpreter.cs:328,941,1119,1146`) but no per-tick `apply_interpreted_movement`-style dispatch was found calling `DoInterpretedMotion(Ready)` + `StopInterpretedMotion(SideStepRight)` from the flag. Also confirmed D3 in doc 2 (MoveToState longjump bit) is now WIRED on the *outbound* wire side (`standingLongjump` param exists in `MoveToState.Build`), but that's the wire byte, not the per-tick animation branch this claim is about — those are different things. | **STILL-TRUE** (the per-tick animation branch is still not called; note the wire-side `standingLongjump` param now exists as an input but nothing sets it to true from a live per-tick standing-longjump detection) | `src/AcDream.Core/Physics/MotionInterpreter.cs:328,941,1119,1146` (flag only, no per-tick apply path); cf. `src/AcDream.Core.Net/Messages/MoveToState.cs:56-70` (wire param exists but is a pass-through, always `false` by default) | -| 9 (supporting) | Remote-entity path dispatches via direct `SetCycle` call from `GameWindow.OnLiveMotionUpdated`, NOT via `move_to_interpreted_state` (executive summary + divergence #5/#9 in the MotionInterp section, L303-317). | Confirmed: `move_to_interpreted_state`/`MoveToInterpretedState` still does not exist anywhere except one comment reference. `OnLiveMotionUpdated` still directly manipulates `ae.Sequencer` (`SetCycle`) and does a raw bulk-copy into `remoteMot.Motion.InterpretedState.ForwardCommand/ForwardSpeed` — bypassing `adjust_motion` for remotes entirely. This is THE central mechanism relevant to the walk<->run-lag symptom: the D6-ported `adjust_motion`/`apply_raw_movement`/`get_state_velocity` triad is **local-player-only** (see doc-3 cross-check below). | **STILL-TRUE** (confirmed, and directly load-bearing for the task's (a)/(c) focus) | `src/AcDream.App/Rendering/GameWindow.cs:4517` (comment only ref to move_to_interpreted_state); `GameWindow.cs:4590,4598` (direct bulk-copy into InterpretedState, bypassing adjust_motion); `GameWindow.cs:4327-4492` (SetCycle-direct dispatch) | -| 10 (supporting) | acdream's remote velocity path bypasses `apply_current_movement` gating differences: acdream calls `PhysicsObj.OnWalkable` gate directly rather than the full retail contact/gravity flow (MotionInterp section divergence #3, L305). | `apply_current_movement` (used by BOTH local and remote via `rm.Motion.apply_current_movement` calls) still gates on `PhysicsObj.OnWalkable` directly and calls `get_state_velocity()` — same shape as described. | **STILL-TRUE** | `src/AcDream.Core/Physics/MotionInterpreter.cs:890-910`; called for remotes at `GameWindow.cs:10042,10074` | - ---- - -## Doc 2: 2026-06-26-movement-animation-retail-parity-audit.md (D1-D12) - -| # | Claim (doc anchor) | Current-code check | Status | Evidence (file:line) | -|---|---|---|---|---| -| D1 | MoveToState raw flags not retail — presence-based instead of default-difference packing; overs-sends default values. (doc L118-130) | `MoveToState.cs` doc header now explicitly says "default-difference flags dword + conditional fields + actions" and `RawMotionState.Default` exists with retail defaults (`CurrentHoldKey=None`, `ForwardCommand=0x41000003`, `ForwardSpeed=1.0`, etc. matching doc's retail defaults table L60-72). Shipped as part of L.2b (`78e163a4`, 2026-06-30). | **CLOSED-SINCE** | `src/AcDream.Core.Net/Messages/MoveToState.cs:22`; `src/AcDream.Core/Physics/RawMotionState.cs:44-58` | -| D2 | RawMotionState action-list and style packing incomplete — no full action-list packing, current-style scenarios. (doc L132-136) | `RawMotionState` now has `Actions` (IReadOnlyList) with a doc comment describing retail's `num_actions` bits 11-15 packing. Appears substantially addressed as part of L.2b, though full conformance vs the bitfield layout (11 one-bit flags + num_actions:5) was not independently re-verified byte-for-byte this session. | **CLOSED-SINCE** (structure now present; exact bit-packing not re-verified) | `src/AcDream.Core/Physics/RawMotionState.cs:57-63` | -| D3 | MoveToState longjump bit not modeled — only contact 0/1 passed, `standing_longjump` not wired as named state. (doc L138-147) | `MoveToState.Build` now has a `standingLongjump` parameter and packs `trailing = (byte)((standingLongjump ? 0x02 : 0) \| (contact ? 0x01 : 0))` — matches doc's described retail byte layout. NOTE: the parameter is wired as an INPUT but this session did not verify any call site sets it to `true` from a live per-tick standing-longjump detection (see doc-1 item #8 above — the per-tick animation branch driving this flag is still missing). So the WIRE FORMAT is fixed; whether it's ever populated correctly is a separate, still-open question. | **CLOSED-SINCE** (wire format only; upstream detection still absent) | `src/AcDream.Core.Net/Messages/MoveToState.cs:56-70,100-101` | -| D4 | JumpAction packet layout retail-incompatible — missing full Position, extra objectGuid/spellId fields, wrong order. (doc L149-161) | `JumpAction.cs` doc header now cites `Position::Pack (0x005a9640)` and explicitly notes the fix: "the pre-slice code had it backwards (two spurious trailing zero u32s, no Position at all)" — confirms this was found and fixed as part of L.2b. | **CLOSED-SINCE** | `src/AcDream.Core.Net/Messages/JumpAction.cs:21-31` | -| D5 | Position heartbeat close but not fully proven — `NotePositionSent` stamps all three (time/position/contact-plane) on BOTH MTS and AP paths, but retail's `SendMovementEvent` (MTS) only stamps `last_sent_position_time`; only `SendPositionEvent` (AP) stamps all three. (doc L163-169) | Confirmed STILL live and explicitly re-audited: a 2026-06-30 dated comment at the MTS send site says verbatim "this is a real, audit-confirmed divergence from retail... left UNCHANGED; reported to the lead engineer instead of fixed here." This is the most concrete, freshest confirmation in the whole audit — it was re-checked via Ghidra decompile-by-address and deliberately NOT fixed. | **STILL-TRUE** (explicitly re-confirmed and deliberately deferred, not a stale claim) | `src/AcDream.App/Rendering/GameWindow.cs:8320-8339` | -| D6 | MotionInterpreter lacks canonical retail raw→interpreted normalization (`adjust_motion`, `apply_run_to_command`, `apply_raw_movement` not ported); SideStepLeft/WalkBackward produce zero velocity; jump lateral velocity hand-patched. (doc L171-182) | **This is exactly what Doc 3 (D6 pseudocode doc) documents as SHIPPED** for the local player. Confirmed present: `adjust_motion` (`MotionInterpreter.cs:741`), `apply_run_to_command` (`:805`), `apply_raw_movement` (`:848`), `get_state_velocity` (`:639`) all exist and are called from `PlayerMovementController.cs:909,974,1014`. | **CLOSED-SINCE** (for LOCAL player only — see D7 below, this does NOT extend to remotes) | `src/AcDream.Core/Physics/MotionInterpreter.cs:639,741,805,848`; `src/AcDream.App/Input/PlayerMovementController.cs:880-1014` | -| D7 | Animation application split from retail motion flow — `AnimationSequencer` has useful pieces but `MotionInterpreter.apply_current_movement` is velocity-oriented and doesn't drive animation state through retail's order (style/forward/sidestep/turn/actions sequencing). (doc L184-188) | **THE central claim for the task's (a)/(c) focus.** Confirmed still true and now MORE precisely characterized: for remotes, `OnLiveMotionUpdated` bulk-copies `ForwardCommand`/`ForwardSpeed` directly into `InterpretedState` (bypassing `adjust_motion`), AND separately calls `ae.Sequencer.SetCycle(...)` directly for the visual cycle — two parallel, only loosely-coupled paths (one drives position/velocity via `apply_current_movement`→`get_state_velocity`, the other drives the visual cycle via `SetCycle`). Neither goes through `apply_interpreted_movement`'s full style/forward/sidestep/turn sequencing. Additionally there is a SEPARATE `ApplyServerControlledVelocityCycle`/`ApplyPlayerLocomotionRefinement` mechanism (UP-velocity-derived, with a 200ms UM grace window + 4.5/5.5 m/s hysteresis bucketing) that ALSO writes `SetCycle` for the local-player-observed-remotely case — this is a THIRD parallel path, not documented in either research doc, that is very likely a major contributor to "walk<->run reacts too slowly" (its grace window + hysteresis band means a walk<->run toggle needs velocity to cross a threshold via UpdatePosition samples before the visual cycle re-buckets, since a UM may not arrive for HoldKey-only toggles per the code's own comments). | **STILL-TRUE** (confirmed and refined — root mechanism now identified precisely, more detailed than the original doc knew) | `src/AcDream.App/Rendering/GameWindow.cs:4590,4598` (bulk-copy bypass); `GameWindow.cs:5112-5176` (`ApplyServerControlledVelocityCycle`); `GameWindow.cs:5178-5300+` (`ApplyPlayerLocomotionRefinement`, grace/hysteresis constants at `:5095,5104,5110`) | -| D8 | Force-walk and MoveTo are approximations — `ServerControlledLocomotion`, `RemoteMoveToDriver`, `PlayerMovementController.BeginServerAutoWalk` approximate steering/cycle selection vs retail's full `MoveToManager` queue (pre-turn, move, aux turn, final heading, sticky targeting, progress failure, `MovementParameters::get_command`). (doc L190-196) | `ServerControlledLocomotion.cs` is still only 87 lines (a `PlanMoveToStart`/`PlanFromVelocity` helper, not a queued state machine). No pre-turn/aux-turn/sticky-target/progress-failure machinery found. | **STILL-TRUE** | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines total); `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340 lines, still a simplified steering helper) | -| D9 | Inbound movement types 8 (TurnToObject) and 9 (TurnToHeading) dropped — `UpdateMotion` handles interpreted state and MoveTo 6/7 but not 8/9. (doc L198-207) | Confirmed: `UpdateMotion.TryParse` only branches on `movementType == 0` and `movementType is 6 or 7`; no `8`/`9` case anywhere. Grep for `TurnToObject`/`TurnToHeading` across `UpdateMotion.cs` and `GameWindow.cs` returns zero hits. The 2026-07-02 handoff doc explicitly reconfirms this live: "CONFIRMED live: mt=0x09 arrives and just SetCycle(Ready) instead of turning." | **STILL-TRUE** (independently re-confirmed via grep + corroborated by a live capture cited in the handoff doc) | `src/AcDream.Core.Net/Messages/UpdateMotion.cs:136-238` (no case 8/9) | -| D10 | Spawn-time movement state weaker than live — `CreateObject` detects movement types/flags but doesn't preserve full MoveTo target/origin/threshold data the way `UpdateMotion` does. (doc L209-213) | Confirmed with exact detail: `CreateObject.TryParseMoveToPayload` (private, 3-out-param overload: `movementParameters`, `speed`, `runRate`) SKIPS Origin/distanceToObject/minDistance/failDistance/walkRunThreshold/desiredHeading via `pos +=` advances with no output — so `CreateObject`'s returned `ServerMotionState.MoveToPath` is always `null`. By contrast `UpdateMotion.TryParseMoveToPayload` (a DIFFERENT, more complete overload) DOES populate `out CreateObject.MoveToPathData? path` with Origin/thresholds. Two parsers of the same wire shape, one materially weaker. | **STILL-TRUE** (confirmed with precise mechanism: two divergent parser overloads for the same MoveTo payload) | `src/AcDream.Core.Net/Messages/CreateObject.cs:1194-1226` (weak 3-field overload, spawn path) vs `src/AcDream.Core.Net/Messages/UpdateMotion.cs:255-331` (full path-populating overload, live-update path) | -| D11 | Sequence/autonomy data parsed then discarded — retail carries movement sequence, server-control sequence, autonomous state, motion flags, position sequence into movement application; acdream exposes only a subset. (doc L215-219) | Confirmed: `UpdateMotion.TryParse` still does a blind `pos += 6` skip over `movementSequence`(u16) + `serverControlSequence`(u16) + `isAutonomous`(u8) + align — none of these three fields are captured into the `Parsed` record or `ServerMotionState`. (Note: `WorldSession.cs` DOES track `_serverControlSequence`/`_instanceSequence`/etc., but those are the OUTBOUND sequence counters sourced from `UpdatePosition`, not the INBOUND per-entity `UpdateMotion` fields this claim is about — different mechanism entirely, do not conflate.) `_motionFlags` (byte) is read into a local var and never used beyond that line either. | **STILL-TRUE** (confirmed, and clarified that the WorldSession sequence counters some might assume closes this are unrelated) | `src/AcDream.Core.Net/Messages/UpdateMotion.cs:89-106` (skip), `:111` (`_motionFlags` read, unused past declaration) | -| D12 | Jump/falling/contact gates simplified — retail allows specific movement while falling/dead, has separate jump checks for posture/stamina/constraints/pending-motion/contact/leave-hit-ground reapplication; acdream blocks or simplifies several. (doc L221-225) | Not exhaustively re-verified line-by-line this session (large surface). Spot-checked `contact_allows_move`/`motion_allows_jump`-equivalent: the Fallen/Dead/Crouch block still lives inside `contact_allows_move` (see doc-1 item #2), which is itself evidence the gates remain simplified/misplaced relative to retail's `DoMotion`-level error codes. No `pending_motions`-driven jump eligibility check found (consistent with item #1). | **STILL-TRUE** (partially re-verified via the contact_allows_move overlap with doc-1 #2; full jump/posture/stamina gate audit not redone) | `src/AcDream.Core/Physics/MotionInterpreter.cs:1081-1107` (same evidence as doc-1 #2) | - ---- - -## Doc 3: 2026-07-01-d6-motion-interp-pseudocode.md — cross-check of its own claims - -| Claim | Current-code check | Status | -|---|---|---| -| "D6 ships `adjust_motion`/`apply_run_to_command`/`apply_raw_movement`/`get_state_velocity` in `MotionInterpreter.cs`, unifying LOCAL player velocity+turn+jump+wire onto one `RawMotionState` at `forward_speed=1.0`" | Confirmed via git log (`0f099bb6` D6.2a, `d34721fa` D6.2b) and via direct code read — all four functions exist and are wired into `PlayerMovementController.cs` only. | **STILL-TRUE (as scoped)** | -| "Out of scope for D6: RawMotionState unification — retail uses ONE raw state for both the wire and the velocity pipeline; D6 keeps the L.2b wire construction separate... unifying is a later slice" (doc L153-156) | This scoping statement is itself accurate and still holds: the wire-side `RawMotionState`/`MoveToState.Build` path and the local velocity-side `apply_raw_movement` input are DIFFERENT `RawMotionState` construction sites (confirmed by design decision #1 in the doc itself, "ONE `RawMotionState`... is built from `MovementInput`" — but this unification was the D6.2 GOAL for local-only; there is still no unification with the REMOTE/inbound path at all). | **STILL-TRUE (scoping honored, and the doc's own "later slice" is exactly the gap this task's handoff doc targets)** | -| "Out of scope for D6: turn ... acdream keeps direct-Yaw turn is replaced" / actually turn WAS ported per design decision #3 ("Turn ported to interpreted, feel unchanged... omega.Z = ±(π/2) × turn_speed... the fixed TurnRateFor direct-Yaw is replaced by the pipeline") — LOCAL only. | For remotes, `RemoteMoveToDriver.TurnRateFor` is still the mechanism (per doc's own "AP-9 stays" note) — confirmed no remote-side `adjust_motion`-sourced turn omega found; `RemoteMoveToDriver.cs` (340 lines) still exists with its own turn-rate logic, separate from `MotionInterpreter`. | **STILL-TRUE (remote turn is still the old direct-rate approximation; local turn was ported per D6 but that's a different code path)** | - ---- - -## Synthesis: what this means for the walk<->run lag + stop-slide symptom (task focus a/b) - -1. **D6 (the shipped port) does not touch the remote/inbound path at all.** Every function it - ported (`adjust_motion`, `apply_run_to_command`, `apply_raw_movement`, `get_state_velocity`) - is called exclusively from `PlayerMovementController.cs` (local player). Zero call sites - from `GameWindow.cs`'s remote-entity code (`OnLiveMotionUpdated`, `_remoteDeadReckon`, - `ApplyServerControlledVelocityCycle`) invoke these D6-ported functions. -2. **The remote path bulk-copies wire commands directly into `InterpretedState`** - (`GameWindow.cs:4590,4598`), bypassing `adjust_motion` — so backward/sidestep-left - normalization and run-rate scaling never happen for remotes the retail-faithful way. - This matches the retail `copy_movement_from` bulk-copy semantics for THAT ONE STEP, but the - surrounding `pending_motions`/queue/`move_to_interpreted_state` state machine around it - (doc-1 items #1, #9) is still entirely absent. -3. **A newly-identified (not named in either doc) mechanism likely explains "reacts too - slowly":** `ApplyPlayerLocomotionRefinement` (`GameWindow.cs:5178+`) only kicks in after a - `UmGraceSeconds = 0.2` (200ms) window since the last UM, and then applies **hysteresis** - bands (`PlayerRunPromoteSpeed = 5.5f`, `PlayerRunDemoteSpeed = 4.5f`) before re-bucketing - walk↔run from UpdatePosition-derived velocity — this is architecturally exactly the kind of - mechanism that would produce a visible lag on a walk↔run toggle, especially combined with - the code's own comment that retail may not broadcast a fresh MoveToState for HoldKey-only - toggles (Shift while W held), forcing reliance on this slower velocity-inference path. -4. **D9 (types 8/9 dropped)** means any server-driven turn command mid-locomotion is silently - turned into a `SetCycle(Ready)`, which would visibly interrupt/desync locomotion — a - plausible contributor to "compounding error... running around + turning." -5. **D11 (sequence numbers discarded)** means acdream has no way to detect/reject - out-of-order or duplicate `UpdateMotion` packets, which is a plausible contributor to the - "position errors once the entity stops" (a late/duplicate packet could re-apply a stale - state after a newer one, with no sequence check to catch it). - -None of the above is a fix recommendation — this is a raw claim/verification map only, per -the report-only investigation mode. diff --git a/docs/research/2026-07-02-inbound-motion-maps/map-retail-decomp.md b/docs/research/2026-07-02-inbound-motion-maps/map-retail-decomp.md deleted file mode 100644 index 5c59e2e1..00000000 --- a/docs/research/2026-07-02-inbound-motion-maps/map-retail-decomp.md +++ /dev/null @@ -1,969 +0,0 @@ -# Retail decomp map: INBOUND remote-entity motion pipeline - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (66 MB, Sept 2013 -EoR build, PDB-named pseudo-C). All line numbers below are LINE NUMBERS in that -file (not addresses), from a Sept-2026 checkout at -`C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad`. -Addresses (0x0051xxxx etc.) are also given per function for cross-reference with -`symbols.json` / Ghidra. - ---- - -## Q1 — INBOUND ENTRY: wire message -> motion interpreter - -Full call chain, outermost (network) to innermost (motion-table state machine): - -``` -ACSmartBox::DispatchSmartBoxEvent(NetBlob*) line 357117 (0x005595d0) - switch (opcode) { - case 0xf619: // "Movement" — the live/current movement update - SmartBox::UnpackPositionEvent(...) line 357142 - if result == NETBLOB_PROCESSED_OK: - CPhysicsObj* obj = CObjectMaint::GetObjectA(pObjMaint, guid) - CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357154 (call), def @271370 (0x00509690) - if nonzero: cmdinterp->LoseControlToServer() - - case 0xf74c: // position+movement combo, has an extra u16 seq check first - is_newer(obj->update_times[8], seq) gate line 357224 - CPhysics::SetObjectMovement(physics, obj, buf, bufSize) line 357232 - } -``` - -`CPhysics::SetObjectMovement` (2-arg overload used for 0xf619/0xf74c dispatch, -`__stdcall`, line 271370 / addr 0x00509690): -``` -int32 SetObjectMovement(CPhysics* this, CPhysicsObj* obj, buf, bufLen, - u16 seqA, u16 seqB, bool isAutonomous) -{ - isPlayer = obj->weenie_obj && obj->weenie_obj->IsThePlayer(); - - // 16-bit wraparound-aware "is this sequence number newer" compare, - // done TWICE against two independent counters obj->update_times[1] - // and obj->update_times[5]: - diff = |seqA - obj->update_times[1]| (mod 0x10000) - newer = (diff > 0x7fff) ? (seqA < old) : (old < seqA) // wraparound rule - if not newer: return 0 // STALE PACKET, DROPPED - - obj->update_times[1] = seqA - diff2 = |obj->update_times[5] - seqB| - newer2 = (diff2 > 0x7fff) ? (old < seqB) : (seqB < old) - if not newer2: return 0 // STALE, DROPPED - obj->update_times[5] = seqB - - if (!isAutonomous || !isPlayer) { // remote entity ALWAYS - // takes this branch; - // local player only - // takes it when NOT - // self-driving (server - // override / rubber-band) - obj->last_move_was_autonomous = isAutonomous - CPhysicsObj::unpack_movement(obj, &buf, bufLen) line 271423 (0x00509742) - if isPlayer: return 1 // signals caller to call LoseControlToServer - } - return 0 -} -``` - -`CPhysicsObj::unpack_movement` (line 280179, addr 0x00512040): -``` -void unpack_movement(CPhysicsObj* this, buf**, bufLen) -{ - if (this->movement_manager == null) - this->movement_manager = MovementManager::Create(this, this->weenie_obj) - MovementManager::unpack_movement(this->movement_manager, buf, bufLen) line 280202 -} -``` - -`MovementManager::unpack_movement` (line 300563, addr 0x00524440) — deserializes -the wire struct and dispatches to ONE of 10 sub-cases (`command_ids[ecx_4]` type -tag read from the first u16 in the buffer, case 0..9 via jump table @300707): - -``` -switch (type_tag) { - case 0: // InterpretedMotionState (RawMotionState command wrapper) -- THIS - // is the walk/run/turn/sidestep command path used for remote - // players AND monsters - InterpretedMotionState::UnPack(&ims, buf, bufLen) line 300606 - // optional trailing u32 = "sticky" target object id - MovementManager::move_to_interpreted_state(this, &ims) line 300618 (0x0052457c) - if sticky_id != 0: CPhysicsObj::stick_to_object(...) - motion_interpreter->standing_longjump = (type_tag & 0x200) - return 1 - - case 6: // MoveToObject - Position::UnPackOrigin + MovementParameters::UnPackNet(MoveToObject) - motion_interpreter->my_run_rate = - CPhysicsObj::MoveToObject(physics_obj, target_guid, ¶ms) line 300644 - - case 7: // MoveToPosition - similar; CPhysicsObj->my_run_rate set from wire; then - MoveToManager::MoveToPosition(...) line 300659 - - case 8: // TurnToObject - case 9: // TurnToHeading - -> MoveToManager::TurnToHeading / handled via MoveToManager -} -``` - -**For Q2-Q7 (walk<->run transition on an ALREADY-moving remote entity), case 0 -(`InterpretedMotionState::UnPack` + `move_to_interpreted_state`) is the relevant -path.** This is opcode 0xF619/0xF74C's "type 0" sub-message — same struct shape -as the client's own `RawMotionState`/interp state, containing -`current_style`, `forward_command`, `forward_speed`, `sidestep_command/speed`, -`turn_command/speed`, plus a list of pending server "actions" -(`context_id`/`action_stamp` pairs used for jump-charge/attack acknowledgement, -NOT used for normal walk/run). - -`MovementManager::move_to_interpreted_state` (line 300259, addr 0x00524170): -``` -void move_to_interpreted_state(MovementManager* this, InterpretedMotionState* ims) -{ - if (motion_interpreter == null) { - motion_interpreter = CMotionInterp::Create(physics_obj, weenie_obj) - CMotionInterp::enter_default_state(motion_interpreter) - } - CMotionInterp::move_to_interpreted_state(motion_interpreter, ims) line 300272 -} -``` - -`CMotionInterp::move_to_interpreted_state` (line 305936, addr 0x005289c0) — THE -entry point that turns a wire InterpretedMotionState into an actual motion-table -transition: -``` -int32 move_to_interpreted_state(CMotionInterp* this, InterpretedMotionState* ims) -{ - if (physics_obj == null) return 0 - this->raw_state.current_style = ims->current_style - CPhysicsObj::interrupt_current_movement(physics_obj) - bool wasJumpAllowed = CMotionInterp::motion_allows_jump(this, interpreted_state.forward_command) - InterpretedMotionState::copy_movement_from(&this->interpreted_state, ims) // <-- OVERWRITES - // forward/side/turn - // command+speed wholesale, - // line 293301 - CMotionInterp::apply_current_movement(this, /*forceReapply=*/1, /*jumpFlag*/ -(...)) line 305949 - - // then replay any queued server "actions" (jump charge etc.) whose - // action_stamp is newer than server_action_stamp — sequence-wraparound - // compare identical in shape to the SetObjectMovement 0x7fff test above - for (action in ims->actions) { - if (newer(action.stamp, this->server_action_stamp)) { - this->server_action_stamp = action.stamp - CMotionInterp::DoInterpretedMotion(this, action.motion, ¶ms) - } - } - return 1 -} -``` - -**KEY: `copy_movement_from` is a flat field-by-field OVERWRITE of the -InterpretedMotionState (forward_command, forward_speed, sidestep_*, turn_*, -current_style) — there is no "diff the old vs new command" step here.** The -actual "is this the same cycle or a new one" decision happens ONE LEVEL DOWN, -inside `CMotionTable::GetObjectSequence`, when `apply_current_movement` -> -`apply_interpreted_movement` -> `DoInterpretedMotion` is called with the new -`forward_command`/`forward_speed`. - ---- - -## Q2 — TRANSITION: walk<->run while already moving - -`CMotionInterp::apply_current_movement` (line 305838, addr 0x00528870): -``` -void apply_current_movement(CMotionInterp* this, int forceFlag, int jumpFlag) -{ - if (physics_obj == null || !initted) return - isPlayerOrNoWeenie = (weenie_obj == null) || weenie_obj->IsThePlayer() - if (isPlayerOrNoWeenie && CPhysicsObj::movement_is_autonomous(physics_obj)) - return apply_raw_movement(this, forceFlag, jumpFlag) // LOCAL player path - return apply_interpreted_movement(this, forceFlag, jumpFlag) // REMOTE entity path -} -``` -`movement_is_autonomous` just returns `physics_obj->last_move_was_autonomous` -(set by `SetObjectMovement` above — for a genuinely-remote object this flag is -always false relative to the LOCAL viewer, so remote players/monsters always -take the `apply_interpreted_movement` branch.) - -`CMotionInterp::apply_interpreted_movement` (line 305713, addr 0x00528600) — the -per-command dispatcher that turns the bookkeeping InterpretedMotionState fields -back into individual `DoInterpretedMotion` calls: -``` -void apply_interpreted_movement(CMotionInterp* this, int a, int b) -{ - if physics_obj == null: return - if interpreted_state.forward_command == RUN_FORWARD (0x44000007): - this->my_run_rate = interpreted_state.forward_speed // caches server-echoed run rate - - DoInterpretedMotion(this, interpreted_state.current_style, {}) // style (stance) first - - if (!contact_allows_move(this, interpreted_state.forward_command)) { - DoInterpretedMotion(this, MOTION_FALLING /*0x40000015*/, {}) - } else if (standing_longjump) { - DoInterpretedMotion(this, READY_STANCE /*0x41000003*/, {}) - StopInterpretedMotion(this, LONGJUMP /*0x6500000f*/, {}) - } else { - DoInterpretedMotion(this, interpreted_state.forward_command, {}) // <-- WALK/RUN COMMAND - if interpreted_state.sidestep_command == 0: - StopInterpretedMotion(this, SIDESTEP /*0x6500000f*/, {}) - else: - DoInterpretedMotion(this, interpreted_state.sidestep_command, {}) - } - - if interpreted_state.turn_command != 0: - DoInterpretedMotion(this, interpreted_state.turn_command, {}) - return // early return — no idle-stop check runs this frame - if (StopInterpretedMotion(physics_obj, TURN /*0x6500000d*/, {}) == 0) - add_to_queue(this, ctx=0, READY_STANCE, tickCount) -} -``` -So a wire "run instead of walk" update decays into exactly one -`CMotionInterp::DoInterpretedMotion(this, RUN_FORWARD, {speed = new run speed})` -call (or `WALK_FORWARD`) — a single call with the SAME semantics as the local -input path, not a special "speed-changed" fast path at this layer. - -`CMotionInterp::DoInterpretedMotion` (line 305575, addr 0x00528360): -``` -uint32 DoInterpretedMotion(CMotionInterp* this, uint32 motion, MovementParameters* p) -{ - if physics_obj == null: return 8 - if (contact_allows_move(this, motion)) { - if (standing_longjump && motion in {JUMP-ish set}) goto label_528440 (bail to - ApplyMotion-only path) - if motion == 0x40000011 /* some "cancel" motion */: - CPhysicsObj::RemoveLinkAnimations(physics_obj) // <-- flush queued link anims - result = CPhysicsObj::DoInterpretedMotion(physics_obj, motion, p) // -> CPartArray -> MotionTableManager - if result == 0: - jumpAllowed = ... - add_to_queue(this, p->context_id, motion, jumpAllowed) - if (flag bit 0x40 of context set): InterpretedMotionState::ApplyMotion(&interpreted_state, motion, p) - } else if (motion & 0x10000000) == 0: - label_528440: - if (flag bit 0x40 set): InterpretedMotionState::ApplyMotion(...) - result = 0 - else: - result = 0x24 // motion rejected (e.g. mid-air command not allowed) - - if (physics_obj != null && physics_obj->cell == 0) - CPhysicsObj::RemoveLinkAnimations(physics_obj) // detached-from-world guard - return result -} -``` -`CPhysicsObj::DoInterpretedMotion` -> `CPartArray::DoInterpretedMotion` -> packs -a `MovementStruct{type=InterpretedCommand}` and calls -`MotionTableManager::PerformMovement`, which for `InterpretedCommand` calls: - -``` -if (CMotionTable::DoObjectMotion(table, motion, &state, &sequence, speed, &outTicks)) - MotionTableManager::add_to_queue(this, motion, outTicks, sequence) // queues in - // pending_animations - // (DIFFERENT list - // from CMotionInterp's - // pending_motions!) -``` -`CMotionTable::DoObjectMotion` is a thin wrapper for -`CMotionTable::GetObjectSequence(table, motion, state, seq, speed, outTicks, /*force*/0)`. - -### `CMotionTable::GetObjectSequence` — THE cycle-swap decision (line 298636, addr 0x00522860) - -This is the true state machine that decides append-vs-replace-vs-fast-path. Given -`new_substate = motion & 0xffffff` bucketed by which high bit is set on `motion`: - -**Bit `0x40000000` set — a normal "cycle" motion (this is what WALK/RUN commands -carry, e.g. `0x44000007` RunForward, `0x45000005` WalkForward):** -``` -cycleData = cycles.lookup((style<<16) | (new_substate & 0xffffff)) -if cycleData != null && CMotionTable::is_allowed(table, new_substate, cycleData, state): - - // *** THE SAME-CYCLE FAST PATH *** - if (new_substate == state->substate // SAME logical - && same_sign(new_speed, state->substate_mod) // command (walk OR run - && CSequence::has_anims(sequence)) { // stays walk, or run stays - // run — direction unchanged) - // AND a cycle is already - // playing - change_cycle_speed(sequence, cycleData, state->substate_mod, new_speed) // rescale playback rate - subtract_motion(sequence, cycleData, state->substate_mod) // remove OLD velocity contribution - combine_motion(sequence, cycleData, new_speed) // add NEW velocity contribution - state->substate_mod = new_speed - return 1 // <-- NO new CSequence nodes appended. Same AnimSequenceNode - // keeps playing; only its playback-rate + the CSequence's - // cached velocity/omega vectors change. - } - - // *** DIFFERENT SUBSTATE (e.g. walk -> run is usually a DIFFERENT - // substate id, not same-sign-same-substate) — LINK TRANSITION PATH *** - linkAnim = CMotionTable::get_link(table, state->style, state->substate, - state->substate_mod, new_substate, new_speed) - if (linkAnim == null || same_sign(new_speed, state->substate_mod) == 0) { - // no direct link authored, OR direction reversed: route through the - // style's registered "default"/rest substate as an intermediate hop - defaultSubstate = style_defaults[state->style] - linkAnim = get_link(style, state->substate, state->substate_mod, defaultSubstate, 1.0) - linkAnim2 = get_link(style, defaultSubstate, 1.0, new_substate, new_speed) - } - - CSequence::clear_physics(sequence) // zero cached velocity/omega — see Q6/Q7 - CSequence::remove_cyclic_anims(sequence) // drop any still-looping cycle node(s) - add_motion(sequence, linkAnim, 1.0-or-substate_mod) // append the transition ("link") anim node(s) - add_motion(sequence, linkAnim2, new_speed) // (if double-hop via default state) - add_motion(sequence, cycleData, new_speed) // append the NEW cyclic anim, marked cyclic - state->substate = new_substate - state->substate_mod = new_speed - CMotionTable::re_modify(table, sequence, state) // re-apply any active modifiers (e.g. sidestep) - // on top of the new chain - *outTicks = cycleData->action_head + linkAnim.num_anims + linkAnim2.num_anims - 1 - return 1 -``` - -**Answering the prompt's explicit sub-question (a):** DoInterpretedMotion on a -speed change does **NOT always reuse the same cycle**. It depends on whether the -new command maps to the SAME `substate` id as the currently-playing one: - - Speed-only change to the SAME substate (e.g. WalkForward speed 0.6 -> - WalkForward speed 1.0, or RunForward at any two different `forward_speed` - values) hits the **fast path**: `change_cycle_speed` + `subtract_motion`/ - `combine_motion` — same `AnimSequenceNode` object, just re-timed and - re-weighted. No new node, no restart. - - **Walk<->Run is a substate CHANGE (`0x45000005` WalkForward vs - `0x44000007` RunForward are different substate ids)**, so it does NOT hit - the fast path. It goes through the **link-transition path**: `get_link` - looks up an authored transition animation (a short blend clip, e.g. - walk-to-run or run-to-walk) between the two substates; that link node(s) - are appended to the sequence via `add_motion`, followed by the new cyclic - node. The OLD cyclic node is dropped (`remove_cyclic_anims`). Playback then - proceeds: link anim plays first (non-cyclic, finite frames), and once it - completes the `CSequence::update_internal` advance mechanism moves - `curr_anim` forward in the `anim_list` to the next node — the new cyclic - walk/run anim — automatically (see Q4). - -**Sub-question (b): the CSequence node list.** `CSequence::anim_list` is a -doubly-linked list (`DLListBase` of `AnimSequenceNode`), NOT a single -"QueuedAnimations" array. `add_motion` -> `CSequence::append_animation` (line -301777) creates one new `AnimSequenceNode` per `MotionData::anims[i]` entry and -`DLListBase::InsertAfter`s it at the tail. `this->first_cyclic` marks where the -cyclic (looping) portion of the list begins; `remove_cyclic_anims` trims -everything from `first_cyclic` onward when a new transition starts (so -`clear_physics` + `remove_cyclic_anims` together mean: "keep any link anim -that's mid-playback [it's before first_cyclic], but throw away the old loop"). -`curr_anim` points at the node currently being played; `CSequence::update` -advances `frame_number` within `curr_anim` and, in `apricot()`, walks -`curr_anim` forward through the list once frames are exhausted for a node, -discarding fully-consumed non-cyclic nodes from the front of the list up to -`first_cyclic`. - -**Sub-question: is there blending?** No cross-fade/blend in the graphics sense. -It's sequential: `link_anim -> cyclic_anim`, back-to-back play, and the -crossover is a hard node-swap at frame boundary (see Q4). "Blending" in this -codebase means the `CSequence.velocity`/`omega` accumulators (float vectors) -are algebraically combined (`combine_motion`/`subtract_motion`/`add_motion` -add or subtract scaled contributions) — that's a physics-level blend of -velocity, not a skeletal pose blend. - -**Sub-question: is there an immediate speed change?** Only in the same-substate -fast path (`change_cycle_speed`+`subtract_motion`+`combine_motion` all happen -synchronously inside `GetObjectSequence`, i.e., on the SAME frame the wire -message is processed — no interpolation of speed itself). For walk<->run -(different substate), the VISIBLE speed change is gated behind the link anim's -playback duration — velocity is whatever `CSequence.velocity` currently holds -(the link anim's own authored velocity/omega, added via `add_motion`), and only -once the cyclic node becomes current does the full run/walk cyclic velocity -apply. - -### `same_sign` (line 298253, addr 0x00522260) — verbatim -``` -int same_sign(float a, float b) { - // true (1) if a and b are both >=0 or both <0 (treats 0 as non-negative); - // this is the "is direction unchanged" test used to gate the same-cycle - // fast path and to decide whether get_link needs a sign-aware lookup. - return !(a<0) == !(b<0); // (pseudocode paraphrase of the FCMP branches) -} -``` - -### `change_cycle_speed` (line 298276, addr 0x00522290) — verbatim constant -``` -void change_cycle_speed(CSequence* seq, MotionData* cyc, float oldSpeed, float newSpeed) { - if (fabs(oldSpeed) >= 0.000199999995f) // EPSILON = ~0.0002 - CSequence::multiply_cyclic_animation_fr(seq, newSpeed / oldSpeed); // rescale framerate - else if (fabs(newSpeed) >= 0.000199999995f) - CSequence::multiply_cyclic_animation_fr(seq, 0.0f); // freeze (old speed ~0) - // else: both ~0, no-op -} -``` -This is literally "new playback rate multiplier = newSpeed / oldSpeed" applied -to every node from `sequence->first_cyclic` onward -(`AnimSequenceNode::multiply_framerate`, line 302425) — so a walk<->walk speed -change (same substate) scales animation playback speed proportionally to the -commanded speed ratio, and ALSO swaps `low_frame`/`high_frame` if the new -multiplier is negative (playing the cycle backward). - ---- - -## Q3 — PENDING_MOTIONS / MOTION_DONE lifecycle - -**There are TWO distinct pending-queues, easy to conflate:** - -1. **`CMotionInterp::pending_motions`** (singly-linked `LListData`, fields: - `[next, context_id, motion, jumpAllowedFlag]`). Owner: `CMotionInterp`. - Appended by `CMotionInterp::add_to_queue` (line 305032, addr 0x00527b80) — - called from `DoInterpretedMotion` (line 305607), `StopInterpretedMotion` - (line 305657), `apply_interpreted_movement`'s idle-stop path (line 305775), - and `StopCompletely` (line 305227). Popped ONLY by - `CMotionInterp::MotionDone` (line 305238, addr 0x00527ec0): - ``` - void MotionDone(CMotionInterp* this, int arg2) { - if (physics_obj == null) return - head = pending_motions.head_ - if (head != null) { - if (head->motion & 0x10000000) { // this queued motion carried - // a server "action" (jump - // charge etc.) - CPhysicsObj::unstick_from_object(physics_obj) - InterpretedMotionState::RemoveAction(&interpreted_state) - RawMotionState::RemoveAction(&raw_state) - } - pop head off pending_motions (delete node) - } - } - ``` - `CMotionInterp::motions_pending()` (line 305322) == `pending_motions.head_ != null`. - **`context_id`/`action_stamp` on this queue is used for jump-charge and - other server-acknowledged "actions", NOT for ordinary walk/run — ordinary - `DoInterpretedMotion` calls still push a node here (so `motions_pending()` - reflects "any interpreted motion is mid-flight"), but nothing about - walk<->run reads the `context_id`/action semantics.** - -2. **`MotionTableManager::pending_animations`** (doubly-linked `DLListBase`, - fields: `[motion_id, tickCount]`), plus `MotionTableManager::animation_counter` - (running decrement counter). Owner: `MotionTableManager` (one per - `CPartArray`, i.e. per rendered mesh/skeleton — this is the ANIMATION-frame - -level completion tracker, distinct from #1's motion-command-level tracker). - Appended by `MotionTableManager::add_to_queue` (line 290854, addr - 0x0051bfe0) every time `GetObjectSequence` succeeds, storing the `outTicks` - value it returned (how many more discrete animation "steps"/frames worth of - non-cyclic content remain before this motion is fully consumed). Also - immediately calls `remove_redundant_links` (line 290771) to prune - already-queued-but-superseded link-transition entries (see Q2 note on - walk<->run spam). - - **Consumption / popping — TWO drivers:** - - **Per-tick poll:** `MotionTableManager::CheckForCompletedMotions` (line - 290645, addr 0x0051be00), called every physics tick via - `CPartArray::HandleMovement` -> `MotionTableManager::UseTime` (alias for - `CheckForCompletedMotions`, line 290845) from - `CPhysicsObj::UpdateObjectInternal` (line 283748). Walks - `pending_animations` from the head while `tickCount == 0`, firing - `CPhysicsObj::MotionDone(physics_obj, motion_id, /*arg3*/1)` for each and - removing action-heads (`MotionState::remove_action_head`) if the - `0x10000000` bit is set. - - **Anim-hook driven:** `CPhysicsObj::Hook_AnimDone` (line 277845, addr - 0x0050fda0) — registered as a `CAnimHook` fired by - `CSequence::execute_hooks` (line 300780) when a specific animation FRAME - carries a hook whose `direction_` matches playback direction. Calls - `CPartArray::AnimationDone(1)` -> `MotionTableManager::AnimationDone(1)` - (line 290558, addr 0x0051bce0), which increments `animation_counter` and - pops every `pending_animations` entry whose `tickCount <= animation_counter` - (decrementing the counter by each popped entry's `tickCount`, i.e. a - running-total consumption model, not a strict per-frame countdown). - - **Synchronous, post-dispatch:** `CMotionInterp::PerformMovement` (line - 306221, addr 0x00528e80) — the outer entry used by the LOCAL player's raw - input path (`MovementManager::PerformMovement` cases 0-4) — calls - `CPhysicsObj::CheckForCompletedMotions` immediately after every - `DoMotion`/`DoInterpretedMotion`/`StopMotion`/`StopInterpretedMotion`/ - `StopCompletely` dispatch (line 306234/241/248/255/262), so a - zero-duration motion completes in the SAME frame it was issued rather - than waiting for the next tick. This path is NOT used by the wire/remote - entry (`CMotionInterp::apply_interpreted_movement` calls - `DoInterpretedMotion`/`StopInterpretedMotion` directly without a following - `CheckForCompletedMotions` — the remote entity therefore only gets its - completions serviced by the per-tick poll and the anim-hook path, not the - synchronous one). - - `CPhysicsObj::MotionDone(physics_obj, motion_id, arg3)` (line 277856, addr - 0x0050fdb0) -> `MovementManager::MotionDone` (line 300396) -> - `CMotionInterp::MotionDone` (line 305238, described in #1 above). **This is - the bridge between the two queues**: a `MotionTableManager`-level - animation-frame completion cascades UP into popping the - `CMotionInterp`-level command queue. - -**Callback / state change on completion:** popping `pending_motions` only (a) -optionally clears the "stuck to object" state + removes a pending server action -if the popped node had the `0x10000000` "carries an action" bit, and (b) frees -the node. It does NOT itself touch velocity, `substate`, or the `CSequence` -node list — those were already mutated synchronously back when -`GetObjectSequence` ran (at command-ISSUE time, not command-COMPLETE time). -**The animation-frame-level completion (`AnimationDone`/`CheckForCompletedMotions`) -is what actually matters for gameplay feel: it's what lets a queued -non-cyclic link anim naturally hand off to the next queued node (see Q4) and -what lets a "jump" or other single-shot server action be acknowledged as -finished.** - ---- - -## Q4 — CYCLE SWAP: frame index carryover vs restart vs link - -Per `CSequence::append_animation` (line 301777, addr 0x00525510): -``` -void append_animation(CSequence* this, AnimData* animData) { - node = new AnimSequenceNode(animData) - if (!node->has_anim()) { delete node immediately; return } // degenerate/empty motion, skip - DLListBase::InsertAfter(&anim_list, node, anim_list.tail_) // always appended at TAIL - this->first_cyclic = node // *** every appended node - // becomes the new - // first_cyclic marker - // until superseded *** - if (curr_anim == null) { // sequence was idle/empty - curr_anim = anim_list.head_ - frame_number = curr_anim->get_starting_frame() - } - // if curr_anim was already non-null (something mid-playback), it is - // left untouched — the newly appended node just waits at the tail. -} -``` -So **appending never resets `frame_number` for whatever's currently playing.** -The frame index of the CURRENTLY playing node (the link anim, or the old cycle -if it's still `curr_anim`) is untouched. - -`CSequence::update` (line 302402, addr 0x00525b80): -``` -void update(CSequence* this, double dt, Frame* outDelta) { - if (anim_list.head_ != null) { - CSequence::update_internal(this, dt, &curr_anim, &frame_number, outDelta) - CSequence::apricot(this) // list-trim housekeeping (below) - } else if (outDelta != null) { - CSequence::apply_physics(this, outDelta, dt, dt) // PURE velocity integration, - // no animation nodes at all - } -} -``` -`update_internal` (line 301839, addr 0x005255d0) is heavily x87-obfuscated in -this decompile (unresolvable float compares/branches show as raw -`/* unimplemented */` FPU op comments) — the BN decompiler could not fully -recover its control flow. What IS recoverable: it advances `frame_number` -within `curr_anim` by `dt * anim->framerate`-derived amount, and once a node's -frames are exhausted it walks `curr_anim` to `AnimSequenceNode::GetNext(...)` -(confirmed indirectly via `apricot`'s cleanup logic below and via -`CSequence::execute_hooks`/`multiply_cyclic_animation_fr` operating on -"`first_cyclic` onward" — i.e., cyclic nodes loop in place by wrapping -`frame_number`, non-cyclic/link nodes advance `curr_anim` to the next list -node when frames are exhausted). - -`CSequence::apricot` (line 300978, addr 0x00524b40) — the list-trim called every -`update()`: -``` -void apricot(CSequence* this) { - i = (anim_list.head_ != null) ? adjustedHead : null - if (i != curr_anim) { - while (i != first_cyclic) { - // unlink node i from anim_list (both directions), delete it, - // then advance i to the new head - ... unlink + delete ... - i = new head - if (i == curr_anim) break - } - } -} -``` -i.e., **once `curr_anim` has moved past the head of the list (a node finished -playing), `apricot` deletes every now-stale node from `anim_list.head_` up to -(but not including) `first_cyclic`.** This is standard "consume finished -one-shot link anims off the front of the queue" behavior. - -**Direct answer:** the frame index does **NOT carry over between the OLD cycle -and the NEW cycle** — they are different `AnimSequenceNode` objects wrapping -different `CAnimation` data with independently-tracked start frames -(`AnimSequenceNode::get_starting_frame()`). What DOES carry over/continue -smoothly is: - - the **link animation plays out fully first** (its own authored frame range, - from `get_starting_frame()` to its end), because it was appended to the - tail and `curr_anim` only advances once the current node's frames are - exhausted (via `update_internal`'s internal advance, not `apricot`, which - is just cleanup). - - once the link anim's frames are exhausted, playback naturally proceeds to - the next node in `anim_list` (the newly appended cyclic walk/run node), - which starts fresh at ITS `get_starting_frame()`. - - So the transition literally IS the link animation: **walk -> run uses an - authored transition clip in between**; there is no cross-fade of the walk - cycle's frame position into the run cycle's frame position. The retail art - pipeline authors these link/transition clips specifically so this hard - swap looks continuous. - - If `get_link` found NO authored link for this style/substate pair (the - `linkAnim == null` branch in `GetObjectSequence`), the code instead hops - through the style's "default" (idle/ready) substate as an intermediate — - two link anims chained — rather than doing a raw cut. - - `change_cycle_speed`'s `multiply_cyclic_animation_fr` (called ONLY on the - same-substate fast path) operates on `this->first_cyclic` onward, i.e., it - re-times whatever is the CURRENT cyclic node in place — it does not touch - frame_number's absolute position within that node, only its rate of - advance, so a walk-speed-change (not walk<->run) preserves the current - frame's phase, just plays faster/slower/backward from there. - ---- - -## Q5 — POSITION DRIVE between inbound packets - -Confirmed by tracing `CPhysicsObj::update_object` -> `UpdateObjectInternal` -> -`UpdatePositionInternal` -> `CPartArray::Update` -> `CSequence::update`: - -``` -CPhysicsObj::update_object(CPhysicsObj* this) line 283950, addr 0x00515d10 -{ - ... skip if parented/no-cell/frozen ... - dt = Timer::cur_time - this->update_time - if dt < 0.000199999995f: return // EPSILON, same constant as change_cycle_speed - if dt < 2.0: - UpdatePositionInternal-chain for the whole dt in one call - else: - // clamp/step: chunk into <=1.0s steps while remaining dt >= 2.0, - // then one final UpdateObjectInternal(remainder) call — prevents a - // huge single-frame teleport after e.g. a stall/loading hitch - while (remaining >= 2.0) { UpdateObjectInternal(this, 1.0); remaining -= 1.0 } - UpdateObjectInternal(this, remaining) -} - -CPhysicsObj::UpdateObjectInternal(CPhysicsObj* this, float dt) line 283611, addr 0x005156b0 -{ - ... early-outs for ethereal/off-world states, still runs particle/script update ... - if (this->cell != 0) { - var deltaFrame = {identity} - UpdatePositionInternal(this, dt, &deltaFrame) // <-- computes the candidate move - if (has spheres / real collision geometry) { - if (deltaFrame == zero-delta) { - set_frame(this, &deltaFrame); cached_velocity = 0 - } else { - heading update (velocity-derived or state-flag-derived) - CTransition* result = CPhysicsObj::transition(this, &m_position, &deltaFrame, 0) // <-- FULL - // COLLISION - // SWEEP, - // same - // machinery - // as local - // player - // movement - if (result == null) { - set_frame(this, &deltaFrame) // blocked entirely -> stays put, but frame still applied?? - // (this branch means find_valid_position failed to - // produce a transition object; effectively a no-collision - // passthrough for objects without real spheres) - cached_velocity = 0 - } else { - cached_velocity = (result->sphere_path.curr_pos - m_position) / dt // ACTUAL POST-COLLISION - // velocity, NOT the - // raw commanded one - SetPositionInternal(this, result) - } - } - } else { - // no collision spheres on this part array: apply frame directly, no sweep - set_frame(this, &deltaFrame); cached_velocity = 0 - } - DetectionManager / TargetManager / MovementManager::UseTime / CPartArray::HandleMovement - (== MotionTableManager::UseTime - == CheckForCompletedMotions) / - PositionManager::UseTime - } -} - -CPhysicsObj::UpdatePositionInternal(CPhysicsObj* this, float dt, Frame* outDelta) line 280817, addr 0x00512c30 -{ - if (!ethereal-ish state bit): CPartArray::Update(part_array, dt, outDelta) // <-- FILLS outDelta - // via CSequence::update - if (position_manager != null): PositionManager::adjust_offset(position_manager, outDelta, dt) - // (server position-correction blend, see Q6) - Frame::combine(outDelta, &this->m_position.frame, outDelta) // outDelta = currentFrame (+) outDelta - if (!ethereal-ish): CPhysicsObj::UpdatePhysicsInternal(this, dt, outDelta) // gravity/step physics on top - CPhysicsObj::process_hooks(this) // <-- fires queued CAnimHooks (incl. AnimDone) EVERY TICK, post-position -} - -CPartArray::Update(CPartArray* this, float dt, Frame* outDelta) line 285883, addr 0x00517db0 -{ - CSequence::update(&this->sequence, dt, outDelta) // exactly the branch described in Q4: - // animation-node-consumption path OR - // pure apply_physics(velocity*dt) fallback -} -``` - -**Direct answer: BOTH, and they are the SAME code path, not two competing -sources.** `CSequence::update` chooses between: - (a) **animation-node consumption** (`update_internal`) when `anim_list` is - non-empty — this advances frames AND, per-node, the per-frame position - delta baked into the `AnimFrame` data (`get_pos_frame`/`get_part_frame`) - contributes to the produced `outDelta` Frame (the x87-obscured part of - `update_internal`, but its role is confirmed by `AnimSequenceNode::get_pos_frame` - / `get_part_frame` existing specifically to fetch per-frame authored - pose+position data), and - (b) **`apply_physics`** (pure `outDelta.origin += dt * this->velocity; - outDelta.rotate(dt * this->omega)`) when `anim_list` is EMPTY (i.e. a - pure-interpreted-velocity idle/moving state with no queued transition - animations left) — this is the steady-state "walking/running in a - straight line between server packets" case for a LOOPING cyclic anim once - its own list bookkeeping considers it "done producing new nodes" — but - note `has_anims()` / `anim_list.head_ != null` is true whenever there's - ANY node (including the still-looping cyclic one), so in practice, for a - normal walk/run cycle, path (a) is what's active essentially always; - path (b) is the true-idle / "no motion data at all, just raw velocity" - fallback (e.g. after `StopCompletely` clears everything, or for - objects that were never given a motion table). - Either way, the output Frame delta is what feeds `Frame::combine` against the - CURRENT position, and the combined candidate then goes through the FULL - `CPhysicsObj::transition` collision sweep — remote entities are - collision-checked every tick exactly like the local player, they are not - simply "teleported" along a straight line. `cached_velocity` (used for e.g. - UI/physics queries, NOT for driving the next tick's move — the next tick - re-derives everything from `CSequence` state) is the ACTUAL post-collision - displacement/dt, which can differ from the commanded interpreted velocity if - a wall was hit. - ---- - -## Q6 — CORRECTION: reconciling inbound position updates - -Two independent correction paths were located; both are called from -`UpdatePositionInternal`/its callers, gated by whether the wire message -carried a full `Position` update or just a motion-command update: - -1. **`PositionManager::adjust_offset`** (called every tick from - `UpdatePositionInternal`, line 280857) — blends a stored "we're behind - where we should be" offset into the per-tick delta over time, i.e. a - position-manager-owned soft-correction/interpolation smoothing layer - (`PositionManager::UnStick`/`StopInterpolating`/`IsInterpolating`/ - `IsFullyConstrained`/`GetStickyObjectID` are its other exposed operations — - all wrapped 1:1 through `CPhysicsObj::unstick_from_object`, - `StopInterpolating`, `IsInterpolating`, `IsFullyConstrained`, - `get_sticky_object_id`). The named-retail decompile does not expose - `PositionManager::adjust_offset`'s internal body in this file (its class - implementation lives outside the traced call chain reached in this pass); - what's confirmed is its CALL SITE and its INPUT/OUTPUT contract: it mutates - the same `Frame* outDelta` that `CPartArray::Update`/`CSequence::update` - just wrote, i.e. it's a correction applied ON TOP OF the - animation/velocity-driven delta, before that delta is combined with - current position and swept for collision. This is the retail equivalent of - "dead-reckoning error absorbed gradually into the next frame's move" rather - than a hard position snap. -2. **Full snap path**: when `0xf74c`/`0xf619` carries not just a motion - command but also a fresh authoritative `Position` (the position+movement - combo case, or `MoveToObject`/`MoveToPosition` in `unpack_movement`'s cases - 6/7), the code calls `CPhysicsObj::SetPositionInternal` (line 283892, addr - 0x00515bd0) via the `MoveToManager`/`CPhysicsObj::MoveToObject`/ - `SetScatterPositionInternal` machinery — this is a direct authoritative - `Position` set (through `AdjustPosition` + `CheckPositionInternal` + - `handle_all_collisions`), i.e. a hard reposition/snap when the server - sends a full position rather than only a motion-state delta. `unpack_movement` - case 0 (the plain `InterpretedMotionState`, used for ordinary walk<->run) - does NOT carry a `Position` at all — it only ever updates the motion - command/speed and lets local dead-reckoning (`CSequence`-driven `update` + - collision sweep, per Q5) carry the position forward until the next - authoritative position or motion packet arrives. There is no visible - "snap-if-error-exceeds-threshold" constant found in the traced functions - in this pass — the correction is structurally continuous - (`adjust_offset` blended every tick) rather than threshold-triggered, - based on what's directly observable in this file. - ---- - -## Q7 — STOP: motion -> ready/stand - -Stopping is **not special-cased outside the normal `GetObjectSequence` -machinery** — it is routed through the exact same link-transition logic as any -other substate change, targeting the style's registered idle/rest substate. - -Entry points, both eventually reaching `CMotionTable::StopSequenceMotion` -(line 298954, addr 0x00522fc0): - -``` -CMotionInterp::StopInterpretedMotion(this, motion, params) line 305635, addr 0x00528470 - -> if contact_allows_move fails OR standing_longjump-with-jump-motion: - just clears bookkeeping (InterpretedMotionState::RemoveMotion) and returns 0 — no - physical stop is even attempted (e.g. can't "stop turning" mid-air the same way) - -> else: - CPhysicsObj::StopInterpretedMotion(physics_obj, motion, params) - -> CPartArray::StopInterpretedMotion -> MotionTableManager::PerformMovement(type=StopCommand) - -> CMotionTable::StopObjectMotion(table, motion, speed, state, seq, outTicks) - -> CMotionTable::StopSequenceMotion(table, motion, speed, state, seq, outTicks) - if success: CMotionInterp::add_to_queue(this, ctx, READY_STANCE/*0x41000003*/, result) - InterpretedMotionState::RemoveMotion(&interpreted_state, motion) // clears forward_command - // back to 0x41000003 READY -``` - -`CMotionTable::StopSequenceMotion` (line 298954, addr 0x00522fc0): -``` -int32 StopSequenceMotion(table, motion, speed, state, seq, outTicks) { - *outTicks = 0 - if ((motion & 0x40000000) != 0 && motion == state->substate) { - // stopping the MAIN cycle (forward walk/run, not a modifier like - // sidestep): look up the style's default (idle/ready) substate and - // re-enter GetObjectSequence targeting IT — i.e. "stop" == "transition - // to idle", full link-anim machinery applies (Q2/Q4) - defaultSubstate = style_defaults[state->style] - return CMotionTable::GetObjectSequence(table, defaultSubstate, state, seq, 1.0f, outTicks, /*force*/1) - } - if ((motion & 0x20000000) != 0) { - // stopping a MODIFIER motion (e.g. sidestep, turn — layered on top of - // the base cycle rather than replacing it): find the modifier's - // MotionData and directly SUBTRACT its velocity/omega contribution - for (m in state->modifier_head-list) { - if (m.motion == motion) { - modData = modifiers.lookup((style<<16)|motion) ?? modifiers.lookup(motion) - if (modData != null) { - subtract_motion(seq, modData, m.speed_mod) // <-- direct velocity/omega - // subtraction, NO link anim, - // NO node changes — this IS - // how e.g. releasing sidestep - // while still running removes - // just the sideways component - MotionState::remove_modifier(state, m, prev) - return 1 - } - break - } - } - } - return 0 -} -``` - -**Velocity zeroing:** happens in TWO places depending on stop type: - - Main-cycle stop (walk/run -> ready): via `GetObjectSequence`'s - link-transition branch, which unconditionally calls - `CSequence::clear_physics(sequence)` BEFORE appending the new link+cycle - chain — `clear_physics` (line 301194, addr 0x00524d50) zeroes - `sequence->velocity` and `sequence->omega` outright, then the new link - anim's OWN authored velocity/omega (if any) is added back via - `add_motion`. So there IS a hard zero, immediately followed by - re-population from the transition-to-idle clip's own baked - velocity/omega (typically ~0 for a stand/ready clip, hence "stop"). - - Modifier stop (sidestep/turn release): `subtract_motion` directly removes - exactly that modifier's contribution (scaled by its `speed_mod`) from the - still-nonzero base-cycle velocity — no full zero, no `clear_physics` call, - because the base cycle (e.g. still running) keeps its own velocity intact. - -**Stop/link animation:** YES — the idle-entry is itself an authored `get_link` -transition clip from the current substate to the style's default substate, -exactly like any other substate-to-substate transition (Q2/Q4). There is no -"instant freeze frame"; retail plays a deceleration/stop clip. - -**Residual-sliding prevention:** because `clear_physics` zeroes -`sequence->velocity`/`omega` at the moment the stop-transition is initiated -(not merely when the stop ANIMATION finishes), the `apply_physics`/animation -per-frame delta stops contributing translation from THAT frame onward except -whatever the stop-link-clip's own authored motion data supplies via -`add_motion(seq, linkAnim, ...)` immediately after the clear. So there's no -"physics keeps sliding while the stop anim plays" bug window — the only motion -during the stop-link clip is whatever the clip's OWN keyframed velocity says -(typically small/decelerating by design), and once the link clip's frames are -exhausted and playback reaches the (typically near-static) idle cyclic node, -velocity is whatever that idle cycle's own `add_motion(..., cycleData, speed)` -contributed (near zero for a proper "Ready"/idle motion). - -Additionally: `CPhysicsObj::RemoveLinkAnimations` (-> `CPartArray::HandleEnterWorld` -which is really "flush the motion table manager's queued link anims", called -from multiple guard points: whenever `physics_obj->cell == 0` inside both -`DoInterpretedMotion` and `StopInterpretedMotion`'s tail, from `HitGround`, -`LeaveGround`, and from `move_to_interpreted_state`'s caller context indirectly) -provides a hard-reset safety valve: if the object leaves the world/cell -mid-transition, any queued link-transition chain is discarded outright rather -than left dangling. - ---- - -## Verbatim float constants collected in this pass - -| Constant | Where | Meaning | -|---|---|---| -| `0.000199999995f` (~0.0002) | `change_cycle_speed` (298276), `CPhysicsObj::update_object` dt-epsilon (283950 area), `CPhysicsObj::SetTranslucency` (279489) | Generic "is this float effectively zero" epsilon used repeatedly across the physics/motion code — NOT walk/run-specific but the exact epsilon guarding the same-cycle speed-rescale divide-by-oldSpeed. | -| `2.0` (dt seconds) | `CPhysicsObj::update_object` (~284009) | Large-dt chunking threshold: any single `update_object` gap >= 2.0s is stepped in 1.0s `UpdateObjectInternal` slices to avoid one huge teleport-y integration step. | -| `1.0` (dt seconds) | same function | Per-slice step size used while chunking large dt. | -| `1.25f` | `CMotionInterp::get_state_velocity` (305160) | Sidestep speed multiplier when computing "logical state velocity" (`sidestep_speed * 1.25f`) — used for e.g. UI/AI queries, not the actual CSequence velocity. | -| `1.5f` | `CMotionInterp::apply_run_to_command` (305062), motion `0x6500000d` (TURN) case | Speed multiplier applied to turn commands. | -| `3f` / `-1f*3f` | `CMotionInterp::apply_run_to_command`, motion `0x6500000f` (SIDESTEP) case | Sidestep speed is clamped/scaled to exactly `+-3.0` depending on sign of the run-rate-scaled input (with sign preserved via the `x87_r7 < 0` branch). | -| `3.11999989f` (~3.12) | `CMotionInterp::get_state_velocity` (305176) | Walk-forward (`0x45000005`) logical-velocity multiplier. | -| `4f` | `CMotionInterp::get_state_velocity` (305180) | Run-forward (`0x44000007`) logical-velocity multiplier. | -| `96f` | `CPhysicsObj::update_object` (283974, player_distance gate) | Distance (world units, ~yards? — needs unit confirmation) beyond which a different `set_active` path is taken for a non-player-object relative to the player. | -| `0.100000001f` (0.1) | `CPhysicsObj::set_elasticity` (277817) | Elasticity clamp floor — unrelated to motion but shares the file region. | - ---- - -## Function/line index (quick lookup for a synthesis pass) - -| Symbol | Line | Addr | Role | -|---|---|---|---| -| `ACSmartBox::DispatchSmartBoxEvent` | 357117 | 0x005595d0 | Wire opcode switch (0xf619/0xf74c entry) | -| `CPhysics::SetObjectMovement` (stdcall) | 271370 | 0x00509690 | Sequence-number staleness gate, dispatch | -| `CPhysicsObj::unpack_movement` | 280179 | 0x00512040 | Lazily creates MovementManager, forwards | -| `MovementManager::unpack_movement` | 300563 | 0x00524440 | Deserializes wire struct, 10-way type switch | -| `MovementManager::move_to_interpreted_state` | 300259 | 0x00524170 | Lazy-create CMotionInterp, forward | -| `CMotionInterp::move_to_interpreted_state` | 305936 | 0x005289c0 | copy_movement_from + apply_current_movement + replay actions | -| `InterpretedMotionState::copy_movement_from` | 293301 | 0x0051e750 | Flat field overwrite (fwd/side/turn cmd+speed, style) | -| `CMotionInterp::apply_current_movement` | 305838 | 0x00528870 | Routes to raw (local) vs interpreted (remote) path | -| `CMotionInterp::apply_interpreted_movement` | 305713 | 0x00528600 | Issues DoInterpretedMotion per active command slot | -| `CMotionInterp::DoInterpretedMotion` | 305575 | 0x00528360 | contact_allows_move gate, dispatch to CPhysicsObj, queue | -| `CPhysicsObj::DoInterpretedMotion` | 276348 | 0x0050ea70 | Thin forward to CPartArray | -| `CPartArray::DoInterpretedMotion` | 286772 | 0x00518750 | Packs MovementStruct{type=2}, calls MotionTableManager | -| `MotionTableManager::PerformMovement` | 290906 | 0x0051c0b0 | type switch: DoObjectMotion / StopObjectMotion / StopObjectCompletely | -| `CMotionTable::DoObjectMotion` | 300045 | 0x00523e90 | -> GetObjectSequence(force=0) | -| `CMotionTable::GetObjectSequence` | 298636 | 0x00522860 | **THE cycle-swap/append/fast-path decision** | -| `same_sign` | 298253 | 0x00522260 | Direction-unchanged test | -| `change_cycle_speed` | 298276 | 0x00522290 | Same-cycle playback-rate rescale | -| `CMotionTable::get_link` | 298552 | 0x00522710 | Authored transition-anim lookup | -| `add_motion` / `combine_motion` / `subtract_motion` | 298437 / 298472 / 298492 | 0x005224b0 / 0x00522580 / 0x00522600 | Append CSequence nodes + scale velocity/omega in/out | -| `CSequence::append_animation` | 301777 | 0x00525510 | Node creation, tail-insert, first_cyclic bump | -| `CSequence::clear_physics` | 301194 (def not read in full but referenced) | 0x00524d50 | Zero velocity/omega | -| `CSequence::remove_cyclic_anims` | referenced 298701 etc | 0x00524e40 | Drop old cyclic tail before new transition | -| `CSequence::update` | 302402 | 0x00525b80 | update_internal (has anims) OR apply_physics (no anims) | -| `CSequence::update_internal` | 301839 | 0x005255d0 | Frame-advance (x87-obfuscated, not fully recoverable) | -| `CSequence::apply_physics` | 300955 | 0x00524ab0 | outDelta.origin += dt*velocity; rotate(dt*omega) | -| `CSequence::apricot` | 300978 | 0x00524b40 | Trim consumed nodes from head up to first_cyclic | -| `CPartArray::Update` | 285883 | 0x00517db0 | == CSequence::update | -| `CPhysicsObj::UpdatePositionInternal` | 280817 | 0x00512c30 | CPartArray::Update -> PositionManager::adjust_offset -> Frame::combine -> UpdatePhysicsInternal -> process_hooks | -| `CPhysicsObj::UpdateObjectInternal` | 283611 | 0x005156b0 | UpdatePositionInternal -> collision transition -> cached_velocity, per-tick UseTime calls | -| `CPhysicsObj::update_object` | 283950 | 0x00515d10 | Outer per-object driver, dt clamp/chunking | -| `CPhysicsObj::transition` | 280904 | 0x00512dc0 | Builds CTransition, sphere sweep, find_valid_position | -| `MotionTableManager::add_to_queue` | 290854 | 0x0051bfe0 | Append to pending_animations, prune redundant links | -| `MotionTableManager::remove_redundant_links` | 290771 | 0x0051bf20 | Collapse superseded queued link transitions | -| `MotionTableManager::CheckForCompletedMotions` | 290645 | 0x0051be00 | Per-tick poll: pop tickCount==0 entries, fire MotionDone | -| `MotionTableManager::AnimationDone` | 290558 | 0x0051bce0 | Anim-hook-driven pop via animation_counter | -| `CPhysicsObj::Hook_AnimDone` | 277845 | 0x0050fda0 | CAnimHook callback -> CPartArray::AnimationDone(1) | -| `CPhysicsObj::MotionDone` | 277856 | 0x0050fdb0 | Bridges MotionTableManager completion -> CMotionInterp queue | -| `MovementManager::MotionDone` | 300396 | 0x005242d0 | Forward | -| `CMotionInterp::MotionDone` | 305238 | 0x00527ec0 | Pops pending_motions head, clears stick/actions if flagged | -| `CMotionInterp::add_to_queue` | 305032 | 0x00527b80 | Appends to CMotionInterp::pending_motions | -| `CMotionInterp::motions_pending` | 305322 | 0x00527fe0 | pending_motions.head_ != null | -| `CMotionInterp::StopInterpretedMotion` | 305635 | 0x00528470 | Entry for stopping a command | -| `CMotionTable::StopObjectMotion` | 300053 | 0x00523ec0 | -> StopSequenceMotion | -| `CMotionTable::StopSequenceMotion` | 298954 | 0x00522fc0 | Main-cycle-stop (re-enter GetObjectSequence w/ default substate) vs modifier-stop (subtract_motion) | -| `CMotionTable::StopObjectCompletely` | 300062 | 0x00523ed0 | Iterates all modifiers + substate, stops each | -| `InterpretedMotionState::ApplyMotion` | 293531 | 0x0051ea40 | Bookkeeping-only overwrite of forward/sidestep/turn fields | -| `InterpretedMotionState::RemoveMotion` | 293315 | 0x0051e790 | Clears turn/sidestep/forward command back to defaults | -| `CPhysicsObj::RemoveLinkAnimations` | 277911 | 0x0050fe20 | -> CPartArray::HandleEnterWorld: flush queued link anims | -| `CMotionInterp::contact_allows_move` | 305471 | 0x00528240 | Gate: only creatures on solid ground can freely swap most motions | -| `CMotionInterp::PerformMovement` | 306221 | 0x00528e80 | LOCAL-input outer dispatcher; calls CheckForCompletedMotions synchronously (NOT used by remote/wire path) | - ---- - -## Notes on scope / what was NOT fully resolved - -- `CSequence::update_internal`'s exact per-frame arithmetic (how `frame_number` - advances, exact interpolation between `low_frame`/`high_frame`, and the - precise mechanism by which a per-frame authored position delta from - `AnimFrame`/`get_pos_frame` gets folded into the output `Frame*`) is - x87-obfuscated in this Binary Ninja pseudo-C dump — individual FPU - compare/branch sequences show as `/* unimplemented {fcomp ...} */` rather - than resolved C. This matches the documented project-wide limitation (see - `memory/feedback_bn_decomp_field_names.md` and the CLAUDE.md cdb-toolchain - section) that some floating-point-heavy retail functions don't fully - decompile via Binary Ninja and may need a cdb live-trace or manual - disassembly pass to pin exact behavior. What IS certain from the - surrounding code (append_animation/get_pos_frame/get_part_frame/apricot) - is the STRUCTURE: node-list advance + per-node authored frame data feeding - the output Frame. -- `PositionManager::adjust_offset`'s body (the Q6 soft-correction blend) was - not located inside this pseudo-C excerpt in this pass — only its call site - and sibling API surface (`UnStick`, `StopInterpolating`, `IsInterpolating`, - `IsFullyConstrained`, `GetStickyObjectID`) were confirmed. A follow-up grep - for `PositionManager::` method bodies (likely a different source file / - address range not covered by the anchors given) would be needed to get its - exact blend formula and any snap-threshold constant. -- No explicit "snap if error > threshold" constant was found for position - correction in the portions traced; the retail design as observed here is a - continuous per-tick blend (`adjust_offset`) plus occasional authoritative - hard `SetPositionInternal` when the wire message actually carries a - `Position` (MoveToObject/MoveToPosition/PositionAndMovement paths), not a - distance-threshold-triggered snap layered on top of ordinary motion-command - packets. diff --git a/docs/research/2026-07-02-inbound-motion-verbatim-port-handoff.md b/docs/research/2026-07-02-inbound-motion-verbatim-port-handoff.md deleted file mode 100644 index 3780585a..00000000 --- a/docs/research/2026-07-02-inbound-motion-verbatim-port-handoff.md +++ /dev/null @@ -1,156 +0,0 @@ -# Handoff: inbound animation + position — verbatim retail port - -Date: 2026-07-02 -Branch: `claude/vigorous-joliot-f0c3ad` (clean; prior arc all committed through `d34721fa`) -Mode for the new session: **/investigate first (report-only), then port on approval.** - -## What just shipped (context — the OUTBOUND / LOCAL half is done) - -This session made acdream's *outbound* wire and *local-player* motion -retail-faithful, verified against live ACE + user/retail-observer sign-off: - -- **L.1b** `2c8620ea` — dual `IMotionCommandCatalog` (AceModern runtime / - Retail2013 conformance, full `command_ids[0x198]` extraction). -- **L.2b** `78e163a4` + sign-off `f271a49e` — outbound wire parity (D1 - default-difference `RawMotionState::Pack`, D3 contact/longjump byte, D4 - `JumpPack`). -- **D6** `4ed27836` (pseudocode) + `0f099bb6` (D6.2a) + `47407506` (sign-off) + - `d34721fa` (D6.2b) — ported `CMotionInterp::adjust_motion` / - `apply_run_to_command` / `apply_raw_movement` / `get_state_velocity` and - unified the local player's velocity + turn + jump + wire onto one - input-built `RawMotionState` at `forward_speed=1.0`. Backward/strafe-left no - longer zero; strafe retail-exact; turn omega from interpreted `turn_speed`. -- **Key wire finding:** ACE **recomputes** the broadcast run speed from run - skill (echo-test confirmed) and auto-upgrades `WalkForward+HoldKey.Run → - RunForward`. The wire sends raw `forward_speed=1.0`. (Crib: - `claude-memory/project_retail_motion_outbound.md`.) - -**That was OUTBOUND + LOCAL. This handoff is the INBOUND / REMOTE half.** - -## The goal - -Port retail's **incoming (inbound) handling of animation + position VERBATIM** -into acdream — but **surgically**, targeted at where acdream deviates (not a -blind wholesale sequencer rewrite; a rushed sequencer swap has "broken -everything" in a past session — integrate carefully). Strict standard: no -approximations; retail decomp / ACE / DAT as oracles; parity tests before -behavior changes. - -## The symptom (the acceptance oracle — user's live observation) - -Watching a **remote player** (inbound motion) in acdream: - -- Overall "works OK" but **not polished**. Some **sliding** and some - **position errors once the entity stops**. -- **The worst offender:** transitioning **walk↔run WITHOUT stopping** — the - motion **interpreter reacts too slowly**, and this **throws all animation + - position off afterward**, compounding as the entity keeps **running around + - turning**. The error accumulates through subsequent motion. - -Fixing the walk↔run-transition responsiveness (and the resulting animation + -position desync) IS the acceptance test. - -## Root-cause hypothesis (UNCONFIRMED — verify in the investigation first) - -acdream's motion sequencer does not implement retail's motion-**transition** -state machine. Retail's `CMotionInterp` doesn't just swap the animation cycle -on a motion change — it **appends** the new motion to a `CSequence` and -transitions through it with `pending_motions` / `MotionDone` bookkeeping. If -acdream applies a walk↔run change abruptly/late (snap `SetCycle`) instead of -sequencing it, the animation cycle and the dead-reckoned position drift apart -and the error accumulates. The 2026-06-04 deep-dive already ranked missing -`pending_motions` / `MotionDone` as **HIGH**. - -## Pre-paid research to leverage (READ THESE) - -1. **`docs/research/2026-06-04-animation-sequencer-deep-dive.md`** — START - HERE. Retail `CMotionInterp` + `CSequence` + hook-dispatch state graph + 8 - ranked divergences (missing `pending_motions`/`MotionDone` HIGH; frame-swap - class; link→cycle boundary). **Verify its findings against the CURRENT code - — the L.1 animation work may have closed some gaps since 2026-06-04; do not - trust its line numbers.** -2. `docs/research/2026-06-26-movement-animation-retail-parity-audit.md` — the - inbound divergences: **D7** (animation split from retail motion flow), **D8** - (force-walk / `MoveToManager` approximate), **D9** (inbound `TurnToObject`/ - `TurnToHeading` types 8/9 dropped — CONFIRMED live: `mt=0x09` arrives and - just `SetCycle(Ready)` instead of turning), **D10** (spawn-time MoveTo weak), - **D11** (sequence/autonomy parsed then discarded), **D12** (jump/contact - gates simplified). -3. `docs/research/2026-07-01-d6-motion-interp-pseudocode.md` — the OUTBOUND/local - `CMotionInterp` port already done; inbound uses the SAME machinery - (`get_state_velocity`, `adjust_motion`, `apply_raw_movement` now exist in - `src/AcDream.Core/Physics/MotionInterpreter.cs`). - -## Decomp anchors (the oracle; grep `docs/research/named-retail/acclient_2013_pseudo_c.txt`) - -- `CMotionInterp::apply_current_movement` (~line 305713 — sequences style / - forward-falling / sidestep / turn / actions), `DoInterpretedMotion` (~305575), - `CMotionTable::GetObjectSequence` (~298636). -- `CSequence` append/advance/update (~lines 301622, 301777, 301839, 302425). -- The 2026-06-04 deep-dive has the full CMotionInterp/CSequence address list. -- `acclient.h` for the structs; Ghidra HTTP bridge at `http://127.0.0.1:8081` - (decompile-by-address) if it's up. - -## Where the inbound code lives (starting points to map) - -- `src/AcDream.App/Rendering/GameWindow.cs` — inbound remote-entity motion: - `OnLiveMotionUpdated`, the remote `SetCycle` / `ObservedOmega` seeding - (~4815-4856), dead-reckoned position + omega application (~9750-9865), the - `[SETCYCLE]`/`[SCFAST]`/`[SCFULL]`/`[OMEGA_DIAG]`/`[VEL_DIAG]`/`[SEQSTATE]`/ - `[UPCYCLE]` diagnostics. -- `src/AcDream.Core/Physics/AnimationSequencer.cs` — the cycle/action sequencer. -- `src/AcDream.Core/Physics/MotionInterpreter.cs` — interpreted state (now has - the D6 `adjust_motion`/`apply_raw_movement`/`get_state_velocity`). -- `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` — remote MoveTo + `TurnRateFor`. -- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`, `UpdatePosition.cs`, - `MoveToState.cs` — inbound parsing. - -## The plan for the new session - -1. **/investigate (report-only):** produce a ranked **deviation map** — - where acdream's inbound animation + position handling deviates from retail's - `CMotionInterp`/`CSequence`, **anchored on the walk↔run transition-lag - symptom** (not a whole-system rewrite). Parallel research agents are a good - fit. Optionally corroborate with a live capture (below). Deliver the report - + recommended verbatim-port slices; get explicit approval before edits. -2. **Port (after approval):** verbatim-port the deviating retail mechanisms — - most likely the `CSequence` motion-queue / transition + `pending_motions` / - `MotionDone`, and the inbound position/dead-reckoning where it deviates. - Tests-first (parity tests before behavior). **Integrate the sequencer - SURGICALLY** — change the minimum; do not replace the whole transform/cycle - pipeline at once. -3. **Verify:** the walk↔run transition is smooth and no cascading - animation/position desync — user visual + retail-observer side-by-side. - -## Live-capture setup (structured testing) - -- acdream = **OBSERVER** (login as `+Acdream`); the **other player driven from a - retail client** (retail packets = ground truth, so any render artifact is a - pure acdream inbound bug). -- Launch env (PowerShell): the standard live vars + - `ACDREAM_DUMP_MOTION=1` + `ACDREAM_REMOTE_VEL_DIAG=1` (heavy; dumps - `[UM_RAW]`/`[SETCYCLE]`/`[OMEGA_DIAG]`/`[VEL_DIAG]`/`[SEQSTATE]`/`[UPCYCLE]` so - you can correlate each inbound packet with what acdream renders). -- Structured protocol, one motion at a time: walk-straight (start→stop), - run-straight, turn-in-place, curved path, idle, **walk↔run toggle - (the key one)**, abrupt-stop-from-run. -- NOTE: `+Acdream` had `run=15225` (GM-buffed) this session → absolute speeds - unrepresentative; judge **relative** behavior + transitions. - -## Housekeeping - -- **Close the running observer acdream client before the new session rebuilds** - (it locks `AcDream.App.exe`; `dotnet build` fails with the DLL-copy lock - otherwise — the user closes it, we do NOT kill it). -- A background research agent (mapping acdream's inbound animation handling) was - in-flight when this session stopped — orphaned; the new session re-does the - mapping cleanly. - -## Discipline reminders - -- The user's side-by-side retail observations are **axioms** (retail-oracle); - once a mechanism plan exists, artifacts become acceptance tests, not new - investigations (no whack-a-mole). -- Report-only until fixes approved. Decomp-verbatim. Tests-first. -- Every deviation the port introduces/retires updates - `docs/architecture/retail-divergence-register.md` in the same commit. diff --git a/docs/research/2026-07-02-r1-csequence/P0-pins.md b/docs/research/2026-07-02-r1-csequence/P0-pins.md deleted file mode 100644 index bf3b4d27..00000000 --- a/docs/research/2026-07-02-r1-csequence/P0-pins.md +++ /dev/null @@ -1,55 +0,0 @@ -# R1-P0 — pseudocode pins and ambiguity resolutions - -The verbatim extraction lives in `r1-csequence-decomp.md` (1,756 lines, per- -function raw pseudo-C + cleaned translation, line anchors into -`docs/research/named-retail/acclient_2013_pseudo_c.txt`). This note records -the corrections and pins the R1 port codes against, per the gap map -(`r1-gap-map.md`). - -## Corrections to the extraction (verified against raw decomp + ACE) - -1. **§21 `update_internal` cleaned flow is garbled in two places.** The - authoritative skeleton is ACE `Sequence.cs:351-443` (verified verbatim, - matches the raw decomp branch layout at 0x005255d0-0x005259ca): - overshoot check → clamp `frame_number` to `get_high_frame()` (fwd) / - `get_low_frame()` (rev) + compute `frameTimeElapsed` leftover + - `animDone = true` → per-frame crossing loop → `if (!animDone) return` → - AnimDone gate (`anim_list.head != first_cyclic`) → - `advance_to_next_animation` → carry leftover → loop. -2. **Hook direction constants**: forward crossings pass +1 - (0x0052578c, counter `++` after), reverse pass −1 (0x0052590c, counter - `--` after). Retail encoding == ACE/DatReaderWriter `AnimationHookDir` - (Backward=−1 / Both=0 / Forward=1) — no remap. - -## PINNED ambiguity — leftover-time carry after node advance - -Raw decomp at 0x0052598a-0x0052598d APPEARS to zero the elapsed argument -after `advance_to_next_animation` before looping; ACE carries the leftover -(`timeElapsed = frameTimeElapsed`, Sequence.cs:436-442). - -**PIN: the ACE reading (carry the leftover).** Rationale: (a) Binary Ninja -routinely loses x87 stack-slot reassignments (the same artifact class as the -garbled setcc returns in `is_newer`); (b) zeroing would make a lag spike -unable to fast-forward through multiple queued nodes in one tick, visibly -freezing links under hitches — behavior nobody has ever reported on retail; -(c) the loop structure (`while(true)` with the leftover recomputed per -node) only makes sense with a carry. - -**Confirmation pending (not blocking):** cdb breakpoints on -`acclient!CSequence::update` + `acclient!CSequence::advance_to_next_animation` -with hit counters (pattern `tools/cdb/l2g-observer.cdb`) under an induced -stall — an advance/update ratio > 1 in a single update proves the carry. -Fold into the next live retail session; if it DISPROVES the carry, the fix -is one line in `update_internal` + rerun of the P4 conformance suite. - -## P0 addenda for later stages - -- The same cdb session should also capture `append_animation` / - `remove_cyclic_anims` argument logs (P2/P5 goldens) — one session serves - all of R1. -- `frame_number` is x87 `long double` (acclient.h:30747); C# `double` is the - closest available → divergence-register row lands with the P2 commit - (G15). -- ACE's `PhysicsGlobals.EPSILON` subtraction in - `get_starting/get_ending_frame` is an ACE fabrication (compensating for - ACE's `float FrameNumber`) — retail returns bare ints. Do NOT copy (G1). diff --git a/docs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.md b/docs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.md deleted file mode 100644 index 13655d86..00000000 --- a/docs/research/2026-07-02-r1-csequence/r1-acdream-sequencer.md +++ /dev/null @@ -1,377 +0,0 @@ -# acdream AnimationSequencer — current-state map (R1) - -File: `src/AcDream.Core/Physics/AnimationSequencer.cs` (1584 lines, read whole). -Companions: `AnimationCommandRouter.cs` (98 lines), `AnimationHookRouter.cs` (95 -lines), `IAnimationHookSink.cs` (89 lines) — all read whole. - -## 0. Type inventory in AnimationSequencer.cs - -- `IAnimationLoader` — abstraction (`LoadAnimation(uint id) : Animation?`) so - the sequencer can be unit-tested without a real `DatCollection`. -- `DatCollectionLoader : IAnimationLoader` — production impl, wraps - `DatCollection.Get(id)`. -- `PartTransform` (readonly struct) — `Vector3 Origin`, `Quaternion - Orientation`. Output unit of `Advance`. -- `AnimNode` (internal sealed class) — one queue entry: - `Animation Anim; double Framerate; int StartFrame; int EndFrame; bool - IsLooping; bool HasPosFrames; Vector3 Velocity; Vector3 Omega`. - Methods: `MultiplyFramerate(factor)`, `GetStartFramePosition()`, - `GetEndFramePosition()`. `FrameEpsilon = 1e-5` (mirrors retail - `_DAT_007c92b4`). -- `AnimationSequencer` (public sealed class) — the engine itself, one - instance per entity. - -## (a) Feature matrix — AnimationSequencer vs retail CSequence concepts - -| Retail concept | Retail anchor cited in acdream | acdream implementation | Notes / fidelity | -|---|---|---|---| -| Node list (AnimSequenceNode queue) | FUN_00525EB0 `advance_to_next_animation`; ACE `Sequence.cs` | `LinkedList _queue`, `_currNode`, `_firstCyclic` pointers | Doubly-linked list mirrored with .NET `LinkedList`. Non-cyclic head (link frames) + looping tail (`_firstCyclic..end`) invariant maintained explicitly rather than via a node flag walked at runtime. | -| Link resolution (`get_link`) | ACE `MotionTable.cs:395-426`; retail `CMotionTable::GetObjectSequence` 0x00522860 | `GetLink(style, substate, substateSpeed, motion, speed)` private method | Forward-direction path: `Links[(style<<16)|substate][motion]`, then style-level catch-all `Links[style<<16][motion]`. **Reversed-direction branch** added (comment cites "K-fix6"): when either speed is negative, looks up `Links[(style<<16)|motion][substate]` instead (link FROM motion TO substate, played reversed) — handles WalkBackward/SideStepLeft/TurnLeft transitions. Also a `StyleDefaults` fallback under the reversed branch. This 2-branch structure is **acdream's own generalization**, not a literal 1:1 decompile citation (no direct FUN_xxx cited for the branch split itself, only for its constituent parts via ACE line numbers). | -| `adjust_motion` (TurnLeft/SideStepLeft/WalkBackward → mirror) | ACE `MotionInterp.cs:394-428` | Inlined at top of `SetCycle` + duplicated in `HasCycle`: 0x000E→0x000D (negate speed), 0x0010→0x000F (negate speed), 0x0006→0x0005 (negate speed × 0.65 `BackwardsFactor`) | Faithful port; the `0x65` low-byte switch is applied to `motion & 0xFFFFu`, i.e. matched purely on the low 16 bits regardless of class byte. | -| Fast-path re-speed (no restart on same motion) | ACE `MotionTable.cs:132-139` | `SetCycle` early-return branch: if `CurrentStyle==style && CurrentMotion==motion && sign(speedMod)==sign(CurrentSpeedMod)` → `MultiplyCyclicFramerate` instead of rebuild | Faithful. Explicit **sign-flip exception** documented: when adjust_motion flips speedMod's sign while `motion` value itself stays the same (WalkForward with negative speed = backward), the fast path is bypassed by the sign check so a full restart occurs — comment marks this as an acdream-observed necessity (2026-05-02), not literally cited to a retail address. | -| `multiply_cyclic_animation_framerate` | FUN_00525CE0; ACE `Sequence.cs L277-L287` | `MultiplyCyclicFramerate(float factor)` — walks `_firstCyclic..end`, calls `AnimNode.MultiplyFramerate` on each, and also scales `CurrentVelocity *= factor; CurrentOmega *= factor` | Faithful for node framerates. The velocity/omega scaling is justified by algebraic equivalence to ACE's `subtract_motion(old)+combine_motion(new)` (`MotionTable.change_cycle_speed`, `MotionTable.cs L372-L379`) rather than being separately decompiled — an acdream derivation, not a citation of a specific retail scaling line. | -| `GetStartFramePosition` / `GetEndFramePosition` | FUN_00526880 / FUN_005268B0 | `AnimNode.GetStartFramePosition()/GetEndFramePosition()` | Faithful 1:1 including the `EPSILON = _DAT_007c92b4` (hardcoded here as `1e-5` — the retail exact float constant was **not** independently verified against the binary in this file's comments; it's asserted equal). | -| `multiply_framerate` (StartFrame↔EndFrame swap for negative speed) | FUN_005267E0 | `AnimNode.MultiplyFramerate(factor)` | **Explicitly documented divergence**: retail swaps StartFrame↔EndFrame for negative factor; acdream keeps `StartFrame ≤ EndFrame` as an invariant and encodes direction purely via `Framerate`'s sign, compensating in the `Advance` loop's boundary checks instead. Comment states this is valid "because the callers we care about... only ever pass positive factors" — i.e. an acknowledged simplification with a stated (unverified beyond code review) precondition. | -| `update_internal` (per-frame advance loop) | FUN_005261D0; ACE `Sequence.cs:351-443` | `Advance(float dt)` | Faithful structurally: `while (timeRemaining>0 && _currNode!=null)` loop (capped `safety=64` — **acdream-invented safety valve**, no retail citation, guards against a "degenerate motion table" infinite loop). Computes `delta = rate*timeRemaining`; forward vs reverse branches each: (1) detect boundary overflow, (2) clamp `_framePosition` to boundary-epsilon, (3) walk every integer frame crossed applying `ApplyPosFrame` + `ExecuteHooks`, (4) on wrap, call `advance_to_next_animation` (`AdvanceToNextAnimation()`) and continue with leftover `overflow` time. | -| `advance_to_next_animation` (node wrap) | FUN_00525EB0 | `AdvanceToNextAnimation()` — moves `_currNode` to `.Next`, or wraps to `_firstCyclic` if null, else holds on last node if no cyclic tail exists | Faithful. Resets `_framePosition` via `GetStartFramePosition()` on transition. | -| `execute_hooks` | ACE `Sequence.cs:262-270` | `ExecuteHooks(node, frameIndex, playbackDir)` — fires hook if `hook.Direction == Both \|\| hook.Direction == playbackDir` | Faithful 1:1 port of the direction-match condition. | -| Root motion (`AFrame.Combine` / `frame.Subtract`) | ACE (`AFrame.Combine`/`Subtract`, cited generically, no line #) | `ApplyPosFrame(node, frameIndex, reverse)` — forward: `_rootMotionPos += Rotate(pf.Origin, _rootMotionRot); _rootMotionRot = Normalize(_rootMotionRot * pf.Orientation)`; reverse: conjugate-then-subtract | Faithful port of the two composition directions. Accumulated into `_rootMotionPos`/`_rootMotionRot`, drained via `ConsumeRootMotionDelta()`. | -| AnimationDone hook on link drain | ACE `PhysicsObj.add_anim_hook(AnimationHook.AnimDoneHook)` (cited generically) | `AnimationDoneSentinel` (static `AnimationDoneHook{Direction=Both}`) pushed to `_pendingHooks` in `Advance` just before `AdvanceToNextAnimation()`, gated on `!_currNode.Value.IsLooping` | Faithful concept; the sentinel object is a single shared static instance (not per-fire), meaning downstream code cannot distinguish *which* motion completed purely from hook identity — must correlate via entity + timing. Not flagged as a bug in the file; simply a design note for consumers. | -| Quaternion slerp | FUN_005360d0 (chunk_00530000.c:4799-4846) | `SlerpRetailClient(q1, q2, t)` static method | Faithful port including retail's odd step-5 validation-then-linear-fallback quirk (`SlerpEpsilon = 1e-4f` near-parallel check, then acos/sin slerp, then re-validate blend weights ∈[0,1] before trusting the slerp result — falls back to linear otherwise). Explicitly documented as differing from "the standard formula" only in this validation step. | -| Frame-boundary blend (`BuildBlendedFrame`) | Not directly cited to a single retail FUN — described as producing "the current blended keyframe" | `BuildBlendedFrame()` — clamps `frameIdx` to `[rangeLo,rangeHi]`; computes `nextIdx` stepping in playback direction; **wraps only if `curr.IsLooping`**, else holds boundary frame | **Explicitly acdream-motivated fix, not retail-cited**: comment for issue **#61** ("door swing-open flap; run-stop twitch") states holding the boundary frame instead of wrapping-blending into frame 0 for one-shot (non-looping) nodes avoids "a brief flash through the anim's starting pose at the link→cycle boundary." This is the **frame-swap / link→cycle boundary flash class of bug** the deep-dive skill targets — currently patched empirically here, not derived from a decompiled retail boundary-hold mechanism. | -| Placement / root frames | `AnimData.AnimId`, `Animation.PartFrames`, `Animation.PosFrames`, `Frame.Origin/Orientation` (DatReaderWriter types) | `LoadAnimNode` reads `anim.PartFrames.Count` for frame bounds; `hasPosFrames = anim.Flags.HasFlag(PosFrames) && anim.PosFrames.Count >= numFrames` | Sentinel resolution for `HighFrame == -1` ("all frames") ported from `MotionResolver.GetIdleCycle` (cited by name, not address). `if (low > high) high = low` guards a degenerate AnimData. | -| "Fix B" (#39, 2026-05-06) — cyclic→cyclic direct transition skips link | cdb live trace 2026-05-03 of a Walk→Run transition: `add_to_queue(45000005, looping=1)` then `add_to_queue(44000007, looping=1)` with `truncate_animation_list` never firing | In `SetCycle`: `IsLocomotionCycleLowByte` check on both `CurrentMotion` and new `motion`'s low byte (0x05/0x06/0x07/0x0F/0x10); if both are locomotion AND `_firstCyclic` exists, the just-enqueued link node is **removed from the queue** and `_currNode` is forced directly onto `_firstCyclic` | This is the most heavily-commented divergence-turned-fix in the file. Explicit warning left in comments: "Commit c06b6c5 (reverted in a2ae2ae) demonstrated that unconditionally skipping the link breaks all of these [Idle→cycle, Falling→Ready, pose-change links, combat substates]" — i.e. the fix is deliberately **scoped only to the locomotion-cycle subset**, confirmed retail-faithful via a live cdb capture (grep-named-first / attach-cdb workflow), not guessed. | -| K-fix18 — `skipTransitionLink` | Not a retail citation — an acdream product decision, explicitly labeled "K-fix18" in the doc comment | `SetCycle(..., bool skipTransitionLink = false)` param. When true: `linkData` forced null, AND (separately) the entire `_queue` is cleared / `_currNode`/`_firstCyclic` reset to null before rebuild | **Confirmed acdream-invented deviation** (not retail behavior) — used only for Falling-on-jump-start (`GameWindow.cs:4830`, `10201`) to avoid a ~100ms "stop running" pose delay before the fall animation engages. This should have (or needs to be checked against) a `retail-divergence-register.md` row per CLAUDE.md's mandatory bookkeeping rule — the file itself doesn't reference the register. | -| Stop-anim fallback (direction-agnostic settle) | Not retail-cited; acdream reasoning: "settle anim is direction-agnostic" | In `SetCycle`, if `linkData is null` after the primary lookup, retries `GetLink` with `CurrentMotion`'s low byte remapped 0x06→0x05, 0x10→0x0F, 0x0E→0x0D (peer forward/right substate) | Acknowledged as an acdream workaround for a `null linkData` gap when stopping from WalkBackward/SideStepLeft/TurnLeft — motivated by an observed visible glitch ("left leg twitches forward two times") rather than by a decompiled retail fallback path. | -| Stale-head handling | Comment: "For remote entities receiving many bundled UMs over time, this stale-head build-up was the root cause of..." | `preEnqueueTail` snapshot before `EnqueueMotionData` calls; after enqueue, `firstNew = preEnqueueTail?.Next ?? _queue.First`; `_currNode` is **force-set** onto `firstNew` (or `_firstCyclic` under the Fix-B branch) rather than left wherever it was | Acdream-diagnosed and acdream-authored fix (no retail citation) — addresses a structural bug where `_currNode` could remain parked on old non-cyclic head frames left over from a previous `SetCycle` call, which visually manifested as "transitions between cycles don't visibly switch the leg pose" for remote entities. Local player was unaffected because `PlayerMovementController` calls `SetCycle` frequently enough to keep the queue clean — this is called out explicitly as a per-entity-class asymmetry. | -| Velocity/Omega synthesis for locomotion & turn cycles | `CMotionInterp::get_state_velocity` (FUN_00528960 cited by address); `MotionInterpreter.RunAnimSpeed` etc (decompiled from `_DAT_007c96e0/e4/e8`); ACE's `omega.z = ±(π/2)×turnSpeed` (cross-checked against holtburger `motion_resolution.rs`) | Post-`EnqueueMotionData` block in `SetCycle`: switches on `motion & 0xFFu` for 0x05/0x06/0x07/0x0F/0x10, sets `CurrentVelocity` from `WalkAnimSpeed=3.12f` / `RunAnimSpeed=4.0f` / `SidestepAnimSpeed=1.25f` constants × `adjustedSpeed`; separately, if `CurrentOmega` is ~zero, synthesizes turn omega `±(π/2)*adjustedSpeed` for 0x0D/0x0E | **Explicitly justified divergence from literal dat content**: "the Humanoid motion table ships every locomotion MotionData with Flags=0x00 (no HasVelocity)" — i.e. the dat itself is silent on velocity for these cycles, and retail's *actual* body-physics source (`get_state_velocity`) is a separate C++ function not expressed as sequencer/dat state at all. acdream folds that separate retail mechanism into `AnimationSequencer.CurrentVelocity` as a pragmatic single surface for consumers (dead-reckoning, `get_state_velocity` Option-B). Comment explicitly documents an earlier bug: a gate of `if (CurrentVelocity.LengthSquared() < 1e-9f)` previously let dat-baked link velocity "win" over synthesis, breaking walk→run transitions — now **unconditionally overwritten** for known locomotion low-bytes. | -| `clear_physics` before rebuild | ACE `Sequence.cs L256-L260` | `ClearPhysics()` — zeroes `CurrentVelocity`/`CurrentOmega`, called from `SetCycle` before the enqueue chain | Faithful; matches retail's `sequence.clear_physics()` call before each `add_motion` chain (`MotionTable.cs L100-L101, L152-L153`). | -| `add_motion` velocity/omega replace-not-accumulate semantics | ACE `MotionTable.add_motion` (`MotionTable.cs L358-L370`) | `EnqueueMotionData`: `if (motionData.Flags.HasFlag(HasVelocity)) CurrentVelocity = vel;` (replace, not add) similarly for Omega | Faithful — comment explicitly notes this REPLACES not accumulates, and that the final value after link+cycle enqueue is the cycle's (last write wins), which dead-reckoning consumers rely on. | -| PlayAction / Modifier & Action-class overlay | ACE `MotionTable.GetObjectSequence` line 189-207 (Action, mask 0x10) and line 234-242 (Modifier, mask 0x20) | `PlayAction(uint motionCommand, float speedMod=1f)` — tries `GetLink` (Links dict) first for Action-mask commands, then falls back to `_mtable.Modifiers` dict (styled key then plain key) for Modifier-mask commands | Faithful dual-path lookup. Notes Jump (`0x2500003B`) has both Action+Modifier-looking bits but ACE treats it via the Modifier path specifically — cited as a cross-check against ACE, not an independent retail decompile of Jump's own class byte. Inserts new non-looping nodes via `_queue.AddBefore(_firstCyclic, n)` (or `AddLast` if no cyclic tail), and force-jumps `_currNode` to the first inserted node if the cursor was currently on the cyclic tail (so overlay actions play immediately instead of waiting for a cycle wrap). | -| `Sequence.Velocity`/`Sequence.Omega` sequence-wide mirror | ACE `Sequence.cs L127-130`; `MotionTable.add_motion` L358-370 | `CurrentVelocity`/`CurrentOmega` public properties, described in the doc comment as "not per-node" — reflects the **most recently added** MotionData's velocity×speedMod, even while an earlier link node is still playing visually | Faithful semantic port — explicitly documented gotcha: "while a link animation plays, the surfaced velocity is still the cycle's velocity." | -| `HasCycle` (retail cycle-existence probe) | Not a literal retail function name — acdream utility mirroring the head of `SetCycle`'s adjust_motion + cycle-key lookup | `HasCycle(uint style, uint motion) : bool` — duplicates the adjust_motion switch (0x000E/0x0010/0x0006) then checks `_mtable.Cycles.ContainsKey(cycleKey)` | Acdream-invented consumer helper (doc comment explains it exists so callers can fall back to a known-good motion instead of hitting `SetCycle`'s unconditional `ClearCyclicTail` on a missing cycle, which "leaves the body without any animation tail" — visible as "torso on the ground"). Not retail-cited as its own function. | -| Reset | Not retail-cited | `Reset()` — clears queue, hooks, root motion, resets Current* fields to defaults | Utility method for entity despawn/respawn recycling; no retail anchor given. | -| Diagnostics: `CurrentNodeDiag`, `FirstCyclicAnimRefHash`, `[SCFAST]/[SCFULL]/[SCNULLFALLBACK]` logging | N/A — acdream-only | `CurrentNodeDiag` tuple property (AnimRefHash via `RuntimeHelpers.GetHashCode`, IsLooping, Framerate, StartFrame, EndFrame, FramePosition, QueueCount); `FirstCyclicAnimRefHash`; three `Console.WriteLine` blocks gated on `ACDREAM_REMOTE_VEL_DIAG=1`, throttled to 0.5s via `_lastSetCycleDiagTime` | Entirely acdream-invented instrumentation ("Commit A 2026-05-03" / D2/D3/D4 diagnostic labels) for the remote-motion debugging campaign. Per CLAUDE.md rule 5 (diagnostic owner classes), this is a per-call-site `Environment.GetEnvironmentVariable` read pattern flagged in CLAUDE.md itself as "tech debt; do not add more" — these predate that rule and haven't been migrated to a diagnostic-owner class. | - -## (b) Public API surface — what consumers depend on - -### Constructor -``` -AnimationSequencer(Setup setup, MotionTable motionTable, IAnimationLoader loader) -``` -All three args are `ArgumentNullException.ThrowIfNull`'d. Consumers must -supply a non-null `Setup`/`MotionTable`/loader — `RenderBootstrap.cs`'s -`SequencerFactory` local function has a 3-tier fallback chain (real -setup+mtable → real setup + empty `MotionTable()` → fully-empty -`Setup()`+`MotionTable()`+`NullAnimLoader()`) precisely because the ctor -won't accept nulls. - -### Methods -- `bool HasCycle(uint style, uint motion)` — probe before calling `SetCycle` - to avoid the "torso on ground" missing-cycle collapse. Called from - `GameWindow.cs:3723/3728/3732/3824` (initial spawn cycle selection with - Run→Walk→Ready fallback chain) and `RemoteMotionSink.Commit()` (same - fallback pattern for remote UM-driven cycles, plus an `ACDREAM_REMOTE_VEL_DIAG` - `[HASCYCLE]` diagnostic). -- `void SetCycle(uint style, uint motion, float speedMod = 1f, bool - skipTransitionLink = false)` — the primary state-transition entry point. - Callers across the codebase: - - `GameWindow.cs:3751` — initial spawn cycle. - - `GameWindow.cs:3825` — initial cycle after a fallback check. - - `GameWindow.cs:4830` — Falling-on-jump-start with `skipTransitionLink: - true` (K-fix18). - - `GameWindow.cs:4936` (`ApplyServerControlledVelocityCycle`) — NPC/monster - remotes still on the legacy `ServerControlledLocomotion.PlanFromVelocity` - path (pre-S6 unification; comment says this path stays until "S6 unifies - all entity classes onto the CMotionInterp funnel"). - - `GameWindow.cs:5155/5309/9817` — landing cycle / stop-to-ready transitions. - - `GameWindow.cs:10223` — local player's own `SetCycle` call (with sidestep - speed-scaling correction factor `WalkAnimSpeed/SidestepAnimSpeed*0.5` - documented against ACE's `MovementData.cs:124-131` wire formula, #45). - - `RemoteMotionSink.Commit()` (`src/AcDream.App/Rendering/RemoteMotionSink.cs:215`) - — remote **player** entities via the L.2g S2b `CMotionInterp` funnel path - (see below); this is the modern replacement for the legacy path above for - player remotes specifically. - - `AnimationCommandRouter.RouteFullCommand` — routes `SubState`-class - commands here (see router section below). -- `void MultiplyCyclicFramerate(float factor)` — not called directly outside - `AnimationSequencer.cs` in the searched consumer set (invoked internally - by `SetCycle`'s fast-path re-speed branch); no external call sites found - in `src/`. -- `IReadOnlyList Advance(float dt)` — per-frame tick. Sole - call site: `GameWindow.cs:9876` inside the animated-entity tick loop - (`seqFrames = ae.Sequencer.Advance(dt)`), guarded by an - `ae.Sequencer is not null` branch; entities without a sequencer fall to a - "legacy path" (`ae.CurrFrame += dt * ae.Framerate` manual slerp, line - ~9896). -- `IReadOnlyList ConsumePendingHooks()` — drained immediately - after `Advance` at `GameWindow.cs:9882`, fanned out per-hook to - `_hookRouter.OnHook(ae.Entity.Id, worldPos, hook)` (`AnimationHookRouter`, - which further fans out to registered `IAnimationHookSink`s). -- `(Vector3 Position, Quaternion Rotation) ConsumeRootMotionDelta()` — no - call sites found in the searched `src/` consumer grep; root motion - (PosFrames) accumulation exists in the sequencer but nothing currently - drains/consumes it in production code (search of `ConsumeRootMotionDelta` - and `\.PlayAction\(` etc. above did not surface a caller — **this looks - like dead/unwired API surface**, worth flagging for the deep-dive). -- `void PlayAction(uint motionCommand, float speedMod = 1f)` — called from - `AnimationCommandRouter.RouteFullCommand` for `Action`/`Modifier`/ - `ChatEmote` route kinds, and directly from `RemoteMotionSink.ApplyMotion` - for the same route kinds (overlay dispatch for remote entities). -- `void Reset()` — no external call site found in the searched consumer set. - -### Properties (read by consumers) -- `CurrentStyle`, `CurrentMotion`, `CurrentSpeedMod` (uint/uint/float, - private-set) — read by `GameWindow.cs` (style fallback defaults `0x8000003Du` - NonCombat at lines 3723/4827/4919 etc.), `RemoteMotionSink` ctor (seeds - `_style` from `sequencer.CurrentStyle`), `RemoteMotionSink.Commit()` - (`_sequencer.CurrentMotion`/`CurrentSpeedMod` for diagnostic comparison), - `GameWindow.cs:4915` (`ApplyServerControlledVelocityCycle`'s - `IsRemoteLocomotion(currentMotion)` gate). -- `CurrentVelocity` / `CurrentOmega` (`Vector3`, private-set) — the most - cross-cutting output surface: - - `GameWindow.cs:9331-9334` — remote player per-tick body translation - (`seqVel`/`seqOmega` feed `PositionManager`-style catch-up/anim - composition; extensive comment block on lines 9310-9330 walks through - retail's `CPartArray::Update` → `PositionManager::adjust_offset` → - `Frame::combine` per-tick pipeline this is meant to mirror). - - `PlayerMovementController.AttachCycleVelocityAccessor(() => - playerSeq.CurrentVelocity)` (`GameWindow.cs:12917`) — wires the local - player's sequencer velocity into `MotionInterpreter.GetCycleVelocity` - as an override of the decompiled constant path (`RunAnimSpeed * - ForwardSpeed`), used because arbitrary creatures' MotionTables don't all - bake `Velocity=4.0` on RunForward the way Humanoid does. - - `MotionInterpreter.cs` — comments describe `CurrentVelocity` as "already - `MotionData.Velocity * speedMod`" (body-local) and reference it as the - accessor payload described above; also cross-checks - `AnimationSequencer.CurrentVelocity`/`CurrentOmega` doc pointers at - lines 347/376/632/662. -- `QueueCount` (int) — diagnostic only (`CurrentNodeDiag` tuple's last - field also duplicates it). -- `HasCurrentNode` (bool) — diagnostic; no external call site found in - the searched set beyond the type itself. -- `CurrentNodeDiag` / `FirstCyclicAnimRefHash` — consumed by - `GameWindow.cs:9863-9871` inside an `ACDREAM_REMOTE_VEL_DIAG`-gated - `[CURRNODE]` log block that compares `AnimRefHash` against - `FirstCyclicAnimRefHash` to detect whether the visible animation is - actually on the intended cyclic tail. - -### Nested/companion API — `IAnimationLoader` / `DatCollectionLoader` -- `IAnimationLoader.LoadAnimation(uint id) : Animation?` — implemented in - production by `DatCollectionLoader` (wraps `DatCollection.Get`), - constructed once in `RenderBootstrap.cs:138` (`animLoader`) and passed by - reference into every `SequencerFactory`-built sequencer; a - `NullAnimLoader` (referenced but not shown in this file — defined - elsewhere) is the last-resort fallback in `RenderBootstrap`'s factory. - -## AnimationCommandRouter.cs — companion routing layer - -Static class, no state. Cited retail anchors: -`CMotionTable::GetObjectSequence` 0x00522860, -`CMotionInterp::DoInterpretedMotion` 0x00528360, plus -`docs/research/deepdives/r03-motion-animation.md` section 3. - -- `Classify(uint fullCommand) : AnimationCommandRouteKind` — classifies by - class-mask bits: `0x12000000`/`0x13000000` class → `ChatEmote`; - `ModifierMask=0x20000000` → `Modifier`; `ActionMask=0x10000000` → - `Action`; `SubStateMask=0x40000000` → `SubState`; `0` → `None`; else - `Ignored`. (Note: checks Modifier before Action, meaning any command with - both bits set classifies as Modifier — matches the `PlayAction` comment - about Jump 0x2500003B having both bits but ACE treating it as Modifier.) -- `RouteWireCommand(sequencer, currentStyle, ushort wireCommand, speedMod)` — - reconstructs the full 32-bit command via - `MotionCommandResolver.ReconstructFullCommand(wireCommand)` then delegates - to `RouteFullCommand`. -- `RouteFullCommand(sequencer, currentStyle, fullCommand, speedMod)` — - dispatch table: `Action`/`Modifier`/`ChatEmote` → `sequencer.PlayAction(fullCommand, - speedMod)`; `SubState` → `sequencer.SetCycle(currentStyle, fullCommand, - speedMod)`. Returns the classified `AnimationCommandRouteKind` to the - caller for logging/branching. - -`AnimationCommandRouteKind` enum: `None, Action, Modifier, ChatEmote, -SubState, Ignored`. - -Consumers: `RemoteMotionSink.ApplyMotion` calls `AnimationCommandRouter.Classify` -directly (to special-case Turn/Sidestep before falling through) and -`RouteFullCommand` for the overlay branch. No other call sites found in the -searched consumer set for `AnimationCommandRouter` itself (GameWindow.cs -appears to call `Sequencer.SetCycle`/`PlayAction` directly rather than -through the router in most of the enumerated call sites above — the router -is primarily the remote-entity/funnel-driven path's dispatch layer). - -## AnimationHookRouter.cs / IAnimationHookSink.cs — hook fan-out layer - -- `IAnimationHookSink.OnHook(uint entityId, Vector3 entityWorldPosition, - AnimationHook hook)` — single-method interface. `AnimationHook` and its - subclasses (`SoundHook`, `SoundTableHook`, `SoundTweakedHook`, - `CreateParticleHook`, `DestroyParticleHook`, `StopParticleHook`, - `CallPESHook`, `DefaultScriptHook`, `DefaultScriptPartHook`, - `AttackHook`, `ReplaceObjectHook`, `TransparentHook`, `LuminousHook`, - `DiffuseHook`, `ScaleHook`, `NoDrawHook`, `SetOmegaHook`, - `TextureVelocityHook`, `SetLightHook`, `AnimationDoneHook`) are all - **external types from `DatReaderWriter.Types`**, not defined in - acdream's own tree — they're dat-format hook payload classes. The doc - comment enumerates intended downstream routing (Phase E.2 audio / E.3 - particles / E.4 combat dispatcher / renderer state mutation / UI - notifications) but this file itself contains no subsystem logic — it's - purely the interface contract + a `NullAnimationHookSink` no-op - singleton for tests/headless. -- `AnimationHookRouter : IAnimationHookSink` — composite/fan-out - implementation. `Register(sink)` / `Unregister(sink)` are lock-protected - (`_gate`), copy-on-write into a `IAnimationHookSink[]` array (idempotent - register — checks `ReferenceEquals` before adding). `OnHook` iterates a - snapshot array **without locking** (explicitly commented "no lock in the - hot path (render thread)") and wraps each sink's `OnHook` call in a - `try/catch` that **silently swallows all exceptions** ("one misbehaving - sink must not take down the entire animation tick... individual - subsystems can log their own errors internally") — this is a blanket - catch-and-discard, worth flagging against CLAUDE.md's "no workarounds" - spirit if a sink is silently failing (per the user's `feedback_logger_injection_for_silent_catches.md` - memory note: libs that silently catch+return null usually should inject - an `ILogger`; this router has no logger injection point at all). - `Sinks` property exposes a read-only snapshot for diagnostics/tests. - -Only one production sink registration path was located via the router's own -file (none) — sink registration call sites (`_hookRouter.Register(...)`) -were not enumerated in this pass; the single confirmed dispatch call site -is `GameWindow.cs:9890` (`_hookRouter.OnHook(ae.Entity.Id, worldPos, hook)`). - -## How MotionData/anim dat structures reach the sequencer - -- **Setup** (`DatReaderWriter.DBObjs.Setup`) — supplies `Parts.Count` (used - by `Advance`/`BuildBlendedFrame`/`BuildIdentityFrame` to size the - `PartTransform[]` output) and `DefaultMotionTable` id (used by - `RenderBootstrap.SequencerFactory` to look up the paired `MotionTable`). -- **MotionTable** (`DatReaderWriter.DBObjs.MotionTable`) — three - dictionaries consumed directly: - - `Cycles : Dictionary` keyed by - `(style<<16)|(adjustedMotion&0xFFFFFF)` — the looping tail source, - read in `SetCycle`/`HasCycle`. - - `Links : Dictionary` where - `MotionCommandData.MotionData : Dictionary` — the - transition-frame source, read in `GetLink` (both forward and - reversed-key branches) and in `PlayAction`'s Action-mask lookup. - - `Modifiers : Dictionary` — the overlay/action source - for Modifier-mask commands in `PlayAction`, tried with a styled key - first then a plain (unstyled) key. - - `StyleDefaults : Dictionary` — used only - inside `GetLink`'s reversed-direction fallback branch. -- **MotionData** (per Cycles/Links/Modifiers entry) — `Anims : - List`; `Velocity`/`Omega : Vector3`; `Flags : - MotionDataFlags` (`HasVelocity=0x01`, `HasOmega=0x02`) gates whether - `Velocity`/`Omega` are applied or zeroed in `EnqueueMotionData` and - `PlayAction`. -- **AnimData** — `AnimId : QualifiedDataId` (cast to `uint` for - `IAnimationLoader.LoadAnimation`), `LowFrame`/`HighFrame` (sentinel - `HighFrame == -1` meaning "all frames", resolved in `LoadAnimNode`), - `Framerate` (float, multiplied by `speedMod` to produce the `double - Framerate` stored on `AnimNode`). -- **Animation** (loaded via `IAnimationLoader.LoadAnimation(animId)`) — - `PartFrames : List` (drives `numFrames` bounds-clamping - in `LoadAnimNode` and the per-part lookup in `BuildBlendedFrame`); - `PosFrames : List` (root motion, gated by `Flags.HasFlag(PosFrames)` - AND `PosFrames.Count >= numFrames`); `Flags : AnimationFlags`. -- **AnimationFrame** — `Frames : List` (one `Frame` per Setup part — - indexed by part index in `BuildBlendedFrame`'s `f0Parts`/`f1Parts` - lookups, defaulting to identity for parts beyond the frame's list length), - `Hooks : List` (read in `ExecuteHooks`). -- **Frame** — `Origin : Vector3`, `Orientation : Quaternion` — the raw - per-part or per-posframe pose sample, lerped/slerped in - `BuildBlendedFrame` or combined in `ApplyPosFrame`. - -Entry point that ties Setup+MotionTable+Loader together for a live entity: -`RenderBootstrap.cs`'s `SequencerFactory(WorldEntity e)` local function -(lines ~147-174, moved verbatim from the pre-extraction `GameWindow.cs` -~2306-2334 per its own comment) — looks up `Setup` via -`dats.Get(e.SourceGfxObjOrSetupId)`, then `MotionTable` via -`dats.Get(setup.DefaultMotionTable)`, falling back through -three tiers (real/real → real-setup/empty-mtable → empty/empty) so the -constructor's null-guards are never tripped for any spawned entity, even -ones missing dat data. - -## (c) acdream-invented vs dat-driven — summary classification - -**Purely dat-driven (faithful mechanical port of retail's node/frame model):** -- Node list structure, link resolution forward-path, `GetStartFramePosition`/ - `GetEndFramePosition`, `multiply_framerate` semantics (modulo the - documented Start/EndFrame-swap simplification), `update_internal`'s - frame-boundary walk + hook firing + root-motion accumulation, - `advance_to_next_animation` wrap, `execute_hooks` direction matching, - the retail slerp, `clear_physics`/`add_motion` replace-semantics for - Velocity/Omega, PlayAction's Action/Modifier dual-path lookup. - -**acdream-invented (no retail citation, or explicitly-flagged deviation/workaround):** -1. **K-fix18 `skipTransitionLink`** — product decision to skip the retail - transition-link pose for Falling-on-jump-start; not retail behavior. -2. **Fix B locomotion cyclic→cyclic link-skip** — scoped fix verified via - live cdb trace (so it IS retail-faithful for that subset), but the - *mechanism* (removing the enqueued link node, forcing `_currNode` onto - `_firstCyclic`) is acdream's own structural patch, not a literal port - of a retail function. -3. **Stale-head `_currNode` force-relocation** (`preEnqueueTail`/`firstNew` - tracking) — acdream bug-fix for a structural mismatch versus retail's - apparent behavior; no retail citation for the mechanism itself. -2. **Stop-anim fallback** (direction-agnostic settle-link retry) — acdream - workaround for a null-linkData gap. -5. **`GetLink`'s reversed-direction branch** — while individually justified - against an *observed* bug (X-key twitch), it's presented as acdream's - own two-branch generalization of `get_link`, cross-checked against ACE - line ranges rather than a single retail function decompile. -6. **CurrentVelocity/CurrentOmega synthesis for locomotion/turn cycles** - (`WalkAnimSpeed=3.12f`, `RunAnimSpeed=4.0f`, `SidestepAnimSpeed=1.25f` - constants, turn `±π/2` synthesis) — folds a *separate* retail C++ - mechanism (`CMotionInterp::get_state_velocity`, a physics-side - function) into the *sequencer's* velocity surface because the - Humanoid dat itself carries no baked velocity for these MotionData - entries. Functionally retail-faithful in intent but architecturally a - deviation (retail keeps this computation outside the Sequence object - entirely). -7. **BuildBlendedFrame's non-looping boundary-hold** (issue #61 fix) — an - empirically-motivated patch for the link→cycle boundary flash, without - a cited retail mechanism describing how the real client avoids this - flash. **This is exactly the class of bug the animation-sequencer - deep-dive skill was built to root-cause** — the current fix may be - masking rather than replicating retail's actual boundary behavior. -8. **`safety = 64` loop cap in `Advance`** — defensive engineering, no - retail citation. -9. **All `[SCFAST]/[SCFULL]/[SCNULLFALLBACK]/[CURRNODE]` diagnostics** — - acdream-only instrumentation, per-call-site env var reads (flagged by - CLAUDE.md itself as a tech-debt pattern that shouldn't be extended - further without promotion to a diagnostic-owner class per Code - Structure Rule 5). -10. **`AnimationDoneSentinel` as a single shared static instance** — works - for "some link just finished" notifications but can't carry per-fire - identity; not verified against how retail's `AnimDoneHook` actually - carries context. - -**Unwired / apparently dead API surface found during this pass:** -- `ConsumeRootMotionDelta()` — no call site found in `src/` outside the - sequencer itself. Root motion (PosFrames) is accumulated every tick but - nothing drains it into entity placement. -- `Reset()` — no external call site found in the searched consumer set. -- `MultiplyCyclicFramerate` — only self-invoked by `SetCycle`'s fast path; - no direct external callers. -- `HasCurrentNode` — no external call site found beyond its own - declaration. - -## Cross-references worth pulling into the deep-dive - -- `docs/research/acclient_animation_pseudocode.md` — cited pseudocode doc - for FUN_005267E0/FUN_00525EB0/FUN_00526880/FUN_005268B0/FUN_005360d0/ - FUN_005261D0 (sections 5-7). -- `docs/research/deepdives/r03-motion-animation.md` section 3 — cited by - `AnimationCommandRouter.cs` for the class-mask routing scheme. -- `references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs` and - `MotionTable.cs` — the ACE C# port cross-referenced throughout - (`Sequence.cs:127-130, 256-260, 262-270, 277-287, 351-443`; - `MotionTable.cs:100-101, 132-139, 152-153, 358-370, 372-379, 395-426`). -- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs:394-428` - — `adjust_motion` source. -- `docs/research/2026-06-04-animation-sequencer-deep-dive.md` (per - MEMORY.md) — prior deep-dive already exists; ranked 8 divergences - including "missing pending_motions/MotionDone HIGH" — **this current - file does not implement any `pending_motions` concept** (no field named - that, no queueing of *future* motions ahead of the current queue beyond - the single link+cycle model) — worth checking whether that HIGH-ranked - gap from the prior research drop is still open against this current - implementation. diff --git a/docs/research/2026-07-02-r1-csequence/r1-ace-sequence.md b/docs/research/2026-07-02-r1-csequence/r1-ace-sequence.md deleted file mode 100644 index 31a60e92..00000000 --- a/docs/research/2026-07-02-r1-csequence/r1-ace-sequence.md +++ /dev/null @@ -1,583 +0,0 @@ -# ACE CSequence port map — cross-reference vs. 2013 retail decomp - -Scope: `references/ACE/Source/ACE.Server/Physics/Animation/{Sequence.cs, AnimSequenceNode.cs, -AnimData.cs, AFrame.cs}` + `MotionTable.cs`'s `add_motion/combine_motion/subtract_motion/change_cycle_speed`. -Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (`CSequence::*`, -`AnimSequenceNode::*`, free functions `add_motion`/`combine_motion`/`subtract_motion`/`change_cycle_speed`) -+ verbatim struct defs in `docs/research/named-retail/acclient.h` (line 30747 `CSequence`, line 31063 -`AnimSequenceNode`). - -## HEADLINE DIVERGENCE (all downstream methods inherit this) - -**`Sequence.FrameNumber` is `float` in ACE; retail's `CSequence::frame_number` is `long double` -(80-bit x87 extended precision), not even `double`.** -`acclient.h:30754`: `long double frame_number;` -Every retail arithmetic op on frame_number/timeElapsed in `update_internal`, -`advance_to_next_animation`, `apply_physics` explicitly upcasts to `(long double)` before the -op and downcasts back only where storing into a `float` field (velocity/omega components, -`AnimSequenceNode::framerate`). The comparisons that decide "did we cross a frame boundary" run -at x87-extended precision in retail, at `float` precision in ACE. -Public entry point confirms the wire type: `CSequence::update(class CSequence* this, double arg2, class Frame* arg3)` -@ `0x00525b80` — the *external* `quantum` parameter is `double`, matching `MotionInterp` callers -(ACE's `Sequence.Update(float quantum, ...)` is `float`). Internally retail immediately treats it -as `long double` in the callee. -**Practical effect:** frame-boundary crossing math (`Math.Floor(frameNum) > lastFrame`, -`get_high_frame() < Math.Floor(frameNum)`) can disagree between ACE (float) and retail (80-bit) -at the ULP level near exact frame boundaries — this is a plausible root cause for -subtle frame-swap / off-by-one animation bugs when the accumulated quantum sum lands very close -to an integer frame number. acdream's own port should use `double` at minimum (C# doesn't expose -80-bit extended); pure `float` (ACE's choice) is the most divergent option available. - -## `Sequence.cs` (ACE) ↔ `CSequence` (retail) — per-method map - -### Fields -| ACE field | Retail field (`acclient.h:30747`) | Type match? | -|---|---|---| -| `int ID` | (not in CSequence; ACE-only bookkeeping, no retail equivalent found in struct) | N/A | -| `LinkedList AnimList` + `FirstCyclic`/`CurrAnim` as `LinkedListNode` | `DLList anim_list` (intrusive doubly-linked list) + `AnimSequenceNode *first_cyclic` + `AnimSequenceNode *curr_anim` | Structural match; ACE trades retail's intrusive DLList for `System.Collections.Generic.LinkedList`, semantically equivalent but allocates node wrappers separately (see `apricot()` note below) | -| `Vector3 Velocity` | `AC1Legacy::Vector3 velocity` | float×3, match | -| `Vector3 Omega` | `AC1Legacy::Vector3 omega` | float×3, match | -| `PhysicsObj HookObj` | `CPhysicsObj *hook_obj` | match | -| `float FrameNumber` | `long double frame_number` | **DIVERGENT — see headline** | -| `AnimationFrame PlacementFrame` | `AnimFrame *placement_frame` | match | -| `int PlacementFrameID` | `unsigned int placement_frame_id` | match (signedness cosmetic) | -| `bool IsTrivial` | `int bIsTrivial` | match (never read/written elsewhere in ACE's Sequence.cs — dead field there too) | - -### Constructor / `Init()` -- Retail `CSequence::CSequence` (`0x005249f0`): `memset(&anim_list, 0, 0x28)` then - `memset(&frame_number, 0, 0x18)` — i.e. zeroes `frame_number`, `curr_anim`, `placement_frame`, - `placement_frame_id`, `bIsTrivial` in one sweep. Velocity/omega/first_cyclic/hook_obj are - zeroed by the first memset (part of `anim_list`+`first_cyclic`+`velocity`+`omega`+`hook_obj` - span, 0x28 bytes). -- ACE `Init()`: sets `Velocity = Zero`, `Omega = Zero`, `FrameNumber = 0.0f`, `AnimList = new()`. - Does **not** reset `CurrAnim`, `FirstCyclic`, `HookObj`, `PlacementFrame`, - `PlacementFrameID`, `IsTrivial` — those are left at C# default (null/0) only because `Init()` - is only ever called from the ctor in practice. Faithful for the ctor path; would silently diverge - if `Init()` were ever called again on a live Sequence (retail's memset always re-zeros - everything; ACE's `Init()` does not). - -### `Update(quantum, ref offsetFrame)` ↔ `CSequence::update` (`0x00525b80`) -Exact 1:1 structural match: -``` -retail: if (anim_list.head_ != 0) { update_internal(...); apricot(); return; } - else if (arg3 != null) apply_physics(arg3, arg2, arg2); -ACE: if (AnimList.First != null) { update_internal(...); apricot(); } - else if (offsetFrame != null) apply_physics(offsetFrame, quantum, quantum); -``` -Param type: retail `arg2` is `double` at this boundary (external quantum). ACE's `quantum` is -`float`. See headline. - -### `advance_to_next_animation` ↔ `CSequence::advance_to_next_animation` (`0x005252b0`) -Retail signature: `(this, double arg2 /*timeElapsed*/, AnimSequenceNode** arg3 /*animNode*/, -double* arg4 /*frameNum*/, Frame* arg5 /*frame*/)`. -Structurally identical two-branch dispatch on `timeElapsed >= 0.0`: -- **Forward branch** (`timeElapsed >= 0`): if `frame != null && currAnim.Framerate < 0` (i.e. - finishing a *reverse-playing* anim), subtract `get_pos_frame(frameNum)`, apply_physics with - `1/framerate` if `|framerate| > EPSILON`. Then advance `animNode` to `.Next` or wrap to - `FirstCyclic`. `frameNum = currAnim.get_starting_frame()`. If `frame != null && framerate > 0` - (starting a *forward-playing* anim), combine pos_frame, apply_physics. - ACE's `advance_to_next_animation` (`Sequence.cs:145`) matches line-for-line, including the - `Math.Abs(currAnim.Framerate) > PhysicsGlobals.EPSILON` guards on `apply_physics`. -- **Backward branch** (`timeElapsed < 0`): mirror image — subtract when `framerate >= 0` - (finishing forward-playing), step to `.Previous` or wrap to `List.Last`, `frameNum = - get_ending_frame()`, combine when `framerate < 0` (starting reverse-playing). - ACE matches. -- EPSILON constant used for `|framerate|` compares: retail literal `0.000199999995f` ≈ - `0.0002f` = `ACE.Server.Physics.PhysicsGlobals.EPSILON` (`references/ACE/Source/ACE.Server/Physics/PhysicsGlobals.cs:9`). Confirmed identical constant. - -### `append_animation` ↔ `CSequence::append_animation` (`0x00525510`) -Retail: allocate `AnimSequenceNode(arg2)`; if `has_anim()` fails, delete + return (no-op). Else -insert at tail of `anim_list`, set `first_cyclic = tail` (**every** append moves `first_cyclic` -to the newest node — i.e. "cyclic" region is always exactly the last-appended anim until removed). -If `curr_anim == null`: set `curr_anim = head`, `frame_number = get_starting_frame(head)` (or, -in an unreachable dead branch, `get_starting_frame(nullptr)` if head is somehow still null after -just inserting — BinNinja artifact, not real control flow). -ACE (`Sequence.cs:203`) matches: `if (!node.has_anim()) return;` — but ACE does NOT delete the -orphan node explicit (no-op is fine in GC'd C#, matches retail's leak-avoidance intent). `AnimList.AddLast`, `FirstCyclic = AnimList.Last`, `if (CurrAnim == null) { CurrAnim = AnimList.First; FrameNumber = CurrAnim.Value.get_starting_frame(); }`. Faithful. - -### `apply_physics(frame, quantum, sign)` ↔ `CSequence::apply_physics` (`0x00524ab0`) -Retail: `quantum = sign>=0 ? |quantum| : -|quantum|` (sign-copy pattern — note retail's param -order is `(Frame*, double quantum-as-arg3, double sign-as-arg4)`, i.e. **arg3 is the magnitude -source, arg4 is only used for its sign**). Then `Origin += Velocity * quantum` per-axis (retail -does each axis as a separate cast-heavy expression — no semantic difference), `Frame::rotate(Omega -* quantum)`. -ACE (`Sequence.cs:221`): `if (sign>=0.0) quantum=Abs(quantum); else quantum=-Abs(quantum); frame.Origin += Velocity*quantum; frame.Rotate(Omega*quantum);` — exact match. All arithmetic in -retail runs at `long double`; ACE at `float`. Same headline-precision divergence as FrameNumber. - -### `apricot()` ↔ `CSequence::apricot` (`0x00524b40`) -Purpose: after `update_internal` may have advanced `curr_anim` forward past older entries, -prune any anim nodes from `anim_list.head_` up to (but not including) `curr_anim`, UNLESS we hit -`first_cyclic` first (in which case stop — don't prune into the cyclic region). -Retail: `i = head; if (i != curr_anim) { while (i != first_cyclic) { if (i == first_cyclic) -break; delete-unlink(i); i = head; if (i == curr_anim) break; } }` — i.e. loop re-reads `head` -after every unlink (since unlinking changes what head is), and has a **redundant double check** -of `i == first_cyclic` (once as the while-condition, once again as the first statement inside -the loop before the delete — likely because retail's `while` condition is evaluated at the *top*, -and the body immediately re-checks in case the initial `head` already equals `first_cyclic`, which -would only be reachable if `i != curr_anim` was somehow also true — defensive but effectively the -same predicate twice). -ACE (`Sequence.cs:232`): -```csharp -var node = AnimList.First; -while (!node.Equals(CurrAnim)) { - if (node.Equals(FirstCyclic)) break; - AnimList.Remove(node); - node = AnimList.First; -} -``` -Semantically equivalent to retail (loop-while-not-curr-anim, break-if-first-cyclic, remove head, -re-read head). Faithful port; the double-check redundancy in retail's disassembly collapses to -ACE's single `if` because C#'s `while(cond)` + `if(cond) break` at the top of the body is exactly -retail's structure once the duplicate compiler artifact is discounted. - -### `clear_animations()` ↔ `CSequence::clear_animations` (`0x00524dc0`) -Retail: pop every node off `anim_list` (unlink+delete each), then `first_cyclic = nullptr`, -`frame_number = 0`, `curr_anim = nullptr`. -ACE: `AnimList.Clear(); FirstCyclic = null; FrameNumber = 0; CurrAnim = null;` — exact match -(GC handles the per-node delete implicitly). - -### `clear_physics()` ↔ `CSequence::clear_physics` (`0x00524d50`) -Retail: zero `velocity` and `omega` component-wise. ACE: `Velocity = Vector3.Zero; Omega = -Vector3.Zero;`. Match. - -### `Clear()` ↔ `CSequence::clear` (`0x005255b0`) -Retail: `clear_animations(); clear_physics();` — does **NOT** touch `placement_frame` / -`placement_frame_id` in the retail disasm shown (only two calls visible at `0x005255b3`/ -`0x005255ba`). ACE's `Clear()` (`Sequence.cs:71`) additionally sets `PlacementFrame = null; -PlacementFrameID = 0;` — **this is an ACE-only addition beyond what the two-instruction retail -`clear()` body does.** Worth flagging as a possible ACE embellishment/bug if a future port -strictly mirrors retail's `clear()`; however note ACE's own `~CSequence`/dtor is not modeled at -all (C# has no destructor equivalent needed), so this may be ACE compensating for a different owner-lifecycle assumption. Flag for acdream: **do not blindly copy ACE's `Clear()` — verify whether placement-frame reset belongs here or only in `UnPack`** (retail's `UnPack` explicitly nulls `placement_frame`/`placement_frame_id` — see below). - -### `remove_cyclic_anims()` ↔ `CSequence::remove_cyclic_anims` (`0x00524e40`) -Retail: iterate from `first_cyclic` forward (`AnimSequenceNode::GetNext`); for each node, if -`curr_anim == node`: set `curr_anim = GetPrev(node)`; if that prev is null, `frame_number = 0`, -else `frame_number = get_ending_frame(prev)`. Then unlink+delete `node` regardless. After the -loop, `first_cyclic = anim_list.tail_` (or null if list now empty — implied by the trailing code -not fully captured above but consistent with ACE). -ACE (`Sequence.cs:303`): -```csharp -var node = FirstCyclic; -while (node != null) { - if (CurrAnim.Equals(node)) { - CurrAnim = node.Previous; - if (CurrAnim != null) FrameNumber = CurrAnim.Value.get_ending_frame(); - else FrameNumber = 0.0f; - } - var next = node.Next; - AnimList.Remove(node.Value); - node = next; -} -FirstCyclic = AnimList.Last; -``` -Faithful — matches retail's per-node dispose-then-advance and the final `first_cyclic = tail` -reset. - -### `remove_link_animations(amount)` ↔ `CSequence::remove_link_animations` (`0x00524be0`) -Retail: loop `amount` times; each iteration, if `GetPrev(first_cyclic) == null` return early -(no more link anims to remove); if `curr_anim == GetPrev(first_cyclic)`, snap `curr_anim = -first_cyclic` and `frame_number = get_starting_frame(first_cyclic)`; then unlink+delete -`GetPrev(first_cyclic)`. -ACE (`Sequence.cs:324`) matches exactly, including the early-return-not-break semantics -(`if (FirstCyclic.Previous == null) return;` inside the `for` loop — matches retail's `break`-out-of-do-while-then-return-since-nothing-else-follows pattern). - -### `remove_all_link_animations()` ↔ `CSequence::remove_all_link_animations` (`0x00524ca0`) -Retail: `while (first_cyclic != null && GetPrev(first_cyclic) != null) { same snap-then-delete -pattern as remove_link_animations, unbounded }`. -ACE (`Sequence.cs:289`): `while (FirstCyclic != null && FirstCyclic.Previous != null) { if -(CurrAnim.Equals(FirstCyclic.Previous)) { CurrAnim = FirstCyclic; if (CurrAnim != null) -FrameNumber = CurrAnim.Value.get_starting_frame(); } AnimList.Remove(FirstCyclic.Previous); }` -Match. - -### `execute_hooks(animFrame, dir)` ↔ `CSequence::execute_hooks` (`0x00524830`) -Retail: `if (hook_obj != 0) for (hook in animFrame->hooks) if (hook.direction_ == 0 /*Both*/ || -dir == hook.direction_) hook_obj->add_anim_hook(hook)`. -ACE (`Sequence.cs:262`): `if (animFrame == null || HookObj == null) return; foreach (hook in -animFrame.Hooks) if (hook.Direction == Both || hook.Direction == dir) HookObj.add_anim_hook(hook);` -Match (ACE adds an explicit `animFrame == null` guard retail doesn't need because retail always -passes a valid `arg2` from `AnimSequenceNode::get_part_frame` which can itself return null — -retail's caller `update_internal` still calls `execute_hooks` unconditionally with a -possibly-null frame pointer, then retail's `execute_hooks` dereferences `arg2->hooks` **without -a null check** at `0x00524844`. **This is a latent null-deref risk in retail itself** if -`get_part_frame` returns null for an out-of-range frame index — ACE's added `animFrame == null` -guard is a defensive divergence, not a bug, and is the *correct* choice for a managed port. Note -this in case a "PARTSDIAG null-guard" investigation (per project memory) surfaces this exact -retail-side latent null-deref as the root cause of a real bug.) - -### `get_curr_frame_number()` ↔ `CSequence::get_curr_frame_number` (`0x005249d0`) -Retail: `floor(frame_number); return (int)floor_result` (`_ftol2` = truncating float-to-long -after `floor`). ACE: `(int)Math.Floor(FrameNumber)`. Match, modulo the float-vs-long-double -headline divergence. - -### `get_curr_animframe()` ↔ `CSequence::get_curr_animframe` (`0x00524970`) -Retail: `if (curr_anim == null) return placement_frame; return curr_anim->get_part_frame((int)floor(frame_number));` -ACE `GetCurrAnimFrame()` (`Sequence.cs:89`): `if (CurrAnim == null) return PlacementFrame; return -CurrAnim.Value.get_part_frame(get_curr_frame_number());` where `get_curr_frame_number()` is the -same floor+truncate. Match. - -### `set_placement_frame` / `set_velocity` / `set_omega` / `combine_physics` / `subtract_physics` -All four are trivial field setters/accumulators in both retail and ACE — verified byte-for-byte -equivalent logic (`Sequence.cs:111-130`, `:83-87`, `:345-349`). No divergence. - -### `multiply_cyclic_animation_framerate(rate)` ↔ `CSequence::multiply_cyclic_animation_fr` (`0x00524940`) -Retail: `for (n = first_cyclic; n != null; n = GetNext(n)) AnimSequenceNode::multiply_framerate(n, rate);` -ACE (`Sequence.cs:277`): identical loop over `FirstCyclic`. Match. - -### `update_internal(timeElapsed, ref animNode, ref frameNum, ref frame)` ↔ `CSequence::update_internal` (`0x005255d0`) -This is the core per-tick state machine; retail's decompiled x87 soup (heavy `long double` -comparison-flag reconstruction, unresolved `fld`/`fcomp` mnemonics the decompiler couldn't -symbolize) obscures direct reading, but the **control-flow skeleton is fully recoverable** and -matches ACE 1:1: -``` -lastFrame = floor(frameNum) -frametime = framerate * timeElapsed -frameNum += frametime -frameTimeElapsed = 0 -animDone = false -if (frametime > 0): - if (get_high_frame() < floor(frameNum)): - frameOffset = frameNum - get_high_frame() - 1; clamp to >=0 - if |framerate| > EPS: frameTimeElapsed = frameOffset / framerate - frameNum = get_high_frame() - animDone = true - while floor(frameNum) > lastFrame: - if frame != null: - combine pos_frame(lastFrame) into frame (if pos_frames != null) - if |framerate| > EPS: apply_physics(frame, 1/framerate, timeElapsed) - execute_hooks(get_part_frame(lastFrame), Forward) - lastFrame++ -elif (frametime < 0): - [mirror: get_low_frame(), subtract instead of combine, Backward hooks, lastFrame--] -else: - if frame != null && |timeElapsed| > EPS: apply_physics(frame, timeElapsed, timeElapsed) - -if (!animDone): return -if (hook_obj != null && head != first_cyclic): hook_obj->add_anim_hook(AnimDoneHook) -advance_to_next_animation(timeElapsed, ref animNode, ref frameNum, ref frame) -timeElapsed = frameTimeElapsed -[LOOP back to top] <-- retail implements this as an actual `while(true)` loop with the - call to advance_to_next_animation + timeElapsed reassignment, - THEN re-enters the top of the function body (0x005255e8 label). -``` -ACE (`Sequence.cs:351`) implements the exact same skeleton but as **explicit tail recursion** -(`update_internal(timeElapsed, ref animNode, ref frameNum, ref frame);` as the last statement) -rather than retail's `while(true)` loop. Semantically equivalent (C#'s JIT does NOT guarantee -tail-call optimization for this shape, so very long same-tick multi-anim-boundary crossings -could theoretically risk stack depth in ACE where retail would not — low practical risk since -`frameTimeElapsed` shrinks each iteration and hits `frametime == 0` quickly, but note this as -a structural (not behavioral) implementation difference). -Retail's `execute_hooks` direction constant: forward pass at `0x0052590c` passes `0xffffffff` -(-1, all-bits) not `1`; backward pass at `0x0052578c` passes `1`. **This looks inverted from a -naive reading** (forward should intuitively be "Forward" not `-1`), but cross-check against -`AnimationHookDir` enum values: ACE's port passes `AnimationHookDir.Forward` for the `lastFrame++` -(ascending, `frametime>0`) branch and `AnimationHookDir.Backward` for the `lastFrame--` -(descending, `frametime<0`) branch — i.e. ACE's C# reads correctly against the *semantic* forward/ -backward regardless of retail's raw enum encoding. Need to verify `AnimationHookDir` enum's -actual underlying values in `ACE.Entity.Enum` to confirm `Forward == -1`/`0xffffffff` vs `1`, -but this is very likely just how the enum is defined (Both=0, Forward=-1, Backward=1, or similar -non-sequential encoding) rather than an ACE bug — **flag as needing confirmation, not a -confirmed divergence.** -EPSILON compares throughout use the same `0.000199999995f` literal as elsewhere. The `frametime -== 0` branch's guard is `|timeElapsed| > EPSILON` before calling `apply_physics(frame, -timeElapsed, timeElapsed)` — ACE matches exactly (`Sequence.cs:424`). - -### `UnPack` (retail-only relevance) -`CSequence::UnPack` (`0x005259d0`) explicitly does `clear_animations(); clear_physics(); -placement_frame = null; placement_frame_id = 0;` before deserializing — this is where retail -actually nulls the placement frame, which is the retail-verified justification for ACE's -`Clear()` including that reset (even though the disassembled 2-line `clear()` body itself does -not). ACE has no `Sequence.UnPack` in this file (no wire (de)serialization path ported) — this -is out of scope for acdream's runtime port (server-authoritative motion state, not client dat -deserialization) but is why ACE's `Clear()` and `clear()`/`clear_animations()`+`clear_physics()` -appear to disagree — they're actually modeling two different retail call sites (`clear()` proper -vs. `UnPack`'s manual clear+reset sequence). **Not a bug in ACE; a naming/scope conflation worth -noting for anyone tracing ACE's `Clear()` back to a single retail function.** - -## `AnimSequenceNode.cs` (ACE) ↔ `AnimSequenceNode` (retail struct @ `acclient.h:31063`) - -### Fields -`CAnimation *anim` / `float framerate` / `int low_frame` / `int high_frame` — all match ACE's -`Animation Anim` / `float Framerate` / `int LowFrame` / `int HighFrame` exactly, including types -(both `int`, not `uint`). - -### Default ctor `AnimSequenceNode()` ↔ retail `AnimSequenceNode::AnimSequenceNode()` (`0x00525d30`) -Retail: `framerate = 30f; low_frame = 0xffffffff (-1); high_frame = 0xffffffff (-1);` (both -low+high default to **-1**, not `low=0`). -ACE (`AnimSequenceNode.cs:15`): `Framerate = 30.0f; LowFrame = 0; HighFrame = -1;` -**CONFIRMED DIVERGENCE: ACE's parameterless ctor sets `LowFrame = 0` where retail sets -`low_frame = -1`.** This constructor is never actually invoked by ACE's own runtime path (the -only call site is `new AnimSequenceNode(animData)`, the parameterized overload, which explicitly -sets `LowFrame = animData.LowFrame`), so this divergence is currently dormant/unreachable in -ACE's code — but it is a real textual mismatch against retail's default-construct semantics and -would matter if any future code path constructs a bare `AnimSequenceNode()` and relies on default -`LowFrame`. - -### `get_starting_frame()` ↔ `AnimSequenceNode::get_starting_frame` (`0x00525c80`) -Retail: `if (framerate < 0) return high_frame + 1; else return low_frame;` — **returns a plain -`int32_t`**, i.e. `high_frame + 1` with NO epsilon subtraction. -ACE (`AnimSequenceNode.cs:72`): -```csharp -public float get_starting_frame() { - if (Framerate >= 0.0f) return LowFrame; - else return HighFrame + 1 - PhysicsGlobals.EPSILON; -} -``` -**CONFIRMED DIVERGENCE (significant):** ACE subtracts `PhysicsGlobals.EPSILON` (0.0002) from -`HighFrame + 1` when framerate is negative — retail does **not**; retail returns the exact -integer `high_frame + 1`. Also note the boundary condition flip: retail's branch condition is -`framerate < 0` (strict) with the `>= 0` case falling to `low_frame`; ACE's condition is -`Framerate >= 0.0f` returning `LowFrame` (equivalent boundary, `framerate==0` behaves the same -in both — returns `low_frame`/`LowFrame`). The boundary logic itself is faithful; **only the -epsilon subtraction is a fabricated addition not present in retail.** This likely exists in ACE -to avoid `Math.Floor` landing exactly on `HighFrame+1` and reading one frame past the end when -used as a float frame-cursor, but retail doesn't need it because retail's `get_starting_frame` -return value is immediately truncated back to an `int` in most call sites — however note retail's -`CSequence::advance_to_next_animation` and `remove_cyclic_anims` etc. store this int return value -directly into `frame_number` (a `long double`), so no fractional epsilon ever appears in retail's -frame_number for this codepath. **acdream should NOT copy this epsilon subtraction if porting -`get_starting_frame` faithfully** — investigate whether ACE added it to work around a downstream -float-precision issue introduced by ACE's OWN `float FrameNumber` choice (i.e. a workaround for -ACE's own divergence, compounding on top of it) rather than something retail does. - -### `get_ending_frame()` ↔ `AnimSequenceNode::get_ending_frame` (`0x00525cb0`) -Retail: `if (framerate >= 0) return high_frame + 1; else return low_frame;` — again plain -integer, no epsilon. -ACE (`AnimSequenceNode.cs:31`): -```csharp -public float get_ending_frame() { - if (Framerate >= 0.0f) return HighFrame + 1 - PhysicsGlobals.EPSILON; - else return LowFrame; -} -``` -**Same confirmed divergence as `get_starting_frame`** — ACE subtracts EPSILON from `HighFrame+1` -where retail returns the bare int. Branch condition (`>= 0` → high+1 path) matches retail exactly -this time (mirrors correctly — `get_starting_frame` and `get_ending_frame` are exact opposites of -each other by design, both in retail and ACE); only the epsilon fabrication persists. - -### `get_high_frame()` / `get_low_frame()` -Trivial accessors in both — direct field reads. No retail decompiled body found by name (likely -inlined/not separately emitted, or address not matched by this grep pass), but ACE's are pure -passthroughs (`return HighFrame;` / `return LowFrame;`) which cannot diverge from the struct field -values already confirmed to match. No risk. - -### `get_part_frame(frameIdx)` ↔ `AnimSequenceNode::get_part_frame` (`0x00525c40`) -Retail: `if (anim != null && arg2 >= 0 && arg2 < anim->num_frames) return &anim->part_frames[arg2]; -else return null;` -ACE (`AnimSequenceNode.cs:49`): `if (Anim == null) return null; if (frameIdx < 0 || frameIdx >= -Anim.NumFrames) return null; return Anim.PartFrames[frameIdx];` Logically equivalent (De Morgan's -of the same guard). Match. **Note the retail-side latent null-deref risk flagged above in -`execute_hooks`**: retail's `get_part_frame` DOES null-check bounds here, so a null `AnimFrame*` -can legitimately flow into `execute_hooks(this, get_part_frame(...), dir)` when `frameIdx` is -out of `[0, num_frames)` — retail's `execute_hooks` then dereferences it unconditionally. ACE -avoids this crash class entirely via its own `animFrame == null` guard in `execute_hooks`. - -### `get_pos_frame(int frameIdx)` ↔ `AnimSequenceNode::get_pos_frame(int32_t)` (`0x00525c10`) -Retail: same null/bounds guard as `get_part_frame` but against `PosFrames`/`pos_frames`, returns -`&anim->pos_frames[arg2 * 0x1c]` (0x1c = sizeof(AFrame) = 28 bytes: Vector3 origin (12) + -Quaternion (16) = 28 — confirms `AFrame` layout) on success, else... retail returns `0` (null -pointer) on failure, whereas **ACE returns `new AFrame()`** (identity frame) instead of null: -```csharp -public AFrame get_pos_frame(int frameIdx) { - if (Anim == null) return new AFrame(); - if (frameIdx < 0 || frameIdx >= Anim.PosFrames.Count) return new AFrame(); - return Anim.PosFrames[frameIdx]; -} -``` -**CONFIRMED DIVERGENCE:** retail can return a null `AFrame*` from `get_pos_frame`; ACE always -returns a non-null identity `AFrame`. This is almost certainly intentional/necessary in ACE -because C#'s callers (`update_internal`, `advance_to_next_animation`) call -`AFrame.Combine(frame, currAnim.get_pos_frame(...))` / `frame.Subtract(...)` unconditionally when -`currAnim.Anim.PosFrames.Count > 0` is already true (guarding the call site) — so in practice the -only way retail's null path is hit is if `pos_frames` is non-null overall but the specific index -is out of the current `[0, num_frames)` bounds, an edge retail's callers appear to avoid by -construction. ACE's identity-frame fallback is a defensive substitute for retail's null (which -would otherwise NPE `AFrame.Combine`/`Subtract` in C#) — functionally converges to a no-op combine -in the one path where it could differ, matching retail's *intended* behavior (no-op) via a -different mechanism (identity frame vs. skipped call). Low risk, but textually a real divergence -worth listing. -There's also a `float`-overload convenience wrapper `get_pos_frame(float frameIdx)` in ACE -(`:67`, `=> get_pos_frame((int)Math.Floor(frameIdx))`) with no direct 1:1 retail counterpart found -by this pass — likely inlined at each call site in retail rather than a separate overload; no -behavioral risk since it's a pure convenience delegator. - -### `has_anim(int appraisalProfile = 0)` ↔ `AnimSequenceNode::has_anim` (`0x00525c70`) -Retail: `return anim != 0;` (no parameter). ACE: `return Anim != null;` with a vestigial unused -`appraisalProfile` parameter (default 0, never read in the body) — **ACE-only dead parameter**, -harmless (matches retail's actual logic; the extra param appears to be scaffolding for a -different unrelated retail overload elsewhere, not a behavioral difference here). - -### `multiply_framerate(multiplier)` ↔ `AnimSequenceNode::multiply_framerate` (`0x00525be0`) -Retail: `if (multiplier < 0) swap(low_frame, high_frame); framerate *= multiplier;` -ACE (`:85`): `if (multiplier < 0.0f) { swap LowFrame/HighFrame } Framerate *= multiplier;` Exact -match, including the swap-BEFORE-multiply ordering (doesn't matter for correctness here since the -swap doesn't depend on the post-multiply framerate value, but confirms ACE preserved retail's -instruction order faithfully). - -### `set_animation_id(animID)` ↔ `AnimSequenceNode::set_animation_id` (`0x00525d60`, body continues past what this pass read in full — only header + first 3 lines captured) -ACE (`:96`): looks up `Anim = new Animation(DBObj.GetAnimation(animID))`; if `Anim == null` -return; clamps `HighFrame` to `-1 -> NumFrames-1` if still default, clamps `LowFrame`/`HighFrame` -individually if `>= NumFrames`, and clamps `LowFrame > HighFrame` by raising `HighFrame = -LowFrame`. This clamping logic was not fully re-derived from the retail disasm in this pass -(truncated read) — **recommend a follow-up grep of `AnimSequenceNode::set_animation_id` body -past `0x00525d60` before treating ACE's clamp order as verified**; flagged as unverified rather -than confirmed-matching. - -### Parameterized ctor `AnimSequenceNode(AnimData animData)` ↔ retail `AnimSequenceNode::AnimSequenceNode(AnimData const*)` (`0x00525f90`, referenced at `0x00525f80` calling `set_animation_id`) -ACE (`:22`): `Framerate = animData.Framerate; LowFrame = animData.LowFrame; HighFrame = -animData.HighFrame; set_animation_id(animData.AnimID);` — order (set framerate/low/high fields -FIRST, then resolve+clamp via `set_animation_id`) matches the retail call sequence implied by -`0x00525f80` calling `set_animation_id` from within the ctor body (consistent with fields being -pre-populated by the ctor's other init statements before the call, standard C++ member-init-list -ordering). Considered faithful pending the same `set_animation_id` body caveat above. - -## `AnimData.cs` (ACE) ↔ retail `AnimData`/`operator*` - -Retail default ctor `AnimData::AnimData` (`0x00525ce0`): `anim_id.id = 0; low_frame = 0; high_frame -= 0xffffffff (-1); framerate = 30f;` -ACE `AnimData` (this file, `references/ACE/.../Animation/AnimData.cs`) is just a plain data holder -with a parameterized ctor `AnimData(DatLoader.Entity.AnimData animData, float speed = 1.0f)` that -does `AnimID = animData.AnimId; LowFrame = animData.LowFrame; HighFrame = animData.HighFrame; -Framerate = animData.Framerate * speed;` — this matches retail's `operator*(float speed, AnimData -const* src)` (`0x00525d00`, invoked from `add_motion` at `0x0052255b`): -``` -retail: dst.id = src.id; dst.low_frame = src.low_frame; dst.high_frame = src.high_frame; - dst.framerate = src.framerate * speed; -``` -Field-for-field, operation-for-operation match, including that `Framerate` is the ONLY field -scaled by `speed` (low/high frame bounds pass through unscaled). No parameterless-ctor -default-value divergence to flag since ACE's `AnimData()` here is a no-op empty ctor (all fields -default to C# zero values: `0, 0, 0, 0f`) — **diverges from retail's `low_frame=0, -high_frame=0xffffffff, framerate=30f` defaults**, but this parameterless ctor does not appear to -be invoked anywhere in the `add_motion` call chain (only the parameterized ctor is used at the -`MotionTable.add_motion` call site), so — like `AnimSequenceNode()`'s bare ctor — this is a -dormant/unreachable-in-practice divergence, not an active bug. - -## `AFrame.cs` — spot notes (not the primary ask, but touched by Sequence/AnimSequenceNode) - -`AFrame` is ACE's C# port of retail's `Frame`/`AFrame` (28-byte struct = Vector3 origin (12B) + -Quaternion orientation (16B), confirmed via the `0x1c` (28) stride multiplier in -`AnimSequenceNode::get_pos_frame` at `0x00525c2c`). `Combine`/`Subtract`/`Rotate`/`apply_physics` -call sites all operate on this type consistently between ACE and retail's `Frame::combine`, -`Frame::subtract1`, `Frame::rotate` (referenced by name at `0x0052541b`, `0x005254c2`, -`0x00524b2d` etc. — not independently re-derived body-for-body in this pass; flagged out of scope -per the task's method list, but the call-site shapes into/out of `Sequence`/`AnimSequenceNode` -were confirmed consistent). - -## `MotionTable.cs` (ACE, class name `MotionTable`) ↔ retail `CMotionTable` — the 4 requested methods - -Retail's class is named `CMotionTable` (not `MotionTable`) — ACE renamed it during the port. The -4 target methods are **retail FREE FUNCTIONS** (not `CMotionTable::` member functions) that take -a `CSequence*` as their first parameter — ACE ported them as instance methods on `MotionTable` -taking a `Sequence` parameter, a structural (not behavioral) reshaping. - -### `add_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `add_motion` (`0x005224b0`) -Retail: `if (motionData == null) return; set_velocity(motionData.velocity * speed); set_omega( -motionData.omega * speed); for each anim in motionData.anims: append_animation(AnimData(speed * -anim))` — i.e. append_animation is called with a **freshly speed-scaled `AnimData` value** -(via `operator*`), never the raw `motionData.Anims[i]`. -ACE (`MotionTable.cs:358`): -```csharp -if (motionData == null) return; -sequence.SetVelocity(motionData.Velocity * speed); -sequence.SetOmega(motionData.Omega * speed); -for (i in motionData.Anims.Count) { - var animData = new AnimData(motionData.Anims[i], speed); - sequence.append_animation(animData); -} -``` -Exact match, including the crucial detail that velocity/omega REPLACE (via `set_velocity`/ -`SetVelocity`, not accumulate) the sequence's existing physics vector, unlike `combine_motion`/ -`subtract_motion` below. - -### `combine_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `combine_motion` (`0x00522580`) -Retail: `if (motionData == null) return; combine_physics(velocity*speed, omega*speed);` (ADDS -into existing sequence velocity/omega via `CSequence::combine_physics`). -ACE (`MotionTable.cs:381`): `if (motionData == null) return; sequence.CombinePhysics(motionData.Velocity * speed, motionData.Omega * speed);` Match. - -### `subtract_motion(Sequence sequence, MotionData motionData, float speed)` ↔ free fn `subtract_motion` (`0x00522600`) -Retail: `if (motionData == null) return; subtract_physics(velocity*speed, omega*speed);` -ACE (`MotionTable.cs:388`): `if (motionData == null) return; sequence.subtract_physics(motionData.Velocity * speed, motionData.Omega * speed);` Match. - -### `change_cycle_speed(Sequence sequence, MotionData motionData, float substateMod, float speedMod)` ↔ free fn `change_cycle_speed` (`0x00522290`) -Retail: `if (|substateMod| > EPSILON) multiply_cyclic_animation_fr(speedMod / substateMod); else -if (|speedMod| < EPSILON) multiply_cyclic_animation_fr(0);` — **note the retail param order is -`(CSequence*, MotionData* [UNUSED in this function's body], float substateMod, float speedMod)` -— `motionData` is passed but never dereferenced inside `change_cycle_speed` itself** (it's there -for signature consistency with the other 3 sibling functions / call-site uniformity, not because -the function needs it). -ACE (`MotionTable.cs:372`): -```csharp -if (Math.Abs(substateMod) > PhysicsGlobals.EPSILON) - sequence.multiply_cyclic_animation_framerate(speedMod / substateMod); -else if (Math.Abs(speedMod) < PhysicsGlobals.EPSILON) - sequence.multiply_cyclic_animation_framerate(0); -``` -Exact match, including that ACE's `motionData` parameter is likewise unused in the method body -(faithfully preserved as dead/unused, mirroring retail's own unused parameter — not an ACE bug, -an intentional signature-parity choice already present in retail). -**Boundary-condition note:** if `substateMod` is exactly `EPSILON` or between `EPSILON` and some -tiny nonzero value such that neither branch's condition is strictly satisfied (i.e. -`|substateMod| <= EPSILON` AND `|speedMod| >= EPSILON`), **neither branch fires and the cyclic -framerate is left unchanged** in both retail and ACE — this is retail's actual (if slightly odd) -behavior, faithfully reproduced, not a port bug. - -## Call-site context confirmed (not itself divergence-graded, informational) - -`MotionTable.GetObjectSequence` (ACE) corresponds to retail's `CMotionTable::GetObjectSequence` -(referenced at `0x00522347` from `CMotionTable::re_modify`, and the `sequence.clear_physics(); -sequence.remove_cyclic_anims();` pairing before each `add_motion` burst matches retail's -`CSequence::clear_physics(arg4); CSequence::remove_cyclic_anims(arg4);` pattern visible at -`0x005229cf`/`0x005229d8`, `0x00522be3`/`0x00522bec`, `0x00522d6d`/`0x00522d74`, -`0x00522e5d`/`0x00522e64` — four separate call sites in retail's `GetObjectSequence`, matching -ACE's four corresponding branches (`Style` / `SubState` / `Action` / the default-state reset in -`SetDefaultState` which additionally calls `clear_animations()` instead of `remove_cyclic_anims()` -— confirmed intentional, `SetDefaultState` is a full reset not an in-place cycle swap). -`re_modify` in retail (`0x005222e0`, `CMotionTable::re_modify`) reapplies queued modifiers by -popping `MotionState.modifier_head` and re-calling `GetObjectSequence` — this exists in ACE too -(referenced in `MotionTable.cs` but not in the requested method list; noted only for completeness -of the call graph around the 4 target functions). - -## Summary of confirmed divergences (ranked by likely runtime impact) - -1. **`FrameNumber`/`frame_number`: `float` (ACE) vs `long double`/80-bit-extended (retail).** - Pervasive — affects every frame-boundary comparison in `update_internal`, - `advance_to_next_animation`, `apply_physics`. Highest-impact, hardest to fix in C# (no native - 80-bit float type; `double` is the closest available and still not bit-exact to retail). -2. **`AnimSequenceNode.get_starting_frame()` / `get_ending_frame()` subtract - `PhysicsGlobals.EPSILON` from `HighFrame + 1` in ACE; retail returns the bare integer with NO - epsilon.** Directly affects where a reverse-playing animation's start/end frame lands — - potential off-by-epsilon frame read at cycle boundaries. Medium-high impact, easy to fix (just - drop the `- PhysicsGlobals.EPSILON` term) if porting fresh. -3. **`AnimSequenceNode()` parameterless ctor: `LowFrame=0` (ACE) vs `low_frame=-1` (retail).** - Dormant in ACE's current call graph (only the parameterized ctor is actually invoked), but a - real textual mismatch. Low impact unless something starts calling the bare ctor. -4. **`AnimData()` parameterless ctor: all-zero defaults (ACE) vs `low_frame=0, high_frame=-1, - framerate=30f` (retail).** Same dormant-but-real-mismatch profile as #3. -5. **`AnimSequenceNode.get_pos_frame` returns identity `AFrame` on failure (ACE) vs `null` - pointer (retail).** Functionally converges to a no-op in practice given how call sites guard - invocation; textual divergence only. -6. **ACE's `Clear()` additionally nulls `PlacementFrame`/`PlacementFrameID`, which retail's own - 2-instruction `clear()` body does NOT do** — that reset actually belongs to retail's separate - `UnPack` function. Scope conflation, not a behavioral bug, but worth knowing which retail - function ACE's `Clear()` is really modeling. -7. **`update_internal`: retail loops (`while(true)`), ACE recurses (tail call).** Structural only; - equivalent output, theoretical (very unlikely in practice) stack-depth difference in - pathological same-tick multi-boundary-crossing cases. -8. Retail's `execute_hooks` has a latent null-deref if `get_part_frame` returns null for an - out-of-range frame index (no null check before `arg2->hooks`); ACE's `animFrame == null` guard - avoids this crash class — a safe defensive divergence, not something to "fix" toward retail. -9. `AnimSequenceNode::set_animation_id` clamp-order in ACE was NOT independently re-verified - against the full retail body in this pass (only the call header + first lines were read) — - flag for a follow-up targeted grep before treating ACE's clamping as ground truth. - -## Constants confirmed identical between ACE and retail - -- `EPSILON = 0.0002f` (retail literal `0.000199999995f`, ACE `PhysicsGlobals.EPSILON`) — used - identically in `advance_to_next_animation`, `update_internal`, `apply_physics` guards, and - `change_cycle_speed`. -- `AFrame`/`Frame` struct size = 28 bytes (0x1c) = Vector3(12) + Quaternion(16), confirmed via - `AnimSequenceNode::get_pos_frame`'s `arg2 * 0x1c` index stride. -- `AnimSequenceNode` struct layout: `CAnimation* anim; float framerate; int low_frame; int - high_frame;` — exact field-for-field match with ACE's C# class (types included). -- `CSequence` struct layout (`acclient.h:30747`): confirms `frame_number` is genuinely `long - double`, not a decompiler artifact — this is the verbatim retail header, authoritative. diff --git a/docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md b/docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md deleted file mode 100644 index b267dce0..00000000 --- a/docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md +++ /dev/null @@ -1,1756 +0,0 @@ -# CSequence family — verbatim retail decomp extraction - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (PDB-named, -Sept 2013 EoR build, x87 32-bit) + `docs/research/named-retail/acclient.h` -(verbatim retail struct layouts). Line numbers below are `grep -n` line -anchors into `acclient_2013_pseudo_c.txt` as of this extraction. - -All code quoted is the tool's literal x87-mangled pseudo-C output -(FCMP_UO / floor / `unimplemented {fld ...}` comments and all) — this is -what the decompiler actually emitted; the "Cleaned control flow" sections -underneath translate it into readable pseudocode without changing any -comparison direction, boundary constant, or branch order. - ---- - -## 0. Struct layouts (verbatim from acclient.h) - -### `CSequence` (acclient.h:30747, PDB record 3267) - -```cpp -struct __cppobj CSequence : PackObj -{ - DLList anim_list; // +0x00 head_, +0x04 tail_ (DLListBase) - AnimSequenceNode *first_cyclic; // +0x08 (per line 30750; see note below) - AC1Legacy::Vector3 velocity; // linear velocity accumulator - AC1Legacy::Vector3 omega; // angular velocity accumulator - CPhysicsObj *hook_obj; // owning physics object (fires hooks through this) - long double frame_number; // x87 80-bit extended — CURRENT FRACTIONAL FRAME - AnimSequenceNode *curr_anim; // node actively playing - AnimFrame *placement_frame; // static override frame when anim_list is empty - unsigned int placement_frame_id; - int bIsTrivial; -}; -``` - -`frame_number` is declared `long double` (x87 extended, 80-bit) — NOT a -`double`. This is why every comparison against it in the decomp goes -through the raw FPU compare/FCMP_UO sequence rather than a SSE `ucomisd`. - -### `CPartArray` (acclient.h:30762) — where CSequence lives - -```cpp -struct __cppobj CPartArray -{ - unsigned int pa_state; - CPhysicsObj *owner; - CSequence sequence; // EMBEDDED, not a pointer - MotionTableManager *motion_table_manager; - CSetup *setup; - unsigned int num_parts; - CPhysicsPart **parts; - AC1Legacy::Vector3 scale; - Palette **pals; - LIGHTLIST *lights; - AnimFrame *last_animframe; -}; -``` - -### `AnimSequenceNode` (acclient.h:31063, PDB record 3266) - -```cpp -struct __cppobj AnimSequenceNode : PackObj, DLListData -{ - CAnimation *anim; - float framerate; // signed — negative = playing this node in reverse - int low_frame; // inclusive start frame index (forward-orientation) - int high_frame; // inclusive end frame index (forward-orientation) -}; -``` - -`DLListData` (base) supplies `dllist_next` / `dllist_prev`. The node -object itself is allocated at `dllist_data_ptr - 4` (the "ADJ" / `- 4` -adjustment seen throughout the list-splice code is converting between a -`DLListData*` and the owning `AnimSequenceNode*`, i.e. `DLListData` is -the FIRST base and the node is 4 bytes past it — actually the compiler -emits `((char*)head_ - 4)` meaning the AnimSequenceNode base starts 4 -bytes BEFORE the DLListData sub-object, so `DLListData` is inherited -second / offset +4 inside the node). - -### `AnimFrame` (acclient.h:31072, PDB record 3264) - -```cpp -struct __cppobj AnimFrame -{ - AFrame *frame; - unsigned int num_frame_hooks; - CAnimHook *hooks; // singly-linked hook chain for THIS frame - unsigned int num_parts; -}; -``` - -### `CAnimHook` (acclient.h:30973, PDB record 3262) - -```cpp -struct __cppobj CAnimHook -{ - CAnimHookVtbl *vfptr; - CAnimHook *next_hook; - int direction_; // 0 = fire on either direction; else must match exactly -}; -``` - -`AnimDoneHook : CAnimHook` (acclient.h:57557) is an empty subclass — -pure dispatch marker, `Execute()` calls `CPhysicsObj::Hook_AnimDone`. - -### Constants (verbatim, multiple identical copies in the data segment) - -``` -F_EPSILON = 0.000199999995f (acclient.h data; ~0.0002f) -data_794610 (double) = 0x0000000000000000 (0.0) -data_7928c0 (double) = 0x3ff0000000000000 (1.0) -``` - ---- - -## 1. `CSequence::CSequence` — ctor (line 300891, addr `0x005249f0`) - -``` -00524a30... wait — ctor is 005249f0 -``` - -```c -00524a30 // actually printed at 300891: -005249f0 void __fastcall CSequence::CSequence(class CSequence* this) -{ - this->vtable = 0x7c84d8; - __builtin_memset(&this->anim_list, 0, 0x28); - __builtin_memset(&this->frame_number, 0, 0x18); -} -``` - -Cleaned: zero-fills `anim_list` (0x28 = 40 bytes, covers `anim_list` + -`first_cyclic` + `velocity` + `omega` + `hook_obj`), then zero-fills the -tail 0x18 = 24 bytes starting at `frame_number` (covers `frame_number` -[10 bytes as long double, padded] + `curr_anim` + `placement_frame` + -`placement_frame_id`). `frame_number` starts at exactly `0.0`. - -## 2. `CSequence::~CSequence` — dtor (line 300901, addr `0x00524a30`) - -```c -00524a30 void __fastcall CSequence::~CSequence(class CSequence* this) -{ - bool cond:0 = this->anim_list.head_ == 0; - this->vtable = 0x7c84d8; - - if (!(cond:0)) - { - do - { - class DLListData* head_ = this->anim_list.head_; - if (head_ != 0) - { - class DLListData* dllist_prev = head_->dllist_prev; - if (dllist_prev == 0) - { - class DLListData* dllist_next = head_->dllist_next; - this->anim_list.head_ = dllist_next; - if (dllist_next != 0) - dllist_next->dllist_prev = nullptr; - } - else - dllist_prev->dllist_next = head_->dllist_next; - - class DLListData* dllist_next_1 = head_->dllist_next; - if (dllist_next_1 == 0) - { - class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev; - this->anim_list.tail_ = dllist_prev_1; - if (dllist_prev_1 != 0) - dllist_prev_1->dllist_next = 0; - } - else - dllist_next_1->dllist_prev = head_->dllist_prev; - - head_->dllist_next = 0; - head_->dllist_prev = nullptr; - - if ((head_ != 0 && head_ != 4)) - *(uint32_t*)head_->dllist_next(1); // AnimSequenceNode::`scalar deleting destructor`(1) - } - } while (this->anim_list.head_ != 0); - } - - this->vtable = 0x79285c; // PackObj vtable (base slice) -} -``` - -Cleaned: pop-and-delete every node from `anim_list.head_` until empty -(unlink from doubly-linked list, then call the node's scalar deleting -destructor with `delete`-flag=1). - -## 3. `CSequence::clear` (line 301828, addr `0x005255b0`) - -```c -005255b0 void __fastcall CSequence::clear(class CSequence* this) -{ - CSequence::clear_animations(this); - CSequence::clear_physics(this); - this->placement_frame = nullptr; - this->placement_frame_id = 0; -} -``` - -## 4. `CSequence::clear_physics` (line 301194, addr `0x00524d50`) - -```c -00524d50 void __fastcall CSequence::clear_physics(class CSequence* this) -{ - this->velocity.x = 0; - this->velocity.y = 0f; - this->velocity.z = 0f; - this->omega.x = 0; - this->omega.y = 0f; - this->omega.z = 0f; -} -``` - -## 5. `CSequence::clear_animations` (line 301207, addr `0x00524dc0`) - -```c -00524dc0 void __fastcall CSequence::clear_animations(class CSequence* this) -{ - while (this->anim_list.head_ != 0) - { - class DLListData* head_ = this->anim_list.head_; - if (head_ != 0) - { - class DLListData* dllist_prev = head_->dllist_prev; - if (dllist_prev == 0) - { - class DLListData* dllist_next = head_->dllist_next; - this->anim_list.head_ = dllist_next; - if (dllist_next != 0) - dllist_next->dllist_prev = nullptr; - } - else - dllist_prev->dllist_next = head_->dllist_next; - - class DLListData* dllist_next_1 = head_->dllist_next; - if (dllist_next_1 == 0) - { - class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev; - this->anim_list.tail_ = dllist_prev_1; - if (dllist_prev_1 != 0) - dllist_prev_1->dllist_next = 0; - } - else - dllist_next_1->dllist_prev = head_->dllist_prev; - - head_->dllist_next = 0; - head_->dllist_prev = nullptr; - - if ((head_ != 0 && head_ != 4)) - *(uint32_t*)head_->dllist_next(1); // node dtor+delete - } - } - - this->first_cyclic = nullptr; - this->frame_number = 0f; - *(uint32_t*)((char*)this->frame_number)[4] = 0; // (high dword of the long double, zeroed too) - this->curr_anim = nullptr; -} -``` - -Cleaned: identical unlink-and-delete loop over the WHOLE list (same -pattern as the dtor), then resets `first_cyclic = null`, -`frame_number = 0.0`, `curr_anim = null`. This is the full "wipe the -sequence back to empty" operation, distinct from `remove_cyclic_anims` -below which only removes the cyclic tail. - -## 6. `CSequence::remove_cyclic_anims` (line 301258, addr `0x00524e40`) - -```c -00524e40 void __fastcall CSequence::remove_cyclic_anims(class CSequence* this) -{ - class CSequence* this_1 = this; - class AnimSequenceNode* first_cyclic_1; - - for (class AnimSequenceNode* first_cyclic = this->first_cyclic; first_cyclic != 0; first_cyclic = first_cyclic_1) - { - if (this->curr_anim == first_cyclic) - { - class AnimSequenceNode* eax_1 = AnimSequenceNode::GetPrev(first_cyclic); - this->curr_anim = eax_1; - - if (eax_1 == 0) - { - this->frame_number = 0f; - *(uint32_t*)((char*)this->frame_number)[4] = 0; - } - else - this->frame_number = ((double)AnimSequenceNode::get_ending_frame(eax_1)); - } - - first_cyclic_1 = AnimSequenceNode::GetNext(first_cyclic); - class DLListData** eax_2; - if (first_cyclic == 0) - eax_2 = nullptr; - else - eax_2 = &first_cyclic->dllist_next; - - class DLListData* dllist_prev = ADJ(eax_2)->dllist_prev; - if (dllist_prev == 0) - { - class DLListData* dllist_next = this->anim_list.head_->dllist_next; - this->anim_list.head_ = dllist_next; - if (dllist_next != 0) - dllist_next->dllist_prev = nullptr; - } - else - dllist_prev->dllist_next = ADJ(eax_2)->dllist_next; - - class DLListData* dllist_next_1 = ADJ(eax_2)->dllist_next; - if (dllist_next_1 == 0) - { - class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev; - this->anim_list.tail_ = dllist_prev_1; - if (dllist_prev_1 != 0) - dllist_prev_1->dllist_next = 0; - } - else - dllist_next_1->dllist_prev = ADJ(eax_2)->dllist_prev; - - ADJ(eax_2)->dllist_next = nullptr; - ADJ(eax_2)->dllist_prev = nullptr; - - if (first_cyclic != 0) - first_cyclic->vtable->__vecDelDtor(1); - } - - class DLListData* tail_ = this->anim_list.tail_; - if (tail_ != 0) - { - this->first_cyclic = ((char*)tail_ - 4); - return; - } - - this->first_cyclic = nullptr; -} -``` - -Cleaned: walks from `first_cyclic` to the END of the list, unlinking -and deleting EVERY node from `first_cyclic` onward (the "cyclic" tail — -i.e. the looping animation(s) queued after the one-shot transition -animations). If `curr_anim` was one of the removed nodes, `curr_anim` -snaps back to the PREVIOUS node (the last non-cyclic node) and -`frame_number` is set to that previous node's `get_ending_frame()` (or -`0.0` if there is no previous node at all). After the sweep, -`first_cyclic` is reset to point at the new tail of the list (`tail_ - -4`), or `null` if the list is now empty. - -## 7. `CSequence::remove_link_animations` (line 301060, addr `0x00524be0`) - -```c -00524be0 void __thiscall CSequence::remove_link_animations(class CSequence* this, uint32_t arg2) -{ - int32_t ebp = 0; - - if (arg2 > 0) - { - do - { - if (AnimSequenceNode::GetPrev(this->first_cyclic) == 0) - break; - - if (AnimSequenceNode::GetPrev(this->first_cyclic) == this->curr_anim) - { - class AnimSequenceNode* first_cyclic = this->first_cyclic; - this->curr_anim = first_cyclic; - if (first_cyclic != 0) - this->frame_number = ((double)AnimSequenceNode::get_starting_frame(first_cyclic)); - } - - class AnimSequenceNode* eax_2 = AnimSequenceNode::GetPrev(this->first_cyclic); - class DLListData** edx_1; - if (eax_2 == 0) - edx_1 = nullptr; - else - edx_1 = &eax_2->dllist_next; - - // unlink eax_2 (the node immediately before first_cyclic) from anim_list - class DLListData* dllist_prev = ADJ(edx_1)->dllist_prev; - if (dllist_prev == 0) - { - class DLListData* dllist_next = this->anim_list.head_->dllist_next; - this->anim_list.head_ = dllist_next; - if (dllist_next != 0) - dllist_next->dllist_prev = nullptr; - } - else - dllist_prev->dllist_next = ADJ(edx_1)->dllist_next; - - class DLListData* dllist_next_1 = ADJ(edx_1)->dllist_next; - if (dllist_next_1 == 0) - { - class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev; - this->anim_list.tail_ = dllist_prev_1; - if (dllist_prev_1 != 0) - dllist_prev_1->dllist_next = 0; - } - else - dllist_next_1->dllist_prev = ADJ(edx_1)->dllist_prev; - - ADJ(edx_1)->dllist_next = nullptr; - ADJ(edx_1)->dllist_prev = nullptr; - - if (eax_2 != 0) - eax_2->vtable->__vecDelDtor(1); - - ebp += 1; - } while (ebp < arg2); - } -} -``` - -Cleaned: removes `arg2` (count) "link" nodes — nodes chained -IMMEDIATELY BEFORE `first_cyclic` (i.e. the linked/one-shot animations -queued ahead of the cyclic tail) — one at a time, working backward from -`first_cyclic`'s predecessor. If the removed node was `curr_anim`, -`curr_anim` is force-advanced to `first_cyclic` and `frame_number` reset -to that node's `get_starting_frame()`. Stops early if there's no -predecessor left (`GetPrev(first_cyclic) == 0`). - -## 8. `CSequence::remove_all_link_animations` (line 301128, addr `0x00524ca0`) - -```c -00524ca0 void __fastcall CSequence::remove_all_link_animations(class CSequence* this) -{ - class AnimSequenceNode* first_cyclic = this->first_cyclic; - - if ((first_cyclic != 0 && AnimSequenceNode::GetPrev(first_cyclic) != 0)) - { - class AnimSequenceNode* i; - do - { - if (AnimSequenceNode::GetPrev(this->first_cyclic) == this->curr_anim) - { - class AnimSequenceNode* first_cyclic_1 = this->first_cyclic; - this->curr_anim = first_cyclic_1; - if (first_cyclic_1 != 0) - this->frame_number = ((double)AnimSequenceNode::get_starting_frame(first_cyclic_1)); - } - - class AnimSequenceNode* eax_3 = AnimSequenceNode::GetPrev(this->first_cyclic); - // ... identical unlink-and-delete of eax_3 as remove_link_animations ... - if (eax_3 != 0) - eax_3->vtable->__vecDelDtor(1); - - i = AnimSequenceNode::GetPrev(this->first_cyclic); - } while (i != 0); - } -} -``` - -Cleaned: identical to `remove_link_animations` but loops until -`GetPrev(first_cyclic) == 0` (i.e. removes ALL link nodes, not a fixed -count). - -## 9. `CSequence::has_anims` (line 301050, addr `0x00524bd0`) - -```c -00524bd0 int32_t __fastcall CSequence::has_anims(class CSequence const* this) -{ - int32_t result; - result = this->anim_list.head_ != 0; - return result; -} -``` - -## 10. `CSequence::set_velocity` (line 300798, addr `0x00524880`) - -```c -00524880 void __thiscall CSequence::set_velocity(class CSequence* this, class AC1Legacy::Vector3 const* arg2) -{ - this->velocity.x = arg2->x; - this->velocity.y = arg2->y; - this->velocity.z = arg2->z; -} -``` - -## 11. `CSequence::set_omega` (line 300808, addr `0x005248a0`) - -```c -005248a0 void __thiscall CSequence::set_omega(class CSequence* this, class AC1Legacy::Vector3 const* arg2) -{ - this->omega.x = arg2->x; - this->omega.y = arg2->y; - this->omega.z = arg2->z; -} -``` - -## 12. `CSequence::combine_physics` (line 300818, addr `0x005248c0`) - -```c -005248c0 void __thiscall CSequence::combine_physics(class CSequence* this, class AC1Legacy::Vector3 const* arg2, class AC1Legacy::Vector3 const* arg3) -{ - float* eax = arg2; - this->velocity.x = ((float)(((long double)this->velocity.x) + ((long double)*(uint32_t*)eax))); - this->velocity.y = ((float)(((long double)eax[1]) + ((long double)this->velocity.y))); - this->velocity.z = ((float)(((long double)eax[2]) + ((long double)this->velocity.z))); - this->omega.x = ((float)(((long double)this->omega.x) + ((long double)arg3->x))); - this->omega.y = ((float)(((long double)arg3->y) + ((long double)this->omega.y))); - this->omega.z = ((float)(((long double)arg3->z) + ((long double)this->omega.z))); -} -``` - -Cleaned: `velocity += arg2; omega += arg3;` (element-wise), all math -promoted through x87 `long double` and truncated back to `float` per -component (retail's usual FP-widen-then-narrow pattern; NOT a -bit-identical no-op — matters for exact conformance tests). - -## 13. `CSequence::subtract_physics` (line 300832, addr `0x00524900`) - -```c -00524900 void __thiscall CSequence::subtract_physics(class CSequence* this, class AC1Legacy::Vector3 const* arg2, class AC1Legacy::Vector3 const* arg3) -{ - float* eax = arg2; - this->velocity.x = ((float)(((long double)this->velocity.x) - ((long double)*(uint32_t*)eax))); - this->velocity.y = ((float)(((long double)this->velocity.y) - ((long double)eax[1]))); - this->velocity.z = ((float)(((long double)this->velocity.z) - ((long double)eax[2]))); - this->omega.x = ((float)(((long double)this->omega.x) - ((long double)arg3->x))); - this->omega.y = ((float)(((long double)this->omega.y) - ((long double)arg3->y))); - this->omega.z = ((float)(((long double)this->omega.z) - ((long double)arg3->z))); -} -``` - -`velocity -= arg2; omega -= arg3;` — mirror of `combine_physics`. - -## 14. `CSequence::multiply_cyclic_animation_fr` (line 300846, addr `0x00524940`) - -Note: the task prompt's "multiply_cyclic_animation_framerate" is this -function; the PDB-recovered name is truncated to `multiply_cyclic_animation_fr` -(28-char Windows debug-symbol truncation on some builds). - -```c -00524940 void __thiscall CSequence::multiply_cyclic_animation_fr(class CSequence* this, float arg2) -{ - for (class AnimSequenceNode* first_cyclic = this->first_cyclic; first_cyclic != 0; first_cyclic = AnimSequenceNode::GetNext(first_cyclic)) - AnimSequenceNode::multiply_framerate(first_cyclic, arg2); -} -``` - -Walks from `first_cyclic` to the tail (the cyclic/looping portion of -the sequence) and multiplies EVERY node's framerate by `arg2`. Used -elsewhere (line 298286/298295 call sites) to retarget a cyclic motion's -playback rate — e.g. scaling Walk_Forward's framerate to match the -character's actual movement speed: - -``` -298286 CSequence::multiply_cyclic_animation_fr(arg1, ((float)(((long double)arg4) / ((long double)arg3)))); -298295 CSequence::multiply_cyclic_animation_fr(arg1, 0f); -``` - -### 14a. `AnimSequenceNode::multiply_framerate` (line 302425, addr `0x00525be0`) - -```c -00525be0 void __thiscall AnimSequenceNode::multiply_framerate(class AnimSequenceNode* this, float arg2) -{ - long double x87_r7 = ((long double)arg2); - long double temp1 = ((long double)0f); - (x87_r7 - temp1); - int32_t eax; - eax = ((((x87_r7 < temp1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp1))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp1) ? 1 : 0) << 0xe) | 0)))); - bool p = /* test ah, 0x5 -> "arg2 < 0.0" (unordered-safe less-than) */; - - if (!(p)) - { - int32_t low_frame = this->low_frame; - this->low_frame = this->high_frame; - this->high_frame = low_frame; - } - - this->framerate = ((float)(((long double)arg2) * ((long double)this->framerate))); -} -``` - -Cleaned: -``` -if (arg2 < 0.0f) - swap(low_frame, high_frame); -framerate *= arg2; -``` -(NOTE the branch condition polarity: the raw x87 test is `test ah,0x5` -on a compare of `arg2` vs `0.0`; the `!(p)` wrapping the swap makes the -swap fire when `arg2 < 0.0` is TRUE — i.e. multiplying by a NEGATIVE -factor swaps low/high frame, consistent with `get_starting_frame`/ -`get_ending_frame` below which key off `framerate < 0` to decide -playback direction.) - -## 15. `CSequence::get_curr_animframe` (line 300855, addr `0x00524970`) - -(This is retail's "get current animation frame" accessor — the closest -named function to the task's "get_curr_animation"; there is no separate -`CSequence::get_curr_animation` symbol in the PDB.) - -```c -00524970 class AnimFrame const* __fastcall CSequence::get_curr_animframe(class CSequence const* this) -{ - class AnimSequenceNode* curr_anim = this->curr_anim; - - if (curr_anim == 0) - return this->placement_frame; - - int32_t eax = this->frame_number; - int32_t ecx = *(uint32_t*)((char*)this->frame_number)[4]; - int32_t var_8 = eax; - int32_t var_4 = ecx; - floor(eax, ecx); - return AnimSequenceNode::get_part_frame(curr_anim, _ftol2()); -} -``` - -Cleaned: -``` -if (curr_anim == null) - return placement_frame; -return curr_anim.get_part_frame((int)floor(frame_number)); -``` -If there is no active animation node, the sequence renders whatever -static `placement_frame` was set (see `set_placement_frame` below). -Otherwise it floors the fractional `frame_number` to an integer frame -index and asks the current node for that frame's part-transform data. - -## 16. `CSequence::set_placement_frame` (line 300872, addr `0x005249b0`) - -```c -005249b0 void __thiscall CSequence::set_placement_frame(class CSequence* this, class AnimFrame const* arg2, uint32_t arg3) -{ - this->placement_frame = arg2; - this->placement_frame_id = arg3; -} -``` - -Trivial setter for the two placement fields; called elsewhere (line -287517, 287550) with `nullptr, 0` to clear placement, or a live -`AnimFrame*` + id to set a static pose (e.g. for objects with no -animation, "placement_frame_id" ~ `0x65` seen in the older function -map's field notes). - -## 17. `CSequence::get_curr_frame_number` (line 300881, addr `0x005249d0`) - -```c -005249d0 uint32_t __fastcall CSequence::get_curr_frame_number(class CSequence const* this) -{ - floor(this->frame_number, *(uint32_t*)((char*)this->frame_number)[4]); - return _ftol2(); -} -``` - -`return (uint32_t)floor(frame_number);` — integer frame index for the -current fractional position (used by `CPartArray::get_curr_frame_number` -callers at line 300779/300781 in `CPartArray`). - -## 18. `CSequence::execute_hooks` — hook dispatch (line 300780, addr `0x00524830`) - -```c -00524830 void __thiscall CSequence::execute_hooks(class CSequence const* this, class AnimFrame const* arg2, int32_t arg3) -{ - if (this->hook_obj != 0) - { - for (class CAnimHook* i = arg2->hooks; i != 0; i = i->next_hook) - { - int32_t direction_ = i->direction_; - if ((direction_ == 0 || arg3 == direction_)) - CPhysicsObj::add_anim_hook(this->hook_obj, i); - } - } -} -``` - -**Hook-dispatch mechanics (verbatim):** `execute_hooks(frame, direction)` -walks the singly-linked `hooks` chain attached to a SPECIFIC `AnimFrame` -(a frame within the current animation's `part_frames` array — see -`AnimFrame` struct, field `hooks`). Each `CAnimHook` node carries a -`direction_` filter: -- `direction_ == 0` → fires regardless of playback direction (both - forward and reverse frame-crossings queue it). -- `direction_ != 0` → only fires when the CALLER'S `arg3` direction - matches exactly. - -The actual call sites in `update_internal` (see §21 below) pass: -- `arg3 = 1` when crossing a frame FORWARD (line 302189: - `CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(*arg3, ebx_2), 1)`) -- `arg3 = 0xffffffff` (i.e. `-1`) when crossing a frame BACKWARD (line - 302039: `CSequence::execute_hooks(this, AnimSequenceNode::get_part_frame(*arg3, ebx_1), 0xffffffff)`) - -So a hook registered with `direction_ = 1` fires only on forward frame -crossings, one with `direction_ = -1` (`0xffffffff`) fires only on -backward crossings, and `direction_ = 0` fires on both. Matched hooks -are NOT executed immediately — they are appended (`CPhysicsObj::add_anim_hook`, -line 282906, `0x00514c20`) to the owning `CPhysicsObj`'s `anim_hooks` -SmartArray, which is drained once per physics tick by -`CPhysicsObj::process_hooks` (line 279431, `0x00511550`): - -```c -00511550 void __fastcall CPhysicsObj::process_hooks(class CPhysicsObj* this) -{ - // ... first drains a SEPARATE linked list `this->hooks` (PhysicsObjHook*) - // — one-shot script/PES/transparency hooks, unrelated to CAnimHook ... - - uint32_t m_num = this->anim_hooks.m_num; - if (m_num > 0) - { - int32_t i = 0; - if (m_num > 0) - { - do - { - this->anim_hooks.m_data[i]->vtable->Execute(this); - i += 1; - } while (i < this->anim_hooks.m_num); - } - AC1Legacy::SmartArray::shrink(&this->anim_hooks); - this->anim_hooks.m_num = 0; - } -} -``` - -So the full pipeline per crossed frame is: `execute_hooks` queues -matching `CAnimHook*` pointers into `anim_hooks` (append-only, growable -SmartArray, doubles capacity from a base of 8 via `grow()`) → -`process_hooks` later executes EVERY queued hook via its vtable -`Execute(CPhysicsObj*)` and then resets `m_num = 0` (queue is drained -completely every call, `shrink()` just trims capacity bookkeeping). - -### 18a. `AnimDoneHook` — the animation-complete hook (line 302227/302223 call site + 303832 Execute) - -```c -00526c20 void __stdcall AnimDoneHook::Execute(class AnimDoneHook const* this @ ecx, class CPhysicsObj* arg2) -{ - CPhysicsObj::Hook_AnimDone(arg2); -} -``` - -```c -0050fda0 void __fastcall CPhysicsObj::Hook_AnimDone(class CPhysicsObj* this) -{ - class CPartArray* part_array = this->part_array; - if (part_array != 0) - CPartArray::AnimationDone(part_array, 1); -} -``` - -`AnimDoneHook` is a GLOBAL singleton instance (`class AnimDoneHook -anim_done_hook` at data address `0x0081d9fc`, vtable installed at -line 901343 `0x007681f0`) — NOT allocated per-node. It has no -per-instance `direction_`/frame association; it's queued directly by -`update_internal` (not via `execute_hooks`/per-frame `hooks` chain) when -a node transition consumes the LAST node in the list (see §21, the -"leading edge" check). This is retail's `MotionDone` signal path: -`AnimDoneHook::Execute` → `CPhysicsObj::Hook_AnimDone` → -`CPartArray::AnimationDone(part_array, 1)`. - -## 19. `CSequence::apply_physics` (line 300955, addr `0x00524ab0`) - -```c -00524ab0 void __thiscall CSequence::apply_physics(class CSequence const* this, class Frame* arg2, double arg3, double arg4) -{ - long double x87_r7 = ((long double)arg4); - long double temp1 = ((long double)0.0); - (x87_r7 - temp1); - long double x87_r7_2 = fabsl(((long double)arg3)); - - if ((*(uint8_t*)((char*)((((x87_r7 < temp1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp1))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp1) ? 1 : 0) << 0xe) | 0x3800)))))[1] & 1) != 0) - x87_r7_2 = -(x87_r7_2); - - arg2->m_fOrigin.x = ((float)((x87_r7_2 * ((long double)this->velocity.x)) + ((long double)arg2->m_fOrigin.x))); - arg2->m_fOrigin.y = ((float)(((long double)((float)(x87_r7_2 * ((long double)this->velocity.y)))) + ((long double)arg2->m_fOrigin.y))); - arg2->m_fOrigin.z = ((float)(((long double)((float)(x87_r7_2 * ((long double)this->velocity.z)))) + ((long double)arg2->m_fOrigin.z))); - long double x87_r5_5 = (x87_r7_2 * ((long double)this->omega.y)); - float var_10_1 = ((float)(x87_r7_2 * ((long double)this->omega.z))); - float var_18 = ((float)(x87_r7_2 * ((long double)this->omega.x))); - float var_14_1 = ((float)x87_r5_5); - Frame::rotate(arg2, &var_18); -} -``` - -Cleaned: -``` -CSequence::apply_physics(this, Frame* frame, double quantum, double sign_source): - signed_quantum = fabs(quantum) - if (sign_source < 0.0) // sign copied from arg4, magnitude from arg3 - signed_quantum = -signed_quantum - - frame.m_fOrigin += velocity * signed_quantum // per-component - frame.rotate( omega * signed_quantum ) // Vector3(omega.x,omega.y,omega.z) * signed_quantum -``` - -This is `copysign(fabs(quantum_magnitude), sign_source)` — i.e. the -function takes the MAGNITUDE from `arg3` and the SIGN from `arg4` -(this matches every call site passing `1.0/framerate` for the magnitude -and the raw signed `frameRate`/`arg2` (elapsed-time-with-direction) as -the sign source — see §21). The result scales BOTH the accumulated -linear `velocity` (added into the frame's origin) and the accumulated -angular `omega` (fed into `Frame::rotate`) by the same signed quantum. - -`Frame::rotate` signature (line 91477, `0x004525b0`): -`void Frame::rotate(Frame* this, AC1Legacy::Vector3 const* arg2)` — -takes the omega*quantum vector and applies it as an incremental -rotation to the frame's orientation. - -## 20. `CSequence::apricot` — trim consumed nodes (line 300978, addr `0x00524b40`) - -(Retail's actual internal name for this function IS `apricot` — verified -via the PDB symbol table, not a placeholder.) - -```c -00524b40 void __fastcall CSequence::apricot(class CSequence* this) -{ - class DLListData* head_ = this->anim_list.head_; - void* __offset(DLListData, -0x4) i; - - if (head_ == 0) - i = nullptr; - else - i = ((char*)head_ - 4); - - if (i != this->curr_anim) - { - int32_t ebx; - int32_t var_c_1 = ebx; - - while (i != this->first_cyclic) - { - class DLListData* eax; - if (i == 0) - eax = nullptr; - else - eax = ((char*)i + 4); - - // unlink `eax` (the head node) from anim_list - class DLListData* dllist_prev = eax->dllist_prev; - if (dllist_prev == 0) - { - class DLListData* dllist_next = this->anim_list.head_->dllist_next; - this->anim_list.head_ = dllist_next; - if (dllist_next != 0) - dllist_next->dllist_prev = nullptr; - } - else - dllist_prev->dllist_next = eax->dllist_next; - - class DLListData* dllist_next_1 = eax->dllist_next; - if (dllist_next_1 == 0) - { - class DLListData* dllist_prev_1 = this->anim_list.tail_->dllist_prev; - this->anim_list.tail_ = dllist_prev_1; - if (dllist_prev_1 != 0) - dllist_prev_1->dllist_next = 0; - } - else - dllist_next_1->dllist_prev = eax->dllist_prev; - - eax->dllist_next = 0; - eax->dllist_prev = nullptr; - - if (i != 0) - *(uint32_t*)ADJ(i)->dllist_next(1); // delete head node - - class DLListData* head__1 = this->anim_list.head_; - if (head__1 == 0) - i = nullptr; - else - i = ((char*)head__1 - 4); - - if (i == this->curr_anim) - break; - } - } -} -``` - -Cleaned: -``` -CSequence::apricot(): - head = anim_list.head (as AnimSequenceNode*) - if (head == curr_anim) - return // nothing consumed yet, no trim needed - - while (head != first_cyclic): - // unlink+delete `head` from anim_list - delete_and_unlink(head) - head = anim_list.head (new head, as AnimSequenceNode*) - if (head == curr_anim) - break -``` - -Called at the end of every `CSequence::update` (see §22), immediately -after `update_internal` advances `curr_anim`. Its job is to free every -node BEFORE `curr_anim` that has already fully played and been popped -— it walks from the OLD list head forward, deleting nodes one at a time, -until it reaches either `curr_anim` (stop — that node is still live) or -`first_cyclic` (stop — do not delete into the cyclic tail even if -`curr_anim` has somehow moved past it, a defensive bound). - -## 21. `CSequence::update_internal` — the core per-frame advance loop (line 301839, addr `0x005255d0`) - -**Signature:** `CSequence::update_internal(CSequence const* this, double arg2, -AnimSequenceNode** arg3, double* arg4, Frame* arg5)` -- `this` — the sequence -- `arg2` — SIGNED elapsed time this tick (positive = forward playback - request, negative = reverse) -- `arg3` = `&this->curr_anim` (in/out — current node pointer) -- `arg4` = `&this->frame_number` (in/out — fractional frame position, - passed as a `double*` even though the underlying field is `long - double`; the field is read/written through `floor(lo,hi)` pairs which - address it as two 32-bit halves — the x87 extended representation) -- `arg5` = destination `Frame*` to accumulate physics into (or `null` - if the caller only wants the frame counter advanced, no motion - applied — see `CSequence::update`'s no-arg5-branch fallback) - -The raw pseudo-C for this function is almost entirely FPU compare -soup (each conditional branch lowers to an `fcom`/`fcomp`/`fcompp` -against `data_794610` [0.0] or `F_EPSILON` [0.0002f], captured as a -software-emulated FPU status word test `((c0<<8)|(c2<<10)|(c3<<14))` -then `test ah, 0x41`/`0x5`). Two call-site name mismatches from -vtable-slot devirtualization noise are corrected below: - -- Every call site the decompiler labeled `MD_Data_Fade::GetDuration(node)` - (lines 301633, 301647, 301657, 301680, 301696, 301706, 301725, 301735, - 301745, 301759, 301769) is a virtual call through `AnimSequenceNode`'s - vtable at the `framerate`-comparison slot — cross-checked against - `AnimSequenceNode::get_starting_frame`/`get_ending_frame` (§26/27 - below), whose boolean logic (`framerate < 0.0` branch, then return - `high_frame+1` or `low_frame`) is EXACTLY what every one of these call - sites' surrounding code does immediately afterward. `MD_Data_Fade` is - an unrelated `MediaDesc`-family class (acclient.h:34176) whose real - `GetDuration` takes incompatible arguments — a decompiler - address-collision mislabel, not a real call target. Read these sites - as `node->framerate` (raw field access via a trivial inline getter), - not a call to media-fade duration. -- Similarly `EffectInfoRegion::GetStat(node)` (lines 301917, 301931, - 301979) and `Attribute2ndInfoRegion::GetStat(node)` (lines 302064, - 302079, 302129) are mislabeled — by call-site position and use - (assigned into `var_28_1` then immediately combined with - `AnimSequenceNode::get_pos_frame`/`get_part_frame` and frame-index - arithmetic) these are `AnimSequenceNode::get_starting_frame(node)` / - `AnimSequenceNode::get_ending_frame(node)` respectively — same - address-collision artifact as above (`EffectInfoRegion`/ - `Attribute2ndInfoRegion::GetStat` are real functions elsewhere in the - binary, lines 244494/245331, unrelated chat/stat classes). - -### Cleaned control flow (semantics-preserving translation) - -``` -CSequence::update_internal(seq, elapsed, &curr_anim, &frame_number, frame): - - loop: - node_framerate = curr_anim.framerate // AnimSequenceNode::framerate (signed) - delta = elapsed * node_framerate // signed frame-step for this tick - old_frame_idx = floor(frame_number) // integer frame BEFORE advancing - new_pos = frame_number + delta - frame_number = new_pos - remaining = 0.0 // leftover elapsed time after a boundary hit - hit_boundary = false // "var_30_1" — did we cross curr_anim's end? - - if (delta < 0.0): // ── REVERSE playback ── - // boundary = curr_anim.get_starting_frame() (mislabeled EffectInfoRegion::GetStat call) - boundary = curr_anim.get_starting_frame() - - if (frame_number < boundary): // crossed/undershot the start - if (frame != null): - if (curr_anim.anim.pos_frames != 0) - Frame::subtract1(frame, frame, curr_anim.get_pos_frame((int)frame_number)) - if (fabs(node_framerate) >= F_EPSILON) // 0.000199999995f - CSequence::apply_physics(this, frame, 1.0 / node_framerate, elapsed) - // (return early here — the "p_5"/"return" branch, line 301911: - // if the compare says frame_number was not < 0.0 relative to F_EPSILON - // test, execute_hooks/advance is skipped this call — degenerate - // micro-step case) - return - - // fire per-frame REVERSE hooks/velocity from old_frame_idx down to - // (but not below) floor(frame_number), highest frame first: - idx = old_frame_idx - do: - if (frame != null): - if (curr_anim.anim.pos_frames != 0) - Frame::subtract1(frame, frame, curr_anim.get_pos_frame(idx)) - if (fabs() >= F_EPSILON): - CSequence::apply_physics(this, frame, 1.0/node_framerate, elapsed) - CSequence::execute_hooks(this, curr_anim.get_part_frame(idx), 0xffffffff) // direction = -1 (backward) - idx -= 1 - while (idx > floor(frame_number)) - - // Node exhausted going backward → boundary crossed - // (var_30_1 = 1 set here; falls through to the "advance" tail) - hit_boundary = true - - else if (delta > 0.0): // ── FORWARD playback ── - // boundary = curr_anim.get_ending_frame() (mislabeled Attribute2ndInfoRegion::GetStat call) - boundary = curr_anim.get_ending_frame() - - if (frame_number > boundary): // crossed/overshot the end - // same early-apply-then-return shape as the reverse case, - // using Frame::combine instead of Frame::subtract1: - if (frame != null): - if (curr_anim.anim.pos_frames != 0) - Frame::combine(frame, frame, curr_anim.get_pos_frame((int)frame_number)) - if (fabs(node_framerate) >= F_EPSILON) - CSequence::apply_physics(this, frame, 1.0 / node_framerate, elapsed) - return - - // fire per-frame FORWARD hooks/velocity from old_frame_idx up to - // (but not past) floor(frame_number), lowest frame first: - idx = old_frame_idx - do: - if (frame != null): - if (curr_anim.anim.pos_frames != 0) - Frame::combine(frame, frame, curr_anim.get_pos_frame(idx)) - if (fabs() >= F_EPSILON): - CSequence::apply_physics(this, frame, 1.0/node_framerate, elapsed) - CSequence::execute_hooks(this, curr_anim.get_part_frame(idx), 1) // direction = +1 (forward) - idx += 1 - while (idx < floor(frame_number)) - - hit_boundary = true - - else: // delta == 0.0 (node_framerate == 0 or elapsed == 0) - return // nothing to do this tick - - // ── boundary crossed: advance to the next queued animation ── - if (hit_boundary == false): - return - - hook_obj = this->hook_obj - if (hook_obj != null): - // if the OLD list head has already been fully consumed and we are - // NOT still inside the "cyclic tail" region, queue AnimDoneHook — - // signals MotionDone to the owning weenie/entity: - list_head_node = (anim_list.head_ != null) ? (anim_list.head_ - 4) : null - if (list_head_node != this->first_cyclic): - CPhysicsObj::add_anim_hook(hook_obj, &anim_done_hook) - - CSequence::advance_to_next_animation(this, elapsed, &curr_anim, &frame_number, frame) - - elapsed = remaining // carry the leftover time (past the boundary) into the next loop pass - goto loop -``` - -**Verbatim structural notes preserved from the raw decomp:** - -- The **degenerate-boundary early-return** (reverse case around line - 301885-301911, forward mirror around line 302008-301912-ish) is a - REAL early exit distinct from the main per-frame loop — it only fires - when the SINGLE-STEP position already lands past the boundary on the - FIRST comparison (i.e. `delta` alone overshoots in one step); in that - branch `apply_physics`/position-combine happens ONCE using the raw - `1.0/node_framerate` quantum and the function returns WITHOUT calling - `advance_to_next_animation` or firing `execute_hooks` at all for that - tick — because `arg5`(frame)==null guards the whole inner block, so if - no destination Frame was supplied, this early-return path does - literally nothing but return. -- The **per-frame do/while loops** (lines 302006-302056 forward; the - reverse mirror is symmetric) always execute the position-combine + - `apply_physics` + `execute_hooks` triple for EVERY whole frame index - crossed this tick, not just the final one — multi-frame skips (large - `elapsed`/`delta` values, e.g. a lag spike) fire ALL intermediate - frame hooks in order, matching the task's requirement to "note which - hooks fire on which frame crossings": every crossed integer frame - fires its `AnimFrame.hooks` chain filtered by direction (`1` forward - / `-1` (`0xffffffff`) reverse) via `execute_hooks`, in strict - ascending (forward) or descending (reverse) frame order. -- `Frame::combine` (forward) vs `Frame::subtract1` (reverse) — these are - NOT symmetric operations; `combine` composes the animation's stored - per-frame `AFrame` (quaternion + origin) INTO the destination frame - (i.e. applies the pose), `subtract1` un-applies (backs out) that same - pose. This is retail's mechanism for incremental per-frame pose - application driven purely by boundary-crossing bookkeeping — the - actual skeletal pose comes from `curr_anim.get_pos_frame(idx)` - (root/`AFrame`-level) and `get_part_frame(idx)` (per-part - `AnimFrame`) tables baked into the `CAnimation` dat resource, NOT from - interpolating `frame_number`'s fractional part at render time inside - this function (rendering interpolation happens elsewhere, in - `PartArray::SetFrame`/`UpdateParts`, off `get_curr_animframe`'s - floored index). -- `CSequence::execute_hooks` is called with the **PART frame** ( - `AnimSequenceNode::get_part_frame(idx)`, an `AnimFrame const*`), NOT - the pos frame — hooks live on `AnimFrame.hooks`, keyed per animation - part-frame index, exactly matching the `AnimFrame` struct's `hooks` - field. -- Boundary detection uses `>` / `<` (strict) against - `get_ending_frame()` / `get_starting_frame()`, meaning `frame_number` - is allowed to sit EXACTLY AT the boundary value without triggering an - advance — the advance only fires once the position strictly exceeds - it. Combined with `get_ending_frame()` returning `high_frame + 1 - - ε`-ish (see §27, actually `high_frame+1` verbatim, no explicit - epsilon subtraction visible in the recovered code — the epsilon - appears in the F_EPSILON velocity-magnitude gates, not the frame - boundary values themselves) this is how retail avoids double-firing - the last frame's hooks. - -## 22. `CSequence::update` — public per-tick entry point (line 302402, addr `0x00525b80`) - -```c -00525b80 void __thiscall CSequence::update(class CSequence* this, double arg2, class Frame* arg3) -{ - if (this->anim_list.head_ != 0) - { - int32_t var_14_1 = *(uint32_t*)((char*)arg2)[4]; - CSequence::update_internal(this, arg2, &this->curr_anim, &this->frame_number, arg3); - CSequence::apricot(this); - return; - } - - if (arg3 != 0) - { - int32_t eax_3 = *(uint32_t*)((char*)arg2)[4]; - int32_t ecx_4 = arg2; - int32_t var_8_2 = eax_3; - int32_t var_10_2 = eax_3; - CSequence::apply_physics(this, arg3, ecx_4, ecx_4); - } -} -``` - -Cleaned: -``` -CSequence::update(this, double elapsed, Frame* frame): - if (anim_list.head_ != null): - update_internal(this, elapsed, &curr_anim, &frame_number, frame) - apricot(this) // trim already-consumed leading nodes - return - - // no queued animations at all — pure physics-only motion (e.g. free-fall, - // knockback velocity with no animation playing) - if (frame != null): - apply_physics(this, frame, elapsed, elapsed) // magnitude == sign source == elapsed -``` - -This is THE per-tick call site (`PartArray::Update` in the older -function-map cross-reference, `FUN_005188e0`/named -`CSequence::update` wrapper) — every physics tick that has an active -animation list goes through `update_internal` then immediately -`apricot`s the consumed leading nodes; a sequence with an EMPTY -animation list (e.g. between transitions, or an object with no motion -table) falls through to a bare `apply_physics` call so accumulated -`velocity`/`omega` (e.g. from `combine_physics` calls, jump/knockback) -still moves the frame even with nothing animating. - -## 23. `CSequence::advance_to_next_animation` (line 301622, addr `0x005252b0`) - -**Signature:** `advance_to_next_animation(CSequence const* this, double arg2, -AnimSequenceNode const** arg3, double* arg4, Frame* arg5)` — `arg2` is -the signed elapsed/rate value carried over from `update_internal` -(same sign convention: negative = reverse). - -```c -005252b0 void __thiscall CSequence::advance_to_next_animation(class CSequence const* this, double arg2, class AnimSequenceNode const** arg3, double* arg4, class Frame* arg5) -{ - class CSequence* this_1 = this; - long double x87_r7 = ((long double)arg2); - long double temp1 = ((long double)0.0); - (x87_r7 - temp1); - class AnimSequenceNode* ecx = *(uint32_t*)arg3; - - if (/* arg2 < 0.0 */) - { - // ── REVERSE: step to the PREVIOUS node ── - if (!(/* node.get_ending_frame's framerate<0 branch NOT taken, i.e. degenerate small-duration guard */) && arg5 != 0) - { - class AnimSequenceNode* ecx_16 = *(uint32_t*)arg3; - if (ecx_16->anim->pos_frames != 0) - Frame::subtract1(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_16, *(uint32_t*)arg4)); - - if (fabs(node.framerate) >= F_EPSILON) // 0.000199999995f - CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2); - } - - // move to predecessor, or wrap to the list tail if there is none - class AnimSequenceNode* eax_17; - if (AnimSequenceNode::GetPrev(*(uint32_t*)arg3) == 0) - { - class DLListData* tail_ = this->anim_list.tail_; - eax_17 = (tail_ == 0) ? nullptr : ((char*)tail_ - 4); - } - else - eax_17 = AnimSequenceNode::GetPrev(*(uint32_t*)arg3); - - *(uint32_t*)arg3 = eax_17; // curr_anim = eax_17 - *(uint64_t*)arg4 = ((double)AnimSequenceNode::get_ending_frame(eax_17)); // frame_number = new node's ending frame - - if (!(/* degenerate small-duration guard */) && arg5 != 0) - { - class AnimSequenceNode* ecx_26 = *(uint32_t*)arg3; - if (ecx_26->anim->pos_frames != 0) - Frame::combine(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_26, *(uint32_t*)arg4)); - - if (fabs(node.framerate) >= F_EPSILON) - CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2); - } - } - else - { - // ── FORWARD: step to the NEXT node, wrapping to first_cyclic at the tail ── - if (!(/* degenerate small-duration guard */) && arg5 != 0) - { - class AnimSequenceNode* ecx_1 = *(uint32_t*)arg3; - if (ecx_1->anim->pos_frames != 0) - Frame::subtract1(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_1, *(uint32_t*)arg4)); - - if (fabs(node.framerate) >= F_EPSILON) - CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2); - } - - if (AnimSequenceNode::GetNext(*(uint32_t*)arg3) == 0) - *(uint32_t*)arg3 = this->first_cyclic; // wrap to first_cyclic when list exhausted - else - *(uint32_t*)arg3 = AnimSequenceNode::GetNext(*(uint32_t*)arg3); - - *(uint64_t*)arg4 = ((double)AnimSequenceNode::get_starting_frame(*(uint32_t*)arg3)); // frame_number = new node's starting frame - - if (/* extra 0x41-mask condition — degenerate guard with an extra bit vs the mirrored branches above */ && arg5 != 0) - { - class AnimSequenceNode* ecx_12 = *(uint32_t*)arg3; - if (ecx_12->anim->pos_frames != 0) - Frame::combine(arg5, arg5, AnimSequenceNode::get_pos_frame(ecx_12, *(uint32_t*)arg4)); - - if (fabs(node.framerate) >= F_EPSILON) - CSequence::apply_physics(this, arg5, ((double)(((long double)1.0) / node.framerate)), arg2); - } - } -} -``` - -Cleaned: -``` -CSequence::advance_to_next_animation(this, elapsed, &curr_anim, &frame_number, frame): - - if (elapsed < 0.0): // REVERSE - // (a) un-apply the outgoing node's pose at its current frame_number, - // and roll its residual velocity/omega into `frame`, UNLESS this - // is a zero-duration/degenerate node - if (frame != null && duration(curr_anim) != 0.0): - if (curr_anim.anim.pos_frames != 0) - Frame::subtract1(frame, frame, curr_anim.get_pos_frame((int)frame_number)) - if (fabs(curr_anim.framerate) >= F_EPSILON) - CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed) - - // (b) step to the PREVIOUS node in the list; if there is no - // predecessor, wrap around to the LIST TAIL - prev = GetPrev(curr_anim) - curr_anim = (prev != null) ? prev : (anim_list.tail_ != null ? tail_node : null) - frame_number = curr_anim.get_ending_frame() - - // (c) apply the INCOMING node's pose at its new frame_number - if (frame != null && duration(curr_anim) != 0.0): - if (curr_anim.anim.pos_frames != 0) - Frame::combine(frame, frame, curr_anim.get_pos_frame((int)frame_number)) - if (fabs(curr_anim.framerate) >= F_EPSILON) - CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed) - - else: // FORWARD (elapsed >= 0.0) - // (a) un-apply the outgoing node's pose - if (frame != null && duration(curr_anim) != 0.0): - if (curr_anim.anim.pos_frames != 0) - Frame::subtract1(frame, frame, curr_anim.get_pos_frame((int)frame_number)) - if (fabs(curr_anim.framerate) >= F_EPSILON) - CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed) - - // (b) step to the NEXT node; if there is none, WRAP TO first_cyclic - // (this is the "loop the cyclic tail forever" mechanism) - next = GetNext(curr_anim) - curr_anim = (next != null) ? next : first_cyclic - frame_number = curr_anim.get_starting_frame() - - // (c) apply the incoming node's pose - if (frame != null && duration(curr_anim) != 0.0): // + one extra mask bit vs (a)/reverse-(c) — same epsilon-style guard - if (curr_anim.anim.pos_frames != 0) - Frame::combine(frame, frame, curr_anim.get_pos_frame((int)frame_number)) - if (fabs(curr_anim.framerate) >= F_EPSILON) - CSequence::apply_physics(this, frame, 1.0 / curr_anim.framerate, elapsed) -``` - -**Key retail-faithful details:** -- **Forward wrap target is `first_cyclic`, NOT the list head.** When - the last node in the forward chain is exhausted, playback loops back - to `first_cyclic` — the boundary marker between one-shot "link" - animations (queued ahead of the loop, consumed once and freed via - `apricot`) and the actual repeating cycle. This is THE mechanism that - makes e.g. Walk_Forward loop forever while a one-shot Jump transition - plays once and falls through. -- **Reverse wrap target is the LIST TAIL**, not `first_cyclic` — reverse - playback (used for backing out of a motion, e.g. an interrupted - transition) wraps to the very end of the queued list, not to the - cyclic boundary. Asymmetric by design. -- Every node transition does FOUR pose operations in sequence: - un-apply-outgoing (`subtract1`/reverse-mirror), select-new-node, - apply-incoming (`combine`), interleaved with `apply_physics` calls - using `1.0 / node.framerate` as the physics quantum's magnitude and - the ORIGINAL caller's `elapsed`/`arg2` as the sign source (matching - `apply_physics`'s `copysign` semantics from §19). -- The three inline "degenerate guard" conditions (reverse-out, - reverse-in, forward-out, forward-in) are the SAME F_EPSILON-style FPU - compare pattern seen throughout — a duration/framerate near-zero - check that skips the pos-frame combine/subtract entirely when the - node's timing is degenerate (e.g. a 1-frame or 0-duration transition - node), a guard against divide-by-near-zero when computing - `1.0/node.framerate`. - -## 24. `CSequence::append_animation` (line 301777, addr `0x00525510`) - -```c -00525510 void __thiscall CSequence::append_animation(class CSequence* this, class AnimData const* arg2) -{ - void* eax = operator new(0x1c); - int32_t* esi; - - if (eax == 0) - esi = nullptr; - else - esi = AnimSequenceNode::AnimSequenceNode(eax, arg2); - - if (AnimSequenceNode::has_anim(esi) != 0) - { - void* eax_3; - if (esi == 0) - eax_3 = nullptr; - else - eax_3 = &esi[1]; - - DLListBase::InsertAfter(&this->anim_list, eax_3, this->anim_list.tail_); - class DLListData* tail_ = this->anim_list.tail_; - void* __offset(DLListData, -0x4) eax_4; - - if (tail_ == 0) - eax_4 = nullptr; - else - eax_4 = ((char*)tail_ - 4); - - this->first_cyclic = eax_4; - - if (this->curr_anim == 0) - { - void* head_ = this->anim_list.head_; - if (head_ != 0) - { - this->curr_anim = ((char*)head_ - 4); - this->frame_number = ((double)AnimSequenceNode::get_starting_frame(((char*)head_ - 4))); - return; - } - - this->curr_anim = nullptr; - this->frame_number = ((double)AnimSequenceNode::get_starting_frame(nullptr)); - } - } - else if (esi != 0) - **(uint32_t**)esi(1); // node had no anim data — self-destruct (scalar deleting dtor, delete=1) -} -``` - -Cleaned: -``` -CSequence::append_animation(this, AnimData const* data): - node = new AnimSequenceNode(data) // heap alloc 0x1c bytes; ctor copies framerate/low_frame/high_frame/anim_id from `data` - if (node.has_anim()): // node.anim != null (DBObj::Get succeeded) - anim_list.InsertAfter(node, anim_list.tail_) // append at the tail - first_cyclic = anim_list.tail_ // ALWAYS repoints first_cyclic to the JUST-APPENDED node - if (curr_anim == null): - if (anim_list.head_ != null): - curr_anim = anim_list.head_ - frame_number = curr_anim.get_starting_frame() - return - curr_anim = null - frame_number = AnimSequenceNode::get_starting_frame(null) // degenerate/default-framerate(30) starting-frame value - else: - delete node // failed to resolve the anim dat resource — discard -``` - -**Critical retail-faithful detail:** `first_cyclic` is updated to the -NEWLY APPENDED node on EVERY successful `append_animation` call, not -just the first. This means the "cyclic tail" boundary always tracks -the LAST node appended so far — i.e. calling `append_animation` -multiple times in sequence (as a transition chain builder does — e.g. -"exit-substate, transition, new-stance" in sequence) keeps sliding -`first_cyclic` forward to the newest node, so only the FINAL -`append_animation` call in a chain-build actually establishes the -node(s) that will be treated as the looping cycle once earlier -one-shot nodes are trimmed by `remove_cyclic_anims`/consumed by -`advance_to_next_animation`'s forward-wrap. - -## 25. `AnimSequenceNode::AnimSequenceNode` ctors (lines 302547 & 302744) - -### Default ctor (line 302547, addr `0x00525d30`) - -```c -00525d30 void __fastcall AnimSequenceNode::AnimSequenceNode(class AnimSequenceNode* this) -{ - this->dllist_next = nullptr; - this->dllist_prev = nullptr; - this->anim = nullptr; - this->vtable = 0x7c8504; - this->framerate = 30f; - this->low_frame = 0xffffffff; - this->high_frame = 0xffffffff; -} -``` - -### From `AnimData` (line 302744, addr `0x00525f90`) - -```c -00525f90 void __thiscall AnimSequenceNode::AnimSequenceNode(class AnimSequenceNode* this, class AnimData const* arg2) -{ - this->dllist_next = nullptr; - this->dllist_prev = nullptr; - this->anim = nullptr; - this->vtable = 0x7c8504; - this->framerate = arg2->framerate; - this->low_frame = arg2->low_frame; - this->high_frame = arg2->high_frame; - AnimSequenceNode::set_animation_id(this, arg2->anim_id.id); -} -``` - -### `AnimSequenceNode::set_animation_id` (line 302561, addr `0x00525d60`) - -```c -00525d60 void __thiscall AnimSequenceNode::set_animation_id(class AnimSequenceNode* this, class IDClass<_tagDataID,32,0> arg2) -{ - class CAnimation* anim_1 = this->anim; - if (anim_1 != 0) - anim_1->vtable->Release(); - - void var_8; - if (arg2 == 0) - this->anim = nullptr; - else - this->anim = DBObj::Get(QualifiedDataID::QualifiedDataID(&var_8, arg2, 8)); - - class CAnimation* anim = this->anim; - if (anim != 0) - { - if (this->high_frame < 0) - this->high_frame = (anim->num_frames - 1); - - uint32_t num_frames_1 = anim->num_frames; - if (this->low_frame >= num_frames_1) - this->low_frame = (num_frames_1 - 1); - - uint32_t num_frames = anim->num_frames; - if (this->high_frame >= num_frames) - this->high_frame = (num_frames - 1); - - int32_t low_frame = this->low_frame; - if (low_frame > this->high_frame) - this->high_frame = low_frame; - } -} -``` - -Cleaned: resolves the dat animation resource (type-8 qualified DBObj -lookup) by `anim_id`, then CLAMPS `low_frame`/`high_frame` into -`[0, anim.num_frames-1]`: -- `high_frame < 0` (the ctor default `0xffffffff` == `-1` when - interpreted signed) → clamp to `num_frames - 1` (i.e. "play to the - end"). -- `low_frame >= num_frames` → clamp to `num_frames - 1`. -- `high_frame >= num_frames` → clamp to `num_frames - 1`. -- if after clamping `low_frame > high_frame`, force `high_frame = - low_frame` (degenerate single-frame range, never inverted). - -`AnimData::AnimData` default ctor (line 302519, addr `0x00525ce0`) -confirms the DEFAULT values a fresh `AnimData` (before per-transition -override) carries: `anim_id = 0`, `low_frame = 0`, `high_frame = -0xffffffff` (i.e. -1, "use full anim"), `framerate = 30.0f`. - -## 26. `AnimSequenceNode::get_starting_frame` (line 302483, addr `0x00525c80`) - -```c -00525c80 int32_t __fastcall AnimSequenceNode::get_starting_frame(class AnimSequenceNode const* this) -{ - class AnimSequenceNode* this_1 = this; - long double x87_r7 = ((long double)this->framerate); - long double temp0 = ((long double)0f); - (x87_r7 - temp0); - int16_t result = ((((x87_r7 < temp0) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp0))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp0) ? 1 : 0) << 0xe) | 0)))); - - if ((*(uint8_t*)((char*)result)[1] & 1) != 0) - return (this->high_frame + 1); - - this->low_frame; - return result; -} -``` - -Cleaned: -``` -AnimSequenceNode::get_starting_frame(): - if (framerate < 0.0f) - return high_frame + 1 - return low_frame -``` -(the `test ah,1` bit pulled from the FPU status word after -`fcomp` is the raw "less-than" flag — `framerate < 0.0`.) - -## 27. `AnimSequenceNode::get_ending_frame` (line 302501, addr `0x00525cb0`) - -```c -00525cb0 int32_t __fastcall AnimSequenceNode::get_ending_frame(class AnimSequenceNode const* this) -{ - class AnimSequenceNode* this_1 = this; - long double x87_r7 = ((long double)this->framerate); - long double temp0 = ((long double)0f); - (x87_r7 - temp0); - int16_t result = ((((x87_r7 < temp0) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp0))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp0) ? 1 : 0) << 0xe) | 0)))); - - if ((*(uint8_t*)((char*)result)[1] & 1) == 0) - return (this->high_frame + 1); - - this->low_frame; - return result; -} -``` - -Cleaned: -``` -AnimSequenceNode::get_ending_frame(): - if (framerate < 0.0f) - return low_frame - return high_frame + 1 -``` - -**These two are exact mirrors of each other, keyed off the SAME sign -test** (`framerate < 0.0`), which is retail's playback-direction flag: -- Forward playback (`framerate >= 0`): starts at `low_frame`, ends at - `high_frame + 1`. -- Reverse playback (`framerate < 0`): starts at `high_frame + 1`, ends - at `low_frame`. - -So "starting frame" and "ending frame" are DIRECTION-AWARE — for a -reverse node, `get_starting_frame()` returns the numerically HIGHER -value (`high_frame + 1`) and `get_ending_frame()` returns the -numerically LOWER value (`low_frame`), because playback is counting -DOWN. This is exactly what feeds `frame_number` at every -`advance_to_next_animation` transition (§23) and is why -`update_internal`'s forward/reverse branches both correctly detect -"exhausted" via a single `frame_number > boundary` / `frame_number < -boundary` test regardless of which physical direction the node's -`framerate` sign implies. - -## 28. `AnimSequenceNode::get_pos_frame` (two overloads, lines 300734 & 302447) - -### `double`-index overload (line 300734, addr `0x005247b0`) - -```c -005247b0 class AFrame* __thiscall AnimSequenceNode::get_pos_frame(class AnimSequenceNode const* this, double arg2) -{ - floor(arg2, *(uint32_t*)((char*)arg2)[4]); - return AnimSequenceNode::get_pos_frame(this, _ftol2()); -} -``` -Truncates `arg2` (a `double` frame position) to `int` via `floor` then -tailcalls the `int`-index overload. - -### `int`-index overload (line 302447, addr `0x00525c10`) - -```c -00525c10 class AFrame* __thiscall AnimSequenceNode::get_pos_frame(class AnimSequenceNode const* this, int32_t arg2) -{ - class CAnimation* anim = this->anim; - if ((anim != 0 && (arg2 >= 0 && arg2 < anim->num_frames))) - return ((arg2 * 0x1c) + anim->pos_frames); - return 0; -} -``` - -Cleaned: -``` -AnimSequenceNode::get_pos_frame(int frame_index): - if (anim != null && 0 <= frame_index < anim.num_frames) - return &anim.pos_frames[frame_index] // AFrame, stride 0x1c (28 bytes) - return null -``` -Bounds-checked lookup into `CAnimation::pos_frames` (root/whole-object -position+orientation per frame — an `AFrame`, stride 28 bytes). Returns -`null` (not a fallback frame) out of range or when the node has no -resolved `anim`. - -## 29. `AnimSequenceNode::get_part_frame` (line 302460, addr `0x00525c40`) - -```c -00525c40 class AnimFrame const* __thiscall AnimSequenceNode::get_part_frame(class AnimSequenceNode const* this, int32_t arg2) -{ - class CAnimation* anim = this->anim; - if ((anim != 0 && (arg2 >= 0 && arg2 < anim->num_frames))) - return &anim->part_frames[arg2]; - return 0; -} -``` - -Cleaned: identical shape to `get_pos_frame` but indexes -`CAnimation::part_frames` (the PER-PART `AnimFrame` array — this is -what carries `.hooks` for the frame, consumed by -`CSequence::execute_hooks`). Same bounds check, same `null` on -out-of-range/no-anim. - -## 30. `AnimSequenceNode::has_anim` (line 302473, addr `0x00525c70`) - -```c -00525c70 int32_t __fastcall AnimSequenceNode::has_anim(class AnimSequenceNode const* this) -{ - int32_t result; - result = this->anim != 0; - return result; -} -``` - -## 31. `AnimSequenceNode::GetNext` / `GetPrev` (lines 302601, 302614) - -```c -00525de0 class AnimSequenceNode const* __fastcall AnimSequenceNode::GetNext(class AnimSequenceNode const* this) -{ - class DLListData* dllist_next = this->dllist_next; - if (dllist_next == 0) - return 0; - return ((char*)dllist_next - 4); -} - -00525df0 class AnimSequenceNode* __fastcall AnimSequenceNode::GetPrev(class AnimSequenceNode* this) -{ - class DLListData* dllist_prev = this->dllist_prev; - if (dllist_prev == 0) - return 0; - return ((char*)dllist_prev - 4); -} -``` - -Both convert the raw `DLListData*` link pointer to the owning -`AnimSequenceNode*` via the same `-4` byte adjustment seen throughout -the list-splice code (§0 struct-layout note). - -## 32. `CSequence::pack_size` / `Pack` / `UnPack` (lines 301334, 301458, 302235) — wire serialization - -Included for completeness (not part of the per-frame hot path, but -documents the `CSequence` wire format used by `PackObj::Pack`/`UnPack` -dispatch, relevant if acdream ever needs to interoperate with a -serialized retail sequence snapshot): - -```c -00524f20 uint32_t __thiscall CSequence::pack_size(class CSequence* this, uint32_t* arg2, uint32_t* arg3) -{ - *(uint32_t*)arg2 = 0; // flags accumulator (bit0 = has non-zero velocity, bit1 = has non-zero omega) - *(uint32_t*)arg3 = 0; // node count accumulator - - // count nodes in anim_list, summing each node's Pack() size (0x10 each) into `edi` - // edi starts at 4 (header dword), += 4 more if list non-empty (node-count field), - // += 0x10 if node count != 0 vs += 4 if empty (mismatched header sizing between - // "has nodes" vs "no nodes" cases) - - result = edi_1 + 4; // + placement_frame_id / frame_number header dword - - // velocity: if fabs(x) < F_EPSILON check EACH axis (short-circuit: first axis that - // fails the epsilon test forces the WHOLE vector to be packed as 0xc bytes + flag bit 0) - if (fabs(velocity.x) >= F_EPSILON) { result += 0xc; flags |= 1; } - else if (fabs(velocity.y) >= F_EPSILON) { result += 0xc; flags |= 1; } - else if (fabs(velocity.z) >= F_EPSILON) { result += 0xc; flags |= 1; } - - // omega: same pattern, flag bit 1 - if (fabs(omega.x) >= F_EPSILON) { result += 0xc; flags |= 2; } - else if (fabs(omega.y) >= F_EPSILON) { result += 0xc; flags |= 2; } - else if (fabs(omega.z) >= F_EPSILON) { result += 0xc; flags |= 2; } - - return result; -} -``` - -`CSequence::Pack` writes: flags-dword, [per-node `Pack()` blob × -count], then EITHER `placement_frame_id` (if no nodes) OR -`frame_number` (8 bytes) + `first_cyclic`-index (distance from head to -`first_cyclic` walking `GetNext`) + `curr_anim`-index (distance from -head to `curr_anim`), then conditionally `velocity` (if flags&1) and -`omega` (if flags&2) as 3 floats each (12 bytes, `0xc`) gated by -`arg3 >= 0xc` (buffer-size guard). - -`AnimSequenceNode::Pack`/`UnPack` (lines 302692/302721, `0x00525ee0`/ -`0x00525f40`) pack as `[DID (or INVALID_DID if anim==null), low_frame, -high_frame, framerate]` = 0x10 (16) bytes fixed. - -## 33. `CSequence::UnPack` (line 302235, addr `0x005259d0`) — tail (velocity/omega restore) - -```c -00525b3f if (((ecx_11 & 2) != 0 && arg3 >= 0xc)) - { - this->omega.x = *(uint32_t*)eax_14; - void* edx_5 = (*(uint32_t*)esi + 4); - *(uint32_t*)esi = edx_5; - this->omega.y = *(uint32_t*)edx_5; - void* ecx_14 = (*(uint32_t*)esi + 4); - *(uint32_t*)esi = ecx_14; - this->omega.z = *(uint32_t*)ecx_14; - *(uint32_t*)esi += 4; - } - - return 1; -} -``` - -Symmetric restore of `velocity` (flag bit 0) then `omega` (flag bit 1), -matching the `Pack`/`pack_size` bit layout above. `UnPack` begins (line -302239-302247) by calling `clear_animations()` + `clear_physics()` and -resetting `placement_frame`/`placement_frame_id` to null/0 before -reading the wire data — a full state wipe before deserializing. - ---- - -## Summary: hook-firing timeline per `CSequence::update` tick - -1. `CSequence::update(elapsed, frame)` called once per physics tick from - `PartArray::Update`. -2. If `anim_list` is non-empty: `update_internal` runs, possibly - LOOPING internally (the `goto loop` in §21) if `elapsed` overshoots - the current node's frame range — each loop iteration can itself fire - MULTIPLE per-frame hook batches (the inner do/while over crossed - integer frame indices) before even considering a node transition. -3. For EVERY whole integer frame index crossed within a single node - (forward or reverse), in strict frame order: - a. The node's stored pose delta at that frame index is - combined/subtracted into the destination `Frame*` (`Frame::combine` - forward, `Frame::subtract1` reverse) — but ONLY if the node's - `CAnimation.pos_frames != null`. - b. `apply_physics` folds in the sequence's accumulated - `velocity`/`omega` scaled by `1.0/framerate`, signed by the - caller's original elapsed/rate — but ONLY if - `fabs(framerate) >= F_EPSILON`. - c. `execute_hooks(part_frame_at_index, direction)` queues every - matching `CAnimHook` (direction 0 = both, else must match caller's - `1`=forward / `-1`=reverse) onto the owning `CPhysicsObj.anim_hooks` - array — NOT executed inline. -4. When a node's frame range is exhausted (`frame_number` strictly past - `get_ending_frame()`/`get_starting_frame()`), BEFORE calling - `advance_to_next_animation`: if the OLD list head has already been - fully consumed (`head != first_cyclic`), `AnimDoneHook` (the global - singleton) is queued directly onto `anim_hooks` — this is the signal - that eventually calls `CPartArray::AnimationDone(1)` / - `MovementManager::MotionDone` once drained. -5. `advance_to_next_animation` performs the un-apply/select-next/apply - pose sequence (§23), wrapping forward exhaustion to `first_cyclic` - (loop the cycle) or reverse exhaustion to the list tail. -6. Control returns to `update_internal`'s outer loop with the LEFTOVER - elapsed time (`remaining`, the fractional overshoot past the old - boundary converted back to a time delta via `/ node_framerate`), - which may immediately trigger ANOTHER frame-crossing pass against - the NEW `curr_anim` in the same `update_internal` call — i.e. a - single `CSequence::update()` call can transition through several - queued animation nodes in one tick if `elapsed` is large enough - (a slow frame / lag spike can legitimately fast-forward through - multiple short one-shot transition nodes in one physics step). -7. After `update_internal` returns (list still non-empty), `apricot()` - frees every node strictly BEFORE the (possibly new) `curr_anim`, - bounded so it never deletes into `first_cyclic`'s cyclic tail. -8. All queued `anim_hooks` (frame hooks + any `AnimDoneHook`) are - actually EXECUTED later, once per physics tick, by - `CPhysicsObj::process_hooks` — a separate call NOT inside - `CSequence` at all, invoked by the owning `CPhysicsObj`'s update - path — and the `anim_hooks` array is fully drained (`m_num = 0`) - after every execute pass. diff --git a/docs/research/2026-07-02-r1-csequence/r1-gap-map.md b/docs/research/2026-07-02-r1-csequence/r1-gap-map.md deleted file mode 100644 index 54cc5890..00000000 --- a/docs/research/2026-07-02-r1-csequence/r1-gap-map.md +++ /dev/null @@ -1,224 +0,0 @@ -# R1 gap map — retail CSequence vs acdream AnimationSequencer - -Inputs: `r1-csequence-decomp.md` (verbatim retail extraction, line anchors into -`docs/research/named-retail/acclient_2013_pseudo_c.txt`), `r1-ace-sequence.md` -(ACE port cross-reference), `r1-acdream-sequencer.md` (current-code map). -Plan of record: `docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (R1 stage). -Current code: `src/AcDream.Core/Physics/AnimationSequencer.cs` (1584 lines) + -`AnimationCommandRouter.cs` / `AnimationHookRouter.cs` / `IAnimationHookSink.cs`. - -**Conflicts between the two research passes RESOLVED in this synthesis (re-read raw decomp):** - -- **Hook direction constants** — r1-ace-sequence.md's claim ("forward pass at 0x0052590c passes - 0xffffffff") was a branch misattribution. Raw decomp verified this pass: - `0x0052578c` `execute_hooks(..., ebx_2, 1)` with `ebx_2 += 1` after = FORWARD crossing → **+1**; - `0x0052590c` `execute_hooks(..., ebx_1, 0xffffffff)` with `ebx_1 -= 1` after = REVERSE → **-1**. - Retail encoding == ACE `AnimationHookDir` (`references/ACE/Source/ACE.Entity/Enum/AnimationHookDir.cs`: - Backward=-1, Both=0, Forward=1) == the DatReaderWriter enum acdream already uses. No enum-remap needed. -- **update_internal fine structure** — r1-csequence-decomp.md §21's cleaned flow is garbled in two - places (an "early return" where retail clamps+continues; `hit_boundary=true` placed - unconditionally after the per-frame loop). The authoritative skeleton is ACE - `Sequence.cs:351-443` (verified verbatim this pass, quoted below in P4), which matches the raw - decomp's branch layout at `0x005255d0`-`0x005259ca`: overshoot check → clamp `frameNum` to - `get_high_frame()` (fwd) / `get_low_frame()` (rev) + compute `frameTimeElapsed` leftover + - `animDone=true` → per-frame crossing loop → `if (!animDone) return` → AnimDone gate - (`head != first_cyclic`) → `advance_to_next_animation` → carry leftover → loop. -- **ONE remaining unresolved decomp ambiguity** — raw decomp at `0x0052598a-0x0052598d` appears to - zero `arg2` (elapsed) after `advance_to_next_animation` before looping, while ACE carries - `timeElapsed = frameTimeElapsed` (the leftover). If retail truly zeroed it, a lag spike could - never fast-forward through multiple queued nodes in one tick (observable). ACE's reading is far - more plausible (BN likely lost the var reassignment through the x87 stack slot). **Pin in the R1 - pseudocode commit (P0)** — cdb breakpoint on `CSequence::advance_to_next_animation` counting - invocations per `CSequence::update` call under induced multi-node overshoot. - -Severity key: **BLOCKER** = must be right or R1's conformance harness is meaningless; -**HIGH** = visible animation wrongness / blocks R2+; **MED** = edge-case visible or blocks a later -stage; **LOW** = dormant/textual. - ---- - -## 1. ITEMIZED GAP LIST - -| # | Retail behavior acdream lacks/diverges on | Decomp anchor | Current-code anchor | Severity | -|---|---|---|---|---| -| G1 | **Direction-aware boundary pair with bare-int values.** Retail `get_starting_frame` = `framerate<0 ? high+1 : low`, `get_ending_frame` = `framerate<0 ? low : high+1` — plain ints, **NO epsilon**. acdream (following ACE) returns `(EndFrame+1) - FrameEpsilon` with `FrameEpsilon = 1e-5` (ACE uses 0.0002); the epsilon is an ACE fabrication compensating for ACE's own `float FrameNumber`. | `0x00525c80`/`0x00525cb0`, pseudo-C lines 302483/302501 (r1-csequence-decomp §26/27); ACE divergence #2 (r1-ace-sequence) | `AnimationSequencer.cs:154,164` (`GetStartFramePosition`/`GetEndFramePosition`), `:171` (`FrameEpsilon=1e-5`) | **BLOCKER** — every boundary decision keys off these values | -| G2 | **`multiply_framerate` swaps `low_frame`↔`high_frame` on negative factor.** acdream keeps `StartFrame ≤ EndFrame` invariant and encodes direction only in Framerate sign, compensating in `Advance` — explicitly documented divergence. Verbatim port requires the swap + the direction-aware G1 pair; the two mechanisms are coupled. | `0x00525be0`, line 302425 (§14a) | `AnimationSequencer.cs` `AnimNode.MultiplyFramerate` (r1-acdream map row "multiply_framerate") | **HIGH** — reverse playback (WalkBackward links, reverse stops) correctness | -| G3 | **update_internal boundary model: clamp-to-`high_frame`/`low_frame` + leftover-time carry, boundary test `floor(frameNum) > high_frame` (i.e. ≥ high+1).** acdream tests `newPos >= maxBoundary - FrameEpsilon` and clamps `_framePosition = maxBoundary - FrameEpsilon` — epsilon-shifted boundary, different clamp target, no strict retail equivalence at exact-integer landings. This is the #61 "link→cycle boundary flash" bug class. | `0x005255d0` body (lines 301839-302235); ACE `Sequence.cs:366-377` (fwd), `:394-406` (rev) | `AnimationSequencer.cs:894,900` (`maxBoundary - FrameEpsilon`) | **BLOCKER** | -| G4 | **`safety=64` loop cap** — retail has none; termination comes from `frameTimeElapsed` shrinking + the `frametime == 0` branch returning. Mandate says delete bandaids on cutover. | ACE `Sequence.cs:351-443` (no cap); decomp `0x005255d0` (while-true loop) | `AnimationSequencer.cs:872-874` | MED (delete in R1 core) | -| G5 | **AnimDone gate is a LIST-STRUCTURE test, not a node flag.** Retail queues the global `anim_done_hook` singleton when `animDone && hook_obj != null && (anim_list.head_ − 4) != first_cyclic` — i.e. "the old head has been consumed and we're not already in the cyclic tail". acdream pushes `AnimationDoneSentinel` gated on `!_currNode.Value.IsLooping` — a per-node flag that acdream itself invented (retail nodes have no IsLooping). Different gate ⇒ different MotionDone timing. R2's `CheckForCompletedMotions → AnimationDone → MotionDone` chain consumes this signal; it must be retail-exact in R1. | `0x00525943-0x00525968` (verified raw this pass); AnimDoneHook singleton at data `0x0081d9fc`, Execute `0x00526c20` → `Hook_AnimDone 0x0050fda0` → `CPartArray::AnimationDone(1)` (§18a) | `AnimationSequencer.cs:1129` (`AnimationDoneSentinel`), Advance's `!IsLooping` gate (r1-acdream map row "AnimationDone hook") | **HIGH** | -| G6 | **Two-stage hook dispatch.** Retail `execute_hooks` QUEUES matching `CAnimHook*` into `CPhysicsObj.anim_hooks` (SmartArray); a separate `CPhysicsObj::process_hooks` drains + `Execute()`s once per physics tick then resets `m_num=0`. acdream's `_pendingHooks` + `ConsumePendingHooks()` is structurally similar (queue then drain) but the drain point is GameWindow's render-tick loop immediately after `Advance` — not positioned per retail's `UpdateObjectInternal` order (`process_hooks` runs LAST, after MovementManager.UseTime etc.). R1 must expose the queue through a host seam so R6 can place the drain correctly. | `execute_hooks 0x00524830` (line 300780), `add_anim_hook 0x00514c20` (line 282906), `process_hooks 0x00511550` (line 279431) (§18) | `AnimationSequencer.cs:1371-1388` (`ExecuteHooks` → `_pendingHooks`), `GameWindow.cs:9882` (drain) | MED for R1 (seam), HIGH by R6 | -| G7 | **`apply_physics` + Frame-target root motion is unwired.** Retail: `update(quantum, Frame*)` accumulates `velocity*signed_quantum` into `frame.m_fOrigin` and `omega*signed_quantum` via `Frame::rotate`, with `signed_quantum = copysign(fabs(arg3), arg4)`; per crossed frame the quantum is `1.0/framerate` signed by elapsed. acdream has NO `apply_physics`: velocity/omega are surfaced as `CurrentVelocity`/`CurrentOmega` properties consumed by external movement code, and pos-frame root motion accumulates into `_rootMotionPos/_rootMotionRot` that **nothing drains** (`ConsumeRootMotionDelta()` has zero callers). | `0x00524ab0` (line 300955, §19); `Frame::rotate 0x004525b0` | `AnimationSequencer.cs:975-979` region (`ConsumeRootMotionDelta`, dead); `CurrentVelocity/CurrentOmega` consumers `GameWindow.cs:9331-9334`, `:12917` | **HIGH** — this is retail's entire physics-from-animation mechanism; R6's `CPartArray.Update → adjust_offset → Frame.combine` tick order needs it | -| G8 | **Empty-list physics-only fallback.** Retail `update`: if `anim_list` empty and `frame != null` → `apply_physics(frame, elapsed, elapsed)` (accumulated velocity still moves the object — free-fall/knockback with no anim). acdream `Advance` with no `_currNode` does nothing. | `0x00525b80` (line 302402, §22) | `AnimationSequencer.cs:874` (`while (... && _currNode != null ...)` — falls out) | MED | -| G9 | **`advance_to_next_animation`'s four-pose-op transition + reverse node stepping + asymmetric wrap.** Retail per node transition: (a) subtract outgoing node's pos_frame at current frame_number + apply_physics(1/framerate, sign=elapsed); (b) step node — forward: `GetNext` else wrap to **first_cyclic**; reverse: `GetPrev` else wrap to **LIST TAIL**; (c) `frame_number = get_starting_frame()` (fwd) / `get_ending_frame()` (rev); (d) combine incoming node's pos_frame + apply_physics. acdream `AdvanceToNextAnimation` only steps `.Next`/wraps to `_firstCyclic`/holds-on-last (invented hold), resets `_framePosition`, and does **none** of the pose subtract/combine ops and has **no reverse branch at all**. | `0x005252b0` (line 301622, §23) | `AnimationSequencer.cs:1344-1364` | **HIGH** (fwd pose ops + reverse branch); the hold-on-last-node is an acdream invention to delete | -| G10 | **`append_animation` slides `first_cyclic` to the just-appended node on EVERY call.** Retail's "cyclic tail" is always exactly the LAST appended anim (so a multi-anim cycle MotionData loops only its final AnimData node once earlier ones are consumed). acdream sets `_firstCyclic` to the FIRST node of the cycle MotionData. Also retail: `if (curr_anim == null) { curr_anim = head; frame_number = get_starting_frame(head); }` — acdream's equivalents are scattered through `SetCycle`'s rebuild. | `0x00525510` (line 301777, §24) | `AnimationSequencer.cs:634-645` region + `EnqueueMotionData` (r1-acdream map rows "Node list", "add_motion") | **HIGH** — divergent loop membership for multi-anim cycles; also the retail invariant that makes remove_cyclic/apricot correct | -| G11 | **The remove-family with curr_anim snap semantics is missing.** Retail: `remove_cyclic_anims` (0x00524e40) deletes `first_cyclic`→tail, snapping `curr_anim` back to prev + `frame_number = get_ending_frame(prev)` (or 0), then `first_cyclic = tail`; `remove_link_animations(n)` (0x00524be0) / `remove_all_link_animations` (0x00524ca0) delete predecessors of `first_cyclic`, snapping `curr_anim` FORWARD to `first_cyclic` + `get_starting_frame`; `clear_animations` (0x00524dc0) full wipe; `apricot` (0x00524b40) trims consumed leading nodes after every update, bounded by `curr_anim`/`first_cyclic`. acdream instead has `ClearCyclicTail` + wholesale queue clears + the invented "stale-head `_currNode` force-relocation" + "Fix B link-skip" — all approximations of what the retail remove-family + apricot do naturally. | lines 301258/301060/301128/301207/300978 (§5-8, §20) | `AnimationSequencer.cs:1311` (`ClearCyclicTail`), `:511`, stale-head relocation + Fix B blocks in `SetCycle` (r1-acdream map rows "Stale-head handling", "Fix B") | **HIGH** — these retire two invented mechanisms | -| G12 | **`combine_physics`/`subtract_physics` accumulators absent.** Retail `velocity += / -=` element-wise (x87-widened). acdream only has replace (`EnqueueMotionData`) + `ClearPhysics`. Needed by R2's fast path (`change_cycle_speed` + `subtract_motion(old)` + `combine_motion(new)`) and by jump/knockback physics later. | `0x005248c0`/`0x00524900` (lines 300818/300832, §12/13) | no equivalent in `AnimationSequencer.cs` | MED (trivial; part of the verbatim class) | -| G13 | **`multiply_cyclic_animation_fr` must touch ONLY node framerates.** Retail walks `first_cyclic`→tail calling `multiply_framerate`. acdream's `MultiplyCyclicFramerate` additionally scales `CurrentVelocity/CurrentOmega` — algebraically equivalent to retail's `change_cycle_speed`+`subtract/combine_motion` composite (an R2 mechanism folded in). Core port must separate them or R2's verbatim fast path double-applies. | `0x00524940` (line 300846, §14) | `AnimationSequencer.cs` `MultiplyCyclicFramerate` (r1-acdream map row) | MED (correct today by accident; wrong the moment R2 lands) | -| G14 | **Placement frames absent.** Retail `set_placement_frame`/`placement_frame_id` + `get_curr_animframe` returning `placement_frame` when `curr_anim == null` (static pose for object with no active anims). acdream has no placement concept (identity frames only). Explicit R1 scope item in the plan. | `0x005249b0`/`0x00524970` (lines 300872/300855, §15/16) | no equivalent (grep "placement" hits only a doc comment at `AnimationSequencer.cs:979`) | MED | -| G15 | **`frame_number` precision.** Retail: x87 `long double` (80-bit; verbatim `acclient.h:30747`). acdream: `double` (`AnimationSequencer.cs:303`) — the best C# can do; ACE's `float` is worse. Residual double-vs-extended ULP divergence at exact frame boundaries is an unavoidable adaptation → **needs a divergence-register row in the R1 core commit**. | `acclient.h:30747`; headline of r1-ace-sequence | `AnimationSequencer.cs:302-303` | LOW runtime / **process-MANDATORY** register row | -| G16 | **Node ctor defaults + `set_animation_id` clamp order.** Retail defaults: `framerate=30f, low_frame=-1, high_frame=-1`; `AnimData` defaults `low=0, high=-1, framerate=30f`; clamp order: `high<0→num-1`, `low>=num→num-1`, `high>=num→num-1`, `low>high→high=low`. acdream `LoadAnimNode` handles the `-1` sentinel + `low>high` but not the full order; per-node it also stores `IsLooping/HasPosFrames/Velocity/Omega` fields retail nodes don't have (retail velocity/omega live on the SEQUENCE only). NOTE: r1-ace-sequence flagged set_animation_id as unverified, but r1-csequence-decomp §25 captured the FULL body — resolved, no follow-up grep needed. | `0x00525d30`/`0x00525f90`/`0x00525d60` (lines 302547/302744/302561, §25); `AnimData` ctor `0x00525ce0` | `AnimationSequencer.cs` `AnimNode` + `LoadAnimNode` (r1-acdream map §0, row "Placement / root frames") | MED | -| G17 | **`add_motion` velocity semantics: unconditional REPLACE.** Retail free fn `add_motion` (0x005224b0) calls `set_velocity(motionData.velocity*speed)`/`set_omega(...)` **unconditionally** (a MotionData without the dat HasVelocity bit carries zero → replace-with-zero). acdream gates on `Flags.HasFlag(HasVelocity)` and otherwise LEAVES the previous value (`AnimationSequencer.cs:1288-1294` — comment claims "matches retail's conditional behavior", which the decomp contradicts). Retail avoids the zero-a-running-cycle problem via call-graph (modifiers go through `combine_motion`, not `add_motion`) — an R2 distinction acdream compensates for with this flag gate. | `0x005224b0` (r1-ace-sequence `add_motion` section); `combine_motion 0x00522580` | `AnimationSequencer.cs:1288-1294` | MED — port unconditional replace in the R1 core; keep the gate in the adapter until R2 routes modifiers through combine_motion, then delete (register row if the adapter gate outlives R1) | -| G18 | **`get_pos_frame` returns null out-of-range** (retail), not identity — and retail's `execute_hooks` has a latent null-deref on `arg2->hooks` (no null check). Port: null-return + the ACE-style null guard in execute_hooks as a documented safe divergence (crash-parity with retail is not a goal). acdream's ExecuteHooks already bounds-checks (`:1373`) — keep the guard, cite it. | `0x00525c10`/`0x00524830` (§28/§18); ACE divergences #5/#8 | `AnimationSequencer.cs:1373` | LOW | -| G19 | **`update` entry contract.** Retail `update(double quantum, Frame*)`: non-empty → `update_internal` then **`apricot`**; empty → physics-only. acdream `Advance(float dt)` returns blended `PartTransform[]` and never trims consumed nodes structurally (rebuilds hide it). Core port needs the retail entry + apricot; the blended-frame render output stays an adapter/render-side concern (retail renders off `get_curr_animframe`'s FLOORED index; interpolation lives in CPartArray-land, out of R1 core scope). | `0x00525b80` + apricot `0x00524b40` (§20/22) | `AnimationSequencer.cs:872+` (`Advance`), `BuildBlendedFrame` | **HIGH** (apricot + entry contract); blend seam MED | -| G20 | **`clear()` scope.** Retail `clear` = `clear_animations()+clear_physics()` ONLY (2 calls, 0x005255b0); the placement_frame reset belongs to `UnPack` (0x005259d0). Do not copy ACE's `Clear()` which folds the placement reset in. | line 301828 (§3); ACE divergence #6 | acdream `Reset()` (no external callers) | LOW | - -Invented behaviors NOT in the gap list because they are R2/R3 scope and survive R1 **in the -adapter, unchanged**: K-fix18 `skipTransitionLink` (retire in R3 jump family), Fix B -locomotion link-skip (retail mechanism = `remove_redundant_links 0x0051bf20`, R2), stop-anim -fallback + GetLink reversed branch (R2 `get_link`/`GetObjectSequence`), velocity-synthesis -constants Walk=3.12/Run=4.0/Side=1.25 (R3 `get_state_velocity`), `HasCycle` probe (R2), -retail slerp + BuildBlendedFrame (render-side, not CSequence). Each keeps/gets its -divergence-register row when touched. - ---- - -## 2. KEEP LIST — already matching retail - -| Behavior | Retail anchor | acdream anchor | -|---|---|---| -| `execute_hooks` direction filter `dir==0(Both) \|\| dir==caller` | `0x00524830` line 300780; constants verified: fwd=+1 @0x0052578c, rev=-1 @0x0052590c | `AnimationSequencer.cs:1371-1388`; DatReaderWriter `AnimationHookDir` Backward=-1/Both=0/Forward=1 == retail encoding | -| Queue-then-drain hook model (hooks NOT executed inline during frame advance) | `add_anim_hook 0x00514c20` + `process_hooks 0x00511550` | `_pendingHooks` + `ConsumePendingHooks()` (drain placement moves in R6, mechanism correct) | -| Per-frame crossing walk fires pose+hooks for EVERY integer frame crossed, strict ascending (fwd) / descending (rev) order | `0x005255d0` do/while loops (lines 302006-302056 + reverse mirror) | `AnimationSequencer.cs:910-941` (fwd `lastFrame++` w/ Forward, rev `lastFrame--` w/ Backward) | -| Forward node wrap to `first_cyclic` (loop-the-cycle mechanism) | `0x005252b0` @0x005253xx: `GetNext==null → first_cyclic` | `AnimationSequencer.cs:1350-1358` | -| Leftover-time carry into the next node after a boundary (multi-node fast-forward in one tick) | ACE `Sequence.cs:436-442` (`timeElapsed = frameTimeElapsed` + recurse); decomp loop-back @0x005255e8 (see open ambiguity above) | `Advance`'s `timeRemaining`/overflow continue (r1-acdream map row "update_internal") | -| Root-motion composition directions: combine (apply pose) forward, subtract (un-apply) reverse | `Frame::combine`/`Frame::subtract1` call sites in `0x005255d0`/`0x005252b0` | `ApplyPosFrame(node, idx, reverse:)` fwd/conjugate-reverse (r1-acdream map row "Root motion") — values correct, TARGET wrong (G7: accumulator never drained) | -| `frame_number` floored to int for pose lookup (`get_curr_animframe`/`get_curr_frame_number` shape) | `0x00524970`/`0x005249d0` (§15/17) | `AnimationSequencer.cs:884` (`(int)Math.Floor(_framePosition)`) | -| `clear_physics` zeroing before rebuild | `0x00524d50` + `GetObjectSequence`'s `clear_physics; remove_cyclic_anims` pairing @0x005229cf etc. | `ClearPhysics()` called from `SetCycle` (r1-acdream map row "clear_physics") | -| `AnimData` speed scaling: only framerate × speed, low/high pass through | `operator* 0x00525d00` (invoked from `add_motion` @0x0052255b) | `LoadAnimNode` (`AnimData.Framerate * speedMod`) | -| `HighFrame == -1` sentinel → last frame; `low > high → high = low` degenerate guard | `set_animation_id 0x00525d60` clamps | `LoadAnimNode` (r1-acdream map row "Placement / root frames") — partial (see G16 for full order) | -| Fast-path re-speed without restart on same motion (concept) | ACE `MotionTable.cs:132-139`; retail `change_cycle_speed 0x00522290` | `SetCycle` early-return → `MultiplyCyclicFramerate` (G13 caveat) | -| `frame_number` at `double` ≥ ACE's float | `acclient.h:30747` (`long double`) | `AnimationSequencer.cs:303` — already the best-available C# type | -| Retail slerp incl. validation-fallback quirk (render blend, not CSequence) | `FUN_005360d0` (chunk_00530000.c:4799-4846) | `SlerpRetailClient` — keep untouched | - ---- - -## 3. PORT ORDER — R1 commit sequence (tests-first, each one commit) - -New code target: `src/AcDream.Core/Physics/Motion/` (plan rule 4). Naming: retail names -(`CSequence`, `AnimSequenceNode`) or thin C# equivalents — decided in P1, consistent after. -Every commit: build+test green; register rows added/retired in-commit. - -**P0 — pseudocode + ambiguity pinning (docs only).** -Write `docs/research/2026-07-xx-csequence-pseudocode.md` from r1-csequence-decomp.md, -CORRECTING §21 to the ACE-verified skeleton (this doc's header), and pin the ONE open ambiguity: -leftover-time carry vs `arg2=0` at `0x0052598a`. -Fixture source: **cdb trace** — breakpoint `acclient!CSequence::advance_to_next_animation` + -`acclient!CSequence::update` with hit counters (pattern: `tools/cdb/l2g-observer.cdb`); ratio >1 -advance-per-update under a stall/lag proves the carry. Also capture -`append_animation`/`remove_cyclic_anims` arg logs here (they feed P2/P5 goldens — one cdb session -serves all of R1). -Dependencies: none. This is the workflow's mandatory step-3 artifact. - -**P1 — `AnimSequenceNode` verbatim.** (closes G1, G2, G16, G18-node-half) -Fields `anim/framerate(float)/low_frame/high_frame` only (NO IsLooping/Velocity/Omega per-node); -ctors with retail defaults (30f/-1/-1); `set_animation_id` full clamp order (§25); -`get_starting_frame`/`get_ending_frame` bare-int direction-aware pair (NO epsilon); -`multiply_framerate` with low/high swap on negative; `get_pos_frame` (null OOB, both overloads) / -`get_part_frame` / `has_anim`. Uses existing `IAnimationLoader` for the DBObj::Get seam. -Tests first: synthetic (all clamp branches; negative-multiply swap; direction-aware boundary -mirror table) + **dat fixtures** (real Humanoid MotionTable AnimData via DatCollection: resolve, -clamp, verify against `Animation.PartFrames.Count`). -Dependencies: P0. - -**P2 — `CSequence` container + list surgery.** (closes G10, G11, G12, G14, G15, G20; G5's structural precondition) -`anim_list` (LinkedList), `first_cyclic`, `curr_anim`, `frame_number:double`, `velocity/omega`, -`placement_frame/placement_frame_id`, `hook_obj`-seam. Methods: `append_animation` -(first_cyclic-slides-to-tail-every-call + curr_anim seed), `clear/clear_animations/clear_physics`, -`remove_cyclic_anims` (snap-back + `get_ending_frame(prev)`), `remove_link_animations(n)`, -`remove_all_link_animations`, `has_anims`, `apricot`, `set_velocity/set_omega/combine_physics/ -subtract_physics`, `set_placement_frame/get_curr_animframe/get_curr_frame_number`, -`multiply_cyclic_animation_fr` (framerates ONLY — G13). -Register rows in-commit: double-vs-long-double (G15); managed LinkedList vs intrusive DLList. -Tests first: list-surgery state tables (curr_anim/first_cyclic/frame_number after every op, incl. -curr_anim-inside-removed-range snaps; apricot bounded by curr_anim AND first_cyclic). -Fixture source: **synthetic** + **cdb goldens from P0** (`append_animation`/`remove_cyclic_anims` -arg sequences from a live Walk→Run→Stop cycle — replay the call sequence, assert list shape). -Dependencies: P1. - -**P3 — `apply_physics` + Frame math.** (closes G7's math half) -`apply_physics(Frame, quantum, sign)` with copysign semantics; verbatim `Frame.combine`/ -`Frame.subtract1`/`Frame.rotate` equivalents (port beside the existing ApplyPosFrame math, which -becomes call-compatible). -Tests first: numeric goldens — hand-computed copysign cases (±quantum × ±sign), combine∘subtract1 -= identity round-trips, rotate vs quaternion reference; cross-check values against ACE -`Sequence.cs:221` + `AFrame.cs`. -Fixture source: **synthetic** (pure math; no dat needed). -Dependencies: P2 (fields), parallel-safe with P1 internals. - -**P4 — `update_internal` + `update` + `advance_to_next_animation`.** (closes G3, G4, G5, G6-queue, G8, G9, G19) -Verbatim per the ACE-verified skeleton: floor(lastFrame) → advance frame_number → overshoot clamp -to `get_high_frame()`/`get_low_frame()` + `frameTimeElapsed` leftover + animDone → per-frame -crossing loop (combine/subtract pos_frame if `pos_frames != null`; `apply_physics(1/framerate, -elapsed)` if `|framerate| ≥ 0.000199999995f`; `execute_hooks(part_frame, +1/-1)`) → `if -(!animDone) return` → AnimDone gate `head != first_cyclic` → queue global AnimDoneHook → -`advance_to_next_animation` (four pose ops; fwd wrap first_cyclic, rev wrap TAIL) → carry leftover -(per P0's pin) → loop (iterative, not ACE's recursion). `update`: non-empty → internal+`apricot`; -empty → `apply_physics(frame, elapsed, elapsed)`. `execute_hooks` queues into an -`IAnimHookQueue` host seam (stands in for `CPhysicsObj.anim_hooks`; GameWindow drain point -unchanged until R6). NO safety cap. -Tests first — the R1 conformance harness core: -(a) **dat fixture**: Humanoid walk cycle advanced at fixed 1/30s quanta N ticks — golden -`frame_number` series + hook-fire (frame,direction) sequence; -(b) **synthetic**: multi-node fast-forward in one tick (lag spike) — hook ORDER across nodes + -AnimDone timing; reverse playback; exact-integer boundary landings (the G3 class); zero-framerate -node; empty-list physics fallback; -(c) **cdb goldens from P0**: advance-per-update counts + frame_number progression trace. -Dependencies: P1+P2+P3. - -**P5 — adapter cutover: `AnimationSequencer` rehosted on the core.** (closes G13-split, G17-core; DELETES stale-head relocation, ClearCyclicTail surgery, per-node Velocity/Omega, safety cap, boundary-epsilon) -`SetCycle` rebuild becomes: `remove_cyclic_anims()` [+ `remove_all_link_animations` where the old -code cleared] → per-AnimData `append_animation(speed-scaled AnimData)` (= retail free-fn -`add_motion 0x005224b0`, unconditional `set_velocity/set_omega` in core) → fast path = -`change_cycle_speed`-equivalent (`multiply_cyclic_animation_fr` framerates-only) + adapter-level -velocity rescale (the R2 subtract/combine composite, kept at adapter, register row). Invented -behaviors that SURVIVE at adapter level, byte-identical: K-fix18, Fix B, stop-anim fallback, -GetLink reversed branch, velocity synthesis (each verified to still have/get its register row). -`Advance` becomes `update(dt, frame)` + `BuildBlendedFrame` reading core `curr_anim`/ -`frame_number`. `PlayAction` inserts via core list ops. -Tests first: FULL existing suite green (behavior-parity is the acceptance bar) + adapter parity -tests (same SetCycle call sequences → same selected cycle/link + same hook stream as pre-cutover -recordings taken BEFORE this commit). #61's boundary-hold re-tested against verbatim boundary -math — if the flash is gone with the hold removed, delete the hold (register row retired); if -not, keep + re-file #61 with the new evidence. -Fixture source: recorded pre-cutover adapter traces (**synthetic harness**) + user visual smoke. -Dependencies: P4. - -**P6 — root-motion/placement wiring + API narrowing + register sweep.** (closes G7-wiring, G14-consumers, dead-API cleanup) -`update(quantum, Frame)` output exposed to the GameWindow tick as the entity root-motion delta -(replaces dead `ConsumeRootMotionDelta` — delete it); placement-frame path wired for -anim-less objects (`get_curr_animframe` fallback); delete `Reset()`/`HasCurrentNode` or map to -`clear()`/`curr_anim != null`; `MultiplyCyclicFramerate` public surface delegates to core. -Consumers keep reading `CurrentVelocity/CurrentOmega` (adapter mirrors core `velocity/omega` + -synthesis until R3). Register reconciliation + roadmap stage-table update + memory digest note. -Fixture source: existing launch-protocol smoke (`ACDREAM_REMOTE_VEL_DIAG` off/on parity) + suite. -Dependencies: P5. - ---- - -## 4. API MIGRATION — consumer survival through the cutover - -R1 is **adapter-preserving**: every public `AnimationSequencer` member keeps its signature through -P5; the core replaces the internals. Narrowing happens only in P6 and touches only dead surface. - -| Consumer (from r1-acdream-sequencer map) | Call sites | R1 impact | -|---|---|---| -| `GameWindow` spawn/fallback cycles | `:3723/3728/3732/3824` (`HasCycle`), `:3751/:3825` (`SetCycle`) | none — adapter unchanged | -| `GameWindow` jump/land/stop | `:4830` (K-fix18), `:5155/:5309/:9817` | none — K-fix18 param preserved at adapter until R3 | -| `GameWindow` NPC legacy path | `:4936` (`ApplyServerControlledVelocityCycle`) | none — path dies in R2/R6, not R1 | -| `GameWindow` local-player cycle | `:10223` | none | -| `GameWindow` anim tick | `:9876` (`Advance`), `:9882` (`ConsumePendingHooks`) | signatures unchanged; `Advance` internally becomes `update()`+blend. Hook STREAM content must be parity-tested (P5) since AnimDone timing changes gate (G5) — RemoteMotionSink/GameWindow don't consume AnimDone yet (R2 does), so risk is bounded to the hook fan-out sinks | -| `RemoteMotionSink.Commit` | `RemoteMotionSink.cs:215` (`SetCycle`), + `HasCycle`, `ApplyMotion→PlayAction` | none — sink dissolves in R2, not R1 | -| `AnimationCommandRouter` | `RouteFullCommand → SetCycle/PlayAction` | none | -| `CurrentVelocity/CurrentOmega` readers | `GameWindow.cs:9331-9334` (remote body translation), `:12917` (`AttachCycleVelocityAccessor` → `MotionInterpreter.GetCycleVelocity`), `MotionInterpreter` docs | semantics preserved: adapter keeps replace-gate + locomotion synthesis EXACTLY as today (G17 core/adapter split); values must be bit-identical for locomotion low-bytes — covered by P5 parity tests | -| `CurrentStyle/CurrentMotion/CurrentSpeedMod` readers | `GameWindow.cs:3723/4827/4915/4919`, `RemoteMotionSink` ctor+Commit | adapter-owned bookkeeping, untouched | -| Diagnostics `CurrentNodeDiag`/`FirstCyclicAnimRefHash`/`QueueCount` | `GameWindow.cs:9863-9871` `[CURRNODE]` block | re-expressed over core list (AnimRefHash from core node's `anim`); tuple shape kept | -| `ConsumeRootMotionDelta` | **zero callers** | deleted in P6; replaced by `update(quantum, Frame)` output | -| `Reset` / `HasCurrentNode` / external `MultiplyCyclicFramerate` | zero external callers | P6: map to `clear()` / `curr_anim != null` / core delegate, or delete | -| `AnimationHookRouter` / `IAnimationHookSink` sinks | `GameWindow.cs:9890` fan-out | unchanged in R1; hook payload type stays DatReaderWriter's `AnimationHook`. (Side note for a separate issue, NOT R1: router's silent catch-all has no logger seam — `feedback_logger_injection_for_silent_catches.md`) | -| `RenderBootstrap.SequencerFactory` | `:138/:147-174` (3-tier Setup/MotionTable fallback) | ctor signature unchanged; empty-MotionTable tier must still yield a working do-nothing sequencer (add a P5 test: empty table → `has_anims()==false` → physics-only update path, no throw) | - -**Cutover invariants (P5 acceptance):** (1) full existing test suite green untouched; (2) recorded -SetCycle→hook/pose traces byte-parity vs pre-cutover for the standard protocol (walk/run/toggle/ -turn/stop/jump, player+NPC); (3) every deleted invented mechanism's register row retired in the -deleting commit; every surviving adapter-level invention has a row; (4) one user visual pass at -R1 end (plan: eyes are final sanity only). diff --git a/docs/research/2026-07-02-r2-motiontable/Q0-pins.md b/docs/research/2026-07-02-r2-motiontable/Q0-pins.md deleted file mode 100644 index d4c44752..00000000 --- a/docs/research/2026-07-02-r2-motiontable/Q0-pins.md +++ /dev/null @@ -1,43 +0,0 @@ -# R2-Q0 — ambiguity pins and ACE-oddity adjudications - -The verbatim extraction is `r2-motiontable-decomp.md` (1,603 lines, line-anchored); -the port plan with the full ambiguity table is `r2-port-plan.md` §0. This note -records the PINNED resolutions the Q1–Q5 ports code against. - -## Pinned - -- **A1 — `get_link` branch predicate: PINNED to ACE's reading** ("EITHER speed - negative → swapped-key branch"). Three independent corroborations: ACE - `MotionTable.cs:395-426`; the working adapter's field-validated `GetLink` - (the reversed-key branch fixed the Ready→WalkBackward glitch); call-site arg - roles. The BN "both negative" reading is the same x87-flag-noise class as - `is_newer`'s garbled setcc and the R1 hook-direction swap. cdb confirmation - (bp `CMotionTable::get_link`, Ready→WalkBackward vs Ready→WalkForward) - folds into the next live retail session — non-blocking. -- **A2 — Branch-2 `signedSpeed`: PINNED to ACE** (`SubstateMod < 0 && - speedMod > 0 → -speedMod`, the single-direction flip) pending the same cdb - session (golden: a Walk(−)→Walk(+) flip). -- **A3 — outTicks** = sum of each appended MotionData's `num_anims` (+ base - cycle in Branch 1/2) − 1. The `action_head` rendering in BN is the packed - `num_anims` byte (decomp's own note). -- **A4 — ACE oddities adjudicated:** (1) Action-branch numAnims double-count = - ACE BUG, do not copy (retail sums outHop + actionLink [+ returnHop] only); - (2) `change_cycle_speed` old≈0 silent no-op = RETAIL (port verbatim, - including the gap); (3) `GetLinkData` 0xFFFF mask = ACE-only helper, not - ported; (4) `StopObjectCompletely` return = `finalStopOk ? 1 : - anyModifierStopOk`, port verbatim; (5) `re_modify` snapshot = deep-copied - MotionState used ONLY as the loop-termination bound — C# port deep-copies, - pops both, terminates on snapshot empty. -- **A5 — `MotionData.Bitfield`** (bit1 = substate-gated for `is_allowed`, - bit0 = clear-modifiers-on-entry): confirm on DatReaderWriter - `Types.MotionData` with a one-line Humanoid-table test at Q2. - -## Q0 cdb capture (pending, non-blocking) - -One live session feeds all R2 goldens: bp GetObjectSequence / get_link / -StopSequenceMotion / add_to_queue / remove_redundant_links / -truncate_animation_list / AnimationDone / CheckForCompletedMotions with -arg+ret logging (pattern `tools/cdb/l2g-observer.cdb`); protocol per -`r2-port-plan.md` §0. Until then Q2/Q3 rest on dat fixtures + synthetic -state tables + the archived 2026-05-03 walk→run trace golden (quoted in the -old Fix B comment block). diff --git a/docs/research/2026-07-02-r2-motiontable/r2-ace-motiontable.md b/docs/research/2026-07-02-r2-motiontable/r2-ace-motiontable.md deleted file mode 100644 index 4335c3f7..00000000 --- a/docs/research/2026-07-02-r2-motiontable/r2-ace-motiontable.md +++ /dev/null @@ -1,548 +0,0 @@ -# ACE MotionTable / MotionTableManager port — cross-reference map - -Files read in full: -- `references/ACE/Source/ACE.Server/Physics/Animation/MotionTable.cs` (615 lines) -- `references/ACE/Source/ACE.Server/Physics/Managers/MotionTableManager.cs` (251 lines) -- `references/ACE/Source/ACE.Server/Physics/Animation/AnimNode.cs` (16 lines) -- `references/ACE/Source/ACE.DatLoader/Entity/MotionData.cs` (34 lines) -- Cross-refs: `PhysicsObj.cs` (L296-300, L653-657, L899-902), `MovementManager.cs` (L118-121), - `MotionInterp.cs` (L210-231, L260), `PartArray.cs` (L52-56, L72-76, L135, L253-293, L425-435, L577-586), - `WeenieObject.cs` (L256-259) - -**NOTE (important for the parent report):** ACE has TWO parallel "pending motion" trackers that -both descend from a `PhysicsObj`, and they are easy to conflate: - -1. `MotionTableManager.PendingAnimations` (`LinkedList`) — driven by - `Table.DoObjectMotion`/`GetObjectSequence` (the **interpreted-command / high-level motion** - path). Its `AnimationDone`/`CheckForCompletedMotions` are the ones requested for this doc. -2. `MotionInterp.PendingMotions` — a *separate* list inside `MotionInterp.cs` (not covered file, - but referenced at MotionInterp.cs:210-231) that also has a `MotionDone(bool success)` method. - `MovementManager.MotionDone` calls `MotionInterpreter.MotionDone(success)`, i.e. the **raw** - motion-interp side, NOT `MotionTableManager.AnimationDone`. `MotionTableManager` and - `MotionInterp` are wired independently; `MotionTableManager` lives under `PartArray`, while - `MotionInterp` lives under `MovementManager`. Both ultimately get fed by `PhysicsObj.MotionDone` - but through different owner objects (`PartArray.MotionTableManager` vs - `MovementManager.MotionInterpreter`), and only one of the two is authoritative depending on - whether the object is server-simulated purely by object-broadcast Motion commands (uses - MotionTableManager via PartArray) vs. by the mover's own physics timestep + MoveToManager - (uses MotionInterp). Do not assume `MotionTableManager.AnimationDone` is "the" MotionDone path - for player-driven movement — cross-check which owner actually gets ticked for the acdream use - case before porting. - ---- - -## MotionTable.cs (`ACE.Server.Physics.Animation.MotionTable`) - -### Fields -``` -uint ID -Dictionary StyleDefaults // style -> default substate -Dictionary Cycles // key = (style<<16)|substate -> cycle anim data -Dictionary Modifiers // key = (style<<16)|modifierID or just modifierID -> modifier anim data -Dictionary> Links // key = (style<<16)|fromSubstate -> { toMotion -> transition MotionData } -uint DefaultStyle -static ConcurrentDictionary WalkSpeed / RunSpeed / TurnSpeed // per-motionTableID cache -``` -Constructed either empty (`Allocator()`) or from the dat-loaded -`DatLoader.FileTypes.MotionTable` (straight field copy, no transform — L40-48). - -### `DoObjectMotion(motion, currState, sequence, speedMod, ref numAnims)` -Trivial forwarder: `return GetObjectSequence(motion, currState, sequence, speedMod, ref numAnims, stopModifiers: false);` -(L55-58) - -### `GetObjectSequence(motion, currState, sequence, speedMod, ref numAnims, stopModifiers)` — L60-257 -The core state-transition dispatcher. `numAnims` is reset to 0 up front. - -Early-out: if `currState.Style == 0 || currState.Substate == 0` → `false` (uninitialized state). - -Looks up `substate = StyleDefaults[currState.Style]` (the *default substate for the current -style*, e.g. "Standing" under style "NonCombat"). - -**Guard (L73-74):** if `motion == substate` (i.e. caller is asking to enter the style's own -default substate) AND `!stopModifiers` AND current substate already has the Modifier bit set -(`CommandMask.Modifier`) → return `true` immediately (no-op — already effectively there via a -modifier). - -Then four command-mask branches, checked with **plain OR semantics — NOT else-if.** Each branch -can independently fire and `return true` from inside if it succeeds; falling out of a branch -(motionData null, is_allowed false, etc.) falls through to the next mask check. - -**`CommandMask.Style` branch (L76-120)** — motion requests a stance/style change (e.g. switch -combat stance): -- If `currState.Style == motion` already → `true` (no-op). -- If `substate != currState.Substate`: compute `motionData = get_link(currState.Style, - currState.Substate, currState.SubstateMod, substate, speedMod)` — the transition INTO the new - style's default substate from the current substate. -- If `substate != 0`: look up `cycles = Cycles[(motion<<16)|substate]` — the cycle anim for the - new style's default substate. - - If found: - - `(cycles.Bitfield & 1) != 0` → `currState.clear_modifiers()` (bit 1 = "clears modifiers on - entry", e.g. entering a stance that can't carry over swimming/etc modifiers). - - `link = get_link(currState.Style, substate, currState.SubstateMod, motion, speedMod)` — - transition from (old style, NEW style's default substate) to the target style itself. - - **Fallback chain if `link == null` and `currState.Style != motion`:** re-resolve via - `DefaultStyle` — `link = get_link(currState.Style, substate, 1.0f, DefaultStyle, 1.0f)`, - then `motionData_ = get_link(DefaultStyle, StyleDefaults[DefaultStyle], 1.0f, motion, 1.0f)`. - This is the "no direct link exists, route through the global default style" path (e.g. - Standing). - - `sequence.clear_physics(); sequence.remove_cyclic_anims();` — wipe outstanding velocity/ - omega contributions and any looping cycle anims before splicing in the new chain. - - Append in strict order: `add_motion(motionData)`, `add_motion(link)`, - `add_motion(motionData_)`, `add_motion(cycles)` — i.e. [transition-to-default-substate] → - [transition-to-target-style] → [fallback-via-default-style, usually null] → - [new cycle]. Each `add_motion` no-ops silently on null `motionData`. - - Commits state: `currState.Substate = substate; currState.Style = motion; currState.SubstateMod = speedMod;` - - `re_modify(sequence, currState)` — replays any still-active modifiers (see below) on top - of the newly spliced sequence. - - `numAnims = sum of each non-null MotionData's Anims.Count, minus 1` (the `-1` is - because the queue entry itself represents completion of ONE playthrough of the LAST - appended motion's cycle, not a raw anim count — cross-check against `add_to_queue` in - MotionTableManager, which stores this count as `AnimNode.NumAnims`, i.e. the number of - "hook" firings, one per queued sub-anim minus a terminal borrow). - - Returns `true`. - -**`CommandMask.SubState` branch (L121-188)** — motion requests a substate change within the -current style (e.g. Standing → Crouching), OR a coalesced-speed no-op update to the CURRENT -cycle: -- `motionID = motion & 0xFFFFFF` (strip command-class bits). -- `motionData = Cycles[(currState.Style<<16)|motionID]`, falling back to - `Cycles[(DefaultStyle<<16)|motionID]` if the current style doesn't define that substate. -- If found and `is_allowed(motion, motionData, currState)` (see below): - - **Speed-only fast path (L132-139):** if `motion == currState.Substate` AND - `sequence.HasAnims()` AND `Math.Sign(speedMod) == Math.Sign(currState.SubstateMod)` — i.e. - caller is re-issuing the SAME substate at a new speed, same direction of travel (fwd vs - reverse) — then: `change_cycle_speed` (rescale the already-playing cyclic anim's framerate), - `subtract_motion` (remove the old velocity/omega contribution at the old speed), - `combine_motion` (add back at the new speed), update `currState.SubstateMod = speedMod`, - return `true`. **No new anims queued, no sequence splice** — this is the "already running, - just changing speed" branch (e.g. walk→run without breaking stride). - - Otherwise (new substate, or sign flip == direction reversal): - - `(motionData.Bitfield & 1) != 0` → `currState.clear_modifiers()`. - - `link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod)` - — direct transition from current substate to target substate. - - **Fallback (L145-151):** if `link == null` OR the sign of `speedMod` differs from - `currState.SubstateMod` (direction reversal, e.g. forward↔backward) — route through the - style's default substate: `link = get_link(currState.Style, currState.Substate, - currState.SubstateMod, defaultMotion, 1.0f)` (transition out to default), - `motionData_ = get_link(currState.Style, defaultMotion, 1.0f, motion, speedMod)` - (transition from default into target). - - `sequence.clear_physics(); sequence.remove_cyclic_anims();` - - If `motionData_ != null` (fallback path taken): append `link` at `currState.SubstateMod`, - then `motionData_` at `speedMod`. - - Else (direct link path): `newSpeedMod = speedMod`, but **flip sign** if - `currState.SubstateMod < 0 && speedMod > 0` (i.e. reversing FROM negative — asymmetric - handling, only corrects one direction of sign mismatch, not both) — append `link` at - `newSpeedMod`. - - Always append `motionData` (the new cycle) at `speedMod`. - - **Modifier carry-over (L170-176):** if the OLD substate differs from the new `motion` AND - the old substate had the Modifier bit set, AND the old substate isn't just the style's - default motion, re-add it as an active modifier via `currState.add_modifier_no_check` - (i.e. modifiers riding on a substate survive a substate change unless they equal the new - target or the style default). - - Commit `currState.SubstateMod = speedMod; currState.Substate = motion;`, then `re_modify`. - - `numAnims = motionData.Anims.Count + link.Anims.Count + motionData_.Anims.Count - 1` - (nulls treated as 0). - - Returns `true`. - -**`CommandMask.Action` branch (L189-233)** — one-shot action motions (attacks, jumps, etc, -things layered on TOP of a cycle without replacing it): -- `cycleKey = (currState.Style<<16)|(currState.Substate & 0xFFFFFF)`; `motionData = - Cycles[cycleKey]` (the CURRENT cycle, must exist). -- If found: - - `link = get_link(currState.Style, currState.Substate, currState.SubstateMod, motion, speedMod)`. - - **If `link != null` (direct action link exists):** `currState.add_action(motion, speedMod)` - (push onto the action stack — see MotionState, not read this pass), clear physics/cyclic, - append `link` at `speedMod` then re-append `motionData` (the ORIGINAL cycle, at the OLD - `currState.SubstateMod` — i.e. resume the cycle after the action plays), `re_modify`, - `numAnims = link.Anims.Count` (note: NOT including motionData's count here — only the - action-link portion counts toward completion tracking, the re-appended base cycle is - presumably a normal looping anim not subject to the same "done" semantics). - - **Else (no direct action link — fallback via style default, L209-231):** - `motionData = get_link(currState.Style, currState.Substate, currState.SubstateMod, substate, 1.0f)` - (transition current substate → style's own default substate) as a NEW `motionData` - (shadows the cycle lookup above). If that resolves: `link = get_link(currState.Style, - substate, 1.0f, motion, speedMod)` (default substate → action), and re-fetch `cycles = - Cycles[cycleKey]` (original cycle again). If `link != null` and `cycles` found: - `motionData_ = get_link(currState.Style, substate, 1.0f, currState.Substate, - currState.SubstateMod)` (default substate → back to original substate, for resuming after). - `currState.add_action(...)`, clear physics/cyclic, append in order `motionData` (→default), - `link` (default→action), `motionData_` (default→back), `cycles` (resume original cycle at - `currState.SubstateMod`). `re_modify`. `numAnims = motionData.Anims.Count + - link.Anims.Count + (motionData_ != null ? motionData.Anims.Count : 0)` — **NOTE: this last - term reads `motionData.Anims.Count` again, NOT `motionData_.Anims.Count` — looks like a copy- - paste bug in ACE's port** (compare to the Style/SubState branches which correctly sum each - distinct MotionData). Flag this explicitly when cross-checking against 2013 retail — - likely a genuine ACE divergence, not a retail behavior to replicate. - -**`CommandMask.Modifier` branch (L234-255)** — continuous modifiers layered on the current cycle -(e.g. sneaking, aiming overlay) that don't interrupt it: -- `styleKey = currState.Style<<16`; `cycles = Cycles[styleKey|(currState.Substate&0xFFFFFF)]` - (current cycle must exist). -- If found AND `(cycles.Bitfield & 1) == 0` (cycle does NOT forbid modifiers): - - `motionData = Modifiers[styleKey|(motion&0xFFFFFF)]`, falling back to - `Modifiers[motion&0xFFFFFF]` (style-agnostic modifier) if not found. - - If found: - - `if (!currState.add_modifier(motion, speedMod))` — if the state rejects adding this - modifier (e.g. already present / list full): - - `StopSequenceMotion(motion, 1.0f, currState, sequence, ref numAnims)` — force-remove it - first, then retry `add_modifier`. If STILL fails, return `false`. - - `combine_motion(sequence, motionData, speedMod)` — layer the modifier's velocity/omega + - anims onto the sequence WITHOUT clearing physics or cyclic anims (additive, unlike the - other three branches which splice/replace). - - Returns `true`. **No numAnims write here — stays whatever it was reset to (0) at entry, - i.e. modifiers are not tracked for animation-completion purposes.** - -Falls through all four branches unmatched → `return false`. - -### `Get(uint motionTableID)` — static factory, L259-264 -Directly `DatManager.PortalDat.ReadFromDat(id)` wrapped in `new -MotionTable(...)`. Comment `//return ObjCache.GetMotionTable(mtableID);` — retail apparently -caches motion tables by ID; ACE re-reads from dat every call (no caching layer here, though the -dat reader itself likely caches file reads elsewhere). - -### `SetDefaultState(state, sequence, ref numAnims)` — L266-291 -Resets to the table's global default (style, substate) pair: -- `defaultSubstate = StyleDefaults[DefaultStyle]`; if missing → `false`. -- `state.clear_modifiers(); state.clear_actions();` -- `cycle = (DefaultStyle<<16)|defaultSubstate`; `motionData = Cycles[cycle]`; if missing → `false`. -- `numAnims = motionData.Anims.Count - 1`. -- `state.Style = DefaultStyle; state.Substate = defaultSubstate; state.SubstateMod = 1.0f;` -- `sequence.clear_physics(); sequence.clear_animations();` (note: `clear_animations`, not - `remove_cyclic_anims` — a harder reset, used only here and presumably at spawn/enter-world). -- `add_motion(sequence, motionData, 1.0f)`. - -### `StopObjectCompletely(currState, sequence, ref numAnims)` — L293-313 -Iterates and removes ALL active modifiers via `StopSequenceMotion` (loop drains -`currState.Modifiers.First` until empty — relies on `StopSequenceMotion` removing the head each -call), tracking whether ANY modifier stop succeeded (`success`). Then stops the current substate -itself: `StopSequenceMotion(currState.Substate, currState.SubstateMod, ...)`. Returns `true` if -EITHER the substate-stop succeeded OR any earlier modifier-stop succeeded (`success || -substateStopSucceeded`, though written as an if/else that returns `true` whenever the final -substate-stop call returns non-... — re-read: `if (!StopSequenceMotion(...)) return success; else -return true;` — i.e. final result is `true` unless the LAST stop call fails, in which case it -falls back to whatever `success` was from the modifier loop). - -### `StopObjectMotion` / `StopSequenceMotion` — L315-356 -`StopObjectMotion` is a trivial forwarder to `StopSequenceMotion`. - -`StopSequenceMotion(motion, speed, currState, sequence, ref numAnims)`: -- `numAnims = 0`. -- **SubState case:** if `(motion & CommandMask.SubState) != 0 && currState.Substate == motion` — - i.e. caller wants to stop the CURRENT substate → resolve the style's default substate and - re-enter it via `GetObjectSequence(style, currState, sequence, 1.0f, ref numAnims, - stopModifiers: true)` (recursion into the main dispatcher with `stopModifiers=true`, which - suppresses the early "already at default via modifier" guard at L73). Returns `true` - unconditionally after this call (return value of the inner `GetObjectSequence` is discarded). -- **Non-modifier, non-matching-substate case:** if `(motion & CommandMask.Modifier) == 0` → - `false` (nothing to stop — motion isn't a substate-stop or a modifier). -- **Modifier case:** linear-scan `currState.Modifiers` linked list for a node whose `.ID == - motion`. On match: - - `key = (currState.Style<<16)|(motion&0xFFFFFF)`; `Modifiers[key]`, falling back to - `Modifiers[motion&0xFFFFFF]`. If neither resolves → `false`. - - `subtract_motion(sequence, motionData, modifier.Value.SpeedMod)` — reverse the modifier's - velocity/omega contribution using its ORIGINAL speed (not the `speed` parameter passed in — - `speed` param appears unused in this branch; only used implicitly via the SubState-case call - above). `currState.remove_modifier(modifier)`. Returns `true`. - - No match found after scanning entire list → `false`. - -### `add_motion` / `combine_motion` / `subtract_motion` / `change_cycle_speed` — L358-393 -- **`add_motion(sequence, motionData, speed)`:** no-op if `motionData == null`. Otherwise - `sequence.SetVelocity(motionData.Velocity * speed)`, `sequence.SetOmega(motionData.Omega * - speed)` (REPLACES, not adds — "Set" not "Combine"), then for each anim in `motionData.Anims` - wraps as `new AnimData(anim, speed)` and `sequence.append_animation(animData)`. -- **`combine_motion(sequence, motionData, speed)`:** no-op if null. Otherwise - `sequence.CombinePhysics(motionData.Velocity * speed, motionData.Omega * speed)` — additive - variant used by the Modifier branch (doesn't touch the anim queue at all, only velocity/omega). -- **`subtract_motion(sequence, motionData, speed)`:** no-op if null. `sequence.subtract_physics - (motionData.Velocity * speed, motionData.Omega * speed)` — inverse of combine, used when - removing a modifier or an old-speed cycle contribution. -- **`change_cycle_speed(sequence, motionData, substateMod, speedMod)`:** if - `|substateMod| > PhysicsGlobals.EPSILON` → `sequence.multiply_cyclic_animation_framerate - (speedMod/substateMod)` (rescale by the RATIO of new to old speed). Else-if `|speedMod| < - EPSILON` → `multiply_cyclic_animation_framerate(0)` (freeze the anim — old speed was ~0 so no - ratio is definable, new speed is also ~0). **Gap: if `substateMod` ~0 AND `speedMod` is - NON-zero, neither branch fires — no framerate change is applied.** Worth checking against - retail whether this is a real edge case (resuming a stopped cycle at nonzero speed without a - ratio) — could be a silent no-op bug carried from retail or an ACE gap. - -### `get_link(style, substate, substateSpeed, motion, speed)` — L395-426 -Direction-aware transition lookup with two symmetric lookup CHAINS depending on sign of the -speeds: -- **If EITHER `speed < 0` or `substateSpeed < 0` (reverse-direction transition):** look up - `Links[(style<<16)|(motion&0xFFFFFF)]` (keyed by DESTINATION motion) then `.TryGetValue - (substate, ...)` (indexed by SOURCE substate) — i.e. reversed key order vs the forward case. - If that fails, fall back through `StyleDefaults[style]` and - `Links[(style<<16)|(substate&0xFFFFFF)]` → `.TryGetValue(defaultMotion, ...)`. -- **Else (forward / non-negative speeds):** look up `Links[(style<<16)|(substate&0xFFFFFF)]` - (keyed by SOURCE substate) then `.TryGetValue(motion, ...)` (indexed by DESTINATION). Fallback: - `Links[style<<16]` (style-wide, substate-agnostic) → `.TryGetValue(motion, ...)`. -- Returns `null` if nothing resolves in either chain. -This encodes retail's dat-side Links table having asymmetric (from,to) vs (to,from) storage -depending on animation reversibility — reverse playback (e.g. walking backward) reuses the -FORWARD anim's link table keyed the other way around rather than storing a mirrored copy. - -### `is_allowed(motion, motionData, state)` — L428-438 -`false` if `motionData == null`. `true` if `(motionData.Bitfield & 2) == 0` (bit 2 = "always -allowed" flag) OR `motion == state.Substate` (re-entering the same substate is always allowed -regardless of the bit). Otherwise: only allowed if the CURRENT substate IS the style's own -default substate (`StyleDefaults[state.Style] == state.Substate`) — i.e. bit-2-gated substates -can only be entered FROM the style's default/neutral substate, not chained from an arbitrary -other substate. - -### `re_modify(sequence, pstate)` — L440-458 -No-op if `pstate.Modifiers.First == null`. Otherwise snapshots `pstate` into a NEW `MotionState -state = new MotionState(pstate)` (deep-ish copy incl. its own Modifiers list), then drains -`pstate`'s modifier list one node at a time: pops `speedMod`/`motion` off `pstate.Modifiers.First`, -removes the SAME logical entry from BOTH `pstate` and the snapshot `state` (odd double-removal — -removing from the snapshot copy seems purposeless unless `MotionState`'s copy ctor shares the -underlying list nodes, in which case this is defensive against aliasing), then calls -`GetObjectSequence(motion, pstate, sequence, speedMod, ref numAnims, stopModifiers: false)` to -RE-APPLY each modifier on top of the now-current (post-transition) `pstate`. This is how the -Style/SubState branches "carry forward" active modifiers across a cycle-changing transition — -after splicing the new base cycle in, `re_modify` walks the still-active modifier list (which at -that point is whatever the branch didn't already clear) and re-runs each one through the full -dispatcher so its layered anims/physics get re-appended onto the NEW sequence. - -### Static helpers (L460-613) -- **`GetAttackFrames(motionTableId, stance, motion)`** — dat-lookup passthrough to - `DatLoader.FileTypes.MotionTable.GetAttackFrames` (not in this file). Returns cached - `emptyList` for `motionTableId == 0`. -- **`GetAnimationLength(motionTableId, stance, motion, speed=1)`** / the 2-motion overload that - also takes `currentMotion` — the latter, if `motion` has the Style bit set and `currentMotion - != Ready`, first adds the Ready→currentMotion transition length, forces `currentMotion = - Ready`, THEN adds `currentMotion→motion`. Divides everything by `speed`. -- **`GetCycleLength`** — dat passthrough / speed. -- **`GetRunSpeed(motionTableID)`** — cached in static `RunSpeed` dict. Computes via - `GetMotionData(id, MotionCommand.RunForward)` → `GetAnimDist(motionData)`. -- **`GetTurnSpeed(motionTableID)`** — cached in static `TurnSpeed` dict. - `Math.Abs(GetMotionData(id, MotionCommand.TurnRight).Omega.Z)`. -- **`GetMotionData(motionTableID, motion, currentStyle=null)`** — resolves `currentStyle` to - `motionTable.DefaultStyle` if unspecified, strips command bits (`motion & 0xFFFFFF`), looks up - `Cycles[(style<<16)|motionID]`. -- **`GetLinkData(motionTableID, motion, currentStyle=null)`** — looks up - `Links[(style<<16)|((int)MotionCommand.Ready & 0xFFFF)]` (**NOTE: masks with `0xFFFF` here, - NOT `0xFFFFFF` like everywhere else in this file — inconsistent mask width, likely harmless - since `MotionCommand.Ready`'s low bits fit in 16 bits, but worth flagging as an ACE - inconsistency if porting verbatim**), then `.TryGetValue(motion, ...)`. -- **`GetAnimDist(motionData)`** — sums `frame.Origin` across every `PosFrames` frame of every - anim in `motionData.Anims` (reads each `Animation` fresh from dat by `anim.AnimId`), takes - `.Length()` of the summed offset vector, divides by `totalFrames`, multiplies by - `motionData.Anims[0].Framerate` → "distance per second". Returns 0 if the vector length is 0. -- **`HasDefaultScript(motionTableID, motion, currentStyle)`** — `GetLinkData` then scans every - anim's `PartFrames[*].Hooks` for `AnimationHookType.DefaultScript`. - ---- - -## MotionTableManager.cs (`ACE.Server.Physics.Managers.MotionTableManager`) — owned by `PartArray` - -### Fields -``` -PhysicsObj PhysicsObj -MotionTable Table -MotionState State -uint AnimationCounter -LinkedList PendingAnimations -``` -`AnimNode { uint Motion; uint NumAnims; }` (trivial struct-like class, `AnimNode.cs`). - -### `AnimationDone(bool success)` — L28-61 -Called from `PartArray.AnimationDone` ← `PhysicsObj.Hook_AnimDone()` (fired when a per-frame -animation HOOK signals completion — the frame-based `Hook_AnimDone` callback, NOT a polling -check). Logic: -- If `PendingAnimations.First == null` → no-op (nothing pending). -- `AnimationCounter++` (one hook fired, counts toward the FIRST queued `AnimNode`'s - `NumAnims` threshold). -- **Loop** while `node != null`: - - `entry = node.Value`. If `entry.NumAnims > AnimationCounter` → **break** (head entry hasn't - accumulated enough hook-fires yet — stop, this increment wasn't enough to finish it). - - If entry's motion has the Action bit set → `State.remove_action_head()` (pop the action - stack — the completed queue entry was an action, so remove its tracking entry from - `MotionState`). - - `motionID = entry.Motion`; `PhysicsObj.MotionDone(motionID, success)` — fires the - completion callback chain (→ `MovementManager.MotionDone` → `MotionInterpreter.MotionDone`, - see note at top: this is the OTHER pending-motion tracker, decoupled from this one except by - sharing the `PhysicsObj` "owner"). - - `AnimationCounter -= entry.NumAnims` (consume the threshold — any EXCESS hooks beyond this - entry's requirement roll over to satisfy the NEXT queued entry, hence the outer `do...while` - loop can complete MULTIPLE queue entries from ONE `AnimationDone` call if `AnimationCounter` - still exceeds the next entry's `NumAnims`). - - If `PendingAnimations.First != null` → `RemoveFirst()` (dequeue the just-completed entry). - - If `PhysicsObj.WeenieObj != null` → `WeenieObj.OnMotionDone(motionID, success)` (separate - weenie-level hook, forwards to `WorldObject.HandleMotionDone` — the game-logic-facing - callback, distinct from the physics-level `PhysicsObj.MotionDone`). - - `node = PendingAnimations.First` (re-check for another completable entry). -- After the loop: if `AnimationCounter != 0 && node == null` (queue fully drained but counter - still has leftover) → **reset `AnimationCounter = 0`** (defensive clamp — discards any - leftover hook-credit once nothing remains queued, rather than carrying it forward to a future - `add_to_queue` call). - -### `CheckForCompletedMotions()` — L63-85 -Called from `PartArray.CheckForCompletedMotions` ← `PhysicsObj.CheckForCompletedMotions()` — -this one is POLLED (search found no evidence of a per-frame automatic caller within these two -files; likely ticked once per physics update from `PhysicsObj.UpdateObjectInternal` or similar, -not confirmed in this pass — flag for follow-up if the caller chain matters). Distinct from -`AnimationDone`: this drains any HEAD entries whose `NumAnims == 0` **already** (i.e. entries -that never needed any hook fires to complete — e.g. zero-length transitions), NOT -counter-driven: -- Loop while `PendingAnimations.First != null`: - - If `pendingAnimation.Value.NumAnims != 0` → **return** (head isn't a zero-length entry — - stop, do NOT touch `AnimationCounter`, this is purely for immediate 0-anim entries). - - Otherwise: pop the same way as `AnimationDone` (Action-bit → `RemoveActionHead`, - `PhysicsObj.MotionDone(motionID, true)` (always `success=true` here — no `success` param on - this method), remove from list, `WeenieObj.OnMotionDone(motionID, true)`. - - Continues looping (could drain several consecutive 0-`NumAnims` entries in one call). - -### `PerformMovement(mvs, seq)` — L116-145 -Dispatches on `mvs.Type` (`MovementStruct.Type`): -- `Table == null` → `WeenieError.NoAnimationTable`. -- **`InterpretedCommand`:** `Table.DoObjectMotion(mvs.Motion, State, seq, mvs.Params.Speed, ref - counter)`; if it returns `false` → `WeenieError.NoMtableData`. Else `add_to_queue(mvs.Motion, - counter, seq)`, return `None`. -- **`StopInterpretedCommand`:** `Table.StopObjectMotion(mvs.Motion, mvs.Params.Speed, State, - seq, ref counter)`; failure → `NoMtableData`. Success → `add_to_queue((uint)MotionCommand.Ready, - counter, seq)` (**note: queues under `MotionCommand.Ready`, NOT `mvs.Motion`** — a stop always - enqueues completion-tracking keyed to Ready, regardless of what was stopped). -- **`StopCompletely`:** `Table.StopObjectCompletely(State, seq, ref counter)` (return value - ignored), `add_to_queue((uint)MotionCommand.Ready, counter, seq)`, return `None` - unconditionally. -- **default:** `WeenieError.None` (comment `// ??` — ACE itself flags this as uncertain; other - `MovementType` values like `RawCommand`/`StopRawCommand` fall through here untouched by this - manager, presumably handled instead by `MotionInterp.PerformMovement`). - -### `SetMotionTableID(mtableID)` — `Table = MotionTable.Get(mtableID); return Table != null;` - -### `UseTime()` — `CheckForCompletedMotions();` — the manager's per-tick entry point (called -from `PartArray.cs:265`, itself presumably ticked from `PhysicsObj`'s per-update pass — same -follow-up caveat as above). - -### `add_to_queue(motion, num_anims, sequence)` — L163-167 -`PendingAnimations.AddLast(new AnimNode(motion, num_anims))`, then immediately -`remove_redundant_links(sequence)` — every enqueue triggers a redundancy pass over the WHOLE -list (not just checking the new tail against its immediate predecessor blindly; see below). - -### `initialize_state(sequence)` — L169-177 -`numAnims = 0`; if `Table != null` → `Table.SetDefaultState(State, sequence, ref numAnims)`. -Always `add_to_queue((uint)MotionCommand.Ready, numAnims, sequence)` regardless of whether -`SetDefaultState` succeeded (numAnims stays 0 on failure, so this enqueues a same-tick-complete -Ready entry). - -### `remove_redundant_links(sequence)` — L179-205 -Walks `PendingAnimations` from **tail to head** (`node = PendingAnimations.Last`, then -`node.Previous`). For each entry with `NumAnims != 0`: -- If the entry's motion does NOT have the SubState bit set, OR it DOES have the Modifier bit set - (i.e. it's a Style/Action/Modifier-class entry, not a plain substate cycle): only proceed if - it also has the Style bit set (`entry.Motion & CommandMask.Style`), else **return** (stop - scanning entirely — non-style, non-substate-eligible entries block further redundancy - checking). If Style-bit set: call `remove_redundant_links_inner(node, sequence, first: true)`; - if it returns `true` → **return** (done, something was truncated). -- Else (a plain substate-cycle entry): `remove_redundant_links_inner(node, sequence, first: - false)`; if `true` → return. -- Otherwise continue to `node = node.Previous` (walk further back toward the head). - -### `remove_redundant_links_inner(node, sequence, first)` — L207-231 -Scans BACKWARD from `node.Previous` looking for an earlier entry with the SAME `Motion` value: -- `motion = first ? 0x70000000 : 0xB0000000` — these are raw `CommandMask`-shaped bit patterns - used as an early-abort guard (0x70000000 covers Style|SubState|Action bits per typical AC - command-mask layout; 0xB0000000 a different combination — exact bit semantics live in - `CommandMask` enum, not read this pass, but functionally: differing abort masks depending on - whether we're scanning for a Style-class or SubState-class duplicate). -- While walking back (`prev = prev.Previous` each iteration): - - If `prevEntry.Motion == entry.Motion` AND (`first` is true, OR `prevEntry.NumAnims != 0`) → - found a duplicate → `trancuate_animation_list(prev, sequence)`, return `true`. - - Else if `prevEntry.NumAnims != 0 && (prevEntry.Motion & motion) != 0` → an intervening entry - of the "abort mask" class with pending anims blocks the search → return `true` WITHOUT - truncating (stops the outer loop but did nothing — prevents redundancy removal across a - still-animating Style/Action boundary). - - Else continue. -- If the walk exhausts (`prev == null`) without matching → return `false` (caller's outer loop - continues scanning further back from the ORIGINAL node). - -### `trancuate_animation_list(node, sequence)` — L233-249 (note: "trancuate" — misspelling of -"truncate" preserved verbatim from ACE source, matches the mismatched name used at the call -site `remove_redundant_links_inner`) -Walks from `PendingAnimations.Last` backward toward (but not including) `node`: sums every -visited entry's `NumAnims` into `totalAnims`, then **zeroes each entry's `NumAnims` in place** -(`entry.Value.NumAnims = 0` — does NOT remove them from the list, just neuters their -completion-tracking contribution). Finally `sequence.remove_link_animations(totalAnims)` — tells -the `Sequence` to physically drop that many trailing "link" animations from its playback queue -(the actual anim-clip splice), while `PendingAnimations` keeps the now-inert `AnimNode` entries -in place with `NumAnims=0` (they'll be silently skipped/instantly-completed by -`CheckForCompletedMotions`'s 0-check, or bypass `AnimationDone`'s counter entirely since their -threshold is unreachable-but-trivially-satisfied at 0... actually re-check: `AnimationDone`'s -break condition is `entry.NumAnims > AnimationCounter`; with `NumAnims == 0` this is never true, -so a zeroed entry always immediately qualifies for completion processing on the NEXT -`AnimationDone` call, consistent with `CheckForCompletedMotions` also draining 0-count heads). - ---- - -## MotionDone hook chain (full call graph, both trackers) - -``` -PhysicsObj.Hook_AnimDone() [per-frame anim-hook fire from Sequence/AFrame dispatch] - -> PartArray.AnimationDone(true) - -> MotionTableManager.AnimationDone(true) [drains PendingAnimations by counter] - -> PhysicsObj.MotionDone(motionID, success) - -> MovementManager.MotionDone(motion, success) - -> MotionInterpreter.MotionDone(success) [SEPARATE tracker: MotionInterp.PendingMotions] - -> PhysicsObj.WeenieObj.OnMotionDone(motionID, success) - -> WorldObject.HandleMotionDone(motionID, success) [game-logic level] - -PhysicsObj.CheckForCompletedMotions() [polled, presumably per physics tick] - -> PartArray.CheckForCompletedMotions() - -> MotionTableManager.CheckForCompletedMotions() [drains 0-NumAnims heads only] - -> (same PhysicsObj.MotionDone / WeenieObj.OnMotionDone chain as above) - -MotionInterp.MotionDone(success) [the OTHER path — NOT reached via MotionTableManager] - -> pops MotionInterp.PendingMotions.First - -> if Action bit set: PhysicsObj.unstick_from_object(), InterpretedState.RemoveAction(), - RawState.RemoveAction() - -> PhysicsObj.IsAnimating = PendingMotions.Count > 0 -``` - -**Key divergence risk for acdream:** `MovementManager.MotionDone` ALWAYS routes to -`MotionInterpreter.MotionDone`, never to `MotionTableManager`. The two trackers are fed by -DIFFERENT owners (`PartArray.MotionTableManager` vs `MovementManager.MotionInterpreter`) and -`PhysicsObj.MotionDone(motion, success)` only calls the `MovementManager` one -(`PhysicsObj.cs:899-902`). `MotionTableManager.AnimationDone`/`CheckForCompletedMotions` DO call -`PhysicsObj.MotionDone` internally (feeding MotionInterp), but nothing in these two files calls -BACK from `MotionInterp` into `MotionTableManager` — i.e. `MotionTableManager` is upstream of -`MotionInterp` in the completion-notification chain, not a peer. When porting to acdream's -`CMotionInterp`-based unification (per the D6.2a work already landed), confirm which ACE class -maps to which acdream responsibility — likely `MotionTableManager` ≈ acdream's discrete-command -completion queue (attached per-PartArray/per-object), `MotionInterp` ≈ acdream's -`CMotionInterp`-normalized local-player path (attached per-MovementManager). Do not assume they -merge into one queue — ACE keeps them structurally separate even though both trace back to the -same `PhysicsObj`. - -## Flagged ACE-side oddities (verify against 2013 retail decomp before porting verbatim) - -1. **Action-branch numAnims miscalculation** (`GetObjectSequence` L227): fallback Action path - sums `motionData.Anims.Count` TWICE instead of once for `motionData` and once for - `motionData_` — looks like a copy-paste error in ACE's port, not necessarily retail-faithful. -2. **`change_cycle_speed` silent no-op gap** (L372-379): when `substateMod ≈ 0` AND `speedMod` - is nonzero, neither branch fires — no framerate adjustment applied. Check retail's - `change_cycle_speed` equivalent for a third branch. -3. **`GetLinkData` mask width inconsistency** (L562): uses `& 0xFFFF` where every other - motion-ID mask in this file uses `& 0xFFFFFF`. Likely harmless (Ready's ID fits in 16 bits) - but inconsistent. -4. **`StopObjectCompletely` return-value semantics** (L293-313): returns `true` unless the FINAL - substate-stop call fails, in which case it falls back to whatever `success` was from the - modifier-draining loop — easy to misport if simplified to a single boolean accumulate. -5. **`re_modify` double-removal from both `pstate` and a snapshot `state`** (L440-458): only - makes sense if `MotionState`'s copy constructor shares the underlying `LinkedList` - nodes with the source, which would make the second `state.remove_modifier(...)` call either - redundant or (if lists are NOT shared) load-bearing to unlink from the snapshot's OWN list — - not verifiable without reading `MotionState.cs`'s copy ctor (out of scope this pass). - -## Files NOT read this pass (would need separate grep if pursued) -- `MotionState.cs` (referenced constantly: `.Modifiers`, `.add_modifier`, - `.add_modifier_no_check`, `.remove_modifier`, `.clear_modifiers`, `.add_action`, - `.remove_action_head`, `.clear_actions`, copy constructor semantics) -- `MotionInterp.cs` full file (only L190-260 read; `PendingMotions`, `enter_default_state`, - `apply_current_movement`, `get_leave_ground_velocity` not traced) -- `Sequence.cs` (`SetVelocity`, `SetOmega`, `CombinePhysics`, `subtract_physics`, - `append_animation`, `clear_physics`, `remove_cyclic_anims`, `clear_animations`, - `remove_link_animations`, `multiply_cyclic_animation_framerate`, `HasAnims`, - `remove_all_link_animations`) -- `CommandMask` enum exact bit values (Style/SubState/Action/Modifier) -- `WeenieObject.HandleMotionDone` in `AC.Server` (game-logic side, outside Physics/) diff --git a/docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md b/docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md deleted file mode 100644 index b7b37688..00000000 --- a/docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md +++ /dev/null @@ -1,1603 +0,0 @@ -# Retail motion-table layer — verbatim decomp extraction - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (line numbers = file -line numbers, left column in raw dumps = virtual address). Structs: -`docs/research/named-retail/acclient.h`. Build: Sept 2013 EoR (acclient.exe -v11.4186). All addresses verified via `grep -n` against the pseudo-C dump on -2026-07-02. - -**Naming caveat (see `feedback_bn_decomp_field_names.md`):** Binary Ninja's decompiler -mis-typed several `LongNIValHash::lookup` calls on `this->style_defaults` -as `LongNIValHash::lookup`. `SurfInfo` is an unrelated texture struct — every -`LongNIValHash::lookup(&this->style_defaults.vtable, ...)` / `lookup(edi, ...)` -call below is actually operating on `CMotionTable::style_defaults` (style→default-substate -map) or `CMotionTable::links` (style→substate-link-hash map), not on any SurfInfo data. -Verified by field offsets against `acclient.h`'s `CMotionTable` layout (below). Treat the -`SurfInfo*` type name in the pseudo-C as decompiler noise; the pointee types are -`unsigned long` (style_defaults) or `LongHash*` (links). - ---- - -## 1. Struct layouts (verbatim from acclient.h) - -```c -/* acclient.h:31081 */ -struct __cppobj MotionState -{ - unsigned int style; - unsigned int substate; - float substate_mod; - MotionList *modifier_head; - MotionList *action_head; - MotionList *action_tail; -}; - -/* acclient.h:31664 — singly-linked node, used for BOTH modifier_head chain and action_head/action_tail chain */ -struct __cppobj MotionList -{ - unsigned int motion; - float speed_mod; - MotionList *next; -}; - -/* acclient.h:9720 */ -struct __cppobj DLListData -{ - DLListData *dllist_next; - DLListData *dllist_prev; -}; - -/* acclient.h:9727 */ -struct __cppobj DLListBase -{ - DLListData *head_; - DLListData *tail_; -}; - -/* acclient.h:57614 — pending_animations node. Offsets in raw pseudo-C: +0x0 dllist_next, - +0x4 dllist_prev, +0x8 motion, +0xc num_anims (also used as a raw tick countdown). */ -struct __cppobj MotionTableManager::AnimNode : DLListData -{ - unsigned int motion; - unsigned int num_anims; -}; - -/* acclient.h:31092 */ -struct __cppobj DLList : DLListBase -{ -}; - -/* acclient.h:31097 — offsets: +0x0 physics_obj, +0x4 table, +0x8 state (24 bytes: - style/substate/substate_mod/modifier_head/action_head/action_tail), +0x20 animation_counter, - +0x24/+0x28 pending_animations {head_,tail_}. Total sizeof = 0x2c (44 bytes), matches - `operator new(0x2c)` in Create(). */ -struct __cppobj MotionTableManager -{ - CPhysicsObj *physics_obj; - CMotionTable *table; - MotionState state; - int animation_counter; - DLList pending_animations; -}; - -/* acclient.h:31654 — style_defaults maps style -> default substate (unsigned long values). - cycles maps (style<<16 | substate&0xffffff) -> MotionData* (base cyclic animation for a state). - modifiers maps (style<<16 | modifier&0xffffff) OR bare modifier&0xffffff -> MotionData*. - links maps (style<<16 | substate&0xffffff) -> LongHash* (a per-(style,substate) - hash of transition-target-substate -> MotionData* link animation). default_style is the - fallback style (e.g. "Standing/Idle") used when no direct link/cycle exists. */ -const struct __cppobj __declspec(align(8)) CMotionTable : SerializeUsingPackDBObj -{ - LongNIValHash style_defaults; - LongHash cycles; - LongHash modifiers; - LongNIValHash *> links; - unsigned int default_style; -}; - -/* acclient.h:57162 */ -struct __cppobj __declspec(align(4)) MotionData : PackObj, LongHashData -{ - char num_anims; - AnimData *anims; - AC1Legacy::Vector3 velocity; - AC1Legacy::Vector3 omega; - char bitfield; /* bit0 (0x1) = "clear modifiers on apply"; bit1 (0x2) = "substate-checked" (is_allowed gate) */ -}; - -/* acclient.h:38069 */ -struct __cppobj MovementStruct -{ - MovementTypes::Type type; - unsigned int motion; - unsigned int object_id; - unsigned int top_level_id; - Position pos; - float radius; - float height; - MovementParameters *params; /* params->speed used as substate_mod / speed */ -}; - -/* acclient.h:2856 */ -enum MovementTypes::Type -{ - Invalid = 0x0, - RawCommand = 0x1, - InterpretedCommand = 0x2, - StopRawCommand = 0x3, - StopInterpretedCommand = 0x4, - StopCompletely = 0x5, - MoveToObject = 0x6, - MoveToPosition = 0x7, - TurnToObject = 0x8, - TurnToHeading = 0x9, - FORCE_Type_32_BIT = 0x7FFFFFFF, -}; -``` - -### Motion-id class bits (the `0x?0000000` family used throughout GetObjectSequence/StopSequenceMotion) - -Observed usage in the decomp below (no single enum found in acclient.h; these are bit-tested -directly as `arg2 & 0x40000000`, `& 0x20000000`, `& 0x10000000`, and `substate & 0x20000000`): - -- `0x40000000` — **cycle class**: substate IDs that name a cyclic/looping animation state - directly addressable via `this->cycles`. GetObjectSequence's `ebx_1 < 0` branch (i.e. sign - bit / top bit set — note `0x40000000` alone doesn't set the sign bit, so this is really the - `(uint32_t)ebx_1 & 0x80000000`-adjacent negative-as-int32 check on the incoming motion id, - handled distinctly from the `& 0x40000000` cycle-lookup path lower down) and the - `StopSequenceMotion` early-out both special-case this bit: "this IS the current substate, no - transition — reissue via GetDefaultStyle equivalent (style_defaults default) with `arg7=1` - (stop flag)." -- `0x20000000` — **modifier class**: substate/motion IDs that are modifiers (overlays combined - onto the base cycle, e.g. "aiming while running") rather than replacing the base state. Stored/ - removed via `MotionState::modifier_head` linked list (`add_modifier`/`add_modifier_no_check`/ - `remove_modifier`). `StopSequenceMotion` walks `modifier_head` to find and subtract a matching - modifier when this bit is set. -- `0x10000000` — **action class**: one-shot, non-cycle-replacing actions (e.g. attacks, emotes) - queued onto `MotionState::action_head`/`action_tail` via `add_action`, and popped in FIFO via - `remove_action_head` when their queued AnimNode completes (`CheckForCompletedMotions`/ - `AnimationDone` test `num_anims_node_flags & 0x10000000` to decide whether to pop the action - queue on completion). - ---- - -## 2. Free functions (motion arithmetic + comparison helpers) - -### `same_sign` — 0x00522260 (@298253) - -```c -int32_t same_sign(float arg1, float arg2) -``` -Pseudo-C is x87-flag-comparison noise from BN; the two `if`/`else if` legs both collapse to the -same conditional. Cleaned flow: returns whether `arg1` and `arg2` compare on the **same side of -zero** (both >=0 or both <0, roughly — BN's `test ah,0x5` pattern after an `fcompp`-style compare -means "ZF|PF clear", i.e. arg2 < arg1 is false in one of the ucomiss orderings). Net effect used -by call sites (`GetObjectSequence`'s `same_sign(ebp_1, esi->substate_mod) != 0`): **true when the -new speed_mod (arg1) and the current substate_mod (arg2) have the same sign** — i.e. direction of -motion hasn't flipped (forward vs backward). Used to gate the "just re-speed the existing cycle, -don't retransition" fast path. - -```c -int32_t same_sign(float arg1, float arg2) -{ - // BN x87 flag noise elided — net semantic: - // return (arg1 >= 0f) == (arg2 >= 0f); // both same sign => 1, else 0 - // (mirrors: compares arg1 vs 0, arg2 vs 0, returns 1 if same side, 0 if opposite) -} -``` - -### `change_cycle_speed` — 0x00522290 (@298276) - -```c -void change_cycle_speed(class CSequence* arg1, class MotionData* arg2, float arg3, float arg4) -{ - // arg1=sequence, arg2=cyclic MotionData (unused directly here besides existence check - // upstream), arg3=old substate_mod, arg4=new substate_mod - if (fabsl(arg3) > 0.0002f) // guard divide-by-near-zero - { - CSequence::multiply_cyclic_animation_fr(arg1, arg4 / arg3); - return; - } - // arg3 ~ 0: fall through to the fabsl(arg4) test - if (fabsl(arg4) > 0.0002f is FALSE, i.e. arg4 also ~0 -> multiply by 0) - CSequence::multiply_cyclic_animation_fr(arg1, 0f); - // (if arg4 is NOT ~0 while arg3 IS ~0, the BN flag test suppresses the call — net: - // only rescale when arg3 is non-trivial; when arg3~0 AND arg4~0, zero the playback rate) -} -``` -Constant `0.000199999995f` ≈ `0.0002f` — hard epsilon for "is this speed effectively zero." -Cleaned intent: **rescale the cyclic animation's frame rate by the ratio new_speed/old_speed**; -guards against div-by-zero when the previous substate_mod was ~0. - -### `add_motion` — 0x005224b0 (@298437) - -```c -void add_motion(class CSequence* arg1 /*sequence*/, class MotionData* arg2 /*motion*/, float arg3 /*speed_mod*/) -{ - if (arg2 == 0) return; - - // Scale linear + angular velocity by speed_mod and SET on the sequence (overwrite, not add) - Vector3 v = arg2->velocity * arg3; // {x,y,z} each independently multiplied - CSequence::set_velocity(arg1, &v); - - Vector3 w = arg2->omega * arg3; - CSequence::set_omega(arg1, &w); - - // Append every sub-animation in this MotionData's anims[] array, each scaled by speed_mod - for (int i = 0; i < arg2->num_anims; i++) - CSequence::append_animation(arg1, /* AnimData scaled-copy ctor */ operator*(&tmp, arg3, &arg2->anims[i])); -} -``` -`num_anims` field is `char` in the struct but compared/looped as unsigned int (BN widens it). -Anim stride in the raw dump is `0x14` (20) bytes per `AnimData` element (`ebx_1 += 0x14`). - -### `combine_motion` — 0x00522580 (@298472) - -```c -void combine_motion(class CSequence* arg1, class MotionData* arg2, float arg3) -{ - if (arg2 == 0) return; - Vector3 w = arg2->omega * arg3; - Vector3 v = arg2->velocity * arg3; - CSequence::combine_physics(arg1, &v, &w); // ADDS to existing sequence velocity/omega (vs add_motion's SET) -} -``` -Note: `combine_motion` does **not** touch `anims` — it only combines the linear/angular physics -contribution (used for modifier overlays where the base cycle's animation frames stay, but a -modifier's velocity/omega gets blended in). - -### `subtract_motion` — 0x00522600 (@298492) - -```c -void subtract_motion(class CSequence* arg1, class MotionData* arg2, float arg3) -{ - if (arg2 == 0) return; - Vector3 w = arg2->omega * arg3; - Vector3 v = arg2->velocity * arg3; - CSequence::subtract_physics(arg1, &v, &w); // inverse of combine_motion; used when REMOVING a modifier -} -``` - ---- - -## 3. `CMotionTable::is_allowed` — 0x005226c0 (@298526) - -```c -int32_t __thiscall CMotionTable::is_allowed(class CMotionTable const* this, - uint32_t arg2 /*candidate substate id*/, - class MotionData* arg3 /*candidate cycle MotionData*/, - class MotionState const* arg4 /*current state*/) -{ - class MotionData* eax_4 = arg3; - if (eax_4 == 0) - return eax_4; // null MotionData -> "not allowed" (returns 0) - - if ((eax_4->bitfield & 2) != 0) // MotionData.bitfield bit1 = "substate-gated" cycle - { - uint32_t substate = arg4->substate; // arg4 = current MotionState - if (arg2 != substate) // candidate substate differs from current substate - { - // style_defaults[arg4->style] -> default substate for that style - uint32_t defaultSubstate; - LongNIValHash::lookup(&this->style_defaults, arg4->style, &defaultSubstate); - return defaultSubstate == substate; // allowed only if current substate == that style's default - } - } - return 1; // not gated, or candidate IS the current substate -> allowed -} -``` -Cleaned semantics: **a bitfield-bit1 ("gated") cycle is only reusable/continuable when the -current substate matches, OR when the current substate already equals the owning style's -default substate.** This stops e.g. a combat-cycle MotionData from being silently reused while -standing in an unrelated substate. - ---- - -## 4. `CMotionTable::get_link` — 0x00522710 (@298552) - -```c -class MotionData* __thiscall CMotionTable::get_link(class CMotionTable const* this, - uint32_t arg2 /*fromStyle*/, - uint32_t arg3 /*fromSubstate*/, - float arg4 /*fromSubstateMod (speed sign)*/, - uint32_t arg5 /*toSubstate*/, - float arg6 /*toSubstateMod (speed sign)*/) -{ - bool fromNeg = (arg6 < 0f); // BN's x87 compare-to-zero noise: p = (arg6<0), computed FIRST - bool toNeg; - if (fromNeg) - toNeg = (arg4 < 0f); - // net predicate: sameDirection = !(fromNeg) || !(toNeg) === NOT(fromNeg && toNeg) - // i.e. "at least one of the two speed_mods is non-negative" chooses branch A; - // "both speed_mods negative" (moving backward on both ends) chooses branch B (swap keys) - - class LongHash* linkHash; - uint32_t key = (arg2 << 16); // style in high 16 bits - - if (!(fromNeg && toNeg)) // branch A: NOT both-negative - { - // links[(arg2<<16) | (arg5 & 0xffffff)] — keyed by (fromStyle, toSubstate) - if (LongNIValHash*>::lookup(&this->links, key | (arg5 & 0xffffff), &linkHash) != 0) - { - MotionData* found = LongHash::lookup(linkHash, arg3); // sub-key = fromSubstate - if (found != 0) return found; - // fall through with found cached in var_4 for the final fallback return - } - } - else // branch B: both negative -> swap which value keys the outer hash vs the inner lookup - { - // links[(arg2<<16) | (arg3 & 0xffffff)] — keyed by (fromStyle, FROMsubstate) this time - if (LongNIValHash*>::lookup(&this->links, key | (arg3 & 0xffffff), &linkHash) != 0) - { - MotionData* found = LongHash::lookup(linkHash, arg5); // sub-key = toSubstate - if (found != 0) return found; - } - } - - // ---- Second predicate block: same test repeated on (arg6, arg4) again (BN redundant re-eval) ---- - bool fromNeg2 = (arg6 < 0f); - bool toNeg2; - if (fromNeg2) toNeg2 = (arg4 < 0f); - - if (!(fromNeg2 && toNeg2)) - { - // style_defaults[arg2] -> defaultSubstateForFromStyle (stored into arg5's slot, reused as "arg5") - // links[(arg2<<16) | (arg3 & 0xffffff)] -> linkHash - // return linkHash[defaultSubstateForFromStyle] - uint32_t defaultSubstate; - if (LongNIValHash::lookup(&this->style_defaults, arg2, &defaultSubstate) != 0 - && LongNIValHash*>::lookup(&this->links, key | (arg3 & 0xffffff), &linkHash) != 0) - return LongHash::lookup(linkHash, defaultSubstate); - } - else if (LongNIValHash*>::lookup(&this->links, key /* (arg2<<16)|0 */, &linkHash) != 0) - { - return LongHash::lookup(linkHash, arg5); - } - - return var_4; // whatever branch A/B's inner lookup found (possibly null) as final fallback -} -``` -Cleaned intent: **look up the link (transition) animation between `fromSubstate` and `toSubstate` -within `fromStyle`.** Direction-of-motion (sign of the two speed mods) picks which of the two IDs -keys the outer per-style hash and which keys the inner per-transition hash — this lets forward -and backward transitions (e.g. walk-forward→run-forward vs walk-backward→run-backward) be stored -as distinct entries without duplicating the whole link table. If no direct link exists, falls back -to a link from the **style's default substate** to the target (the "return via idle" link). - ---- - -## 5. `CMotionTable::GetObjectSequence` — 0x00522860 (@298636) — COMPLETE - -```c -int32_t __thiscall CMotionTable::GetObjectSequence( - class CMotionTable const* this, - uint32_t arg2, // requested target substate/motion id ("ebx_1" after copy) - class MotionState* arg3, // in/out current motion state (style/substate/substate_mod/modifier_head) - class CSequence* arg4, // sequence to build/mutate (physics + animation queue) - float arg5, // requested speed_mod for the new substate - uint32_t* arg6, // OUT: outTicks (frame-tick count of appended animation, minus 1) - int32_t arg7) // 0 = normal "do motion", 1 = "stop" call (re-invoked from StopSequenceMotion) -{ - MotionState* esi = arg3; - *arg6 = 0; // outTicks defaults to 0 - - uint32_t style = esi->style; - if (style == 0) - return 0; // no current style at all -> can't sequence anything - - uint32_t substate = esi->substate; - if (substate == 0) - return 0; - - MotionData* var_10_1 = nullptr; // "toDefault" link data (2nd hop of a style-default double-hop) - MotionData* var_4_1 = nullptr; // "fromDefault" link data (1st hop of a style-default double-hop) - - uint32_t var_c; // default substate for esi->style - LongNIValHash::lookup(&this->style_defaults, style, &var_c); - - uint32_t ebx_1 = arg2; // target motion/substate id, working copy - - // ---- FAST PATH: requesting the SAME substate we're already in, is-a-modifier-class id, not a stop-call ---- - if (ebx_1 == var_c && arg7 == 0 && (substate & 0x20000000) != 0) - return 1; // no-op: already at the style default and target is a modifier-class request - - float ebp_1 = arg5; // working speed_mod - - // ================================================================ - // BRANCH 1: ebx_1 interpreted as NEGATIVE int32 (top bit set) — style-change request - // (BN emits `if (ebx_1 < 0)`; ebx_1's raw bit pattern with the top bit set is used - // elsewhere as a STYLE id encoded in the upper bits, not a plain substate) - // ================================================================ - if ((int32_t)ebx_1 < 0) - { - uint32_t style_1 = esi->style; - if (style_1 == ebx_1) - return 1; // requesting the style we're already in -> no-op success - - uint32_t eax_1; // default substate for style_1 (current style) - LongNIValHash::lookup(&this->style_defaults, style_1, &eax_1); - uint32_t substate_3 = substate; // == esi->substate, aliased - - if (substate_3 != eax_1) - // current substate isn't already the current style's default -> need a link OUT of it first - var_4_1 = CMotionTable::get_link(this, esi->style, substate_3, esi->substate_mod, eax_1, ebp_1); - - uint32_t arg7_style_default; // default substate for the TARGET style (ebx_1) - if (LongNIValHash::lookup(&this->style_defaults, ebx_1, &arg7_style_default) != 0) - { - // cycles[(arg7_style_default & 0xffffff) | (ebx_1<<16)] — target style's default cycle - MotionData* eax_5 = LongHash::lookup(&this->cycles, (arg7_style_default & 0xffffff) | (ebx_1 << 16)); - arg3 = eax_5; // becomes the "new base cycle" MotionData - if (eax_5 != 0) - { - if ((eax_5->bitfield & 1) != 0) - MotionState::clear_modifiers(esi); // new cycle wants a clean modifier slate - - // link FROM current style's default substate TO target style's default substate - MotionData* eax_7 = CMotionTable::get_link(this, esi->style, var_c, esi->substate_mod, ebx_1, ebp_1); - arg2 = eax_7; - - if (eax_7 == 0) - { - uint32_t style_2 = esi->style; - if (ebx_1 != style_2) - { - // DOUBLE-HOP VIA this->default_style (the table-wide default style, e.g. "Standing"): - // hop 1: current style's default substate -> default_style's matching substate (speed 1.0) - arg2 = CMotionTable::get_link(this, style_2, var_c, 1f, this->default_style, 1f); - // hop 2: default_style's default substate -> target style's default substate (speed 1.0) - uint32_t defaultStyleDefaultSubstate; - LongNIValHash::lookup(&this->style_defaults, this->default_style, &defaultStyleDefaultSubstate); - var_10_1 = CMotionTable::get_link(this, this->default_style, defaultStyleDefaultSubstate, 1f, ebx_1, 1f); - } - } - - // Rebuild the sequence from scratch: clear physics + strip cyclic anims, then layer: - // 1) the "exit current substate" link (var_4_1) - // 2) the direct or hop-1 link (arg2) - // 3) the hop-2 link if double-hop taken (var_10_1) - // 4) the new base cycle itself (arg3) - CSequence::clear_physics(arg4); - CSequence::remove_cyclic_anims(arg4); - add_motion(arg4, var_4_1, ebp_1); - add_motion(arg4, arg2, ebp_1); - add_motion(arg4, var_10_1, ebp_1); - add_motion(arg4, arg3, ebp_1); - - // Commit new state: substate<-arg7(ORIGINAL passed-in arg7, i.e. the stop-flag param! - // at this point arg7 has been OVERWRITTEN above by the style_defaults lookup output — - // BN reuses arg7's stack slot for the "arg7_style_default" out-param), style<-ebx_1 - esi->substate = arg7_style_default; // == the target style's default substate - esi->style = ebx_1; - esi->substate_mod = arg5; - CMotionTable::re_modify(this, arg4, esi); // replay any pending modifiers atop new state - - // outTicks = action_head_ticks(arg3) + var_10_1.num_anims + arg2.num_anims(as byte@+0x10) + var_4_1.num_anims - 1 - uint32_t num_anims_2 = (var_4_1 == 0) ? 0 : var_4_1->num_anims; - uint32_t ecx_20 = (arg2 == 0) ? 0 : *(uint8_t*)(arg2 + 0x10); // arg2 here is a MotionData*, +0x10 byte = num_anims field (packed layout) - uint32_t num_anims_1 = (var_10_1== 0) ? 0 : var_10_1->num_anims; - *arg6 = (uint32_t)arg3->action_head /* NOTE: arg3 is MotionData* here, ->action_head is BN's - mis-decoded field name for MotionData's tick-count field - at that offset, NOT the MotionState action_head pointer */ - + num_anims_1 + ecx_20 + num_anims_2 - 1; - return 1; - } - } - // else: fall through to the class-bit blocks below with ebx_1 still negative - } - - // ================================================================ - // BRANCH 2: target id has the CYCLE-CLASS bit (0x40000000) set - // ================================================================ - if ((ebx_1 & 0x40000000) != 0) - { - uint32_t eax_23 = ebx_1 & 0xffffff; - MotionData* eax_24 = LongHash::lookup(&this->cycles, (esi->style << 16) | eax_23); - arg3 = eax_24; - - if (eax_24 == 0) - { - // no cycle for THIS style at that id -> retry under the table-wide default_style - eax_24 = LongHash::lookup(&this->cycles, (this->default_style << 16) | eax_23); - arg3 = eax_24; - if (eax_24 != 0) - goto label_522ae6; // found one under default_style -> proceed as if found directly - // else: fall through past this whole branch (arg3 stays whatever eax_24 was = 0) - } - else - { - label_522ae6: - if (CMotionTable::is_allowed(this, ebx_1, eax_24, esi) != 0) - { - // ---- FAST RE-SPEED PATH: same substate, same direction, already animating ---- - if (ebx_1 == substate /* the ORIGINAL esi->substate captured at entry */ - && same_sign(ebp_1, esi->substate_mod) != 0 - && CSequence::has_anims(arg4) != 0) - { - change_cycle_speed(arg4, arg3, esi->substate_mod, ebp_1); - subtract_motion(arg4, arg3, esi->substate_mod); // remove old-speed contribution - combine_motion(arg4, arg3, ebp_1); // add new-speed contribution - esi->substate_mod = arg5; - return 1; - } - - if ((arg3->bitfield & 1) != 0) // MotionData.bitfield: this cycle clears mods on entry - MotionState::clear_modifiers(esi); - - // direct link from current (style,substate,substate_mod) to target substate - MotionData* eax_34 = CMotionTable::get_link(this, esi->style, esi->substate, esi->substate_mod, ebx_1, ebp_1); - arg2 = eax_34; - - bool sameSign = (eax_34 != 0) ? (same_sign(ebp_1, esi->substate_mod) != 0) : false; - - if (eax_34 == 0 || !sameSign) - { - // direct link missing OR direction flipped -> route via style default substate - uint32_t styleDefaultSubstate; - LongNIValHash::lookup(&this->style_defaults, esi->style, &styleDefaultSubstate); - arg2 = CMotionTable::get_link(this, esi->style, esi->substate, esi->substate_mod, styleDefaultSubstate, 1f); - var_10_1 = CMotionTable::get_link(this, esi->style, styleDefaultSubstate, 1f, ebx_1, ebp_1); - } - - CSequence::clear_physics(arg4); - CSequence::remove_cyclic_anims(arg4); - - if (var_10_1 == 0) - { - // No double-hop link needed. Decide sign for arg2's contribution: - // if current substate_mod ~0 use sign of arg5 as-is; else if substate_mod's - // sign bit differs from... (BN x87 flag block collapses to:) - float signedSpeed = (esi->substate_mod == 0f || same_sign(esi->substate_mod, arg5)) - ? arg5 : -arg5; - add_motion(arg4, arg2, signedSpeed); - } - else - { - add_motion(arg4, arg2, esi->substate_mod); - add_motion(arg4, var_10_1, ebp_1); - } - - add_motion(arg4, arg3, ebp_1); // layer the new base cycle itself - - // If we're leaving a modifier-class substate (0x20000000) for something else, - // and the style's default substate isn't the new target, re-register the OLD - // substate as a still-active modifier (so it keeps contributing) instead of dropping it. - uint32_t substate_1 = esi->substate; - if (substate_1 != ebx_1 && (substate_1 & 0x20000000) != 0) - { - uint32_t styleDefaultSubstate2; - LongNIValHash::lookup(&this->style_defaults, esi->style, &styleDefaultSubstate2); - if (styleDefaultSubstate2 != ebx_1) - MotionState::add_modifier_no_check(esi, substate_1, esi->substate_mod); - } - - esi->substate_mod = arg5; - esi->substate = ebx_1; - CMotionTable::re_modify(this, arg4, esi); - - uint32_t ecx_45 = (arg2 == 0) ? 0 : *(uint8_t*)(arg2 + 0x10); // arg2's packed num_anims byte - uint32_t num_anims_1 = (var_10_1 == 0) ? 0 : var_10_1->num_anims; - *arg6 = (uint32_t)arg3->action_head /* MotionData tick-count field, see BRANCH 1 note */ - + num_anims_1 + ecx_45 - 1; - return 1; - } - // is_allowed() rejected -> fall through past this whole branch - } - } - - // ================================================================ - // BRANCH 3: target id has the ACTION-CLASS bit (0x10000000) set - // ================================================================ - if ((ebx_1 & 0x10000000) != 0) - { - uint32_t cycleKey = (esi->style << 16) | (substate & 0xffffff); // current (style,substate) cycle key - MotionData* eax_57 = LongHash::lookup(&this->cycles, cycleKey); - if (eax_57 != 0) - { - // direct link from current (style,substate) to the action id - MotionData* eax_60 = CMotionTable::get_link(this, esi->style, substate, esi->substate_mod, ebx_1, ebp_1); - arg2 = eax_60; - if (eax_60 != 0) - { - MotionState::add_action(esi, ebx_1, ebp_1); // queue the action id on action_head/action_tail - CSequence::clear_physics(arg4); - CSequence::remove_cyclic_anims(arg4); - add_motion(arg4, arg2, ebp_1); // the action's own link animation - add_motion(arg4, eax_57, esi->substate_mod); // re-add the still-running base cycle - CMotionTable::re_modify(this, arg4, esi); - *arg6 = *(uint8_t*)(arg2 + 0x10); // outTicks = arg2's packed num_anims byte - return 1; - } - - // No direct link -> route the action through the style's default substate (double-hop) - uint32_t styleDefaultSubstate; - LongNIValHash::lookup(&this->style_defaults, esi->style, &styleDefaultSubstate); - MotionData* eax_66 = CMotionTable::get_link(this, esi->style, substate, esi->substate_mod, styleDefaultSubstate, 1f); - arg2 = eax_66; - if (eax_66 != 0) - { - MotionData* eax_68 = CMotionTable::get_link(this, esi->style, styleDefaultSubstate, 1f, ebx_1, ebp_1); - if (eax_68 != 0) - { - MotionData* eax_69 = LongHash::lookup(&this->cycles, cycleKey); // re-fetch base cycle (same key) - if (eax_69 != 0) - { - // link BACK from styleDefaultSubstate to substate (returning-hop, note args flipped - // vs eax_68: (style,defaultSubstate,1.0) -> (substate, esi->substate_mod)) - MotionData* returningLink = CMotionTable::get_link(this, esi->style, styleDefaultSubstate, 1f, substate, esi->substate_mod); - - MotionState::add_action(esi, ebx_1, ebp_1); - CSequence::clear_physics(arg4); - CSequence::remove_cyclic_anims(arg4); - add_motion(arg4, arg2, 1f); // hop 1: substate -> default (speed 1.0) - add_motion(arg4, eax_68, ebp_1); // hop 2: default -> action (new speed) - add_motion(arg4, returningLink, 1f); // hop back: default -> substate (speed 1.0) - add_motion(arg4, eax_69, esi->substate_mod); // base cycle continuing at old speed - CMotionTable::re_modify(this, arg4, esi); - - uint32_t outTicks = *(uint8_t*)(arg2 + 0x10) + eax_68->num_anims; - *arg6 = outTicks; - if (returningLink != 0) - *arg6 = *(uint8_t*)(returningLink + 0x10) + outTicks; - return 1; - } - } - } - } - } - - // ================================================================ - // BRANCH 4: target id has the MODIFIER-CLASS bit (0x20000000) set - // ================================================================ - if ((ebx_1 & 0x20000000) != 0) - { - uint32_t styleShifted = esi->style << 16; - MotionData* eax_81 = LongHash::lookup(&this->cycles, (substate & 0xffffff) | styleShifted); - - // must have a base cycle for the current (style,substate) AND that cycle must NOT be - // the "clear modifiers on entry" kind (bitfield bit0 clear) - if (eax_81 != 0 && (eax_81->bitfield & 1) == 0) - { - uint32_t modKey = ebx_1 & 0xffffff; - MotionData* eax_85 = LongHash::lookup(&this->modifiers, modKey | styleShifted); // style-specific modifier - MotionData* eax_87 = 0; - if (eax_85 == 0) - eax_87 = LongHash::lookup(&this->modifiers, modKey); // style-agnostic (global) modifier fallback - MotionData* modifierData = (eax_85 != 0) ? eax_85 : eax_87; - - if (eax_85 != 0 || eax_87 != 0) - { - int32_t added = MotionState::add_modifier(esi, ebx_1, ebp_1); // returns 0 if already present or == substate - if (added == 0) - { - // Already active as a modifier or equals current substate -> STOP it first, then re-add - // (this is the "toggle" path: requesting the same modifier again removes+readds it, - // which is how e.g. re-triggering a held key restarts a modifier cleanly) - uint32_t dummyTicks; - CMotionTable::StopSequenceMotion(this, ebx_1, 1f, esi, arg4, &dummyTicks); - added = MotionState::add_modifier(esi, ebx_1, ebp_1); - } - if (added != 0) - { - combine_motion(arg4, modifierData, ebp_1); // ADD the modifier's velocity/omega contribution - return 1; - } - } - } - } - - return 0; // nothing matched any of the four class branches -> failure -} -``` - -### Cleaned high-level flow of `GetObjectSequence` - -1. **Guard**: `esi->style == 0` or `esi->substate == 0` → fail (0). No-op fast path: requesting - the style's own default substate while it's already a modifier-class substate and this isn't - a stop-call → success (1) with no work. -2. **Style-change request** (`(int32_t)ebx_1 < 0`, i.e. target id encodes a STYLE not a substate): - no-op if already that style; otherwise builds a **1–2 hop link chain** from the current - style's default substate to the target style's default substate — with a fallback double-hop - through `this->default_style` (the table's global fallback style, e.g. standing-idle) when no - direct style-to-style link exists. Commits `esi->style`/`esi->substate`/`esi->substate_mod`, - replays modifiers via `re_modify`, computes `*arg6` (outTicks) as the sum of all appended - link/cycle animation tick counts minus 1. -3. **Cycle-class target** (`ebx_1 & 0x40000000`): looks up (or falls back to `default_style`'s) - base cyclic MotionData for the target substate; gates via `is_allowed`. If already in that - exact substate with the same speed sign and animations already running, takes the **cheap - re-speed path** (`change_cycle_speed` + subtract-old/combine-new). Otherwise builds a direct - or double-hop (via style default) transition-link chain, optionally preserving the outgoing - substate as a still-active modifier if it was itself modifier-class, commits new - substate/substate_mod, replays modifiers, computes outTicks. -4. **Action-class target** (`ebx_1 & 0x10000000`): requires a base cycle to already exist for - the current substate. Direct link → queue action + layer link + re-add base cycle. No direct - link → 2-hop-out-and-back through the style default substate, queuing four animation layers - (out-hop, action, back-hop, base cycle) and summing all their tick counts for outTicks. -5. **Modifier-class target** (`ebx_1 & 0x20000000`): requires a base cycle that does NOT clear - modifiers on entry. Looks up a style-specific modifier MotionData, falling back to a - style-agnostic one. `add_modifier` is idempotent-guarded; if it reports "already present," - the code **stops** the existing modifier via `StopSequenceMotion` first, then re-adds — this - is the toggle/restart semantics. On success, `combine_motion` adds the modifier's - velocity/omega into the sequence (animation frames untouched — modifiers are physics-only - overlays). -6. Falls through to `return 0` if no branch's preconditions were satisfied. - ---- - -## 6. `CMotionTable::re_modify` — 0x005222e0 (@298300) - -```c -void __thiscall CMotionTable::re_modify(class CMotionTable const* this, class CSequence* arg2, class MotionState* arg3) -{ - MotionState* esi = arg3; - if (esi->modifier_head != 0) - { - MotionState snapshot; - MotionState::MotionState(&snapshot, esi); // copy-construct a snapshot of the CURRENT modifier list - CSequence* seq = arg2; - - // Walk esi's modifier list, popping ONE entry per iteration, re-driving it through - // GetObjectSequence (arg7=0, "do motion") so each modifier gets correctly re-applied - // on top of whatever base cycle/link chain was just installed. - do - { - MotionList* modifier_head = esi->modifier_head; - uint32_t motion = modifier_head->motion; - float speedMod = modifier_head->speed_mod; - MotionState::remove_modifier(esi, modifier_head, nullptr); // pop from esi's live list - MotionState::remove_modifier(&snapshot, snapshot_head, nullptr); // pop matching node from the snapshot copy - CMotionTable::GetObjectSequence(this, motion, esi, seq, speedMod, &outTicksIgnored, 0); - } while (snapshot.modifier_head != 0); - - MotionState::~MotionState(&snapshot); - } -} -``` -Cleaned intent: **after installing a new base cycle/substate, replay every previously-active -modifier back onto the sequence** by re-invoking `GetObjectSequence` for each one. This is why -every substate-changing branch in `GetObjectSequence` calls `re_modify` right before returning — -modifiers (aiming overlays, etc.) must survive a cycle/style transition. - ---- - -## 7. `CMotionTable::StopSequenceMotion` — 0x00522fc0 (@298954) - -```c -int32_t __thiscall CMotionTable::StopSequenceMotion( - class CMotionTable const* this, - uint32_t arg2, // motion/substate id being stopped - float arg3, // speed_mod (usually 1.0f from call sites) - class MotionState* arg4, // current state - class CSequence* arg5, // sequence - uint32_t* arg6) // OUT: outTicks -{ - *arg6 = 0; - - // Case A: stopping the CYCLE-class substate we are currently in - if ((arg2 & 0x40000000) != 0 && arg2 == arg4->substate) - { - uint32_t styleDefaultSubstate; - LongNIValHash::lookup(&this->style_defaults, arg4->style, &styleDefaultSubstate); - // Re-invoke GetObjectSequence targeting the STYLE'S DEFAULT SUBSTATE, with arg7=1 ("stop" flag) - CMotionTable::GetObjectSequence(this, styleDefaultSubstate, arg4, arg5, 1f, arg6, 1); - return 1; - } - - // Case B: stopping a MODIFIER-class id — search arg4->modifier_head for a matching node - if ((arg2 & 0x20000000) != 0) - { - MotionList* prev = nullptr; - for (MotionList* node = arg4->modifier_head; node != 0; node = node->next) - { - if (node->motion == arg2) - { - uint32_t modKey = arg2 & 0xffffff; - // style-specific modifier data first - MotionData* modData = LongHash::lookup(&this->modifiers, (arg4->style << 16) | modKey); - if (modData != 0) - { - found: - subtract_motion(arg5, modData, node->speed_mod); // remove its velocity/omega contribution - MotionState::remove_modifier(arg4, node, prev); // unlink from the modifier_head chain - return 1; - } - // style-agnostic fallback - modData = LongHash::lookup(&this->modifiers, modKey); - if (modData != 0) - goto found; - break; // matching motion id found but NO MotionData for it anywhere -> give up - } - prev = node; - } - } - - return 0; // nothing matched -> stop was a no-op -} -``` -Cleaned intent: **two independent stop mechanisms.** Stopping the active cycle re-drives -`GetObjectSequence` toward the style's default substate (i.e. "return to idle"). Stopping a -modifier just unwinds its physics contribution and removes it from the linked list — no -animation re-sequencing needed since modifiers don't touch `anims[]`. - -Note: action-class (`0x10000000`) ids are **not handled here at all** — actions complete via -their own tick-countdown mechanism in `MotionTableManager::CheckForCompletedMotions`/ -`AnimationDone` (popping `action_head`), not via `StopSequenceMotion`. - ---- - -## 8. `CMotionTable::SetDefaultState` — 0x005230a0 (@299004) - -```c -int32_t __thiscall CMotionTable::SetDefaultState(class CMotionTable const* this, - class MotionState* arg2, - class CSequence* arg3, - uint32_t* arg4) -{ - uint32_t defaultSubstate; - if (LongNIValHash::lookup(&this->style_defaults, this->default_style, &defaultSubstate) == 0) - return 0; // table has no entry for its own default_style -> fail - - MotionState::clear_modifiers(arg2); - MotionState::clear_actions(arg2); - - uint32_t cycleKey = (defaultSubstate & 0xffffff) | (this->default_style << 16); - // manual open-addressed hash bucket walk against this->cycles (inlined LongHash lookup) - HashNode* node = this->cycles.buckets[hash(cycleKey) & this->cycles.table_mask]; - if (node != 0) - { - while (cycleKey != node->key) - { - node = node->hashNext; - if (node == 0) return 0; - } - if (node != EMPTY_SENTINEL /* raw check: ecx_10 != 4 */) - { - arg2->style = this->default_style; - arg2->substate = defaultSubstate; - arg2->substate_mod = 1f; - *arg4 = node->motionData->num_anims - 1; // outTicks - CSequence::clear_physics(arg3); - CSequence::clear_animations(arg3); - add_motion(arg3, node->motionData, arg2->substate_mod); - return 1; - } - } - return 0; -} -``` -Cleaned intent: **reset a MotionState to the motion table's baseline (default_style + -that style's default substate)**, clearing all modifiers/actions and installing the base -cyclic animation for that (style,substate) fresh. Used by -`MotionTableManager::initialize_state` on world-enter. - ---- - -## 9. `CMotionTable::DoObjectMotion` / `StopObjectMotion` / `StopObjectCompletely` - -```c -/* 0x00523e90 @300045 — thin wrapper: "do" == GetObjectSequence with the stop-flag forced to 0 */ -int32_t __thiscall CMotionTable::DoObjectMotion(class CMotionTable const* this, uint32_t arg2, - class MotionState* arg3, class CSequence* arg4, float arg5, uint32_t* arg6) -{ - return CMotionTable::GetObjectSequence(this, arg2, arg3, arg4, arg5, arg6, 0); -} - -/* 0x00523ec0 @300053 — thin wrapper: "stop" == tailcall straight into StopSequenceMotion */ -int32_t __thiscall CMotionTable::StopObjectMotion(class CMotionTable const* this, uint32_t arg2, - float arg3, class MotionState* arg4, class CSequence* arg5, uint32_t* arg6) -{ - return CMotionTable::StopSequenceMotion(this, arg2, arg3, arg4, arg5, arg6); -} - -/* 0x00523ed0 @300062 */ -int32_t __thiscall CMotionTable::StopObjectCompletely(class CMotionTable const* this, - class MotionState* arg2, class CSequence* arg3, uint32_t* arg4) -{ - MotionState* esi = arg2; - int32_t result = 0; - - // Stop EVERY currently-active modifier first, one at a time (list shrinks each iteration - // because StopSequenceMotion unlinks the node it stops) - for (MotionList* node = esi->modifier_head; node != 0; node = esi->modifier_head) - { - float speedMod = node->speed_mod; - if (CMotionTable::StopSequenceMotion(this, node->motion, speedMod, esi, arg3, arg4) != 0) - result = 1; - } - - // Finally stop the base cycle/substate itself - if (CMotionTable::StopSequenceMotion(this, esi->substate, esi->substate_mod, esi, arg3, arg4) != 0) - return 1; - - return result; -} -``` -Cleaned intent: `StopObjectCompletely` is the "return to idle from everything" call — strips -every active modifier first (each one individually going through the same -subtract-and-unlink path as a single `StopObjectMotion` call), then stops the base cycle -itself (which re-drives `GetObjectSequence` toward the style's default substate). - ---- - -## 10. `CMotionTable::~CMotionTable` (destructor) — 0x00523f50 (@300087) - -```c -void __fastcall CMotionTable::~CMotionTable(class CMotionTable* this) -{ - this->vtable = &CMotionTable::`vftable'; - CMotionTable::Destroy(this); // (Destroy body not requested; frees cycles/modifiers/style_defaults contents) - LongNIValHash*>::destroy_contents(&this->links); - if (!this->links.fPlacementNew_) - operator delete[](this->links.buckets); - if (!this->modifiers.fPlacementNew_) - operator delete[](this->modifiers.buckets); - // (truncated in source at capture boundary; remaining teardown is style_defaults + cycles hash frees) -} -``` - ---- - -## 11. `MotionTableManager` — full member list (grep of `MotionTableManager::` in the pseudo-C) - -| Member | Address | Line | -|---|---|---| -| `SetPhysicsObject` | 0x0051bbc0 | @290464 | -| `SetMotionTableID` | 0x0051bbd0 | @290472 | -| `GetMotionTableID` | 0x0051bc10 | @290490 | -| `Create` (static factory) | 0x0051bc50 | @290510 | -| `truncate_animation_list` | 0x0051bca0 | @290533 | -| `AnimationDone` | 0x0051bce0 | @290558 | -| `HandleExitWorld` | 0x0051bda0 | @290625 | -| `HandleEnterWorld` | 0x0051bdd0 | @290634 | -| `CheckForCompletedMotions` | 0x0051be00 | @290645 | -| `Destroy` | 0x0051be90 | @290705 | -| `~MotionTableManager` | 0x0051bf00 | @290761 | -| `remove_redundant_links` | 0x0051bf20 | @290771 | -| `UseTime` | 0x0051bfd0 | @290845 | -| `add_to_queue` | 0x0051bfe0 | @290854 | -| `initialize_state` | 0x0051c030 | @290875 | -| `PerformMovement` | 0x0051c0b0 | @290906 | - -(No separately-decompiled `GetMotion_PersistentStates` — not present as a named symbol under -`MotionTableManager::` in this dump; the CLAUDE.md task listed it speculatively. Confirmed absent -via the exhaustive grep above — 16 members is the complete list in this PDB build.) - -### `SetPhysicsObject` — 0x0051bbc0 (@290464) -```c -void __thiscall MotionTableManager::SetPhysicsObject(class MotionTableManager* this, class CPhysicsObj* arg2) -{ - this->physics_obj = arg2; -} -``` - -### `SetMotionTableID` — 0x0051bbd0 (@290472) -```c -int32_t __thiscall MotionTableManager::SetMotionTableID(class MotionTableManager* this, class IDClass<_tagDataID,32,0> arg2) -{ - if (this->table != 0) - this->table->vtable->Release(); // drop existing CMotionTable ref - - class CMotionTable* loaded = DBObj::Get(QualifiedDataID(arg2, /*type tag*/ 0xe)); - int32_t result = (loaded != 0); - this->table = loaded; - return result; -} -``` -Type-tag `0xe` identifies the DBObj category for motion tables in `DBObj::Get`'s qualified-ID -dispatch. - -### `GetMotionTableID` — 0x0051bc10 (@290490) -```c -class IDClass<_tagDataID,32,0>* __thiscall MotionTableManager::GetMotionTableID( - class MotionTableManager const* this, class IDClass<_tagDataID,32,0>* __return) -{ - if (this->table != 0) - { - *(uint32_t*)__return = this->table->m_DID.id; - return __return; - } - *(uint32_t*)__return = INVALID_DID.id; - return __return; -} -``` - -### `Create` (static factory) — 0x0051bc50 (@290510) -```c -class MotionTableManager* MotionTableManager::Create(class IDClass<_tagDataID,32,0> arg1) -{ - void* result = operator new(0x2c); // sizeof(MotionTableManager) == 44 bytes - if (result == 0) return 0; - - result->physics_obj = 0; - result->table = 0; - MotionState::MotionState((char*)result + 8); // placement-construct the embedded `state` member - result->animation_counter = 0; // offset +0x20 - result->pending_animations.head_ = 0; // +0x24 - result->pending_animations.tail_ = 0; // +0x28 - - if (arg1 != INVALID_DID.id) - MotionTableManager::SetMotionTableID(result, arg1); - - return result; -} -``` -Offset map confirms the struct layout above: `physics_obj`@0x0, `table`@0x4, `state`@0x8 -(24 bytes: style/substate/substate_mod/modifier_head/action_head/action_tail = 4+4+4+4+4+4=24, -i.e. 0x8..0x1f), `animation_counter`@0x20, `pending_animations`@0x24 (head_@0x24, tail_@0x28). -Total 0x2c = 44 bytes, matches `operator new(0x2c)`. - -### `truncate_animation_list` — 0x0051bca0 (@290533) -```c -void __thiscall MotionTableManager::truncate_animation_list(class MotionTableManager* this, - class MotionTableManager::AnimNode const* arg2 /* stop-at node, exclusive */, class CSequence* arg3) -{ - if (arg2 == 0) return; - - // Walk pending_animations.tail_ BACKWARD toward head_, zeroing each node's num_anims (tick - // count) field and accumulating the total ticks removed, UNTIL we reach arg2 (not included). - DLListData* node = this->pending_animations.tail_; - uint32_t removedTicks = 0; - while (node != arg2) - { - if (node == 0) return; // arg2 wasn't actually in the list -> abort quietly - removedTicks += ((AnimNode*)node)->num_anims; - ((AnimNode*)node)->num_anims = 0; // neuter the tick count (node stays in the list though!) - node = node->dllist_prev; - } - - CSequence::remove_link_animations(arg3, removedTicks); // strip that many ticks' worth of link anims from the sequence -} -``` -Note: this does **not unlink** the neutered nodes from `pending_animations` — it only zeroes -their `num_anims` tick-countdown so `CheckForCompletedMotions`/`AnimationDone` treat them as -"already complete" and pop them without firing `CPhysicsObj::MotionDone` for their real motion -(since the countdown-based completion loop tests `num_anims == 0` and immediately dequeues+frees -them, but WITHOUT the `0x10000000` action-head-pop / `MotionDone` callback semantics being -meaningfully attached to a zeroed node — effectively "cancel these queued transitions silently"). - -### `AnimationDone` — 0x0051bce0 (@290558) -```c -void __thiscall MotionTableManager::AnimationDone(class MotionTableManager* this, int32_t arg2 /* success flag passed to CPhysicsObj::MotionDone */) -{ - DLListData* head = this->pending_animations.head_; - if (head == 0) return; - - this->animation_counter += 1; - - // Pop every head node whose tick-countdown (num_anims) is <= the running animation_counter - while (((AnimNode*)head)->num_anims <= this->animation_counter) - { - if ((((AnimNode*)head)->motion & 0x10000000) != 0) // action-class node - MotionState::remove_action_head(&this->state); // pop the matching queued action - - CPhysicsObj::MotionDone(this->physics_obj, ((AnimNode*)head)->motion, arg2); - this->animation_counter -= ((AnimNode*)head)->num_anims; - - // Manual DLList unlink-and-free of `head` (standard doubly-linked-list node removal, - // handling both-null / single-neighbor / has-neighbor cases for prev and next pointers) - DLListUnlinkAndDelete(&this->pending_animations, head); - - head = this->pending_animations.head_; - if (head == 0) break; - } - - if (this->animation_counter != 0 && head == 0) - this->animation_counter = 0; // list drained -> reset counter to avoid drift -} -``` -Cleaned intent: **advance the animation clock by one tick and fire `MotionDone` for every -queued motion whose duration has elapsed.** `num_anims` on a pending-queue node doubles as a -**relative tick-duration** (not an absolute deadline) — that's why it's subtracted from -`animation_counter` after firing (a decrementing countdown chain, not absolute timestamps). - -### `HandleExitWorld` — 0x0051bda0 (@290625) -```c -void __fastcall MotionTableManager::HandleExitWorld(class MotionTableManager* this, class CSequence* arg2) -{ - while (this->pending_animations.head_ != 0) - MotionTableManager::AnimationDone(this, 0); // drain the whole queue, signaling "failure/aborted" (arg2=0) -} -``` - -### `HandleEnterWorld` — 0x0051bdd0 (@290634) -```c -void __thiscall MotionTableManager::HandleEnterWorld(class MotionTableManager* this, class CSequence* arg2) -{ - CSequence::remove_all_link_animations(arg2); // strip any stale link anims from the sequence - while (this->pending_animations.head_ != 0) - MotionTableManager::AnimationDone(this, 0); // drain the whole queue (same as ExitWorld) -} -``` -Both handlers fully drain `pending_animations` — the difference is `HandleEnterWorld` also -scrubs the live `CSequence`'s link animations first (fresh-world-state guarantee). - -### `CheckForCompletedMotions` — 0x0051be00 (@290645) -```c -void __fastcall MotionTableManager::CheckForCompletedMotions(class MotionTableManager* this) -{ - DLListData* head = this->pending_animations.head_; - if (head == 0) return; - - // Pop every head node whose tick-countdown is EXACTLY 0 already (no counter increment here, - // unlike AnimationDone — this is the "did anything finish with zero remaining ticks" poll) - while (((AnimNode*)head)->num_anims == 0) - { - if ((((AnimNode*)head)->motion & 0x10000000) != 0) - MotionState::remove_action_head(&this->state); - - CPhysicsObj::MotionDone(this->physics_obj, ((AnimNode*)head)->motion, 1); // success flag = 1 (hardcoded) - - DLListUnlinkAndDelete(&this->pending_animations, head); - - head = this->pending_animations.head_; - if (head == 0) break; - } -} -``` -Distinction from `AnimationDone`: `CheckForCompletedMotions` never increments -`animation_counter` and only pops nodes that are ALREADY at `num_anims == 0` (e.g. zero-tick -entries, or ones neutered by `truncate_animation_list`), always signaling success (`arg2=1` -hardcoded). `AnimationDone` is the real per-tick clock; `CheckForCompletedMotions`/`UseTime` is -a lightweight "sweep any already-zero entries" pass called every frame via `UseTime`. - -### `Destroy` — 0x0051be90 (@290705) -```c -void __fastcall MotionTableManager::Destroy(class MotionTableManager* this) -{ - // Unconditionally unlink+delete every node in pending_animations (no MotionDone callbacks fired) - while (this->pending_animations.head_ != 0) - DLListUnlinkAndDelete(&this->pending_animations, this->pending_animations.head_); - - if (this->table != 0) - { - this->table->vtable->Release(); - this->table = nullptr; - } -} -``` - -### `~MotionTableManager` — 0x0051bf00 (@290761) -```c -void __fastcall MotionTableManager::~MotionTableManager(class MotionTableManager* this) -{ - MotionTableManager::Destroy(this); - /* tailcall */ - return MotionState::~MotionState(&this->state); // frees modifier_head + action_head/tail chains -} -``` - -### `remove_redundant_links` — 0x0051bf20 (@290771) -```c -void __thiscall MotionTableManager::remove_redundant_links(class MotionTableManager* this, class CSequence* arg2) -{ - DLListData* tail = this->pending_animations.tail_; - if (tail == 0) return; - - // Skip backward over any trailing zero-tick nodes (already-neutered/instant entries) - while (((AnimNode*)tail)->num_anims == 0) - { - tail = tail->dllist_prev; - if (tail == 0) return; - } - - uint32_t motion = ((AnimNode*)tail)->motion; - - if ((motion & 0x40000000) != 0 && (motion & 0x20000000) == 0) - { - // CYCLE-class (and not also modifier-class) tail node: look backward for an EARLIER - // node with the SAME motion id that also has a non-zero tick count — if found, every - // node between them is a now-redundant intermediate link that can be truncated, UNLESS - // an intervening node has a HIGH-priority class bit (0xb0000000 = cycle|action|... mask) - // with a non-zero tick count, in which case we bail (can't safely collapse through it). - DLListData* scan = tail->dllist_prev; - while (scan != 0) - { - uint32_t scanMotion = ((AnimNode*)scan)->motion; - if (scanMotion == motion && ((AnimNode*)scan)->num_anims != 0) - break; // found the earlier matching node -> collapse everything after it - if (((AnimNode*)scan)->num_anims != 0 && (scanMotion & 0xb0000000) != 0) - return; // blocked by an intervening "important" non-zero node -> can't collapse - scan = scan->dllist_prev; - if (scan == 0) return; - } - if (scan != 0) - MotionTableManager::truncate_animation_list(this, scan, arg2); - } - else if ((int32_t)motion < 0) - { - // STYLE-class tail node (top bit set): same backward scan, but the "block" mask is - // wider (0x70000000 = cycle|action|modifier) and the match test is exact-equality only - // (no separate not-modifier-class requirement on the match itself). - DLListData* scan = tail->dllist_prev; - while (scan != 0) - { - uint32_t scanMotion = ((AnimNode*)scan)->motion; - if (scanMotion == motion) - break; - if (((AnimNode*)scan)->num_anims != 0 && (scanMotion & 0x70000000) != 0) - return; - scan = scan->dllist_prev; - if (scan == 0) return; - } - if (scan != 0) - MotionTableManager::truncate_animation_list(this, scan, arg2); - } -} -``` -Cleaned intent: **collapse redundant back-to-back re-entries of the same motion in the pending -queue** — e.g. if the player rapidly re-issues the same run cycle or the same style change -several times before the first one's link animation finishes, everything between the earlier -matching entry and the new tail entry is provably superseded and gets truncated (ticks zeroed), -UNLESS a higher-priority class of motion (action/modifier for cycle-tails; action/modifier/cycle -for style-tails) sits in between with real remaining ticks, in which case the collapse is -unsafe and skipped. - -### `UseTime` — 0x0051bfd0 (@290845) -```c -void __fastcall MotionTableManager::UseTime(class MotionTableManager* this) -{ - /* tailcall */ - return MotionTableManager::CheckForCompletedMotions(this); -} -``` -Per-frame entry point (called from `CSequence`'s owning object's per-tick update, per the -earlier `MotionTableManager::UseTime(motion_table_manager)` call site at 0x00517d67/@285852) -that just sweeps already-complete queue entries. - -### `add_to_queue` — 0x0051bfe0 (@290854) -```c -void __thiscall MotionTableManager::add_to_queue(class MotionTableManager* this, uint32_t arg2 /*motion*/, uint32_t arg3 /*ticks*/, class CSequence* arg4) -{ - AnimNode* node = (AnimNode*)operator new(0x10); // sizeof(AnimNode) == 16: dllist_next+prev (8) + motion+num_anims (8) - if (node != 0) - { - node->dllist_next = 0; - node->dllist_prev = 0; - node->motion = arg2; - node->num_anims = arg3; // NOTE: here num_anims holds the TICK COUNT (outTicks from GetObjectSequence), not an anim array length - } - DLListBase::InsertAfter(&this->pending_animations, node, this->pending_animations.tail_); - MotionTableManager::remove_redundant_links(this, arg4); // opportunistically collapse right after insert -} -``` - -### `initialize_state` — 0x0051c030 (@290875) -```c -void __thiscall MotionTableManager::initialize_state(class MotionTableManager* this, class CSequence* arg2) -{ - uint32_t outTicks = 0; - if (this->table != 0) - CMotionTable::SetDefaultState(this->table, &this->state, arg2, &outTicks); - // else outTicks stays 0 - - AnimNode* node = (AnimNode*)operator new(0x10); - if (node != 0) - { - node->dllist_next = 0; - node->dllist_prev = 0; - node->motion = 0x41000003; // sentinel motion id: "initial default state installed" marker - node->num_anims = outTicks; - } - DLListBase::InsertAfter(&this->pending_animations, node, this->pending_animations.tail_); - MotionTableManager::remove_redundant_links(this, arg2); -} -``` -`0x41000003` recurs as the sentinel motion id for "stop completely / reset to default" queue -entries — also used verbatim in `PerformMovement`'s `StopCompletely` and -`StopInterpretedCommand`-failure paths below. - -### `PerformMovement` — 0x0051c0b0 (@290906) — COMPLETE - -```c -uint32_t __thiscall MotionTableManager::PerformMovement(class MotionTableManager* this, - class MovementStruct const* arg2, class CSequence* arg3) -{ - if (this->table == 0) - return 7; // error code: no motion table loaded - - uint32_t outTicks = 0; - - switch (arg2->type) - { - case MovementTypes::InterpretedCommand: // 0x2 - if (CMotionTable::DoObjectMotion(this->table, arg2->motion, &this->state, arg3, - arg2->params->speed, &outTicks) != 0) - { - MotionTableManager::add_to_queue(this, arg2->motion, outTicks, arg3); - return 0; // success - } - // fall through to the trailing `return 0x43` below (BN control flow: no explicit - // else-branch for the failed-DoObjectMotion case within InterpretedCommand) - break; - - case 4: // MovementTypes::StopInterpretedCommand - if (CMotionTable::StopObjectMotion(this->table, arg2->motion, arg2->params->speed, - &this->state, arg3, &outTicks) != 0) - { - MotionTableManager::add_to_queue(this, 0x41000003, outTicks, arg3); // sentinel motion id on stop-success - return 0; - } - break; // falls through to 0x43 on failure, same as above - - case 5: // MovementTypes::StopCompletely - CMotionTable::StopObjectCompletely(this->table, &this->state, arg3, &outTicks); - MotionTableManager::add_to_queue(this, 0x41000003, outTicks, arg3); - return 0; - - default: // anything else (RawCommand, StopRawCommand, MoveToObject, MoveToPosition, - // TurnToObject, TurnToHeading, Invalid) is NOT handled by MotionTableManager at all - return arg3; // BN artifact: returns the CSequence pointer reinterpreted as uint32_t — - // effectively "not my job, caller's arg3 pointer value leaks out as the - // return code" for these unhandled types. (Likely dead/unreachable in - // practice since callers gate on type before reaching here, or this - // return value is never consulted for those types.) - } - - return 0x43; // shared failure code: DoObjectMotion / StopObjectMotion returned false -} -``` -Cleaned high-level flow: **`PerformMovement` is the single chokepoint between the wire-level -`MovementStruct` (interpreted command / stop / stop-completely) and the motion-table state -machine.** `InterpretedCommand` drives `DoObjectMotion` → `GetObjectSequence(...,arg7=0)`. -`StopInterpretedCommand` (type 4) drives `StopObjectMotion` → `StopSequenceMotion`. -`StopCompletely` (type 5) drives `StopObjectCompletely` (strip all modifiers + stop base cycle), -**unconditionally** queuing the `0x41000003` sentinel regardless of return value (unlike the -other two cases which only queue on success). Every successful path funnels the resulting -tick count into `add_to_queue`, which itself opportunistically calls -`remove_redundant_links` to collapse superseded queue entries. - ---- - -## 12. Where `MotionTableManager` is driven from `CPhysicsObj`/`CSequence` (call-site context, not full functions — for orientation only) - -``` -0x00517d1d @285801 MotionTableManager::initialize_state(motion_table_manager, &this->sequence) -0x00517d37 @285815 return MotionTableManager::AnimationDone(motion_table_manager, arg2) -0x00517d57 @285838 return MotionTableManager::CheckForCompletedMotions(motion_table_manager) -0x00517d67 @285852 return MotionTableManager::UseTime(motion_table_manager) -0x00517d7d @285863 MotionTableManager::HandleEnterWorld(motion_table_manager, &this->sequence) -0x00517d9d @285877 MotionTableManager::HandleExitWorld(motion_table_manager, edx) -0x00518722 @286744 class MotionTableManager* eax_2 = MotionTableManager::Create(ebx) -0x00518737 @286750 MotionTableManager::SetPhysicsObject(eax_2, this->owner) -0x005186ed @286753 else if (MotionTableManager::GetMotionTableID(motion_table_manager, &arg2)->id != ebx) -0x00518707 @286759 MotionTableManager::~MotionTableManager(motion_table_manager_1) -0x005187e9 @286793 return MotionTableManager::PerformMovement(motion_table_manager, &var_64, &this->sequence) -0x00518889 @286819 return MotionTableManager::PerformMovement(motion_table_manager, &var_64, &this->sequence) -0x00518917 @286843 return MotionTableManager::PerformMovement(motion_table_manager, &var_64, &this->sequence) -0x005192ad @287430 MotionTableManager::~MotionTableManager(motion_table_manager) -``` -These are all inside a `CPhysicsObj`-scoped wrapper (the surrounding function at ~0x00517xxx -is a `CPhysicsObj` member that owns `motion_table_manager` and `this->sequence`; three separate -call sites to `PerformMovement` at 0x005187e9/0x00518889/0x00518917 correspond to three distinct -public `CPhysicsObj` motion-request entry points that all funnel into the same -`PerformMovement(this, &localMovementStruct, &this->sequence)` pattern). Not expanded further — -out of scope for the motion-table layer itself per the task (these are `CPhysicsObj`, not -`CMotionTable`/`MotionTableManager`/free-function territory). - ---- - -## 13. `MotionState` member functions (state the motion table reads/writes — full support cast) - -### `MotionState::MotionState` (default ctor) — 0x00525fd0 (@302759) -```c -void __fastcall MotionState::MotionState(class MotionState* this) -{ - this->style = 0; - this->substate = 0; - this->substate_mod = 1f; - this->modifier_head = nullptr; - this->action_head = nullptr; - this->action_tail = nullptr; -} -``` - -### `MotionState::MotionState` (copy ctor) — 0x00526790 (@303432) — signature only (body not required by task scope; used by `re_modify`'s snapshot copy) -```c -void __thiscall MotionState::MotionState(class MotionState* this, class MotionState const* arg2) -``` - -### `MotionState::add_modifier_no_check` — 0x00525ff0 (@302772) -```c -void __thiscall MotionState::add_modifier_no_check(class MotionState* this, uint32_t arg2 /*motion*/, float arg3 /*speed_mod*/) -{ - MotionList* node = (MotionList*)operator new(0xc); // sizeof(MotionList) == 12: motion(4)+speed_mod(4)+next(4) - if (node != 0) - { - node->motion = 0; - node->speed_mod = 1f; - node->next = nullptr; - } - node->motion = arg2; - node->speed_mod = arg3; - node->next = this->modifier_head; // push-front onto modifier_head - this->modifier_head = node; -} -``` - -### `MotionState::add_modifier` — 0x00526340 (@303081) -```c -int32_t __thiscall MotionState::add_modifier(class MotionState* this, uint32_t arg2, float arg3) -{ - for (MotionList* i = this->modifier_head; i != 0; i = i->next) - if (i->motion == arg2) - return 0; // already present as a modifier -> caller must Stop-then-Add to "refresh" - - if (this->substate == arg2) - return 0; // already IS the current base substate -> refuse (no-op) - - MotionState::add_modifier_no_check(this, arg2, arg3); - return 1; -} -``` - -### `MotionState::remove_modifier` — 0x00526040 (@302794) -```c -void __thiscall MotionState::remove_modifier(class MotionState* this, class MotionList* arg2 /*node to remove*/, class MotionList* arg3 /*predecessor, or null if arg2 is head*/) -{ - if (arg3 != 0) - { - arg3->next = arg2->next; - operator delete(arg2); - return; - } - this->modifier_head = arg2->next; - operator delete(arg2); -} -``` - -### `MotionState::clear_modifiers` — 0x00526070 (@302810) -```c -void __fastcall MotionState::clear_modifiers(class MotionState* this) -{ - for (MotionList* i = this->modifier_head; i != 0; ) - { - MotionList* next = i->next; - operator delete(i); - i = next; - } - this->modifier_head = nullptr; -} -``` - -### `MotionState::add_action` — 0x005260a0 (@302828) -```c -void __thiscall MotionState::add_action(class MotionState* this, uint32_t arg2, float arg3) -{ - MotionList* node = (MotionList*)operator new(0xc); - if (node != 0) { node->motion=0; node->speed_mod=1f; node->next=nullptr; } - node->motion = arg2; - node->speed_mod = arg3; - node->next = nullptr; - - if (this->action_head == 0) - this->action_head = node; - - if (this->action_tail != 0) - this->action_tail->next = node; // append to tail (FIFO queue, unlike modifier's push-front stack) - - this->action_tail = node; -} -``` - -### `MotionState::clear_actions` — 0x005260f0 (@302859) -```c -void __fastcall MotionState::clear_actions(class MotionState* this) -{ - for (MotionList* i = this->action_head; i != 0; ) - { - MotionList* next = i->next; - operator delete(i); - i = next; - } - this->action_head = nullptr; - this->action_tail = nullptr; -} -``` - -### `MotionState::remove_action_head` — 0x00526120 (@302877) -```c -uint32_t __fastcall MotionState::remove_action_head(class MotionState* this) -{ - MotionList* head = this->action_head; - if (head == 0) return 0; - - MotionList* next = head->next; - uint32_t motion = head->motion; - this->action_head = next; - if (next == 0) - this->action_tail = next; // list now empty -> clear tail too - - operator delete(head); - return motion; // returns the popped action's motion id (caller mostly ignores it) -} -``` - -### `MotionState::~MotionState` — 0x00526330 (@303072) — signature only (frees both chains; body not dumped, trivially `clear_modifiers`+`clear_actions` equivalent based on ctor/field symmetry) -```c -void __fastcall MotionState::~MotionState(class MotionState* this) -``` - ---- - -## 14. Cross-reference: MotionList — one struct, two independent chains - -`MotionState` owns **two separate singly-linked `MotionList` chains** that never intermix: -- `modifier_head` — a **stack** (push-front via `add_modifier_no_check`, arbitrary removal via - `remove_modifier` given a node+predecessor pointer). Represents currently-active modifier - overlays (0x20000000-class motions). -- `action_head`/`action_tail` — a **FIFO queue** (append-tail via `add_action`, pop-front via - `remove_action_head`). Represents queued one-shot actions (0x10000000-class motions) waiting - for their turn to fire / complete. - -Both chains use the identical 12-byte `MotionList{motion, speed_mod, next}` node — only the -insertion/removal discipline differs, matching how the two motion classes behave semantically -(modifiers overlay/toggle in any order; actions execute in submission order). - ---- - -## 15. Constants summary (verbatim, all confirmed via grep against this dump) - -| Constant | Meaning | Source | -|---|---|---| -| `0x40000000` | cycle-class motion id bit | GetObjectSequence, StopSequenceMotion, remove_redundant_links | -| `0x20000000` | modifier-class motion id bit | GetObjectSequence, StopSequenceMotion, remove_redundant_links | -| `0x10000000` | action-class motion id bit | GetObjectSequence, AnimationDone, CheckForCompletedMotions | -| `0xb0000000` | combined cycle\|action\|(bit29 unset variant) block-mask used in remove_redundant_links's cycle-tail scan | remove_redundant_links | -| `0x70000000` | combined cycle\|action\|modifier block-mask used in remove_redundant_links's style-tail scan | remove_redundant_links | -| `0x41000003` | sentinel "stop-completely / default-state-installed" motion id queued to pending_animations | initialize_state, PerformMovement (StopInterpretedCommand success + StopCompletely) | -| `0.000199999995f` (~0.0002f) | epsilon for "speed is effectively zero" in change_cycle_speed | change_cycle_speed | -| `0xe` | DBObj qualified-type-tag for motion table assets | SetMotionTableID → DBObj::Get(QualifiedDataID(id, 0xe)) | -| `0x2c` (44 bytes) | sizeof(MotionTableManager) | Create's `operator new(0x2c)` | -| `0xc` (12 bytes) | sizeof(MotionList) | add_modifier_no_check / add_action's `operator new(0xc)` | -| `0x10` (16 bytes) | sizeof(MotionTableManager::AnimNode) | add_to_queue / initialize_state's `operator new(0x10)` | -| `0x14` (20 bytes) | AnimData element stride within MotionData::anims[] | add_motion's `ebx_1 += 0x14` | -| `0x2` (bit1) | MotionData.bitfield: "substate-gated" (is_allowed requires exact/default-substate match) | is_allowed | -| `0x1` (bit0) | MotionData.bitfield: "clear modifiers on entry" | GetObjectSequence (both cycle-class and style-change branches) | -| PerformMovement error codes | `7` = no table loaded; `0x43` = DoObjectMotion/StopObjectMotion returned failure; `0` = success | PerformMovement | -| `MovementTypes::Type` values | Invalid=0, RawCommand=1, InterpretedCommand=2, StopRawCommand=3, StopInterpretedCommand=4, StopCompletely=5, MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9 | acclient.h:2856 | - ---- - -## 16. Function inventory checklist (against the task's requested coverage) - -- [x] `CMotionTable::GetObjectSequence` 0x00522860 @298636 — COMPLETE, all 4 class branches + fast paths -- [x] `CMotionTable::get_link` 0x00522710 @298552 — COMPLETE -- [x] `CMotionTable::is_allowed` 0x005226c0 @298526 — COMPLETE -- [x] `CMotionTable::GetDefaultStyle`/`GetDefaultMotion` — **NOT PRESENT as named symbols** in this - PDB build (grepped `CMotionTable::` exhaustively — 22 members total, see full grep output - below). The task's "GetDefaultStyle/GetDefaultMotion" likely refer to the `style_defaults` - hash lookups (`LongNIValHash::lookup(&this->style_defaults, style, &out)`) - that appear inline throughout `GetObjectSequence`/`get_link`/`SetDefaultState`/`is_allowed` - — there is no separate named accessor method; it's always an inline hash lookup. -- [x] `CMotionTable::DoObjectMotion` 0x00523e90 @300045 — COMPLETE (thin wrapper) -- [x] `CMotionTable::StopObjectMotion` 0x00523ec0 @300053 — COMPLETE (thin wrapper) -- [x] `CMotionTable::StopSequenceMotion` 0x00522fc0 @298954 — COMPLETE -- [x] `CMotionTable::re_modify` 0x005222e0 @298300 — COMPLETE -- [x] `CMotionTable::StopObjectCompletely` 0x00523ed0 @300062 — COMPLETE (bonus, referenced by PerformMovement) -- [x] `CMotionTable::SetDefaultState` 0x005230a0 @299004 — COMPLETE (bonus, referenced by initialize_state) -- [x] `add_motion` 0x005224b0 @298437 — COMPLETE -- [x] `combine_motion` 0x00522580 @298472 — COMPLETE -- [x] `subtract_motion` 0x00522600 @298492 — COMPLETE -- [x] `change_cycle_speed` 0x00522290 @298276 — COMPLETE -- [x] `same_sign` 0x00522260 @298253 — COMPLETE (BN x87-flag noise cleaned) -- [x] `MotionTableManager` ctor/Create — COMPLETE (0x0051bc50 @290510) -- [x] `MotionTableManager::SetPhysicsObject` — COMPLETE (0x0051bbc0 @290464) -- [x] `MotionTableManager::PerformMovement` — COMPLETE, all 3 handled MovementTypes + default (0x0051c0b0 @290906) -- [x] `MotionTableManager::add_to_queue` 0x0051bfe0 @290854 — COMPLETE -- [x] `MotionTableManager::remove_redundant_links` 0x0051bf20 @290771 — COMPLETE -- [x] `MotionTableManager::truncate_animation_list` — COMPLETE (0x0051bca0 @290533) -- [x] `MotionTableManager::CheckForCompletedMotions` 0x0051be00 @290645(~290645) — COMPLETE -- [x] `MotionTableManager::AnimationDone` — COMPLETE (0x0051bce0 @290558) -- [x] `MotionTableManager::UseTime` — COMPLETE (0x0051bfd0 @290845) -- [x] `MotionTableManager::HandleEnterWorld`/`HandleExitWorld` — COMPLETE (0x0051bdd0/0x0051bda0) -- [x] `MotionTableManager::GetMotion_PersistentStates` — **NOT PRESENT** in this PDB build (confirmed - via exhaustive grep of `MotionTableManager::` — 16 total members, listed in section 11) -- [x] `MotionStruct`/state types (`MotionState`, `MotionList`, `MotionData`, `AnimNode`, - `MovementStruct`, `MovementTypes::Type`) — struct layouts + all reader/writer member - functions (section 13) COMPLETE -- [x] Grep of full `CMotionTable::` member list performed (22 total members: CMotionTable ctor, - scalar/vector deleting destructors, Allocate, re_modify, is_allowed, get_link, - GetObjectSequence, StopSequenceMotion, SetDefaultState, Pack, UnPack, Destroy, - DoObjectMotion, StopObjectMotion, StopObjectCompletely, ~CMotionTable — Pack/UnPack/Destroy - bodies not expanded here as out-of-scope serialization plumbing, not motion-selection logic) -- [x] Grep of full `MotionTableManager::` member list performed (16 total members, all covered above) diff --git a/docs/research/2026-07-02-r2-motiontable/r2-port-plan.md b/docs/research/2026-07-02-r2-motiontable/r2-port-plan.md deleted file mode 100644 index a7018e89..00000000 --- a/docs/research/2026-07-02-r2-motiontable/r2-port-plan.md +++ /dev/null @@ -1,299 +0,0 @@ -# R2 port work-list — GetObjectSequence + MotionTableManager - -Inputs: `r2-motiontable-decomp.md` (verbatim retail extraction, this scratchpad), -`r2-ace-motiontable.md` (ACE cross-ref, this scratchpad), plan of record -`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R2), R1 gap map -`docs/research/2026-07-02-r1-csequence/r1-gap-map.md`, R1 core -`src/AcDream.Core/Physics/Motion/{CSequence,AnimSequenceNode,FrameOps}.cs`, adapter -`src/AcDream.Core/Physics/AnimationSequencer.cs`, S2b sink -`src/AcDream.App/Rendering/RemoteMotionSink.cs`. - -**Precondition / state at R2 start:** R1 commits P0–P4 are landed (`1371c2a1`, -`778744bf`, `5138b8fb`, `658b91d8`); the P5 adapter cutover is IN the working tree -(AnimationSequencer already rehosted on `_core: CSequence`) but **uncommitted**, and -P6 (root-motion wiring + register sweep) is not started. **R2 depends on P5+P6 being -committed first.** Every CSequence primitive R2 needs already exists in the R1 core: -`AppendAnimation` (first_cyclic-slides, G10), `RemoveCyclicAnims`, -`RemoveLinkAnimations(count)`, `RemoveAllLinkAnimations`, `ClearPhysics`, -`Combine/SubtractPhysics`, `MultiplyCyclicAnimationFramerate` (framerates-only, G13), -`HasAnims`, and the `IAnimHookQueue.AddAnimDoneHook` seam (G5). - ---- - -## 0. Decomp ambiguities to pin BEFORE porting (the Q0 pseudocode commit) - -| # | Ambiguity | Evidence each way | Pin method | -|---|---|---|---| -| A1 | **`get_link` branch predicate is likely INVERTED in the BN extraction.** r2-motiontable-decomp §4 reads "both speed_mods negative → branch B (swap keys)". But under that reading the NORMAL forward case (both positive) takes branch A = outer hash keyed by **to**-substate, which contradicts (a) the dat Links layout the working adapter uses (outer key = `(style<<16)\|fromSubstate`, `AnimationSequencer.cs:989`), (b) ACE `MotionTable.cs:395-426` ("EITHER negative → reversed keys"), and (c) the call sites' arg roles (`get_link(style, currentSubstate, mod, targetSubstate, speed)`). If the predicate is "**either** negative → swapped-key branch", retail branch A == ACE's reverse branch and branch B == ACE's forward branch, and BOTH fallback blocks align exactly (either-neg fallback = style_defaults hop = ACE reverse fallback; both-pos fallback = `links[style<<16][to]` = ACE forward fallback). Same x87-flag-noise class as `same_sign` (§2) and the r1 gap-map hook-direction fix. | BN §4 vs ACE L395-426 + adapter GetLink (user-validated in the field: the reversed-key branch fixed the "left leg twitches" Ready→WalkBackward glitch) | Re-read the raw pseudo-C at @298552 for the flag test; if still ambiguous, cdb bp `acclient!CMotionTable::get_link` logging args + which hash bucket is probed during a Ready→WalkBackward start vs Ready→WalkForward start. Default resolution if cdb unavailable: ACE's reading (three independent corroborations). | -| A2 | **Branch-2 `signedSpeed` block** (no-double-hop leg): decomp cleans it as `(substate_mod==0 \|\| same_sign(substate_mod,arg5)) ? arg5 : -arg5` (§5 line ~550); ACE flips only one direction (`SubstateMod < 0 && speedMod > 0 → -speedMod`, MotionTable.cs:155-166). | BN x87 noise vs ACE asymmetry | Re-read raw @~0x00522bXX; conformance test both readings against a cdb golden of a Walk(-)→Walk(+) flip. | -| A3 | **outTicks field decode**: BN renders one term as `arg3->action_head` — actually MotionData's packed `num_anims` byte (decomp's own note, §5). All outTicks arithmetic = sum of each appended MotionData's `num_anims` (link chains) [+ base-cycle num_anims in Branch 1/2] − 1. | decomp note + ACE numAnims sums | Textual pin only; assert in tests: outTicks(walk→run direct) == link.num_anims + cycle.num_anims − 1. | -| A4 | **Adjudicate the 5 flagged ACE oddities** (r2-ace-motiontable "Flagged ACE-side oddities") against the decomp: (1) Action-branch numAnims double-count (ACE L227) — retail sums `outHop + actionLink [+ returnHop]` (never the base cycle, never one MotionData twice, §5 Branch 3) → **ACE bug, do not copy**; (2) `change_cycle_speed` silent no-op when old≈0 & new≠0 — retail §2 has the SAME structure → **port verbatim including the gap** (it's retail); (3) `GetLinkData` 0xFFFF mask — ACE-only helper, not ported; (4) `StopObjectCompletely` return = `finalStopOk ? 1 : anyModifierStopOk` (§9) — port verbatim; (5) `re_modify` snapshot double-removal — retail copy-ctor deep-copies the chains, the snapshot exists ONLY as the loop-termination bound (§6) → C# port: deep-copy MotionState, pop both, terminate on snapshot empty. | — | Documented adjudications in the pseudocode doc. | -| A5 | **DatReaderWriter `MotionData.Bitfield` availability** — `is_allowed` needs bit1 ("substate-gated") and Branches 1/2/4 need bit0 ("clear modifiers on entry"). `Bitfield` string is present in the DLL (`chorizite.datreaderwriter/1.0.0`); confirm it's on `Types.MotionData` (not just GfxObj etc.) at Q2 time. | DLL strings grep | One-line test against the Humanoid table (a known bit-2-gated cycle, e.g. TurnRight). | - -**Q0 cdb capture (one session serves all of R2):** bp -`CMotionTable::GetObjectSequence` / `get_link` / `StopSequenceMotion`, -`MotionTableManager::add_to_queue` / `remove_redundant_links` / -`truncate_animation_list` / `AnimationDone` / `CheckForCompletedMotions` with arg+ret -logging (pattern: `tools/cdb/l2g-observer.cdb`), user protocol: walk / run / shift-toggle -(fast path) / backward / turn-in-place / run-while-turning / sidestep / emote-while-running -/ attack / stop / stance change / rapid W-tapping (redundant-link collapse) / jump. - ---- - -## 1. ITEMIZED GAPS — current vs retail (R2 scope) - -Severity: **BLOCKER** = R2's conformance harness meaningless without it; **HIGH** = -visible animation wrongness / blocks R3; **MED** = edge-visible; **LOW** = textual. - -| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity | -|---|---|---|---|---| -| H1 | **No `CMotionTable::GetObjectSequence` — motion selection is a partial adapter hybrid.** Retail (0x00522860, decomp §5) is one dispatcher with 4 class branches (style `(int)id<0` / cycle `0x40000000` / action `0x10000000` / modifier `0x20000000`), plain-OR fallthrough, `is_allowed` gating, style-default double-hop link routing, modifier replay (`re_modify`), and outTicks. acdream's `SetCycle` implements only a subset of Branch 2 and its inventions (below) stand in for the rest. | decomp §5 @298636 | `AnimationSequencer.SetCycle` (:321-636), `PlayAction` (:791-888) | **BLOCKER** | -| H2 | **No `MotionState`** — no style/substate/substate_mod struct, no modifier stack (push-front `add_modifier_no_check` / dup-guarded `add_modifier` / `remove_modifier(node,prev)` / `clear_modifiers`), no action FIFO (`add_action` tail-append / `remove_action_head`). The adapter's `CurrentStyle/CurrentMotion/CurrentSpeedMod` is a 3-field flat approximation; modifiers/actions have no bookkeeping at all. | acclient.h:31081; decomp §13/§14 (all 9 members, 0x00525fd0-0x00526340) | `AnimationSequencer.cs` CurrentStyle/CurrentMotion/CurrentSpeedMod props; `RemoteMotionSink.cs:39-45` (per-UM axis duplicates) | **BLOCKER** | -| H3 | **No `MotionTableManager`** — no `pending_animations` DLList, no `animation_counter`, no `add_to_queue` (0x0051bfe0), no `remove_redundant_links` (0x0051bf20, 0xb0000000/0x70000000 block masks), no `truncate_animation_list` (0x0051bca0, zero-in-place + `remove_link_animations(ticks)`), no `AnimationDone` (0x0051bce0, counter-driven countdown-chain multi-pop + action-head pop + MotionDone), no `CheckForCompletedMotions`/`UseTime` (0x0051be00/0x0051bfd0, zero-tick sweep, success=1 hardcoded), no `initialize_state` (0x0051c030, `0x41000003` sentinel), no `HandleEnterWorld/HandleExitWorld` drains, no `PerformMovement` (0x0051c0b0, error codes 7/0x43/0). | decomp §11 (all 16 members) | nothing — the AnimationDoneSentinel the R1 core queues (CSequence.cs:413-418 → AnimationSequencer.cs:925) is consumed by NOBODY (gap map API table: "RemoteMotionSink/GameWindow don't consume AnimDone yet") | **BLOCKER** | -| H4 | **RemoteMotionSink's single-cycle pick** — axis collection (`_substate/_sidestep/_turn`), priority pick (fwd > sidestep > turn), `Commit()` one-SetCycle resolution. Retail: each funnel dispatch is its own `DoObjectMotion` → GetObjectSequence; turn/sidestep-while-moving resolve via `is_allowed` rejection of the (bitfield&2-gated) turn cycle → Branch 4 modifier combine (physics-only overlay), run cycle keeps playing; turn-in-place resolves via Branch 2 (cycle exists, allowed from the style default). Register row **AP-73** (retail-divergence-register.md:181). | decomp §3 (is_allowed) + §5 Branch 2/4 | `RemoteMotionSink.cs:141-216` (`Commit`), `:55-119` (`ApplyMotion` classify/collect) | **HIGH** — plan of record: "single-cycle pick DELETED — GetObjectSequence decides" | -| H5 | **HasCycle probe + Run→Walk→Ready missing-cycle fallback chain.** Existed because SetCycle clears the cyclic tail BEFORE knowing the cycle resolves ("torso on the ground"). Retail never has the problem: GetObjectSequence resolves ALL MotionData first, `clear_physics`/`remove_cyclic_anims` happen only inside a success path, missing cycles retry under `default_style` (Branch 2 second lookup) and otherwise `return 0` leaving the sequence untouched (PerformMovement → 0x43). | decomp §5 Branch 2 lines ~494-505 + return-0 tail | `RemoteMotionSink.cs:169-204`, `AnimationSequencer.HasCycle` (:281-295), GameWindow spawn fallbacks :3723-3825 | **HIGH** | -| H6 | **Fix B locomotion link-skip** (cyclic→cyclic transitions call `RemoveAllLinkAnimations` for the locomotion low-byte subset). Retail mechanism = `remove_redundant_links` on the PENDING QUEUE (tail-anchored backward scan for an earlier same-motion node; collapse via `truncate_animation_list` → zero ticks + `remove_link_animations(removedTicks)`; blocked by intervening non-zero 0xb0000000-class (cycle-tail scan) / 0x70000000-class (style-tail scan) nodes). The 2026-05-03 cdb trace in the Fix B comment block is the golden: cyclic→cyclic = `add_to_queue(45000005)` + `add_to_queue(44000007)`, truncate NOT firing — Fix B's outcome falls out of retail's structure (the link nodes the queue would truncate never get double-enqueued once GetObjectSequence owns link selection). | decomp §11 remove_redundant_links @290771 + truncate @290533 | `AnimationSequencer.cs:468-514` (Fix B block + `IsLocomotionCycleLowByte`) | **HIGH** — delete in favor of the queue mechanism | -| H7 | **Stop-anim fallback** (SetCycle low-byte remap WalkBackward→WalkForward etc. when linkData null). Retail: `adjust_motion` normalization happens UPSTREAM in CMotionInterp (already ported, D6.2a `0f099bb6`) so GetObjectSequence receives 0x05/0x0D/0x0F + signed speed; direction flips route via Branch 2's `link==null \|\| !same_sign` style-default double-hop (`get_link(...,styleDefault,1f)` + `get_link(styleDefault,1f,target,speed)`). | decomp §5 Branch 2 lines ~533-540 | `AnimationSequencer.cs:402-423` | **HIGH** | -| H8 | **Adapter fast path ≠ retail fast path.** Retail (§5 Branch 2): gate = `target==substate && same_sign(newSpeed, substate_mod) && has_anims()`, then `change_cycle_speed` (ratio, 0.0002f epsilon guards) + `subtract_motion(old)` + `combine_motion(new)` + commit substate_mod. acdream: gate keyed on Current* fields with its own 1e-4/1e-6 epsilons, and `MultiplyCyclicFramerate` folds a velocity/omega rescale composite in (the G13 stand-in, `AnimationSequencer.cs:673-686`). | decomp §5 lines ~513-522, §2 change_cycle_speed @298276 | `AnimationSequencer.cs:345-388`, `:673-686` | **HIGH** — G13/G17 retire here | -| H9 | **`add_motion` velocity gate (G17 adapter half).** Core `EnqueueMotionData` still gates `SetVelocity/SetOmega` on `MotionDataFlags.HasVelocity/HasOmega`; retail `add_motion` (0x005224b0) sets unconditionally (dat-silent MotionData carries zero → replace-with-zero), safe once modifiers route through `combine_motion` (Branch 4) instead of `add_motion`. | decomp §2 @298437 | `AnimationSequencer.cs:1044-1058` (documented "R2 stand-in") + `PlayAction:836-839,872-875` | MED — mechanical once Branch 4 exists | -| H10 | **PlayAction inventions**: (a) actions resolved via `GetLink` direct only — no default-substate out-and-back 4-layer chain (outHop@1.0 → actionLink@speed → returnHop@1.0 → base cycle@old substate_mod); (b) no `add_action` FIFO bookkeeping (so no completion pop, H3); (c) no `clear_physics`/`remove_cyclic_anims` rebuild — nodes are INSERTED before the cyclic tail + an invented cursor-jump (`AnimationSequencer.cs:877-887`); retail REBUILDS (base cycle restarts after the action); (d) modifier-class ids ENQUEUE ANIMS from the Modifiers dict — retail Branch 4 is **physics-only** (`combine_motion` velocity/omega; anims untouched) + `add_modifier` bookkeeping with the stop-then-re-add toggle. | decomp §5 Branch 3 (~591-647) / Branch 4 (~652-687) | `AnimationSequencer.PlayAction` :791-888 | **HIGH** | -| H11 | **No stop machinery**: `StopSequenceMotion` (0x00522fc0: cycle-stop = re-drive GetObjectSequence toward the style default with arg7=1; modifier-stop = `subtract_motion` + unlink), `StopObjectCompletely` (strip all modifiers then stop the substate), `SetDefaultState` (0x005230a0: full baseline reset, `clear_animations` not remove_cyclic). "Stop" today is whoever calls SetCycle(Ready 0x41000003). | decomp §7/§8/§9 | funnel `StopMotion` → `RemoteMotionSink.cs:121-131` (ObservedOmega zero only); GameWindow SetCycle(Ready) sites | **BLOCKER** for stop conformance | -| H12 | **The GetObjectSequence guard set**: entry guards (`style==0 \|\| substate==0` → 0), the modifier-class no-op fast path (`target==styleDefault && !stopCall && (substate & 0x20000000)` → 1), Branch 2's "leaving a modifier-class substate re-registers it as a modifier" (`add_modifier_no_check`) — none exist anywhere in acdream. | decomp §5 lines ~384-404, ~565-572 | — | HIGH (part of H1, called out because each is an easy silent omission) | -| H13 | **`re_modify` modifier replay across transitions** — every substate/style-changing branch replays the active modifier stack through recursive GetObjectSequence calls. This is DEV-9 / AP-73's "retail BLENDS modifiers over the substate cycle". | decomp §6 @298300 | — | **HIGH** — the plan of record's "(modifier blend — retires AP-73)" | -| H14 | **Style-change transitions absent** (Branch 1): stance switches never play the exit-link + style-to-style link + double-hop-via-default_style chain; RemoteMotionSink just stores `_style` and SetCycle keys the cycle dict with it. | decomp §5 Branch 1 (~411-487) | `RemoteMotionSink.cs:57-61`; `SetCycle` (style used only as key material) | MED-HIGH (visible on combat-stance changes) | -| H15 | **Spawn baseline**: retail `initialize_state` → `SetDefaultState` + queue `0x41000003` sentinel; enter/exit-world drain the queue (`AnimationDone(0)` loop; enter also `remove_all_link_animations`). acdream: RenderBootstrap 3-tier fallback + GameWindow SetCycle(Ready)/HasCycle chains. | decomp §11 initialize_state/HandleEnterWorld/HandleExitWorld | GameWindow :3723-3825; `RenderBootstrap.SequencerFactory` | MED | -| H16 | **MotionDone signal dead-ends.** Retail chain: CSequence AnimDone gate → AnimDoneHook singleton → `Hook_AnimDone` → `CPartArray::AnimationDone(1)` → `MotionTableManager::AnimationDone` → countdown pop → `CPhysicsObj::MotionDone(motion, success)` → (R3) CMotionInterp pending_motions. acdream stops at `AnimationDoneSentinel` in `_pendingHooks`; nothing counts it. | decomp §11 AnimationDone @290558 + gap map G5 | `AnimationSequencer.cs:918-931`; GameWindow :9882 drain ignores the sentinel type | **HIGH** — R2's named deliverable; see §4 below | -| H17 | **ObservedOmega side-write** — the sink seeds `RemoteMotion.ObservedOmega` from wire turns (`RemoteMotionSink.cs:95-101`) so remote rotation starts same-tick. Retail: turn omega enters the sequence via Branch 2 add_motion (turn cycle omega + adapter synthesis) or Branch 4 combine_motion, and body rotation comes from `CSequence.omega` through apply_physics per tick (R6 tick-order territory). | decomp §2 combine_motion; plan R6 | `RemoteMotionSink.cs:95-101`, `:127` | MED — carry the side-write into the replacement sink verbatim (register row), retire in R6 | - ---- - -## 2. KEEP LIST — already matching retail (do not re-port) - -| Behavior | Retail anchor | acdream anchor | -|---|---|---| -| `get_link` two-branch sign-aware lookup + both fallbacks (pending A1 pin, which almost certainly CONFIRMS it) | 0x00522710 §4 (predicate per A1) | `AnimationSequencer.GetLink` :961-1005 — re-home into CMotionTable verbatim, do not rewrite | -| Cycle/link/modifier hash keying incl. 32-bit `<<16` truncation of full command words (`(0x8000003D<<16)==0x003D0000`) | §5 key math throughout | `SetCycle`/`HasCycle`/`PlayAction` key builds (`:426,293,824`) — carry the full-command-word convention into CMotionTable | -| `AnimData` speed scaling: framerate only (`AnimData::operator*`) | 0x00525d00 (r1 §25) | `BuildNode` :1014-1024 / `EnqueueMotionData` append path | -| `append_animation` first_cyclic-slides-to-tail + curr_anim seed (the structural base every add_motion depends on) | 0x00525510 (r1 §24) | `CSequence.AppendAnimation` (Motion/CSequence.cs:109-123) | -| Full remove-family + apricot + combine/subtract_physics + multiply_cyclic_animation_fr (framerates-only) | r1 §5-§14 | `CSequence` :163-253 — R2 calls these, zero changes | -| AnimDone LIST-STRUCTURE gate (head != first_cyclic) + IAnimHookQueue seam | 0x00525943 (r1 G5) | `CSequence.UpdateInternal` :407-422 — R2 only adds the CONSUMER | -| adjust_motion normalization upstream of dispatch (left→right / backward→forward + sign) | CMotionInterp (D6.2a port) | `MotionInterpreter` normalization (commit `0f099bb6`) — stays upstream; Q4 verifies single-site and deletes the SetCycle-head duplicate | -| Inbound funnel: `MoveToInterpretedState` / `ApplyInterpretedMovement` / `DispatchInterpretedMotion` / `contact_allows_move` + 183-case conformance suite | S2a (7b0cbbda) | `MotionInterpreter.cs:1312-1420` — R2 sits BELOW it (replaces only the sink) | -| `MotionSequenceGate` (S1), `InterpolationManager` (L.3), outbound packers | plan "absorbed" list | untouched | -| K-fix18 `skipTransitionLink` instant-engage (jump/Falling) | none — invented; retires in **R3** jump family | `AnimationSequencer.cs:321,398,441-444`; GameWindow :4817-4831, :10224 — SURVIVES R2 at adapter, byte-identical, register row kept | -| Locomotion velocity synthesis (Walk 3.12 / Run 4.0 / Side 1.25 m·s⁻¹) + turn omega synthesis (π/2 rad·s⁻¹) | retail `get_state_velocity` (R3 scope) | `SetCycle` :539-635 — SURVIVES R2 (runs after PerformMovement), register rows kept | -| Retail slerp + BuildBlendedFrame render blend | FUN_005360d0 | `SlerpRetailClient` / `BuildBlendedFrame` — untouched | -| `0x41000003` == full-word MotionCommand.Ready == retail's stop/default sentinel | decomp §15 | adapter already uses 0x41000003 as the Ready id | - ---- - -## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first - -New code target: `src/AcDream.Core/Physics/Motion/` (plan rule 4). Tests: -`tests/AcDream.Core.Tests/Physics/Motion/` (pattern: R1's -`AnimSequenceNodeTests`/`CSequenceTests`). Every commit: build+test green, register -rows added/retired in-commit. - -**Q0 — pseudocode + ambiguity pinning (docs only).** -`docs/research/2026-07-0x-motiontable-pseudocode.md` from r2-motiontable-decomp.md, -resolving A1–A5 (§0 above) and adjudicating the 5 ACE oddities (A4). Run the ONE cdb -capture session (protocol in §0) — it feeds Q2/Q3/Q4 goldens. -Fixture source: **cdb** (live retail, l2g-observer.cdb pattern). -Deps: none (R1-P5/P6 committed is a precondition for Q4+, not Q0). - -**Q1 — `MotionState` verbatim.** (closes H2) -`Motion/MotionState.cs`: `Style/Substate/SubstateMod(=1f)` + modifier STACK -(`AddModifierNoCheck` push-front 0x00525ff0; `AddModifier` dup-guard + `substate==id` -refuse 0x00526340; `RemoveModifier(node, prev)` 0x00526040; `ClearModifiers` -0x00526070) + action FIFO (`AddAction` tail-append 0x005260a0; `RemoveActionHead` -0x00526120; `ClearActions` 0x005260f0) + deep-copy ctor (A4-#5: chains copied, not -shared — re_modify's snapshot is a termination bound). -Tests first: stack-vs-FIFO discipline tables; AddModifier rejection (already-present / -equals-substate); copy independence (mutate original, snapshot unchanged). -Fixture source: **synthetic**. -Deps: Q0. - -**Q2 — `CMotionTable` verbatim (pure selection logic, no queue).** (closes H1, H5-resolve-side, H7-routing, H8-core, H10-core, H11, H12, H13, H14; A1/A2/A5 land here) -`Motion/CMotionTable.cs` wrapping the DatReaderWriter `MotionTable` DBObj -(style_defaults/cycles/modifiers/links/default_style; keys = `(style<<16)|(id&0xFFFFFF)` -full-command-word convention). Free functions in the same file (retail free fns): -`add_motion` 0x005224b0 (**unconditional** SetVelocity/SetOmega — G17 core — + -AppendAnimation per speed-scaled AnimData), `combine_motion` 0x00522580 / -`subtract_motion` 0x00522600 (CombinePhysics/SubtractPhysics only — never anims), -`change_cycle_speed` 0x00522290 (0.0002f epsilons, verbatim incl. the A4-#2 gap), -`same_sign` 0x00522260. Members: `get_link` 0x00522710 (per A1 pin — expected: the -adapter's existing port re-homed), `is_allowed` 0x005226c0 (`Bitfield & 2` gate), -`GetObjectSequence` 0x00522860 (ALL of: entry guards; modifier-class no-op fast path; -Branch 1 style-change with exit-link + direct link + default_style double-hop + commit -+ re_modify + outTicks; Branch 2 with default_style cycle retry, is_allowed, re-speed -fast path (change_cycle_speed + subtract + combine), clear-modifiers bit0, direct-link -vs `!same_sign` double-hop, A2 signedSpeed, outgoing-modifier-substate re-registration, -re_modify, outTicks; Branch 3 action direct + 4-layer out-and-back, add_action, -outTicks; Branch 4 modifier physics-only combine + stop-then-re-add toggle), -`re_modify` 0x005222e0, `StopSequenceMotion` 0x00522fc0, `SetDefaultState` 0x005230a0 -(`clear_animations` hard reset), `DoObjectMotion`/`StopObjectMotion`/ -`StopObjectCompletely` 0x00523e90/ec0/ed0 (A4-#4 return semantics). -Tests first — the R2 conformance harness core: -(a) **dat fixtures** (Humanoid MotionTable via DatCollection, R1-P1 pattern): -Ready→Walk link+cycle chain shape; walk↔run re-speed fast path (framerates rescaled, -velocity = subtract-old+combine-new, NO list change); Walk→WalkBackward-normalized -(0x05, −speed) sign-flip → style-default double-hop; stance change → Branch 1 chain; -emote-while-running → 4-layer out-and-back with base cycle re-added at OLD -substate_mod; turn-in-place → Branch 2 cycle; **run-while-turning → is_allowed rejects -the gated turn cycle → Branch 4 physics-only combine, run anims untouched** (the AP-73 -mechanism test); modifier stop = subtract + unlink; StopObjectCompletely drains -modifiers then re-drives to style default; missing cycle → return 0, sequence -UNTOUCHED (H5); outTicks values per A3. -(b) **cdb goldens from Q0**: GetObjectSequence arg/ret + resolved-MotionData-key -conformance for the captured protocol. -Deps: Q1 (MotionState), R1 core. - -**Q3 — `MotionTableManager` + pending_animations.** (closes H3, H15-core) -`Motion/MotionTableManager.cs` + `AnimNode {Motion, NumAnims}`. Fields per `Create` -0x0051bc50: table, state, animation_counter, pending queue (LinkedList; -register row: managed list vs intrusive DLList — reuse the R1 AD-34 wording), plus an -`IMotionDoneSink` seam (stands in for `CPhysicsObj::MotionDone`; see §4). Methods: -`add_to_queue` 0x0051bfe0 (append + immediate `remove_redundant_links`), -`remove_redundant_links` 0x0051bf20 — **port retail's tail-anchored single scan** -(skip trailing zero-tick nodes; cycle-class-not-modifier tail: match earlier same-motion -non-zero node, blocked by intervening non-zero `0xb0000000`-class; style-class -(`(int)motion<0`) tail: exact match, blocked by non-zero `0x70000000`-class), NOT -ACE's restructured outer loop; `truncate_animation_list` 0x0051bca0 (zero `NumAnims` -in place — nodes stay queued — + `CSequence.RemoveLinkAnimations(removedTicks)`); -`AnimationDone(success)` 0x0051bce0 (counter += 1; pop every head with -`NumAnims <= counter`, action-class → `state.RemoveActionHead()`, fire -`sink.MotionDone(motion, success)`, counter −= NumAnims; drained-list counter reset); -`CheckForCompletedMotions` 0x0051be00 (zero-tick heads only, success=1, no counter -touch) + `UseTime` alias; `initialize_state` 0x0051c030 (SetDefaultState + queue -`0x41000003`/outTicks + redundancy pass); `HandleEnterWorld` (remove_all_link_animations -+ drain via AnimationDone(0)) / `HandleExitWorld` (drain only); `PerformMovement` -0x0051c0b0 (InterpretedCommand → DoObjectMotion → add_to_queue(motion, outTicks); -StopInterpretedCommand → StopObjectMotion → add_to_queue(**0x41000003**, outTicks) on -success; StopCompletely → StopObjectCompletely + **unconditional** -add_to_queue(0x41000003); error codes 7 / 0x43 / 0; other MovementTypes untouched). -Tests first: countdown-chain tables (one AnimationDone completing MULTIPLE entries via -counter rollover; leftover-counter reset on drain); truncate blocked/allowed matrices -for both masks; zero-tick sweep vs counter sweep distinction; enter/exit-world drains -fire MotionDone(success=0) for every queued motion; rapid same-motion re-issue → -collapse (the Fix B replacement proof); the Q0 golden: cyclic→cyclic walk→run yields -`add_to_queue(0x45000005)` + `add_to_queue(0x44000007)` with truncate NOT firing -(2026-05-03 trace, quoted in the Fix B comment block). -Fixture source: **synthetic** + **cdb goldens from Q0**. -Deps: Q2. - -**Q4 — adapter cutover: SetCycle/PlayAction rehosted on PerformMovement; DELETE Fix B + stop-anim fallback + fast-path composite + G17 gate; wire the queue drain.** (closes H6, H7, H8, H9, H10-adapter, H16-wiring; H12 guards live via Q2) -`AnimationSequencer` gains a `MotionTableManager` (constructed with the same -DatReaderWriter table + the CSequence core). `SetCycle(style, motion, speedMod, -skipTransitionLink)` becomes: dispatch style-class id then motion through -`PerformMovement(InterpretedCommand)` (MotionState now OWNS style/substate/ -substate_mod; `CurrentStyle/CurrentMotion/CurrentSpeedMod` become read-only mirrors — -GameWindow :3723/4827/4915/4919 + sink ctor keep compiling). `PlayAction` → the same -dispatch (action/modifier ids hit Branch 3/4). K-fix18 preserved byte-identical at -adapter: `skipTransitionLink` → post-dispatch `ClearAnimations`-of-links exactly as -today (register row survives → R3). Velocity/omega synthesis blocks run AFTER dispatch, -unchanged (→ R3). **DELETE:** the SetCycle-head adjust_motion duplicate (verify every -caller pre-normalizes via MotionInterpreter; if any GameWindow raw call site doesn't, -normalize at the adapter boundary ONCE and note it), the adapter fast-path block -(:345-388), the stop-anim low-byte fallback (:402-423), Fix B + -`IsLocomotionCycleLowByte` (:468-514), `MultiplyCyclicFramerate`'s velocity-rescale -composite (:681-686 — change_cycle_speed+subtract/combine are now real; G13 row -retired), `EnqueueMotionData`'s HasVelocity/HasOmega gate (:1055-1058 — add_motion -unconditional; G17 row retired), PlayAction's insert-before-tail + cursor-jump + -modifier-anim-enqueue (:836-887). **Queue drain wiring (same commit — the queue must -not grow unbounded):** GameWindow anim tick (:9876-9890) counts drained -`AnimationDoneSentinel` instances → `manager.AnimationDone(true)` per sentinel, and -calls `manager.UseTime()` once per tick (zero-tick completions: stop-with-no-link, -fast-path re-speed outTicks=0). `IMotionDoneSink` bound to a diagnostic recorder -(ACDREAM_DUMP_MOTION line) — consumed for real in R3 (register row: MotionDone -observed-not-consumed, retire R3). -Tests first: FULL existing suite green (parity bar) + pre-cutover recorded -SetCycle-sequence traces (captured BEFORE this commit) replayed → same selected -cycle/link identities + same hook stream, with EXPECTED-DIFF annotations documented -per case (known intentional changes: action overlays now rebuild → base cycle restarts; -direction flips route the double-hop) + #61 boundary-flash re-check under the new link -path. Empty-MotionTable tier: PerformMovement returns 7, sequencer stays do-nothing -(RenderBootstrap invariant, r1 API table). -Fixture source: **pre-cutover recorded adapter traces (synthetic harness)** + suite. -Deps: Q2+Q3 (+ R1-P5/P6 committed). - -**Q5 — RemoteMotionSink DELETED; funnel dispatches straight into PerformMovement; spawn/world lifecycle; AP-73 retired.** (closes H4, H5-callers, H11-callers, H15, H17-carry) -Replace `RemoteMotionSink` with a thin Core `IInterpretedMotionSink` implementation -(`Motion/MotionTableDispatchSink.cs` or direct on the entity's manager): -`ApplyMotion(motion, speed)` → `PerformMovement(InterpretedCommand{motion, speed})`; -`StopMotion(motion)` → `PerformMovement(StopInterpretedCommand{motion, 1f})`. No axis -collection, no priority pick, no Commit, no HasCycle probe, no Run→Walk→Ready chain — -GetObjectSequence + is_allowed decide (H4/H5). The ObservedOmega turn seed (H17) moves -verbatim into the new sink (register row updated: retire in R6 when apply_physics -drives remote rotation). GameWindow: `RemoteMotionSink` ctor sites (:4643-4646) swap -to the new sink; spawn/fallback SetCycle(Ready)/HasCycle chains (:3723-3825) → -`manager.initialize_state`; teleport/despawn/enter-world → HandleEnterWorld/ -HandleExitWorld. Delete `AnimationSequencer.HasCycle` if caller-free after this (else -keep as diagnostic, note it). -Tests first: S2a 183-case funnel conformance suite green with sink assertions -re-targeted (dispatch order is funnel-owned and unchanged; assertions move from -"collected axes" to "PerformMovement call sequence"); MotionInterpreterFunnelTests -green; live-protocol smoke via ACDREAM_DUMP_MOTION + ACDREAM_REMOTE_VEL_DIAG -(walk/run/toggle/backward/turn/circle/sidestep/emote/attack/stop, player+NPC+monster); -**ONE user visual pass** (walk↔run stride continuity, turn-while-running legs, emote -overlay, stop settle — the stage acceptance). -Registers: **AP-73 DELETED in this commit**; rows added for ObservedOmega side-write -(if not already covered) and any adapter boundary normalization from Q4. -Deps: Q4. - -**Q6 — register sweep + roadmap + digest (docs/cleanup only).** -Grep-sweep dead code (IsLocomotionCycleLowByte remnants, HasCycle callers, fallback -chains, SCFAST/SCFULL diag re-anchoring); reconcile every touched register row -(retired: AP-73, Fix B, stop-anim fallback, G13, G17; surviving with rows: K-fix18→R3, -velocity/omega synthesis→R3, ObservedOmega seed→R6, MotionDone-unconsumed→R3, -managed-LinkedList-vs-DLList); roadmap stage table (R2 shipped); memory digest note -(animation sequencer deep-dive cross-link: pending_animations gap CLOSED). -Deps: Q5. - -Parallelization note: Q1 and the Q0 cdb capture are independent; Q2/Q3 are -sequential-coupled (Q3 calls Q2); Q4/Q5 each touch GameWindow — do NOT fan out -(feedback_dont_parallelize_coupled_plan_slices). - ---- - -## 4. MotionDone → S2-funnel pending_motions (the R3 boundary contract) - -What R2 ships and where R3 plugs in: - -``` -CSequence.UpdateInternal [R1, shipped] - └─ G5 gate (head != first_cyclic) → IAnimHookQueue.AddAnimDoneHook - └─ AdapterHookQueue → AnimationDoneSentinel into _pendingHooks [R1-P5] -GameWindow anim tick (:9876-9890) [R2-Q4 wiring] - ├─ per drained AnimationDoneSentinel → manager.AnimationDone(success: true) - │ [retail: AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone 0x0050fda0 - │ → CPartArray::AnimationDone(1) — one call per queued hook] - └─ once per tick → manager.UseTime() ≡ CheckForCompletedMotions() - [retail call sites 0x00517d57/0x00517d67; drains zero-tick entries: - stops-without-links, fast-path re-speeds (outTicks=0), truncated nodes] -MotionTableManager.AnimationDone / CheckForCompletedMotions [R2-Q3] - ├─ action-class head (0x10000000) → MotionState.RemoveActionHead() [R2-OWNED — - │ the action FIFO pop lives HERE, not in R3] - └─ IMotionDoneSink.MotionDone(uint motion, bool success) [R2 seam] - R2: diagnostic recorder only (register row: unconsumed until R3) - R3: MotionInterpreter.MotionDone — pops CMotionInterp.pending_motions, - action-class → RemoveAction from raw+interpreted state, recomputes - IsAnimating (ACE MotionInterp.cs:210-231; MovementManager.MotionDone - relay). The funnel's own note at MotionInterpreter.cs:1395 marks the - attachment point. -``` - -Two structural facts R3 must respect (from r2-ace-motiontable's headline finding, -decomp-confirmed): **MotionTableManager is UPSTREAM of CMotionInterp in the -completion chain, never a peer** — retail keeps TWO pending trackers -(`MotionTableManager.pending_animations` under CPartArray vs -`CMotionInterp.pending_motions` under MovementManager) and `CPhysicsObj::MotionDone` -feeds only the interp side; do not merge the queues. And the per-tick PLACEMENT of -both the sentinel drain and UseTime is provisional until **R6** installs retail's -`UpdateObjectInternal` order (process_hooks LAST; MovementManager.UseTime/ -CPartArray.HandleMovement mid-tick) — R2 documents the current GameWindow drain point -as the G6 seam, unchanged. - -Success-flag semantics to preserve: AnimationDone passes the CALLER's flag (true from -Hook_AnimDone; false from enter/exit-world drains); CheckForCompletedMotions hardcodes -true. R3's jump/HitGround logic keys off this flag — getting it wrong is invisible -until R3. diff --git a/docs/research/2026-07-02-r3-motioninterp/W0-pins.md b/docs/research/2026-07-02-r3-motioninterp/W0-pins.md deleted file mode 100644 index 607f1bc6..00000000 --- a/docs/research/2026-07-02-r3-motioninterp/W0-pins.md +++ /dev/null @@ -1,277 +0,0 @@ -# R3-W0 — ambiguity pins and ACE-oddity adjudications - -The verbatim extraction is `r3-motioninterp-decomp.md` (line-anchored); the port -plan with the full ambiguity table is `r3-port-plan.md` §0. This note records the -PINNED resolutions the W1–W6 ports code against. Every pin below was produced by an -independent raw re-read of `docs/research/named-retail/acclient_2013_pseudo_c.txt` -(line numbers cited are that file's) and A1/A3 additionally survived an adversarial -refutation pass (independent Ghidra MCP decompiles + vtable-dump slot resolution). -**No pin was refuted; none blocks on cdb.** - -## Pinned - -- **A1 — `motion_allows_jump` (0x005279e0) polarity: PINNED as a BLOCKLIST. - `0` = jump allowed (pass), `0x48` = jump BLOCKED** (a - "YouCantJumpFromThisPosition"-class error code). The extraction doc's original - §3a "whitelist / 0x48 = allowed" note was inverted (now corrected in-place, same - commit). Confidence: **high** (adversarially verified; refutation failed). - - Definitive blocklist (port as **literal uint ranges**, NOT enum-ordinal ranges): - - | Motion id (literal uint) | MotionCommand names | Returns | - |---|---|---| - | `[0x1000006f, 0x10000078]` | MagicPowerUp01..MagicPowerUp10 | `0x48` BLOCK | - | `[0x10000128, 0x10000131]` | TripleThrustLow..MagicPowerUp07Purple | `0x48` BLOCK | - | `0x40000008` (exact) | **Fallen** | `0x48` BLOCK | - | `[0x40000016, 0x40000018]` | Reload, Unload, Pickup | `0x48` BLOCK | - | `[0x4000001e, 0x40000039]` | AimLevel..MagicPray | `0x48` BLOCK | - | `[0x41000012, 0x41000014]` | Crouch, Sitting, Sleeping | `0x48` BLOCK | - | everything else — incl. **Falling `0x40000015`**, Ready `0x41000003`, Dead `0x40000011`, all turn/sidestep ids (`0x6500000d` TurnRight etc.) | — | `0` PASS | - - Evidence (all boundaries re-derived from the verbatim body, raw 304908–304931): - `005279e9 if (arg2 > 0x40000018) { 00527a24 if (arg2 > 0x41000014) return 0; - 00527a39 if ((arg2 < 0x41000012 && (arg2 < 0x4000001e || arg2 > 0x40000039))) - return 0; } 005279e9 else if (arg2 < 0x40000016) { 005279f7 if (arg2 > 0x10000131) - { 00527a18 if (arg2 != 0x40000008) 00527a1c return 0; } 005279f7 else if ((arg2 < - 0x10000128 && (arg2 < 0x1000006f || arg2 > 0x10000078))) 00527a10 return 0; } - 00527a40 return 0x48;` — the middle band `[0x40000016..0x40000018]` satisfies - neither outer branch and falls straight to `return 0x48`. Polarity sealed by four - independent callers plus the top of the chain: - 1. `jump_is_allowed` 0x005282b0 (raw 305539–305542): `eax_7 = - motion_allows_jump(this, this->interpreted_state.forward_command); if (eax_7 - != 0) return eax_7;` — nonzero returned verbatim as the failure code; all - success paths return `eax_7 == 0`. - 2. `DoInterpretedMotion` 0x00528360 (raw 305597–305607): params bit `0x20000` - (disable_jump_during_link) FORCES `eax_5 = 0x48` — a jump-*disable* bit - forcing the value only makes sense if `0x48` = block; `eax_5` is stashed as - the queue node's `jump_error_code` (add_to_queue arg4 → node +0xc, raw - 305044), which `jump_is_allowed`'s head peek returns as an error when nonzero. - 3. `CMotionInterp::jump` 0x00528780 (raw 305801–305812): `result = - jump_is_allowed(...); if (result != 0) { standing_longjump = 0; return - result; }` — the jump only executes on the zero path. - 4. Sibling convention: `jump_charge_is_allowed` (raw 304940–304948) and - `charge_jump` (raw 305453–305466) both `return 0` on pass / `0x48`/`0x49` on - block; `charge_jump`'s success path arms `standing_longjump` then `return 0`. - - **Falling vs Fallen: retail blocks Fallen (0x40000008) and PASSES Falling - (0x40000015). ACE mis-transcribed** — ACE MotionInterp.cs:770-779's exact-id - term is `substate == (uint)MotionCommand.Falling` and Fallen appears nowhere in - its motion_allows_jump: the exact inverse of retail on those two ids. ACE's own - jump_charge_is_allowed (MotionInterp.cs:736) uses Fallen, matching retail's - 0x40000008 in both charge gates — the Falling term is a one-off ACE transposition - slip, not a deliberate change. Falling still can't yield mid-air jumps in retail: - `jump_is_allowed`'s airborne gate (raw 305559–305570) independently returns - `0x24` unless transient_state has Contact(1)+OnWalkable(2). **Port: block Fallen, - pass Falling.** - -- **A2 — `jump_is_allowed` pending-head peek: PINNED — the peek fires whenever the - queue is non-empty; there is NO `Count > 1` gate.** Raw 305524–305556 - (0x005282b0): `if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0) - return 0x47;` THEN `class LListData* head_ = this->pending_motions.head_; ... - if (head_ != 0) eax_6 = *(int32_t*)((char*)head_ + 0xc); if ((head_ == 0 || - eax_6 == 0)) { eax_6 = jump_charge_is_allowed(this); ... } return eax_6;` — if - the head exists AND its `jump_error_code` (+0xc) is nonzero, the whole - charge/motion/stamina chain is skipped and the peeked code returned. Ordering - note (both decompilers agree): **IsFullyConstrained (→0x47) is checked BEFORE - the peek** — port in that order. ACE L752-753's `PendingMotions.Count > 1 &&` - has no retail counterpart; do not copy. ACE L746's `WeenieObj == null && - !WeenieObj.IsCreature()` NPE-typo also confirmed wrong — retail (raw - 305517–305520) is `if (weenie_obj != 0) eax_2 = weenie_obj->vtable->IsCreature(); - if ((weenie_obj != 0 && eax_2 == 0))` → non-creature-with-weenie skips the ground - check. Confidence: **high**. - -- **A3 — dual-dispatch gate: PINNED to `IsThePlayer` (vtable slot +0x14), NOT - `IsCreature` (+0x2c), in all four functions.** Confidence: **high** - (adversarially verified: independent Ghidra decompile shows raw offset +0x14 at - all four sites vs +0x2c in HitGround; the ACCWeenieObject vftable dump at - 0x007e3ea0, raw 1035101–1035135, binds +0x14 → `ACCWeenieObject::IsThePlayer` - 0x0058C3D0 and +0x2c → `IsCreature` 0x0058C3F0). - - | Function | Address @ raw line | Weenie tested | Predicate → raw path | Dispatch args | Entry gate | - |---|---|---|---|---|---| - | `apply_current_movement` | 0x00528870 @305838 | `this->weenie_obj` | `(weenie==0 \|\| IsThePlayer()) && movement_is_autonomous()` | `(arg2, arg3)` passthrough | `physics_obj != 0 && initted != 0` | - | `ReportExhaustion` | 0x005288d0 @305861 | `this->weenie_obj` | same | `(0, 0)` | same | - | `SetWeenieObject` | 0x00528920 @305884 | **incoming `arg2`** (stored to `this->weenie_obj` first) | same | `(1, 0)` | same | - | `SetPhysicsObject` | 0x00528970 @305911 | `this->weenie_obj` | same | `(1, 0)` | `arg2 != 0 && initted != 0` (after `this->physics_obj = arg2`) | - - Predicate true → `apply_raw_movement(a, b)`; false → `apply_interpreted_movement(a, b)`. - Verbatim (apply_current_movement, raw 305846–305855): `if (weenie_obj != 0) - eax_2 = weenie_obj->vtable->IsThePlayer(); if (((weenie_obj == 0 || eax_2 != 0) - && CPhysicsObj::movement_is_autonomous(this->physics_obj) != 0)) { - apply_raw_movement(this, arg2, arg3); return; } apply_interpreted_movement(this, - arg2, arg3);`. Semantics: `IsThePlayer` (raw 406494–406500) = `this->id == - SmartBox::smartbox->player_id` (neg/sbb idiom); `movement_is_autonomous` (raw - 276443–276447) = `return this->last_move_was_autonomous;`. Anti-artifact proof: - `HitGround` (raw 306005) and `LeaveGround` (raw 306031), ~0x250 bytes away in - the same decomp region, call `IsCreature()` — BN distinguishes the two slots - locally, ruling out a slot-shift mislabel. **ACE MotionInterp.cs:430-438's - `IsCreature` predicate is a genuine server-side divergence — do NOT copy** (a - remote player is a creature but not the player; the wrong gate sends remotes - down `apply_raw_movement` against an empty raw state). - Watchouts: (a) a FIFTH IsThePlayer site exists in `move_to_interpreted_state` - (raw 305977): per queued action node, `if ((weenie_obj->vtable->IsThePlayer() - == 0 || node->autonomous == 0))` → execute — i.e. server-echoed autonomous - actions are SKIPPED for the local player (local-echo suppression). (b) the BASE - `CWeenieObject::vftable` data dump at 0x007e3df4 (raw 1035061–1035094) is - misaligned (BN data-label overlap artifact) — only the derived ACC table at - 0x007e3ea0 is trustworthy. (c) the PDB also has a distinct - `ACCWeenieObject::IsPlayer` (slot +0x10, 0x0058C890) — never conflate the three - `Is*Player*` virtuals. - -- **A4 — MovementParameters bit numbering: PINNED via the retail PDB's own - bitfield struct, acclient.h:31423-31443** — bit-for-bit identical to ACE's - `MovementParamFlags`. Confidence: **high**. Absolute masks: - - | Mask | Flag | Mask | Flag | - |---|---|---|---| - | `0x1` | can_walk | `0x400` | use_spheres | - | `0x2` | can_run | `0x800` | set_hold_key | - | `0x4` | can_sidestep | `0x1000` | autonomous | - | `0x8` | can_walk_backwards | `0x2000` | modify_raw_state | - | `0x10` | can_charge | `0x4000` | modify_interpreted_state | - | `0x20` | fail_walk | `0x8000` | cancel_moveto | - | `0x40` | use_final_heading | `0x10000` | stop_completely | - | `0x80` | sticky | `0x20000` | disable_jump_during_link | - | `0x100` | move_away | | | - | `0x200` | move_towards | | | - - Ctor default (raw 300510–300534, 0x00524380): `(bitfield & 0xfffdee0f) | - 0x1ee0f` → **0x1EE0F** sets {can_walk, can_run, can_sidestep, - can_walk_backwards, move_towards, use_spheres, set_hold_key, modify_raw_state, - modify_interpreted_state, cancel_moveto, stop_completely}; clears {can_charge, - fail_walk, use_final_heading, sticky, move_away, autonomous, - disable_jump_during_link}. Runtime reads all consistent: DoMotion @306183 - byte1-sign = `0x8000` cancel_moveto → interrupt; @306188 byte1&8 = `0x800` - set_hold_key → `SetHoldKey(hold_key_to_apply, ((__inner0 >> 0xf) & 1))` — note - **SetHoldKey's second arg is the cancel_moveto bit**, not a constant; @306213 - byte1&0x20 = `0x2000` modify_raw_state → RawMotionState::ApplyMotion mirror. - DoInterpretedMotion @305597 full-dword `& 0x20000` = disable_jump_during_link → - jump_error_code forced `0x48`; @305609/305617 byte1&0x40 = `0x4000` - modify_interpreted_state. StopMotion @305684 sign → interrupt; @305705 - byte1&0x20 → RawState.RemoveMotion. - **ACE-divergence traps for W1:** ACE's MovementParameters ctor sets - `CanCharge = true` (MovementParameters.cs:58) — retail's default has `0x10` - CLEAR; port `can_charge = false`. ACE also changed Default_WalkRunThreshold to - 1.0 (L50) vs retail's **15.0** (@300519). Retail's ctor caches the computed - default in a static (`normal_bitfield`, @300523–300533) whose AND-mask preserves - uninitialized bits 18–31 on first call — harmless (only bits 0–17 defined); port - as a plain 0x1EE0F default. - -- **A5 — `get_jump_v_z` (0x00527aa0): PINNED to ACE's shape; the BN text is - x87-flag garbled exactly as suspected** (raw 304953–304984 returns FCOM flag - synthesis on two paths — unreadable either way; the Ghidra decompile is clean - and adjudicates). Behavior: `jump_extent < 0.000199999995f` → **0.0f**; else - clamp extent to max **1.0**; `weenie_obj == NULL` → **10.0f** (`___real_41200000`); - `InqJumpVelocity(extent, &out)` false → **0.0f**; true → out. Matches ACE - L634-652. The epsilon literal is verbatim in the raw @304959: `long double - temp0 = ((long double)0.000199999995f);`. **acdream's 0.001 - (MotionInterpreter.cs:278 JumpExtentEpsilon) must become 0.000199999995f.** - Confidence: **high**. - -- **A6 — `get_leave_ground_velocity` (0x005280c0) zero-fallback matrix direction: - PINNED as GLOBAL→LOCAL** — decisively, by index-pattern match against Frame's - own transform pair. The fallback (raw 305434–305440) computes `out.i = - fl2gv[3i+0]*v.x + fl2gv[3i+1]*v.y + fl2gv[3i+2]*v.z` — the IDENTICAL row-linear - pattern as `Frame::globaltolocalvec` 0x00452550 (raw 91466–91471), while - `Frame::localtoglobalvec` 0x004524f0 (raw 91452–91457) uses the transpose - (`fl2gv[0]*x + fl2gv[3]*y + fl2gv[6]*z`). BN's "m_fl2gv = local→global" reading - was name-misled; the applied math is global→local, matching ACE's - `GlobalToLocalVec(Velocity)`. **acdream's `Quaternion.Inverse(Orientation)` - transform (MotionInterpreter.cs:1024) IS global→local — keep.** Mechanics: - function is void (writes the caller's Vector3); body order = - `get_state_velocity(out)` → `out.z = get_jump_v_z()` → fallback fires ONLY when - `|x| AND |y| AND |z|` are ALL `< 0.000199999995f` (epsilon appears three times, - @305412/305420/305427, one per component) and then overwrites ALL THREE - components (including z, discarding the jump v_z) with - `globaltolocal(physics_obj->m_velocityVector)`. Epsilon must become 0.0002 - (currently 0.001). Confidence: **high**. - -- **A7 — `MovementManager::MotionDone` uninit-`edx` relay + dead `arg2` in - `CMotionInterp::MotionDone`.** Decompiler artifact (adjudicated in the decomp - doc §6d). Port as `MotionDone(uint motion, bool success)` passing the completed - node's motion id; the interp body reads NEITHER param in this build — **pop the - HEAD unconditionally, never match by motion id**. Keep the params for R5 - signature parity. (Copied verbatim from the plan §0 adjudication — no new - research needed.) - -- **A8 — `enter_default_state` (0x00528c80) does NOT clear `pending_motions`: it - APPENDS the `{0, 0x41000003, 0}` sentinel onto whatever queue exists.** Raw - 306128–306154: the body resets only raw_state + interpreted_state (fresh - default-constructed copies) and calls `CPhysicsObj::InitializeMotionTables`, - then `void* eax_2 = operator new(0x10); ... *(+8) = 0x41000003; *(+0xc) = 0;` - followed by the tail-append (`tail_ == 0 ? head_ = eax_2 : *tail_ = eax_2; - tail_ = eax_2`) — byte-identical in shape to `add_to_queue`'s append (raw - 305047–305057), just inlined — then `initted = 1; LeaveGround(this);`. No - drain/clear anywhere. ACE's `PendingMotions = new LinkedList` reset is the - divergence; pre-existing nodes survive in retail (W2 test asserts this). The - MovementManager lazy-create double-call (§6g) is genuine retail — do not "fix"; - R3's direct-bind construction calls it once explicitly. Confidence: **high**. - -- **A9 — `StopCompletely` (0x00527e40) jump-snapshot quirk: PINNED verbatim.** - Raw 305216–305227 ordering: `interrupt_current_movement(physics_obj);` → `eax_2 - = motion_allows_jump(this, this->interpreted_state.forward_command);` (**OLD** - forward_command, BEFORE the overwrite) → write raw_state - {forward_command=0x41000003, forward_speed=1f, sidestep_command=0, - turn_command=0} → same four writes to interpreted_state → - `StopCompletely_Internal(physics_obj)` → `add_to_queue(this, 0, 0x41000003, - eax_2)` → cell==0 → `RemoveLinkAnimations` → `return 0`. Entry: physics_obj==0 - → return 8. **Retail touches ONLY forward cmd/speed + sidestep/turn COMMANDS — - it does NOT write sidestep_speed or turn_speed** (acdream's 1.0 speed resets - are a divergence to remove per plan J9). Golden: the queued node's error code - reflects the pre-stop command. Confidence: **high**. - -- **A10 — error-code semantics: PINNED with the definitive table** (exhaustive - `return ` sweep over raw 304908–306277 + 300150–300540; 19 return sites + - 1 store site, all attributed). Codes are local-only (never on the wire) — safe - renumber in W1. Confidence: **high**. - - | Code | Retail meaning | Sites (address @ raw line) | - |---|---|---| - | `8` | no physics_obj | StopCompletely @305214; DoInterpretedMotion @305579; StopInterpretedMotion @305639; StopMotion @305680; jump @305798; DoMotion @306165 | - | `0x24` | not grounded / no contact | jump_is_allowed @305570 (0x005282f0 — gravity-state creature without Contact+OnWalkable; **also covers its physics_obj==0 case**, see note); **DoInterpretedMotion @305622-305623** (action-class motion `arg2 & 0x10000000` blocked by contact_allows_move — second site, absent from the plan row) | - | `0x3f` | Crouch 0x41000012 in combat stance | DoMotion @306196 | - | `0x40` | Sitting 0x41000013 in combat stance | DoMotion @306199 | - | `0x41` | Sleeping 0x41000014 in combat stance | DoMotion @306202 | - | `0x42` | `motion & 0x2000000` outside NonCombat (0x8000003d) | DoMotion @306205 | - | `0x45` | action depth: GetNumActions ≥ 6 | DoMotion @306209 | - | `0x47` | general movement failure | jump_is_allowed @305525 (IsFullyConstrained); jump_is_allowed @305549+305556 (staged, returned when `JumpStaminaCost(arg2, arg3) == 0`); CMotionInterp::PerformMovement @306227 (`type-1 > 4`); MovementManager::PerformMovement @300201 (`type-1 > 8`) | - | `0x48` | jump BLOCKED by motion/position (**NOT a success sentinel** — the A1 polarity fix) | motion_allows_jump @304930; jump_charge_is_allowed @304948 (Fallen or Crouch..Sleeping); charge_jump @305459 (same predicate); STORED (not returned) as node jump_error_code in DoInterpretedMotion @305605 on disable_jump_during_link | - | `0x49` | weenie `CanJump(jump_extent)` refused | jump_charge_is_allowed @304941; charge_jump @305454 | - - Note: `jump_is_allowed` returns **0x24** (not 8) when `physics_obj == 0` (raw - structure 305512 + 305570: the `if (this->physics_obj != 0) {...}` wrapper falls - out to `return 0x24;`) — the "8 = no physics obj" convention holds everywhere - else but NOT there; ACE (L744-745) returns NoPhysicsObject there — small ACE - divergence, port retail. W1 renames acdream's WeenieError values to these - numerics (adds 0x3f/0x40/0x41/0x42/0x45; fixes 0x24/0x47/0x48 semantics — the - current enum returns 0x48 for airborne where retail returns 0x24). - -## Adjacent findings (load-bearing for W-commits, discovered while pinning) - -- **`move_to_interpreted_state` call-site garble (W-commit trap):** raw - 305946–305949 renders `int32_t esi_1 = -(eax_2); ... - apply_current_movement(this, 1, -((esi_1 - esi_1)));` — the second arg - algebraically collapses to constant 0, which is decompiler nonsense (sbb/neg - idiom). Intended semantics per ACE MotionInterp.cs:796-799: `allowJump = - (motion_allows_jump(OLD forward_command) == 0)` passed as - apply_current_movement's second arg. Port `allowJump = (eax_2 == 0)` — another - minor polarity trap in the A1 family. -- **`jump` (0x00528780) details for J7:** contains `interrupt_current_movement` - (@305800); on success stores `jump_extent = arg2` and calls - `set_on_walkable(obj, 0)`; clears `standing_longjump` ONLY on the failure path. - -## W0 cdb capture (pending, non-blocking) - -No pin above depends on it — all ten are textually pinned with adversarial -verification. One live session feeds the R3 goldens (W2/W3/W5/W6): bp -`CMotionInterp::MotionDone` / `add_to_queue` / `jump` / `jump_is_allowed` / -`jump_charge_is_allowed` / `charge_jump` / `motion_allows_jump` / `HitGround` / -`LeaveGround` / `StopCompletely` / `set_hold_run` / `SetHoldKey` / -`enter_default_state` with arg+ret logging (pattern `tools/cdb/l2g-observer.cdb`); -user protocol per `r3-port-plan.md` §0 (idle / walk / run / shift-toggle / -tap-jump / full-charge jump / running jump / standing long-jump / jump-while- -crouched / jump-in-combat-stance (0x3f–0x42 family) / walk off a ledge (the A6 -momentum fallback) / land / emote-while-running / 7 rapid emotes (0x45 cap) / -logout drain). Nice-to-have live spot-checks it will also provide: A1 polarity -(log `motion_allows_jump` arg2+eax during walk/run/mid-fall/crouch jumps) and the -A2 head-peek short-circuit. diff --git a/docs/research/2026-07-02-r3-motioninterp/r3-ace-motioninterp.md b/docs/research/2026-07-02-r3-motioninterp/r3-ace-motioninterp.md deleted file mode 100644 index 887a5648..00000000 --- a/docs/research/2026-07-02-r3-motioninterp/r3-ace-motioninterp.md +++ /dev/null @@ -1,987 +0,0 @@ -# R3 — ACE port map: MotionInterp + MovementManager + MotionDone chain - -Sources (full-file reads, all line numbers are 1-indexed in the ACE source tree): -- `references/ACE/Source/ACE.Server/Physics/Animation/MotionInterp.cs` (836 lines, whole file read) -- `references/ACE/Source/ACE.Server/Physics/Managers/MovementManager.cs` (216 lines, whole file read) -- `references/ACE/Source/ACE.Server/Physics/Animation/MotionNode.cs` (21 lines, whole file read) -- `references/ACE/Source/ACE.Server/Physics/Managers/MotionTableManager.cs` (252 lines, whole file read — AnimationDone/CheckForCompletedMotions, the actual MotionDone producer) -- `references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs` (targeted reads: L80-109 IsAnimating/IsMovingOrAnimating, L290-301 CheckForCompletedMotions, L890-903 MotionDone, L4235-4247 ShowPendingMotions/motions_pending) -- `references/ACE/Source/ACE.Server/Physics/PartArray.cs` (L60-77 CheckForCompletedMotions passthrough) - -No named-retail cross-reference was performed in this pass (ACE-side mapping only, per R3 scope). ACE class is `MotionInterp` (note: **not** `CMotionInterp` — ACE dropped the `C` Hungarian prefix project-wide, this is a naming-convention divergence not a functional one). - ---- - -## 1. Class fields (MotionInterp.cs L14-32) - -```csharp -public bool Initted; -public WeenieObject WeenieObj; -public PhysicsObj PhysicsObj; -public RawMotionState RawState; -public InterpretedMotionState InterpretedState; -public float CurrentSpeedFactor; -public bool StandingLongJump; -public float JumpExtent; -public int ServerActionStamp; -public float MyRunRate; -public LinkedList PendingMotions; - -public const float BackwardsFactor = 6.4999998e-1f; // 0.65 -public const float MaxSidestepAnimRate = 3.0f; -public const float RunAnimSpeed = 4.0f; -public const float RunTurnFactor = 1.5f; -public const float SidestepAnimSpeed = 1.25f; -public const float SidestepFactor = 0.5f; -public const float WalkAnimSpeed = 3.1199999f; // 3.12 -``` - -Notes: -- `PendingMotions` is `LinkedList`, not an array/ring buffer — ACE reimplemented retail's pending-motion queue as a doubly-linked list. `MotionNode` (see §2) carries `ContextID`, `Motion`, `JumpErrorCode` — this is the FIFO queue of in-flight interpreted motions waiting on animation completion. -- `CurrentSpeedFactor` is declared but the only read site in this file is `get_adjusted_max_speed()` (L629) — no write site anywhere in MotionInterp.cs. Likely set externally (WeenieObj or PhysicsObj) — not visible in this file. -- `JumpExtent` doubles as both "how hard is the current jump charging" (set in `jump()`, read in `charge_jump()`/`get_jump_v_z()`) and as a guard the WeenieObj layer checks via `CanJump(JumpExtent)`. - ---- - -## 2. MotionNode (queue element) — MotionNode.cs, whole file - -```csharp -public class MotionNode -{ - public int ContextID; - public uint Motion; - public WeenieError JumpErrorCode; - - public MotionNode() { } - public MotionNode(int contextID, uint motion, WeenieError jumpErrorCode) - { - ContextID = contextID; - Motion = motion; - JumpErrorCode = jumpErrorCode; - } -} -``` -Trivial DTO. `JumpErrorCode` is the pre-computed "would a jump be legal right now, if the player pressed jump while this pending motion is still in flight" — computed once at `add_to_queue` time (see `motion_allows_jump` call sites in `DoInterpretedMotion`/`StopCompletely`), then consumed lazily by `jump_is_allowed()` (§7) via `PendingMotions.First.Value.JumpErrorCode` when `PendingMotions.Count > 1`. - ---- - -## 3. add_to_queue / MotionDone / RemoveMotion (queue lifecycle) - -### add_to_queue (L388-392) -```csharp -public void add_to_queue(int contextID, uint motion, WeenieError jumpErrorCode) -{ - PendingMotions.AddLast(new MotionNode(contextID, motion, jumpErrorCode)); - PhysicsObj.IsAnimating = true; -} -``` -Simple append + set the `IsAnimating` flag unconditionally true (even if this is the first entry). Called from: -- `DoInterpretedMotion` (L85) — normal interpreted-motion success path, `jump_error_code` precomputed at L73-83. -- `StopCompletely` (L321) — with hardcoded `MotionCommand.Ready` and a `jump` precomputed at L307. -- `StopInterpretedMotion` (L348) — with hardcoded `MotionCommand.Ready` and `WeenieError.None`. -- `apply_interpreted_movement` (L495) — the turn-stop fallback branch, hardcoded `MotionCommand.Ready` / `WeenieError.None`. - -### MotionDone (L210-234) — the dequeue-on-animation-complete handler -```csharp -public void MotionDone(bool success) -{ - if (PhysicsObj == null) return; - - var motionData = PendingMotions.First; - - // null or != last in list? - if (motionData != null) - { - var pendingMotion = motionData.Value; - if ((pendingMotion.Motion & (uint)CommandMask.Action) != 0) - { - PhysicsObj.unstick_from_object(); - InterpretedState.RemoveAction(); - RawState.RemoveAction(); - } - - motionData = PendingMotions.First; - if (motionData != null) - { - PendingMotions.Remove(motionData); - PhysicsObj.IsAnimating = PendingMotions.Count > 0; - } - } -} -``` -Note the ACE dev's own inline comment `// null or != last in list?` — flags this as a spot they weren't fully certain about vs. retail (a "did I get this right" breadcrumb). Two things happen if the head node exists: -1. If the head's Motion has the `Action` bit set (`CommandMask.Action`), it's treated as an "action" motion (e.g. attack/emote) that stuck the object to something — `unstick_from_object()` + pop the action off both InterpretedState and RawState action stacks. -2. **Re-fetches `PendingMotions.First` a second time** (redundant re-read — `motionData` unchanged since nothing between the two reads mutates the list) before removing it and recomputing `IsAnimating` as `Count > 0` (as opposed to `add_to_queue`'s unconditional `true`). - -`success` parameter is accepted but **never read** in this method body — dead parameter as far as MotionInterp.MotionDone goes. (It IS read/propagated further down in `MotionTableManager.AnimationDone`/`CheckForCompletedMotions`, see §8 — but `MotionInterp.MotionDone` itself ignores it entirely.) - -### RemoveMotion -Not defined in MotionInterp.cs — it's called on `InterpretedState`/`RawState` (`InterpretedState.RemoveMotion(motion)`, `RawState.RemoveMotion(motion)`), i.e. lives in `InterpretedMotionState`/`RawMotionState`/`MotionState` classes, NOT in MotionInterp. Call sites inside this file: L340, L351, L358, L498. Out of scope for this file-bounded pass (not in MotionInterp.cs or MovementManager.cs). - ---- - -## 4. DoMotion / StopMotion / StopCompletely (top-level motion API) - -### DoMotion (L112-158) — raw-command entry point -```csharp -public WeenieError DoMotion(uint motion, MovementParameters movementParams) -{ - if (PhysicsObj == null) return WeenieError.NoPhysicsObject; - - var currentParams = new MovementParameters(); - currentParams.CopySome(movementParams); - - var currentMotion = motion; - - if (movementParams.CancelMoveTo) PhysicsObj.cancel_moveto(); - if (movementParams.SetHoldKey) SetHoldKey(movementParams.HoldKeyToApply, movementParams.CancelMoveTo); - - adjust_motion(ref currentMotion, ref currentParams.Speed, movementParams.HoldKeyToApply); - - if (InterpretedState.CurrentStyle != (uint)MotionCommand.NonCombat) - { - switch (motion) - { - case (uint)MotionCommand.Crouch: return WeenieError.CantCrouchInCombat; - case (uint)MotionCommand.Sitting: return WeenieError.CantSitInCombat; - case (uint)MotionCommand.Sleeping: return WeenieError.CantLieDownInCombat; - } - if ((motion & (uint)CommandMask.ChatEmote) != 0) return WeenieError.CantChatEmoteInCombat; - } - - if ((motion & (uint)CommandMask.Action) != 0) - { - if (InterpretedState.GetNumActions() >= 6) return WeenieError.TooManyActions; - } - var result = DoInterpretedMotion(currentMotion, currentParams); - - if (result == WeenieError.None && movementParams.ModifyRawState) - RawState.ApplyMotion(motion, movementParams); - - return result; -} -``` -Key points: -- `switch (motion)` (combat-blocked motions) tests the **original** `motion` parameter, not `currentMotion` (post-`adjust_motion` mutation) — combat-block checks happen on the raw pre-adjustment command. -- Hardcoded action cap of 6 concurrent actions (`GetNumActions() >= 6`). -- `RawState.ApplyMotion` uses the **original** `motion`, not `currentMotion` — raw state records what was actually requested, interpreted state (via `DoInterpretedMotion`) gets the post-`adjust_motion` (walk→run promoted, backward-inverted, etc.) version. - -### StopMotion (L367-386) -Mirror of DoMotion but for the "stop" side: `cancel_moveto` gate, `adjust_motion`, delegates to `StopInterpretedMotion`, then conditionally calls `RawState.RemoveMotion(motion)` (original motion, not adjusted) on success. - -### StopCompletely (L301-327) -```csharp -public WeenieError StopCompletely() -{ - if (PhysicsObj == null) return WeenieError.NoPhysicsObject; - - PhysicsObj.cancel_moveto(); - - var jump = motion_allows_jump(InterpretedState.ForwardCommand); - - RawState.ForwardCommand = (uint)MotionCommand.Ready; - RawState.ForwardSpeed = 1.0f; - RawState.SideStepCommand = 0; - RawState.TurnCommand = 0; - - InterpretedState.ForwardCommand = (uint)MotionCommand.Ready; - InterpretedState.ForwardSpeed = 1.0f; - InterpretedState.SideStepCommand = 0; - InterpretedState.TurnCommand = 0; - - PhysicsObj.StopCompletely_Internal(); - - add_to_queue(0, (uint)MotionCommand.Ready, jump); - - if (PhysicsObj.CurCell == null) - PhysicsObj.RemoveLinkAnimations(); - - return WeenieError.None; -} -``` -Hard-resets both Raw and Interpreted state (Forward/SideStep/Turn) to Ready/1.0/0/0 directly by field assignment — bypasses the normal `ApplyMotion`/`RemoveMotion` mutators entirely. `jump` (the eligibility precomputed BEFORE the reset, off the OLD `InterpretedState.ForwardCommand`) is stashed into the queued `Ready` node's `JumpErrorCode`. Delegates the physics-side reset to `PhysicsObj.StopCompletely_Internal()` (not in this file). - ---- - -## 5. DoInterpretedMotion / StopInterpretedMotion (interpreted-command layer) - -### DoInterpretedMotion (L51-110) -```csharp -public WeenieError DoInterpretedMotion(uint motion, MovementParameters movementParams) -{ - if (PhysicsObj == null) return WeenieError.NoPhysicsObject; - var result = WeenieError.None; - - if (contact_allows_move(motion)) - { - if (StandingLongJump && (motion == WalkForward || motion == RunForward || motion == SideStepRight)) - { - if (movementParams.ModifyInterpretedState) - InterpretedState.ApplyMotion(motion, movementParams); - } - else - { - if (motion == (uint)MotionCommand.Dead) - PhysicsObj.RemoveLinkAnimations(); - - result = PhysicsObj.DoInterpretedMotion(motion, movementParams); - - if (result == WeenieError.None) - { - var jump_error_code = WeenieError.None; - if (movementParams.DisableJumpDuringLink) - jump_error_code = WeenieError.YouCantJumpFromThisPosition; - else - { - jump_error_code = motion_allows_jump(motion); - if (jump_error_code == WeenieError.None && (motion & (uint)CommandMask.Action) == 0) - jump_error_code = motion_allows_jump(InterpretedState.ForwardCommand); - } - add_to_queue(movementParams.ContextID, motion, jump_error_code); - - if (movementParams.ModifyInterpretedState) - InterpretedState.ApplyMotion(motion, movementParams); - } - } - } - else - { - if ((motion & (uint)CommandMask.Action) != 0) - result = WeenieError.YouCantJumpWhileInTheAir; - else - { - if (movementParams.ModifyInterpretedState) - InterpretedState.ApplyMotion(motion, movementParams); - result = WeenieError.None; - } - } - - if (PhysicsObj.CurCell == null) - PhysicsObj.RemoveLinkAnimations(); - - return result; -} -``` -Control-flow shape: -1. Gate on `contact_allows_move(motion)` (§7) — governs whether the object is grounded enough to accept this motion at all. -2. **StandingLongJump special-case**: if mid-standing-long-jump AND the incoming motion is one of {WalkForward, RunForward, SideStepRight}, the motion is applied to InterpretedState ONLY (no `PhysicsObj.DoInterpretedMotion` call, no queue entry) — the animation itself is suppressed while charging/airborne from a standing jump, but the *intent* state still updates so movement resumes correctly on landing. -3. Otherwise: `Dead` motion clears link animations first; then delegates the actual animation dispatch to `PhysicsObj.DoInterpretedMotion` (physics/animation-table layer, out of file scope); on success, computes the jump-error-code for THIS pending motion (double motion_allows_jump check: first against the incoming `motion` itself, then — only if the incoming motion isn't itself an Action AND passed — against the current `ForwardCommand`), queues it, and conditionally applies to InterpretedState. -4. If contact does NOT allow movement: Action-class motions fail outright with `YouCantJumpWhileInTheAir`; everything else silently updates InterpretedState (if requested) and returns success — i.e., non-action motions (turning, etc.) are allowed to update intent state even while airborne, just not animate/queue. -5. Unconditional tail: if `CurCell == null` (off the cell grid — e.g. despawned/uninitialized), strip link animations. - -### StopInterpretedMotion (L329-365) -Structural mirror of DoInterpretedMotion for the "stop" direction: same `contact_allows_move` gate, same StandingLongJump special-case (calls `InterpretedState.RemoveMotion` instead of `ApplyMotion`), same delegate-to-PhysicsObj pattern (`PhysicsObj.StopInterpretedMotion`), same add_to_queue-with-Ready-on-success pattern, same CurCell==null tail cleanup. The "contact disallows" else-branch here has NO action-vs-non-action split (StopInterpretedMotion always just conditionally calls `RemoveMotion` and returns `WeenieError.None`) — asymmetric with DoInterpretedMotion's stricter "action motions error out while airborne" rule. - ---- - -## 6. HitGround / LeaveGround (contact transition hooks) - -### HitGround (L175-185) -```csharp -public void HitGround() -{ - if (PhysicsObj == null) return; - if (WeenieObj != null && !WeenieObj.IsCreature()) return; - if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) return; - - PhysicsObj.RemoveLinkAnimations(); - apply_current_movement(false, true); -} -``` -Guarded to creature-only (or WeenieObj==null, i.e. non-weenie-backed physics objects) AND gravity-affected objects. Strips link animations and re-applies current movement intent with `cancelMoveTo=false, allowJump=true`. - -### LeaveGround (L192-208) -```csharp -public void LeaveGround() -{ - if (PhysicsObj == null) return; - if (WeenieObj != null && !WeenieObj.IsCreature()) return; - if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) return; - - var velocity = get_leave_ground_velocity(); - PhysicsObj.set_local_velocity(velocity, true); - - StandingLongJump = false; - JumpExtent = 0; - - PhysicsObj.RemoveLinkAnimations(); - apply_current_movement(false, true); -} -``` -Same guard pattern as HitGround. Computes leave-ground velocity (§9), pushes it into physics as a LOCAL velocity (`set_local_velocity(velocity, true)` — second arg likely "isLocal"/"autonomous", not resolved in this file), clears the jump-charge state (`StandingLongJump=false`, `JumpExtent=0`), strips link anims, reapplies movement. **Called from `enter_default_state()` (L615)** as the terminal step of default-state setup — i.e. every newly-initialized MotionInterp starts as if it just left the ground. - -MovementManager wrappers (MovementManager.cs): -- `HitGround()` (L66-73): calls `MotionInterpreter.HitGround()` THEN `MoveToManager.HitGround()` — both interpreters get the hit-ground signal, MotionInterp first. -- `LeaveGround()` (L104-110): calls `MotionInterpreter.LeaveGround()` only; has a dead commented-out line `// NoticeHandler::RecvNotice_PrevSpellSection` (retail-symbol trace left as a comment — not ported to ACE — a network-notice hook ACE apparently didn't implement here). - ---- - -## 7. Jump family - -### jump (L710-727) -```csharp -public WeenieError jump(float extent, int adjustStamina) -{ - if (PhysicsObj == null) return WeenieError.NoPhysicsObject; - - PhysicsObj.cancel_moveto(); - - var result = jump_is_allowed(extent, adjustStamina); - - if (result == WeenieError.None) - { - JumpExtent = extent; - PhysicsObj.set_on_walkable(false); - } - else - StandingLongJump = false; - - return result; -} -``` -Note: on success it sets `JumpExtent` and clears the walkable flag but does NOT itself call `LeaveGround()` or apply velocity — that must happen elsewhere (likely triggered by the physics tick detecting `on_walkable==false` + `JumpExtent>0`, out of file scope). On failure it clears `StandingLongJump` (defensive — aborts an in-progress standing-long-jump charge if the actual jump call fails). - -### jump_is_allowed (L742-768) -```csharp -public WeenieError jump_is_allowed(float extent, int staminaCost) -{ - if (PhysicsObj == null) return WeenieError.NoPhysicsObject; - - if (WeenieObj == null && !WeenieObj.IsCreature() || !PhysicsObj.State.HasFlag(PhysicsState.Gravity) || - PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable)) - { - if (PhysicsObj.IsFullyConstrained()) - return WeenieError.GeneralMovementFailure; - - if (PendingMotions.Count > 1 && PendingMotions.First.Value.JumpErrorCode != 0) - return PendingMotions.First.Value.JumpErrorCode; - - var jumpError = jump_charge_is_allowed(); - - if (jumpError == WeenieError.None) - { - jumpError = motion_allows_jump(InterpretedState.ForwardCommand); - - if (jumpError == WeenieError.None && WeenieObj != null && WeenieObj.JumpStaminaCost(extent, staminaCost) == 0) - jumpError = WeenieError.GeneralMovementFailure; - } - return jumpError; - } - return WeenieError.YouCantJumpWhileInTheAir; -} -``` -**LIKELY BUG (ACE-side, flag for cross-check against named-retail):** `WeenieObj == null && !WeenieObj.IsCreature()` — if `WeenieObj` actually is null, `!WeenieObj.IsCreature()` would NPE; C#'s `&&` short-circuits left-to-right so `WeenieObj == null` being true still evaluates the right side... wait, no: `&&` short-circuits so if `WeenieObj == null` is `true`, C# does NOT evaluate `!WeenieObj.IsCreature()` — correct, no NPE. But semantically this condition is almost certainly meant to be `WeenieObj != null && !WeenieObj.IsCreature()` (matching the guard pattern used identically in HitGround/LeaveGround/apply_current_movement/adjust_motion — all of which use `WeenieObj != null && !WeenieObj.IsCreature()`). As written, `WeenieObj == null && !WeenieObj.IsCreature()` can **never be true** (if WeenieObj==null is true, the whole condition short-circuits false because `&&` needs the null-check false... actually WeenieObj==null must be TRUE for this branch, and if it's true the right operand isn't even evaluated, so `WeenieObj==null && [anything]` reduces to just needing `WeenieObj==null` — no, `A && B` requires BOTH true; if A is true B is still checked. If A is true (WeenieObj IS null) and B accesses `WeenieObj.IsCreature()` on a null WeenieObj, this WOULD NPE.** This is a genuine apparent typo vs. the established pattern elsewhere in the same file (should be `WeenieObj != null && !WeenieObj.IsCreature()`) — **flag as a divergence risk to verify against `docs/research/named-retail/` before porting this exact condition to acdream.** -- Entry condition: allowed to even check jump-legality if EITHER not-a-creature-weenie, OR not gravity-affected, OR (already in Contact+OnWalkable state). -- `IsFullyConstrained()` → GeneralMovementFailure. -- **Queue-lookahead check**: if there's more than one pending motion AND the head's precomputed `JumpErrorCode != 0`, return that cached error immediately (short-circuit — reuses the jump-eligibility computed back when that motion was queued, rather than recomputing). -- Otherwise chains `jump_charge_is_allowed()` → `motion_allows_jump(ForwardCommand)` → `WeenieObj.JumpStaminaCost(extent, staminaCost) == 0` (stamina check, external to this file) → `GeneralMovementFailure` if stamina call returns 0. -- If the outer gate fails, returns `YouCantJumpWhileInTheAir` (airborne + gravity + not a special-cased non-creature). - -### jump_charge_is_allowed (L729-740) -```csharp -public WeenieError jump_charge_is_allowed() -{ - if (WeenieObj != null && !WeenieObj.CanJump(JumpExtent)) - return WeenieError.CantJumpLoadedDown; - - var forward = InterpretedState.ForwardCommand; - - if (forward == (uint)MotionCommand.Fallen || forward >= (uint)MotionCommand.Crouch && forward <= (uint)MotionCommand.Sleeping) - return WeenieError.YouCantJumpFromThisPosition; - - return WeenieError.None; -} -``` -`WeenieObj.CanJump(JumpExtent)` is presumably an encumbrance/burden check (name: "loaded down"). Blocks jump-charging while `Fallen` or in the [Crouch..Sleeping] MotionCommand range (a contiguous enum-range test — same idiom used throughout this file for "in one of these seated/prone states"). - -### charge_jump (L564-582) -```csharp -public int charge_jump() -{ - if (WeenieObj != null && !WeenieObj.CanJump(JumpExtent)) - return 0x49; - - var forward = InterpretedState.ForwardCommand; - - if (forward == (uint)MotionCommand.Falling || forward >= (uint)MotionCommand.Crouch && forward < (uint)MotionCommand.Sleeping) - return 0x48; - else - { - if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable) && forward == (uint)MotionCommand.Ready && - InterpretedState.SideStepCommand == 0 && InterpretedState.TurnCommand == 0) - { - StandingLongJump = true; - } - } - return 0; -} -``` -Returns raw `int` error codes (`0x49`, `0x48`, `0`) rather than `WeenieError` enum — a leftover-looking retail-style raw-hresult-ish return convention (likely these hex values ARE `WeenieError` underlying ints but the method signature wasn't converted — worth checking `0x49`/`0x48` against the `WeenieError` enum values for `CantJumpLoadedDown`/`YouCantJumpFromThisPosition` equivalents). **Note the range-check here uses `Falling` (not `Fallen` as in `jump_charge_is_allowed`) and `< Sleeping` (exclusive) vs `jump_charge_is_allowed`'s `<= Sleeping` (inclusive)** — these two "similar" gating functions have subtly different boundary conditions; flag for retail cross-check, this looks like it could be an ACE transcription inconsistency OR an intentional retail distinction between "starting to charge a jump" vs "already mid-charge-and-issuing-the-actual-jump." -The actual `StandingLongJump = true` side-effect only fires when: grounded+walkable, current forward command is exactly `Ready` (standing still), AND no sidestep/turn in progress — i.e. this is the "player pressed-and-held jump while standing still" detector that arms the long-jump-charge special path used throughout DoInterpretedMotion/StopInterpretedMotion/apply_interpreted_movement. - -### get_jump_v_z (L634-652) -```csharp -public float get_jump_v_z() -{ - if (JumpExtent < PhysicsGlobals.EPSILON) return 0.0f; - - var extent = JumpExtent; - if (extent > 1.0f) extent = 1.0f; - - if (WeenieObj == null) return 10.0f; - - float vz = extent; - if (WeenieObj.InqJumpVelocity(extent, out vz)) - return vz; - - return 0.0f; -} -``` -Clamps `JumpExtent` to [something-above-EPSILON, 1.0], delegates the actual extent→velocity curve to `WeenieObj.InqJumpVelocity` (out of file scope — presumably reads jump skill). Fallback of `10.0f` if there's no WeenieObj at all (non-weenie physics object jumping at max power, e.g. test/editor objects). `float vz = extent;` is a dead initializer — immediately overwritten by the `out vz` param if `InqJumpVelocity` returns true, else the method returns `0.0f` on the false path (never returns the dead-initialized `extent` value). - -### get_leave_ground_velocity (L654-663) -```csharp -public Vector3 get_leave_ground_velocity() -{ - var velocity = get_state_velocity(); - velocity.Z = get_jump_v_z(); - - if (Vec.IsZero(velocity)) - velocity = PhysicsObj.Position.GlobalToLocalVec(PhysicsObj.Velocity); - - return velocity; -} -``` -Composes horizontal velocity from current interpreted-state motion (§9 `get_state_velocity`) with vertical velocity from the jump charge, UNLESS the composed vector is exactly zero — in which case it falls back to converting the physics object's actual (global) velocity into local space. This fallback matters for e.g. falling-off-a-ledge (no jump input, no WASD, but the object still has downward/lateral velocity from having walked off an edge) vs. a genuine standing-still jump. - ---- - -## 8. HitGround/MotionDone chain — full call graph (cross-file, MotionTableManager.cs) - -The actual "animation finished playing" signal originates in `MotionTableManager` (a SEPARATE class from `MotionInterp`, owned by `PartArray`, NOT by `MovementManager`): - -``` -MotionTableManager.AnimationDone(bool success) [L28-61] -MotionTableManager.CheckForCompletedMotions() [L63-85] - -> PhysicsObj.MotionDone(motionID, success) [PhysicsObj.cs L899-903] - -> MovementManager.MotionDone(motion, success) [MovementManager.cs L118-122] - -> MotionInterpreter.MotionDone(success) [MotionInterp.cs L210-234] - -> PhysicsObj.WeenieObj.OnMotionDone(motionID, success) [parallel notify, weenie-layer] -``` - -Both `AnimationDone` and `CheckForCompletedMotions` in MotionTableManager independently walk `PendingAnimations` (a `LinkedList`, the animation-table-layer's OWN pending queue — distinct from `MotionInterp.PendingMotions`) and call `PhysicsObj.MotionDone` once per completed entry, followed immediately by `WeenieObj.OnMotionDone` — i.e. **every completed motion fires two independent listeners**: the MotionInterp queue-pop (state-machine bookkeeping) and the WeenieObject notification (game-logic reaction, e.g. quest triggers, sound cues — out of scope here). - -`CheckForCompletedMotions()` reachability: -``` -MotionInterp.PerformMovement(mvs) [L260] -- called after EVERY movement dispatch (Do/Stop/StopCompletely) - -> PhysicsObj.CheckForCompletedMotions() [PhysicsObj.cs L296-300] - -> PartArray.CheckForCompletedMotions() [PartArray.cs L72-76] - -> MotionTableManager.CheckForCompletedMotions() [L63-85] -``` -So every `MovementManager.PerformMovement` → `MotionInterp.PerformMovement` call (§10) ends with an immediate synchronous drain of any already-finished animations at the head of `PendingAnimations` — this is how a 0-frame-length animation (e.g. an instant `Ready` transition) gets its `MotionDone` fired same-tick rather than waiting for the next animation-tick callback. - -`MotionTableManager.UseTime()` (L158-161) also calls `CheckForCompletedMotions()` — this is the periodic (per-tick, presumably driven by `MovementManager.UseTime()` → `MoveToManager.UseTime()`... **NOTE:** `MovementManager.UseTime()` (MovementManager.cs L176-179) only forwards to `MoveToManager.UseTime()`, NOT to `MotionInterpreter`/`MotionTableManager` — so `MotionTableManager.UseTime()`'s caller is NOT in this file pair; it must be driven directly by `PartArray`/`Sequence`'s own per-tick update, bypassing MovementManager entirely). - -`AnimationDone` (L28-61) is the OTHER path into the same `PhysicsObj.MotionDone` call — driven by whatever animation-tick/callback system invokes it directly (not visible in these two files; likely `Sequence`/`AFrame` playback completion). Its loop differs from `CheckForCompletedMotions` in that it decrements a running `AnimationCounter` against each node's `NumAnims` rather than checking `NumAnims != 0` directly — i.e. `AnimationDone` is the incremental "one more anim-frame-group finished" tick, while `CheckForCompletedMotions` is the "resync/drain everything already at zero" batch pass. - ---- - -## 9. apply_raw / apply_interpreted / apply_current_movement (state → animation dispatch) - -### apply_current_movement (L430-438) — dispatcher -```csharp -public void apply_current_movement(bool cancelMoveTo, bool allowJump) -{ - if (PhysicsObj == null || !Initted) return; - - if (WeenieObj != null && !WeenieObj.IsCreature() || !PhysicsObj.movement_is_autonomous()) - apply_interpreted_movement(cancelMoveTo, allowJump); - else - apply_raw_movement(cancelMoveTo, allowJump); -} -``` -Routes to raw-vs-interpreted based on: non-creature-weenie OR not-autonomous-movement → interpreted path; creature AND autonomous movement → raw path. (`movement_is_autonomous()` presumably distinguishes server-driven/scripted motion from player-input-driven motion — out of file scope.) Requires `Initted == true` (set at the tail of `enter_default_state`, §11) — calls before init are no-ops. - -### apply_raw_movement (L506-523) -```csharp -public void apply_raw_movement(bool cancelMoveTo, bool allowJump) -{ - if (PhysicsObj == null) return; - - InterpretedState.CurrentStyle = RawState.CurrentStyle; - InterpretedState.ForwardCommand = RawState.ForwardCommand; - InterpretedState.ForwardSpeed = RawState.ForwardSpeed; - InterpretedState.SideStepCommand = RawState.SideStepCommand; - InterpretedState.SideStepSpeed = RawState.SideStepSpeed; - InterpretedState.TurnCommand = RawState.TurnCommand; - InterpretedState.TurnSpeed = RawState.TurnSpeed; - - adjust_motion(ref InterpretedState.ForwardCommand, ref InterpretedState.ForwardSpeed, RawState.ForwardHoldKey); - adjust_motion(ref InterpretedState.SideStepCommand, ref InterpretedState.SideStepSpeed, RawState.SideStepHoldKey); - adjust_motion(ref InterpretedState.TurnCommand, ref InterpretedState.TurnSpeed, RawState.TurnHoldKey); - - apply_interpreted_movement(cancelMoveTo, allowJump); -} -``` -Copies all 7 raw-state fields verbatim into interpreted-state, THEN runs `adjust_motion` (§12) independently over each of the three command/speed pairs (Forward, SideStep, Turn) with their respective per-axis hold-keys, THEN falls through to `apply_interpreted_movement`. This is the "translate the low-level input intent into the higher-level interpreted/animation intent" step — walk→run promotion, backward-inversion, sidestep animation-rate scaling all happen here per-axis. - -### apply_interpreted_movement (L440-504) -```csharp -public void apply_interpreted_movement(bool cancelMoveTo, bool allowJump) -{ - if (PhysicsObj == null) return; - - var movementParams = new MovementParameters(); - movementParams.SetHoldKey = false; - movementParams.ModifyInterpretedState = false; - movementParams.CancelMoveTo = cancelMoveTo; - movementParams.DisableJumpDuringLink = !allowJump; - - if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward) - MyRunRate = InterpretedState.ForwardSpeed; - - DoInterpretedMotion(InterpretedState.CurrentStyle, movementParams); - - if (contact_allows_move(InterpretedState.ForwardCommand)) - { - if (!StandingLongJump) - { - movementParams.Speed = InterpretedState.ForwardSpeed; - DoInterpretedMotion(InterpretedState.ForwardCommand, movementParams); - - if (InterpretedState.SideStepCommand != 0) - { - movementParams.Speed = InterpretedState.SideStepSpeed; - DoInterpretedMotion(InterpretedState.SideStepCommand, movementParams); - } - else - StopInterpretedMotion((uint)MotionCommand.SideStepRight, movementParams); - } - else - { - movementParams.Speed = 1.0f; - DoInterpretedMotion((uint)MotionCommand.Ready, movementParams); - StopInterpretedMotion((uint)MotionCommand.SideStepRight, movementParams); - } - } - else - { - movementParams.Speed = 1.0f; - DoInterpretedMotion((uint)MotionCommand.Falling, movementParams); - } - - if (InterpretedState.TurnCommand != 0) - { - movementParams.Speed = InterpretedState.TurnSpeed; - DoInterpretedMotion(InterpretedState.TurnCommand, movementParams); - } - else - { - var result = PhysicsObj.StopInterpretedMotion((uint)MotionCommand.TurnRight, movementParams); - if (result == WeenieError.None) - { - add_to_queue(movementParams.ContextID, (uint)MotionCommand.Ready, WeenieError.None); - if (movementParams.ModifyInterpretedState) - InterpretedState.RemoveMotion((uint)MotionCommand.TurnRight); - } - if (PhysicsObj.CurCell == null) - PhysicsObj.RemoveLinkAnimations(); - } -} -``` -This is the per-axis re-dispatch of the CURRENT interpreted state as fresh `DoInterpretedMotion`/`StopInterpretedMotion` calls (used both after raw→interpreted translation, and directly whenever contact/gravity transitions need to re-trigger animation — e.g. HitGround/LeaveGround/SetPhysicsObject/SetWeenieObject/SetHoldKey/set_hold_run all funnel here via `apply_current_movement`). Sequence: -1. `CurrentStyle` motion (stance) always re-dispatched first. -2. `MyRunRate` cache updated from `ForwardSpeed` whenever currently running (side-channel — persists the "last known run speed" independent of whatever WeenieObj.InqRunRate later reports). -3. If contact allows movement: normal case re-dispatches Forward + (SideStep OR explicit SideStepRight-stop); StandingLongJump case instead forces Ready + explicit SideStepRight-stop (suppresses forward/sidestep animation while charging a standing jump, matching the earlier note in DoInterpretedMotion). -4. If contact does NOT allow movement: unconditionally dispatches `Falling` (Speed=1.0) regardless of what ForwardCommand was. -5. Turn is handled as its own independent branch — Turn re-dispatches like Forward/SideStep, but the "stop" path when TurnCommand==0 is inlined directly here (calls `PhysicsObj.StopInterpretedMotion` — the PHYSICS layer method, not `this.StopInterpretedMotion` — and duplicates the add_to_queue/RemoveMotion/RemoveLinkAnimations bookkeeping inline rather than delegating to `this.StopInterpretedMotion`). This inline duplication vs delegating is a structural oddity worth flagging — every other "stop with bookkeeping" path in this file goes through the member `StopInterpretedMotion` method, but this one open-codes it against `PhysicsObj.StopInterpretedMotion` directly. - -### get_state_velocity (L678-700) — used by get_leave_ground_velocity (§7) -```csharp -public Vector3 get_state_velocity() -{ - var velocity = Vector3.Zero; - - if (InterpretedState.SideStepCommand == (uint)MotionCommand.SideStepRight) - velocity.X = SidestepAnimSpeed * InterpretedState.SideStepSpeed; - if (InterpretedState.ForwardCommand == (uint)MotionCommand.WalkForward) - velocity.Y = WalkAnimSpeed * InterpretedState.ForwardSpeed; - else if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward) - velocity.Y = RunAnimSpeed * InterpretedState.ForwardSpeed; - - var rate = MyRunRate; - if (WeenieObj != null) WeenieObj.InqRunRate(ref rate); - - var maxSpeed = RunAnimSpeed * rate; - if (velocity.Length() > maxSpeed) - { - velocity = Vector3.Normalize(velocity); - velocity *= maxSpeed; - } - return velocity; -} -``` -X = lateral (sidestep) component, Y = forward component (local space — X/Y here are NOT world axes). Clamped to a max speed derived from `RunAnimSpeed * runRate` — note `WeenieObj.InqRunRate(ref rate)`'s return value is discarded here (unlike `get_adjusted_max_speed`/`get_max_speed` which check the bool return and fall back to `MyRunRate` only if it returns false) — `rate` is pre-seeded with `MyRunRate` and then `InqRunRate` is allowed to overwrite it unconditionally regardless of success/failure return. - ---- - -## 10. PerformMovement (MotionInterp) vs PerformMovement (MovementManager) - -### MotionInterp.PerformMovement (L236-262) -```csharp -public WeenieError PerformMovement(MovementStruct mvs) -{ - var result = WeenieError.None; - switch (mvs.Type) - { - case MovementType.RawCommand: result = DoMotion(mvs.Motion, mvs.Params); break; - case MovementType.InterpretedCommand: result = DoInterpretedMotion(mvs.Motion, mvs.Params); break; - case MovementType.StopRawCommand: result = StopMotion(mvs.Motion, mvs.Params); break; - case MovementType.StopInterpretedCommand: result = StopInterpretedMotion(mvs.Motion, mvs.Params); break; - case MovementType.StopCompletely: result = StopCompletely(); break; - default: return WeenieError.GeneralMovementFailure; - } - PhysicsObj.CheckForCompletedMotions(); - return result; -} -``` -Dispatch table over `MovementType`; unconditionally drains completed motions (§8) after every dispatched call (except the `default`/unknown-type early-return, which skips the drain). - -### MovementManager.PerformMovement (L124-157) -```csharp -public WeenieError PerformMovement(MovementStruct mvs) -{ - PhysicsObj.set_active(true); - - switch (mvs.Type) - { - case MovementType.RawCommand: - case MovementType.InterpretedCommand: - case MovementType.StopRawCommand: - case MovementType.StopInterpretedCommand: - case MovementType.StopCompletely: - if (MotionInterpreter == null) - { - MotionInterpreter = MotionInterp.Create(PhysicsObj, WeenieObj); - if (PhysicsObj != null) MotionInterpreter.enter_default_state(); - } - return MotionInterpreter.PerformMovement(mvs); - - case MovementType.MoveToObject: - case MovementType.MoveToPosition: - case MovementType.TurnToObject: - case MovementType.TurnToHeading: - if (MoveToManager == null) - MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj); - return MoveToManager.PerformMovement(mvs); - - default: - return WeenieError.GeneralMovementFailure; - } -} -``` -Outer layer: unconditionally activates the physics object (`set_active(true)`) BEFORE dispatch, lazy-constructs `MotionInterpreter` (with `enter_default_state()`) on first use for the motion-command group, lazy-constructs `MoveToManager` on first use for the moveto/turnto group. This is the true entry point most game-logic code calls — `MotionInterp.PerformMovement` is only reachable through this wrapper (or directly if something already holds a `MotionInterp` reference, e.g. via `get_minterp()`). - ---- - -## 11. Lifecycle: enter_default_state / HandleExitWorld / HandleEnterWorld - -### MotionInterp.enter_default_state (L604-616) -```csharp -public void enter_default_state() -{ - RawState = new RawMotionState(); - InterpretedState = new InterpretedMotionState(); - - PhysicsObj.InitializeMotionTables(); - PendingMotions = new LinkedList(); // ?? - - add_to_queue(0, (uint)MotionCommand.Ready, 0); - - Initted = true; - LeaveGround(); -} -``` -Note the ACE dev's own `// ??` comment on the `PendingMotions` reset — another self-flagged uncertainty spot (worth checking retail decomp for whether PendingMotions is genuinely reset here or whether retail preserves/asserts-empty). Order: fresh Raw/Interpreted state objects → physics-layer motion table init → fresh pending-motion queue → seed the queue with one `Ready` motion (contextID=0, jumpErrorCode=`WeenieError.None`=0) → flip `Initted=true` → call `LeaveGround()` (§6) which itself is now unlocked since `Initted` gates nothing in LeaveGround directly, but LeaveGround calls `apply_current_movement` which DOES gate on `Initted`. - -### MovementManager.EnterDefaultState (L38-46) -```csharp -public void EnterDefaultState() -{ - if (PhysicsObj == null) return; - if (MotionInterpreter == null) - MotionInterpreter = MotionInterp.Create(PhysicsObj, WeenieObj); - MotionInterpreter.enter_default_state(); -} -``` -Thin lazy-construct + delegate wrapper. - -### HandleExitWorld — two versions -**MotionInterp.HandleExitWorld (L160-173):** -```csharp -public void HandleExitWorld() -{ - foreach (var pendingMotion in PendingMotions) - { - if (PhysicsObj != null && (pendingMotion.Motion & (uint)CommandMask.Action) != 0) - { - PhysicsObj.unstick_from_object(); - InterpretedState.RemoveAction(); - RawState.RemoveAction(); - } - } - PendingMotions.Clear(); - if (PhysicsObj != null) PhysicsObj.IsAnimating = false; -} -``` -Iterates ALL pending motions (not just the head, unlike `MotionDone`) — for EVERY pending motion that has the Action bit set, unsticks + pops an action off both state stacks (this will over-pop if multiple pending Action motions exist simultaneously and InterpretedState/RawState only track a bounded action stack — worth checking `GetNumActions()`'s cap of 6 against how many action-removals this loop could trigger). Then clears the whole queue and force-sets `IsAnimating = false` directly (bypassing the `Count > 0` recompute that `MotionDone` uses). - -**MovementManager.HandleExitWorld (L54-58):** thin delegate, `if (MotionInterpreter != null) MotionInterpreter.HandleExitWorld();` — no MoveToManager involvement (contrast with `HitGround` which drives both). - -### MovementManager.HandleEnterWorld (L48-52) -```csharp -public void HandleEnterWorld() -{ - //if (MotionInterpreter != null) - //NoticeHandler.RecvNotice_PrevSpellSelection(MotionInterpreter); -} -``` -Entirely commented out — dead/no-op in ACE. The commented reference to `NoticeHandler.RecvNotice_PrevSpellSelection` is a retail-symbol breadcrumb (spell-selection restore notice on world-enter) that ACE chose not to implement. **Flag for acdream: if named-retail decomp confirms this notice is meaningful (e.g. restoring a previously-selected spell/combat-style UI state on relog), this is a genuine ACE gap, not just dead weight — acdream may need to port it from decomp directly since ACE has nothing to copy.** - -Also note: `MotionTableManager.HandleEnterWorld(Sequence sequence)` (MotionTableManager.cs L103-108) is a DIFFERENT, unrelated `HandleEnterWorld` on a different class — it drains `PendingAnimations` via repeated `AnimationDone(false)` calls and clears link animations on the sequence. Not reachable from `MovementManager.HandleEnterWorld` (which is a no-op) — must be invoked from elsewhere (PartArray or the weenie enter-world path, out of scope). - ---- - -## 12. adjust_motion / apply_run_to_command (raw→interpreted transform helpers) - -### adjust_motion (L394-428) -```csharp -public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey) -{ - if (WeenieObj != null && !WeenieObj.IsCreature()) - return; - - switch (motion) - { - case (uint)MotionCommand.RunForward: - return; - - case (uint)MotionCommand.WalkBackwards: - motion = (uint)MotionCommand.WalkForward; - speed *= -BackwardsFactor; // -0.65 - break; - - case (uint)MotionCommand.TurnLeft: - motion = (uint)MotionCommand.TurnRight; - speed *= -1.0f; - break; - - case (uint)MotionCommand.SideStepLeft: - motion = (uint)MotionCommand.SideStepRight; - speed *= -1.0f; - break; - } - - if (motion == (uint)MotionCommand.SideStepRight) - speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); // 0.5 * (3.12/1.25) = 1.248 - - if (holdKey == HoldKey.Invalid) - holdKey = RawState.CurrentHoldKey; - - if (holdKey == HoldKey.Run) - apply_run_to_command(ref motion, ref speed); -} -``` -Canonicalizes "left/backward" variants into their "right/forward" counterparts with negated speed (single-animation-per-axis-direction retail convention: there's no separate WalkBackwards/TurnLeft/SideStepLeft animation state, just the canonical one played at negative speed). `WalkBackwards` gets an EXTRA `-BackwardsFactor` (0.65) scalar on top of the sign flip — backward walking is intentionally slower than forward. `SideStepRight` always gets rescaled by `SidestepFactor * (WalkAnimSpeed/SidestepAnimSpeed)` ≈ 1.248 regardless of hold-key, to convert a walk-speed-denominated input into the sidestep animation's own speed scale. `HoldKey.Invalid` falls back to whatever `RawState.CurrentHoldKey` currently is; if the resolved hold key is `Run`, defers to `apply_run_to_command` for the walk→run promotion. - -### apply_run_to_command (L525-562) -```csharp -public void apply_run_to_command(ref uint motion, ref float speed) -{ - var speedMod = 1.0f; - - if (WeenieObj != null) - { - var runFactor = 0.0f; - if (WeenieObj.InqRunRate(ref runFactor)) - speedMod = runFactor; - else - speedMod = MyRunRate; - } - switch (motion) - { - case (uint)MotionCommand.WalkForward: - if (speed > 0.0f) - motion = (uint)MotionCommand.RunForward; - speed *= speedMod; - break; - - case (uint)MotionCommand.TurnRight: - speed *= RunTurnFactor; // 1.5 - break; - - case (uint)MotionCommand.SideStepRight: - speed *= speedMod; - if (MaxSidestepAnimRate < Math.Abs(speed)) - speed = speed > 0.0f ? MaxSidestepAnimRate : -MaxSidestepAnimRate; - break; - } -} -``` -`WalkForward` promotes to `RunForward` ONLY if `speed > 0.0f` — i.e. a negative-speed "walk forward" (which per `adjust_motion` is actually a canonicalized WalkBackwards) does NOT get promoted to running even while the Run hold-key is active; backward movement stays at walk-animation regardless of hold-key. `TurnRight` while running gets a flat 1.5x speed multiplier (turns faster while running). `SideStepRight` gets the run-rate multiplier applied AND is then hard-clamped to ±`MaxSidestepAnimRate` (3.0) — this clamp exists ONLY in the run-path, not in the base `adjust_motion` sidestep scaling, meaning sidestep-while-walking is unclamped but sidestep-while-running is capped. - ---- - -## 13. contact_allows_move / motion_allows_jump / is_standing_still (gating predicates) - -### contact_allows_move (L584-602) -```csharp -public bool contact_allows_move(uint motion) -{ - if (PhysicsObj == null) return false; - - if (motion == (uint)MotionCommand.Dead || motion == (uint)MotionCommand.Falling || - motion >= (uint)MotionCommand.TurnRight && motion <= (uint)MotionCommand.TurnLeft) - return true; - - if (WeenieObj != null && !WeenieObj.IsCreature()) - return true; - - if (!PhysicsObj.State.HasFlag(PhysicsState.Gravity)) - return true; - if (!PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)) - return false; - if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable)) - return true; - - return false; -} -``` -Always-allowed motion classes: `Dead`, `Falling`, and the [TurnRight..TurnLeft] enum range (turning is always allowed regardless of contact — matches `apply_interpreted_movement`'s independent Turn-branch handling). Non-creature weenies bypass all contact gating. Non-gravity objects bypass gating. Otherwise: requires BOTH `Contact` AND `OnWalkable` transient flags to allow movement; `Contact` without `OnWalkable` (e.g. touching a wall/ceiling, not a floor) explicitly disallows. - -### motion_allows_jump (L770-779) -```csharp -public WeenieError motion_allows_jump(uint substate) -{ - if (substate >= (uint)MotionCommand.Reload && substate <= (uint)MotionCommand.Pickup || - substate >= (uint)MotionCommand.TripleThrustLow && substate <= (uint)MotionCommand.MagicPowerUp07Purple || - substate >= (uint)MotionCommand.MagicPowerUp01 && substate <= (uint)MotionCommand.MagicPowerUp10 || - substate >= (uint)MotionCommand.Crouch && substate <= (uint)MotionCommand.Sleeping || - substate >= (uint)MotionCommand.AimLevel && substate <= (uint)MotionCommand.MagicPray || - substate == (uint)MotionCommand.Falling) - { - return WeenieError.YouCantJumpFromThisPosition; - } - return WeenieError.None; -} -``` -Five contiguous MotionCommand enum ranges + one exact match, ALL block jumping: [Reload..Pickup], [TripleThrustLow..MagicPowerUp07Purple], [MagicPowerUp01..MagicPowerUp10], [Crouch..Sleeping], [AimLevel..MagicPray], and exactly `Falling`. This depends entirely on the ORDER of values in the `MotionCommand` enum matching retail's numeric ordering — **any acdream MotionCommand enum that doesn't preserve retail's exact ordinal layout for these ranges will silently break this range-check port.** Called from: `DoInterpretedMotion` (jump-error precompute, twice — against incoming motion AND against ForwardCommand), `StopCompletely` (against ForwardCommand before reset), `jump_is_allowed` (against ForwardCommand), `move_to_interpreted_state` (against ForwardCommand, to gate `allowJump`). - -### is_standing_still (L702-708) -```csharp -public bool is_standing_still() -{ - return PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact | TransientStateFlags.OnWalkable) && - InterpretedState.ForwardCommand == (uint)MotionCommand.Ready && - InterpretedState.SideStepCommand == 0 && - InterpretedState.TurnCommand == 0; -} -``` -Same predicate shape as the `StandingLongJump` arm-condition inlined in `charge_jump()` (§7) — grounded+walkable, Ready forward, zero sidestep, zero turn. Not called anywhere within these two files (public API surface for external callers, e.g. WeenieObj/game-logic layer, out of scope). - ---- - -## 14. Misc small methods - -- **InqStyle (L187-190):** `return InterpretedState.CurrentStyle;` — trivial accessor, returned as `long` despite `CurrentStyle` presumably being `uint` (implicit widening). -- **SetHoldKey (L274-287):** only handles `HoldKey.None` explicitly — if the NEW key is `None` AND the current key was `Run`, clears to `None` and re-applies movement; the `HoldKey.Run` case (or others) falls through the switch with NO explicit case, meaning setting the hold key TO `Run` via `SetHoldKey` is a silent no-op unless `key == RawState.CurrentHoldKey` returns early first — the only way `HoldKey.Run` actually gets set appears to be `set_hold_run` (below), not `SetHoldKey`. Early-return guard: `if (key == RawState.CurrentHoldKey) return;` — no-op if unchanged. -- **set_hold_run (L826-833):** - ```csharp - public void set_hold_run(int val, bool cancelMoveTo) - { - if ((val == 0) != (RawState.CurrentHoldKey != HoldKey.Run)) - { - RawState.CurrentHoldKey = val != 0 ? HoldKey.Run : HoldKey.None; - apply_current_movement(cancelMoveTo, true); - } - } - ``` - XOR-style guard: `(val==0) != (CurrentHoldKey != Run)` is true exactly when `val` and the "is currently Run" state disagree, i.e. this is a real toggle. Sets `HoldKey.Run` or `HoldKey.None` (never `Invalid` or others) and re-applies movement with `allowJump=true` unconditionally. -- **SetPhysicsObject (L289-293) / SetWeenieObject (L295-299):** both simply assign the field then call `apply_current_movement(true, true)` (force cancelMoveTo=true, allowJump=true) — any (re)binding of either owning object triggers a full movement re-application. -- **get_adjusted_max_speed (L618-632):** - ```csharp - public float get_adjusted_max_speed() - { - var rate = 1.0f; - if (WeenieObj != null) - { - if (!WeenieObj.InqRunRate(ref rate)) - rate = MyRunRate; - } - if (InterpretedState.ForwardCommand == (uint)MotionCommand.RunForward) - rate = InterpretedState.ForwardSpeed / CurrentSpeedFactor; - return rate * 4.0f; - } - ``` - Note: if currently running, `rate` is OVERWRITTEN entirely by `ForwardSpeed / CurrentSpeedFactor` — the `WeenieObj.InqRunRate`/`MyRunRate` lookup above becomes dead work in that branch. `4.0f` is presumably `RunAnimSpeed` inlined rather than referencing the constant (magic-number duplication — flag for acdream: use the named constant, don't inline `4.0f`). -- **get_max_speed (L665-676):** simpler sibling — `RunAnimSpeed * rate` where `rate` comes from `InqRunRate`/`MyRunRate` fallback, no ForwardCommand override. Two "max speed" methods with different semantics (adjusted = "current effective speed accounting for what's actually playing," max = "theoretical max run speed") — both present, worth checking retail naming/usage split before porting just one. -- **motions_pending (L784-787) — MotionInterp:** `return PendingMotions.Count > 0;` with an XML doc comment noting `PhysicsObj.IsAnimating` is the faster equivalent. **PhysicsObj.motions_pending() (PhysicsObj.cs L4244-4247)** is a DIFFERENT, simpler method: `return IsAnimating;` directly — i.e. PhysicsObj's own `motions_pending()` already takes the fast path the MotionInterp doc-comment recommends; **MovementManager.motions_pending() (L195-200)** delegates to `MotionInterpreter.motions_pending()` (the SLOW `Count>0` path, not the fast `IsAnimating` path) when `MotionInterpreter != null`, else returns `false` — so the three `motions_pending()` overloads across PhysicsObj/MovementManager/MotionInterp are NOT all equivalent-cost, and MovementManager's public-facing one takes the slower route. -- **move_to_interpreted_state (L789-824):** syncs an externally-supplied `InterpretedMotionState` (e.g. from a network update / DR snapshot) into this instance. Computes `allowJump` from the OLD `ForwardCommand` (before `copy_movement_from` overwrites it) via `motion_allows_jump(...) == WeenieError.None`. Replays the incoming state's `Actions` list through a **sequence-stamp wraparound comparator**: `currentStamp = action.Stamp & 0x7FFF`, `serverStamp = ServerActionStamp & 0x7FFFF` (**note: differing mask widths, `0x7FFF` (15 bits) vs `0x7FFFF` (19 bits) — likely a typo, should probably both be the same mask; flag for retail cross-check**), `deltaStamp = abs(currentStamp - serverStamp)`, and picks a "is this newer" test based on whether the delta exceeds `0x3FFF` (half of 15-bit range) — a classic sequence-number wraparound comparison. Actions only replay if `WeenieObj.IsCreature() || action.Autonomous`. -- **ReportExhaustion (L264-272):** - ```csharp - public void ReportExhaustion() - { - if (PhysicsObj == null || !Initted) return; - if (WeenieObj == null || WeenieObj.IsCreature() && PhysicsObj.movement_is_autonomous()) - apply_raw_movement(false, false); - else - apply_interpreted_movement(false, false); - } - ``` - Same raw-vs-interpreted split condition shape as `apply_current_movement` but with the WeenieObj null-check ADDED to the raw-path condition (here: `WeenieObj==null || (IsCreature && autonomous)` routes to raw; there: `WeenieObj!=null && !IsCreature || !autonomous` routes to interpreted) — these two conditions are NOT exact logical complements of each other across the two methods (worth a careful truth-table comparison before assuming they're meant to be identical dispatch logic). Both calls pass `cancelMoveTo=false, allowJump=false` — exhaustion reporting never cancels an active moveto and never allows a fresh jump. - MovementManager.ReportExhaustion (L159-165) is a thin delegate with a dead commented-out `// NoticeHandler::RecvNotice_PrevSpellSelection` line, same as HandleEnterWorld. - ---- - -## 15. MovementManager remaining surface (not covered above) - -- **CancelMoveTo / HandleUpdateTarget / IsMovingTo / MakeMoveToManager / UseTime:** all thin null-guarded delegates to `MoveToManager` — no MotionInterp involvement. `MakeMoveToManager()` (L112-116) lazy-constructs `MoveToManager` WITHOUT calling any init-state method (contrast with the `MotionInterpreter` lazy-construct sites which always follow with `enter_default_state()`). -- **InqInterpretedMotionState / InqRawMotionState / get_minterp:** identical three-line lazy-construct-with-enter_default_state pattern, just returning a different field (`.InterpretedState`, `.RawState`, or the interpreter itself). -- **unpack_movement(object addr, uint size) (L213):** empty body — `{ }`. Stub/no-op, presumably a retail network-deserialization entry point ACE doesn't need (server already has structured data, doesn't need to unpack a wire buffer here) or hasn't ported. Flag: if named-retail decomp shows this doing real work, it's client-side-only logic ACE correctly skips (server doesn't need to interpret its own outbound format), OR it's a genuine gap — check the decomp before assuming either way. - ---- - -## Summary table — ACE method inventory vs retail-decomp cross-check TODO - -| ACE method | File:Line | Retail decomp checked this pass? | -|---|---|---| -| `add_to_queue` | MotionInterp.cs:388 | No — ACE-only pass | -| `MotionDone` | MotionInterp.cs:210 | No | -| `DoMotion` | MotionInterp.cs:112 | No | -| `DoInterpretedMotion` | MotionInterp.cs:51 | No | -| `StopMotion` | MotionInterp.cs:367 | No | -| `StopInterpretedMotion` | MotionInterp.cs:329 | No | -| `StopCompletely` | MotionInterp.cs:301 | No | -| `HitGround` | MotionInterp.cs:175 | No | -| `LeaveGround` | MotionInterp.cs:192 | No | -| `jump` | MotionInterp.cs:710 | No | -| `jump_is_allowed` | MotionInterp.cs:742 | No — **flag: possible null-guard typo L747** | -| `jump_charge_is_allowed` | MotionInterp.cs:729 | No | -| `charge_jump` | MotionInterp.cs:564 | No — **flag: Falling/Fallen + range-inclusivity mismatch vs jump_charge_is_allowed** | -| `get_jump_v_z` | MotionInterp.cs:634 | No | -| `get_leave_ground_velocity` | MotionInterp.cs:654 | No | -| `enter_default_state` | MotionInterp.cs:604 | No — **flag: `// ??` on PendingMotions reset** | -| `apply_raw_movement` | MotionInterp.cs:506 | No | -| `apply_interpreted_movement` | MotionInterp.cs:440 | No — **flag: inline PhysicsObj.StopInterpretedMotion duplication in Turn-stop branch** | -| `apply_current_movement` | MotionInterp.cs:430 | No | -| `adjust_motion` | MotionInterp.cs:394 | No | -| `apply_run_to_command` | MotionInterp.cs:525 | No | -| `contact_allows_move` | MotionInterp.cs:584 | No | -| `motion_allows_jump` | MotionInterp.cs:770 | No — **depends on MotionCommand enum ordinal layout matching retail exactly** | -| `move_to_interpreted_state` | MotionInterp.cs:789 | No — **flag: 0x7FFF vs 0x7FFFF mask width mismatch** | -| `MovementManager.PerformMovement` | MovementManager.cs:124 | No | -| `MovementManager.HandleEnterWorld` | MovementManager.cs:48 | No — **dead/commented; retail NoticeHandler::RecvNotice_PrevSpellSelection not ported** | -| `MotionTableManager.AnimationDone` | MotionTableManager.cs:28 | No | -| `MotionTableManager.CheckForCompletedMotions` | MotionTableManager.cs:63 | No | - -All rows above are candidates for Step 0 (`grep named-retail/acclient_2013_pseudo_c.txt` by `CMotionInterp::` — note retail almost certainly uses the `C`-prefixed class name ACE dropped) before any acdream port work proceeds, per CLAUDE.md's mandatory workflow. diff --git a/docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md b/docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md deleted file mode 100644 index f2bb898a..00000000 --- a/docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md +++ /dev/null @@ -1,1705 +0,0 @@ -# R3 — CMotionInterp completion + MovementManager relay: verbatim decomp extraction - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (line numbers = file -line numbers, not addresses). Structs: `docs/research/named-retail/acclient.h`. -Already ported (S2a/D6, NOT re-extracted here): `move_to_interpreted_state`, -`apply_interpreted_movement`, `DoInterpretedMotion`, `StopInterpretedMotion`, -`contact_allows_move`, `adjust_motion`, `apply_run_to_command`, `apply_raw_movement`, -`get_state_velocity`. - ---- - -## 0. Key structs (acclient.h) - -```c -/* acclient.h:31407 — struct #3457 */ -struct __cppobj CMotionInterp -{ - int initted; - CWeenieObject *weenie_obj; - CPhysicsObj *physics_obj; - RawMotionState raw_state; - InterpretedMotionState interpreted_state; - float current_speed_factor; - int standing_longjump; - float jump_extent; - unsigned int server_action_stamp; - float my_run_rate; - LList pending_motions; -}; - -/* acclient.h:53293 — struct #5857 */ -struct __cppobj CMotionInterp::MotionNode : LListData -{ - unsigned int context_id; // +4 (offset from LListData's own +0 next-ptr) - unsigned int motion; // +8 — the "action-class" field; bit 0x10000000 = action-class flag - unsigned int jump_error_code; // +0xc -}; - -/* acclient.h:30943 — struct #3463 */ -struct __cppobj MovementManager -{ - CMotionInterp *motion_interpreter; - MoveToManager *moveto_manager; - CPhysicsObj *physics_obj; - CWeenieObject *weenie_obj; -}; - -/* acclient.h:38069 — struct #4067 */ -struct __cppobj MovementStruct -{ - MovementTypes::Type type; - unsigned int motion; - unsigned int object_id; - unsigned int top_level_id; - Position pos; - float radius; - float height; - MovementParameters *params; -}; - -/* acclient.h:2856 — enum #229 */ -enum MovementTypes::Type -{ - Invalid = 0x0, - RawCommand = 0x1, - InterpretedCommand = 0x2, - StopRawCommand = 0x3, - StopInterpretedCommand = 0x4, - StopCompletely = 0x5, - MoveToObject = 0x6, - MoveToPosition = 0x7, - TurnToObject = 0x8, - TurnToHeading = 0x9, - FORCE_Type_32_BIT = 0x7FFFFFFF, -}; - -/* acclient.h:31453 — struct #3460 */ -struct __cppobj MovementParameters : PackObj -{ - union { unsigned int bitfield; ... } ___u1; // flags: bit0x8=SetHoldKey, bit0x20=RawMotionState::ApplyMotion/RemoveMotion mirror, bit0x40=InterpretedMotionState::ApplyMotion/RemoveMotion, bit0x80000000(sign)=interrupt_current_movement, bit0x1ee0f default set (see MovementParameters ctor) - float distance_to_object; - float min_distance; - float desired_heading; - float speed; - float fail_distance; - float walk_run_threshhold; - unsigned int context_id; - HoldKey hold_key_to_apply; - unsigned int action_stamp; -}; -``` - -`MovementParameters` default ctor (line 300510-300534, `00524380`): `min_distance=0`, -`distance_to_object=0.6`, `fail_distance=FLT_MAX`, `desired_heading=0`, `speed=1`, -`walk_run_threshhold=15`, `context_id=0`, `hold_key_to_apply=HoldKey_Invalid`, -`action_stamp=0`, bitfield defaults to `(bitfield & 0xfffdee0f) | 0x1ee0f` via a static -cached template (`normal_bitfield`). - ---- - -## 1. `pending_motions` lifecycle - -### 1a. `CMotionInterp::add_to_queue` — `00527b80` @ line 305032 - -```c -00527b80 void __thiscall CMotionInterp::add_to_queue(class CMotionInterp* this, uint32_t arg2, uint32_t arg3, uint32_t arg4) -00527b80 { -00527b85 class LListData* eax = operator new(0x10); -00527b8f if (eax == 0) -00527bae eax = nullptr; -00527b8f else -00527b8f { -00527b99 *(int32_t*)((char*)eax + 4) = arg2; // MotionNode.context_id = arg2 -00527ba0 eax->llist_next = 0; -00527ba6 *(int32_t*)((char*)eax + 8) = arg3; // MotionNode.motion = arg3 -00527ba9 *(int32_t*)((char*)eax + 0xc) = arg4; // MotionNode.jump_error_code = arg4 -00527b8f } -00527b8f -00527bb0 class LListData** tail_ = this->pending_motions.tail_; -00527bb0 -00527bb8 if (tail_ != 0) -00527bb8 { -00527bba *(uint32_t*)tail_ = eax; -00527bbc this->pending_motions.tail_ = eax; -00527bc3 return; -00527bb8 } -00527bb8 -00527bc6 this->pending_motions.head_ = eax; -00527bcc this->pending_motions.tail_ = eax; -00527b80 } -``` - -Cleaned flow: allocate a `MotionNode{context_id=arg2, motion=arg3, jump_error_code=arg4}`, -append to the singly-linked `pending_motions` queue (append at tail; if queue was empty, -set both head and tail). - -**Callers** (every enqueue point in CMotionInterp): -- `StopCompletely` (line 305227): `add_to_queue(this, 0, 0x41000003 /*forward=none*/, eax_2)` where `eax_2 = motion_allows_jump(this, interpreted_state.forward_command)`. -- `DoInterpretedMotion` (line 305607): `add_to_queue(this, arg3->context_id, arg2 /*the motion just applied*/, eax_5)` where `eax_5` is the jump-error-code computed just above (see §3 `motion_allows_jump`). -- `StopInterpretedMotion` (line 305657): `add_to_queue(this, arg3->context_id, 0x41000003, result_1)` — always re-queues a "return to none" motion node after a successful stop. -- `apply_interpreted_movement` (line 305775): `add_to_queue(this, var_c /*uninitialized in decomp — context_id*/, 0x41000003, eax_10)` when the fallback "stop 0x6500000d" path succeeds. - -### 1b. `CMotionInterp::motions_pending` — `00527fe0` @ line 305322 - -```c -00527fe0 int32_t __fastcall CMotionInterp::motions_pending(class CMotionInterp const* this) -00527fe0 { -00527fea int32_t result; -00527fea result = this->pending_motions.head_ != 0; -00527fed return result; -00527fe0 } -``` - -Cleaned: `motions_pending()` = `pending_motions.head_ != null`. Non-empty queue means -"at least one motion in flight waiting for a `MotionDone` callback." - -**Caller (`MovementManager::motions_pending`, `00524280` @ line 300365):** -```c -00524280 int32_t __fastcall MovementManager::motions_pending(class MovementManager const* this) -00524280 { -00524280 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524284 if ((motion_interpreter != 0 && CMotionInterp::motions_pending(motion_interpreter) != 0)) -00524294 return 1; -00524294 return 0; -00524280 } -``` - -### 1c. `CMotionInterp::MotionDone` — `00527ec0` @ line 305238 (FULL BODY) - -```c -00527ec0 void __fastcall CMotionInterp::MotionDone(class CMotionInterp* this, int32_t arg2) -00527ec0 { -00527ec3 class CPhysicsObj* physics_obj = this->physics_obj; -00527ec8 if (physics_obj != 0) -00527ec8 { -00527eca class LListData* head_ = this->pending_motions.head_; -00527ed2 if (head_ != 0) -00527ed2 { -00527edb if ((*(int32_t*)((char*)head_ + 8) & 0x10000000) != 0) // MotionNode.motion & 0x10000000 (action-class bit) -00527edb { -00527edd CPhysicsObj::unstick_from_object(physics_obj); -00527ee5 InterpretedMotionState::RemoveAction(&this->interpreted_state); -00527eed RawMotionState::RemoveAction(&this->raw_state); -00527edb } -00527edb -00527ef2 class LListData* head__1 = this->pending_motions.head_; -00527efa if (head__1 != 0) -00527efa { -00527efc class LListData* llist_next = head__1->llist_next; -00527f00 this->pending_motions.head_ = llist_next; -00527f06 if (llist_next == 0) -00527f08 this->pending_motions.tail_ = llist_next; -00527f08 -00527f0f head__1->llist_next = 0; -00527f15 operator delete(head__1); -00527efa } -00527ed2 } -00527ec8 } -00527ec0 } -``` - -Cleaned flow: `MotionDone(arg2)` — note `arg2` (the "was interrupted?" flag from the -caller) is **read into `edx` but never actually used** in this build (dead parameter -per the `MovementManager::MotionDone` relay below, which loads `var_4_1 = arg3` then -passes an uninitialized `edx` through — a decompiler artifact, but functionally this -build's `CMotionInterp::MotionDone` body never branches on `arg2`). The real logic: - -1. If `physics_obj == null`, no-op (motion interpreter isn't attached). -2. If `pending_motions` is empty, no-op. -3. Peek the **head** node. If `head.motion & 0x10000000` (the **action-class flag**, - i.e. this pending motion was queued via the `0x10000000`-flagged multi-action path — - see `DoMotion`'s `InterpretedMotionState::GetNumActions(...) >= 6` gate and - `move_to_interpreted_state`'s per-action replay loop), then: - - `CPhysicsObj::unstick_from_object(physics_obj)` — release any "stuck to object" - sticky-manager attachment associated with this action. - - `InterpretedMotionState::RemoveAction(&interpreted_state)` — pop the action-list - head off the interpreted state's `actions` queue. - - `RawMotionState::RemoveAction(&raw_state)` — same for raw state. -4. Unconditionally pop the queue head (dequeue + `delete` the `LListData` node), - updating `tail_` to null if the queue is now empty. - -**No explicit `CheckForCompletedMotions` on `CMotionInterp` itself** — the actual -"walk the completed-animation list and fire callbacks" driver lives on -`MotionTableManager`/`CPartArray` (see §6). `CMotionInterp::MotionDone` is the -*consumer* end of that pipeline: `CPhysicsObj::MotionDone` → `MovementManager::MotionDone` -→ `CMotionInterp::MotionDone`, called once per completed animation node. - -### 1d. `CMotionInterp::HandleExitWorld` — `00527f30` (drains the whole queue, same action-class handling, looped until empty) - -```c -00527f30 void __fastcall CMotionInterp::HandleExitWorld(class CMotionInterp* this) -00527f30 { -00527f3b for (class LListData* head_ = this->pending_motions.head_; head_ != 0; head_ = this->pending_motions.head_) -00527f3b { -00527f40 class CPhysicsObj* physics_obj = this->physics_obj; -00527f49 if ((physics_obj != 0 && head_ != 0)) -00527f49 { -00527f52 if ((*(int32_t*)((char*)head_ + 8) & 0x10000000) != 0) -00527f52 { -00527f54 CPhysicsObj::unstick_from_object(physics_obj); -00527f5c InterpretedMotionState::RemoveAction(&this->interpreted_state); -00527f64 RawMotionState::RemoveAction(&this->raw_state); -00527f52 } -00527f52 -00527f69 class LListData* head__1 = this->pending_motions.head_; -00527f71 if (head__1 != 0) -00527f71 { -00527f73 class LListData* llist_next = head__1->llist_next; -00527f77 this->pending_motions.head_ = llist_next; -00527f7d if (llist_next == 0) -00527f7f this->pending_motions.tail_ = llist_next; -00527f7f -00527f86 head__1->llist_next = 0; -00527f8c operator delete(head__1); -00527f71 } -00527f49 } -00527f3b } -00527f30 } -``` - -Identical body to `MotionDone`'s single-pop logic, just looped until the queue drains -(world exit = flush all pending motion callbacks unconditionally, since no more -animation-completion events will arrive). - ---- - -## 2. `DoMotion` family — `00528d20` @ line 306159 - -```c -00528d20 uint32_t __thiscall CMotionInterp::DoMotion(class CMotionInterp* this, uint32_t arg2, class MovementParameters const* arg3) -00528d20 { -00528d26 class CPhysicsObj* physics_obj = this->physics_obj; -00528d2b if (physics_obj == 0) -00528d36 return 8; -00528d36 -00528d3a uint32_t ebp = arg2; // ebp = original motion id (unmutated copy) -00528d46 union __inner0 = arg3->__inner0; // MovementParameters bitfield -00528d4b float distance_to_object = arg3->distance_to_object; -00528d52 float min_distance = arg3->min_distance; -00528d59 float desired_heading = arg3->desired_heading; -00528d60 float speed = arg3->speed; -00528d67 float fail_distance = arg3->fail_distance; -00528d6e float walk_run_threshhold = arg3->walk_run_threshhold; -00528d75 uint32_t context_id = arg3->context_id; -00528d7c enum HoldKey hold_key_to_apply = arg3->hold_key_to_apply; -00528d80 uint32_t action_stamp = arg3->action_stamp; -00528d83 arg2 = ebp; -00528d87 int32_t var_2c = 0x7c83f8; // local MovementParameters vtable stamp (stack copy) -00528d8f union __inner0_2 = __inner0; -00528d93 uint32_t action_stamp_1 = action_stamp; -00528d93 -00528d97 if (*(uint8_t*)((char*)__inner0 + 1) < 0) // bitfield high-byte sign bit => "interrupt" flag -00528d99 CPhysicsObj::interrupt_current_movement(physics_obj); -00528d99 -00528da4 if ((*(uint8_t*)((char*)__inner0_1 + 1) & 8) != 0) // bit 0x800 => SetHoldKey requested -00528db3 CMotionInterp::SetHoldKey(this, arg3->hold_key_to_apply, ((__inner0_1 >> 0xf) & 1)); -00528db3 -00528dc8 CMotionInterp::adjust_motion(this, &arg2, &speed, arg3->hold_key_to_apply); -00528dc8 -00528dd4 if (this->interpreted_state.current_style != 0x8000003d) // not "MotionStance_NonCombat" (the free/uninhibited style) -00528dd4 { -00528ddd if (ebp == 0x41000012) -00528e22 return 0x3f; -00528e22 -00528de0 if (ebp == 0x41000013) -00528e14 return 0x40; -00528e14 -00528de3 if (ebp == 0x41000014) -00528e06 return 0x41; -00528e06 -00528deb if ((ebp & 0x2000000) != 0) -00528df8 return 0x42; -00528dd4 } -00528dd4 -00528e2b if (((ebp & 0x10000000) != 0 && InterpretedMotionState::GetNumActions(&this->interpreted_state) >= 6)) -00528e45 return 0x45; -00528e45 -00528e55 uint32_t result = CMotionInterp::DoInterpretedMotion(this, arg2, &var_2c); -00528e55 -00528e66 if ((result == 0 && (*(uint8_t*)((char*)((int16_t)arg3->__inner0))[1] & 0x20) != 0)) // bit 0x2000 => mirror to raw_state -00528e6d RawMotionState::ApplyMotion(&this->raw_state, ebp, arg3); -00528e6d -00528e7b return result; -00528d20 } -``` - -Cleaned flow: - -1. No-op (return `8` = "no physics object") if `physics_obj` is null. -2. Snapshot every `MovementParameters` field onto the stack (locals), then rebuild a - **fresh local `MovementParameters` (`var_2c`)** — this is `DoMotion` defaulting: the - incoming `arg3` is copied field-by-field but the call into `DoInterpretedMotion` - below passes the **freshly-defaulted local**, not the caller's `arg3`, except for - the mutated `arg2`/`speed`/`hold_key_to_apply` triple that `adjust_motion` writes in - place. In other words: `DoMotion` re-derives a canonical `MovementParameters` from - the caller's flags/speed/holdkey, discarding caller-supplied distance/heading/ - fail-distance fields for the interpreted-motion call (those only matter for - MoveTo-family commands, not raw DoMotion). -3. If the incoming bitfield's sign bit is set → `interrupt_current_movement` (cancel - any physics-level transition in progress before applying the new motion). -4. If bit `0x800` (`SetHoldKey` flag) is set → call `SetHoldKey(hold_key_to_apply, bit0xf)` - BEFORE `adjust_motion` runs, so the new hold-key affects the walk/run - reinterpretation below. -5. `adjust_motion(&arg2, &speed, hold_key_to_apply)` — this is the D6-ported function; - mutates `arg2` (the motion id) and `speed` in place per the retail walk/run/sidestep - reinterpretation table. -6. **Combat-stance gate**: if `interpreted_state.current_style != 0x8000003d` - (i.e. the creature is in ANY combat/special stance, not the neutral - `MotionStance_NonCombat`), then jump-charge motions (`0x41000012`/`13`/`14`) and any - motion with bit `0x2000000` set are **rejected outright** with distinct error codes - `0x3f`/`0x40`/`0x41`/`0x42` — you cannot charge or release a jump while in combat - stance, non-combat with bow drawn, etc. -7. **Action-queue depth gate**: if the motion is an "action-class" motion (bit - `0x10000000` set) AND `InterpretedMotionState::GetNumActions(...) >= 6`, reject with - `0x45` ("too many pending actions" — 6 is the hard cap on queued interpreted - actions). -8. Otherwise delegate to `DoInterpretedMotion(this, arg2, &var_2c)` (the already-ported - function) using the **locally reconstructed** `MovementParameters`. -9. If that succeeded AND bit `0x2000` of the ORIGINAL `arg3->__inner0` is set (mirror- - to-raw flag), replay the motion into `raw_state` via `RawMotionState::ApplyMotion`. -10. Return whatever `DoInterpretedMotion` returned (0 = success, propagated error code - otherwise). - -**Relationship to `DoInterpretedMotion`**: `DoMotion` is a thin **gating + defaulting -wrapper** around `DoInterpretedMotion`. It exists specifically for the `RawCommand` -movement-type entry point (`MovementTypes::Type::RawCommand = 1`, dispatched via -`CMotionInterp::PerformMovement` case 0, see §5), whereas `InterpretedCommand` (type 2, -case 1) calls `DoInterpretedMotion` directly with the caller's own `MovementParameters` -(no combat-stance / action-depth gating, no forced-local-defaulting). So: raw commands -(keyboard input from THIS client, i.e. autonomous local movement) get extra validation -that interpreted/remote commands skip. - ---- - -## 3. Jump family - -### 3a. `CMotionInterp::motion_allows_jump` — `005279e0` @ line 304908 (`__pure`) - -```c -005279e0 uint32_t __stdcall CMotionInterp::motion_allows_jump(class CMotionInterp* this @ ecx, uint32_t arg2) __pure -005279e0 { -005279e9 if (arg2 > 0x40000018) -005279e9 { -00527a24 if (arg2 > 0x41000014) -00527a10 return 0; -00527a10 -00527a39 if ((arg2 < 0x41000012 && (arg2 < 0x4000001e || arg2 > 0x40000039))) -00527a10 return 0; -005279e9 } -005279e9 else if (arg2 < 0x40000016) -005279f0 { -005279f7 if (arg2 > 0x10000131) -005279f7 { -00527a18 if (arg2 != 0x40000008) -00527a1c return 0; -005279f7 } -005279f7 else if ((arg2 < 0x10000128 && (arg2 < 0x1000006f || arg2 > 0x10000078))) -00527a10 return 0; -005279f0 } -005279f0 -00527a40 return 0x48; -005279e0 } -``` - -Cleaned **[polarity corrected per W0-pins A1 — the original note here was inverted]**: -returns `0x48` = jump BLOCKED ("YouCantJumpFromThisPosition"-class error code) for a -specific **blocklist** of motion-id ranges, else `0` = jump allowed (pass). Callers -treat nonzero as the error (`jump_is_allowed` @305541: `if (eax_7 != 0) return eax_7;`; -`jump` 0x00528780 executes only when the chain returns 0). Blocked set: -- `arg2 in [0x1000006f, 0x10000078]` (MagicPowerUp01..MagicPowerUp10) — BLOCKED. -- `arg2 in [0x10000128, 0x10000131]` (TripleThrustLow..MagicPowerUp07Purple) — BLOCKED. -- `arg2 == 0x40000008` (**Fallen** — NOT Falling; ACE mis-transcribed this as Falling) — BLOCKED. -- `arg2 == 0x40000016` through `0x40000018` inclusive (Reload/Unload/Pickup) — BLOCKED - (falls through the `else if` since neither branch passes it; note `< 0x40000016` is - the else-if guard, so `0x40000016..0x40000018` skip both inner branches and hit - `return 0x48` directly). -- `arg2 in [0x4000001e, 0x40000039]` (AimLevel..MagicPray) — BLOCKED. -- `arg2 in [0x41000012, 0x41000014]` (Crouch/Sitting/Sleeping) — BLOCKED. -- everything else → `0` (pass), including **Falling `0x40000015`**, Ready `0x41000003`, - Dead `0x40000011`, and all ids > `0x41000014` (turns etc.). - -This is the "can retail let you jump given the motion currently applying" check — used -by `StopCompletely`, `DoInterpretedMotion`, `move_to_interpreted_state` to compute the -`jump_error_code` stashed in each `MotionNode`. - -### 3b. `CMotionInterp::jump_charge_is_allowed` — `00527a50` @ line 304935 - -```c -00527a50 uint32_t __fastcall CMotionInterp::jump_charge_is_allowed(class CMotionInterp* this) -00527a50 { -00527a53 class CWeenieObject* weenie_obj = this->weenie_obj; -00527a58 if ((weenie_obj != 0 && weenie_obj->vtable->CanJump(this->jump_extent) == 0)) -00527a6d return 0x49; -00527a6d -00527a6e uint32_t forward_command = this->interpreted_state.forward_command; -00527a87 if ((forward_command != 0x40000008 && (forward_command <= 0x41000011 || forward_command > 0x41000014))) -00527a8c return 0; -00527a8c -00527a93 return 0x48; -00527a50 } -``` - -Cleaned **[polarity corrected per W0-pins A1]**: `0x49` if the weenie's -`CanJump(jump_extent)` virtual rejects the current charge amount (e.g. stamina/burden -gate). `0x48` = charge BLOCKED if the **current interpreted forward_command** is either -exactly `0x40000008` (Fallen — prone) OR strictly inside `(0x41000011, 0x41000014]` -(Crouch/Sitting/Sleeping `0x41000012..0x41000014`); anything else → `0` = charge -allowed (pass). - -### 3c. `CMotionInterp::get_jump_v_z` — `00527aa0` @ line 304953 - -```c -00527aa0 float __fastcall CMotionInterp::get_jump_v_z(class CMotionInterp const* this) -00527aa0 { -00527aa0 class CMotionInterp* jump_extent = this; -00527aa4 jump_extent = this->jump_extent; -00527aac long double temp0 = ((long double)0.000199999995f); -00527ab2 float result; -00527ab2 result = /* jump_extent < 0.0002f (epsilon) ? */; -00527ab4 bool p = /* test ah, 0x5 — i.e. "jump_extent < 0.0002" */; -00527ab7 if (p) -00527ab7 { -00527ab9 long double x87_r7_1 = ((long double)jump_extent); -00527abd long double temp1_1 = ((long double)1f); -00527ac3 result = /* jump_extent < 1.0f */; -00527ac8 if ((*(uint8_t*)((char*)result)[1] & 0x41) == 0) // i.e. jump_extent >= 1.0 -00527aca jump_extent = 0x3f800000; // clamp jump_extent to 1.0f -00527aca -00527ad2 class CWeenieObject* weenie_obj = this->weenie_obj; -00527ad7 if (weenie_obj == 0) -00527ae0 return result; -00527ae0 -00527aed result = weenie_obj->vtable->InqJumpVelocity(jump_extent, &jump_extent); -00527ab7 } -00527ab7 -00527b01 return result; -00527aa0 } -``` - -Cleaned: if `jump_extent < 0.0002` (essentially zero — no charge held), **`result` is -left as the raw FCOM-flags byte from the comparison, effectively returning 0** (this -looks like a decompiler mis-render of a `fldz`-style zero return, not a real -"return the comparison flags" — the practical behavior is: **near-zero charge returns -0 velocity contribution**, deferred entirely to the `else` path). If `jump_extent >= 0.0002`, -clamp `jump_extent` to `[extent, 1.0]` (values ≥ 1.0 get clamped to exactly `1.0f`), -then if there's a weenie object, call its `InqJumpVelocity(jump_extent, &jump_extent)` -virtual to get the actual world-space jump Z velocity for that charge fraction -(this is where creature-specific jump power / `JUMP_STAMINA` scaling lives — outside -CMotionInterp, in `CWeenieObject`/`PlayerWeenie`). If no weenie object, returns the -clamped `jump_extent` directly as the velocity (fallback for non-weenie physics -objects). - -### 3d. `CMotionInterp::get_leave_ground_velocity` — `005280c0` @ line 305404 - -```c -005280c0 void __thiscall CMotionInterp::get_leave_ground_velocity(class CMotionInterp* this, class AC1Legacy::Vector3* arg2) -005280c0 { -005280c4 class AC1Legacy::Vector3* esi = arg2; -005280cc CMotionInterp::get_state_velocity(this, esi); // fills esi.x/esi.y from interpreted forward/sidestep speed (D6-ported) -005280d8 arg2 = ((float)CMotionInterp::get_jump_v_z(this)); -005280e2 long double x87_r0_2 = fabsl(((long double)esi->x)); -005280e4 esi->z = arg2; // esi.z = jump vertical velocity -005280e7 long double x87_r7 = ((long double)0.000199999995f); -005280ef /* if fabs(esi.x) < 0.0002 */ -005280f4 if ( /* fabs(esi.x) < 0.0002 */ ) -005280f4 { -005280fd long double x87_r0_4 = fabsl(((long double)esi->y)); -00528105 /* if fabs(esi.y) < 0.0002 */ -0052810c if ( /* fabs(esi.y) < 0.0002 */ ) -0052810c { -00528116 long double x87_r0_6 = fabsl(((long double)arg2)); // fabs(esi.z) -0052811e /* if fabs(esi.z) < 0.0002 */ -00528125 if ( /* fabs(esi.z) < 0.0002 */ ) -00528125 { -0052812b class CPhysicsObj* physics_obj = this->physics_obj; -0052814b // esi = physics_obj->m_velocityVector transformed by m_position.frame.m_fl2gv (local->global velocity basis, 3x3-ish) -0052814b long double x87_r0_10 = (m_fl2gv[1]*vel.y + m_fl2gv[0]*vel.x) + m_fl2gv[2]*vel.z; -00528172 long double x87_r0_14 = (m_fl2gv[4]*vel.y + m_fl2gv[3]*vel.x) + m_fl2gv[5]*vel.z; -0052818e long double x87_r0_17 = m_fl2gv[7]*vel.y + m_fl2gv[6]*vel.x; -00528196 long double x87_r7_14 = m_fl2gv[8]*vel.z; -0052819c esi->x = ((float)x87_r0_10); -0052819e esi->y = ((float)x87_r0_14); -005281ab esi->z = ((float)(x87_r0_17 + x87_r7_14)); -00528125 } -0052810c } -005280f4 } -005280c0 } -``` - -Cleaned flow: compute the leave-ground (jump) launch velocity vector. - -1. Start with `get_state_velocity(esi)` — the horizontal (x/y) velocity implied by the - current interpreted forward/sidestep speed (already ported in D6). -2. Set `esi.z = get_jump_v_z(this)` — the vertical jump-charge velocity. -3. **Fallback**: if the resulting velocity vector is essentially zero on ALL THREE axes - (`|x| < 0.0002 && |y| < 0.0002 && |z| < 0.0002` — i.e. no forward/sidestep motion AND - no jump charge, e.g. leaving ground via a fall-off-edge rather than an active jump), - overwrite `esi` entirely with the **physics object's current local velocity - transformed into global space** via the `m_position.frame.m_fl2gv` 3x3 local→global - basis matrix (a 9-float row-major array: `m_fl2gv[0..8]`). This preserves momentum - from e.g. being pushed off a ledge, rather than snapping to zero velocity. - -`m_fl2gv` — "frame local to global vector" transform matrix, part of `Position.frame`. - -**Caller**: `LeaveGround` (§4b) — this is the velocity CMotionInterp hands to -`CPhysicsObj::set_local_velocity` the instant the mover leaves the ground. - -### 3e. `CMotionInterp::charge_jump` — `005281c0` @ line 305448 - -```c -005281c0 uint32_t __fastcall CMotionInterp::charge_jump(class CMotionInterp* this) -005281c0 { -005281c3 class CWeenieObject* weenie_obj = this->weenie_obj; -005281c8 if ((weenie_obj != 0 && weenie_obj->vtable->CanJump(this->jump_extent) == 0)) -005281dd return 0x49; -005281dd -005281de uint32_t forward_command = this->interpreted_state.forward_command; -005281f7 if ((forward_command == 0x40000008 || (forward_command > 0x41000011 && forward_command <= 0x41000014))) -00528233 return 0x48; -00528233 -005281fc uint8_t transient_state = ((int8_t)this->physics_obj->transient_state); -00528222 if (((transient_state & 1) != 0 && ((transient_state & 2) != 0 && (forward_command == 0x41000003 && (this->interpreted_state.sidestep_command == 0 && this->interpreted_state.turn_command == 0))))) -00528224 this->standing_longjump = 1; -00528224 -0052822c return 0; -005281c0 } -``` - -Cleaned **[polarity corrected per W0-pins A1]**: `0x49` if `CanJump` virtual rejects -the current charge. `0x48` = charge BLOCKED if `forward_command` is `0x40000008` -(Fallen) or in `[0x41000012,0x41000014]` (Crouch/Sitting/Sleeping) — the same blocked -set as `jump_charge_is_allowed`'s gate. Otherwise: -`standing_longjump = 1` **iff** the physics object is currently grounded -(`transient_state` bits `0x1` AND `0x2` both set — matches `CPhysicsObj::on_ground`, -§3g) AND the mover is perfectly idle (`forward_command == 0x41000003 /*none*/`, -`sidestep_command == 0`, `turn_command == 0`) — i.e. a **standing** long-jump charge -(distinct code path from a running jump). Returns `0` = success (charge accepted, flag -set if it was a standing charge). - -**Caller** (from outside CMotionInterp, line 376144, `0056afac`): -``` -uint32_t eax_3 = CMotionInterp::charge_jump(CPhysicsObj::get_minterp(SmartBox::smartbox->player)); -``` -Reached via the player-input/SmartBox layer — outside R3 scope (input handling), noted -for completeness only. - -### 3f. `CMotionInterp::jump` — `00528780` @ line 305792 - -```c -00528780 uint32_t __thiscall CMotionInterp::jump(class CMotionInterp* this, float arg2, int32_t* arg3) -00528780 { -00528783 class CPhysicsObj* physics_obj = this->physics_obj; -00528788 if (physics_obj == 0) -00528790 return 8; -00528790 -00528794 CPhysicsObj::interrupt_current_movement(physics_obj); -005287a5 uint32_t result = CMotionInterp::jump_is_allowed(this, arg2, arg3); -005287ae if (result != 0) -005287ae { -005287ca this->standing_longjump = 0; -005287d2 return result; -005287ae } -005287ae -005287b4 class CPhysicsObj* physics_obj_1 = this->physics_obj; -005287b8 this->jump_extent = arg2; -005287bb CPhysicsObj::set_on_walkable(physics_obj_1, result); // result == 0 here, so set_on_walkable(false) -005287c4 return result; -00528780 } -``` - -Cleaned: `arg2` = jump extent/charge fraction (`0.0`..`1.0`), `arg3` = out-param for -stamina cost (threaded through `jump_is_allowed` → `JumpStaminaCost`). `8` if no -physics object. Always calls `interrupt_current_movement` first (cancel any pending -transition). Calls `jump_is_allowed(arg2, arg3)` (§3h below); on failure, **clears** -`standing_longjump` (a failed jump attempt cancels any pending standing-longjump flag) -and returns the error. On success (`result == 0`): store `jump_extent = arg2`, then -`CPhysicsObj::set_on_walkable(physics_obj, 0)` — explicitly mark the physics object as -**no longer on a walkable surface** (the jump has begun; this is what triggers the -ground→air physics transition that eventually calls back into `LeaveGround`). - -### 3g. `CPhysicsObj::on_ground` — `00527b20` @ line 304996 (context, referenced by 3e) - -```c -00527b20 int32_t __fastcall CPhysicsObj::on_ground(class CPhysicsObj const* this) -00527b20 { -00527b20 uint8_t transient_state = ((int8_t)this->transient_state); -00527b2c if (((transient_state & 1) != 0 && (transient_state & 2) != 0)) -00527b33 return 1; -00527b33 return 0; -00527b20 } -``` - -Both `transient_state` bit `0x1` and bit `0x2` must be set for "on ground" — same test -inlined directly in `charge_jump`, `contact_allows_move`, `is_standing_still`. - -### 3h. `CMotionInterp::jump_is_allowed` — `005282b0` (context — feeds `jump()`, §3f) - -```c -005282b0 uint32_t __thiscall CMotionInterp::jump_is_allowed(class CMotionInterp* this, float arg2, int32_t* arg3) -005282b0 { -005282b8 if (this->physics_obj != 0) -005282b8 { -005282ba class CWeenieObject* weenie_obj = this->weenie_obj; -005282bf int32_t eax_2; -005282bf if (weenie_obj != 0) -005282c3 eax_2 = weenie_obj->vtable->IsCreature(); -005282c3 -005282c8 if ((weenie_obj != 0 && eax_2 == 0)) // non-creature weenie => skip contact/ground gating -005282c8 { -005282f6 label_5282f6: -005282fd if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0) -00528305 return 0x47; -00528305 -00528308 class LListData* head_ = this->pending_motions.head_; -00528310 uint32_t eax_6; -00528310 if (head_ != 0) -00528312 eax_6 = *(int32_t*)((char*)head_ + 0xc); // peek head's jump_error_code -00528312 -00528317 if ((head_ == 0 || eax_6 == 0)) -00528317 { -0052831b eax_6 = CMotionInterp::jump_charge_is_allowed(this); -0052831b -00528322 if (eax_6 == 0) -00528322 { -0052832b uint32_t eax_7 = CMotionInterp::motion_allows_jump(this, this->interpreted_state.forward_command); -0052832b -00528334 if (eax_7 != 0) -00528355 return eax_7; -00528355 -00528336 class CWeenieObject* weenie_obj_1 = this->weenie_obj; -0052833b if (weenie_obj_1 == 0) -00528355 return eax_7; -00528355 -0052834e eax_6 = 0x47; -00528353 if (weenie_obj_1->vtable->JumpStaminaCost(arg2, arg3) != 0) -00528355 return eax_7; // eax_7 == 0 here (success) -00528322 } -00528317 } -00528317 -00528359 return eax_6; -005282c8 } -005282c8 -005282ca class CPhysicsObj* physics_obj = this->physics_obj; -005282da if ((physics_obj == 0 || (*(uint8_t*)((char*)((int16_t)physics_obj->state))[1] & 4) == 0)) -005282cf goto label_5282f6; // state bit 0x400 not set => skip ground check, go straight to constrained/queue gating -005282cf -005282dc uint8_t transient_state = ((int8_t)physics_obj->transient_state); -005282e8 if (((transient_state & 1) != 0 && (transient_state & 2) != 0)) -005282e8 goto label_5282f6; // on-ground => also goes to the shared gating path -005282b8 } -005282b8 -005282f0 return 0x24; // not on ground and state bit 0x400 is set => "can't jump, not grounded" (0x24) -005282b0 } -``` - -Cleaned: for a creature-type weenie that is NOT on the ground (and physics `state` bit -`0x400` — i.e. `HAS_PHYSICS_BSP`/gravity-active flag — is set), jumping is disallowed -outright (`0x24`). Otherwise (non-creature weenie, OR grounded, OR gravity-inactive -object) falls into the shared gate: `IsFullyConstrained` check (`0x47` if constrained), -then peek the pending-motion-queue head's `jump_error_code` — if the head node already -carries a nonzero jump-error, that's returned directly (an earlier motion already -determined jump is blocked); otherwise recompute via `jump_charge_is_allowed` + -`motion_allows_jump(forward_command)`, and finally consult the weenie's -`JumpStaminaCost(extent, &outCost)` virtual (rejects with `0x47` if the weenie can't -afford the stamina cost of this jump extent). - ---- - -## 4. Ground transitions - -### 4a. `CMotionInterp::HitGround` — `00528ac0` @ line 305996 - -```c -00528ac0 void __fastcall CMotionInterp::HitGround(class CMotionInterp* this) -00528ac0 { -00528ac8 if (this->physics_obj != 0) -00528ac8 { -00528aca class CWeenieObject* weenie_obj = this->weenie_obj; -00528acf int32_t eax_2; -00528acf if (weenie_obj != 0) -00528ad3 eax_2 = weenie_obj->vtable->IsCreature(); -00528ad3 -00528ad8 if ((weenie_obj == 0 || eax_2 != 0)) -00528ad8 { -00528ada class CPhysicsObj* physics_obj = this->physics_obj; -00528aea if ((physics_obj != 0 && (*(uint8_t*)((char*)((int16_t)physics_obj->state))[1] & 4) != 0)) // state bit 0x400 (gravity/BSP-active) -00528aea { -00528aec CPhysicsObj::RemoveLinkAnimations(physics_obj); -00528af7 CMotionInterp::apply_current_movement(this, 0, 0); -00528aea } -00528ad8 } -00528ac8 } -00528ac0 } -``` - -Cleaned: guarded to non-creature-or-creature weenies (basically always runs unless the -weenie is a non-creature — mirrors the same `IsCreature` gate used throughout). If -`physics_obj` has gravity/BSP active (`state & 0x400`), remove any link/stuck -animations and re-apply current movement (`apply_current_movement(interruptFlag=0, -otherFlag=0)`) — this re-syncs the motion interpreter's applied motion once landing -occurs (e.g. transitioning out of a fall/jump animation into idle/walk/run). - -### 4b. `CMotionInterp::LeaveGround` — `00529710`... **actual address is `00528b00`** @ line 306022 - -(Note: the task description's address `00529710` does not match this build; the -correctly-grepped symbol resolves to `00528b00`.) - -```c -00528b00 void __fastcall CMotionInterp::LeaveGround(class CMotionInterp* this) -00528b00 { -00528b0b if (this->physics_obj != 0) -00528b0b { -00528b0d class CWeenieObject* weenie_obj = this->weenie_obj; -00528b12 int32_t eax_2; -00528b12 if (weenie_obj != 0) -00528b16 eax_2 = weenie_obj->vtable->IsCreature(); -00528b16 -00528b1b if ((weenie_obj == 0 || eax_2 != 0)) -00528b1b { -00528b1d class CPhysicsObj* physics_obj = this->physics_obj; -00528b2d if ((physics_obj != 0 && (*(uint8_t*)((char*)((int16_t)physics_obj->state))[1] & 4) != 0)) -00528b2d { -00528b36 void var_c; -00528b36 CMotionInterp::get_leave_ground_velocity(this, &var_c); -00528b45 CPhysicsObj::set_local_velocity(this->physics_obj, &var_c, 1); -00528b4a class CPhysicsObj* physics_obj_1 = this->physics_obj; -00528b4d this->standing_longjump = 0; -00528b54 this->jump_extent = 0f; -00528b5b CPhysicsObj::RemoveLinkAnimations(physics_obj_1); -00528b66 CMotionInterp::apply_current_movement(this, 0, 0); -00528b2d } -00528b1b } -00528b0b } -00528b00 } -``` - -Cleaned: same creature/gravity-active gating as `HitGround`. When leaving the ground: -1. Compute the launch velocity via `get_leave_ground_velocity` (§3d). -2. `CPhysicsObj::set_local_velocity(&velocity, autonomous=1)` — apply it as the physics - object's local velocity (marked autonomous — i.e. this is a locally-simulated - velocity change, not a server-dictated one). -3. Reset `standing_longjump = 0` and `jump_extent = 0.0` — the charge is consumed the - instant you actually leave the ground. -4. `RemoveLinkAnimations` + `apply_current_movement(0, 0)` — same re-sync as HitGround. - -**Also called from `enter_default_state`** (§4d) — entering the default motion state -always calls `LeaveGround` unconditionally as its last step (see below), which is how a -freshly-created `CMotionInterp` initializes into an airborne-capable state. - -### 4c. `CMotionInterp::ReportExhaustion` — `005288d0` @ line 305861 - -```c -005288d0 void __fastcall CMotionInterp::ReportExhaustion(class CMotionInterp* this) -005288d0 { -005288dd if ((this->physics_obj != 0 && this->initted != 0)) -005288dd { -005288df class CWeenieObject* weenie_obj = this->weenie_obj; -005288e4 int32_t eax_2; -005288e4 if (weenie_obj != 0) -005288e8 eax_2 = weenie_obj->vtable->IsThePlayer(); -005288e8 -005288ed if (((weenie_obj == 0 || eax_2 != 0) && CPhysicsObj::movement_is_autonomous(this->physics_obj) != 0)) -005288ed { -00528901 CMotionInterp::apply_raw_movement(this, 0, 0); -00528907 return; -005288ed } -005288ed -0052890e CMotionInterp::apply_interpreted_movement(this, 0, 0); -005288dd } -005288d0 } -``` - -Cleaned: requires `initted`. If (no weenie OR this IS the player weenie) AND the -physics object's last move was autonomous (`movement_is_autonomous` — local prediction, -not a server-driven DR update), re-apply via `apply_raw_movement(0,0)` (raw/local input -path). Otherwise, re-apply via `apply_interpreted_movement(0,0)` (remote/server-driven -path). This exact dual-dispatch pattern (`apply_raw_movement` vs `apply_interpreted_movement` -gated on `IsThePlayer() && movement_is_autonomous()`) is **identical** in -`apply_current_movement`, `SetWeenieObject`, `SetPhysicsObject` — see §4e-4g. Called -from `CPhysicsObj::report_exhaustion` (`0050fdd0`, tailcall) which itself relays from -`MovementManager::ReportExhaustion` (`00524360`, see §7). - -### 4d. `CMotionInterp::enter_default_state` — `00528c80` @ line 306124 - -```c -00528c80 void __fastcall CMotionInterp::enter_default_state(class CMotionInterp* this) -00528c80 { -00528c93 void var_38; -00528c93 RawMotionState::operator=(&this->raw_state, RawMotionState::RawMotionState(&var_38)); -00528c9c RawMotionState::~RawMotionState(&var_38); -00528cae InterpretedMotionState::operator=(&this->interpreted_state, InterpretedMotionState::InterpretedMotionState(&var_38)); -00528cb7 InterpretedMotionState::~InterpretedMotionState(&var_38); -00528cbf CPhysicsObj::InitializeMotionTables(this->physics_obj); -00528cc6 void* eax_2 = operator new(0x10); -00528cc6 if (eax_2 == 0) -00528ce5 eax_2 = nullptr; -00528cd2 else -00528cd2 { -00528cd4 *(uint32_t*)eax_2 = 0; -00528cd6 *(uint32_t*)((char*)eax_2 + 4) = 0; // MotionNode.context_id = 0 -00528cd9 *(uint32_t*)((char*)eax_2 + 8) = 0x41000003; // MotionNode.motion = 0x41000003 (forward=none) -00528ce0 *(uint32_t*)((char*)eax_2 + 0xc) = 0; // MotionNode.jump_error_code = 0 -00528cd2 } -00528cd2 -00528ce7 void** tail_ = this->pending_motions.tail_; -00528cef if (tail_ == 0) -00528cf5 this->pending_motions.head_ = eax_2; -00528cef else -00528cf1 *(uint32_t*)tail_ = eax_2; -00528cf1 -00528cfb this->pending_motions.tail_ = eax_2; -00528d03 this->initted = 1; -00528d09 CMotionInterp::LeaveGround(this); -00528c80 } -``` - -Cleaned: reset `raw_state` and `interpreted_state` to fresh default-constructed values -(discarding any prior motion), re-initialize the physics object's motion tables, then -**manually enqueue a `MotionNode{context_id=0, motion=0x41000003 /*forward-none*/, -jump_error_code=0}`** directly onto `pending_motions` (bypassing `add_to_queue`'s -inline construction but doing the exact same list-splice), mark `initted = 1`, and -finally call `LeaveGround()` unconditionally. Since this is called both from -`CMotionInterp::Create`'s callers (whenever `physics_obj != null`) AND is the shared -"reset to idle" path, the manual sentinel enqueue + forced `LeaveGround` establishes: -"a freshly-initted motion interpreter starts with one pending 'idle' motion queued and -immediately treats itself as airborne-transitioning" (i.e. it re-syncs velocity/anim -state exactly like any other ground-leave event, even though nothing physically left -the ground — this primes the pending_motions queue and the walkable flag consistently). - ---- - -## 5. `StopCompletely` / `StopMotion` / `HoldKey` boundary - -### 5a. `CMotionInterp::StopCompletely` — `00527e40` @ line 305208 - -```c -00527e40 uint32_t __fastcall CMotionInterp::StopCompletely(class CMotionInterp* this) -00527e40 { -00527e44 class CPhysicsObj* physics_obj = this->physics_obj; -00527e4b if (physics_obj == 0) -00527e54 return 8; -00527e54 -00527e56 CPhysicsObj::interrupt_current_movement(physics_obj); -00527e61 uint32_t eax_2 = CMotionInterp::motion_allows_jump(this, this->interpreted_state.forward_command); -00527e70 this->raw_state.forward_command = 0x41000003; -00527e77 this->raw_state.forward_speed = 1f; -00527e7a this->raw_state.sidestep_command = 0; -00527e7d this->raw_state.turn_command = 0; -00527e80 this->interpreted_state.forward_command = 0x41000003; -00527e87 this->interpreted_state.forward_speed = 1f; -00527e8a this->interpreted_state.sidestep_command = 0; -00527e8d this->interpreted_state.turn_command = 0; -00527e90 CPhysicsObj::StopCompletely_Internal(this->physics_obj); -00527e9e CMotionInterp::add_to_queue(this, 0, 0x41000003, eax_2); -00527ea3 class CPhysicsObj* physics_obj_1 = this->physics_obj; -00527eb1 if ((physics_obj_1 != 0 && physics_obj_1->cell == 0)) -00527eb3 CPhysicsObj::RemoveLinkAnimations(physics_obj_1); -00527ebc return 0; -00527e40 } -``` - -Cleaned: `8` if no physics object. Otherwise: interrupt any current transition, snapshot -whether a jump would still be allowed given the (about-to-be-overwritten) -`forward_command`, forcibly zero out BOTH `raw_state` and `interpreted_state`'s -forward/sidestep/turn commands to "none" (`forward_command = 0x41000003`, -`forward_speed = 1.0`, `sidestep_command = turn_command = 0`), call -`CPhysicsObj::StopCompletely_Internal` (the actual physics-level full stop — outside -CMotionInterp), then unconditionally enqueue a `MotionNode{0, 0x41000003, eax_2}` onto -`pending_motions` (this is the ONLY caller that passes the **precomputed** jump-error -code (0 = allowed) as the queued node's `jump_error_code`, rather than recomputing after applying the -new motion — note the semantic quirk: `eax_2` was computed from the OLD -`forward_command` value, before the overwrite two lines later). If the physics object -has no cell (detached / not in world), also strip link animations. Always returns `0` -(success — `StopCompletely` cannot fail once a physics object exists). - -**Note the constant `0x41000003`** = the canonical "no forward motion" / neutral -forward-command id used throughout this whole subsystem (`StopCompletely`, -`enter_default_state`'s sentinel, `StopInterpretedMotion`'s post-stop -requeue, `apply_interpreted_movement`'s fallback requeue). - -### 5b. `CMotionInterp::StopMotion` — `00528530` @ line 305674 - -```c -00528530 uint32_t __thiscall CMotionInterp::StopMotion(class CMotionInterp* this, uint32_t arg2, class MovementParameters const* arg3) -00528530 { -00528536 class CPhysicsObj* physics_obj = this->physics_obj; -0052853b if (physics_obj == 0) -00528546 return 8; -00528546 -0052854c class MovementParameters* esi = arg3; -00528555 if (*(uint8_t*)((char*)((int16_t)esi->__inner0))[1] < 0) // sign-bit / interrupt flag -00528557 CPhysicsObj::interrupt_current_movement(physics_obj); -00528557 -00528562 float min_distance = esi->min_distance; -00528569 union __inner0 = esi->__inner0; -00528570 float desired_heading = esi->desired_heading; -00528577 float distance_to_object = esi->distance_to_object; -0052857e float walk_run_threshhold = esi->walk_run_threshhold; -00528582 enum HoldKey hold_key_to_apply = esi->hold_key_to_apply; -00528585 float speed = esi->speed; -0052858d float min_distance_1 = min_distance; -00528594 enum HoldKey hold_key_to_apply_1 = hold_key_to_apply; -00528598 uint32_t context_id = esi->context_id; -005285a5 float fail_distance = esi->fail_distance; -005285a9 uint32_t action_stamp = esi->action_stamp; -005285af arg3 = arg2; // reuse arg3 slot to hold the motion id (register shuffle) -005285b3 int32_t var_2c = 0x7c83f8; // fresh local MovementParameters (vtable stamp) -005285bb uint32_t action_stamp_1 = action_stamp; -005285bf CMotionInterp::adjust_motion(this, &arg3, &speed, hold_key_to_apply); -005285d0 uint32_t result = CMotionInterp::StopInterpretedMotion(this, arg3, &var_2c); -005285e1 if ((result == 0 && (*(uint8_t*)((char*)((int16_t)esi->__inner0))[1] & 0x20) != 0)) // bit 0x2000 mirror -005285e7 RawMotionState::RemoveMotion(&this->raw_state, arg2); -005285f5 return result; -00528530 } -``` - -Cleaned: the `StopMotion` mirror of `DoMotion` — same defaulting/reconstruction pattern -(snapshot all `arg3` fields onto the stack, build a fresh local `MovementParameters`), -same optional `interrupt_current_movement`, same `adjust_motion` reinterpretation of -the motion id + speed, but no combat-stance or action-depth gating (those are -`DoMotion`-only). Delegates to `StopInterpretedMotion(arg3 /*adjusted motion id*/, -&var_2c /*local params*/)` (already ported). On success, mirrors the removal into -`raw_state` via `RawMotionState::RemoveMotion(arg2)` **using the ORIGINAL unmutated -motion id**, not the adjusted one — matches `DoMotion`'s equivalent -`RawMotionState::ApplyMotion(ebp, arg3)` also using the pre-adjustment id. - -### 5c. `CMotionInterp::set_hold_run` — `00528b70` @ line 306053 - -```c -00528b70 void __thiscall CMotionInterp::set_hold_run(class CMotionInterp* this, int32_t arg2, int32_t arg3) -00528b70 { -00528b79 int32_t eax; -00528b79 eax = arg2 == 0; -00528b85 int32_t edx; -00528b85 edx = this->raw_state.current_holdkey != HoldKey_Run; -00528b8a if (eax != edx) -00528b8a { -00528b94 int32_t eax_1; -00528b94 eax_1 = arg2 != 0; -00528b9b this->raw_state.current_holdkey = (eax_1 + 1); // 1 = HoldKey_None+1? see enum below -00528b9e CMotionInterp::apply_current_movement(this, arg3, 0); -00528b8a } -00528b70 } -``` - -Cleaned: `arg2` is a bool "hold run key down?" (nonzero = yes). `eax = (arg2 == 0)` -(i.e. "run key is up"), `edx = (current_holdkey != Run)`. The XOR-style -`if (eax != edx)` is "only act if this toggles a *change* in effective hold-run state" -— i.e. skip if we're already in the requested state (guards against redundant -re-application). If changed: `current_holdkey = (arg2 != 0) + 1`. Given -`HoldKey_None = 1`? — cross-check the enum: `HoldKey_Invalid=0`? See note below; -regardless, the arithmetic `(bool)+1` maps `false→1`, `true→2`, and `SetHoldKey` (§5d) -explicitly writes `HoldKey_None` and `HoldKey_Run` as the two values it ever assigns — -so `1 = HoldKey_None`, `2 = HoldKey_Run` is confirmed by direct comparison against -`SetHoldKey`'s own literal assignments. Then calls `apply_current_movement(arg3, 0)` to -push the change through immediately. - -**Callers** (outside CMotionInterp, both from the CommandInterpreter/input boundary — -noted per the request, out of R3's direct scope but shown for the seam): -- Line 405488 (`0058b303`): `CMotionInterp::set_hold_run(CPhysicsObj::get_minterp(*(this-0xc0)), eax_3)` — 2-arg call site (arg3 defaults via calling convention / omitted in decomp). -- Line 699120 (`006b33ca`): `CMotionInterp::set_hold_run(CPhysicsObj::get_minterp(this->player), eax_4, 1)` — explicit `arg3=1` (interrupt flag) from the player input/command layer. - -### 5d. `CMotionInterp::SetHoldKey` — `00528bb0` @ line 306072 - -```c -00528bb0 void __thiscall CMotionInterp::SetHoldKey(class CMotionInterp* this, enum HoldKey arg2, int32_t arg3) -00528bb0 { -00528bb0 enum HoldKey current_holdkey = this->raw_state.current_holdkey; -00528bb9 if (arg2 != current_holdkey) -00528bb9 { -00528bbc if (arg2 == HoldKey_None) -00528bbc { -00528bdf if (current_holdkey == HoldKey_Run) -00528bdf { -00528be8 this->raw_state.current_holdkey = HoldKey_None; -00528bef CMotionInterp::apply_current_movement(this, arg3, 0); -00528bdf } -00528bbc } -00528bbc else if ((arg2 == 2 && current_holdkey != HoldKey_Run)) // arg2 == HoldKey_Run (== 2) -00528bc4 { -00528bcd this->raw_state.current_holdkey = HoldKey_Run; -00528bd4 CMotionInterp::apply_current_movement(this, arg3, 0); -00528bb9 } -00528bb9 } -00528bb0 } -``` - -Cleaned: no-op if `arg2` already equals the current hold key. Setting to `HoldKey_None` -only takes effect (and re-applies movement) if we were previously `HoldKey_Run` — -setting `None` while already something else (e.g. `Invalid`) is silently ignored. -Setting to `HoldKey_Run` (`== 2`) takes effect whenever we weren't already `Run`. Both -effective branches call `apply_current_movement(arg3 /*interrupt flag passthrough*/, 0)`. -This is the function `DoMotion` calls (§2, step 4) when bit `0x800` of the incoming -`MovementParameters` bitfield requests a hold-key change, and it's what -`CMotionInterp::adjust_motion` (D6, already ported) reads back via -`this->raw_state.current_holdkey` to decide whether `apply_run_to_command` fires. - ---- - -## 6. MovementManager (beyond `unpack_movement`, S2a-covered) - -### 6a. `MovementManager::Create` — `00524050` @ line 300150 - -```c -00524050 class MovementManager* MovementManager::Create(class CPhysicsObj* arg1, class CWeenieObject* arg2) -00524050 { -00524054 void* result_1 = operator new(0x10); -0052405e void* result; -0052405e if (result_1 == 0) -0052407f result = nullptr; -0052405e else -0052405e { -00524060 *(uint32_t*)result_1 = 0; // motion_interpreter = null -00524066 *(uint32_t*)((char*)result_1 + 4) = 0; // moveto_manager = null -0052406d *(uint32_t*)((char*)result_1 + 8) = 0; // physics_obj = null (overwritten below) -00524074 *(uint32_t*)((char*)result_1 + 0xc) = 0; // weenie_obj = null (overwritten below) -0052407b result = result_1; -0052405e } -0052405e -00524081 class CMotionInterp* ecx = *(uint32_t*)result; -00524089 *(uint32_t*)((char*)result + 8) = arg1; // physics_obj = arg1 -0052408c if (ecx != 0) -0052408f CMotionInterp::SetPhysicsObject(ecx, arg1); -0052408f -00524094 class MoveToManager* ecx_1 = *(uint32_t*)((char*)result + 4); -00524099 if (ecx_1 != 0) -0052409c MoveToManager::SetPhysicsObject(ecx_1, arg1); -0052409c -005240a1 class CMotionInterp* ecx_2 = *(uint32_t*)result; -005240a9 *(uint32_t*)((char*)result + 0xc) = arg2; // weenie_obj = arg2 -005240ac if (ecx_2 != 0) -005240af CMotionInterp::SetWeenieObject(ecx_2, arg2); -005240af -005240b4 class MoveToManager* ecx_3 = *(uint32_t*)((char*)result + 4); -005240b9 if (ecx_3 != 0) -005240bc MoveToManager::SetWeenieObject(ecx_3, arg2); -005240bc -005240c5 return result; -00524050 } -``` - -Cleaned: allocate+zero a `MovementManager`, set `physics_obj = arg1` and (if a -`motion_interpreter` already existed — it never does on a fresh alloc, this branch is -dead on first construction but matches the setter-forwarding pattern used everywhere -else) forward to `CMotionInterp::SetPhysicsObject`/`MoveToManager::SetPhysicsObject`. -Same for `weenie_obj = arg2`. **`motion_interpreter` and `moveto_manager` are NOT -eagerly created here** — they're lazily created on first access (see `get_minterp`, -`PerformMovement`, `move_to_interpreted_state`, `EnterDefaultState`, -`InqRawMotionState`, `InqInterpretedMotionState` — every one of them has the identical -`if (motion_interpreter == 0) { motion_interpreter = CMotionInterp::Create(...); if -(physics_obj != 0) enter_default_state(...); }` guard). - -### 6b. `MovementManager::SetPhysicsObject` / `SetWeenieObject` - -Not directly decompiled as named top-level functions in this range (they're the -`CMotionInterp::SetPhysicsObject`/`SetWeenieObject` calls forwarded from `Create` -above, §3/§4e-4g show `CMotionInterp`'s own versions). `MovementManager` itself has no -separate `SetPhysicsObject`/`SetWeenieObject` — `Create` is the only place these fields -are set, matching the struct having no setters of its own (raw field assignment inline -in `Create`). - -### 6c. `MovementManager::PerformMovement` — `005240d0` @ line 300194 (the dispatch) - -```c -005240d0 uint32_t __thiscall MovementManager::PerformMovement(class MovementManager* this, class MovementStruct const* arg2) -005240d0 { -005240d9 CPhysicsObj::set_active(this->physics_obj, 1); -005240e4 void* eax_1 = (arg2->type - 1); -005240e8 if (eax_1 > 8) -00524159 return 0x47; -00524159 -005240f1 switch (eax_1) -005240f1 { -005240fb case nullptr: // type == RawCommand(1) -> eax_1 == 0 -005240fb case 1: // type == InterpretedCommand(2) -> eax_1 == 1 -005240fb case 2: // type == StopRawCommand(3) -> eax_1 == 2 -005240fb case 3: // type == StopInterpretedCommand(4) -> eax_1 == 3 -005240fb case 4: // type == StopCompletely(5) -> eax_1 == 4 -005240fb { -005240fb if (this->motion_interpreter == 0) -005240fb { -00524105 class CMotionInterp* eax_3 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); -00524110 bool cond:0_1 = this->physics_obj == 0; -00524112 this->motion_interpreter = eax_3; -00524114 if (!(cond:0_1)) -00524118 CMotionInterp::enter_default_state(eax_3); -005240fb } -005240fb -00524127 return CMotionInterp::PerformMovement(this->motion_interpreter, arg2); -005240fb break; -005240fb } -0052412f case 5: // type == MoveToObject(6) -> eax_1 == 5 -0052412f case 6: // type == MoveToPosition(7) -> eax_1 == 6 -0052412f case 7: // type == TurnToObject(8) -> eax_1 == 7 -0052412f case 8: // type == TurnToHeading(9) -> eax_1 == 8 -0052412f { -0052412f if (this->moveto_manager == 0) -00524141 this->moveto_manager = MoveToManager::Create(this->physics_obj, this->weenie_obj); -00524141 -00524148 MoveToManager::PerformMovement(this->moveto_manager, arg2); -0052414f return 0; -0052412f break; -0052412f } -005240f1 } -005240d0 } -``` - -Cleaned: this is the **top-level `MovementManager` dispatch** — always marks the -physics object active first. `MovementTypes::Type` values `1..5` (Raw/Interpreted/ -StopRaw/StopInterpreted/StopCompletely) all route to **`CMotionInterp::PerformMovement`** -(lazily creating + default-initing the motion interpreter first if needed). Values -`6..9` (MoveToObject/MoveToPosition/TurnToObject/TurnToHeading) route to -**`MoveToManager::PerformMovement`** (lazily creating the MoveTo manager; R4 territory, -not expanded here — `MoveToManager::Create`/`PerformMovement` entry points only, per -the task's explicit deferral). Any `type` outside `[1,9]` → error `0x47`. - -### 6d. `MovementManager::MotionDone` (the relay) — `005242d0` @ line 300396 - -```c -005242d0 void __thiscall MovementManager::MotionDone(class MovementManager* this, uint32_t arg2, int32_t arg3) -005242d0 { -005242d0 class CMotionInterp* motion_interpreter = this->motion_interpreter; -005242d4 if (motion_interpreter != 0) -005242d4 { -005242da int32_t var_4_1 = arg3; -005242db int32_t edx; -005242db CMotionInterp::MotionDone(motion_interpreter, edx); // NOTE: passes uninitialized `edx`, NOT arg2/arg3 — see caveat below -005242d4 } -005242d0 } -``` - -Cleaned: pure relay, guarded on `motion_interpreter != null` (no lazy-create here — -if the interpreter doesn't exist yet, the done-callback is simply dropped, which is -correct: no interpreter means no motion was ever dispatched through it to complete). - -**Decompiler caveat**: the pseudo-C shows `var_4_1 = arg3` computed but discarded, and -`edx` (uninitialized) passed as `CMotionInterp::MotionDone`'s second argument instead -of `arg2`. Given `CMotionInterp::MotionDone`'s body never reads its `arg2` parameter at -all (§1c), this is very likely a register-allocation artifact of the decompiler (the -real assembly probably loads `arg2` into `edx` via the calling convention and the -pseudo-C's "uninitialized edx" annotation is simply failing to trace that it came from -the argument) rather than a genuine "pass garbage" bug — **do not port this as "pass -garbage"; port as `CMotionInterp::MotionDone(motionId)` taking whatever came in as -`arg2`,** consistent with `arg2` at the `CPhysicsObj::MotionDone` → `MovementManager::MotionDone` -call site always being the real `MotionNode.motion` id (see §6e below and the two call -sites at lines 290573/290657 in `MotionTableManager`). - -### 6e. `MovementManager::UseTime`, `HitGround`, `LeaveGround`, `HandleEnterWorld`, `HandleExitWorld`, `ReportExhaustion` - -```c -005242f0 void __fastcall MovementManager::UseTime(class MovementManager* this) -005242f0 { -005242f0 class MoveToManager* moveto_manager = this->moveto_manager; -005242f5 if (moveto_manager == 0) -005242fc return; -005242f7 /* tailcall */ -005242f7 return MoveToManager::UseTime(moveto_manager); -005242f0 } -``` -Pure relay to `MoveToManager::UseTime` only — **CMotionInterp has no `UseTime` of its -own** (motion interpreter's per-frame work happens through the physics/anim pipeline, -not a polled `UseTime`). - -```c -00524300 void __fastcall MovementManager::HitGround(class MovementManager* this) -00524300 { -00524303 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524307 if (motion_interpreter != 0) -00524309 CMotionInterp::HitGround(motion_interpreter); -00524309 -0052430e class MoveToManager* moveto_manager = this->moveto_manager; -00524314 if (moveto_manager == 0) -0052431b return; -0052431b /* tailcall */ -0052431b return MoveToManager::HitGround(moveto_manager); -00524300 } -``` -Fans out to BOTH `CMotionInterp::HitGround` (§4a) AND `MoveToManager::HitGround` (R4, -not expanded) — both subsystems need to know when the ground was hit. - -```c -00524320 void __fastcall MovementManager::LeaveGround(class MovementManager* this) -00524320 { -00524323 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524327 if (motion_interpreter != 0) -00524329 CMotionInterp::LeaveGround(motion_interpreter); -00524329 -0052432e class MoveToManager* moveto_manager = this->moveto_manager; -00524334 if (moveto_manager == 0) -0052433b return; -0052433b /* tailcall */ -0052433b return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(moveto_manager); -00524320 } -``` -Same fan-out pattern as `HitGround` (the trailing tailcall target name -`IDClass<...>::~IDClass<...>` is a decompiler mis-symbolication — the actual target is -almost certainly `MoveToManager::LeaveGround`, matching the `HitGround` sibling's -shape exactly; flagging this as a **known decomp mislabel**, not a real destructor -call). - -```c -00524340 void __fastcall MovementManager::HandleEnterWorld(class MovementManager* this) -00524340 { -00524340 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524344 if (motion_interpreter == 0) -0052434b return; -0052434b /* tailcall */ -0052434b return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(motion_interpreter); -00524340 } -``` -Same mis-symbolication pattern — tailcalls to what is almost certainly -`CMotionInterp::HandleEnterWorld` (there is no such symbol independently decompiled in -this dump; `CPartArray::HandleEnterWorld`/`MotionTableManager::HandleEnterWorld` exist -at the animation layer — §7 — but `CMotionInterp` itself does not appear to define a -distinct `HandleEnterWorld`, only `HandleExitWorld` was found named). **Only relays to -`motion_interpreter`, no `moveto_manager` fan-out** (contrast with `HitGround`/ -`LeaveGround` above) — enter-world doesn't need to touch pending MoveTo state. - -```c -00524350 void __fastcall MovementManager::HandleExitWorld(class MovementManager* this) -00524350 { -00524350 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524354 if (motion_interpreter == 0) -0052435b return; -0052435b /* tailcall */ -0052435b return CMotionInterp::HandleExitWorld(motion_interpreter); -00524350 } -``` -Clean relay to `CMotionInterp::HandleExitWorld` (§1d) — correctly symbolicated this -time. Also no `moveto_manager` fan-out. - -```c -00524360 void __fastcall MovementManager::ReportExhaustion(class MovementManager* this) -00524360 { -00524363 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524367 if (motion_interpreter != 0) -00524369 CMotionInterp::ReportExhaustion(motion_interpreter); -00524369 -0052436e class MoveToManager* moveto_manager = this->moveto_manager; -00524374 if (moveto_manager == 0) -0052437b return; -0052437b /* tailcall */ -0052437b return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(moveto_manager); -00524360 } -``` -Fan-out to `CMotionInterp::ReportExhaustion` (§4c) AND (mis-symbolicated, real target -almost certainly `MoveToManager::ReportExhaustion`) the MoveTo manager. - -### 6f. `MovementManager::CancelMoveTo` — `005241b0` @ line 300277 - -```c -005241b0 void __fastcall MovementManager::CancelMoveTo(class MovementManager* this, uint32_t arg2) -005241b0 { -005241b0 class MoveToManager* moveto_manager = this->moveto_manager; -005241b5 if (moveto_manager == 0) -005241bc return; -005241bc /* tailcall */ -005241b7 return MoveToManager::CancelMoveTo(moveto_manager, arg2); -005241b0 } -``` -Pure relay, no lazy-create (canceling a MoveTo that was never started is a no-op). - -### 6g. `MovementManager::EnterDefaultState` — `005241c0` @ line 300292 - -```c -005241c0 void __fastcall MovementManager::EnterDefaultState(class MovementManager* this) -005241c0 { -005241c3 class CPhysicsObj* physics_obj = this->physics_obj; -005241c8 if (physics_obj == 0) -005241f5 return; -005241f5 -005241cd if (this->motion_interpreter == 0) -005241cd { -005241d4 class CMotionInterp* eax = CMotionInterp::Create(physics_obj, this->weenie_obj); -005241df bool cond:0_1 = this->physics_obj == 0; -005241e1 this->motion_interpreter = eax; -005241e1 -005241e3 if (!(cond:0_1)) -005241e7 CMotionInterp::enter_default_state(eax); -005241cd } -005241cd -005241ef /* tailcall */ -005241ef return CMotionInterp::enter_default_state(this->motion_interpreter); -005241c0 } -``` -No-op if no physics object. Lazy-creates the motion interpreter (with the same -"create-then-immediately-enter-default-state-if-physics-exists" double-init pattern — -note this means a brand-new interpreter gets `enter_default_state` called on it TWICE -in a row when reached via this path: once inside the lazy-create guard, once again as -the trailing unconditional tailcall — harmless since `enter_default_state` is fully -idempotent/reset-style, but worth flagging as a genuine retail double-call, not a -porting bug to "fix"). - -### 6h. `MovementManager::InqRawMotionState` / `InqInterpretedMotionState` / `IsMovingTo` / `motions_pending` / `get_minterp` - -```c -00524200 class RawMotionState const* __fastcall MovementManager::InqRawMotionState(class MovementManager* this) -00524200 { -00524206 if (this->motion_interpreter == 0) -00524206 { -00524210 class CMotionInterp* eax_2 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); -0052421b bool cond:0_1 = this->physics_obj == 0; -0052421d this->motion_interpreter = eax_2; -0052421f if (!(cond:0_1)) -00524223 CMotionInterp::enter_default_state(eax_2); -00524206 } -00524206 return &this->motion_interpreter->raw_state; -00524200 } -``` -```c -00524230 class InterpretedMotionState const* __fastcall MovementManager::InqInterpretedMotionState(class MovementManager* this) -00524230 { /* identical lazy-create pattern */ return &this->motion_interpreter->interpreted_state; } -``` -```c -00524260 int32_t __fastcall MovementManager::IsMovingTo(class MovementManager const* this) -00524260 { -00524260 class MoveToManager* moveto_manager = this->moveto_manager; -00524265 if ((moveto_manager != 0 && MoveToManager::is_moving_to(moveto_manager) != 0)) -00524275 return 1; -00524275 return 0; -00524260 } -``` -```c -005242a0 class CMotionInterp* __fastcall MovementManager::get_minterp(class MovementManager* this) -005242a0 { /* identical lazy-create pattern */ return this->motion_interpreter; } -``` -All four follow the exact same "lazy-create-with-default-state-if-physics-exists, then -return a pointer/reference into the freshly-guaranteed-nonnull interpreter" shape as -`get_minterp`/`EnterDefaultState`/`PerformMovement`. - -### 6i. `MovementManager::Destroy` — `005243f0` @ line 300538 - -```c -005243f0 void __fastcall MovementManager::Destroy(class MovementManager* this) -005243f0 { -005243f4 class CMotionInterp* motion_interpreter = this->motion_interpreter; -005243f8 if (motion_interpreter != 0) -005243f8 { -005243fc CMotionInterp::~CMotionInterp(motion_interpreter); -00524402 operator delete(motion_interpreter); -005243f8 } -005243f8 -0052440a class MoveToManager* moveto_manager = this->moveto_manager; -0052440f this->motion_interpreter = 0; -00524415 if (moveto_manager != 0) -00524415 { -00524419 MoveToManager::~MoveToManager(moveto_manager); -0052441f operator delete(moveto_manager); -00524415 } -00524415 -00524428 this->moveto_manager = nullptr; -005243f0 } -``` -Explicit teardown of both sub-managers (dtor + `operator delete` each, since these are -raw owned pointers, not smart pointers, per retail C++ idiom). - -### 6j. `HandleUpdateTarget` — **not found** in this build's decomp under `MovementManager::` or `CMotionInterp::` namespaces (grepped both; no match). Likely does not exist as a named symbol in the Sept 2013 PDB, or the functionality lives elsewhere (possibly `MoveToManager`-internal, R4 territory) — noting the negative result rather than guessing. - -### 6k. `MakeMoveToManager` — entry points only (deferred to R4 per instructions) - -`MoveToManager::Create(class CPhysicsObj*, class CWeenieObject*)` is referenced at -line 300230 (`0052412f` case block) and `MovementManager::Create` does NOT eagerly -construct it. Full `MoveToManager` internals (its own `PerformMovement`, `UseTime`, -`HitGround`, `is_moving_to`, `CancelMoveTo`, `ReportExhaustion`, `LeaveGround`) are -**out of scope for R3** — only the entry-point call shape from `MovementManager` is -captured above (§6c, §6f, §6e, §6g). - ---- - -## 7. `CPhysicsObj::MotionDone` / `IsAnimating` / `IsMovingOrAnimating` / `InqRawMotionState` / `movement_is_autonomous` / animation-completion driver - -### 7a. `CPhysicsObj::movement_is_autonomous` — `0050eb30` @ line 276443 - -```c -0050eb30 int32_t __fastcall CPhysicsObj::movement_is_autonomous(class CPhysicsObj const* this) -0050eb30 { -0050eb36 return this->last_move_was_autonomous; -0050eb30 } -``` -Trivial field accessor — `last_move_was_autonomous` is a plain flag on `CPhysicsObj`, -set elsewhere (transition/physics-tick code, not in this range) whenever a locally- -simulated (as opposed to server-dictated dead-reckoning) motion update occurs. This is -the flag every `apply_raw_movement` vs `apply_interpreted_movement` gate reads -(§4c, and `apply_current_movement`/`SetWeenieObject`/`SetPhysicsObject` below). - -### 7b. `CPhysicsObj::MotionDone` — `0050fdb0` @ line 277856 (FULL BODY — pure tailcall relay) - -```c -0050fdb0 void __thiscall CPhysicsObj::MotionDone(class CPhysicsObj* this, uint32_t arg2, int32_t arg3) -0050fdb0 { -0050fdb0 class MovementManager* movement_manager = this->movement_manager; -0050fdb8 if (movement_manager == 0) -0050fdbf return; -0050fdbf -0050fdba /* tailcall */ -0050fdba return MovementManager::MotionDone(movement_manager, arg2, arg3); -0050fdb0 } -``` -No-op if the physics object has no `movement_manager` attached (e.g. static/inanimate -objects never get a `MovementManager`). Otherwise a clean 2-arg tailcall relay to -`MovementManager::MotionDone` (§6d) — `arg2` is the `MotionNode.motion` id that just -completed, `arg3` is whatever caller-specific flag the two call sites below pass. - -**Call sites** — this is the actual **animation-completion driver**, both inside -`MotionTableManager` (the per-`CPartArray` animation-node scheduler): - -```c -/* MotionTableManager::AnimationDone, 0051bce0 @ line 290558 — fired once per game-clock tick as the animation_counter advances past queued nodes */ -0051bd20 CPhysicsObj::MotionDone(this->physics_obj, *(int32_t*)((char*)head__2 + 8) /* pending_animations node's motion id */, arg2 /* AnimationDone's own arg2, e.g. "was interrupted" */); - -/* MotionTableManager::CheckForCompletedMotions, 0051be00 @ line 290645 — fired once per completed-immediately (0-duration) animation node */ -0051be2e CPhysicsObj::MotionDone(this->physics_obj, *(int32_t*)((char*)head__1 + 8), 1 /* literal 1 — always "not interrupted" for immediate completions */); -``` - -Both `MotionTableManager::AnimationDone` and `MotionTableManager::CheckForCompletedMotions` -walk `this->pending_animations` (a **separate** `DLListData`-based doubly-linked queue -scoped to `MotionTableManager`, distinct from `CMotionInterp::pending_motions` — they -are two parallel queues that both track in-flight motions, one at the -animation-table/CPartArray layer keyed by duration-in-ticks, one at the -CMotionInterp/movement layer keyed by "waiting for a done callback"), and for every -node whose duration has elapsed (`AnimationDone`: `node.duration <= animation_counter` -after incrementing; `CheckForCompletedMotions`: `node.duration == 0`, i.e. instantly -complete), they: -1. If the node's `motion & 0x10000000` (action-class flag — same bit `CMotionInterp:: - MotionDone` tests), call `MotionState::remove_action_head(&this->state)` — pop the - **animation-table's own parallel action-tracking state** (a THIRD action-queue, - local to `MotionTableManager::state`, distinct from `InterpretedMotionState::actions` - and `RawMotionState`'s equivalent — all three are kept in lockstep by the - `0x10000000` bit). -2. Fire `CPhysicsObj::MotionDone(physics_obj, node.motion, ...)` — which is the whole - chain that eventually reaches `CMotionInterp::MotionDone` (§1c) to pop - `pending_motions`. -3. Unlink+delete the `pending_animations` node itself (standard doubly-linked-list - removal, handling head/tail edge cases). - -**This is the actual driver behind "when does a queued motion get dequeued"** — it is -**NOT** a per-frame poll of `CMotionInterp` itself; it's the animation-tick system -(`MotionTableManager`, driven by `CPartArray::Update`→`CSequence::update`, and by -`CPartArray::CheckForCompletedMotions`/`AnimationDone` tailcalls from `CPhysicsObj`, -§7d/7e) that fires the completion callback back UP into `CMotionInterp`. - -### 7c. `CPhysicsObj::InqRawMotionState` — `0050fde0` @ line 277883 - -```c -0050fde0 class RawMotionState* __fastcall CPhysicsObj::InqRawMotionState(class CPhysicsObj const* this) -0050fde0 { -0050fde0 class MovementManager* movement_manager = this->movement_manager; -0050fde8 if (movement_manager != 0) -0050fded /* tailcall */ -0050fded return MovementManager::InqRawMotionState(movement_manager); -0050fded -0050fdec return 0; -0050fde0 } -``` -Null-safe relay (returns `null` if no `movement_manager`, vs. `MovementManager:: -InqRawMotionState`'s own lazy-create-guaranteed-nonnull behavior once a manager -exists). - -### 7d. `CPhysicsObj::CheckForCompletedMotions` — `0050fe30` @ line 277925 (pure relay, tailcall) - -```c -0050fe30 void __fastcall CPhysicsObj::CheckForCompletedMotions(class CPhysicsObj* this) -0050fe30 { -0050fe30 class CPartArray* part_array = this->part_array; -0050fe35 if (part_array == 0) -0050fe3c return; -0050fe3c /* tailcall */ -0050fe37 return CPartArray::CheckForCompletedMotions(part_array); -0050fe30 } -``` -This is what `CMotionInterp::PerformMovement` (§ from S2a/2) calls after EVERY -dispatched motion op (`DoMotion`/`DoInterpretedMotion`/`StopMotion`/ -`StopInterpretedMotion`/`StopCompletely`) — see line 306234/306241/306248/306255/ -306262 in §2's sibling `CMotionInterp::PerformMovement`. It synchronously drains any -**already-elapsed** (0-duration) animation-table nodes right after the motion is -applied, in case the newly-applied motion table entry immediately completes (e.g. a -0-frame transition). - -`CPartArray::CheckForCompletedMotions` (`00517d50`) is itself a null-safe tailcall -relay to `MotionTableManager::CheckForCompletedMotions` (§7b, second call site). - -### 7e. `CPhysicsObj::Hook_AnimDone` — `0050fda0` @ line 277845 (the animation-hook entry point, distinct from `MotionDone`) - -```c -0050fda0 void __fastcall CPhysicsObj::Hook_AnimDone(class CPhysicsObj* this) -0050fda0 { -0050fda0 class CPartArray* part_array = this->part_array; -0050fda5 if (part_array != 0) -0050fda9 CPartArray::AnimationDone(part_array, 1); -0050fda0 } -``` -This is the actual **per-frame/per-tick animation hook callback entry point** (called -from the CSequence/hook-dispatch machinery covered by the separate animation-sequencer -deep-dive) — it always passes literal `1` down into `CPartArray::AnimationDone` → -`MotionTableManager::AnimationDone(1)` (§1c/§7b's first call site — the `arg2` there -IS this `1`, meaning "not interrupted", contrasted with whatever value -`CPartArray::AnimationDone` gets called with from other paths). - -`CPartArray::AnimationDone` (`00517d30`) and `CPartArray::CheckForCompletedMotions` -(`00517d50`) are both null-safe single-line tailcall relays to the identically-named -`MotionTableManager::` methods (§1c/§7b bodies already shown in full). - -### 7f. `IsAnimating` / `IsMovingOrAnimating` — **not found** as literal symbol names -`CPhysicsObj::IsAnimating` and `CPhysicsObj::IsMovingOrAnimating` were grepped across -the full decomp file and **do not appear** as named functions in this PDB (neither -under `CPhysicsObj::` nor any other class). The closest matching concepts actually -present in this build: -- `CPartArray::HasAnims` (`00517d40`) — tailcalls `CSequence::has_anims(&sequence)`. -- `CMotionInterp::is_standing_still` (`00527fa0`, full body already shown inline - below since it's small and directly relevant) — tests `on_ground` bits AND - `interpreted_state.forward_command == 0x41000003 && sidestep_command == 0 && - turn_command == 0`. -- `CMotionInterp::motions_pending` (§1b). - -```c -00527fa0 int32_t __fastcall CMotionInterp::is_standing_still(class CMotionInterp const* this) -00527fa0 { -00527fa3 uint8_t transient_state = ((int8_t)this->physics_obj->transient_state); -00527fc6 if (((transient_state & 1) != 0 && ((transient_state & 2) != 0 && (this->interpreted_state.forward_command == 0x41000003 && (this->interpreted_state.sidestep_command == 0 && this->interpreted_state.turn_command == 0))))) -00527fcd return 1; -00527fcd return 0; -00527fa0 } -``` - -If acdream needs an `IsAnimating`/`IsMovingOrAnimating`-equivalent gate, the retail -composite would be built from `CPartArray::HasAnims()` (raw sequence has active -animation hooks) OR'd with `!CMotionInterp::is_standing_still()` OR'd with -`MovementManager::motions_pending()` — but this is a **synthesis**, not a verbatim -retail function, since no single named retail function computes exactly this union. -Flag before porting: confirm the actual acdream call site's intent against these three -primitives individually rather than inventing a combined helper that doesn't exist in -retail. - ---- - -## 8. `set_hold_run` at the CommandInterpreter boundary — call-site context only - -Both call sites (already shown in §5c) originate outside `CMotionInterp`/ -`MovementManager` — one at `0058b303` (function context not extracted; a -`CPhysicsObj::get_minterp`-based call with only 2 explicit args in the decomp, arg3 -presumably defaulted or passed via a register the decompiler didn't attribute), one at -`006b33ca` inside what's very likely `CommandInterpreter`-adjacent code (`this->player` -field access, `arg3=1` for the interrupt flag) at address range `006b33xx` — this is -**outside R3's CMotionInterp/MovementManager scope** per the task's own boundary -("input handling" / "CommandInterpreter boundary" is a note-only ask); recorded here -only as confirmation that `set_hold_run`'s two callers exist and roughly where. - ---- - -## 9. Summary table — function → address → line → status - -| Function | Address | Line | Extracted | -|---|---|---|---| -| `CMotionInterp::add_to_queue` | 0x00527b80 | 305032 | full body | -| `CMotionInterp::motions_pending` | 0x00527fe0 | 305322 | full body | -| `CMotionInterp::MotionDone` | 0x00527ec0 | 305238 | full body | -| `CMotionInterp::HandleExitWorld` | 0x00527f30 | 305275 | full body | -| `MovementManager::motions_pending` | 0x00524280 | 300365 | full body | -| `MovementManager::MotionDone` | 0x005242d0 | 300396 | full body + caveat | -| `CMotionInterp::DoMotion` | 0x00528d20 | 306159 | full body | -| `CMotionInterp::PerformMovement` | 0x00528e80 | 306221 | full body | -| `CMotionInterp::motion_allows_jump` | 0x005279e0 | 304908 | full body | -| `CMotionInterp::jump_charge_is_allowed` | 0x00527a50 | 304935 | full body | -| `CMotionInterp::get_jump_v_z` | 0x00527aa0 | 304953 | full body | -| `CMotionInterp::get_leave_ground_velocity` | 0x005280c0 | 305404 | full body | -| `CMotionInterp::charge_jump` | 0x005281c0 | 305448 | full body | -| `CMotionInterp::jump` | 0x00528780 | 305792 | full body | -| `CMotionInterp::jump_is_allowed` | 0x005282b0 | 305509 | full body (context) | -| `CPhysicsObj::on_ground` | 0x00527b20 | 304996 | full body | -| `CMotionInterp::HitGround` | 0x00528ac0 | 305996 | full body | -| `CMotionInterp::LeaveGround` | 0x00528b00 | 306022 | full body (addr differs from task's 0x00529710 guess) | -| `CMotionInterp::ReportExhaustion` | 0x005288d0 | 305861 | full body | -| `CMotionInterp::enter_default_state` | 0x00528c80 | 306124 | full body | -| `CMotionInterp::StopCompletely` | 0x00527e40 | 305208 | full body | -| `CMotionInterp::StopMotion` | 0x00528530 | 305674 | full body | -| `CMotionInterp::set_hold_run` | 0x00528b70 | 306053 | full body | -| `CMotionInterp::SetHoldKey` | 0x00528bb0 | 306072 | full body | -| `CMotionInterp::is_standing_still` | 0x00527fa0 | 305309 | full body | -| `CMotionInterp::apply_current_movement` | 0x00528870 | 305838 | full body | -| `CMotionInterp::SetWeenieObject` | 0x00528920 | 305884 | full body | -| `CMotionInterp::SetPhysicsObject` | 0x00528970 | 305911 | full body | -| `CMotionInterp::Create` | 0x00528c00 | 306097 | full body | -| `CMotionInterp::Destroy` | 0x00527b40 | 305009 | full body | -| `CMotionInterp::~CMotionInterp` | 0x00527ff0 | 305332 | full body | -| `MovementManager::Create` | 0x00524050 | 300150 | full body | -| `MovementManager::PerformMovement` | 0x005240d0 | 300194 | full body | -| `MovementManager::move_to_interpreted_state` | 0x00524170 | 300259 | full body | -| `MovementManager::CancelMoveTo` | 0x005241b0 | 300277 | full body | -| `MovementManager::EnterDefaultState` | 0x005241c0 | 300292 | full body | -| `MovementManager::InqRawMotionState` | 0x00524200 | 300316 | full body | -| `MovementManager::InqInterpretedMotionState` | 0x00524230 | 300334 | full body | -| `MovementManager::IsMovingTo` | 0x00524260 | 300352 | full body | -| `MovementManager::get_minterp` | 0x005242a0 | 300378 | full body | -| `MovementManager::UseTime` | 0x005242f0 | 300411 | full body | -| `MovementManager::HitGround` | 0x00524300 | 300425 | full body | -| `MovementManager::LeaveGround` | 0x00524320 | 300444 | full body (tailcall target mis-symbolicated) | -| `MovementManager::HandleEnterWorld` | 0x00524340 | 300463 | full body (tailcall target mis-symbolicated) | -| `MovementManager::HandleExitWorld` | 0x00524350 | 300477 | full body | -| `MovementManager::ReportExhaustion` | 0x00524360 | 300491 | full body (tailcall target mis-symbolicated) | -| `MovementManager::Destroy` | 0x005243f0 | 300538 | full body | -| `MovementParameters::MovementParameters` (ctor) | 0x00524380 | 300510 | full body | -| `CPhysicsObj::movement_is_autonomous` | 0x0050eb30 | 276443 | full body | -| `CPhysicsObj::MotionDone` | 0x0050fdb0 | 277856 | full body | -| `CPhysicsObj::report_exhaustion` | 0x0050fdd0 | 277870 | full body | -| `CPhysicsObj::InqRawMotionState` | 0x0050fde0 | 277883 | full body | -| `CPhysicsObj::InqInterpretedMotionState` | 0x0050fe00 | 277897 | full body | -| `CPhysicsObj::RemoveLinkAnimations` | 0x0050fe20 | 277911 | full body | -| `CPhysicsObj::CheckForCompletedMotions` | 0x0050fe30 | 277925 | full body | -| `CPhysicsObj::Hook_AnimDone` | 0x0050fda0 | 277845 | full body | -| `MotionTableManager::AnimationDone` | 0x0051bce0 | 290558 | full body | -| `MotionTableManager::CheckForCompletedMotions` | 0x0051be00 | 290645 | full body (partial read, list-unlink tail identical pattern) | -| `MotionTableManager::HandleExitWorld` | 0x0051bda0 | 290625 | full body | -| `MotionTableManager::HandleEnterWorld` | 0x0051bdd0 | 290634 | full body | -| `CPartArray::AnimationDone` | 0x00517d30 | 285806 | full body | -| `CPartArray::CheckForCompletedMotions` | 0x00517d50 | 285829 | full body | -| `CPartArray::HandleMovement` | 0x00517d60 | 285843 | full body | -| `CPartArray::HandleEnterWorld` | 0x00517d70 | 285857 | full body | -| `CPartArray::HandleExitWorld` | 0x00517d90 | 285868 | full body | -| `CPartArray::HasAnims` | 0x00517d40 | 285820 | full body | -| `CBaseFilter::GetPinVersion` | 0x00527b10 | 304988 | full body (context for `unpack_movement`'s pin-version check) | -| `CPhysicsObj::IsAnimating` | — | — | **not present in this PDB** (see §7f) | -| `CPhysicsObj::IsMovingOrAnimating` | — | — | **not present in this PDB** (see §7f) | -| `MovementManager::HandleUpdateTarget` | — | — | **not present in this PDB** (see §6j) | -| `MakeMoveToManager` | — | — | deferred to R4; only `MoveToManager::Create` entry-point call shape captured (§6c/§6k) | - ---- - -## 10. Verbatim constants inventory (motion ids / error codes / bit flags seen in this scope) - -- `0x41000003` — canonical "forward = none" motion id (neutral). Used as the reset - target everywhere: `StopCompletely`, `enter_default_state`'s sentinel node, - `StopInterpretedMotion`'s post-stop requeue, `apply_interpreted_movement`'s fallback. -- `0x10000000` — the **action-class bit** on a motion id — tested identically in - `CMotionInterp::MotionDone`, `CMotionInterp::HandleExitWorld`, - `DoMotion`'s action-depth gate, `MotionTableManager::AnimationDone`/ - `CheckForCompletedMotions`. -- `0x8000003d` — `MotionStance` id for "non-combat / unrestricted" (the ONLY stance - from which jump-charge motions and bit-`0x2000000` motions are allowed through - `DoMotion`'s gate). -- `0x2000000` — bit tested against the raw motion id in `DoMotion`'s combat-stance - gate (rejected with `0x42` outside non-combat stance). -- `0x41000012` / `0x41000013` / `0x41000014` — the three jump-charge motion ids, - rejected in combat stance with distinct codes `0x3f`/`0x40`/`0x41` respectively. -- Error codes seen: `8` (no physics_obj), `0x24` (not grounded, physics `state` - bit `0x400` set — `jump_is_allowed`), `0x3f`/`0x40`/`0x41`/`0x42` (combat-stance - jump-charge rejections, `DoMotion`), `0x45` (action queue depth ≥ 6, `DoMotion`), - `0x47` (bad `MovementStruct.type`, OR `IsFullyConstrained`, OR - `JumpStaminaCost` failure), `0x48` (jump BLOCKED by the current motion/position — - the blocklist reject from `motion_allows_jump`/`jump_charge_is_allowed`/ - `charge_jump`; **NOT a success sentinel** — 0 is the success/pass value across the - whole jump-gate family, per W0-pins A1), `0x49` - (`CanJump` virtual rejected the charge — stamina/burden gate). -- `0.000199999995f` (~0.0002) — the epsilon used throughout for "is this velocity - component effectively zero" (`get_jump_v_z`, `get_leave_ground_velocity` x3). -- `HoldKey` encoding confirmed by direct assignment: `HoldKey_None = 1`, - `HoldKey_Run = 2` (derived from `set_hold_run`'s `(bool)+1` arithmetic cross-checked - against `SetHoldKey`'s literal `HoldKey_None`/`HoldKey_Run` assignments). -- `MovementTypes::Type` enum (verbatim, acclient.h:2856): `Invalid=0, RawCommand=1, - InterpretedCommand=2, StopRawCommand=3, StopInterpretedCommand=4, StopCompletely=5, - MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9`. -- `physics_obj->state` bit `0x400` (tested as `(int16_t)state)[1] & 4`, i.e. byte 1 - bit 2 of the 16-bit state word = bit 10 overall = `0x400`) — gravity/BSP-active flag, - gates `HitGround`/`LeaveGround`'s body AND `jump_is_allowed`'s ground-check branch. -- `transient_state` bits `0x1` and `0x2` (both required) = "on ground" — `on_ground`, - `charge_jump`, `contact_allows_move`, `is_standing_still` all inline this exact test. diff --git a/docs/research/2026-07-02-r3-motioninterp/r3-port-plan.md b/docs/research/2026-07-02-r3-motioninterp/r3-port-plan.md deleted file mode 100644 index 64f86393..00000000 --- a/docs/research/2026-07-02-r3-motioninterp/r3-port-plan.md +++ /dev/null @@ -1,377 +0,0 @@ -# R3 port work-list — CMotionInterp completion + local-player unification - -Inputs: `r3-motioninterp-decomp.md` (verbatim retail extraction, same dir), -`r3-ace-motioninterp.md` (ACE cross-ref, same dir), plan of record -`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R3), R2 boundary -contract `docs/research/2026-07-02-r2-motiontable/r2-port-plan.md` §4, current code -`src/AcDream.Core/Physics/MotionInterpreter.cs` (1426 lines, post-D6/S2a), -`src/AcDream.App/Input/PlayerMovementController.cs` (1521 lines), -`src/AcDream.Core/Physics/InterpretedMotionFunnel.cs`, GameWindow K-fix18 sites. - -**Precondition / state at R3 start (updated at vaulting, 2026-07-02):** R1 complete -(`a987cad1` P6). R2 is IN FLIGHT: Q0 (`dc54a3e4`) + Q1 MotionState (`2345da30`) + -Q2 `Motion/CMotionTable.cs` (`98f58db9`, 44 tests) committed; -Q3 (MotionTableManager + `IMotionDoneSink`) in flight, Q4 (adapter cutover + -queue-drain wiring) and Q5 (RemoteMotionSink deletion) NOT started. -`RemoteMotionSink.cs` still exists (217 lines). **R3 commits W2+ hard-depend on -R2 Q3/Q4/Q5 being shipped** (the `IMotionDoneSink` seam, the -sentinel→`AnimationDone` drain, and funnel-direct `PerformMovement` dispatch are -R2 deliverables R3 consumes). W0/W1 are independent and can start while R2 -finishes. - ---- - -## 0. DECOMP AMBIGUITIES TO PIN before porting (the W0 pseudocode commit) - -| # | Ambiguity | Evidence each way | Pin method | -|---|---|---|---| -| A1 | **`motion_allows_jump` 0x48 polarity is INVERTED in the BN extraction's "cleaned" note.** r3-motioninterp-decomp §3a annotates `0x48` = "jump allowed" and the range set as a whitelist. But the CALLER `jump_is_allowed` (0x005282b0, §3h) does `eax_7 = motion_allows_jump(fwd); if (eax_7 != 0) return eax_7;` — nonzero is returned AS THE ERROR, so **0x48 = "YouCantJumpFromThisPosition"-class BLOCK and the ranges are the blocklist** (matches ACE MotionInterp.cs:770-779: five blocked ranges + one exact id, return None otherwise). Under the corrected polarity the retail blocklist is: `[0x1000006f,0x10000078]`, `[0x10000128,0x10000131]`, `0x40000008 (Fallen)`, `[0x40000016,0x40000018]`, `[0x4000001e,0x40000039]`, `[0x41000012,0x41000014] (Crouch..Sleeping)`; everything else (incl. **0x40000015 Falling → 0 = pass**) falls through. ACE instead blocks exactly `Falling` and never `Fallen` — one of the two mis-transcribed a constant. The same wrong "0x48 = allowed" annotation propagates into the decomp doc's §10 constants inventory. | decomp §3a/§3h vs ACE L770-779; acdream MotionCommand.cs: Fallen=0x40000008, Falling=0x40000015 | Re-read raw pseudo-C @305 line 304908 for the exact comparison chain; map every boundary constant through the dat MotionCommand table (does 0x40000015 really pass?); decisive cdb: bp `acclient!CMotionInterp::motion_allows_jump` log arg2+eax while user jumps during walk/run/mid-fall/crouch. **Port the pinned blocklist as literal uint ranges, NOT enum-ordinal ranges** (ACE's enum-order dependence is the flagged fragility). | -| A2 | **`jump_is_allowed` pending-head peek gate.** Retail (§3h full body): peek `pending_motions.head_` whenever non-empty; if `head.jump_error_code != 0` return it. ACE (L756): `PendingMotions.Count > 1 && First.JumpErrorCode != 0` — the `Count > 1` has no retail counterpart in this decomp. | decomp §3h (no count check) vs ACE L756 | Retail decomp is full-body and unambiguous → port retail (head peek whenever queue non-empty). Note in pseudocode why ACE differs (their head = currently-playing heuristic). ACE's L747 `WeenieObj == null && !WeenieObj.IsCreature()` NPE-typo is CONFIRMED wrong vs retail's `weenie != 0 && IsCreature()==0` gate — do not copy. | -| A3 | **`apply_current_movement` / `SetWeenieObject` / `SetPhysicsObject` dual-dispatch gate: `IsThePlayer` or `IsCreature`?** The decomp extraction (§4c) states ReportExhaustion gates on `IsThePlayer() && movement_is_autonomous()` and claims the identical pattern in apply_current_movement/SetWeenieObject/SetPhysicsObject. ACE apply_current_movement (L430-438) gates on `IsCreature`. These are NOT the same predicate: a remote player is a creature but not the player. Client-side, `IsThePlayer` is the coherent reading (raw_state is only meaningful for the locally-controlled object); ACE is a server where its own physics objects are all "the player". Getting this wrong sends remotes down `apply_raw_movement` and corrupts their interpreted state from an empty raw state. | decomp §4c text vs ACE L430/L497 | Re-read raw pseudo-C at 0x00528870 (line 305838, apply_current_movement), 0x00528920 (SetWeenieObject), 0x00528970 (SetPhysicsObject) — the vtable slot called distinguishes `IsThePlayer` from `IsCreature`. If still ambiguous, cdb bp with `this` dump on a remote-visible session. Default: **IsThePlayer-based for apply_current_movement/ReportExhaustion** (decomp's explicit ReportExhaustion body shows IsThePlayer; §4c says the others are identical). | -| A4 | **MovementParameters bit numbering.** DoMotion reads byte-1 bits: `&8` → SetHoldKey (=0x800 overall), `&0x20` → raw mirror (=0x2000); acclient.h's union comment says "bit0x8=SetHoldKey, bit0x20=Raw mirror, bit0x40=Interpreted mirror" (byte-relative). Sign bit = interrupt. Ctor default = `(bitfield & 0xfffdee0f) | 0x1ee0f`. | decomp §0/§2 vs acclient.h:31453 comment | Derive the absolute bit positions from the ctor default 0x1ee0f + ACE MovementParameters property names (CancelMoveTo/SetHoldKey/ModifyRawState/ModifyInterpretedState/DisableJumpDuringLink/…) which ACE mapped from the same bitfield. Document the full flag table in the pseudocode doc; the C# port uses named bool properties + a `ToBitfield()` only if the wire ever needs it. | -| A5 | **`get_jump_v_z` no-weenie fallback + near-zero path.** BN garbles the `extent < 0.0002` path (x87 flag noise) and reads the no-weenie return as "clamped extent"; ACE: `< EPSILON → 0.0f`, no-weenie → `10.0f`, InqJumpVelocity false → `0.0f`. Current acdream uses ACE's shape but with epsilon **0.001** (wrong — retail literal is `0.000199999995f`). | decomp §3c vs ACE L634-652 vs MotionInterpreter.cs:278 | Textual pin at 0x00527aa0 raw. Low stakes (creatures always have a weenie); fix the epsilon regardless. | -| A6 | **`get_leave_ground_velocity` zero-fallback matrix direction.** BN reads `m_fl2gv` (local→global) applied to the velocity; ACE does `GlobalToLocalVec(Velocity)`. Since the result feeds `set_local_velocity` (a LOCAL vector) and `m_velocityVector` is global, global→local is the only coherent reading — BN row/column confusion. Current `Quaternion.Inverse(Orientation)` transform (MotionInterpreter.cs:1024) IS global→local: keep. Epsilon must become 0.0002 (currently 0.001 via JumpExtentEpsilon). | decomp §3d vs ACE L654-663 vs current :1011-1029 | Adjudicate textually in pseudocode; conformance test: walk-off-ledge with nonzero world velocity preserves momentum direction. | -| A7 | **`MovementManager::MotionDone` uninit-`edx` relay + dead `arg2` in `CMotionInterp::MotionDone`.** Decompiler artifact (adjudicated in the decomp doc §6d). Port as `MotionDone(uint motion, bool success)` passing the completed node's motion id; the interp body reads NEITHER param in this build — **pop the HEAD unconditionally, never match by motion id**. Keep the params for R5 signature parity. | decomp §1c/§6d | Documented adjudication only. | -| A8 | **`enter_default_state` does NOT clear `pending_motions`.** Retail (§4d): resets raw+interp state, InitializeMotionTables, then APPENDS the `{0, 0x41000003, 0}` sentinel to whatever queue exists — no drain. ACE resets `PendingMotions = new LinkedList` with the author's own `// ??`. Port retail (append, no clear); note the MovementManager lazy-create path calls enter_default_state TWICE on first touch (retail double-call, §6g) — genuine retail, do not "fix", but R3's direct-bind construction calls it once explicitly. | decomp §4d/§6g vs ACE L604-616 | Documented adjudication; test asserts pre-existing nodes survive an enter_default_state. | -| A9 | **`StopCompletely` jump-snapshot quirk**: `motion_allows_jump` is evaluated on the OLD forward_command, then the state is overwritten and the OLD result is stashed in the queued Ready node. Port verbatim including the quirk (ACE L301-327 agrees). | decomp §5a + ACE | Textual pin only; golden asserts the queued node's error code reflects the pre-stop command. | -| A10 | **Error-code renumber vs current acdream `WeenieError`.** Retail numeric usage: `8`=no physics obj, `0x24`=not grounded (jump_is_allowed airborne), `0x3f/0x40/0x41`=combat-stance crouch/sit/sleep rejects, `0x42`=combat-stance chat-emote-bit reject, `0x45`=action depth ≥6, `0x47`=constrained OR stamina OR bad MovementStruct.type, `0x48`=motion/position blocks jump, `0x49`=CanJump refused. acdream's current enum (MotionInterpreter.cs:113-127) has the NAMES shuffled (0x24 named GeneralMovementFailure but used where retail uses 0x48-class; jump_is_allowed returns 0x48 for airborne where retail returns 0x24). | decomp §10 vs MotionInterpreter.cs:113-127, :1064 | W1 fixes the enum to retail's numeric semantics (add 0x3f/0x40/0x41/0x42/0x45); W3/W5 use them. Codes are local-only (never on the wire) — safe rename. | - -**W0 cdb capture (one session serves all of R3):** bp `CMotionInterp::MotionDone` / -`add_to_queue` / `jump` / `jump_is_allowed` / `jump_charge_is_allowed` / `charge_jump` -/ `motion_allows_jump` / `HitGround` / `LeaveGround` / `StopCompletely` / `set_hold_run` -/ `SetHoldKey` / `enter_default_state` with arg+ret logging (pattern: -tools/cdb/l2g-observer.cdb). User protocol: idle / walk / run / shift-toggle mid-walk / -tap-jump / full-charge jump / running jump / standing long-jump (charge while idle, -then W mid-charge — the StandingLongJump suppression) / jump attempt while crouched -(blocked codes) / jump attempt in combat stance (0x3f-0x42 family) / walk off a ledge -(LeaveGround without jump — the momentum fallback) / land / emote-while-running -(action-class node through MotionDone) / 7 rapid emotes (0x45 depth cap) / logout -(HandleExitWorld drain). - ---- - -## 1. ITEMIZED GAPS — current vs retail (R3 scope) - -Severity: **BLOCKER** = R3's conformance harness meaningless without it; **HIGH** = -visible behavior wrongness / blocks R4-R6; **MED** = edge-visible; **LOW** = textual. - -| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity | -|---|---|---|---|---| -| J1 | **No `pending_motions` queue** — no MotionNode, no `add_to_queue` (append `{context_id, motion, jump_error_code}`), no `MotionDone` (head action-class 0x10000000 → unstick + Interpreted+Raw `RemoveAction`, then unconditional head pop), no `motions_pending`, no `HandleExitWorld` drain loop. The R2 `IMotionDoneSink` signal dead-ends in a diagnostic recorder (per r2-port-plan Q4). | add_to_queue 0x00527b80 @305032; MotionDone 0x00527ec0 @305238; motions_pending 0x00527fe0; HandleExitWorld 0x00527f30; ACE MotionInterp.cs:210-234/388-392/160-173 | `MotionInterpreter.cs:1394` ("Idle add_to_queue(Ready) bookkeeping lands with S3") + `:1417` ("add_to_queue … S3 bookkeeping") — both TODO comments; no queue field exists | **BLOCKER** | -| J2 | **No action FIFO on the states** — retail `RawMotionState`/`InterpretedMotionState` each carry an actions list (`ApplyMotion`/`RemoveMotion`/`AddAction`/`RemoveAction`/`GetNumActions`); MotionDone's action-class pop and DoMotion's ≥6 depth cap operate on it. acdream's `InterpretedMotionState` (MotionInterpreter.cs:185-210) + `LegacyRawMotionState` (:148-179) are flat 6/7-field structs; the outbound `RawMotionState` packs an always-empty action list (register TS-24). | acclient.h RawMotionState/InterpretedMotionState; DoMotion @306159 GetNumActions≥6; MotionDone RemoveAction pair | `MotionInterpreter.cs:148-210`; `Core/Physics/RawMotionState.cs` (packer type, no actions) | **BLOCKER** | -| J3 | **DoMotion is a 12-line approximation.** Retail 0x00528d20: interrupt on sign bit; `SetHoldKey` on bit-SetHoldKey BEFORE `adjust_motion`; fresh local `MovementParameters` re-default (caller's distance/heading fields discarded for the interpreted call); combat-stance gate (`current_style != 0x8000003d` rejects 0x41000012/13/14 → 0x3f/0x40/0x41 and `motion & 0x2000000` → 0x42 — tested on the ORIGINAL id); action-depth cap → 0x45; delegate `DoInterpretedMotion(adjusted, localParams)`; mirror `RawState.ApplyMotion(ORIGINAL id)` on the mirror bit. Current: writes RawState.Forward\* then calls the legacy DoInterpretedMotion — no params, no gates, no mirror discipline. | 0x00528d20 @306159; ACE L112-158 | `MotionInterpreter.cs:448-462` | **BLOCKER** | -| J4 | **TWO parallel DoInterpretedMotion implementations, both partial.** The legacy overload (:473-491) hand-applies state + apply_current_movement; the S2a funnel `DispatchInterpretedMotion` (:1408-1425) has the verbatim contact gate + sink dispatch but no StandingLongJump special-case (Walk/Run/SideStepRight while charging → state-only, NO dispatch, NO queue), no `Dead → RemoveLinkAnimations`, no jump_error_code compute (double `motion_allows_jump` check: incoming motion, then ForwardCommand if non-action) + `add_to_queue`, no `ModifyInterpretedState` param, no `CurCell==null → RemoveLinkAnimations` tail. Same story for StopInterpretedMotion (:527-553 vs the funnel's bare `sink.StopMotion`): missing post-stop `add_to_queue(ctx, Ready, None)` + raw `RemoveMotion` mirror. They must MERGE into one verbatim pair. | DoInterpretedMotion 0x00528360 (S2a base) + queue callers @305607/305657/305775; ACE L51-110/329-365 | `MotionInterpreter.cs:473-491`, `:498-553`, `:1408-1425`, `:1358-1396` | **BLOCKER** | -| J5 | **Jump gate family absent**: no `motion_allows_jump` (0x005279e0 literal-range blocklist, A1), no `jump_charge_is_allowed` (0x00527a50: CanJump → 0x49; Fallen/Crouch..Sleeping → 0x48), no `charge_jump` (0x005281c0: arms `standing_longjump` only when on-ground + fwd==Ready + no sidestep/turn). `jump_is_allowed` (:1053-1071) is a 15-line approximation missing the creature/state-0x400 entry shape, IsFullyConstrained → 0x47, the pending-head jump_error_code peek (A2), the charge→motion→stamina chain, and `JumpStaminaCost` (not on IWeenieObject). Wrong airborne code (0x48, retail 0x24 — A10). | §3a/3b/3e/3h; ACE L729-779 | `MotionInterpreter.cs:1053-1071`; IWeenieObject :239-256 (no JumpStaminaCost/IsThePlayer) | **BLOCKER** | -| J6 | **StandingLongJump armed in the WRONG function** — the S2a-flagged misattribution: acdream arms it as a side effect of `contact_allows_move` (:1139-1148, explicitly marked "PRE-EXISTING acdream side effect (not part of 0x00528240)"); retail arms it ONLY in `charge_jump`. Consequence today: every grounded idle contact check flips the flag, so the funnel's StandingLongJump branch (:1371-1375) can fire without any jump charge. | charge_jump 0x005281c0 @305448 | `MotionInterpreter.cs:1139-1148` | **HIGH** | -| J7 | **jump() missing `interrupt_current_movement`** (retail always cancels the in-flight transition first; ACE `cancel_moveto`). And the CONTROLLER manually calls `LeaveGround()` + pre-captures `get_jump_v_z` at key-release (PlayerMovementController.cs:996-1028) — retail's LeaveGround fires from the physics layer detecting ground departure (`set_on_walkable(false)` → transition → MovementManager::LeaveGround). Walk-off-a-ledge never calls LeaveGround at all today (the get_leave_ground_velocity momentum fallback is dead code). | jump 0x00528780 @305792; LeaveGround 0x00528b00 (NOT the doc-comment's 0x00529710) | `MotionInterpreter.cs:943-958`; PlayerMovementController.cs:996-1028, :1226-1257 (transition block calls HitGround but never LeaveGround) | **HIGH** | -| J8 | **HitGround/LeaveGround bodies incomplete**: both missing the creature gate (`weenie==null OR IsCreature`) and `RemoveLinkAnimations`; LeaveGround missing the state-0x400 gate, `apply_current_movement(0,0)` re-sync, and the autonomous flag on `set_local_velocity(v, 1)`. **This is the retail mechanism K-fix18 fakes**: leaving ground strips pending link animations, so Falling engages instantly — no `skipTransitionLink` needed. | HitGround 0x00528ac0 @305996; LeaveGround 0x00528b00 @306022 | `MotionInterpreter.cs:1166-1198`; K-fix18 sites: AnimationSequencer.cs:305/313/390/401/433, GameWindow.cs:4817-4831, :10194-10224 | **HIGH** | -| J9 | **StopCompletely missing** interrupt, the A9 jump snapshot, `add_to_queue(0, Ready, jumpErr)`, and cell-null `RemoveLinkAnimations`; extra divergence: resets sidestep/turn SPEEDS to 1.0 (retail writes only fwd cmd/speed + zeroes sidestep/turn COMMANDS). `set_velocity(Zero)` stands in for `StopCompletely_Internal` (acceptable, keep + note). | 0x00527e40 @305208 | `MotionInterpreter.cs:577-602` | **HIGH** | -| J10 | **No `enter_default_state` / `initted`**: no state reset + InitializeMotionTables + Ready-sentinel enqueue (A8) + `initted=1` + unconditional LeaveGround; nothing gates `apply_current_movement`/`ReportExhaustion` on initted. | 0x00528c80 @306124 | constructors at `MotionInterpreter.cs:386-398` only | **BLOCKER** | -| J11 | **`apply_current_movement` is a direct-velocity approximation**, not the retail dual dispatch (`initted` gate → IsThePlayer/autonomous (A3) → `apply_raw_movement(cancelMoveTo, allowJump)` else `apply_interpreted_movement(...)`). The grounded `set_local_velocity(get_state_velocity())` write is an acdream adaptation of root-motion-driven velocity — survives R3 (register row), retires in R6. Also: no `(cancelMoveTo, allowJump)` param plumbing anywhere — `DisableJumpDuringLink = !allowJump` feeds the jump_error_code path (ACE L538/L232). | 0x00528870 @305838 (A3 pin); ACE L430-438 | `MotionInterpreter.cs:905-925`; funnel `ApplyInterpretedMovement` :1358 (no params) | **HIGH** | -| J12 | **No `ReportExhaustion`** (initted gate + IsThePlayer/autonomous dual dispatch, both `(0,0)` args); no `movement_is_autonomous` flag on PhysicsBody; no `IsThePlayer` on IWeenieObject. Retail relay chain: MovementManager::ReportExhaustion → CMotionInterp (+ MoveToManager, R4). The dead ACE `NoticeHandler.RecvNotice_PrevSpellSelection` breadcrumb is NOT R3 scope (spell-UI notice) — file as a note, don't port. | 0x005288d0 @305861 | nothing | MED-HIGH (needed for stamina-exhaustion demotes; wire caller lands with server stamina events) | -| J13 | **No `SetHoldKey`/`set_hold_run`** — retail's Shift edge handling (XOR toggle guard, None-only-meaningful-from-Run, both re-apply movement). acdream fakes it by rebuilding the whole RawMotionState every frame (J15). `HoldKey_None=1 / HoldKey_Run=2` encoding already matches acdream's HoldKey enum usage. | set_hold_run 0x00528b70 @306053; SetHoldKey 0x00528bb0 @306072 | `MotionInterpreter.cs:340` (bare CurrentHoldKey field, written by apply_raw_movement only) | **HIGH** | -| J14 | **No `CMotionInterp::PerformMovement` 5-way dispatch** with `CheckForCompletedMotions` after EVERY op (the synchronous zero-tick drain — retail calls it at 306234-306262 after each of DoMotion/DoInterpretedMotion/StopMotion/StopInterpretedMotion/StopCompletely). Current PerformMovement (:416-430) dispatches to the approximations and has a comment explicitly skipping the flush. Needs a seam to the entity's MotionTableManager (R2-Q3). | 0x00528e80 @306221; CPhysicsObj::CheckForCompletedMotions 0x0050fe30 | `MotionInterpreter.cs:400-430` | **BLOCKER** | -| J15 | **Local player motion state machine lives in PlayerMovementController, level-triggered.** Per-frame RawMotionState rebuild from held keys (:887-910) instead of retail's edge-driven DoMotion/StopMotion + set_hold_run through PerformMovement; jump charge/fire block calls `_motion.jump` + manual LeaveGround (:986-1028); outbound wire commands re-derived from `input` (:1264-1328) instead of read from the raw state; `LocalAnimationCommand`/`LocalAnimationSpeed` synthesized (:1284-1296, :1418-1456) instead of the interpreted state driving the sequencer through the same funnel path remotes use. This is the plan-of-record's "motion half of PlayerMovementController" — R3's unification target. | DoMotion/StopMotion callers = CommandInterpreter edges; set_hold_run call sites §5c (0x0058b303 / 0x006b33ca) | PlayerMovementController.cs:887-910, :986-1028, :1264-1359, :1418-1456; GameWindow.cs:10062-10234 (UpdatePlayerAnimation fallback chain + SetCycle drive) | **BLOCKER** for the stage's named deliverable | -| J16 | **Epsilons + codes**: `JumpExtentEpsilon = 0.001` used in get_jump_v_z AND get_leave_ground_velocity — retail is `0.000199999995f` in both (§10); WeenieError names shuffled vs retail numerics (A10). | §3c/§3d/§10 | `MotionInterpreter.cs:278`, :978, :1020; :113-127 | MED | -| J17 | **`is_standing_still` absent** (on-ground + Ready + no sidestep/turn) — trivial; needed by callers (input layer, R7 cadence) and by the decomp's documented substitute set for the not-in-retail `IsAnimating`/`IsMovingOrAnimating`. **Do NOT invent a combined IsAnimating helper** — retail has none (decomp §7f negative result); ACE's `PhysicsObj.IsAnimating` flag is an ACE-ism (retail queries `motions_pending`). | 0x00527fa0 @305309 | nothing | LOW | -| J18 | **`adjust_motion` creature guard unwired** (register TS-34): `IWeenieObject.IsCreature()` exists (default true) but adjust_motion ignores it (comment ":758 Creature guard unwired"). One-line fix; retires TS-34. | adjust_motion @305343 gate | `MotionInterpreter.cs:756-758` | LOW | -| J19 | **VectorUpdate remote-jump path bypasses the interp** (GameWindow.cs:4782-4840): sets body velocity/airborne + forces the Falling cycle via `SetCycle(..., skipTransitionLink:true)` (K-fix18 site 1). Retail: the remote's physics leaves ground → LeaveGround → RemoveLinkAnimations + apply_current_movement → contact-blocked funnel dispatches Falling. After J8, this becomes `rm.Motion.LeaveGround()` (+ the funnel's existing Falling dispatch). | SmartBox::DoVectorUpdate 0x004521C0 → set_velocity/set_omega; LeaveGround chain | GameWindow.cs:4802-4833 | **HIGH** | - ---- - -## 2. KEEP LIST — already matching retail (do not re-port) - -| Behavior | Retail anchor | acdream anchor | -|---|---|---| -| Inbound funnel dispatch order: `MoveToInterpretedState` (flat copy + 15-bit stamp gate + local-echo skip) / `ApplyInterpretedMovement` axis order (style → fwd-or-Falling → sidestep(-stop) → turn(-stop, early return)) + 183-case live-trace suite | 0x005289c0 / 0x00528600 (S2a, `7b0cbbda`) | `MotionInterpreter.cs:1312-1396` — W5 merges the Dispatch backend into verbatim DoInterpretedMotion WITHOUT touching dispatch order; suite is the parity bar | -| `contact_allows_move` verbatim body (turns/Falling/Dead always-allowed, non-creature bypass, gravity bypass, Contact+OnWalkable) | 0x00528240 (S2a rewrite) | `MotionInterpreter.cs:1096-1151` — MINUS the :1139-1148 StandingLongJump side effect (J6 deletes it) | -| `adjust_motion` / `apply_run_to_command` / `apply_raw_movement` / `get_state_velocity` incl. the RunForward-early-return, sign-gated promotion, ±3.0 sidestep clamp, max-speed clamp | D6/D6.2a (`0f099bb6`) | `MotionInterpreter.cs:654-888` — untouched except J18's one-line guard | -| `GetMaxSpeed` ×4.0 (UN-2 byte-verified vs the BN x87 dropout) | 0x00527cb0 | `MotionInterpreter.cs:1235-1245` | -| `GetCycleVelocity` Option-B forward-velocity source (MotionData.Velocity over the constant) | adaptation, r03 §1.3 | `MotionInterpreter.cs:382`, :668-683 — keep; verify register row exists, retire when R6 root motion drives the body | -| `StopCompletely_Internal` stand-in = `PhysicsObj.set_velocity(Zero)` | 0x0050f5a0 is CPhysicsObj-side | `MotionInterpreter.cs:599` — keep + register note (full port is physics-layer scope) | -| Jump charge accumulation UI timing (2.0 extent/s, AP-24) + extent→height formula | GetPowerBarLevel 0x0056ADE0 illegible | PlayerMovementController.cs:167-178 — charge STAYS controller-side (retail's charge lives at the SmartBox/input boundary too, §3e caller 0x0056afac); AP-24 row survives | -| Physics tick gate (30 Hz MinQuantum), Resolve/bounce/landing detection, AP diff cadence, PortalSpace, SetPosition/SnapToCell, NotePositionSent | L.5/L.2c/B.6 anchors in-file | PlayerMovementController.cs sections 4-5, 8 — R3 does not touch; R6 owns tick order | -| Auto-walk (B.6) block incl. `DoMotion(Ready)`/`DoMotion(WalkForward)` drive | MovementManager case 6 stand-in | PlayerMovementController.cs:254-766 — survives R3 verbatim (calls route through the new DoMotion transparently); REPLACED in R4 by MoveToManager | -| Outbound wire packers (RawMotionState::Pack, MoveToStatePack, JumpPack) + dual command catalogs | L.2b/D6.2b/L.1b | untouched; W6 changes only WHERE the packed values are read from (raw state instead of re-derivation) | -| `MotionSequenceGate` (S1), `InterpolationManager` (L.3), R1 CSequence core, R2 CMotionTable/MotionState/MotionTableManager | plan "absorbed" + R2 | untouched — R3 sits between MovementManager(-to-be) and the R2 queue | -| `0x41000003` Ready sentinel / `0x10000000` action-class bit / `0x8000003d` NonCombat conventions | §10 | already the file-wide constants | - ---- - -## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first - -New code target: extend `src/AcDream.Core/Physics/MotionInterpreter.cs` in place (it IS -the CMotionInterp port; plan rule 4's `Motion/` dir holds the R2 table/queue classes it -talks to). Tests: `tests/AcDream.Core.Tests/Physics/` (existing MotionInterpreter -suites + `Motion/` for new fixtures). Every commit: build+test green, register rows -added/retired in-commit. - -**W0 — pseudocode + ambiguity pinning (docs only).** -`docs/research/2026-07-0x-motioninterp-pseudocode.md` from r3-motioninterp-decomp.md, -resolving A1-A10 (§0) — the A1 polarity fix and A3 dispatch-gate pin are load-bearing; -correct the decomp doc's §3a/§10 annotations in the same commit. Run the ONE cdb -session (§0 protocol) — feeds W2/W3/W5/W6 goldens. -Fixture source: **cdb** (live retail). Deps: none (parallel with R2 completion). - -**W1 — retail state completion: action FIFO + MovementParameters + WeenieError renumber.** (closes J2, J16-codes/A10) -`InterpretedMotionState`/`RawMotionState` gain the actions list -(`AddAction`/`RemoveAction`/`GetNumActions`/`ApplyMotion`/`RemoveMotion` per retail -member set; the outbound packer's empty-list TS-24 comment updates to "populated by -CMotionInterp"); fold `LegacyRawMotionState` into the full-field -`AcDream.Core.Physics.RawMotionState` (one raw-state type; delete the legacy struct); -`MovementParameters` verbatim class (ctor defaults: distance_to_object=0.6, -fail_distance=FLT_MAX, speed=1, walk_run_threshhold=15, hold_key=Invalid, bitfield -0x1ee0f per A4 pin; named bool properties); `MotionNode {ContextId, Motion, -JumpErrorCode}`; WeenieError renumbered to retail (add 0x3f/40/41/42/45, fix -0x24/0x47/0x48 semantics). -Tests first: FIFO discipline; params defaults vs ctor decomp; existing suites green -under the raw-state fold. -Fixture source: **synthetic**. Deps: W0. - -**W2 — pending_motions lifecycle + the MotionDone consumer (R2 seam lands).** (closes J1, J17; §4 below is this commit's wiring spec) -`MotionInterpreter` gains `PendingMotions` (LinkedList — register note -reuses AD-34 wording), `add_to_queue` 0x00527b80, `motions_pending` 0x00527fe0, -`MotionDone(uint motion, bool success)` 0x00527ec0 (head-peek action-class → unstick -seam (no-op Action? callback until R5 StickyManager — register row) + -`InterpretedState.RemoveAction()` + `RawState.RemoveAction()`; unconditional head pop; -NEVER match by motion id per A7), `HandleExitWorld` 0x00527f30 (loop), -`is_standing_still` 0x00527fa0, and `motion_allows_jump` 0x005279e0 (pure fn, pinned -per A1 — needed here because every add_to_queue caller computes jump_error_code). -Wire the queue's PRODUCERS into the funnel: `DispatchInterpretedMotion` success path -computes jump_error_code (double-check per ACE L231-239 shape pinned at W0) + -`add_to_queue(ctx, motion, err)`; `ApplyInterpretedMovement`'s turn-stop adds -`add_to_queue(ctx, Ready, None)` (@305775); funnel StopMotion path adds the post-stop -Ready node (@305657). GameWindow: replace R2-Q4's diagnostic `IMotionDoneSink` binding -with the real per-entity bind (§4); teleport/despawn/exit paths call BOTH -`manager.HandleExitWorld()` (R2) and `interp.HandleExitWorld()` (retail has both -layers' drains). -Tests first: queue tables (enqueue per successful dispatch incl. Ready stops; pop -order; action-class pops from BOTH states + fires unstick; exit drain); S2a 183-case -suite green (dispatch unchanged, queue side effects asserted additively); end-to-end -chain test: MotionTableManager.AnimationDone → sink → interp pops (§4 diagram). -Fixture source: **synthetic + W0 cdb goldens** (add_to_queue arg conformance). -Deps: W1 + **R2 Q3/Q4 shipped**. - -**W3 — jump family verbatim.** (closes J5, J6, J7-interp-side, J16-epsilons) -`jump_charge_is_allowed` 0x00527a50; `charge_jump` 0x005281c0 (arms StandingLongJump; -**DELETE the contact_allows_move side effect :1139-1148** — J6); `get_jump_v_z` -(epsilon → 0.000199999995f; A5 fallback pinned); `get_leave_ground_velocity` (same -epsilon; A6 direction confirmed); `jump_is_allowed` 0x005282b0 full chain (creature/ -state-0x400 entry shape → 0x24; `IsFullyConstrained` seam on PhysicsBody (stub false + -register row); pending-head peek per A2; charge → motion_allows_jump(fwd) → -`JumpStaminaCost` (new IWeenieObject member; PlayerWeenie implements — real gating -stays TS-5-deferred, row survives)); `jump` 0x00528780 (+`interrupt_current_movement` -seam — no-op Action? until R4 cancel_moveto exists; register row). IWeenieObject gains -`IsThePlayer()` (default false; PlayerWeenie true) for W4's A3 dispatch. -Tests first: per-error-code gate tables from W0 goldens; StandingLongJump arming -matrix (grounded+idle only, cleared on failed jump); head-peek short-circuit; -blocked-motion jump attempts (crouch range per pinned A1 table). -Fixture source: **W0 cdb goldens + synthetic**. Deps: W2. - -**W4 — ground transitions + lifecycle: HitGround/LeaveGround/enter_default_state/ReportExhaustion/hold keys; K-fix18 DELETED.** (closes J8, J10, J11-shape, J12, J13, J19; J18 one-liner rides along) -`HitGround` 0x00528ac0 verbatim (creature gate + Gravity + `RemoveLinkAnimations` seam -+ `apply_current_movement(false, true)`); `LeaveGround` 0x00528b00 verbatim (velocity -+ autonomous flag arg on set_local_velocity + resets + RemoveLinkAnimations + -apply_current_movement); `RemoveLinkAnimations` seam = `Action?` on MotionInterpreter, -App binds to the entity's AnimationSequencer (CSequence.RemoveAllLinkAnimations per -CPhysicsObj::RemoveLinkAnimations 0x0050fe20 → CPartArray → sequence); -`enter_default_state` 0x00528c80 (A8: append sentinel, no clear; `Initted` field; -LeaveGround tail); `Initted` gates apply_current_movement + ReportExhaustion; -`apply_current_movement` becomes the verbatim dual dispatch per A3 (the direct -grounded-velocity write MOVES to the controller-side call site unchanged — register -row: velocity-from-state adaptation until R6 root motion); `ReportExhaustion` -0x005288d0 (A3 gate; `movement_is_autonomous` flag added to PhysicsBody, set true at -the local-player chokepoint, false on DR-applied updates); `SetHoldKey` 0x00528bb0 + -`set_hold_run` 0x00528b70; `SetPhysicsObject`/`SetWeenieObject` re-apply pattern. -**PlayerMovementController transition wiring:** grounded→airborne edge (section 5, -:1245-1257) now calls `_motion.LeaveGround()`; the jump-fire block stops calling -LeaveGround manually — `jump()` sets on_walkable(false) and the SAME frame's -transition detection fires LeaveGround (ordering test); JumpAction wire velocity read -from the LeaveGround-computed vector (get_leave_ground_velocity at fire time — -byte-identical values, source unified). -**K-fix18 DELETION (both sites + the parameter):** GameWindow :4817-4833 remote -VectorUpdate → `rm.Motion.LeaveGround()` replaces the forced SetCycle (J19); GameWindow -:10194-10224 skipLink computation + arg deleted; `AnimationSequencer.SetCycle`'s -`skipTransitionLink` parameter + its post-dispatch link-clear (whatever shape R2-Q4 -left it in) deleted; K-fix18 register row (r2-port-plan keep-list says "register row -kept → R3") retired — if no row exists, the sweep note records it was comment-only. -Tests first: HitGround/LeaveGround call RemoveLinkAnimations + re-apply (seam mock); -ledge walk-off momentum fallback (A6); jump-fire → LeaveGround same-tick ordering; -enter_default_state seeds queue + initted + LeaveGround; ReportExhaustion dispatch -truth table (A3 pin); hold-key toggle tables (set_hold_run XOR guard; SetHoldKey -None-only-from-Run). Visual acceptance carried to W6's pass: jump engages Falling -instantly WITHOUT K-fix18. -Fixture source: **synthetic + W0 cdb goldens (LeaveGround/HitGround arg traces)**. -Deps: W3 (+ R2-Q4 committed for the sequencer seam shape). - -**W5 — DoMotion/StopMotion/StopCompletely verbatim + the ONE DoInterpretedMotion + PerformMovement flush.** (closes J3, J4, J9, J14) -`DoMotion` 0x00528d20 (interrupt bit; SetHoldKey bit BEFORE adjust_motion; params -re-default; combat-stance gate on ORIGINAL id → 0x3f/0x40/0x41/0x42; depth cap 0x45; -raw mirror ORIGINAL id); `StopMotion` 0x00528530 (mirror shape, no gates, raw -RemoveMotion ORIGINAL id); `StopCompletely` 0x00527e40 (A9 verbatim incl. quirk + -exact-fields-only reset + add_to_queue + cell-null RemoveLinkAnimations); **merge** -legacy `DoInterpretedMotion`/`StopInterpretedMotion` overloads with the funnel -Dispatch backend into ONE verbatim `DoInterpretedMotion(uint, MovementParameters)` / -`StopInterpretedMotion(uint, MovementParameters)` (StandingLongJump state-only branch; -Dead → RemoveLinkAnimations; jump_error_code + add_to_queue (moves here from W2's -interim funnel site); ModifyInterpretedState; CurCell-null tail — CurCell proxy = -`PhysicsObj.CellId == 0`); **DELETE** `ApplyMotionToInterpretedState` (:1253-1282) + -the legacy overloads; `PerformMovement` gains the per-op -`CheckForCompletedMotions` seam call (bound to the entity's MotionTableManager -UseTime/CheckForCompletedMotions — R2-Q3 object) after every dispatch. -Tests first: DoMotion gate tables (each combat-stance code, depth cap, mirror -discipline: raw gets ORIGINAL id, interpreted gets adjusted) from W0 goldens; S2a -183-case suite green under the merged backend (assertions unchanged — dispatch order -is funnel-owned); zero-tick flush test (stop-with-no-link completes same call). -Fixture source: **W0 cdb goldens + suite**. Deps: W2+W4 (+ R2-Q5 so the funnel's sink -IS PerformMovement dispatch — otherwise the merge has two consumers). - -**W6 — LOCAL PLAYER UNIFICATION (the GameWindow + controller commit; do NOT fan out — feedback_dont_parallelize_coupled_plan_slices).** (closes J15) -PlayerMovementController sheds the motion state machine; edge-driven retail input: -- **DIES in the controller:** per-frame RawMotionState rebuild + `apply_raw_movement` - call (:878-910); the manual LeaveGround + get_jump_v_z pre-capture (done in W4); - `localAnimCmd`/`LocalAnimationSpeed` synthesis (:1283-1296, :1340-1359 change - detection on localAnimCmd, :1412-1456 K-fix5 block + auto-walk anim overrides — - the sequencer is now driven by the interp's own dispatch, not by - UpdatePlayerAnimation); `MovementResult.LocalAnimationCommand/LocalAnimationSpeed` - fields (and GameWindow consumers). -- **MOVES to MotionInterpreter calls (edge-driven):** input edges → `PerformMovement`: - W/S press → `DoMotion(WalkForward/WalkBackward, params)`; release → - `StopMotion(same)`; strafe/turn keys likewise; Shift edge → `set_hold_run(run, - interrupt:true)` (retail 0x006b33ca shape); jump release → `jump(extent)` (charge - accumulation STAYS controller-side, AP-24). The controller keeps a tiny - prev-key-state edge detector — that IS retail's CommandInterpreter altitude. -- **STAYS in the controller (input/camera/physics/wire):** Yaw + mouse turn - (:938-946); keyboard-turn Yaw integration reading - `_motion.InterpretedState.TurnCommand/TurnSpeed` (:932-937, unchanged); grounded - velocity write from `get_state_velocity` (:966-976 — the R6-deferred adaptation); - physics integrate/Resolve/bounce/landing (sections 4-5); heartbeat/AP (section 8); - auto-walk (B.6, until R4); SetPosition/PortalSpace (the :825 `DoMotion(Ready)` - arrival-idle becomes `StopCompletely()` — retail's teleport idle is a full stop; - note in commit); outbound section 6 now READS `_motion.RawState` (commands + per-axis - hold keys) instead of re-deriving from `input` — wire bytes identical (golden-byte - packer tests prove it). -- **GameWindow call sites that change:** `UpdatePlayerAnimation(result)` (:10062-10234) - DELETED — the player's AnimatedEntity sequencer/manager is bound to the player's - MotionInterpreter via the same funnel/PerformMovement path remotes use (R2-Q5 - sink shape); the airborne-Falling override falls out of contact_allows_move + - apply_current_movement (retail mechanism, already live in the funnel); the #45 - sidestep animSpeed factor + ACDREAM_ANIM_SPEED_SCALE knob move into (or are - re-verified against) the CMotionTable-driven speedMod path — expected-diff - annotations required; :4344 `ApplyServerRunRate` retargets to `my_run_rate` + - `apply_current_movement` (same effect, no InterpretedState poke); :7953-7971 - JumpAction build reads the W4-unified launch vector; :7975 call deleted with - UpdatePlayerAnimation. -Tests first: FULL suite green; pre-cutover recorded local-player SetCycle/dispatch -traces (captured BEFORE this commit under the W0 protocol) replayed → same cycle -identities + speeds, expected-diffs documented (Falling engage now via -link-strip; sidestep factor source); outbound golden-byte parity (MoveToState/AP/Jump -byte-identical for the same input script); live smoke ACDREAM_DUMP_MOTION + -observer view; **ONE user visual pass** (walk/run/toggle/strafe/turn/jump instant -Falling/landing/emote-mid-run — the stage acceptance). -Fixture source: **pre-cutover recorded traces + golden-byte + suite**. Deps: W5. - -**W7 — register sweep + roadmap + digest (docs/cleanup only).** -Retired rows: MotionDone-unconsumed (R2's), K-fix18, TS-34 (adjust_motion guard), -plus the S2a StandingLongJump-misattribution note. Surviving rows (verify/add): -TS-5 (CanJump always-true → now ALSO JumpStaminaCost stub), AP-24 (charge rate), -TS-24 updated (actions now populated locally — packing next), TS-21/TS-23/AP-30 -(re-anchor line numbers after controller edits), NEW rows: unstick_from_object no-op -seam (→R5 StickyManager), interrupt_current_movement/cancel_moveto no-op (→R4), -grounded velocity-from-state write (→R6 root motion), managed LinkedList -vs LList (AD-34 wording), A5/A6 pinned-reading notes if either resolves against ACE. -Roadmap stage table (R3 shipped); milestones "currently working toward" check; memory -digest note (animation-sequencer deep-dive: pending_motions gap CLOSED end-to-end). -Deps: W6. - -Parallelization: W0∥W1 only. W2→W3→W4→W5→W6 are sequential-coupled (queue → gates -using queue → transitions using gates → dispatchers using all → cutover). W4 and W6 -both touch GameWindow — single-agent each. - ---- - -## 4. IMotionDoneSink → MotionInterpreter.MotionDone — exact wiring vs R2's seam - -R2's contract (r2-port-plan §4) delivers: `CSequence` G5 gate → `AnimationDoneSentinel` -→ GameWindow anim tick drain (:9876-9890 area) → `manager.AnimationDone(true)` per -sentinel + `manager.UseTime()` once per tick → `MotionTableManager` countdown pop → -action-class head pops the MANAGER's own `MotionState.RemoveActionHead()` (R2-OWNED) -→ `IMotionDoneSink.MotionDone(uint motion, bool success)` — bound in R2-Q4 to a -diagnostic recorder. - -R3-W2 replaces the recorder: - -``` -MotionTableManager (per entity) [R2-Q3, exists] - └─ IMotionDoneSink.MotionDone(motion, success) [R2 seam — signature UNCHANGED] - └─ R3: the entity's MotionInterpreter.MotionDone(motion, success) - [stands in for retail CPhysicsObj::MotionDone 0x0050fdb0 → - MovementManager::MotionDone 0x005242d0 → CMotionInterp::MotionDone - 0x00527ec0 — both intermediates are null-guarded relays with ZERO - logic; R5 inserts MovementManager between manager and interp - WITHOUT changing this behavior] - body: head-peek pending_motions; - head.Motion & 0x10000000 → unstick seam (no-op → R5) - + InterpretedState.RemoveAction() - + RawState.RemoveAction(); - pop head UNCONDITIONALLY (A7: motion id + success are IGNORED — - positional protocol, never match-by-id, never branch on success) -``` - -Binding points (App, per entity, at creation): -- **Remote:** where `RemoteMotion` is constructed (each remote already owns a - `MotionInterpreter` — `remoteMot.Motion`, GameWindow.cs:4646): the same construction - that gives the entity its MotionTableManager (R2-Q4/Q5) passes - `sink: remoteMot.Motion` (MotionInterpreter implements IMotionDoneSink directly). -- **Local player:** `PlayerMovementController` exposes its `MotionInterpreter` - (new internal property `Motion`); GameWindow binds the player entity's manager to it - when the player's AnimatedEntity/sequencer is created (same site as - `AttachCycleVelocityAccessor`, :384). - -Two structural rules carried from R2 §4 (decomp-confirmed in r3 extraction §7b): -1. **Two queues, never merged**: `MotionTableManager.pending_animations` (CPartArray - side, duration-in-ticks) vs `CMotionInterp.pending_motions` (movement side, - waiting-for-callback). THREE action trackers stay in lockstep via the 0x10000000 - bit: manager's MotionState action FIFO (popped in R2's AnimationDone), - InterpretedState.actions + RawState.actions (popped in R3's MotionDone). -2. **Tick placement provisional until R6**: the sentinel drain + UseTime stay at the - R2-documented GameWindow drain point (G6 seam). R3 adds only the SYNCHRONOUS flush: - `CMotionInterp.PerformMovement` calls the manager's CheckForCompletedMotions after - every op (W5), which fires MotionDone same-call for zero-tick nodes. - -Success-flag adjudication (closes r2-plan §4's warning): retail -`CMotionInterp::MotionDone` never reads the flag in this build — R3's jump/HitGround -logic does NOT key off it after all. The flag's only real consumers are -server/weenie-side (`OnMotionDone`) and the R2 manager's own countdown semantics. -Preserve pass-through (true from Hook_AnimDone, false from world drains, hardcoded -true from CheckForCompletedMotions) for R5 signature parity; document as inert at the -interp. - -Enter/exit world: exit paths call `manager.HandleExitWorld()` (drains -pending_animations, each firing sink.MotionDone(motion, false) → interp pops in step) -THEN `interp.HandleExitWorld()` (drains any remainder — retail runs both layers' -drains via CPartArray::HandleExitWorld and MovementManager::HandleExitWorld -independently). Enter-world: manager-side only (`remove_all_link_animations` + drain); -`MovementManager::HandleEnterWorld`'s interp relay is the mis-symbolicated tailcall -(decomp §6e) — CMotionInterp has NO HandleEnterWorld; do not invent one (negative -result, decomp §6e/§1d). - ---- - -## 5. NEGATIVE RESULTS / DO-NOT-INVENT (binding on subagents) - -- `CPhysicsObj::IsAnimating` / `IsMovingOrAnimating` — **not in this PDB** (decomp - §7f). ACE's `PhysicsObj.IsAnimating` bool is an ACE-ism. acdream queries - `motions_pending()` / `is_standing_still()` / `CPartArray.HasAnims` individually. -- `MovementManager::HandleUpdateTarget` — not in this PDB (§6j); likely - MoveToManager-internal (R4). Do not stub. -- `CMotionInterp::HandleEnterWorld` — does not exist (§6e); enter-world work is - manager/CPartArray-side only. -- `MakeMoveToManager` / MoveToManager internals — R4. R3 leaves the B.6 auto-walk + - `interrupt_current_movement`/`cancel_moveto` as registered no-op seams. -- `NoticeHandler::RecvNotice_PrevSpellSelection` (HandleEnterWorld/ReportExhaustion - relays) — retail spell-UI notice ACE never ported; out of R3 scope; file a research - note only (no ACE reference exists — decomp-direct if ever needed). -- `LeaveGround` address is **0x00528b00** — the 0x00529710 in MotionInterpreter.cs's - doc comment (:1153, :1159) is a stale Ghidra-chunk guess; W4 fixes the citations - (several header comments cite old FUN_ addresses — sweep them to named symbols). diff --git a/docs/research/2026-07-02-r3-motioninterp/w6-cutover-map.md b/docs/research/2026-07-02-r3-motioninterp/w6-cutover-map.md deleted file mode 100644 index 251051ac..00000000 --- a/docs/research/2026-07-02-r3-motioninterp/w6-cutover-map.md +++ /dev/null @@ -1,726 +0,0 @@ -# R3-W6 cutover-execution-map — local player motion unification - -Research-only doc. Re-anchors `r3-port-plan.md` §3 W6 (which cites stale -line numbers) against the CURRENT code, post-W1-W5. Every anchor below was -read directly from the working tree at HEAD (`e214acdf`) during this -session — no line number here is inherited from the plan. - -Inputs read: `r3-port-plan.md` §3 W6 + §1 row J15 + §4 (sink wiring -contract); `r3-motioninterp-decomp.md` §5c/§8 (`set_hold_run` callers); -`src/AcDream.App/Input/PlayerMovementController.cs` (full file, 1547 -lines); `src/AcDream.App/Rendering/GameWindow.cs` (targeted sections via -subagent + direct reads); `src/AcDream.Core/Physics/MotionInterpreter.cs` -(full file, 3083 lines); `src/AcDream.Core/Physics/RawMotionState.cs`; -`src/AcDream.Core/Physics/Motion/MovementParameters.cs`; the test tree. - ---- - -## §1 Current-state inventory — every member/block that DIES - -### 1a. Per-frame `RawMotionState` rebuild + `apply_raw_movement(RawMotionState)` call - -**`PlayerMovementController.cs:892-924`** (comment block + body, inside -`Update`). Every frame, regardless of whether input changed, builds a -fresh `RawMotionState` from `MovementInput` and pushes it through the -snapshot-consuming overload: - -```csharp -var axisHold = input.Run ? HoldKey.Run : HoldKey.None; -var raw = new RawMotionState { CurrentHoldKey = axisHold, ForwardCommand = ..., ... }; -_motion.apply_raw_movement(raw); -``` - -This is retail's `apply_raw_movement(RawMotionState raw)` overload -(`MotionInterpreter.cs:1346-1371`) — it copies 6 fields into -`InterpretedState` and normalizes each axis via `adjust_motion`, but does -**not** drive velocity or dispatch through a sink. It's a **level-triggered** -substitute for retail's **edge-triggered** `DoMotion`/`StopMotion`/ -`set_hold_run` calls that mutate `RawState` incrementally via -`RawMotionState.ApplyMotion`/`RemoveMotion`. DIES in W6 — replaced by §2's -edge table. - -### 1b. Section 2 grounded-velocity write (STAYS, not a W6 kill — flagged for clarity) - -**`PlayerMovementController.cs:962-991`**. Reads -`_motion.get_state_velocity()` and writes `_body.set_local_velocity(...)` -when grounded. The plan (§3 W6 "STAYS") keeps this — it's the R6-deferred -"grounded velocity from state" adaptation (register row). Listed here only -so the doc distinguishes it from 1a, which it's textually adjacent to. - -### 1c. Jump charge/fire block - -**`PlayerMovementController.cs:993-1043`**. Charge accumulates via -`_jumpCharging`/`_jumpExtent` (unchanged — AP-24, stays controller-side). -On release, calls: - -```csharp -var jumpResult = _motion.jump(_jumpExtent); -``` - -**Confirmed gap (not in the plan's text, found this session):** -`MotionInterpreter.ChargeJump()` (`MotionInterpreter.cs:1765-1789`, the -verbatim `charge_jump` port, the ONLY place `StandingLongJump` arms) is -**never called** by the controller today — only by -`MotionInterpreterJumpFamilyTests.cs`. The doc comment at -`MotionInterpreter.cs:1756-1762` says outright: *"no regression today -since nothing calls ChargeJump yet, so remotes/local both continue -unaffected."* W6 must add a `ChargeJump()` call at charge-START (the -retail caller is the SmartBox/input boundary at `0x0056afac`, out of -CMotionInterp's own scope but exactly where the controller's charge -block lives). Without this, `StandingLongJump` never arms for the local -player post-cutover either — same bug, just moved. See §6 risk R1. - -The manual `LeaveGround()` pre-capture this block used to need was -**already deleted in W4** (comment at `:1016-1021` confirms: "the manual -LeaveGround call is DELETED — jump() clears OnWalkable, and the SAME -frame's grounded→airborne edge... fires `_motion.LeaveGround()`"). This -part of the plan's W6 description is stale — it's W4-done, not a W6 task. - -### 1d. `LocalAnimationCommand` / `LocalAnimationSpeed` synthesis - -**`MovementResult` record fields**: `PlayerMovementController.cs:55` -(`LocalAnimationCommand`) and `:62` (`LocalAnimationSpeed`), part of the -record at `:42-65`. - -**Synthesis sites**: -- `:1308-1322` — `localAnimCmd` set from `input.Forward`/`input.Backward` - + `_weenie.InqRunRate` (Run cycle selection independent of wire command). -- `:1443-1481` — K-fix5 `localAnimSpeed` computation (run-rate pacing for - Backward+Run/Strafe+Run) + auto-walk override block - (`_autoWalkMovingForwardThisFrame`/`_autoWalkTurnDirectionThisFrame` - branches overwrite `localAnimCmd`/`localAnimSpeed`). -- `:1355-1377` — change-detection block includes `localAnimCmd != - _prevLocalAnimCmd` as a `changed` trigger (7th line of the OR-chain, - `:1371`) and tracks `_prevLocalAnimCmd` (`:191`, `:1377`). -- `:1503-1504` — final `MovementResult` construction passes both fields - through. - -DIES: the two fields, all three synthesis sites, and the -`_prevLocalAnimCmd` change-detection leg (the OTHER six `changed` -triggers survive unchanged — see §5). - -### 1e. `SetPosition`'s arrival-idle `DoMotion(Ready)` - -**`PlayerMovementController.cs:839`**, inside `SetPosition(Vector3, uint, -Vector3)`: - -```csharp -_motion.DoMotion(MotionCommand.Ready, 1.0f); -``` - -Per the plan: "the `:825` `DoMotion(Ready)` arrival-idle becomes -`StopCompletely()` — retail's teleport idle is a full stop." Confirmed -`StopCompletely()` (`MotionInterpreter.cs:1054-1084`) is the correct -retail-faithful substitute: it resets forward/sidestep/turn COMMANDS -(not speeds, per J9), zeros `PhysicsObj.set_velocity`, enqueues the A9 -jump-snapshot node, and fires `RemoveLinkAnimations` if uncelled — a -strictly more complete reset than the current one-field `DoMotion(Ready)` -call. Same substitution applies to the identical call inside -`DriveServerAutoWalk`'s turn-in-place branch (`:732`) — **the plan does -NOT call this one out**, but it is textually the same pattern -(`_motion.DoMotion(MotionCommand.Ready, 1.0f)`) serving the same "go -idle" role mid-auto-walk. Flagged as a judgment call in §6 (R4) — recommend -leaving `:732` as `DoMotion(Ready)` since auto-walk survives R3 verbatim -per the KEEP LIST and StopCompletely's jump-snapshot/queue side effects -are not obviously wanted mid-auto-walk-turn. - -### 1f. Shift/Run detection - -**Today**: `input.Run` is read directly into `axisHold` (1a) every frame -— no edge detection exists; the level-triggered rebuild makes edge -detection moot today. W6 replaces this with an explicit prev-Shift-state -edge feeding `set_hold_run(bool holdingRun, bool interrupt)` -(`MotionInterpreter.cs:2453-2463`), which itself has an internal XOR -no-op guard (so calling it every frame with the same value is safe, but -the controller should still gate on an edge per the plan's "tiny -prev-key-state edge detector"). - -### 1g. Outbound section 6 — re-derivation from `input` - -**`PlayerMovementController.cs:1288-1354`** (`outForwardCmd`/ -`outForwardSpeed`/`outSidestepCmd`/etc.) re-derives the wire commands -from `MovementInput` fields (`input.Forward`, `input.StrafeRight`, etc.) -completely independently of `_motion.RawState`, which by this point in -the frame (post 1a) already holds equivalent values. This is redundant -computation that DIES — the outbound-wire-build in GameWindow (§4/§5) -should read `_motion.RawState` (now the SAME instance the controller has -been mutating via `DoMotion`/`StopMotion`/`set_hold_run`) directly instead -of `MovementResult.ForwardCommand`/etc. **Note**: this doc treats "which -fields of `MovementResult` survive" as a §5 design decision, not a -foregone conclusion — see §5. - -### 1h. GameWindow: `UpdatePlayerAnimation` (DELETE) + its caller - -**Definition**: `GameWindow.cs:10139-10307` (169 lines). -**Single caller**: `GameWindow.cs:8006`, inside `OnUpdate`'s -`if (_playerController is not null)` block, immediately after the -JumpAction wire-send block (`:7984-8002`). - -Reads `result.IsOnGround` (airborne→Falling override, `:10164-10165`), -`result.LocalAnimationCommand` (`:10166`, preferred source), -`result.ForwardCommand`/`SidestepCommand`/`TurnCommand` (`:10168-10172`, -fallback chain when `LocalAnimationCommand` is null), -`result.LocalAnimationSpeed` (`:10186`, `:10255-10257`, feeds `SetCycle`). -Calls `ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * -animScale)` directly (`:10297`) — this is a **parallel path** to -`MotionTableDispatchSink.ApplyMotion` that remotes use; it does NOT go -through `_playerController.Motion` at all. DIES in full — the player's -`AnimatedEntity.Sequencer` gets driven through the SAME -`MotionTableDispatchSink` → `DefaultSink` funnel remotes use (§3). - -### 1i. `input.` reads for MOTION (not camera) — full inventory - -All in `PlayerMovementController.Update`, all replaced by edge calls -except where noted: -- `input.Forward`/`input.Backward` (`:907-909`, `:1309-1322`) → W press/S - press/release edges. -- `input.StrafeLeft`/`input.StrafeRight` (`:912-914`, `:1328-1337`) → - strafe press/release edges. -- `input.TurnLeft`/`input.TurnRight` (`:917-919`, `:1344-1353`) → turn - press/release edges. **Note**: `input.TurnRight`/`TurnLeft` ALSO drive - Yaw integration directly at `:946-951` — that consumption STAYS (reads - `_motion.InterpretedState.TurnCommand`, unchanged per the plan). -- `input.Run` (`:903`, `:910`, `:915`, `:920`, `:1313`, `:1365`, - `:1445-1446`, `:1502`) → collapses to the Shift edge → `set_hold_run`. -- `input.Jump` (`:1000`) → STAYS as the charge/fire trigger (unchanged - shape, gains the `ChargeJump()` call per 1c). -- `input.MouseDeltaX` (`:952`) → STAYS (camera/Yaw only, never a motion - command — deliberately, per the existing comment on why mouse-turn - generates no wire command). - ---- - -## §2 The edge-detector design - -### 2a. Minimal prev-state - -The controller needs exactly 6 booleans (one per discrete axis-direction) -plus the existing Shift/Run bool, replacing the current -`_prevForwardCmd`/`_prevSidestepCmd`/`_prevTurnCmd`/`_prevForwardSpeed`/ -`_prevRunHold`/`_prevLocalAnimCmd` fields (`:186-191`) which tracked -OUTPUT state for change-detection. The new fields track INPUT state for -edge-detection: - -```csharp -private bool _prevForwardHeld; // input.Forward -private bool _prevBackwardHeld; // input.Backward -private bool _prevStrafeLeftHeld; -private bool _prevStrafeRightHeld; -private bool _prevTurnLeftHeld; -private bool _prevTurnRightHeld; -private bool _prevRunHeld; // input.Run (Shift) -``` - -Six of the seven map 1:1 onto retail's per-axis `RawMotionState` channels -(forward, sidestep, turn — each a single "current command" slot, so -Forward-press-while-Backward-held needs the SAME "switch channel" handling -`ApplyMotion` already does verbatim — no new logic needed, just route -both to the forward channel). - -### 2b. Edge → call table - -| Edge | Call | MovementParameters | -|---|---|---| -| Forward pressed (was false, now true) | `DoMotion(MotionCommand.WalkForward, p)` | ctor defaults (`new MovementParameters()`) — `Speed=1f` is the retail raw speed (ACE recomputes broadcast speed; D6.2b finding already established this). | -| Forward released (was true, now false), AND Backward not held | `StopMotion(MotionCommand.WalkForward, p)` | ctor defaults. | -| Backward pressed | `DoMotion(MotionCommand.WalkBackward, p)` | ctor defaults (`adjust_motion` applies `BackwardsFactor` — unchanged, already verbatim). | -| Backward released, Forward not held | `StopMotion(MotionCommand.WalkBackward, p)` | ctor defaults. | -| StrafeRight pressed | `DoMotion(MotionCommand.SideStepRight, p)` | ctor defaults. | -| StrafeLeft pressed | `DoMotion(MotionCommand.SideStepLeft, p)` | ctor defaults. | -| Strafe released (both false) | `StopMotion(MotionCommand.SideStepRight, p)` (retail always stops via the RIGHT id per `adjust_motion`'s SideStepLeft→SideStepRight canonicalization — confirmed in `RawMotionState.ApplyMotion`'s switch, which treats `0x6500000f`/`0x65000010` as the same channel) | ctor defaults. | -| TurnRight pressed | `DoMotion(MotionCommand.TurnRight, p)` | ctor defaults. | -| TurnLeft pressed | `DoMotion(MotionCommand.TurnLeft, p)` | ctor defaults. | -| Turn released (both false) | `StopMotion(MotionCommand.TurnRight, p)` | ctor defaults. | -| Shift edge (any transition) | `set_hold_run(input.Run, interrupt: true)` | N/A — direct call, no `MovementParameters`. `interrupt:true` matches the decomp's SECOND caller (`006b33ca`, `arg3=1`, explicitly the "player input/command layer" call site per `r3-motioninterp-decomp.md:1001`) — the FIRST caller (`0058b303`) omits arg3 in the decomp text (ambiguous, likely also non-zero given calling convention defaults per the decomp's own hedge) and is not the player-input site anyway. | -| Jump release (existing charge-fire block) | `_motion.jump(_jumpExtent)` — UNCHANGED call, only gains the `ChargeJump()` call at charge-START (see §1c/§6 R1) | N/A — `jump(float, int)` takes no `MovementParameters`. | -| Auto-walk `DoMotion(Ready)`/`DoMotion(WalkForward)` (`:732`, `:767`) | UNCHANGED — plan explicitly keeps this. | 2-arg compat overload, unchanged. | - -**TODO flag (per the task's own instruction)**: the decomp does not show -a `DoMotion`/`StopMotion` call site at the CommandInterpreter boundary — -only `set_hold_run`'s two callers are visible in -`r3-motioninterp-decomp.md` §5c/§8 (`0058b303`/`006b33ca`). The -DoMotion/StopMotion edge calls above are this doc's inference from the -verbatim `DoMotion`/`StopMotion` bodies' own defaulting behavior (both -build a **fresh** `MovementParameters` internally for the -`DoInterpretedMotion`/`StopInterpretedMotion` tail-call regardless of what -the caller passes — see `MotionInterpreter.cs:915`, `:992` — so the -caller-supplied `p`'s only LIVE effects are `CancelMoveTo` (interrupt), -`SetHoldKey`+`HoldKeyToApply` (hold-key push), `Speed`, and -`ModifyRawState`/`ModifyInterpretedState` gates). Ctor defaults -(`CancelMoveTo=true, SetHoldKey=true, ModifyRawState=true, -ModifyInterpretedState=true, Speed=1f, HoldKeyToApply=Invalid`) are the -right choice UNLESS retail's real CommandInterpreter caller passes -`HoldKeyToApply` per-axis (plausible for Run) — **TODO(W6 impl): verify -against a fresh cdb capture of the CommandInterpreter boundary before -implementing**, per the task's own note. This doc marks it a TODO rather -than guessing further, consistent with CLAUDE.md's "do not guess" rule. - -### 2c. Why `DoMotion`'s own re-defaulting makes this safe - -`DoMotion(motion, p)` (`MotionInterpreter.cs:908-949`) builds `var local = -new MovementParameters()` (fresh re-default) and only carries -`local.Speed = speed` and `local.HoldKeyToApply = p.HoldKeyToApply` -through to `DoInterpretedMotion`. The CALLER's `p` only matters for: -`p.CancelMoveTo` (interrupt-before-dispatch), `p.SetHoldKey` (whether to -call `SetHoldKey` first), `p.ModifyRawState` (whether the raw-state mirror -runs at the end). This means the controller passing ctor-default -`MovementParameters` per edge is not a simplification that loses -information — it is EXACTLY what retail's own `DoMotion` does internally -regardless of caller intent, for every field except the four above. Low -risk. - ---- - -## §3 The player sequencer drive — tracing DefaultSink to the sequencer - -### 3a. Call path confirmed present, ONE binding missing - -Full trace, verified directly in `MotionInterpreter.cs`: - -1. Controller calls `DoMotion(motion, p)` (edge) → -2. `DoMotion` → `DoInterpretedMotion(motion, local)` (`:943`) → -3. `DoInterpretedMotion(uint, MovementParameters)` (`:2837-2838`) → - `DoInterpretedMotion(motion, p, DefaultSink)` (the sink-parameterized - core) → -4. Inside (`:2850-2947`): `sink?.ApplyMotion(motion, p.Speed)` (`:2884`) - — **this is the dispatch to the animation backend**. - -Separately, for the CONTINUOUS axes (forward/sidestep/turn re-applied -every physics event, e.g. on `LeaveGround`/`HitGround`/`set_hold_run`): - -1. `set_hold_run`/`SetHoldKey`/`LeaveGround`/`HitGround` all call - `apply_current_movement(cancelMoveTo, allowJump)` (`:1404-1417`) → -2. Dual-dispatch gate (A3): `isThePlayer = WeenieObj is null || - WeenieObj.IsThePlayer()`. Local player: `WeenieObj` is - `PlayerWeenie` (constructed at `PlayerMovementController.cs:354`, - presumably `IsThePlayer() == true` — **needs one-line confirmation in - IWeenieObject/PlayerWeenie, not re-derived here**) AND - `PhysicsObj.LastMoveWasAutonomous` is set `true` at construction - (`PlayerMovementController.cs:360`, with the comment "R3-W6 refines - this per-motion") → routes to `apply_raw_movement(cancelMoveTo, - allowJump)` (the bool-arg overload, `:1443-1450`) → -3. That overload calls `apply_raw_movement(RawState)` (mutates - `InterpretedState` from the interpreter's OWN `RawState` — no longer - an externally-built snapshot, this is the key W6 shift) then - `ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump)` (`:1449`) → -4. `ApplyCurrentMovementInterpreted` (`:1491-1536`): `if (DefaultSink is - not null) { ApplyInterpretedMovement(InterpretedState.CurrentStyle, - DefaultSink, cancelMoveTo, allowJump); return; }` (`:1502-1506`) — - **this IS the full funnel dispatch** (style → forward-or-Falling → - sidestep(-stop) → turn(-stop)), the SAME `ApplyInterpretedMovement` - the inbound remote funnel uses and the SAME dispatch-order the S2a - 183-case suite pins. - -**CONFIRMED: the call path from an edge-driven `DoMotion`/`set_hold_run` -all the way to `AnimationSequencer.SetCycle`/`PerformMovement` already -exists in Core, fully wired, contingent on ONE thing: `DefaultSink` must -be non-null for the player's `MotionInterpreter`.** - -### 3b. The missing binding — confirmed via direct read + subagent cross-check - -`MotionInterpreter.cs:1479-1489` doc comment states explicitly: *"Null -(the local player until R3-W6, interp-less entities) → the AP-77 -physics-only tail."* Confirmed live: at -`GameWindow.cs:12987-13000` (`EnterPlayerModeNow`), the player's -`_playerController.Motion` gets THREE of the four seams bound -(`RemoveLinkAnimations`, `InitializeMotionTables`, -`CheckForCompletedMotions`) but explicitly NOT `DefaultSink` — inline -comment: *"DefaultSink stays null until R3-W6 (UpdatePlayerAnimation -still drives the player's cycles)."* - -Compare against the remote pattern, `EnsureRemoteMotionBindings` -(`GameWindow.cs:4195-4224`), called lazily from `OnLiveMotionUpdated` -(`:4679`) and `OnLiveVectorUpdated`(`:4859`): - -```csharp -rm.Sink = new MotionTableDispatchSink(ae.Sequencer) -{ - TurnApplied = (turnMotion, turnSpeed) => { /* ObservedOmega seed */ }, - TurnStopped = () => rmForSink.ObservedOmega = Vector3.Zero, -}; -rm.Motion.DefaultSink = rm.Sink; // <-- MISSING for player -rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations; -rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState(); -rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions; -``` - -**W6 action**: at `GameWindow.cs:12987-13000` (`EnterPlayerModeNow`), -construct a `MotionTableDispatchSink(playerSeq)` and bind -`_playerController.Motion.DefaultSink = ` alongside the three -existing binds. The `TurnApplied`/`TurnStopped` callbacks exist for the -`ObservedOmega` remote dead-reckoning seed — the LOCAL player has no -`ObservedOmega` concept (it's a remote-only field on `RemoteMotion`), so -these two callbacks can be no-ops for the player's sink, OR omitted -entirely if `MotionTableDispatchSink`'s constructor doesn't require them -(confirm the class's actual required-vs-optional members before -implementing — not independently re-verified in this pass beyond the -subagent's citation of `MotionTableDispatchSink.cs:34-72`). - -**Where to store the sink instance**: unlike `RemoteMotion` (a wrapper -struct with `.Sink`/`.Motion`/`.ObservedOmega` fields), the player has no -equivalent wrapper — `_playerController` plays that role directly. The -sink instance needs a home: either (a) a new field on -`PlayerMovementController` (parallels `Motion` being exposed there -already), or (b) a `GameWindow`-local field alongside the existing -`_playerController` field. Recommend (a) for symmetry with `Motion`'s -existing exposure pattern (`PlayerMovementController.cs:375`) — a new -`internal MotionTableDispatchSink? Sink { get; set; }` property, set from -`EnterPlayerModeNow`, mirrors `RemoteMotion.Sink` exactly. - -### 3c. What this replaces - -Once `DefaultSink` is bound, `UpdatePlayerAnimation`'s direct -`ae.Sequencer.SetCycle(...)` call becomes fully redundant — the funnel -dispatch (`ApplyInterpretedMovement` → `DefaultSink.ApplyMotion` → -`MotionTableDispatchSink.ApplyMotion` → `sequencer.PerformMovement`) now -fires on every edge-driven `DoMotion`/`StopMotion`/`set_hold_run`/ -`LeaveGround`/`HitGround` call, exactly mirroring how remotes already -work. The airborne→Falling swap "falls out of `contact_allows_move` + -`apply_current_movement`" per the plan — confirmed: `ApplyInterpretedMovement` -(`:2731-2733`) already has `if (!contact_allows_move(...)) -DispatchInterpretedMotion(MotionCommand.Falling, 1.0f, sink);` as its -SECOND dispatch (unconditional check every call), so once the player's -`apply_current_movement` reaches this funnel on the LeaveGround edge, the -Falling swap happens automatically with no App-side airborne branch -needed at all — `UpdatePlayerAnimation`'s `:10164-10165` airborne -override becomes dead code, confirming full deletion is correct, not just -plumbing-safe. - ---- - -## §4 GameWindow edit list - -| # | Current anchor | What replaces it | -|---|---|---| -| 1 | `UpdatePlayerAnimation` definition, `:10139-10307` | DELETE the method entirely. | -| 2 | `UpdatePlayerAnimation(result)` call, `:8006` | DELETE the call (the tail of the `if (_playerController is not null)` block in `OnUpdate`). | -| 3 | `EnterPlayerModeNow`, binding block `:12987-13000` | ADD the `DefaultSink` bind (§3b) as a 4th line alongside the existing 3 seam binds. | -| 4 | Outbound wire-build block, `:7893-7913` (fresh `RawMotionState` built from `MovementResult` fields) | Read `_playerController.Motion.RawState` directly instead of re-deriving. See §5 for exactly which fields still need `MovementResult` vs which now come from `RawState`. | -| 5 | `ApplyServerRunRate` call site, `OnLiveMotionUpdated` `:4373` (per subagent citation) | UNCHANGED — `ApplyServerRunRate` (`PlayerMovementController.cs:410-414`) already calls `_motion.apply_current_movement(false, false)`, which after 3b's bind now ALSO drives the sequencer via `DefaultSink` (a currently-inert call becomes live) — no GameWindow-side edit needed, but flag as a **behavior change**, not just plumbing (see §6 R2). | -| 6 | JumpAction wire-build block, `:7984-8002` | UNCHANGED — still reads `result.JumpExtent`/`result.JumpVelocity` from `MovementResult` (§5 keeps these fields; the jump path is not touched by the DoMotion/StopMotion edge redesign). | -| 7 | `OnLiveVectorUpdated`'s remote-jump `LeaveGround()`+`DefaultSink` pattern, `:4845-4864` | UNCHANGED — this is the ALREADY-MIGRATED reference pattern (W4/J19 closed) that item 3 mirrors for the player. Cited here only as the proof-of-pattern, not an edit site. | - -Two GameWindow items the plan names that turned out to be **already -resolved, not pending W6 work** (found this session, not anticipated by -the plan text): - -- **`skipTransitionLink`**: confirmed via repo-wide grep — zero - occurrences in any production `.cs` file. Fully deleted in W4. The - plan's W6 bullet listing this as something to delete is stale; no - action needed. -- **`ACDREAM_ANIM_SPEED_SCALE`/#45 sidestep factor**: both live entirely - inside the doomed `UpdatePlayerAnimation` (`:10258-10295`) — they die - WITH the method, they don't need a separate "move into the - CMotionTable-driven speedMod path" step, because there is no equivalent - concept upstream in `MotionTableDispatchSink`/`AnimationSequencer. - PerformMovement` today. See §6 R3 — this is a real behavior-loss risk, - not a mechanical relocation, and needs a decision before deletion. - ---- - -## §5 Wire-parity plan - -### 5a. Current outbound flow (pre-W6) - -`GameWindow.cs:7893-7913` builds a FRESH `RawMotionState` from -`MovementResult.ForwardCommand`/`SidestepCommand`/`TurnCommand`/ -`ForwardSpeed`/`SidestepSpeed`/`TurnSpeed`/`IsRunning`, then passes it to -`MoveToState.Build(...)` (`:7916`), which internally calls -`RawMotionStatePacker.Pack(w, rawMotionState)` -(`MoveToState.cs:80`). - -### 5b. Post-W6 target flow - -`_playerController.Motion.RawState` is, by construction, the SAME -`RawMotionState` instance the edge-driven `DoMotion`/`StopMotion` calls -have been mutating via `ApplyMotion`/`RemoveMotion` all frame (per J2's -W1 fold: `RawState` IS the packer-compatible type, action FIFO included). -GameWindow's outbound block should read directly from -`_playerController.Motion.RawState` instead of reconstructing one. - -**Field-by-field disposition**: - -| `RawMotionState` field | Source post-W6 | Notes | -|---|---|---| -| `CurrentHoldKey` | `RawState.CurrentHoldKey`, set by `set_hold_run` edge | Was `result.IsRunning ? Run : None` — now the interpreter's own tracked value. | -| `ForwardCommand`/`ForwardHoldKey`/`ForwardSpeed` | `RawState.*`, set by `ApplyMotion`/`RemoveMotion` on the Forward/Backward edges | Was `result.ForwardCommand ?? Default`. | -| `SidestepCommand`/`SidestepHoldKey`/`SidestepSpeed` | `RawState.*` | Was `result.SidestepCommand ?? Default`. | -| `TurnCommand`/`TurnHoldKey`/`TurnSpeed` | `RawState.*` | Was `result.TurnCommand ?? Default`. | -| `Actions` | `RawState.Actions` | Was ALWAYS EMPTY pre-W1 (TS-24); post-W1 populated by `ApplyMotion`'s action-class branch — this is a genuine NEW behavior surfacing on the wire once W6 reads live `RawState` instead of a `MovementResult`-rebuilt empty-actions snapshot. Flag in §6 R5. | - -**`MovementResult` fields that SURVIVE** (still needed by GameWindow for -non-wire purposes): `Position`, `RenderPosition`, `CellId`, `IsOnGround` -(camera/HUD), `JustLanded` (landing SFX/anim triggers if any exist -outside `UpdatePlayerAnimation` — verify no other consumer before -deleting used-only-by-UpdatePlayerAnimation fields), `JumpExtent`/ -`JumpVelocity` (JumpAction wire build, item 6 in §4, untouched by this -redesign since jump keeps its existing dedicated path). - -**`MovementResult` fields that DIE**: `LocalAnimationCommand`, -`LocalAnimationSpeed` (§1d). **Candidates for deletion pending §5c -decision**: `ForwardCommand`/`SidestepCommand`/`TurnCommand`/ -`ForwardSpeed`/`SidestepSpeed`/`TurnSpeed`/`IsRunning`/ -`MotionStateChanged` — these become redundant with `RawState` reads, but -deleting them is a larger surface change to `MovementResult`'s consumers -(the golden-byte tests test `RawMotionStatePacker`/`MoveToState` -directly, NOT `MovementResult`, so they're unaffected either way — see -5d). Recommend KEEPING these fields in `MovementResult` for this W6 slice -(mark `[Obsolete]` or comment "now diagnostic-only, GameWindow reads -RawState") rather than deleting, to keep the W6 diff scoped to the -motion-drive mechanism, not a `MovementResult` API redesign — a follow-up -cleanup commit can trim the struct once nothing reads it. - -### 5c. `MotionStateChanged` gate — needs a replacement signal - -`GameWindow.cs:7874` gates the whole outbound MoveToState build on -`result.MotionStateChanged && !_playerController.IsServerAutoWalking`. -Post-W6, `MovementResult.MotionStateChanged` (computed from the DYING -`_prevForwardCmd`/etc. output-comparison block, §1d) needs a replacement. -**Recommend**: derive change-detection from the edge table itself — ANY -edge fired this frame (forward/backward/strafe/turn/run edges, §2a) is -definitionally a state change; expose a `bool MotionChangedThisFrame` -computed from `_prevXHeld != inputXHeld` ORed across all axes, computed -in the SAME `Update` call, replacing the output-comparison approach with -an input-edge approach. This is a strictly simpler equivalent (retail's -CommandInterpreter only calls `DoMotion`/`StopMotion` ON edges in the -first place, so "an edge fired" and "state changed" are the same -predicate by construction — no separate comparison needed). Confirm this -doesn't regress the `runHold`-without-directional-edge case (Shift toggle -while already moving) — the edge table's Shift-edge row already covers -this (`set_hold_run` fires independently of the directional edges), so -OR-ing in the Shift edge too closes that case. - -### 5d. Golden-byte tests — confirmed unaffected - -`RawMotionStatePackTests.cs`, `MoveToStateGoldenTests.cs`, -`MoveToStateTests.cs`, `AutonomousPositionTests.cs`, `JumpActionTests.cs`, -`PositionPackTests.cs` (all under -`tests/AcDream.Core.Net.Tests/Messages/`) construct `RawMotionState`/call -`MoveToState.Build` with EXPLICIT field values directly — none reference -`PlayerMovementController` or `MovementResult`. They pin -`RawMotionStatePacker`'s bit-packing contract, which W6 does not touch -(only WHERE the packed values are sourced from changes). These stay green -as a structural guarantee, not something W6 needs to specifically verify -beyond "still compiles, still passes" — but a NEW test is needed for the -end-to-end path: build a `RawMotionState` via live `DoMotion`/`StopMotion` -edges, feed it to `RawMotionStatePacker.Pack`, and diff against the SAME -byte sequence the pre-cutover `MovementResult`-rebuilt path produced for -an identical input script (see §7). - ---- - -## §6 Risk list - -**R1 — `ChargeJump()` never called (BLOCKER-adjacent discovery, not in -the plan text).** Confirmed via grep: `MotionInterpreter.ChargeJump()` -has zero production call sites — only `MotionInterpreterJumpFamilyTests.cs` -calls it. `StandingLongJump` therefore never arms for ANY entity today -(local or remote), despite W3 having fully ported the mechanism. W6's -jump-charge block (§1c) MUST add a `ChargeJump()` call when charge begins -(mirroring `input.Jump && !_jumpCharging` → also call `_motion.ChargeJump()`, -checking its `WeenieError` return the same way the fire-path already -checks `jump()`'s). Without this, the "standing long-jump" feature stays -silently dead post-cutover — same bug, just no longer excusable as "W3 -hasn't wired the call site yet." **Recommendation: treat as in-scope for -W6, not a separate follow-up** — it's the SAME jump-charge block W6 is -already editing to add `ChargeJump`'s sibling gate calls. - -**R2 — `ApplyServerRunRate`'s `apply_current_movement` call goes from -inert to live.** Pre-W6, `ApplyServerRunRate` (`PlayerMovementController.cs: -410-414`) calls `_motion.apply_current_movement(false, false)`, but -`DefaultSink` is null so `ApplyCurrentMovementInterpreted` falls through -to the AP-77 physics-only tail (a `get_state_velocity` → `set_local_velocity` -write, no sequencer touch). Post-W6 (once `DefaultSink` is bound, §3b), -the SAME call now ALSO dispatches through the funnel -(`ApplyInterpretedMovement` → `SetCycle`) on every server RunRate echo — -this could fire a redundant/premature `SetCycle` call on every -`UpdateMotion (0xF61C)` echo for the player's own guid, independent of -any local edge. **Recommendation**: verify this doesn't cause visible -cycle-restart flicker (retail's real `apply_current_movement` call here IS -supposed to re-dispatch — this may be CORRECT retail behavior surfacing -for the first time — but it's a genuine visual-risk item for the "ONE -user visual pass" acceptance test, not a mechanical no-op). - -**R3 — `#45` sidestep factor + `ACDREAM_ANIM_SPEED_SCALE` have no -upstream equivalent.** Per §4, both die with `UpdatePlayerAnimation` -because `MotionTableDispatchSink.ApplyMotion`/`AnimationSequencer. -PerformMovement` (the remote path, now shared) has no known -sidestep-specific speed-scale multiplier or global visual-pacing knob. -Two sub-risks: (a) sidestep animation pacing may visibly change (the -1.248× factor matched ACE's `BroadcastMovement` wire formula — losing it -means the sidestep CYCLE plays at `SidestepAnimSpeed`-derived pace instead -of the wire-matched pace); (b) `ACDREAM_ANIM_SPEED_SCALE` (a debug/tuning -knob) simply stops having any effect for the local player post-cutover -(remotes never had it either, so this makes local and remote consistent — -arguably correct, but a functionality LOSS for whoever uses that env var). -**Recommendation**: confirm with the user whether (a) is an -expected-diff (remotes today do NOT get the 1.248× factor either — so -post-cutover the LOCAL player's sidestep pacing becomes consistent with -how remotes have always looked, which may in fact be the retail-faithful -answer the #45 comment's "matching ACE's wire formula" was compensating -for a DIFFERENT bug in) before deleting; document as an expected-diff in -the W6 commit either way, per the plan's own instruction ("expected-diff -annotations required"). - -**R4 — `DriveServerAutoWalk`'s `DoMotion(Ready)` at `:732` is not named -in the plan's W6 SetPosition-only StopCompletely note.** §1e flags this -textual sibling. Auto-walk survives R3 verbatim per the KEEP LIST, so -changing `:732` is OUT of scope — but a future reader diffing "every -`DoMotion(Ready)` call site" against the plan's single cited line (`:825` -old numbering) could reasonably assume both change. **Recommendation**: -leave `:732` untouched, but add a one-line comment there cross-referencing -this doc so the distinction is explicit at the code site, not just here. - -**R5 — `RawState.Actions` starts reaching the wire for the first time.** -Per §5b, reading live `RawState` instead of a `MovementResult`-rebuilt -snapshot means the `Actions` FIFO (populated since W1/W2 by -`ApplyMotion`'s action-class branch, e.g. `motion & 0x10000000` bit — -action-class motions like emotes/attacks IF the local player ever calls -`DoMotion` with an action-class id) starts appearing in the packed -`MoveToState` bytes where it was previously always empty (TS-24). If -nothing in the current player-input path ever issues an action-class -`DoMotion` call, this is a no-op in practice for W6's scope (movement axes -only) — but flag it so a future action/emote wiring phase doesn't get -blindsided by "wait, did this already work?" - -**R6 — `IWeenieObject.IsThePlayer()` for `PlayerWeenie` not -independently re-verified this session.** §3a's dispatch-path trace -ASSUMES `PlayerWeenie.IsThePlayer() == true` (required for -`apply_current_movement`'s A3 gate to route to `apply_raw_movement`, -which is the path that reaches `DefaultSink`). This is almost certainly -already correct (W3 added `IsThePlayer()` to `IWeenieObject` specifically -for this dispatch, per J5's gap list), but a W6 implementer should grep -`PlayerWeenie.cs` for the override before writing code, not assume from -this doc alone. - ---- - -## §7 Test plan - -### 7a. New unit tests — edge table - -Add to `tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs` -(or a new `PlayerMovementControllerEdgeTests.cs` alongside it, matching -the existing per-concern file split seen in -`DispatcherToMovementIntegrationTests.cs`): - -- Press-W → `_motion.RawState.ForwardCommand == WalkForward` after one - `Update` call (was: never directly observable pre-W6 since the raw - state was an ephemeral per-frame local; post-W6 it's the persistent - field — this test is only possible/meaningful after the cutover). -- Press-W, hold 3 frames, release → `RawState.ForwardCommand == - MotionCommand.Ready` after release frame (StopMotion fired). -- Press-W then press-S same frame → forward channel ends on S (or - whichever wins per `ApplyMotion`'s single-channel-per-axis semantics — - pin the actual retail-faithful tie-break, likely "last processed wins" - given the controller processes Forward before Backward in its own - `MovementInput` field order — assert whichever the implementation - produces, don't guess a preference here). -- Shift press while W held → `RawState.CurrentHoldKey == Run` + - `set_hold_run`'s internal `apply_current_movement` fired (observable - via a velocity change or a `DefaultSink` mock's call count once 3b - lands). - Shift press while NOT moving → `CurrentHoldKey == Run` still updates - (retail's `set_hold_run` doesn't gate on movement) but no velocity - visible change. -- Redundant Shift-held-while-already-Run (two consecutive `Update` calls - with `input.Run=true`) → `set_hold_run`'s XOR guard no-ops on the - second call (assert via a call-counting mock on - `apply_current_movement`'s side effect, e.g. `DefaultSink.ApplyMotion` - invocation count stays flat). -- Jump charge-then-release still produces `WeenieError.None` from - `jump()` AND (new) `ChargeJump()` was called at charge-start with a - `StandingLongJump` arm when idle+grounded (R1's fix, needs its own - assertion — grounded+idle+charge+release → `StandingLongJump == true` - observable via `_motion.StandingLongJump` before the release clears it, - or via the funnel's state-only dispatch branch firing). -- SetPosition (teleport) → `RawState.ForwardCommand == Ready` AND - `PendingMotions` gained the A9 snapshot node (StopCompletely's - `AddToQueue` call) — this is a NEW observable side effect vs the old - `DoMotion(Ready)` call, worth a dedicated assertion. - -### 7b. Existing tests needing re-pin - -Per the tests-agent's findings: - -- **`PlayerMovementControllerTests.cs`** (15 facts, 29 `Update` calls) — - the two jump tests (`Update_JumpOnFlatTerrain_BecomesAirborne`, - `Update_AirborneFrames_ZRiseThenFalls`) are HIGH risk for needing - re-pin: they exercise the exact charge→release block §1c/R1 touches. - Re-run after the `ChargeJump()` addition — if `StandingLongJump` arms - during a stationary charge-then-release-while-still-idle scenario these - tests use, a NEW dispatch path (`DispatchInterpretedMotion(Ready)` + - `DispatchStopInterpretedMotion(SideStepRight)` per - `ApplyInterpretedMovement`'s StandingLongJump branch) fires that didn't - before — verify it doesn't change `IsAirborne`/`VerticalVelocity`/ - `Position.Z` assertions (it shouldn't, since that branch is state-only - no-dispatch-to-velocity per J6, but MUST be checked, not assumed). -- **`DispatcherToMovementIntegrationTests.cs`** (6 facts) — asserts - dispatcher-built `MovementInput` produces IDENTICAL results to - hand-built input. Should stay green unchanged (it exercises the - `MovementInput`→`Update` boundary, not the internals W6 rewrites) — - but re-run explicitly since it's the integration seam most likely to - surface an edge-detection regression (e.g. if a test builds two - DIFFERENT `MovementInput` values across two `Update` calls without the - edge-detector's `_prevXHeld` fields being exercised the same way a - real per-frame loop would). -- **`CellarUpTrajectoryReplayTests.cs`**'s - `IndoorCell_FullController_AtRestNoInput_RenderPositionBitStable` — a - 600-frame bit-stability regression guard with NO input held. Should be - UNAFFECTED (no edges ever fire), but it's cited in memory - (`feedback_render_perf_measurement.md`-adjacent class of test) as a - historically fragile guard — run it explicitly, don't assume "no input - means no risk" given this test exists specifically because a prior - change broke exactly this "nothing should happen when nothing is - pressed" invariant. - -### 7c. Pre-cutover trace capture (plan-mandated, not yet done) - -Per the plan's Fixture source line: "pre-cutover recorded traces + -golden-byte + suite." This doc does not capture the trace (out of -research-only scope) but flags that the W6 IMPLEMENTATION session must -run `ACDREAM_DUMP_MOTION=1` (or the equivalent `SetCycle`/dispatch trace -capture) against the CURRENT (pre-W6) code first, save the log, THEN -implement the cutover, THEN replay the same input script and diff — this -is a sequencing dependency on the implementation session, not something -this research pass can pre-stage without running the live client. - -### 7d. Suites that must stay green throughout, unmodified - -`RetailObserverTraceConformanceTests.cs` (184-case funnel dispatch-order -suite) + `MotionInterpreterFunnelTests.cs` (13 facts, synthetic -action-class supplement) — both exercise the INBOUND remote funnel path -(`DispatchInterpretedMotion`/`MoveToInterpretedState`), a sibling path to -what W6 touches. Should require zero changes; green throughout is the -signal that W6's edits to the SHARED `MotionInterpreter` machinery -(`DoMotion`/`PerformMovement`/`apply_current_movement`) didn't perturb -the remote dispatch order. All six golden-byte packer test files (§5d) -likewise stay green unmodified. - ---- - -## Anchor summary (file:line, this session, HEAD `e214acdf`) - -- `src/AcDream.App/Input/PlayerMovementController.cs` — full file read, - 1547 lines. -- `src/AcDream.App/Rendering/GameWindow.cs` — `UpdatePlayerAnimation` - `:10139-10307`; caller `:8006`; `ApplyServerRunRate` call `:4373`; - JumpAction block `:7984-8002`; outbound RawMotionState build - `:7893-7913`; `EnterPlayerModeNow` binding block `:12987-13000`; - `EnsureRemoteMotionBindings` `:4195-4224`; `OnLiveVectorUpdated` - LeaveGround pattern `:4845-4864`. -- `src/AcDream.Core/Physics/MotionInterpreter.cs` — full file read, 3083 - lines; key anchors: `DoMotion` `:856-949`; `StopMotion` `:958-1008`; - `StopCompletely` `:1054-1084`; `apply_raw_movement` (2 overloads) - `:1346-1371`, `:1443-1450`; `apply_current_movement` `:1404-1417`; - `DefaultSink` property `:1489`; `ApplyCurrentMovementInterpreted` - `:1491-1536`; `ChargeJump` `:1765-1789`; `jump` `:1822-1839`; - `LeaveGround` `:2310-2330`; `HitGround` `:2361-2375`; - `EnterDefaultState` `:2415-2427`; `set_hold_run` `:2453-2463`; - `SetHoldKey` `:2506-2525`; `MotionDone` `:2132-2154`; - `ApplyInterpretedMovement` `:2715-2762`; `DoInterpretedMotion` - `:2837-2948`. -- `src/AcDream.Core/Physics/RawMotionState.cs` — full file, `ApplyMotion` - `:208-273`, `RemoveMotion` `:293-321`. -- `src/AcDream.Core/Physics/Motion/MovementParameters.cs` — full file, - fields `:84-186`. diff --git a/docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md b/docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md deleted file mode 100644 index ee8648c6..00000000 --- a/docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md +++ /dev/null @@ -1,165 +0,0 @@ -# L.2g S2 — inbound CMotionInterp funnel: verbatim pseudocode - -Date: 2026-07-02. Oracle: named decomp (line refs into -`docs/research/named-retail/acclient_2013_pseudo_c.txt`) + the LIVE cdb trace -of a retail observer (`l2g-observer-trace.log`, breakpoint script -`tools/cdb/l2g-observer.cdb`) which confirmed the exact runtime chain and -per-UM `DoInterpretedMotion` order: **style → forward → sidestep(-stop) → -turn(-stop)**, applied wholesale the tick the message arrives. - -Empty-UM semantics (settled): `InterpretedMotionState::UnPack` (0x0051f400, -294360) decodes absent fields to ctor defaults — style `0x8000003D`, forward -`0x41000003 Ready`, speeds `1.0`, sidestep/turn `0` — so a flags=0 UM is a -retail-verbatim FULL STOP. No special-casing. (The wire varies with the -DRIVER's client state; both explicit-walk and empty variants are handled by -the same wholesale apply. `RawMotionState::Pack` 0x0051ed10 confirmed pure -static-default-diff — outbound L.2b port stays as-is.) - -## 1. MovementManager::unpack_movement — case 0 (0x00524440, 300563) - -``` -unpack_movement(blob): - if minterp == null or physics_obj == null: return 0 - physics_obj.interrupt_current_movement() - physics_obj.unstick_from_object() - - u16 header = read_u16() # low byte: movement type; high byte: motionFlags - u16 styleIdx = read_u16() # outer style, command_ids[] index - style = command_ids[styleIdx] - if minterp.get_current_style() != style: # "GetPinVersion" in BN - minterp.DoMotion(style, default_params) # style applied ONLY on change - - switch header.low_byte: - case 0: # InterpretedMotionState - ims = InterpretedMotionState() # ctor defaults (see above) - ims.UnPack(blob) # absent fields keep defaults - stickyGuid = (header & 0x100) ? read_u32() : 0 - MovementManager.move_to_interpreted_state(ims) - if stickyGuid: physics_obj.stick_to_object(stickyGuid) - minterp.standing_longjump = (header & 0x200) - return 1 - case 6/7: MoveTo… (existing acdream path, keep) - case 8/9: TurnTo… (S6) - default: return 0 -``` - -## 2. CMotionInterp::move_to_interpreted_state (0x005289c0, 305936) - -``` -move_to_interpreted_state(ims): - if physics_obj == null: return 0 - raw_state.current_style = ims.current_style - physics_obj.interrupt_current_movement() - jumpAllowed = motion_allows_jump(interpreted_state.forward_command) # OLD state! - interpreted_state.copy_movement_from(ims) # FLAT overwrite (0x0051e750) - apply_current_movement(force=1, jumpAllowed) - - for action in ims.actions: # MotionItem list - # 15-bit wraparound stamp gate vs server_action_stamp (305953-305971) - if newer_15bit(action.stamp, server_action_stamp): - if weenie is player and action.autonomous: skip # local echo guard - server_action_stamp = action.stamp - DoInterpretedMotion(action.command, params(speed=action.speed)) - return 1 -``` - -## 3. CMotionInterp::apply_current_movement (0x00528870, 305838) - -``` -apply_current_movement(force, jumpAllowed): - if physics_obj == null or !initted: return - if (weenie==null or weenie.IsThePlayer()) and physics_obj.movement_is_autonomous(): - apply_raw_movement(force, jumpAllowed) # LOCAL player (already ported, D6) - else: - apply_interpreted_movement(force, jumpAllowed) # REMOTES — this port -``` - -## 4. CMotionInterp::apply_interpreted_movement (0x00528600, 305713) - -``` -apply_interpreted_movement(force, jumpAllowed): - if physics_obj == null: return - if interpreted_state.forward_command == RunForward (0x44000007): - my_run_rate = interpreted_state.forward_speed # cache server run rate - - DoInterpretedMotion(interpreted_state.current_style, {}) # stance - - if !contact_allows_move(interpreted_state.forward_command): - DoInterpretedMotion(0x40000015 Falling, {}) - elif standing_longjump: - DoInterpretedMotion(0x41000003 Ready, {}) - StopInterpretedMotion(0x6500000F SideStep…, {}) - else: - DoInterpretedMotion(interpreted_state.forward_command, - {speed: interpreted_state.forward_speed}) - if interpreted_state.sidestep_command == 0: - StopInterpretedMotion(0x6500000F, {}) - else: - DoInterpretedMotion(interpreted_state.sidestep_command, - {speed: interpreted_state.sidestep_speed}) - - if interpreted_state.turn_command != 0: - DoInterpretedMotion(interpreted_state.turn_command, - {speed: interpreted_state.turn_speed}) - return # early — no idle-stop this call - if StopInterpretedMotion(0x6500000D Turn…, {}) == 0: - add_to_queue(ctx=0, Ready, tick) # idle bookkeeping (S3 wires fully) -``` - -Live-trace confirmation (actor minterp 18e8b0f8): per UM exactly -`[DIM] 0x8000003D` then `[DIM] ` (0x45000005 / 0x41000003 / 0x44000007) -then sidestep/turn stops (0x6500000F / 0x6500000D) — order verbatim. - -## 5. CMotionInterp::DoInterpretedMotion (0x00528360, 305575) - -``` -DoInterpretedMotion(motion, params): - if physics_obj == null: return 8 - if contact_allows_move(motion): - if standing_longjump and motion in jump-set: goto apply_only - if motion == 0x40000011: physics_obj.RemoveLinkAnimations() - result = physics_obj.DoInterpretedMotion(motion, params) - # → CPartArray → MotionTableManager::PerformMovement - # → CMotionTable::GetObjectSequence ≙ AnimationSequencer.SetCycle - if result == 0: - add_to_queue(params.context_id, motion, jumpAllowed) # pending_motions (S3) - if params.flags & 0x40: interpreted_state.ApplyMotion(motion, params) - elif (motion & 0x10000000) == 0: - apply_only: - if params.flags & 0x40: interpreted_state.ApplyMotion(motion, params) - result = 0 - else: - result = 0x24 - if physics_obj != null and physics_obj.cell == 0: - physics_obj.RemoveLinkAnimations() - return result -``` - -## acdream mapping (surgical) - -- **New in `MotionInterpreter` (Core.Physics):** `MoveToInterpretedState(ims)`, - `ApplyInterpretedMovement()`, using the EXISTING `DoInterpretedMotion` / - `StopInterpretedMotion` (extended to retail semantics above) with the - sequencer as the `GetObjectSequence` backend. `InterpretedMotionStateData` - = a plain struct mirroring the ctor defaults; built from - `UpdateMotion.Parsed` (absent wire fields → defaults — the parser already - yields nullables; the CONVERSION applies the defaults). -- **`OnLiveMotionUpdated` remote SubState branch collapses** to: build ims → - `remoteMot.Motion.MoveToInterpretedState(ims, sequencer)`. Style-on-change - at the unpack level (today's `fullStyle` preserve-current logic maps to - `get_current_style()` compare). PRESERVE deliberately: K-fix17 airborne - cycle guard, HasCycle fallback chain, MoveTo (case 6/7) seeding, overlay - (Action-class) routing — these live in the DIM backend, not deleted. -- **Stop** rides the same path: empty UM → ims defaults → fwd=Ready → - apply → sequencer Ready + `get_state_velocity`→0 → body velocity zero. The - acdream-invented 300 ms stop-detection window + UP-near-zero StopCompletely - become dead code to remove in the same slice (DEV-3). -- **`my_run_rate` caching** per remote (apply_interpreted_movement head). -- Tests first: fake-sequencer call-order tests per UM shape (empty, walk, - run@2.85, run+turn, action list, stale action stamp), then live smoke. - -## Live-trace answer to the S0-open question - -Retail observer applies EVERY accepted UM wholesale — including empties as -full stops. "Correct behavior" (user-verified during the trace) comes from -this + the chase, not from any inference. acdream's S2 target = exactly this. diff --git a/docs/research/2026-07-03-phase-r-session-handoff.md b/docs/research/2026-07-03-phase-r-session-handoff.md deleted file mode 100644 index d9a32e04..00000000 --- a/docs/research/2026-07-03-phase-r-session-handoff.md +++ /dev/null @@ -1,173 +0,0 @@ -# Phase R handoff — fresh-session entry point (2026-07-03) - -> **POSTSCRIPT (2026-07-03, later session): R4 is COMPLETE.** V5 (local-player -> cutover) shipped as `b3decdfa` — P1 gate ported verbatim, B.6 auto-walk -> deleted, TS-36 bound, run-rate re-anchored to PD skills + mt-6/7 -> my_run_rate with NO AD row (the contingency wasn't needed — see the -> V0-pins.md P1 postscript), MoveToComplete seam widened to natural -> completion for AD-27, plus three adversarial-review fixes (remote -> HitGround relay, mt-8 wire_heading degrade, remote curTime clock). V6 -> register/roadmap/memory sweep shipped in the follow-up commit. The V4 -> smoke log was analyzed CLEAN (mt-6/8 events all dispatched, arrival -> stop transitions, zero exceptions/stalls). Suite: 3,956 green. -> REMAINING: the ONE combined R2+R3+R4 user visual pass, then R5. -> The sections below are the historical entry state, kept verbatim. -> -> **POSTSCRIPT 2 (2026-07-03, the live verify session):** four live bugs -> found+fixed during the user's pass, each root-caused (trail: -> `c2dc1a88` StopCompletely's missing StopCompletely_Internal animation -> dispatch orphaned pending_motions → wait-for-anims wedge; `24569fd2` -> login SetPosition ran before the sink bind → one immortal orphan node; -> `006cf659` remote-player movetos never ticked (glide) + door UMs -> dropped (no RemoteMotion for UP-less entities); `350fb5e3` the -> detached-object guard's ObjCellId proxy stripped ALL remote transition -> links → door-swing snap, register TS-40). CONFIRMED WORKING by user: -> local use-walk + door open + door swing + remote close-range walk. -> STILL OPEN: ISSUES **#160** (remote run-anim pace vs ACE movement -> speed), **#161** (remote landing pose — retest first, TS-40 may have -> fixed it), **#162** (glide-class ACE autonomous-reflection cancels — -> adjudication pending the user's retail-vs-retail A/B; adaptation needs -> explicit approval), **#163** (strip the temp diagnostics). - -**Read this first, then the plan of record** -(`docs/plans/2026-07-02-retail-motion-animation-rewrite.md`). This session -ran the R2→R4 arc of the Phase R mandate; the fresh session picks up at -**R4-V4 smoke verification → R4-V5**. - -## The mandate (user, standing) - -Total overhaul of movement + animation as a VERBATIM retail port — all -movement, inbound and outbound, all animation, for players/NPCs/monsters. -No guessing, no approximations, no bandaids. The user cancelled the /loop -self-scheduling near the end of this session — **work continues on user -prompts, not self-scheduled wakeups**, but the drive-autonomously rules in -CLAUDE.md still apply within a turn. - -## State at handoff (worktree `vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`) - -- Tree: CLEAN at `7016b26c` (only `v4-smoke.log` untracked — session - scratch, delete freely). Full suite green: **3,948 tests** - (385 Net + 425 UI + 713 App + 2,425 Core, +4 pre-existing skips). -- **R1 ✅ R2 ✅ R3 ✅ shipped** (trail + commit SHAs recorded in the plan - doc's stage entries). **R4: V0–V4 shipped, V5+V6 remain.** -- A smoke client was launched with the V4 build (`ACDREAM_DUMP_MOTION=1`, - log `v4-smoke.log` in the worktree root, UTF-16) — **the user had NOT yet - reported an NPC-behavior verdict at handoff time.** First order of - business: get that verdict (or relaunch and ask). Watch items: NPC - chase/wander legs, turn-to-face, melee-range stop distance (arrival is - now retail cylinder distance — the old max() gate is gone). - -## What just landed (this session, newest first) - -| Commit | What | -|---|---| -| `7016b26c` | **R4-V4** remote cutover: per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED; P4 TargetTracker adapter; retail unpack_movement dispatch (head-interrupt every UM; mt 6–9 never touch the interpreted funnel) | -| `a144e873` | **R4-V3** wire: mt 8/9 parsing (TurnToPathData), raw bitfield surfaced, mt-0 sticky trailer | -| `addc8e97` | **R4-V2** the verbatim MoveToManager (33 members, 101 tests; seams listed in its final report — ctor takes the interp + 14 providers; the identity-quaternion TDD catch) | -| `e0d2492c` | **R4-V1** command-selection family (GetCommand + CanCharge fast-path; MoveToMath incl. the Ghidra-confirmed heading_diff mirror) | -| `386b1ce5` | **R4-V0** pins P1–P7 (see below) | -| `988304e1` | R4 research base (`docs/research/2026-07-03-r4-moveto/` — decomp, ACE cross-ref, port plan) | -| `30115d96` | **W6b fix** — the "press W and stop instantly" live regression (see gotchas) | -| `fb7beb70`…`8eff3978` | R3 W0–W7 + R2 Q0–Q6 (see the plan doc's stage trails) | - -## NEXT: R4-V5 — LOCAL PLAYER cutover (the delicate one) - -Spec: `docs/research/2026-07-03-r4-moveto/r4-port-plan.md` §3 V5. -**ONE commit, GameWindow + PlayerMovementController, do NOT fan out** -(feedback_dont_parallelize_coupled_plan_slices). Shape: - -- The player gets a MoveToManager bound like remotes (the - EnsureRemoteMotionBindings block at GameWindow is the reference wiring; - the player's Motion/seams already exist from R3 — DefaultSink is bound, - `_playerController.Motion` exposed). -- **B.6 auto-walk DELETED**: `DriveServerAutoWalk` + `BeginServerAutoWalk` - + the `_autoWalk*` fields + the local mt-6 branch in GameWindow - (`BeginServerAutoWalk` call site) + the two relocated constants - (`AutoWalkArrivalEpsilon`, `AutoWalkTurnRateFor` — marked V5-scope in the - controller). -- **P1 pin applies here**: port the missing tail of retail's - `CPhysics::SetObjectMovement` gate — DROP any 0xF74C event whose wire - `IsAutonomous` byte is set when guid == local player (parsed at - `UpdateMotion.cs` isAutonomous, relayed by WorldSession, consumed - NOWHERE yet). That's what makes retail's unconditional head-interrupt - safe against ACE's mt-0 echo. `V0-pins.md` P1 has the full adjudication + - the LoseControlToServer note. This RETIRES the informal - "don't cancel on non-MoveTo" comment block in the local UM branch. -- TS-36 retires (InterruptCurrentMovement bound for the player); - AD-26 retires; AD-27/AP-23 re-anchor. -- Movement keys auto-cancel a server MoveTo (retail: the CommandInterpreter - DoMotion's cancel bit → interrupt → CancelMoveTo — the edge-driven W6 - input already calls DoMotion with ctor-default params whose CancelMoveTo - is TRUE, so this may fall out for free — verify, don't assume). -- Tests: the V2 harness pattern for the player-side bind; the W6 - edge-driven regression suite (`W6EdgeDrivenMovementTests.cs`) is the - template for controller-level tests with a real sink. -- Then **V6**: register/roadmap/plan/memory sweep (V6 spec in the plan). - After V6, R4 is done → R5 (MovementSystem facade + StickyManager + - ConstraintManager + TargetManager full port), R6 (per-tick - UpdateObjectInternal order — retires the 300ms stop window, PlanFromVelocity - AP-80, ObservedOmega AP-76, AP-75/AP-77 velocity adaptations), R7 - (outbound cadence TS-33), R8 (audit + final visual pass). - -## Pending user verifications (visual passes) - -1. **V4 smoke verdict** (see above — NPC movement). -2. **R2+R3 combined visual pass** (task #9): jump instant-Falling engage, - walk-off-ledge momentum (NEW behavior), running-in-circles blend, stop - settle, strafe pacing (EXPECTED-DIFF: #45's 1.248× factor retired — - local now matches remotes), retail-observer view (no rubber-band). - Partially confirmed ("works a lot better now") after the W6b fix. - -## Load-bearing gotchas from this session (do not relearn these) - -1. **BN hoisted-register artifact class** (caused the W6b live bug): the - pseudo-C renders pre-call register caches as post-call field reads. - `ApplyInterpretedMovement` must ENTRY-CACHE the axis values — the style - dispatch's `ApplyMotion(style)` resets forward to Ready (verbatim, - raw 0051ea6c) and retail self-heals via the cached fwd re-apply. The - 183-case suite can NOT catch sink-result-dependent bugs: its - RecordingSink's bool return doesn't mirror the real - MotionTableDispatchSink (style dispatches return TRUE → the resetting - state-write runs). Regression suite: `W6EdgeDrivenMovementTests.cs`. -2. **The dispatch RESULT gates queue+state writes** (W5 discovery): - `IInterpretedMotionSink.ApplyMotion/StopMotion` return bool for this - reason — documented on the interface. -3. **Retail's echo discriminator is the wire autonomous byte** (V0 P1): - not a workaround — `CPhysics::SetObjectMovement` 0x00509690's final - branch. Port it in V5, don't invent gating. -4. **MoveToManager entry points never drain pending_actions** — re-issues - MUST route through `PerformMovement` (V2 finding, tested). -5. **`default(Quaternion)` is zero, not identity** — heading math reads - 90° off a zero quat (V2 TDD catch; `IdentityPosition` constant). -6. **fail_progress_count is WRITE-ONLY in retail** — there is no MoveTo - give-up threshold; do not invent one. -7. **Delegation pattern that works**: sonnet implementers against - committed pinned specs (research → pins with adversarial verify → - implement → orchestrator independently verifies scope + suite + - spot-checks against the raw decomp → commit). GameWindow/controller - coupled surgery stays with the orchestrator. Workflow tool for - research fan-outs (r3/r4 extraction + pin workflows are the templates). -8. **py (not python) exists on this box**; logs from Tee-Object are - UTF-16; heredocs with quotes inside python strings break — write - scripts to the scratchpad with the Write tool and run `py file.py`. -9. **Ghidra MCP** (port 8080/8081, needs the user to open patchmem.gpr): - used live this session for heading_diff/get_desired_heading - (`ghidra-confirmations.md`). Ask the user to open it when a BN garble - blocks a pin. -10. **ACE session care**: graceful client close (CloseMainWindow) or ACE - holds the session ~3 min (exit 29 on relaunch). Don't kill clients - the user launched — ask. - -## Key file map (R4 additions) - -- `src/AcDream.Core/Physics/Motion/MoveToManager.cs` + `MoveToNode.cs` — - the V2 port (seams doc'd on the ctor). -- `src/AcDream.Core/Physics/Motion/MoveToMath.cs` — heading/cylinder math - + `OriginToWorld` (relocated from the deleted RemoteMoveToDriver). -- `src/AcDream.Core/Physics/Motion/MovementParameters.cs` — `GetCommand` / - `FromWire` / `FromWireTurnTo` / `GetDesiredHeading` / `TowardsAndAway`. -- `docs/research/2026-07-03-r4-moveto/` — decomp + ACE cross-ref + - port plan + `V0-pins.md` + `ghidra-confirmations.md`. -- GameWindow: `EnsureRemoteMotionBindings` (the full per-remote wiring - reference), the remote UM dispatch (mt 6–9 vs mt-0 funnel), the - per-tick tracker+UseTime block. diff --git a/docs/research/2026-07-03-r4-moveto/V0-pins.md b/docs/research/2026-07-03-r4-moveto/V0-pins.md deleted file mode 100644 index 94a0a039..00000000 --- a/docs/research/2026-07-03-r4-moveto/V0-pins.md +++ /dev/null @@ -1,330 +0,0 @@ -# R4-V0 — ambiguity pins and ACE-oddity adjudications - -The verbatim extraction is `r4-moveto-decomp.md` (line-anchored); the port plan -with the full ambiguity table is `r4-port-plan.md` §0. This note records the -PINNED resolutions the V1–V6 ports code against. Every pin below was produced by -an independent raw re-read of `docs/research/named-retail/acclient_2013_pseudo_c.txt` -(line numbers cited are that file's), cross-read against ACE source -(`references/ACE/Source/ACE.Server/`, MoveToManager under `Physics/Managers/`), -and P1/P3 additionally survived an adversarial refutation pass (P3 by direct -byte-level disassembly of the PDB-matched retail binary — evidence one tier -stronger than a Ghidra decompile). **No pin was refuted; none blocks on cdb or -Ghidra.** Ghidra MCP was down for the whole pass; the deferred decompile checks -are listed per-pin and in the tail section — all confirmatory, none load-bearing. - -## Pinned - -- **P1 — ACE's companion mt-0 echo vs retail's unpack-head cancel: PINNED — no - adaptation needed; port the missing TAIL of retail's own gate.** - `CPhysics::SetObjectMovement` (0x00509690) drops any movement event whose wire - autonomous byte is set when the addressed object `IsThePlayer`, BEFORE - `unpack_movement` is ever called — so retail's unconditional head-interrupt - never sees an autonomous echo. Plan option (c) (suppress-when-command-matches) - is DISCARDED. Confidence: **high** (adversarially verified; refutation failed - on every angle). - - Port shape: in the local-player 0xF74C path, after the S1 `MotionSequenceGate` - passes, drop the event entirely — no interrupt, no state application — when - `update.IsAutonomous && guid == local player guid`. Verbatim (raw 271370–271431): - `ebx = weenie_obj->vtable->IsThePlayer()` @005096a8; two wraparound sequence - gates; then @0050972e `if ((arg7 == 0 || ebx == 0)) { - arg2->last_move_was_autonomous = arg7; CPhysicsObj::unpack_movement(arg2, - &arg3, arg4); if (ebx != 0) return 1; }` else fall to `return 0`. Note - `last_move_was_autonomous` is stored ONLY on the unpack path — do not store it - for dropped events. The `||`-vs-`&&` BN-inversion worry is refuted - behaviorally: with `&&` the local player would never unpack ANY movement event, - yet server MoveTos demonstrably drive retail clients against ACE. The 4-arg - overload (0x00509790, raw 271458–271485) pins arg7 = the wire byte at offset +4 - after two u16 sequences with align-to-4 — byte-identical to ACE's writer - (MovementData.cs:189-200: `GetNextSequence(ObjectMovement)`; autonomous ? - `GetCurrentSequence` : `GetNextSequence(ObjectServerControl)`; - `Convert.ToByte(motion.IsAutonomous)`; `writer.Align()`). - - With the gate in place, the verbatim `unpack_movement` head-cancel ports - cleanly: `CPhysicsObj::interrupt_current_movement` + `unstick_from_object` on - EVERY UM type 0/6/7/8/9 before the type switch (0x00524440, raw 300566–300573; - jump table @300707–300719 — types 1–5 fall to return 0, head already ran). - `interrupt_current_movement` = `if (movement_manager) - MovementManager::CancelMoveTo(0x36)` (raw 278189–278200), a no-op when - `moveto_manager == 0` (raw 300277–300288 @0x005241b0). mt 6/7/8/9 keep the - retail unconditional cancel as planned (PerformMovement re-cancels anyway, §3a). - - Why the echo was never a retail problem: the "companion mt-0 echo" is NOT - unconditional ACE behavior — it is ACE reflecting the client's OWN outbound - MoveToState back to the sender. `BroadcastMovement` has exactly ONE caller - (GameActionMoveToState.cs:36); `MovementData(Creature, MoveToState)` hardcodes - `IsAutonomous = true` (MovementData.cs:162) and Player_Networking.cs:365 sends - it to self (`EnqueueBroadcast(true, movementEvent)`; ACE's own comment - "shouldn't need to go to originating player?"). The 2026-05-14 trace behind - GameWindow.cs:4534-4546 was self-inflicted: the pre-#75 auto-walk synthesized - Forward+Run input that leaked outbound as a MoveToState - (PlayerMovementController.cs:546-557 preserves the finding); post-#75 - (:298-308) no MoveToState is built during auto-walk, so no echo exists at - moveto start — **the GameWindow.cs:4534-4546 causal story ("ACE follows every - mt=0x06 with an mt=0x00") is stale**. ACE's mt-6/7/8/9 senders are - `IsAutonomous = false` (Motion.cs:18 default, copied at MovementData.cs:44; - only two `IsAutonomous = true` sites in ACE.Server: MovementData.cs:162 and - the Player.cs:944-951 jump hack), so they pass the gate and the auto-walk - trigger survives. The gate also absorbs ACE's other self-addressed autonomous - mt-0 class (the charged-jump hack) — and it is precisely why retail WASD - against ACE (every keypress echoed back IsAutonomous=1) doesn't stutter: - standing behavioral corroboration. - - **Register adjudication (the exact wording V5/V6 execute):** NO new AD row. - The informal "do NOT cancel local moveto on a non-MoveTo UM" adaptation at - GameWindow.cs:4534-4546 has no register row (verified — a row-less deviation) - and is RETIRED by the ported retail mechanism; delete the comment block with - the code in V5. ONE CONTINGENCY: the gate severs acdream's only live runSkill - sync feed — GameWindow.cs:4364-4372 wires - `ApplyServerRunRate(update.MotionState.ForwardSpeed)` and its ONLY live source - is the autonomous mt-0 echo. V5 must re-anchor run-rate sync to retail's own - mechanism (mt-6/7 `my_run_rate` wire read = plan item M13, plus - PlayerDescription). If V5 instead taps the echo's ForwardSpeed BEFORE the drop, - that tap IS an ACE-compat adaptation requiring this row in the same commit: - - > **AD-new | Local-player autonomous-echo ForwardSpeed tap** — acdream reads - > `InterpretedMotionState.ForwardSpeed` from the self-addressed autonomous - > mt-0 UM (ACE's MoveToState reflection, MovementData.cs:162 - > IsAutonomous=true hardcoded) before the ported retail - > SetObjectMovement autonomous/IsThePlayer drop (0x00509690 @0050972e) - > discards it, feeding `PlayerMovementController.ApplyServerRunRate`. Retail - > discards the event entirely. | Risk: local/observer run-speed desync if the - > echo changes; masks the missing my_run_rate wire feed. | Retire when M13's - > mt-6/7 my_run_rate + PlayerDescription feed lands. - - Adjacent seam (R5 note): retail's 0xF74C dispatch calls - `cmdinterp->vtable->LoseControlToServer()` whenever SetObjectMovement returns - nonzero — i.e. whenever a NON-autonomous movement event is applied to the - local player (raw 357214–357235: instance-seq gate then `if - (CPhysics::SetObjectMovement(...) != 0) this->cmdinterp->vtable-> - LoseControlToServer();`). This is the retail autonomy-handoff hook that - `BeginServerAutoWalk` currently approximates — R5/MovementManager scope. - - Ghidra deferred (non-blocking): decompile `0x00509690` (re-verify the - `(arg7 == 0 || ebx == 0)` condition + which sequence stamps are written on the - reject paths — the function has BN self-subtraction flag-test garbles at - @005096db/@00509712, but the decisive gate is a plain integer test with the - adjacent `last_move_was_autonomous` store); decompile the 0xF74C dispatch case - at ~`0x00559850` (confirm LoseControlToServer fires iff SetObjectMovement != 0). - -- **P2 — `get_desired_heading` (0x0052aad0) 180/0 constants: PINNED to ACE's - shape.** `RunForward|WalkForward → movingAway ? 180 : 0`; `WalkBackwards → - movingAway ? 0 : 180`; default 0. Confidence: **high**. The BN raw - (308016–308033) confirms only the command GROUPING — {RunForward 0x44000007, - WalkForward 0x45000005} vs {WalkBackwards 0x45000006} vs default — while both - constant branches render identically (`result = arg3`) and the default renders - as x87 flag-synthesis nonsense (`return (arg2 - 0x45000006)`), the known - setcc-garble class. Constants + per-branch polarity are 100% ACE-sourced - (MovementParameters.cs:186-198) but doubly corroborated: (a) caller math (raw - 307227–307233, HandleMoveToPosition @0x00529e25) adds the return to - `Position::heading(to target)` then wraps [0,360) — a degree OFFSET; (b) - physics: §5c pure-away picks WalkForward+movingAway=1 ("heading flips via - get_desired_heading +180", r4-moveto-decomp.md:619-620) → away-walk faces - away = +180; §5d towards_and_away picks WalkBackwards+movingAway=1 inside the - min band ("backs up with WalkBackwards (no turn)", :656) → backstep faces the - target = 0. Ghidra deferred (non-blocking): decompile `0x0052aad0`; mark the - pseudocode doc UNVERIFIED-BY-GHIDRA until then. - -- **P3 — `heading_diff` (0x00528fb0) third-arg mirror: PINNED as MIRROR - PRESENT — port ACE's body verbatim.** `result = h1 − h2; if |result| < ε - result = 0; if result < −ε result += 360; if (result > ε && motion != - TurnRight) result = 360 − result;` with ε = 0.000199999995f and TurnRight = - 0x6500000D. r4-moveto-decomp §5g's "arg3 UNUSED in the body" is a BN x87-setcc - artifact (the W6b class); r4-ace-moveto §1's suspicion was correct. - Confidence: **high** — sealed by direct disassembly of the PDB-matched binary - (`C:\Turbine\Asheron's Call\acclient.exe`, CodeView GUID - 9e847e2f-777c-4bd9-886c-22256bb87f32 = MATCH), which supersedes the deferred - Ghidra check entirely. The tail BN garbled into a fake "return flag word" - (raw 306344–306346) disassembles as exactly the mirror guard: - - ``` - 00528fec fcom dword [0x007c8a00] ; result vs ε (0.00019999...) - 00528ff2 fnstsw ax - 00528ff4 test ah, 0x41 ; C0|C3 → result <= ε - 00528ff7 jnz 0x00529009 ; skip mirror unless result > ε STRICT - 00528ff9 cmp dword [esp+0xC], 0x6500000D ; arg3 vs TurnRight — arg3 IS read - 00529001 je 0x00529009 ; TurnRight → skip mirror - 00529003 fsubr dword [0x0079bc60] ; ST0 = 360.0f − result - 00529009 ret ; float return in ST0 - ``` - - The 29 bytes BN collapsed (0x00528fec→0x00529008) fit the mirror encoding to - the byte; `test ah,0x41 / jnz` encodes strict `>` (skip on less OR equal), so - ACE's `>` comparison (Managers/MoveToManager.cs:825-826) is the correct port. - Call sites: `BeginTurnToHeading` @0x00529be0 passes CONSTANT 0x6500000d - TurnRight (mirror explicitly disabled — direction pick stays the ≤180 test); - `HandleTurnToHeading` @0x0052a1bc (raw :307495) passes the LIVE - `current_command`. Two refinements over the plan's P3 row: (1) the pin's - "previous_heading update path differs" clause is FALSE — both branches of the - progress test write `previous_heading` identically (raw @0x0052a1ef / - @0x0052a206; ACE :611-621); (2) the mirror is behaviorally DEAD in this build — - its only divergent effect is `fail_progress_count` reset-vs-increment, and - that counter is write-only (6 refs in the whole raw, all writes: =0 - @0x005292b1/@0x00529f3e/@0x0052a13a/@0x0052a1e5, +=1 @0x0052a014/@0x0052a224; - zero reads) — port it for verbatim fidelity + conformance tests, not because - retail-visible behavior hangs on it. No deferred check remains (Ghidra + cdb - fallback both MOOT). - -- **P4 — TargetManager scope: DECISION CONFIRMED — R4 ships the minimal - App-side `TargetTracker` adapter, not a TargetManager port.** Confidence: - **high** (as a recorded decision; the retail feed constants and delivery - contract are pinned from the extraction). On `set_target(0, tlid, 0.5, 0)` - (verbatim call sites: r4-moveto-decomp.md:359 MoveToObject §3b, :423 - TurnToObject §3d; constants table :1168 — radius 0.5f, initial quantum 0) - GameWindow's entity table delivers one immediate - `TargetInfo{ObjectId, Ok, target_position = entity pos, interpolated_position - = same}`, then re-delivers per tick when the target has moved > 0.5 units - since last delivery; `clear_target` unsubscribes; target despawn/teleport - delivers `status = ExitWorld/Teleported` so `CancelMoveTo 0x37/0x38` falls out - of the verbatim manager (§6d @307802–307867: `!= Ok` on first callback → - 0x38 NoObject :857-858; on retarget → 0x37 ObjectGone :866-867; retarget sets - `sought_position = interpolated`, `current_target_position = target` - :869-870 + FLT_MAX progress reseeds :871-874). `set_target_quantum` is - accepted and recorded but does NOT throttle (over-delivery is - convergence-safe). TargetStatus values from ACE (r4-ace-moveto.md:654-656): - Undefined=0, OK=1, ExitWorld=2, Teleported=3, Contained=4, Parented=5, - TimedOut=6; ACE's HandleTargetting cadence is 0.5 s with a 10 s - Undefined→TimedOut timeout. Wire inputs available today: mt-6 TargetGuid - (UpdateMotion.cs:310-315, parsed-but-unused = gap M8 — the subscription key); - wire Origin (:319-326) doubles as the first-delivery fallback when the guid - is unresolvable, mirroring retail's own case-6 degrade to - MoveToPosition(wire origin) (r4-moveto-decomp.md:290-292). NOT on the wire: - target radius/height for `cylinder_distance` (from the local entity table) - and ongoing target updates (the adapter's per-tick tracking; ACE's ~1 Hz - MoveTo re-emit is the backstop). **Register row (AP class) lands in V4, - replacing AP-8's "no target re-tracking" clause; full TargetManager port is - R5 scope.** Fallback if the adapter proves too loose in V4: grep - `TargetManager::` in the raw (~200 lines) and port verbatim. - -- **P5 — Position::heading convention: PINNED — compass degrees, 0 = North - (+Y), 90 = East (+X), CLOCKWISE, [0,360); identity quaternion faces heading - 0.** Formula: `heading(from, to) = (450 − atan2Deg(dy, dx)) % 360`. Wire - `DesiredHeading` is in THIS convention (retail @0052a71d feeds it straight - into `Frame::set_heading` on sought_position, §3e). Golden cardinals: - N(0,+1)→0, E(+1,0)→90, S(0,−1)→180, W(−1,0)→270. Confidence: **high**. - Evidence: `Position::heading` 0x005a9520 (raw 438288–438290): `450.0 − - fpatan(...) × 57.295779513082323`, fmod tail; ACE Position.cs:200-209 pins the - atan2 arg order (`Atan2(dir.Y, dir.X)`); `Frame::get_heading` 0x00535760 (raw - 319781) same formula on the forward basis (ACE AFrame.cs:147-152); - `Frame::set_heading` 0x00535e40 (raw 320055–320066) round-trips — h=0 → - forward (sin 0, cos 0) = +Y. An in-tree twin of the formula already exists at - SceneryHelpers.cs:75. - - **⚠ CORRECTION to the plan's own pin method — the packer-reuse trap.** The - plan says "the outbound AP packer already encodes body yaw→AC heading; REUSE - that exact conversion". Reuse it at the **QUATERNION level ONLY** - (`YawToAcQuaternion`/`AcQuaternionToYaw` are wire-correct end-to-end — its - two internal errors cancel). Its internal SCALAR intermediate - (GameWindow.cs:8083-8103: `headingDeg = 180f − yawDeg`) is **holtburger's - convention = retail + 90°**, and its comment "AC heading: 0=West, 90=North, - 180=East, 270=South" (from holtburger math.rs:56, whose identity-quat note - :146-148 says "Heading 90 (North)") is WRONG as a statement of the retail wire - convention. Literal scalar reuse would face `use_final_heading` and every - `get_desired_heading` offset 90° wrong. The correct scalar bridge from acdream - yaw (yaw=0 faces +X per PlayerMovementController.cs:1022-1025) is - `heading = (90 − yawDeg) mod 360`. Quaternion-level proof: - YawToAcQuaternion(yaw=0) → (z=−0.7071, w=+0.7071), from which ACE's - AFrame.get_heading extracts 90 = East — correct for a +X-facing body. - -- **P6 — TurnToObject (mt 8) wire layout: PINNED byte-for-byte from ACE's - writer, matching the decomp read order exactly.** After the common UM header - (u8 movementType, u8 motionFlags, u16 currentStyle — MovementData.cs:202-205): - `[u32 object guid][f32 standalone heading][u32 params bitfield][f32 speed] - [f32 params desired_heading]`. mt 9 (TurnToHeading) = the 3-dword - TurnToParameters only. Confidence: **high**. The standalone second field is - ACE `TurnToObject.DesiredHeading` = retail's `wire_heading`, consumed ONLY in - the unresolvable-object fallback: substituted into `params.desired_heading`, - then degrade to TurnToHeading (decomp §2f case 8, r4-moveto-decomp.md:293-298: - `object_id = read_dword(); wire_heading = read_dword(); UnPackNet(...); if - (GetObjectA(object_id) == 0) { var_9c.desired_heading = wire_heading; goto - label_524725; }`; ACE TurnToObject.cs:25-31 + field comment :10-11 "used - instead of the DesiredHeading in the TurnToParameters"). UnPackNet 0xc-byte - form per §2g (:311): bitfield, speed, desired_heading. Gap M7 confirmed: - UpdateMotion.cs:252 `else if (movementType is 6 or 7)` — mt 8/9 payloads - silently discarded today. **V3 fixture caveat:** ACE populates BOTH heading - fields from the same source (TurnToObject.cs:17 and TurnToParameters.cs:18 - both copy `motion.DesiredHeading`), so ACE-golden fixtures always have - field2 == field5 — distinguish the two fields by OFFSET (hand-vary one in - synthetic fixtures), never by value. - -- **P7 — remote Contact for the UseTime gate: PINNED — Contact IS live for - grounded remotes; no state-writer fix needed.** Confidence: **high**. Remote - bodies are BORN with `Contact | OnWalkable | Active` (GameWindow.cs:580-591) - and BOTH remote per-tick paths re-assert `TransientState |= Contact | - OnWalkable | Active` unconditionally every tick while `!rm.Airborne` - (:9366-9370 player-remote pipeline; :9570-9574 NPC/legacy — the path - RemoteMoveToDriver runs on today). The bits are cleared ONLY for the airborne - arc (:4833-4841, `Velocity.Z > 0.5f` launch) and restored on landing by two - independent paths (:5161-5167 UP grounded snap; :9808-9818 resolver landing — - which already calls `rm.Motion.HitGround()`, the exact retail re-arm seam - `MoveToManager::HitGround` §6e binds to: `if (movement_type == Invalid) - return; BeginNextNode(this);`, r4-moveto-decomp.md:884-889). Retail UseTime - gates on `transient_state & 1` (Contact only, @307781) — a strict SUBSET of - the funnel's `Contact && OnWalkable` that demonstrably works for remote - Falling dispatch today (MotionInterpreter.cs:2089-2092). A chasing grounded - NPC passes the gate every tick; an airborne remote stalls the moveto until - landing — retail behavior by design. The pin-method's live - `ACDREAM_DUMP_MOTION` smoke was NOT run (read-only pass; the write is - unconditional so the smoke is confirmatory only) — **folded into V4's - acceptance run** (already listed there: "Live smoke: NPC chase + - ACDREAM_DUMP_MOTION"). - -## Pending - -None. No pin was refuted or left uncorrectable. - -## Deferred non-blocking checks (Ghidra MCP down during V0) - -All three are confirmatory — the textual adjudications above are decisive. - -| Address | Function | What to confirm | -|---|---|---| -| `0x00509690` | `CPhysics::SetObjectMovement` (7-arg) | The `(arg7 == 0 \|\| ebx == 0)` gate condition; which sequence stamps are written on the reject paths (BN garbles @005096db/@00509712 are self-subtraction flag-test artifacts) | -| ~`0x00559850` | 0xF74C dispatch case | `LoseControlToServer()` fires iff `SetObjectMovement != 0` | -| `0x0052aad0` | `MovementParameters::get_desired_heading` | The 180/0 constants + per-branch polarity (currently ACE-sourced; grouping BN-verified) | - -P3's deferred check (`0x00528fb0`) is MOOT — resolved by direct disassembly of -the PDB-matched binary (see the pin). - -## Adjacent findings (load-bearing for V-commits, discovered while pinning) - -- **runSkill-sync severance (V5 trap, from P1):** GameWindow.cs:4364-4372's - `ApplyServerRunRate(ForwardSpeed)` is fed EXCLUSIVELY by the autonomous mt-0 - echo the P1 gate will drop. V5 re-anchors run-rate sync to M13 (mt-6/7 - `my_run_rate` wire read → `Motion.MyRunRate`) + PlayerDescription — or ships - the contingent AD row quoted in P1. - **V5 EXECUTED (2026-07-03): re-anchor taken, NO AD row needed.** Both - retail-mechanism feeds already existed: PlayerDescription run/jump skills - flow via `SetCharacterSkills` (K-fix7, wired since before V5 — the - "we don't parse PD yet" comment in the controller ctor was stale) into - `PlayerWeenie.InqRunRate`, which `apply_run_to_command`/`get_state_velocity` - PREFER over `MyRunRate`; and V5's shared `RouteServerMoveTo` performs the - M13 `Motion.MyRunRate = MoveToRunRate` write for the local player on - mt-6/7. `ApplyServerRunRate` was deleted outright — its - `InterpretedState.ForwardSpeed` overwrite was a pre-R3 mechanism that - fought the ported machinery (any DoMotion edge recomputes ForwardSpeed - through adjust_motion/apply_run_to_command). -- **LoseControlToServer seam (R5):** retail hands autonomy to the server at the - 0xF74C dispatch whenever a non-autonomous movement event is applied to the - local player (raw 357214–357235). Note for R5/MovementManager — the retail - hook `BeginServerAutoWalk` approximates today. -- **GameWindow.cs:4534-4546 comment is stale** (from P1): the "ACE follows every - mt=0x06 with an mt=0x00" causal story described the pre-#75 build's - self-inflicted echo; post-#75 no echo exists at moveto start. The comment - block dies with the code in V5 — do not port against it. -- **RemoteMoveToDriver.cs:53-57 stale claim FIXED in this commit** (plan §0 - mandate): the class doc claimed "ACE swaps the chase/flee arrival predicates" - — refuted by r4-ace-moveto §1 and by the file's own :186-199 comment; retail - and ACE agree (chase arrives `dist <= distance_to_object`, flee arrives - `dist >= min_distance`). - -## V0 cdb capture (pending, non-blocking) - -No pin above depends on it — P1/P3 were adversarially sealed textually (P3 down -to the instruction bytes). One live retail session feeds V2/V4 goldens: bp -`MoveToManager::PerformMovement` / `BeginMoveForward` / `BeginTurnToHeading` / -`HandleMoveToPosition` / `HandleUpdateTarget` / -`MovementParameters::get_command` (args+ret) while the user runs the plan §0 -protocol (door-use at range, use-while-facing-away, monster chase, outrun). A -`heading_diff` arg3+ST0 log during a TurnLeft-direction TurnToHeading would -spot-check P3's mirror live — nice-to-have only. diff --git a/docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md b/docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md deleted file mode 100644 index 00766b69..00000000 --- a/docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md +++ /dev/null @@ -1,58 +0,0 @@ -# R4-V0 — Ghidra ground-truth decompiles (patchmem.gpr, fetched live 2026-07-03) - -Authoritative resolutions for the two pins the BN pseudo-C garbled. Fetched -via the Ghidra MCP while the V0 pin workflow ran; these OVERRIDE any textual -adjudication that disagrees. - -## P3 — `heading_diff` 0x00528fb0: the TurnLeft mirror EXISTS - -```c -float __cdecl heading_diff(float param_1, float param_2, ulong param_3) -{ - float fVar1; - - fVar1 = param_1 - param_2; - if (ABS(param_1 - param_2) < F_EPSILON) { - fVar1 = 0.0f; - } - if (fVar1 < -F_EPSILON) { - fVar1 = fVar1 + 360.0f; // ___real_43b40000 - } - if ((F_EPSILON < fVar1) && (param_3 != 0x6500000d)) { - fVar1 = 360.0f - fVar1; // the mirror — NOT-TurnRight only - } - return fVar1; -} -``` - -Adjudication of the research contradiction: -- `r4-moveto-decomp.md` §5g ("turn-command arg UNUSED") is **WRONG** — the - arg gates the mirror. -- `r4-ace-moveto.md`'s suspicion (an x87-garbled `360−diff` TurnLeft branch) - is **CONFIRMED**: the mirror applies whenever the turn command is not - TurnRight (0x6500000d), i.e. the TurnLeft direction measures the - complementary angle. Port verbatim with the epsilon literal - 0.000199999995f (F_EPSILON). - -## P2 — `MovementParameters::get_desired_heading` 0x0052aad0: ACE-shaped constants CONFIRMED - -```c -float __thiscall MovementParameters::get_desired_heading( - MovementParameters* this, ulong command, int movingAway) -{ - if ((command == 0x44000007) || (command == 0x45000005)) { // Run/WalkForward - if (movingAway == 0) - return 0.0f; - } - else { - if (command != 0x45000006) // not WalkBackward - return 0.0f; - if (movingAway != 0) - return 0.0f; - } - return 180.0f; // ___real_43340000 -} -``` - -Truth table: forward+towards → 0°; forward+away → 180°; -backward+towards → 180°; backward+away → 0°; any other command → 0°. diff --git a/docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md b/docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md deleted file mode 100644 index d44b3e57..00000000 --- a/docs/research/2026-07-03-r4-moveto/r4-ace-moveto.md +++ /dev/null @@ -1,857 +0,0 @@ -# R4 ACE cross-reference — MoveToManager / MovementParameters - -**Purpose:** ACE-side oracle for the R4 MoveToManager port. Per-method transcription of -`references/ACE/Source/ACE.Server/Physics/Managers/MoveToManager.cs` (874 lines) + -`references/ACE/Source/ACE.Server/Physics/Animation/MovementParameters.cs`, call-site map, -ACE-ism flags (each spot-checked against the named retail decomp where noted), and the -acdream R4 blast-radius inventory. - -**Note on paths:** the ACE reference lives in the MAIN repo tree -(`C:/Users/erikn/source/repos/acdream/references/ACE/...`), NOT in the worktree — the worktree's -`references/` only contains WorldBuilder. ACE's file is `Physics/Managers/MoveToManager.cs` -(namespace still says `Physics.Animation` — ACE moved the file without renaming the namespace). - -**Retail anchor table** (named pseudo-C, `docs/research/named-retail/acclient_2013_pseudo_c.txt`): - -| Function | Address | pseudo-C line | -|---|---|---| -| `MovementParameters::MovementParameters` (ctor) | 0x00524380 | 300510 | -| `heading_greater` (free fn) | 0x00528f60 | 306281 | -| `heading_diff` (free fn) | 0x00528fb0 | 306327 | -| `MoveToManager::_DoMotion` | 0x00529010 | 306351 | -| `MoveToManager::_StopMotion` | 0x00529080 | 306368 | -| `MoveToManager::CheckProgressMade` | 0x005290f0 | 306385 | -| `MoveToManager::GetCurrentDistance` | 0x005291b0 | 306435 | -| `MoveToManager::is_moving_to` | 0x00529220 | 306464 | -| `MoveToManager::InitializeLocalVariables` | 0x00529250 | 306490 | -| `MoveToManager::RemovePendingActionsHead` | 0x00529380 | 306538 | -| `MoveToManager::MoveToManager` (ctor) | 0x005293b0 | 306554 | -| `MoveToManager::CleanUp` | 0x005295c0 | 306710 | -| `MoveToManager::CleanUpAndCallWeenie` | 0x00529650 | 306740 | -| `MoveToManager::MoveToObject` | 0x00529680 | 306756 | -| `MoveToManager::TurnToObject` | 0x005297d0 | 306820 | -| `MoveToManager::CancelMoveTo` | 0x00529930 | 306886 | -| `MoveToManager::BeginMoveForward` | 0x00529a00 | 306957 | -| `MoveToManager::BeginTurnToHeading` | 0x00529b90 | 307046 | -| `MoveToManager::BeginNextNode` | 0x00529cb0 | 307123 | -| `MoveToManager::HitGround` | 0x00529d70 | 307175 | -| `MoveToManager::HandleMoveToPosition` | 0x00529d80 | 307187 | -| `MoveToManager::HandleTurnToHeading` | 0x0052a0c0 | 307442 | -| `MoveToManager::MoveToPosition` | 0x0052a240 | 307521 | -| `MoveToManager::MoveToObject_Internal` | 0x0052a400 | 307597 | -| `MoveToManager::TurnToObject_Internal` | 0x0052a550 | 307667 | -| `MoveToManager::TurnToHeading` | 0x0052a630 | 307706 | -| `MoveToManager::UseTime` | 0x0052a780 | 307776 | -| `MoveToManager::HandleUpdateTarget` | 0x0052a7d0 | 307802 | -| `MoveToManager::PerformMovement` | 0x0052a900 | 307871 | -| `MovementParameters::towards_and_away` | 0x0052a9a0 | 307917 | -| `MovementParameters::get_command` | 0x0052aa00 | 307946 | -| `MovementParameters::get_desired_heading` | 0x0052aad0 | 308016 | -| `MovementParameters::Pack / UnPack / UnPackNet` | 0x0052ab20 / 0x0052abc0 / 0x0052ac50 | 308037 / 308078 / 308118 | - -Retail struct layout (verbatim PDB, `acclient.h:31473-31497`): `movement_type, sought_position, -current_target_position, starting_position, movement_params, previous_heading, previous_distance, -previous_distance_time (long double), original_distance, original_distance_time, fail_progress_count, -sought_object_id, top_level_object_id, sought_object_radius, sought_object_height, current_command, -aux_command, moving_away, initialized, pending_actions (DLList), physics_obj, weenie_obj`. -`MovementNode : DLListData { MovementTypes::Type type; float heading; }` (`acclient.h:57702`). - ---- - -## 1. ACE MoveToManager — state - -`MoveToManager.cs:13-35`: - -``` -MovementType MovementType // enum: Invalid=0 ... MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9 -Position SoughtPosition // MoveToObject: interpolated target pos; also carries heading for TurnTo* -Position CurrentTargetPosition // raw target position (distance measured against THIS) -Position StartingPosition // for FailDistance check -MovementParameters MovementParams -float PreviousHeading // NOTE: BeginTurnToHeading stores a heading DIFF here, HandleTurnToHeading stores headings (retail quirk, faithful) -float PreviousDistance; double PreviousDistanceTime -float OriginalDistance; double OriginalDistanceTime -int FailProgressCount // incremented, NEVER read (see §5 negative results — retail-faithful) -uint SoughtObjectID, TopLevelObjectID -float SoughtObjectRadius, SoughtObjectHeight -uint CurrentCommand // active forward/turn motion (full 32-bit command) -uint AuxCommand // in-motion heading-correction turn command -bool MovingAway, Initialized -List PendingActions // MovementNode { MovementType Type; float Heading } -PhysicsObj PhysicsObj; WeenieObject WeenieObj -bool AlwaysTurn // ACE-ONLY (no retail counterpart) — see flag A7 -``` - -MotionCommand constants used: `WalkForward = 0x45000005`, `WalkBackwards = 0x45000006`, -`TurnRight = 0x6500000D`, `TurnLeft = 0x6500000E`, `RunForward = 0x44000007`. -`PhysicsGlobals.EPSILON = 0.0002f` (retail literal `0.000199999995f` everywhere in this family). - -### Init / InitializeLocalVars (`:57-89`) - -``` -Init(): MovementParams = new MovementParameters(); PendingActions = new List(); -InitializeLocalVars(): - MovementType = Invalid - MovementParams.DistanceToObject = 0 // ⚠ flag A2 — retail zeroes the FLAGS word here, not distance_to_object - MovementParams.ContextID = 0 - PreviousDistanceTime = OriginalDistanceTime = now - PreviousHeading = 0 - FailProgressCount = 0; CurrentCommand = 0; AuxCommand = 0; MovingAway = false; Initialized = false - SoughtPosition = new Position(); CurrentTargetPosition = new Position() - SoughtObjectID = TopLevelObjectID = 0; SoughtObjectRadius = SoughtObjectHeight = 0 - // ⚠ flag A3 — retail ALSO sets previous_distance = original_distance = FLT_MAX here (0052926c/0052928a); ACE omits both -``` - -### PerformMovement(MovementStruct mvs) (`:91-112`) — retail 0x0052a900 ✓ matches - -``` -CancelMoveTo(WeenieError.ActionCancelled) // 0x36 — retail same constant -PhysicsObj.unstick_from_object() // ⚠ no null check; retail also derefs unconditionally here -switch (mvs.Type): - MoveToObject → MoveToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Radius, mvs.Height, mvs.Params) - MoveToPosition → MoveToPosition(mvs.Position, mvs.Params) - TurnToObject → TurnToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Params) - TurnToHeading → TurnToHeading(mvs.Params) -return WeenieError.None // retail returns 0 unconditionally too -``` - -### MoveToObject(objectID, topLevelID, radius, height, params) (`:114-139`) — retail 0x00529680 ✓ - -``` -if (PhysicsObj == null) return // retail: null branch still runs StopCompletely-if-nonnull tail (no-op) -PhysicsObj.StopCompletely(false) -StartingPosition = copy(PhysicsObj.Position) -SoughtObjectID = objectID; SoughtObjectRadius = radius; SoughtObjectHeight = height -MovementType = MoveToObject; TopLevelObjectID = topLevelID -MovementParams = copy(movementParams) // retail: full field-by-field copy incl flags; Sticky NOT unset here -Initialized = false -if (PhysicsObj.ID != topLevelID): - PhysicsObj.set_target(0, TopLevelObjectID, 0.5f, 0.0) // contextID=0, radius=0.5, quantum=0 — retail identical constants - return -CleanUp(); PhysicsObj.StopCompletely(false) // self-target degenerate path -``` - -No nodes are queued here — the chain continues via TargetManager voyeur callback → -`HandleUpdateTarget` → `MoveToObject_Internal`. - -### MoveToObject_Internal(targetPosition, interpolatedPosition) (`:141-182`) — retail 0x0052a400 ✓ - -``` -if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject=8); return } -SoughtPosition = copy(interpolatedPosition); CurrentTargetPosition = copy(targetPosition) -iHeading = PhysicsObj.Position.heading(interpolatedPosition) // note: heading measured to INTERPOLATED pos -heading = iHeading - PhysicsObj.get_heading() -dist = GetCurrentDistance() -if (|heading| < EPSILON) heading = 0; if (heading < -EPSILON) heading += 360 -MovementParams.get_command(dist, heading, ref motionID, ref holdKey, ref moveAway) // on the STORED member — retail 0052a4d6 same -if (motionID != 0): AddTurnToHeadingNode(iHeading); AddMoveToPositionNode() -if (MovementParams.UseFinalHeading): // member read is CORRECT here (params stored by MoveToObject already) - dHeading = iHeading + MovementParams.DesiredHeading; if (dHeading >= 360) dHeading -= 360 - AddTurnToHeadingNode(dHeading) -Initialized = true -BeginNextNode() -``` - -### MoveToPosition(position, params) (`:184-228`) — retail 0x0052a240 — ⚠ flag A1 - -``` -if (PhysicsObj == null) return -PhysicsObj.StopCompletely(false) -CurrentTargetPosition = copy(position); SoughtObjectRadius = 0 -distance = GetCurrentDistance() -headingDiff = Position.heading(position) - get_heading(); epsilon-normalize as above -movementParams.get_command(distance, headingDiff, ref command, ref holdKey, ref moveAway) // on the ARGUMENT — retail 0052a304 same -if (command != 0): AddTurnToHeadingNode(heading(position)); AddMoveToPositionNode() -if (MovementParams.UseFinalHeading) // ⚠ A1: reads the STALE MEMBER (old params) — retail 0052a33b reads - AddTurnToHeadingNode(movementParams.DesiredHeading) // the ARGUMENT's flag byte (arg3->__inner0 & 0x40) -SoughtPosition = copy(position); StartingPosition = copy(PhysicsObj.Position) -MovementType = MoveToPosition -MovementParams = copy(movementParams); MovementParams.Sticky = false // retail: full copy then __inner0 &= 0xffffff7f ✓ -BeginNextNode() -``` - -### TurnToObject(objectID, topLevelID, params) (`:230-259`) — retail 0x005297d0 ✓ - -``` -if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return } -if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false) // retail gates on flag byte2 & 1 (0x10000) ✓ -MovementType = TurnToObject; SoughtObjectID = objectID -CurrentTargetPosition.Frame.set_heading(movementParams.DesiredHeading) // retail 0052986c writes current_target_position ✓ -TopLevelObjectID = topLevelID -MovementParams = copy(movementParams) // retail: full copy; Sticky NOT unset (unlike TurnToHeading) -if (PhysicsObj.ID != topLevelID): - Initialized = false - PhysicsObj.set_target(0, topLevelID, 0.5f, 0.0) - return -CleanUp(); PhysicsObj.StopCompletely(false) -``` - -Note the desired-heading value written into `CurrentTargetPosition.Frame` is CLOBBERED by -`TurnToObject_Internal` before it is ever read (Internal overwrites CurrentTargetPosition from the -wire target and reads `SoughtPosition.Frame`'s heading, which InitializeLocalVars reset to 0). -Verified this is a *retail* quirk, not an ACE bug — the retail decomp does exactly the same -(0052986c write → 0052a571 clobber → 0052a58d read-from-sought). Port it verbatim. - -### TurnToObject_Internal(targetPosition) (`:261-282`) — retail 0x0052a550 ✓ - -``` -if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } -CurrentTargetPosition = copy(targetPosition) -targetHeading = PhysicsObj.Position.heading(CurrentTargetPosition) -soughtHeading = SoughtPosition.Frame.get_heading() -heading = (targetHeading + soughtHeading) % 360 // retail: fmod(…, 360) via CIfmod -SoughtPosition.Frame.set_heading(heading) -PendingActions.Add(TurnToHeading node, heading) // retail: node type 9 -Initialized = true -BeginNextNode() -``` - -### TurnToHeading(params) (`:284-304`) — retail 0x0052a630 — ⚠ flag A4 - -``` -if (PhysicsObj == null) { MovementParams.ContextID = movementParams.ContextID; return } -if (movementParams.StopCompletely) PhysicsObj.StopCompletely(false) -MovementParams = copy(movementParams); MovementParams.Sticky = false // retail __inner0 &= 0xffffff7f ✓ -SoughtPosition.Frame.set_heading(movementParams.DesiredHeading) // retail 0052a71d ✓ -MovementType = TurnToHeading -PendingActions.Add(TurnToHeading node, DesiredHeading) -// ⚠ A4: ACE STOPS HERE. Retail 0052a76d calls BeginNextNode(this) immediately after the insert. -``` - -### BeginNextNode (`:316-349`) — retail 0x00529cb0 — ⚠ flag A5 (weenie callback) - -``` -if (PendingActions non-empty): - head.Type == MoveToPosition → BeginMoveForward() - head.Type == TurnToHeading → BeginTurnToHeading() - (other types: fall through / return — retail explicitly returns for any other type) -else: - if (MovementParams.Sticky): // retail: signed test on flags byte0 (bit 0x80) - capture (radius, height, topLevelID) BEFORE CleanUp - CleanUpAndCallWeenie(WeenieError.None) // retail: CleanUp + StopCompletely inline — NO weenie call - if (PhysicsObj != null) - PhysicsObj.get_position_manager().StickTo(topLevelObjectID, radius, height) - else: - CleanUpAndCallWeenie(WeenieError.None) -``` - -### BeginMoveForward (`:351-399`) — retail 0x00529a00 ✓ - -``` -if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } -dist = GetCurrentDistance() -heading = Position.heading(CurrentTargetPosition) - get_heading(); epsilon-normalize -MovementParams.get_command(dist, heading, ref motion, ref holdKey, ref moveAway) // stored member ✓ -if (motion == 0) { RemovePendingActionsHead(); BeginNextNode(); return } -mp = new MovementParameters { HoldKeyToApply = holdKey, // the get_command RESULT — retail 00529af4 ✓ - CancelMoveTo = false, // retail flags &= 0xffff7fff ✓ - Speed = MovementParams.Speed } -result = _DoMotion(motion, mp); if (result != None) { CancelMoveTo(result); return } -CurrentCommand = motion; MovingAway = moveAway -MovementParams.HoldKeyToApply = holdKey // write-back onto stored params — retail 00529b45 ✓ -PreviousDistance = OriginalDistance = dist -PreviousDistanceTime = OriginalDistanceTime = now -``` - -### HandleMoveToPosition (`:404-504`) — per-tick chase driver — retail 0x00529d80 — ⚠ flags A6, A8, A9 - -``` -if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } -curPos = copy(Position) -mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed, - HoldKeyToApply = MovementParams.HoldKeyToApply } // retail 00529dc7-00529dea ✓ -if (!PhysicsObj.IsAnimating): // retail: motions_pending() == 0 - heading = MovementParams.get_desired_heading(CurrentCommand, MovingAway) + curPos.heading(CurrentTargetPosition) - if (heading >= 360) heading -= 360 - diff = heading - get_heading(); epsilon-normalize (0 clamp, +360 if < -EPSILON) - if (diff > 20 && diff < 340): // retail constants 20 / (360-20) ✓ - motionID = (diff >= 180) ? TurnLeft : TurnRight - if (motionID != AuxCommand) { _DoMotion(motionID, mp); AuxCommand = motionID } - else: - if (AuxCommand != 0) PhysicsObj.set_heading(heading, true) // ⚠ A6: ACE-ONLY ("custom: sync for server ticrate") - stop_aux_command(mp) // retail only stops the aux command, no snap -else: - stop_aux_command(mp) // retail: aux stop under motions_pending ✓ -dist = GetCurrentDistance() -if (!CheckProgressMade(dist)): - if (!IsInterpolating() && !IsAnimating) FailProgressCount++ // retail identical gate ✓ -else: - inRange = false // ⚠ A8: whole inRange block is ACE-ONLY ("custom for low monster update rate") - if (!MovementParams.UseSpheres): // including the extra PreviousDistance/Time refresh - if (dist < 1.0 && PreviousDistance < dist) inRange = true - PreviousDistance = dist; PreviousDistanceTime = now - FailProgressCount = 0 - if (MovingAway && dist >= MinDistance || !MovingAway && dist <= DistanceToObject || inRange): - PendingActions.RemoveAt(0) - _StopMotion(CurrentCommand, mp); CurrentCommand = 0 - stop_aux_command(mp) // ⚠ A9: retail stops aux INSIDE the same block, before BeginNextNode — same order ✓ - BeginNextNode() - else: - if (StartingPosition.Distance(Position) > MovementParams.FailDistance) - CancelMoveTo(YouChargedTooFar) // 0x3D — retail same constant (00529f79) -if (TopLevelObjectID != 0 && MovementType != Invalid): - v = |get_velocity()| - if (v > 0.1): // retail double literal 0.1 ✓ - time = dist / v - if (|time - get_target_quantum()| > 1.0) set_target_quantum(time) // retail 1.0 ✓ -``` - -Arrival predicate cross-check: flee branch is explicit in the retail decomp -(`dist >= min_distance` → arrival, 00529f44-00529f51). The chase branch's literal BN rendering -*appears* inverted (`dist <= distance_to_object` branching to the fail-distance check) — that is a -known `test ah, 0x41` branch-sense garble; the flee branch + symmetric structure + two prior -independent research passes (see `RemoteMoveToDriver.cs:186-199`) fix the correct reading as: -**chase arrives at `dist <= distance_to_object`, flee arrives at `dist >= min_distance`** — which -is exactly ACE's line 476. (The stale class-doc comment in acdream's `RemoteMoveToDriver.cs:53-57` -claiming "ACE swaps the predicates vs retail" is WRONG and superseded by the later comment in the -same file — see §6 blast radius.) - -### BeginTurnToHeading (`:510-565`) — retail 0x00529b90 — ⚠ flags A7, A10 - -``` -if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } - // ⚠ A10: retail ALSO CancelMoveTo(8) when pending_actions head == null; - // ACE indexes PendingActions[0] later (throws if empty — callers guard) -if (PhysicsObj.IsAnimating && !AlwaysTurn) return // ⚠ A7: retail is a plain motions_pending()!=0 → return; AlwaysTurn is ACE-only -headingDiff = heading_diff(node.Heading, get_heading(), TurnRight) -if (headingDiff <= 180): - if (headingDiff > EPSILON) motionID = TurnRight - else { RemovePendingActionsHead(); BeginNextNode(); return } -else: - if (headingDiff + EPSILON <= 360) motionID = TurnLeft - else { RemovePendingActionsHead(); BeginNextNode(); return } -mp = new MovementParameters { CancelMoveTo = false, Speed = MovementParams.Speed, - HoldKeyToApply = MovementParams.HoldKeyToApply } -result = _DoMotion(motionID, mp); if (result != None) { CancelMoveTo(result); return } -CurrentCommand = motionID -PreviousHeading = headingDiff // stores the DIFF, not a heading — retail 00529c82 identical quirk -``` - -(Retail decomp's turn-direction branch senses are x87-garbled here too; the retail structure — -`heading_diff(node, current, TurnRight)`, ≤180 → TurnRight, else TurnLeft, pop-when-within-epsilon — -matches ACE's reading. Constants: 180, 360, EPSILON.) - -### HandleTurnToHeading (`:570-621`) — per-tick turn driver — retail 0x0052a0c0 ✓ - -``` -if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } -if (CurrentCommand != TurnRight && CurrentCommand != TurnLeft) { BeginTurnToHeading(); return } -node = PendingActions[0]; heading = get_heading() -if (heading_greater(heading, node.Heading, CurrentCommand)): // crossed the target heading - FailProgressCount = 0 - PhysicsObj.set_heading(node.Heading, true) // snap exactly — retail 0052a146 ✓ (this snap IS retail) - RemovePendingActionsHead() - mp = new MovementParameters { CancelMoveTo = false, HoldKeyToApply = MovementParams.HoldKeyToApply } - _StopMotion(CurrentCommand, mp); CurrentCommand = 0 - BeginNextNode(); return -diff = heading_diff(heading, PreviousHeading, CurrentCommand) -if (diff > EPSILON && diff < 180): - FailProgressCount = 0; PreviousHeading = heading -else: - PreviousHeading = heading - if (!IsInterpolating() && !IsAnimating) FailProgressCount++ -``` - -Note ACE omits `movementParams.Speed` in the stop-params here (retail also builds a default-speed -params for the stop — retail 0052a16a copies only hold_key + clears CancelMoveTo; ACE ✓). - -### HandleUpdateTarget(TargetInfo) (`:623-666`) — retail 0x0052a7d0 ✓ (reordered but equivalent) - -``` -if (PhysicsObj == null) { CancelMoveTo(NoPhysicsObject); return } -if (TopLevelObjectID != targetInfo.ObjectID) return -if (Initialized): - if (status == OK): - if (MovementType == MoveToObject): - SoughtPosition = copy(InterpolatedPosition); CurrentTargetPosition = copy(TargetPosition) - PreviousDistance = OriginalDistance = float.MaxValue - PreviousDistanceTime = OriginalDistanceTime = now - else CancelMoveTo(ObjectGone) // 0x37 — retail same (0052a80f) -else if (TopLevelObjectID == PhysicsObj.ID): - SoughtPosition = CurrentTargetPosition = copy(Position) - CleanUpAndCallWeenie(None) -else if (status == OK): - MovementType == MoveToObject → MoveToObject_Internal(TargetPosition, InterpolatedPosition) - MovementType == TurnToObject → TurnToObject_Internal(TargetPosition) -else CancelMoveTo(NoObject) // 0x38 — retail same (0052a8bd) -``` - -Retail evaluates `!initialized` first and nests the self-target + status checks inside it; ACE -flipped to `if (Initialized)` first. Same reachable behavior; error codes verified identical -(0x37 initialized-path, 0x38 uninitialized-path — no transposition). - -### CheckProgressMade(currDistance) (`:668-688`) — retail 0x005290f0 ✓ - -``` -deltaTime = now - PreviousDistanceTime -if (deltaTime > 1.0): // 1-second window - diffDist = MovingAway ? curr - PreviousDistance : PreviousDistance - curr - if (diffDist / deltaTime < 0.25) return false // 0.25 m/s minimum closing rate - PreviousDistance = curr; PreviousDistanceTime = now - dOrigDist = MovingAway ? curr - OriginalDistance : OriginalDistance - curr - if (dOrigDist / (now - OriginalDistanceTime) < 0.25) return false -return true -``` - -### CancelMoveTo(retval) (`:690-699`) — retail 0x00529930 ✓ - -``` -if (MovementType == Invalid) return -PendingActions.Clear() // retail: DLList drain + delete -CleanUpAndCallWeenie(retval) -``` - -### CleanUp (`:701-719`) — retail 0x005295c0 ✓ - -``` -mp = new MovementParameters { HoldKeyToApply = MovementParams.HoldKeyToApply, CancelMoveTo = false } -if (PhysicsObj != null): - if (CurrentCommand != 0) _StopMotion(CurrentCommand, mp) - if (AuxCommand != 0) _StopMotion(AuxCommand, mp) - if (TopLevelObjectID != 0 && MovementType != Invalid) PhysicsObj.clear_target() -InitializeLocalVars() -``` - -### CleanUpAndCallWeenie(status) (`:721-729`) — retail 0x00529650 — ⚠ flag A5 - -``` -CleanUp() -if (PhysicsObj != null) PhysicsObj.StopCompletely(false) -WeenieObj.OnMoveComplete(status) // ⚠ ACE "server custom": retail body is CleanUp + StopCompletely ONLY - // (despite the function name, the 2013 client build has no weenie - // call here — compiled out / client-side no-op). - // ⚠ also: WeenieObj is NOT null-checked while PhysicsObj is → NPE if - // constructed via the parameterless ctor (flag A11). -``` - -### GetCurrentDistance (`:731-741`) — retail 0x005291b0 - -``` -if (PhysicsObj == null) return float.MaxValue -if (!UseSpheres flag) return Position.Distance(CurrentTargetPosition) -return Position.CylinderDistance(GetRadius(), GetHeight(), Position, - SoughtObjectRadius, SoughtObjectHeight, CurrentTargetPosition) -``` - -Distance is measured against **CurrentTargetPosition** in both variants; the sphere variant folds -in both bodies' radius/height (this is why ACE chase packets can carry `distance_to_object = 0.6` -and still stop at melee range). - -### HitGround (`:743-747`) — retail 0x00529d70 ✓ - -``` -if (MovementType != Invalid) BeginNextNode() -``` - -### UseTime (`:765-787`) — retail 0x0052a780 — ⚠ flag A12 (gate inversion) - -``` -if (PhysicsObj == null || !TransientState.Contact) return // retail: transient_state & 1 ✓ -if (PendingActions.Count == 0) return -if (TopLevelObjectID != 0 || MovementType != Invalid || Initialized): // ⚠ A12: retail gate is - head.Type == MoveToPosition → HandleMoveToPosition() // (top_level_object_id == 0 - head.Type == TurnToHeading → HandleTurnToHeading() // || movement_type == Invalid - // || initialized != 0) -``` - -Retail (0052a7b4): proceed when `topLevel == 0 || type == Invalid || initialized` — i.e. **skip -only while a MoveToObject/TurnToObject is still waiting for its first target resolution**. ACE -negated the first two clauses. In practice ACE is masked by the `PendingActions.Count == 0` early -return (nodes only exist after `*_Internal` ran, which also sets `Initialized`), but port the -retail condition verbatim — the masked edge (stale nodes surviving a CleanUp that doesn't clear -PendingActions) differs. - -### _DoMotion / _StopMotion (`:789-815`) — retail 0x00529010 / 0x00529080 ✓ - -``` -if (PhysicsObj == null) return NoPhysicsObject // 8 -minterp = PhysicsObj.get_minterp(); if null return NoMotionInterpreter // 0xB -minterp.adjust_motion(ref motion, ref movementParams.Speed, movementParams.HoldKeyToApply) -return minterp.DoInterpretedMotion(motion, movementParams) // _StopMotion → StopInterpretedMotion -``` - -The adjust-then-dispatch double (DoInterpretedMotion internally copies params and adjusts again — -`MotionInterp.cs:117-129`) **is retail-faithful**: retail's 0x00529057 calls -`CMotionInterp::adjust_motion` and `CMotionInterp::DoInterpretedMotion` also adjusts internally. -Do NOT "fix" the double adjust. - -### heading_diff(h1, h2, motion) (`:817-828`) — retail free fn 0x00528fb0 (x87-garbled) - -``` -result = h1 - h2 -if (|result| < EPSILON) result = 0 -if (result < -EPSILON) result += 360 -if (result > EPSILON && motion != TurnRight) result = 360 - result // mirror for TurnLeft -``` - -The BN pseudo-C for the retail fn is return-value garbled (float in ST0 rendered as a flags-word -int) and doesn't show the `motion != TurnRight` mirror — the R4 port should verify the mirror once -in Ghidra (`/decompile_function?address=0x00528fb0`), but ACE's shape is consistent with every call -site (BeginTurnToHeading passes TurnRight explicitly to get the raw clockwise diff). - -### heading_greater(h1, h2, motion) (`:830-858`) — retail free fn 0x00528f60 ✓ - -``` -diff = |h1 - h2| -if (diff <= 180): result = h1 > h2 -else: result = h2 > h1 -if (motion != TurnRight) result = !result -return result -``` - -ACE's rewrite verified equivalent to the retail comparison tree (including the equality case — -equal headings → false for TurnRight). The original decompiled expression is preserved in an ACE -comment at `:832-836`. - -### is_moving_to / stop_aux_command / small helpers (`:860-872`, `:306-314`, `:749-763`) - -``` -is_moving_to() => MovementType != Invalid -stop_aux_command(mp): if (AuxCommand != 0) { _StopMotion(AuxCommand, mp); AuxCommand = 0 } -AddMoveToPositionNode(): PendingActions.Add(node(MoveToPosition)) // retail node type 7 -AddTurnToHeadingNode(h): PendingActions.Add(node(TurnToHeading, h)) // retail node type 9 -RemovePendingActionsHead(): if (Count > 0) RemoveAt(0) -SetPhysicsObject / SetWeenieObject: plain assignments -``` - ---- - -## 2. ACE MovementParameters (`Physics/Animation/MovementParameters.cs`) - -Bool-per-flag class; `Flags` property round-trips through `MovementParamFlagsHelper` -(`MovementParamFlags.cs`): `CanWalk 0x1, CanRun 0x2, CanSidestep 0x4, CanWalkBackwards 0x8, -CanCharge 0x10, FailWalk 0x20, UseFinalHeading 0x40, Sticky 0x80, MoveAway 0x100, MoveTowards 0x200, -UseSpheres 0x400, SetHoldKey 0x800, Autonomous 0x1000, ModifyRawState 0x2000, -ModifyInterpretedState 0x4000, CancelMoveTo 0x8000, StopCompletely 0x10000, -DisableJumpDuringLink 0x20000`. Matches `acclient.h` bit assignments (cross-checked against -acdream `UpdateMotion.cs` wire comments). - -### Defaults (ctor, `:52-85`) — retail 0x00524380 — ⚠ flags A13, A14 - -| Field | ACE default | Retail default | -|---|---|---| -| flags | CanWalk+CanRun+CanSidestep+CanWalkBackwards+**CanCharge**+MoveTowards+UseSpheres+SetHoldKey+ModifyRawState+ModifyInterpretedState+CancelMoveTo+StopCompletely = **0x1EE1F** | **0x1EE0F** (CanCharge 0x10 CLEAR) — ⚠ A13 | -| DistanceToObject | 0.6f | 0.6f ✓ | -| FailDistance | float.MaxValue | FLT_MAX ✓ | -| MinDistance | 0 | 0 ✓ | -| Speed | 1.0f | 1.0f ✓ | -| WalkRunThreshold | **1.0f** (15.0f commented out) | **15.0f** — ⚠ A14 (ACE server tuning) | -| DesiredHeading | 0 | 0 ✓ | -| HoldKeyToApply | Invalid | Invalid ✓ | -| ContextID / ActionStamp | 0 | 0 ✓ | - -(Retail caches the default flag word in a static `set_moveto_flags::normal_bitfield` — one-time -compute of 0x1EE0F, 005243c1-005243e1.) - -### Copy constructors (`:90-150`) - -- `MovementParameters(mvp, onlyBits=false)` — copies Flags always, plus non-flag fields - (`CopyNonFlag`: DistanceToObject, FailDistance, DesiredHeading, MinDistance, Speed, - WalkRunThreshold, ContextID, HoldKeyToApply, ActionStamp) unless `onlyBits`. - Retail assigns field-by-field at each MoveTo*/TurnTo* site — full copy. Equivalent. -- `CopySome(mvp)` — used by MotionInterp.DoInterpretedMotion/StopMotion: copies the "capability + - behavior" flags and all non-flag fields EXCEPT FailWalk, UseFinalHeading, Sticky, MoveAway, - Autonomous, DisableJumpDuringLink, DesiredHeading, ContextID, ActionStamp. (ACE `TODO: review` — - interop with the R3 MotionInterp port, not an R4 concern per se.) -- `MovementParameters(MoveToParameters)` — wire-side ctor (server outbound); flags from the packed - dword + the six floats. Matches `MovementParameters::UnPackNet` 0x0052ac50 field order. - -### get_command(dist, heading, ref motion, ref holdKey, ref movingAway) (`:152-184`) — retail 0x0052aa00 — ⚠ flag A15 (CanCharge gate dropped) - -ACE: - -``` -if (MoveTowards || !MoveAway): - if (MoveAway): towards_and_away(dist, heading, ref motion, ref movingAway) - else: - if (dist > DistanceToObject) { motion = WalkForward; movingAway = false } else motion = 0 -else if (MoveAway): - if (dist < MinDistance) { motion = WalkForward; movingAway = true } else motion = 0 -if (CanRun && (!CanWalk || dist - DistanceToObject > WalkRunThreshold)) holdKey = Run -else holdKey = None -``` - -Retail (0052aa00, byte-1 flag reads = bits 0x100/0x200; verified decode): - -``` -if (MoveTowards): - if (MoveAway) towards_and_away(...) // both set → bidirectional band - else chase-only (dist > distance_to_object → WalkForward, away=0; else 0) -else if (!MoveAway): chase-only // neither set → still chase-only (matches ACE's OR) -else: flee-only (dist < min_distance → WalkForward, away=1; else 0) -// HoldKey (0052aa90-0052aab4): -if (flags & 0x10 /*CanCharge*/) → HoldKey_Run // ⚠ unconditional run fast-path -else if (!(flags & 0x2 /*CanRun*/)) → HoldKey_None -else if ((flags & 0x1 /*CanWalk*/) && dist - distance_to_object <= walk_run_threshhold) → HoldKey_None -else → HoldKey_Run -``` - -Movement-command selection matches ACE exactly. The hold-key selection does NOT: **retail's -CanCharge (0x10) bit short-circuits straight to HoldKey_Run** before any CanRun/CanWalk/threshold -logic; ACE dropped it entirely. (This is the already-documented #77 finding — -`memory/feedback_autowalk_cancharge_bit.md`; acdream's B.6 honors the wire CanCharge bit.) Note the -two ACE deviations A13 (default CanCharge=true) + A15 (gate removed) roughly cancel within ACE's -own server, but a verbatim port must take retail's version of BOTH. - -### towards_and_away(dist, heading, ref command, ref movingAway) (`:200-214`) — retail 0x0052a9a0 ✓ - -``` -if (dist > DistanceToObject) { command = WalkForward; movingAway = false } -else if (dist - MinDistance < EPSILON) { command = WalkBackwards; movingAway = true } // retail 0x45000006 ✓ -else command = 0 -``` - -(Note: `heading` param is unused in both ACE and retail.) - -### get_desired_heading(motion, movingAway) (`:186-198`) — retail 0x0052aad0 ✓ - -``` -RunForward / WalkForward → movingAway ? 180 : 0 -WalkBackwards → movingAway ? 0 : 180 -default → 0 -``` - ---- - -## 3. Call sites into MoveToManager (ACE) - -**MovementManager** (`Physics/Managers/MovementManager.cs`) — owns exactly one `MotionInterpreter` -+ one `MoveToManager`: - -- `PerformMovement(mvs)` (`:124-157`): `PhysicsObj.set_active(true)` (ACE-ism, server activity - tracking), then dispatch — types 1-5 → MotionInterp.PerformMovement (lazy-create + enter_default_state), - types 6-9 → MoveToManager.PerformMovement (lazy-create), default → `WeenieError.GeneralMovementFailure`. -- `CancelMoveTo(err)` (`:27`), `HandleUpdateTarget(targetInfo)` (`:60`), `UseTime()` (`:176`, - forwards to MoveToManager only), `HitGround()` (`:66-73`, forwards to BOTH MotionInterp.HitGround - and MoveToManager.HitGround), `IsMovingTo()` (`:97`), `MakeMoveToManager()` (`:112`), - `SetWeenieObject` (`:167`). - -**PhysicsObj** (`Physics/PhysicsObj.cs`) seams that drive the manager: - -- `UseTime` ticks: `:1729` + `:1791` (`MovementManager.UseTime()` from the per-tick update paths, - followed by `PositionManager.UseTime()`). -- `set_on_walkable` (`:3791-3814`): OnWalkable rising edge → `MovementManager.HitGround()`, falling - edge → `LeaveGround()`. Additional LeaveGround sites `:1243`, `:2242`, `:2911` (clear_transient_states - and friends). -- `cancel_moveto()` (`:2143-2147`): → `MovementManager.CancelMoveTo(ActionCancelled)`. Called from - MotionInterp on every user-motion (`DoInterpretedMotion :123-124`, `StopMotion :371-372`, - `StopCompletely :305`, `jump :714`, `move_to_interpreted_state :794`) — i.e. any fresh motion with - the CancelMoveTo param bit set kills the active MoveTo. -- `HandleUpdateTarget(targetInfo)` (`:637-644`): forwards to MovementManager AND PositionManager. -- Teleport: `:4036-4037` → `CancelMoveTo(WeenieError.ITeleported)`. -- `StopCompletely(bool)` (`:1546-1551`): builds `MovementStruct(StopCompletely)` → - MovementManager.PerformMovement → MotionInterp.StopCompletely (which itself calls cancel_moveto — - recursion is broken because CancelMoveTo no-ops when MovementType == Invalid, and CleanUp runs - InitializeLocalVars BEFORE the StopCompletely call in CleanUpAndCallWeenie). -- `set_target(ctx, objID, radius, quantum)` (`:3947`), `clear_target()` (`:2231`), - `get_target_quantum()` (`:2638`, returns `TargetManager.TargetInfo.Quantum` or 0), - `set_target_quantum(q)` (`:3955`), `unstick_from_object()` (`:4134` → PositionManager.Unstick). - -**TargetManager / TargetInfo** (`Physics/Managers/TargetManager.cs`, `Physics/Combat/TargetInfo.cs`): -`SetTarget` clears old target, registers this obj as a voyeur on the target -(`target.add_voyeur(ID, radius, quantum)`); target movement beyond `radius` from last-sent → -`SendVoyeurUpdate` → `voyeurObj.receive_target_update` → `TargetManager.ReceiveUpdate` → -`PhysicsObj.HandleUpdateTarget` → MoveToManager.HandleUpdateTarget. `TargetStatus`: -`Undefined=0, OK=1, ExitWorld=2, Teleported=3, Contained=4, Parented=5, TimedOut=6`. -`HandleTargetting` runs on a 0.5 s cadence with a 10 s Undefined→TimedOut timeout. This whole -subsystem is the "target re-tracking" acdream currently skips (server re-emits MoveTo ~1 Hz instead). - -**MotionInterp seams used by MoveToManager:** `adjust_motion(ref motion, ref speed, holdKey)` -(`MotionInterp.cs:394-428` — WalkBackwards→WalkForward·−BackwardsFactor, TurnLeft→TurnRight·−1, -SideStepLeft→SideStepRight·−1, sidestep scale, holdKey Invalid→RawState.CurrentHoldKey, Run→ -apply_run_to_command), `DoInterpretedMotion`, `StopInterpretedMotion`. These are R3-ported in -acdream's `MotionInterpreter` already. - ---- - -## 4. ACE-ism / oddity flags for the R4 retail cross-check - -Ranked. "RETAIL-VERIFIED" = I read the named decomp this session; anchors above. - -- **A1 — stale-member UseFinalHeading read in MoveToPosition** (`MoveToManager.cs:214`). - ACE reads `MovementParams.UseFinalHeading` (the PREVIOUS move's stored params — the new ones - aren't assigned until `:222`); retail 0052a33b reads the ARGUMENT's flag (`arg3->__inner0 & 0x40`). - RETAIL-VERIFIED divergence — exactly the R3 NPE/transposition class. Port the argument read. -- **A2 — InitializeLocalVars zeroes the wrong field** (`:68`). ACE: `MovementParams.DistanceToObject = 0`. - Retail 0052925b: `movement_params.__inner0 = 0` (the FLAGS word) + `context_id = 0`. - RETAIL-VERIFIED transposition. -- **A3 — InitializeLocalVars omits distance resets**. Retail 0052926c/0052928a also set - `previous_distance = original_distance = FLT_MAX`; ACE leaves them stale (masked because - BeginMoveForward reseeds them). RETAIL-VERIFIED. -- **A4 — TurnToHeading missing BeginNextNode** (`:284-304`). Retail 0052a76d calls - `BeginNextNode()` immediately after queuing the node; ACE returns without it (turn starts one - UseTime tick late, and an empty-queue Sticky/complete path can never fire from TurnToHeading). - RETAIL-VERIFIED control-flow divergence. -- **A5 — CleanUpAndCallWeenie weenie callback** (`:728`). `WeenieObj.OnMoveComplete(status)` is an - ACE server addition (drives the server's MoveToChain completion). Retail 0x00529650 is - CleanUp + StopCompletely only. Client-side R4 equivalent: this is where acdream's - `AutoWalkArrived`-style completion notification belongs architecturally — but retail does NOT - notify anything here. RETAIL-VERIFIED. -- **A6 — HandleMoveToPosition aligned-branch `set_heading` snap** (`:444-446`). ACE-only, self-labeled - "custom: sync for server ticrate". Retail only stops the aux command when within the ±20° band — - the residual alignment is finished by the normal per-tick turn integration. acdream runs at - client tick rates → do NOT port the snap. RETAIL-VERIFIED absent. -- **A7 — `AlwaysTurn` field** (`:35`, used `:520`). ACE-only override of the IsAnimating gate in - BeginTurnToHeading (server emote-turn convenience). No retail counterpart. RETAIL-VERIFIED absent. -- **A8 — HandleMoveToPosition `inRange` block** (`:463-473`). ACE-only ("custom for low monster - update rate"): when `!UseSpheres`, arrival also fires if `dist < 1.0 && PreviousDistance < dist` - (distance started increasing inside 1 m), plus an extra PreviousDistance/Time refresh outside - CheckProgressMade. Retail has neither. RETAIL-VERIFIED absent — do not port. -- **A9 — (non-flag) arrival stop order** — ACE pops the node, stops CurrentCommand, zeroes it, stops - aux, BeginNextNode — same order as retail 00529f94-00529fef. Listed only so nobody "fixes" it. -- **A10 — BeginTurnToHeading missing empty-head cancel**. Retail 00529bad treats - `pending_actions.head == null` the same as physics-obj-null → `CancelMoveTo(8)`; ACE would throw - on `PendingActions[0]` instead (unreachable through current callers, but port the retail guard). - RETAIL-VERIFIED. -- **A11 — CleanUpAndCallWeenie NPE asymmetry** (`:721-729`). `PhysicsObj` is null-checked, - `WeenieObj` is not — the parameterless `MoveToManager()` ctor leaves WeenieObj null → NPE on any - cancel. Same null-check-typo class the R3 pass caught in ACE. Irrelevant once A5 is resolved - client-side, but don't transcribe. -- **A12 — UseTime gate negation** (`:775`). ACE: `TopLevelObjectID != 0 || MovementType != Invalid || Initialized`. - Retail 0052a7b4: `top_level_object_id == 0 || movement_type == Invalid || initialized != 0`. - The first two clauses are NEGATED in ACE (masked by the empty-queue early-out; still, port retail's). - RETAIL-VERIFIED boundary inversion. -- **A13 — default CanCharge flag**. ACE ctor sets CanCharge=true (flags 0x1EE1F); retail default is - 0x1EE0F (CanCharge clear). RETAIL-VERIFIED. -- **A14 — Default_WalkRunThreshold = 1.0f** vs retail 15.0f (0x005243b5). ACE deliberately retuned - (15.0f left commented out at `MovementParameters.cs:49-50`). acdream already treats 15 m as the - retail/wire default (#77 work). RETAIL-VERIFIED. -- **A15 — get_command drops the CanCharge fast-path**. Retail 0052aa95: `flags & 0x10` → - unconditional `HoldKey_Run` BEFORE the CanRun/CanWalk/threshold cascade; ACE has only the cascade. - A13+A15 cancel inside ACE's ecosystem; a verbatim port needs retail's version of both. - RETAIL-VERIFIED (and consistent with `memory/feedback_autowalk_cancharge_bit.md`). -- **A16 — server-tick constants to re-check at 30 Hz.** ACE runs MoveToManager on its own cadence; - the constants (20°/340° aux band, 1.0 s / 0.25 m/s progress, 0.1 velocity floor, 1.0 quantum - hysteresis) are retail-identical (verified), so no rescaling is needed — but the *feel* of the - aux-turn band differs with tick rate; retail integrates at 30 Hz physics ticks. - -### BN-artifact warnings for the R4 decomp pass (do NOT trust the literal pseudo-C) - -- `HandleMoveToPosition` chase-arrival branch (00529f8f `test ah,0x41`) renders INVERTED — the flee - branch (explicit bit math) + ACE + two prior research passes fix the truth: chase arrives at - `dist <= distance_to_object`. See §1 HandleMoveToPosition note. -- `BeginTurnToHeading` turn-direction branches (00529bf4/00529c23 `test ah,0x41`/`0x5`) — senses - garbled; ACE's ≤180→TurnRight reading is correct. -- `heading_diff` (0x00528fb0) — x87 return garble; the `motion != TurnRight → 360 - result` mirror - is invisible in the pseudo-C. Verify once via Ghidra MCP before porting. -- `get_command`'s first flag read is BN-rendered as a bogus `(int16_t)` pointer deref of the flags - word — it's just `flags & 0x200` / `& 0x100` on byte 1. - -### Negative results (look like ACE-isms, are retail-faithful) - -- **FailProgressCount is vestigial in RETAIL too** — grep shows only writes (reset/increment) at - 005292b1 / 0052a014 / 00529f3e / 0052a13a / 0052a1e5 / 0052a224; no threshold read anywhere. - Do not invent a cancel-after-N-failures mechanism. -- **Double adjust_motion** (_DoMotion + DoInterpretedMotion-internal) — retail does the same. -- **TurnToObject desired-heading clobber** (write CurrentTargetPosition → Internal overwrites and - reads SoughtPosition) — retail quirk, verified against the PDB struct layout; port verbatim. -- **BeginTurnToHeading storing a DIFF into PreviousHeading** — retail 00529c82 does the same. -- **PerformMovement returning success unconditionally** — retail returns 0 too. -- **Error codes all line up** (no ordinal traps): NoPhysicsObject=0x8, NoMotionInterpreter=0xB, - ActionCancelled=0x36, ObjectGone=0x37, NoObject=0x38, YouChargedTooFar=0x3D — verified against - both retail immediates and ACE's WeenieError enum. - ---- - -## 5. acdream R4 blast radius — what exists today - -All paths worktree-relative (`.claude/worktrees/vigorous-joliot-f0c3ad`). - -### `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (341 lines, static) - -Per-tick steering for **server-controlled remotes** during mt 6/7. Approximates -`HandleMoveToPosition`'s aux-turn + arrival only: - -- `Drive(bodyPos, bodyOrient, destWorld, minDistance, distanceToObject, dt, moveTowards, out newOrient)` - (`:170-254`): horizontal distance; arrival `moveTowards ? dist <= distanceToObject + ε : dist >= minDistance − ε` - (`ArrivalEpsilon = 0.05` — acdream fudge, not retail); heading via quaternion yaw; **±20° snap-to-heading** - (`HeadingSnapToleranceRad`, borrowed from ACE's A6 custom snap — retail has no snap in the ≤20° band); - otherwise rotate at `TurnRateRadPerSec = π/2`. -- Constants: `RunTurnFactor = 1.5` (retail run_turn_factor @ 0x007c8914), `BaseTurnRateRadPerSec = π/2`, - `TurnRateFor(running)`, `StaleDestinationSeconds = 1.5` (acdream-only staleness guard — replaces - retail's TargetManager re-tracking), `ClampApproachVelocity` (`:296-329`, acdream-only overshoot clamp), - `OriginToWorld` (`:263-277`, landblock-local → render world). -- ⚠ Its class-level doc (`:53-57`) still claims "ACE swaps the chase/flee arrival predicates vs - retail" — WRONG, contradicted by its own later comment (`:186-199`) and by this document. Fix the - doc comment during R4. -- What it deliberately skips (doc `:44-51`): target re-tracking, Sticky/StickTo, fail-distance + - progress detector, cylinder-sphere distance — the R4 port's main new surface. - -### `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (88 lines, static) - -Chooses the visible cycle for remotes on mt 6/7 (which carry no ForwardCommand): -`PlanMoveToStart(moveToSpeed, runRate, canRun)` ≈ `BeginMoveForward → get_command → adjust_motion` -collapsed into "RunForward @ speed·runRate if canRun else WalkForward" — no distance/threshold logic -at all (no DistanceToObject, no WalkRunThreshold, no CanCharge). `PlanFromVelocity` (fallback from -observed velocity; `StopSpeed=0.20`, `RunThreshold=1.25` — acdream heuristics, not retail). -R4 replaces this seeding with real `MovementParameters.get_command`. - -### `src/AcDream.App/Input/PlayerMovementController.cs` — B.6 auto-walk block (KEEP-LIST until R4) - -- Fields `:264-296`: `_autoWalkActive/_autoWalkDestination/_autoWalkMinDistance/ - _autoWalkDistanceToObject/_autoWalkMoveTowards/_autoWalkInitiallyRunning`; turn-direction tracker - `_autoWalkTurnDirectionThisFrame` (`:323`); `IsServerAutoWalking` (`:308`); `AutoWalkArrived` event - (`:340`, host re-sends the Use/PickUp action on natural arrival — ACE MoveToChain timeout workaround). -- `BeginServerAutoWalk(destWorld, minDistance, distanceToObject, moveTowards, canCharge)` (`:452-487`): - installs state; walk-vs-run is a ONE-SHOT decision from the wire CanCharge bit (#77 fix — matches - retail A15 semantics relayed by ACE's server-side `Creature.SetWalkRunThreshold`). -- `EndServerAutoWalk(reason)` (`:495-503`): idempotent; fires `AutoWalkArrived` on `reason=="arrived"`. -- `DriveServerAutoWalk(dt, input)` (`:567-790`) — the local-player approximation of - HandleMoveToPosition + Begin/HandleTurnToHeading collapsed into one function: user-input cancel; - horizontal distance; arrival `moveTowards ? dist <= distanceToObject : dist >= minDistance + ε` - **gated on 5° alignment** (acdream-only: retail fires arrival purely on distance; the alignment - gate exists because ACE rotates server-side before the Use callback); continuous turn at - `TurnRateFor(_autoWalkInitiallyRunning)` with no snap (deliberately retail-er than - RemoteMoveToDriver); 30° walk-while-turning band (acdream-only — retail's equivalent is the - discrete TurnToHeading node THEN MoveToPosition node, plus the 20° aux band while moving); - turn-in-place phase issues `DoMotion(Ready)` + zeroes horizontal velocity; forward phase issues - `DoMotion(WalkForward, runRate|1.0)` + `set_local_velocity` from `get_state_velocity`. - R4 replaces this whole function with real pending-node MoveToManager driving. -- Call site: `Update` `:896` (`autoWalkConsumedMotion` skips the user-input motion/velocity blocks); - `:1028` and `:1528` carry R4 KEEP-LIST notes (auto-walk owns motion until R4 cutover). - -### `src/AcDream.App/Rendering/GameWindow.cs` — MoveTo packet handling - -- Inbound UM seeding `:4401-4435`: when `update.MotionState.IsServerControlledMoveTo` and - ForwardCommand absent → `ServerControlledLocomotion.PlanMoveToStart(MoveToSpeed, MoveToRunRate, - MoveToCanRun)` provides `fullMotion/speedMod` for the animation funnel. -- Local player `:4507-4547`: mt 6/7 with `MoveToPath` payload → `OriginToWorld` → - `_playerController.BeginServerAutoWalk(dest, MinDistance, DistanceToObject, MoveTowards, CanCharge)`. - Deliberately does NOT cancel on the companion mt 0x00 InterpretedMotionState echo (`:4534-4546`, - trace-verified ACE behavior). -- Remotes `:4570-4610`: `remoteMot.ServerMoveToActive = IsServerControlledMoveTo`; path payload → - `MoveToDestinationWorld/MinDistance/DistanceToObject/MoveTowards/HasMoveToDestination/ - LastMoveToPacketTime`; non-MoveTo UM clears `HasMoveToDestination`. -- Remote per-tick `:9594-9682`: staleness guard (1.5 s) → else `RemoteMoveToDriver.Drive` → - orientation write; Arrived → zero velocity; Steering → `rm.Motion.apply_current_movement` + - `ClampApproachVelocity`. MoveTo-active-but-no-path → hold velocity zero (882a07c stance). -- Speculative install `:12122-12164`: `InstallSpeculativeTurnToTarget` — local prediction of ACE's - MoveToObject before the wire packet arrives; predicts CanCharge via the 7.5 m rule - (`AceCanChargeDistance = WalkRunThreshold/2`); per-type use radius (0.6 default / 3.0 creature / - 2.0 door-lifestone-portal-corpse). -- `:4942`: second `PlanMoveToStart` consumer (same seeding on another path — CreateObject-time). - -### Wire layer - -- `src/AcDream.Core.Net/Messages/UpdateMotion.cs`: parses mt 6/7 union → `CreateObject.MoveToPathData` - (`:157`, `:287-344`); exposes `IsServerControlledMoveTo` (mt is 6 or 7), `MoveToCanRun` - (params bit 0x2), `MoveTowards` (0x200), `CanCharge` (0x10), `MoveToSpeed`, `MoveToRunRate`. -- `src/AcDream.Core.Net/Messages/CreateObject.cs:292-302`: `MoveToPathData(TargetGuid, OriginCellId, - OriginX/Y/Z, DistanceToObject, MinDistance, FailDistance, WalkRunThreshold, DesiredHeading)` — - the full retail `MovementParameters` float block is already captured on the wire; FailDistance and - WalkRunThreshold and DesiredHeading are parsed but currently UNUSED by any driver (R4 consumers). -- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`: `ProbeAutoWalkEnabled` (`ACDREAM_PROBE_AUTOWALK`) - gates `[autowalk-mt]/[autowalk-begin]/[autowalk-end]` traces. - -### What R4 must reconcile - -Today there are **three independent approximations** of one retail mechanism: DriveServerAutoWalk -(local player), RemoteMoveToDriver.Drive (remotes), ServerControlledLocomotion.PlanMoveToStart -(animation seed). Retail runs ONE MoveToManager per CPhysicsObj (player and remotes identical), -driven by pending nodes + UseTime, issuing motions through CMotionInterp (already R3-ported as -`MotionInterpreter`). The wire already delivers every field the retail manager needs -(MoveToPathData + params bits). Missing retail apparatus: pending-node queue, TurnToHeading/ -MoveToPosition node alternation, get_command/towards_and_away, the aux-turn band, CheckProgressMade + -fail-distance cancel, HitGround → BeginNextNode, Sticky/StickTo (position manager), and the -TargetManager re-tracking loop (acdream substitutes ACE's ~1 Hz MoveTo re-emit + the 1.5 s staleness -guard — decide in R4 whether that substitution stays as a documented divergence-register row). diff --git a/docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md b/docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md deleted file mode 100644 index d7c0527b..00000000 --- a/docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md +++ /dev/null @@ -1,1232 +0,0 @@ -# R4 — MoveToManager: verbatim decomp extraction - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (line numbers = file -line numbers, not addresses). Structs: `docs/research/named-retail/acclient.h`. -Style/pin conventions follow `docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md`; -the MovementParameters bit masks are the R3 **A4 pin** (`W0-pins.md`) and are used as-is here. -ACE cross-ref: `references/ACE/Source/ACE.Server/Physics/Managers/MoveToManager.cs` -(interpretation aid only; decomp wins). StickyManager / ConstraintManager bodies are -**out of scope (R5)** — only their call shapes from MoveToManager appear here. - -Coverage: every `MoveToManager::` member in the raw (grep enumerated 33 members, all -extracted below), the MovementManager relay/dispatch, the MovementParameters -command-selection family, and the CPhysicsObj-side entry points. - ---- - -## 0. Key structs (acclient.h) - -```c -/* acclient.h:31473 — struct #3462 */ -struct __cppobj __declspec(align(8)) MoveToManager -{ - MovementTypes::Type movement_type; // +0x00 - Position sought_position; // +0x04 (Position = vtable+objcell_id+Frame = 0x48) - Position current_target_position; // +0x4C - Position starting_position; // +0x94 - MovementParameters movement_params; // +0xDC (bitfield +0xE0, distance_to_object +0xE4, - // min_distance +0xE8, desired_heading +0xEC, - // speed +0xF0, fail_distance +0xF4, - // walk_run_threshhold +0xF8) - float previous_heading; // +0x108 - float previous_distance; // +0x10C - long double previous_distance_time; // +0x110 - float original_distance; // +0x118 - long double original_distance_time; // +0x120 - unsigned int fail_progress_count; // +0x128 - unsigned int sought_object_id; // +0x12C - unsigned int top_level_object_id; // +0x130 - float sought_object_radius; // +0x134 - float sought_object_height; // +0x138 - unsigned int current_command; // +0x13C - unsigned int aux_command; // +0x140 - int moving_away; // +0x144 - int initialized; // +0x148 - DLList pending_actions; // +0x14C head, +0x150 tail - CPhysicsObj *physics_obj; // +0x154 (confirmed: Create writes +0x154) - CWeenieObject *weenie_obj; // +0x158 (confirmed: Create writes +0x158) -}; // sizeof = 0x160 (operator new(0x160) in Create) - -/* acclient.h:57702 — struct #6350; alloc sites use operator new(0x10) */ -struct __cppobj MoveToManager::MovementNode : DLListData -{ // +0 dllist_next, +4 dllist_prev (DLListData) - MovementTypes::Type type; // +8 — only 7 (MoveToPosition) and 9 (TurnToHeading) ever queued - float heading; // +0xc — only meaningful for type 9 -}; - -/* acclient.h:2856 — enum #229 */ -enum MovementTypes::Type { Invalid=0, RawCommand=1, InterpretedCommand=2, - StopRawCommand=3, StopInterpretedCommand=4, StopCompletely=5, - MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9 }; - -/* acclient.h:38069 — struct #4067 */ -struct __cppobj MovementStruct -{ - MovementTypes::Type type; - unsigned int motion; // types 1-4 only - unsigned int object_id; // types 6, 8 - unsigned int top_level_id; // types 6, 8 - Position pos; // type 7 - float radius; // type 6 - float height; // type 6 - MovementParameters *params; // types 1-4, 6-9 -}; - -/* acclient.h:31453 + bitfield 31423-31443 — the A4 pin masks apply: - 0x1 can_walk / 0x2 can_run / 0x4 can_sidestep / 0x8 can_walk_backwards / - 0x10 can_charge / 0x20 fail_walk / 0x40 use_final_heading / 0x80 sticky / - 0x100 move_away / 0x200 move_towards / 0x400 use_spheres / 0x800 set_hold_key / - 0x1000 autonomous / 0x2000 modify_raw_state / 0x4000 modify_interpreted_state / - 0x8000 cancel_moveto / 0x10000 stop_completely / 0x20000 disable_jump_during_link. - Ctor default 0x1EE0F; distance_to_object=0.6, min_distance=0, fail_distance=FLT_MAX, - desired_heading=0, speed=1, walk_run_threshhold=15, hold_key=HoldKey_Invalid. */ - -/* acclient.h:31591 — struct #3482 */ -struct __cppobj TargetInfo -{ - unsigned int context_id; - unsigned int object_id; - float radius; - long double quantum; - Position target_position; - Position interpolated_position; - AC1Legacy::Vector3 interpolated_heading; - AC1Legacy::Vector3 velocity; - TargetStatus status; - long double last_update_time; -}; - -/* acclient.h:7264 */ -enum TargetStatus { Undef=0, Ok_TargetStatus=1, ExitWorld=2, Teleported=3, - Contained=4, Parented=5, TimedOut=6 }; - -/* acclient.h:3394 */ -enum HoldKey { HoldKey_Invalid=0, HoldKey_None=1, HoldKey_Run=2 }; -``` - -Motion-command literals used throughout (cross-ref ACE `MotionCommand`): -`0x45000005` WalkForward, `0x45000006` WalkBackwards, `0x44000007` RunForward, -`0x6500000d` TurnRight, `0x6500000e` TurnLeft. - ---- - -## 1. Creation / lifecycle - -### 1a. `MoveToManager::MoveToManager` — `005293b0` @ 306554-306593 - -Verbatim (member-init noise elided): default-constructs `sought_position`, -`current_target_position`, `starting_position` (identity Frame, objcell_id 0, -`Frame::cache` each), `MovementParameters::MovementParameters(&this->movement_params)`, -`pending_actions.head_ = 0; tail_ = 0; physics_obj = 0; weenie_obj = 0;` then -`MoveToManager::InitializeLocalVariables(this)` (@306592). - -### 1b. `MoveToManager::Create` — `00529470` @ 306597-306614 - -```c -00529475 void* eax = operator new(0x160); -0052947f if (eax == 0) return 0; -00529483 void* result = MoveToManager::MoveToManager(eax); -0052948a if (result != 0) { -00529494 *(uint32_t*)((char*)result + 0x154) = arg1; // physics_obj -0052949a *(uint32_t*)((char*)result + 0x158) = arg2; // weenie_obj -0052948a } -005294a0 return result; -``` - -Note: unlike `MovementManager::Create`, this writes the pointers DIRECTLY (no -`SetPhysicsObject`/`SetWeenieObject` virtual-ish path). - -### 1c. `MoveToManager::InitializeLocalVariables` — `00529250` @ 306490-306534 - -```c -00529259 this->movement_type = 0; // Invalid -0052925b this->movement_params.__inner0 = 0; // bitfield CLEARED (not re-defaulted!) -00529261 this->movement_params.context_id = 0; -0052926c this->previous_distance = 3.40282347e+38f; // FLT_MAX -0052927e this->previous_distance_time = Timer::cur_time; // [BN renders the 8-byte double copy - // as two 32-bit halves @306497-306498] -0052928a this->original_distance = 3.40282347e+38f; -005292a5 this->original_distance_time = Timer::cur_time; -005292ab this->previous_heading = 0f; -005292b1 this->fail_progress_count = 0; -005292b7 this->current_command = 0; -005292bd this->aux_command = 0; -005292c3 this->moving_away = 0; -005292c9 this->initialized = 0; - /* @306508-306529: sought_position and current_target_position reset to - objcell_id 0 + identity Frame (starting_position NOT reset) */ -0052935d this->sought_object_id = 0; -00529363 this->top_level_object_id = 0; -00529369 this->sought_object_radius = 0f; -0052936f this->sought_object_height = 0f; -``` - -Cleaned intent: full soft-reset. Only `movement_params.bitfield` + `context_id` are -zeroed of the params (the float fields keep stale values — every entry point copies -all ten fields anyway). `pending_actions` is NOT drained here (callers drain first). - -### 1d. `MoveToManager::Destroy` — `005294b0` @ 306618-306663 - -Drains `pending_actions` with the inlined DLList unlink+`operator delete` loop -(@306621-306659, byte-identical in shape to the drain in `CancelMoveTo` §7c), then -tailcalls `InitializeLocalVariables` (@306662). - -### 1e. `MoveToManager::~MoveToManager` — `005299d0` @ 306945-306953 - -`Destroy(this)` then repoints the four Position/PackObj vtables (`0x79285c`) — pure -dtor bookkeeping. - -### 1f. `SetWeenieObject` `00529230` @ 306474-306478 / `SetPhysicsObject` `00529240` @ 306482-306486 - -Plain field stores. **No side effects** (unlike CMotionInterp's versions, which -re-dispatch movement — R3 A3). - -### 1g. `MoveToManager::is_moving_to` — `00529220` @ 306464-306470 - -```c -00529226 result = this->movement_type != Invalid; -``` - ---- - -## 2. MovementManager relay layer - -### 2a. `MovementManager::MakeMoveToManager` — `00524000` @ 300124-300129 - -```c -00524008 if (this->moveto_manager == 0) -0052401a this->moveto_manager = MoveToManager::Create(this->physics_obj, this->weenie_obj); -``` - -### 2b. `MovementManager::PerformMovement` — `005240d0` @ 300194-300237 - -```c -005240d9 CPhysicsObj::set_active(this->physics_obj, 1); -005240e4 void* eax_1 = (arg2->type - 1); -005240e8 if (eax_1 > 8) return 0x47; -005240f1 switch (eax_1) { - case 0..4: // RawCommand, InterpretedCommand, StopRawCommand, - // StopInterpretedCommand, StopCompletely -005240fb if (this->motion_interpreter == 0) { -00524105 eax_3 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); -00524112 this->motion_interpreter = eax_3; -00524114 if (physics_obj != 0) CMotionInterp::enter_default_state(eax_3); -005240fb } -00524127 return CMotionInterp::PerformMovement(this->motion_interpreter, arg2); - case 5..8: // MoveToObject(6), MoveToPosition(7), TurnToObject(8), TurnToHeading(9) -0052412f if (this->moveto_manager == 0) -00524141 this->moveto_manager = MoveToManager::Create(this->physics_obj, this->weenie_obj); -00524148 MoveToManager::PerformMovement(this->moveto_manager, arg2); -0052414f return 0; - } -``` - -The lookup table @300244-300255 confirms the 1-5 → interp / 6-9 → moveto split. -Type 0 (Invalid) and >9 return `0x47`. **Moveto types always return 0** — errors -surface via `CancelMoveTo`, not the return value. - -### 2c. `MovementManager::CancelMoveTo` — `005241b0` @ 300277-300288 - -```c -005241b0 class MoveToManager* moveto_manager = this->moveto_manager; -005241b5 if (moveto_manager == 0) return; -005241b7 uint32_t edx; -005241b7 return MoveToManager::CancelMoveTo(moveto_manager, edx); // [BN uninit-edx relay - // artifact — A7 family; - // arg2 (WeenieError) is - // passed through] -``` - -### 2d. Per-tick / event forwarders - -- `MovementManager::UseTime` `005242f0` @ 300411-300421: `if (moveto_manager) tailcall MoveToManager::UseTime`. **CMotionInterp is NOT ticked here** — UseTime is moveto-only. -- `MovementManager::HitGround` `00524300` @ 300425-300440: `minterp->HitGround()` then `tailcall MoveToManager::HitGround(moveto_manager)`. -- `MovementManager::HandleUpdateTarget` `00524790` @ 300723-300730: `if (moveto_manager) MoveToManager::HandleUpdateTarget(moveto_manager, &arg2)`. **Confirms the R3 negative result: HandleUpdateTarget lives on MoveToManager** (§6 below). -- `MovementManager::IsMovingTo` `00524260` @ 300352-300361: `moveto_manager && MoveToManager::is_moving_to(...)`. -- `MovementManager::Destroy` `005243f0` @ 300538-300559: deletes both minterp (`~CMotionInterp` + `operator delete`) and moveto (`~MoveToManager` + `operator delete`). -- `MovementManager::SetWeenieObject` `00524020` @ 300133-300146: forwards to both members. - -### 2e. ⚠ COMDAT-folded no-ops (BN symbol-collision mislabels) - -Three forwarders tailcall `IDClass<_tagDataID,32,0>::~IDClass` — the TRIVIAL EMPTY -function; the linker's identical-COMDAT folding collapsed several empty methods into -one body and the PDB picked the IDClass dtor as the canonical name: - -- `MovementManager::LeaveGround` `00524320` @ 300444-300459: `minterp->LeaveGround()` then "tailcall IDClass dtor(moveto_manager)" → **`MoveToManager::LeaveGround` is a no-op in this build.** -- `MovementManager::ReportExhaustion` `00524360` @ 300491-300506: `minterp->ReportExhaustion()` then the same folded no-op on moveto_manager → **`MoveToManager::ReportExhaustion` is a no-op.** -- `MovementManager::HandleEnterWorld` `00524340` @ 300463-300473: folded no-op on motion_interpreter → **`CMotionInterp::HandleEnterWorld` is a no-op** (HandleExitWorld @300477-300487 calls the real `CMotionInterp::HandleExitWorld`). - -Port: MoveToManager needs no LeaveGround/ReportExhaustion members; do not invent -behavior for them. - -### 2f. `MovementManager::unpack_movement` — `00524440` @ 300563-300719 (the wire entry) - -The client-side parse of the server movement event. Head (@300566-300598): requires -`motion_interpreter != 0 && physics_obj != 0`; then -`CPhysicsObj::interrupt_current_movement` + `CPhysicsObj::unstick_from_object`; -reads uint16 `type` header + uint16 style index; `ecx_5 = command_ids[style]`; if the -minterp's current style differs (@300597 — `CBaseFilter::GetPinVersion(minterp)` is a -**mislabeled trivial getter** of the interp's current style, symbol-collision artifact) -→ `CMotionInterp::DoMotion(minterp, style, &defaultParams)`. Then per type: - -```c -case 0: // full InterpretedMotionState -00524551 InterpretedMotionState::UnPack(&var_28, arg2, arg3); -0052455d if (header & 0x100) sticky_object_guid = read_dword(); // [flag bit via var_a4 byte1&1] -0052457c MovementManager::move_to_interpreted_state(this, &var_28); -00524583 if (sticky_object_guid) CPhysicsObj::stick_to_object(this->physics_obj, guid); -0052458e this->motion_interpreter->standing_longjump = (header & 0x200); -case 6: // MoveToObject -005245b3 MovementManager::MakeMoveToManager(this); -005245ba object_id = read_dword(); -005245ce Position::UnPackOrigin(&var_70, ...); // target origin -005245db MovementParameters::UnPackNet(&var_9c, MoveToObject, ...); -005245e9 this->motion_interpreter->my_run_rate = read_float(); -005245f9 if (CPhysicsObj::GetObjectA(object_id) == 0) goto label_524668; // object not - // resolvable → -00524604 CPhysicsObj::MoveToObject(this->physics_obj, object_id, &var_9c); // else full path -case 7: // MoveToPosition -00524629 MakeMoveToManager; UnPackOrigin(&var_70); UnPackNet(MoveToPosition); my_run_rate = read_float(); -00524668 label_524668: -00524668 MoveToManager::MoveToPosition(this->moveto_manager, &var_70, &var_9c); - // ^ the case-6 fallback DEGRADES MoveToObject to MoveToPosition(wire origin) -case 8: // TurnToObject -0052468d MakeMoveToManager; object_id = read_dword(); wire_heading = read_dword(); -005246b5 UnPackNet(&var_9c, TurnToObject, ...); -005246c5 if (GetObjectA(object_id) == 0) { var_9c.desired_heading = wire_heading; // @300676: - goto label_524725; } // fallback → TurnToHeading -005246d0 CPhysicsObj::TurnToObject(this->physics_obj, object_id, &var_9c); -case 9: // TurnToHeading -00524704 MakeMoveToManager; UnPackNet(&var_9c, TurnToHeading, ...); -00524725 label_524725: -00524725 MoveToManager::TurnToHeading(this->moveto_manager, &var_9c); -``` - -(Return 1 on every handled case, 0 otherwise; jump table @300707-300719.) - -### 2g. `MovementParameters::UnPackNet` — `0052ac50` @ 308118-308205 (wire field order) - -- MoveToObject / MoveToPosition (0x1c bytes, 7 dwords): `bitfield, distance_to_object, - min_distance, fail_distance, speed, walk_run_threshhold, desired_heading`. -- TurnToObject / TurnToHeading (0xc bytes, 3 dwords): `bitfield, speed, desired_heading`. -- Types outside 6-9 → return 0. **The wire bitfield carries can_charge 0x10** — this is - the walk-vs-run answer consumed in §5c (cross-ref `feedback_autowalk_cancharge_bit`). - -(`Pack`/`UnPack` @308037-308114 are the full 0x28-byte 10-field forms — dat/persist -serialization, order: bitfield, distance_to_object, min_distance, fail_distance, -desired_heading, speed, walk_run_threshhold, context_id, hold_key, action_stamp.) - ---- - -## 3. Entry points (movement_type setters) - -### 3a. `MoveToManager::PerformMovement` — `0052a900` @ 307871-307904 - -```c -0052a901 int32_t var_8 = 0x36; // WeenieError.ActionCancelled arg setup -0052a905 MoveToManager::CancelMoveTo(this, edx); // [uninit-edx artifact; 0x36 is the arg] -0052a910 CPhysicsObj::unstick_from_object(this->physics_obj); // → PositionManager::UnStick -0052a923 switch (arg2->type - 6) { -0052a940 case 0: MoveToManager::MoveToObject(this, arg2->object_id, arg2->top_level_id, - arg2->radius, arg2->height, arg2->params); break; -0052a955 case 1: MoveToManager::MoveToPosition(this, &arg2->pos, arg2->params); break; -0052a96e case 2: MoveToManager::TurnToObject(this, arg2->object_id, arg2->top_level_id, - arg2->params); break; -0052a97f case 3: MoveToManager::TurnToHeading(this, arg2->params); break; - } -0052a987 return 0; -``` - -Every new moveto CANCELS the previous one (0x36) and UNSTICKS first. - -### 3b. `MoveToManager::MoveToObject` — `00529680` @ 306756-306817 - -```c -0052968e if (physics_obj != 0) { -005296cc CPhysicsObj::StopCompletely(physics_obj, edx); -005296e0 this->starting_position = physics_obj->m_position; // objcell + Frame copy -005296fe this->sought_object_id = arg2; -00529708 this->sought_object_radius = arg4; -00529712 this->sought_object_height = arg5; -00529718 this->movement_type = 6; // MoveToObject -0052971e this->top_level_object_id = arg3; -00529727-306783 this->movement_params = *arg6; // all 10 fields copied individually -00529784 this->initialized = 0; -00529791 if (arg3 == physics_obj->id) { // targeting SELF -00529795 MoveToManager::CleanUp(this); -0052979a if (physics_obj) CPhysicsObj::StopCompletely(physics_obj, edx); // label_52979a -00529791 } else { -005297c4 CPhysicsObj::set_target(physics_obj, 0, this->top_level_object_id, 0.5f, 0f); - // (context_id=0, target=top_level, radius=0.5, quantum=0.0) - } } - // physics_obj == 0 path (@306798-306807): dead frame-cache noise, falls into - // label_52979a (StopCompletely if obj appeared) — effectively a no-op guard. -``` - -**No nodes are queued yet** — object moves are deferred until the first -`HandleUpdateTarget` callback delivers the target's position (§6). Note the wire's -`MovementParameters.sticky` (0x80) is preserved here (unlike position moves, §3c). - -### 3c. `MoveToManager::MoveToPosition` — `0052a240` @ 307521-307593 - -```c -0052a24e if (physics_obj_1 != 0) { -0052a259 CPhysicsObj::StopCompletely(physics_obj_1, edx); -0052a26c this->current_target_position = *arg2; -0052a276 this->sought_object_radius = 0f; -0052a280 MoveToManager::GetCurrentDistance(this); // → curr_distance (x87) -0052a292 heading_diff = Position::heading(&myPos, arg2) - CPhysicsObj::get_heading(); -0052a2ba if (fabs(heading_diff) < 0.000199999995f) heading_diff = 0; // @307547 -0052a2d5 if (heading_diff < -0.000199999995f) heading_diff += 360f; // @307554 -0052a304 MovementParameters::get_command(arg3, curr_distance, heading_diff, - &cmd, &holdKey, &movingAway); -0052a30f if (cmd != 0) { -0052a31b MoveToManager::AddTurnToHeadingNode(this, Position::heading(&myPos, arg2)); -0052a332 MoveToManager::AddMoveToPositionNode(this); -0052a30f } -0052a33b if ((arg3->bitfield & 0x40) != 0) // use_final_heading -0052a343 MoveToManager::AddTurnToHeadingNode(this, arg3->desired_heading); -0052a34f this->sought_position = *arg2; -0052a366 this->starting_position = physics_obj->m_position; -0052a378 this->movement_type = 7; // MoveToPosition -0052a381-307589 this->movement_params = *arg3; // 10-field copy -0052a3e5 this->movement_params.__inner0 &= 0xffffff7f; // CLEAR sticky (0x80) -0052a3eb MoveToManager::BeginNextNode(this); -``` - -Node plan: `[TurnToHeading(face target)] → [MoveToPosition] → [TurnToHeading(final)]?`. -If `get_command` says no motion needed (already inside distance_to_object), NO -move nodes are queued — only the optional final-heading turn. Position moves and -heading turns **force sticky off**; only object moves can stick (§4c). - -### 3d. `MoveToManager::TurnToObject` — `005297d0` @ 306820-306882 - -```c -005297de if (physics_obj != 0) { -0052984c if ((arg4->bitfield & 0x10000) != 0) // stop_completely -00529850 CPhysicsObj::StopCompletely(physics_obj, edx); -00529859 this->movement_type = 8; // TurnToObject -0052985f this->sought_object_id = arg2; -0052986c Frame::set_heading(&this->current_target_position.frame, arg4->desired_heading); - // ^ seeds CURRENT_TARGET's heading — but TurnToObject_Internal later - // OVERWRITES current_target_position wholesale and reads - // SOUGHT_position's heading (§6c). ACE MoveToManager.cs:246 matches - // this verbatim (also writes CurrentTargetPosition) — retail quirk, - // NOT a BN artifact: desired_heading is effectively DISCARDED for - // TurnToObject; the final heading = heading-to-target + sought.frame - // heading (0 after InitializeLocalVariables). -00529875 this->top_level_object_id = arg3; -0052987e-306847 this->movement_params = *arg4; // 10-field copy -005298d5 if (arg3 == physics_obj->id) { CleanUp; StopCompletely; } // self-target - else { -0052990c this->initialized = 0; -00529916 CPhysicsObj::set_target(physics_obj_1, 0, arg3, 0.5f, 0f); - } } - // physics_obj == 0 path @306862-306872: stores context_id only + dead - // frame noise, then StopCompletely-if-obj (label_5298e8). -``` - -Deferred like MoveToObject; `stop_completely` (0x10000) is honored HERE (conditional) -where MoveToObject/MoveToPosition stop unconditionally. - -### 3e. `MoveToManager::TurnToHeading` — `0052a630` @ 307706-307772 - -```c -0052a641 if (physics_obj != 0) { -0052a6aa if ((arg2->bitfield & 0x10000) != 0) // stop_completely -0052a6ad CPhysicsObj::StopCompletely(physics_obj, edx); -0052a6b5-307729 this->movement_params = *arg2; // 10-field copy -0052a70c this->movement_params.__inner0 &= 0xffffff7f; // CLEAR sticky -0052a71d Frame::set_heading(&this->sought_position.frame, arg2->desired_heading); -0052a722 this->movement_type = 9; // TurnToHeading -0052a731 node = operator new(0x10); node->type(+8) = 9; node->heading(+0xc) = arg2->desired_heading; -0052a766 DLListBase::InsertAfter(&this->pending_actions, node, tail_); -0052a76d MoveToManager::BeginNextNode(this); -0052a641 } else { /* @307753-307770: context_id store + StopCompletely-if-obj noise */ } -``` - -Note `initialized` is NOT set for TurnToHeading/MoveToPosition — the UseTime gate -(§6a) passes anyway because `top_level_object_id == 0` for non-object moves. - ---- - -## 4. Node stepping - -### 4a. Node factories - -`AddTurnToHeadingNode` `00529530` @ 306667-306685: `new(0x10)`, `+0xc = heading`, -`+8 = 9`, `DLListBase::InsertAfter(&pending_actions, node, tail_)` (tail-append). -`AddMoveToPositionNode` `00529580` @ 306689-306706: same with `+8 = 7`, no heading. -`RemovePendingActionsHead` `00529380` @ 306538-306550: `DLListBase::Remove(head)` + -`operator delete(head)` (several sites inline this same pattern instead of calling it). - -### 4b. `MoveToManager::BeginNextNode` — `00529cb0` @ 307123-307171 - -```c -00529cb6 head_ = this->pending_actions.head_; -00529cbe if (head_ != 0) { -00529cc0 type = *(int*)((char*)head_ + 8); -00529cc6 if (type == 7) return MoveToManager::BeginMoveForward(this); // tailcall -00529ccb if (type == 9) return MoveToManager::BeginTurnToHeading(this); // tailcall -00529ccb return; // unknown node type: stall (defensive) -00529cbe } - // queue EMPTY → moveto complete: -00529ce5 if ((int8_t)(this->movement_params.bitfield & 0xff) < 0) { // byte0 sign = bit7 = STICKY -00529cef height = this->sought_object_height; radius = this->sought_object_radius; -00529d00 tlid = this->top_level_object_id; -00529d0c MoveToManager::CleanUp(this); -00529d19 if (physics_obj) CPhysicsObj::StopCompletely(physics_obj, edx); -00529d3a PositionManager::StickTo(CPhysicsObj::get_position_manager(this->physics_obj), - tlid, radius, height); // ← R5 seam (call shape) -00529d44 return; -00529ced } -00529d47 MoveToManager::CleanUp(this); -00529d54 if (physics_obj) CPhysicsObj::StopCompletely(physics_obj, edx); -``` - -**This is the sticky handoff**: arrival at a sticky object move transfers control to -StickyManager via `PositionManager::StickTo(top_level_object_id, sought_radius, -sought_height)`. The radius/height/tlid are read BEFORE CleanUp zeroes them. - -### 4c. `MoveToManager::BeginMoveForward` — `00529a00` @ 306957-307042 - -```c -00529a0e if (physics_obj == 0) { CancelMoveTo(this, 8); return; } // [8 = NoPhysicsObject - // via __saved_edi store] -00529a1d dist = MoveToManager::GetCurrentDistance(this); // [x87 return] -00529a3e heading = Position::heading(&myPos, ¤t_target_position) - get_heading(); -00529a5a if (fabs(heading) < 0.000199999995f) heading = 0; -00529a75 if (heading < -0.000199999995f) heading += 360f; // normalize [0,360) -00529aa4 MovementParameters::get_command(&this->movement_params, dist, heading, - &cmd, &holdKey, &movingAway); -00529aaf if (cmd == 0) { // nothing to do (already in range) -00529ab1 pop+delete head node; // inlined RemovePendingActionsHead -00529ad6 MoveToManager::BeginNextNode(this); -00529ae0 return; -00529aaf } -00529ae5 MovementParameters localParams; // default-ctor -00529af4 localParams.hold_key_to_apply = holdKey; // [BN dead-store garble: the -00529b07 localParams.bitfield &= 0xffff7fff; // var_8/var_28 writes are stores -00529b0e localParams.speed = this->movement_params.speed; // into the stack params — - // hoisted-register artifact. - // 0xffff7fff clears 0x8000 - // cancel_moveto so _DoMotion - // doesn't cancel THIS moveto] -00529b12 err = MoveToManager::_DoMotion(this, cmd, &localParams); -00529b19 if (err != 0) { MoveToManager::CancelMoveTo(this, err); return; } -00529b35 this->current_command = cmd; -00529b3f this->moving_away = movingAway; -00529b45 this->movement_params.hold_key_to_apply = holdKey; // chosen key WRITTEN BACK -00529b4b this->previous_distance = dist; -00529b5d this->previous_distance_time = Timer::cur_time; -00529b69 this->original_distance = dist; -00529b7c this->original_distance_time = Timer::cur_time; -``` - -### 4d. `MoveToManager::BeginTurnToHeading` — `00529b90` @ 307046-307120 - -```c -00529bad if (head_ == 0 || physics_obj == 0) { CancelMoveTo(this, 8); return; } -00529bba if (CPhysicsObj::motions_pending(physics_obj) != 0) return; // wait for anims to drain -00529bc0 target_heading = node->heading; // head +0xc -00529be0 diff = heading_diff(target_heading, get_heading(), 0x6500000d /*TurnRight*/); - // pick direction / early-out: -00529bee if (diff > 180f) { // [flag-test rendering @307074] -00529c19 if (diff + 0.000199999995f >= 360f) // effectively already there -00529c8f { RemovePendingActionsHead; BeginNextNode; return; } // label_529c8f -00529c28 turn = 0x6500000e; // TurnLeft -00529bf7 } else { -00529bf9 if (diff <= 0.000199999995f) // @307094: & 0x41 → ≤ eps -00529c04 goto label_529c8f; // done -00529c0a turn = 0x6500000d; // TurnRight -00529bf7 } -00529c31 MovementParameters localParams; // default; & 0xffff7fff (clear - // cancel_moveto); speed + -00529c58 localParams.speed = movement_params.speed; // hold_key copied from -00529c5c localParams.hold_key = movement_params.hold_key_to_apply; // this->movement_params -00529c60 err = MoveToManager::_DoMotion(this, turn, &localParams); -00529c67 if (err != 0) { CancelMoveTo(this, err); return; } -00529c7b this->current_command = turn; -00529c82 this->previous_heading = diff; // NOTE: stores the REMAINING DIFF, not a heading - // (HandleTurnToHeading's progress test then - // compares heading against this seed on its - // first tick — retail quirk, keep verbatim; - // ACE matches: PreviousHeading = diff) -``` - ---- - -## 5. Distance / progress / command-selection helpers - -### 5a. `MoveToManager::GetCurrentDistance` — `005291b0` @ 306435-306460 - -```c -005291bb if (physics_obj == 0) return; // [x87 return garbled to void] -005291ce if ((movement_params.bitfield & 0x400) == 0) // use_spheres NOT set -0052920f return Position::distance(&physics_obj->m_position, ¤t_target_position); - // use_spheres: -005291d0 return Position::cylinder_distance(GetRadius(physics_obj), GetHeight(physics_obj), - &physics_obj->m_position, - this->sought_object_radius, this->sought_object_height, - &this->current_target_position); - // [BN garbles the x87 float plumbing; arg order per the PDB signature — - // own radius/height from CPhysicsObj::GetRadius/GetHeight, target's from - // the stored sought_object_* fields] -``` - -Object moves (which set sought radius/height and get `use_spheres` on the wire) use -edge-to-edge cylinder distance; position moves use center distance. - -### 5b. `MoveToManager::CheckProgressMade` — `005290f0` @ 306385-306431 - -```c -005290f7 elapsed = Timer::cur_time - this->previous_distance_time; -00529102 if (elapsed <= 1.0) return 1; // [@306392 & 0x41: window < 1s → OK] -00529113 progress = moving_away ? (curr - previous_distance) - : (previous_distance - curr); // @306397-306400 -00529135 if (progress / elapsed >= 0.25f) { // [@306402-306406 flag test] -0052914a previous_distance = curr; previous_distance_time = Timer::cur_time; -00529175 total = moving_away ? (curr - original_distance) - : (original_distance - curr); -0052918d total_rate = total / (Timer::cur_time - original_distance_time); -0052918f if (total_rate >= 0.25f) return 1; // @306419-306423 -00529142 } -005291a1 return 0; // no progress -``` - -Cleaned intent (adjudicated with ACE `CheckProgressMade`, which matches): progress -is only evaluated after a **1-second** window; requires BOTH the incremental rate -(since last checkpoint) AND the overall rate (since the move began) to be -**≥ 0.25 units/second** toward (or away from, when moving_away) the target. -The incremental checkpoint only advances when the incremental rate passes. - -### 5c. `MovementParameters::get_command` — `0052aa00` @ 307946-308012 — **walk-vs-run + command pick** - -```c -0052aa03 flags = (int16_t)this->bitfield; - // --- motion command + moving_away --- -0052aa09 if (flags & 0x200) { // move_towards -0052aa0e if ((flags & 0x100) == 0) goto label_52aa66; // not move_away → plain towards -0052aa26 MovementParameters::towards_and_away(this, dist, heading, &cmd, &movingAway); -0052aa09 } else if ((flags & 0x100) == 0) { // neither towards nor away -0052aa66 label_52aa66: // plain TOWARDS: -0052aa66 if (dist > this->distance_to_object) // [@307962 test ah,0x41 — BN's if-sense -0052aa84 { cmd = 0x45000005; movingAway = 0; } // is INVERTED in the text; adjudicated -0052aa74 else cmd = 0; // via towards_and_away's explicit twin - // @307924 + ACE GetCommand] -0052aa30 } else { // move_away only: -0052aa32 if (dist < this->min_distance) // @307978 & 1 = C0 (explicit, reliable) -0052aa54 { cmd = 0x45000005; movingAway = 1; } // WalkForward, away (heading flips -0052aa44 else cmd = 0; // via get_desired_heading +180) -0052aa30 } - // --- HoldKey (walk vs run) --- -0052aa90 flags = (int16_t)this->bitfield; -0052aa95 if ((flags & 0x10) == 0) { // can_charge NOT set: -0052aa99 if ((flags & 2) == 0) // can_run not set → -0052aac2 { *holdKey = HoldKey_None; return; }// always walk -0052aa9d if ((flags & 1) != 0) { // can_walk set: -0052aaa3 if ((dist - distance_to_object) <= walk_run_threshhold) // @308003 & 0x41 ≤ -0052aac2 { *holdKey = HoldKey_None; return; } // close → WALK -0052aa9d } -0052aa95 } -0052aab4 *holdKey = HoldKey_Run; // can_charge, or far, or walk-incapable -``` - -**THE walk-vs-run rule** (confirms `feedback_autowalk_cancharge_bit`): -`HoldKey_Run` ⇐ `can_charge (0x10)` set, OR (`can_run` set AND (`can_walk` clear OR -`dist - distance_to_object > walk_run_threshhold` [default 15 units])). -`HoldKey_None` (walk) ⇐ no `can_run`, or walking-capable within threshold of target. -The chosen key rides into `_DoMotion` via `localParams.hold_key_to_apply` and is -written back to `movement_params.hold_key_to_apply` (§4c) — MoveToManager never -touches `set_hold_run` directly. - -### 5d. `MovementParameters::towards_and_away` — `0052a9a0` @ 307917-307942 - -```c -0052a9a4 if (dist > this->distance_to_object) // @307924 & 0x41 == 0 → greater (explicit) -0052a9b6 { *cmd = 0x45000005; *movingAway = 0; return; } // WalkForward, towards -0052a9cc if ((dist - this->min_distance) < 0.000199999995f) // inside min band -0052a9e1 { *cmd = 0x45000006; *movingAway = 1; } // WalkBackwards, away -0052a9d7 else *cmd = 0; // inside [min, dto] — idle - // [@307932-307937: BN's if(p)-sense inverted in the text; the cleaned - // branch assignment above is adjudicated by ACE TowardsAndAway + the - // physical meaning (back away only when too close)] -``` - -Note the asymmetry vs §5c: pure-away uses WalkForward+turn-around; towards_and_away -backs up with WalkBackwards (no turn). - -### 5e. `MovementParameters::get_desired_heading` — `0052aad0` @ 308016-308033 — ⚠ BN garble - -```c -0052aae9 if ((arg2 == 0x44000007 || arg2 == 0x45000005)) // RunForward | WalkForward -0052ab00 result = arg3; -0052aaec else if (arg2 != 0x45000006) return (arg2 - 0x45000006); // ← x87-return garble -0052aaee else result = arg3; // WalkBackwards -``` - -The BN text is an x87 setcc-garbled float return (the `arg2 - 0x45000006` "return" is -flag-synthesis nonsense — the R3 A5 artifact class). Cleaned intent (ACE -`get_desired_heading`, matching the command grouping the BN text preserves): - -``` -WalkForward | RunForward : return moving_away ? 180.0f : 0.0f; -WalkBackwards : return moving_away ? 0.0f : 180.0f; -default : return 0.0f; -``` - -i.e. the heading OFFSET added to raw heading-to-target so an "away" walk faces -away and an "away" backstep faces the target. **Port verify:** worth one Ghidra MCP -decompile of `0x0052aad0` when a CodeBrowser is open (server was down this session) -to confirm the two 180/0 constants — confidence high but the BN text alone doesn't -show them. - -### 5f. `heading_greater` — `00528f60` @ 306281-306323 (free function) - -```c -00528f68 if (fabs(arg1 - arg2) > 180.0) // wrapped case: compare flipped -00528f81 greater = (arg2 > arg1); // [@306291 & 0x41 → not-≤ = >] -00528f77 else greater = (arg1 > arg2); -00528fa1 if (arg3 == 0x6500000d) return greater; // TurnRight: heading increases -00528fa7 return greater == 0; // TurnLeft: inverted -``` - -"Has the turn passed the target heading" — direction-aware, 360°-wrap-aware -(cleaned reading; the two fcom branches @306291-306308 are explicit `& 0x41` -patterns, reliable). - -### 5g. `heading_diff` — `00528fb0` @ 306327-306347 (free function) - -```c -00528fb4 d = arg1 - arg2; -00528fc9 if (fabs(d) <= 0.000199999995f) d = 0; // [@306335 & 0x41] -00528fe1 if (d < -0.000199999995f) d += 360f; // [@306338-306342 flag test] -00529009 return d; // [BN renders the x87 return as flag - // synthesis vs eps — garble; callers - // (§4d, §6d) consume it as the - // normalized [0,360) diff] -``` - -`arg3` (the turn command) is UNUSED in this build's body — keep the parameter for -signature parity (ACE keeps it too). - ---- - -## 6. Per-tick drivers + target updates - -### 6a. `MoveToManager::UseTime` — `0052a780` @ 307776-307798 — **the tick gate** - -```c -0052a791 if (physics_obj != 0 && (physics_obj->transient_state & 1) != 0) { // CONTACT bit -0052a793 head_ = this->pending_actions.head_; -0052a7b4 if (head_ != 0 && ((this->top_level_object_id == 0 || - this->movement_type == Invalid) || - this->initialized != 0)) { -0052a7b6 type = *(int*)((char*)head_ + 8); -0052a7bc if (type == 7) return MoveToManager::HandleMoveToPosition(this); -0052a7c1 if (type == 9) return MoveToManager::HandleTurnToHeading(this); -0052a7b4 } -0052a791 } -``` - -Three gates: (1) grounded (`transient_state & 1` = CONTACT — no moveto progress -mid-air; `HitGround` §6e resumes), (2) a node exists, (3) object-moves -(`top_level_object_id != 0 && movement_type != Invalid`) must be `initialized` -(i.e. the first target update arrived). - -### 6b. `MoveToManager::HandleMoveToPosition` — `00529d80` @ 307187-307438 — the big per-tick driver - -```c -00529d94 if (physics_obj == 0) { CancelMoveTo(this, 8); return; } -00529db5 curPos = physics_obj->m_position; // objcell + Frame stack copy -00529dc7 MovementParameters localParams; // default; speed = - // movement_params.speed; - // bitfield &= ~0x8000; - // hold_key = movement_params. - // hold_key_to_apply [same - // dead-store garble as §4c] - // ---- PHASE 1: aux turn steering (only when not animating) ---- -00529df5 if (CPhysicsObj::motions_pending(this->physics_obj) != 0) { -00529ef6 if (aux_command) { _StopMotion(aux_command, &localParams); aux_command = 0; } -00529df5 } else { -00529e03 heading = get_desired_heading(&movement_params, current_command, moving_away) - + Position::heading(&curPos, ¤t_target_position); // @307225-307227 -00529e2d if (heading >= 360f) heading -= 360f; -00529e4e diff = heading - get_heading(); -00529e68 if (fabs(diff) < 0.000199999995f) diff = 0; -00529e80 if (diff < -0.000199999995f) diff += 360f; -00529e8b if (diff <= 20f || diff >= 340f) { // deadband [@307255-307261] -00529ee3 if (aux_command) { _StopMotion(aux); aux_command = 0; } -00529ead } else { -00529eaf turn = (diff >= 180f) ? 0x6500000e /*TurnLeft*/ : 0x6500000d /*TurnRight*/; - // [@307274-307280: BN default TurnRight + `if(p_3)` flip — the - // test-sense is ambiguous in the text; adjudicated via ACE - // HandleMoveToPosition (diff >= 180 → TurnLeft) + §4d's twin] -00529ecc if (turn != aux_command) { _DoMotion(turn, &localParams); aux_command = turn; } -00529ead } -00529df5 } - // ---- PHASE 2: arrival / progress ---- -00529f15 dist = GetCurrentDistance(); -00529f2c if (CheckProgressMade(this, dist) == 0) { -0052a003 if (!CPhysicsObj::IsInterpolating(physics_obj) && - !CPhysicsObj::motions_pending(physics_obj)) -0052a014 this->fail_progress_count += 1; // counter is WRITE-ONLY (§8) -00529f2c } else { -00529f3e this->fail_progress_count = 0; - // arrival predicate [@307306-307331: fcomp garbles; away-branch @307310 - // `& 1`=C0 is explicit; towards-branch if-sense inverted in BN text — - // adjudicated via ACE + the away branch]: - // arrived = moving_away ? (dist >= min_distance) // fcomp [esi+0xe8] - // : (dist <= distance_to_object) // fcomp [esi+0xe4] - if (!arrived) { -00529f63 startDist = Position::distance(&starting_position, &physics_obj->m_position); -00529f68 if (startDist > movement_params.fail_distance) // fcomp [esi+0xf4] -00529f7d MoveToManager::CancelMoveTo(this, 0x3d); // YouChargedTooFar - } else { // label_529f94 -00529f94 pop+delete head node; -00529fc5 _StopMotion(this->current_command, &localParams); -00529fd2 this->current_command = 0; -00529fd8 if (aux_command) { _StopMotion(aux); aux_command = 0; } -00529fef MoveToManager::BeginNextNode(this); - } -00529f2c } - // ---- PHASE 3: TargetManager quantum retune (object moves only) ---- -0052a029 if (this->top_level_object_id != 0 && this->movement_type != Invalid) { -0052a03a v = CPhysicsObj::get_velocity(this->physics_obj); - speed = sqrt(v.x² + v.y² + v.z²); // @307380-307393 raw x87 -0052a05f if (speed > 0.10000000000000001) { // double @data_7a6db0 -0052a06c eta = dist / speed; // @307413-307415 -0052a07e if (fabs(eta - CPhysicsObj::get_target_quantum(physics_obj)) >= 1.0) -0052a0a6 CPhysicsObj::set_target_quantum(this->physics_obj, eta); -0052a05f } -0052a029 } -``` - -Phase-3 cleaned intent (the x87 block @307378-307436 is heavily garbled; structure -adjudicated against ACE, which matches operation-for-operation): while chasing an -object, retune the TargetManager update quantum to the estimated time-to-arrival -(distance/speed) whenever it drifts ≥1 s from the current quantum — faster target -updates as you close in. **ACE-divergence trap:** ACE lines 444-446 add a -`\ custom: sync for server ticrate` `set_heading` in the stop-aux path — retail has -NO set_heading there; do not copy. - -### 6c. `MoveToManager::HandleTurnToHeading` — `0052a0c0` @ 307442-307517 - -```c -0052a0ce if (physics_obj == 0) { CancelMoveTo(this, 8); return; } -0052a0de cmd = this->current_command; -0052a0f0 if (cmd != 0x6500000e && cmd != 0x6500000d) // not currently turning -0052a0f4 { MoveToManager::BeginTurnToHeading(this); return; } -0052a0ff head_ = pending_actions.head_; -0052a10d heading = CPhysicsObj::get_heading(physics_obj); -0052a130 if (heading_greater(heading, head_->heading, cmd) != 0) { // PASSED the target -0052a13a this->fail_progress_count = 0; -0052a146 CPhysicsObj::set_heading(physics_obj, head_->heading, 1); // snap + send(1) -0052a14b pop+delete head node; -0052a16a MovementParameters localParams; // default; &= ~0x8000; hold_key copied -0052a195 _StopMotion(this->current_command, &localParams); -0052a19c this->current_command = 0; -0052a1a2 MoveToManager::BeginNextNode(this); -0052a1ae return; -0052a130 } -0052a1bc diff = heading_diff(heading, this->previous_heading, cmd); -0052a1cf if (diff < 180f && diff > 0.000199999995f) { // made rotational progress -0052a1e5 this->fail_progress_count = 0; -0052a1ef this->previous_heading = heading; -0052a1f9 return; -0052a1dc } -0052a206 this->previous_heading = heading; // no progress: -0052a213 if (!CPhysicsObj::IsInterpolating(physics_obj) && - !CPhysicsObj::motions_pending(this->physics_obj)) -0052a224 this->fail_progress_count += 1; -``` - -### 6d. `MoveToManager::HandleUpdateTarget` — `0052a7d0` @ 307802-307867 — **CONFIRMED here** (R3 open item closed) - -```c -0052a7db if (physics_obj == 0) { CancelMoveTo(this, 8); return; } -0052a7f6 if (this->top_level_object_id != arg2->object_id) return; // not our target -0052a804 if (this->initialized == 0) { - // ---- first callback: build the node plan ---- -0052a886 if (top_level_object_id == physics_obj->id) { // self-target -0052a88f sought_position = physics_obj->m_position; -0052a8aa current_target_position = physics_obj->m_position; -0052a8aa MoveToManager::CleanUpAndCallWeenie(this, 0); // instant success -0052a8b1 return; -0052a886 } -0052a8bb if (arg2->status != Ok_TargetStatus) -0052a8c1 { CancelMoveTo(this, 0x38); return; } // NoObject -0052a8d0 if (movement_type == MoveToObject) // 6 -0052a8f1 MoveToManager::MoveToObject_Internal(this, &arg2->target_position, - &arg2->interpolated_position); -0052a8d0 else if (movement_type == 8) // TurnToObject -0052a8dd MoveToManager::TurnToObject_Internal(this, &arg2->target_position); -0052a804 } else { - // ---- retarget while running ---- -0052a80d if (arg2->status != Ok_TargetStatus) -0052a813 { CancelMoveTo(this, 0x37); return; } // ObjectGone -0052a820 if (this->movement_type == MoveToObject) { -0052a82d sought_position = arg2->interpolated_position; -0052a839 current_target_position = arg2->target_position; -0052a843 previous_distance = 3.40282347e+38f; // FLT_MAX -0052a855 previous_distance_time = Timer::cur_time; -0052a861 original_distance = 3.40282347e+38f; -0052a873 original_distance_time = Timer::cur_time; // progress reset -0052a820 } -0052a804 } -``` - -Note the retarget path updates positions + resets the progress clock but does NOT -requeue nodes — the running MoveToPosition node keeps steering toward the moved -`current_target_position` each tick (§6b Phase 1). TurnToObject gets NO retarget -handling (heading was frozen at the initial callback). - -### 6e. `MoveToManager::HitGround` — `00529d70` @ 307175-307183 - -```c -00529d73 if (this->movement_type == Invalid) return; -00529d75 return MoveToManager::BeginNextNode(this); // tailcall — re-arm after landing -``` - -### 6f. `MoveToManager::MoveToObject_Internal` — `0052a400` @ 307597-307663 - -```c -0052a40e if (physics_obj == 0) { CancelMoveTo(this, 8); return; } -0052a42d this->sought_position = *arg3; // interpolated_position -0052a443 this->current_target_position = *arg2; // target_position -0052a455 iHeading = Position::heading(&physics_obj->m_position, arg3); // toward INTERPOLATED -0052a460 dist = GetCurrentDistance(); // vs current_target (cylinder if spheres) -0052a46f diff = iHeading - get_heading(); -0052a48c if (fabs(diff) < 0.000199999995f) diff = 0; -0052a4a7 if (diff < -0.000199999995f) diff += 360f; -0052a4d6 MovementParameters::get_command(&this->movement_params, dist, diff, - &cmd, &holdKey, &movingAway); -0052a4e1 if (cmd != 0) { -0052a4ea MoveToManager::AddTurnToHeadingNode(this, iHeading); -0052a4f1 MoveToManager::AddMoveToPositionNode(this); -0052a4e1 } -0052a4fd if ((this->movement_params.bitfield & 0x40) != 0) { // use_final_heading -0052a503 final = iHeading + this->movement_params.desired_heading; -0052a513 if (final >= 360f) final -= 360f; // [@307653-307656] -0052a52f MoveToManager::AddTurnToHeadingNode(this, final); -0052a4fd } -0052a536 this->initialized = 1; -0052a540 MoveToManager::BeginNextNode(this); -``` - -Same node plan as MoveToPosition (§3c) but heading aims at the INTERPOLATED position -and the final heading is RELATIVE (`heading-to-target + desired_heading`) instead of -absolute. - -### 6g. `MoveToManager::TurnToObject_Internal` — `0052a550` @ 307667-307702 - -```c -0052a55b if (physics_obj == 0) { CancelMoveTo(this, 8); return; } -0052a571 this->current_target_position = *arg2; // target_position -0052a58d soughtHeading = Frame::get_heading(&this->sought_position.frame); -0052a59b targetHeading = Position::heading(&physics_obj->m_position, ¤t_target_position); -0052a5aa final = fmod(targetHeading + soughtHeading, 360.0); // j__CIfmod @307682; the BN - // float plumbing @307680-307684 - // is garbled — structure per - // ACE TurnToObject_Internal - // (matches call-for-call) -0052a5ba Frame::set_heading(&this->sought_position.frame, final); -0052a5c1 node = new(0x10); node->type = 9; node->heading = final; // inlined AddTurnToHeadingNode -0052a607 DLListBase::InsertAfter(&pending_actions, node, tail_); -0052a60e this->initialized = 1; -0052a618 MoveToManager::BeginNextNode(this); -``` - -With §3d's quirk, `soughtHeading` is 0 for a fresh TurnToObject (sought was reset by -`InitializeLocalVariables`; `desired_heading` went to current_target's frame and was -overwritten here) → the final heading is simply "face the object". ACE matches -verbatim (MoveToManager.cs:246 + 271-276). - ---- - -## 7. Motion issuing + cleanup family - -### 7a. `MoveToManager::_DoMotion` — `00529010` @ 306351-306364 - -```c -0052901b if (physics_obj == 0) return 8; // NoPhysicsObject -0052902d if (CPhysicsObj::get_minterp(physics_obj) == 0) return 0xb; // NoMotionInterpreter -00529057 CMotionInterp::adjust_motion(get_minterp(physics_obj), &arg2, &arg3->speed, - arg3->hold_key_to_apply); -00529076 return CMotionInterp::DoInterpretedMotion(get_minterp(physics_obj), arg2, arg3); -``` - -### 7b. `MoveToManager::_StopMotion` — `00529080` @ 306368-306381 - -Identical shape; tail is `CMotionInterp::StopInterpretedMotion(minterp, arg2, arg3)`. - -**This is the entire CMotionInterp seam**: MoveToManager only ever issues -`adjust_motion` → `DoInterpretedMotion` / `StopInterpretedMotion`. `adjust_motion` -(already ported, R3) applies the hold key: WalkForward + HoldKey_Run → -RunForward + speed scale. It never calls `DoMotion`, `set_hold_run`, or raw-state -APIs directly. - -### 7c. `MoveToManager::CancelMoveTo` — `00529930` @ 306886-306940 - -```c -0052993a if (this->movement_type != Invalid) { -00529946 while (pending_actions.head_ != 0) { /* inlined unlink + operator delete, - @306891-306929 */ } -005299b5 MoveToManager::CleanUp(this); -005299c2 if (physics_obj) CPhysicsObj::StopCompletely(physics_obj, edx); -0052993a } -``` - -Signature is `(this, uint32_t arg2)` — **arg2 (the WeenieError) is NEVER READ in the -body**. Every call site stores a code into a stack slot right before the call -(`8`, `0x36`, `0x37`, `0x38`, `0x3d`) which BN disconnects into dead stores + -uninit-edx (A7 artifact family). In this client build the code is dropped (the -weenie-callback plumbing of the original source is compiled out). Port: keep the -`WeenieError` parameter for parity/logging; the retail-visible behavior is -error-independent. Codes seen (ACE names): `8` NoPhysicsObject, `0x36` -ActionCancelled, `0x37` ObjectGone, `0x38` NoObject, `0x3d` YouChargedTooFar. - -### 7d. `MoveToManager::CleanUp` — `005295c0` @ 306710-306736 - -```c -005295ca MovementParameters localParams; // default ctor -005295d9 localParams.hold_key_to_apply = this->movement_params.hold_key_to_apply; -005295eb localParams.bitfield &= 0xffff7fff; // clear cancel_moveto [dead-store garble] -005295ef if (this->physics_obj != 0) { -005295f9 if (current_command) _StopMotion(this, current_command, &localParams); -00529610 if (aux_command) _StopMotion(this, aux_command, &localParams); -0052962c if (this->top_level_object_id != 0 && this->movement_type != Invalid) -00529634 CPhysicsObj::clear_target(this->physics_obj); // → TargetManager::ClearTarget -005295ef } -0052963b MoveToManager::InitializeLocalVariables(this); -``` - -Does NOT drain `pending_actions` (CancelMoveTo/Destroy do that first). - -### 7e. `MoveToManager::CleanUpAndCallWeenie` — `00529650` @ 306740-306752 - -```c -00529653 MoveToManager::CleanUp(this); -00529661 if (physics_obj == 0) return; -0052966b return CPhysicsObj::StopCompletely(physics_obj, edx); // tailcall -``` - -Despite the name, **no weenie call survives in this build** (same compiled-out -callback as §7c; the `arg2 = 0` @306749 is argument-setup residue). Body ≡ -CleanUp + StopCompletely. - ---- - -## 8. `fail_progress_count` — write-only (negative result with teeth) - -Exhaustive grep (`fail_progress_count`, 6 hits total @306503/307297/307303/307472/ -307506/307516): initialized to 0, reset to 0 at three progress sites, incremented at -two stall sites (§6b/§6c) — **never compared, never read**. The stall counter is -vestigial in the 2013 client; there is NO give-up-after-N-ticks path (the only -abort conditions are fail_distance §6b and target-status §6d). Port it as a field -(cheap parity + diagnostics) but wire no behavior to it. - ---- - -## 9. CPhysicsObj-side entry points (MovementSystem-facing) - -### 9a. `CPhysicsObj::MoveToObject` — `00512860` @ 280598-280655 - -```c -0051286c if (movement_manager == 0) { MovementManager::Create + EnterDefaultState; - set update_time; transient_state |= 0x80; } // lazy-make -005128c9 if (CPhysicsObj::obj_maint != 0) { -005128d2 target = CObjectMaint::GetObjectA(obj_maint, arg2); -005128db if (target != 0) { -005128e9 height = target->part_array ? CPartArray::GetHeight(part_array) : 0; -00512903 radius = target->part_array ? CPartArray::GetRadius(part_array_1) : 0; -00512913 top = target->parent ? target->parent : target; // TOP-LEVEL resolve -00512932 CPhysicsObj::MoveToObject_Internal(this, arg2, top->id, radius, height, arg3); - } } -``` - -`CPhysicsObj::MoveToObject_Internal` `005102e0` @ 278254-278296: lazy-make again, -then builds a stack `MovementStruct { type=6 (@278293), object_id=arg2 (@278290), -top_level_id=arg3 (@278288), radius=arg4 (@278289), height=arg5 (@278294), -params=arg6 (@278291) }` → `MovementManager::PerformMovement(movement_manager, &ms)`. - -### 9b. `CPhysicsObj::TurnToObject` — `00512940` @ 280659-280679 - -Same GetObjectA + parent-resolve shape (NO radius/height fetch) → -`CPhysicsObj::TurnToObject_Internal(this, arg2, top->id, arg3)`; `_Internal` -`005103f0` @ 278300-278340 builds `MovementStruct { type=8, object_id, top_level_id, -params }` → PerformMovement. - -### 9c. `CPhysicsObj::TurnToHeading` — `00512980` @ 280683-280721 - -Lazy-make; `MovementStruct { type=9 (@280718), params=arg2 (@280719) }` → PerformMovement. - -### 9d. `CPhysicsObj::StopCompletely` — `00510180` @ 278165-278185 - -`if (movement_manager)` build `MovementStruct { type=5 }` → PerformMovement. The -`arg2` in its `__fastcall(this, int32_t arg2)` signature is unused (BN phantom-edx -at every call site). - -### 9e. `CPhysicsObj::interrupt_current_movement` — `005101f0` @ 278189-278200 — the cancel entry - -```c -005101f8 if (movement_manager != 0) { -005101fa int32_t var_4_1 = 0x36; // ActionCancelled -005101fc MovementManager::CancelMoveTo(movement_manager, edx); // [uninit-edx artifact] -005101f8 } -``` - -**There is no `CPhysicsObj::cancel_moveto` symbol** (grepped, §10) — this is the -public cancel path (called from unpack_movement heads, StopCompletely-family, etc.). - -### 9f. Target plumbing (TargetManager seam — call shapes) - -- `CPhysicsObj::set_target` `0050ed30` @ 276571-276589: lazy-`new TargetManager(0x18)`, - `TargetManager::SetTarget(tm, context_id, object_id, radius, quantum)`. MoveToManager - always passes `(0, top_level_object_id, 0.5f, 0.0)`. -- `CPhysicsObj::clear_target` `0050ed90` @ 276593-276603: `TargetManager::ClearTarget`. -- `CPhysicsObj::get_target_quantum` `0050edc0` @ 276620-276634: returns - `target_manager->target_info` quantum — the BN text reads `last_update_time` and - returns a pointer; **field-attribution garble** (x87 double-return through a - TargetInfo field; ACE: returns `TargetInfo.Quantum`, default 0). -- `CPhysicsObj::set_target_quantum` `0050eda0` @ 276606-276616: - `TargetManager::SetTargetQuantum(tm, quantum)`. -- `CPhysicsObj::receive_target_update` `0050ede0` @ 276638-276648: - `TargetManager::ReceiveUpdate(tm, TargetInfo*)` — the inbound side that eventually - fans back out through `CPhysicsObj::HandleUpdateTarget`. -- `CPhysicsObj::HandleUpdateTarget` `00512bc0` @ 280794-280813: - -```c -00512bc9 if (arg2.context_id == 0) { // context 0 = the movement context -00512bd3 if (movement_manager) MovementManager::HandleUpdateTarget(mm, copy(TargetInfo)); -00512bfd if (position_manager) PositionManager::HandleUpdateTarget(pm, copy(TargetInfo)); -00512bc9 } -``` - -(Sticky/interp side of PositionManager::HandleUpdateTarget is R5.) - -### 9g. Sticky call shapes referenced from this extraction (bodies = R5) - -- `PositionManager::StickTo(pm, object_id, radius, height)` — from §4b (arrival with - sticky bit) and from `CPhysicsObj::stick_to_object` `005127e0` @ 280559-280595 - (parent-resolved id + CPartArray radius/height, unpack case-0 path). -- `PositionManager::UnStick(pm)` — via `CPhysicsObj::unstick_from_object` `0050eae0` - @ 276403-276413 (called at the head of every `MoveToManager::PerformMovement` and - `unpack_movement`). -- `CPhysicsObj::IsInterpolating` `0050eb50` @ 276464-276474 → - `PositionManager::IsInterpolating` (consumed by the fail-progress stall tests). - ---- - -## 10. Negative results (grepped-not-found) - -- **`MoveToManager::add_listener` / `remove_listener` / `AddListener` / `RemoveListener` - — DO NOT EXIST** (grep over the full raw for both managers: zero hits). No - listener/observer machinery on MoveToManager or MovementManager in this build. -- **`CPhysicsObj::cancel_moveto` — does not exist**; the cancel entry is - `interrupt_current_movement` (§9e). **`CPhysicsObj::MoveToPosition` — does not - exist** either; position moves enter only via `unpack_movement` case 7 or a direct - `MoveToManager::MoveToPosition` call. -- **`MoveToManager::LeaveGround` / `ReportExhaustion` — no bodies**; COMDAT-folded - no-ops (§2e). `CMotionInterp::HandleEnterWorld` likewise. -- **`fail_progress_count` has no consumer** (§8) — no give-up threshold exists. -- **`heading_diff`'s third arg (turn command) is unused** in the body (§5g). -- **R3 open item CLOSED:** `HandleUpdateTarget` does live on MoveToManager - (`0x0052a7d0`), reached via `CPhysicsObj::HandleUpdateTarget` → - `MovementManager::HandleUpdateTarget` → here, fed by - `TargetManager::ReceiveUpdate`. -- Older Ghidra chunk (`docs/research/decompiled/chunk_00520000.c`) is from a - DIFFERENT build for this region — function boundaries don't align - (`FUN_005297c0` there ≠ `MoveToManager::TurnToObject` 0x005297d0 here); unusable - for adjudication. Ghidra MCP was down this session (both ports probed) — the one - soft item worth a live decompile later is §5e's 180/0 constants. - -## 11. BN artifact ledger (this extraction) - -| Site | Artifact class | Adjudication | -|---|---|---| -| `get_command` towards branch @307962, `towards_and_away` @307932, HandleMoveToPosition arrival @307329 + turn pick @307277 | flag-test `if(p)` sense inversion | explicit `& 0x41`/`& 1` twins in the same bodies + ACE (matches retail line-for-line in this class) | -| `get_desired_heading` @308027 | x87 setcc garbled return | ACE shape (180/0 offsets); verify via Ghidra when up | -| `heading_diff` return @306346, `GetCurrentDistance` void-return @306448/306459, HandleMoveToPosition Phase-3 @307378-307436, `TurnToObject_Internal` fmod plumbing @307682 | x87 float plumbing lost | structure per ACE, constants verbatim from the raw | -| local MovementParameters writes rendered as dead `var_28`/`var_8` stores (§4c/§4d/§6b/§7d) | hoisted-register / lost store destination | field offsets (+4 bitfield, +0x24 hold_key, +0x14 speed) + ACE | -| `CancelMoveTo(this, edx)` at 7 sites; `StopCompletely(obj, edx)` everywhere | uninit-edx arg relay (A7 family) | stack store adjacent to each call is the real arg; both callees ignore it | -| `MovementManager::LeaveGround`/`ReportExhaustion`/`HandleEnterWorld` tails → `IDClass::~IDClass` | COMDAT-fold symbol collision | trivial-body fold; treat as no-ops | -| `CBaseFilter::GetPinVersion(minterp)` @300597 | symbol-collision mislabel | trivial getter of the interp's current style | -| `CPhysicsObj::get_target_quantum` reading `last_update_time` @276630 | field-attribution on double return | ACE: returns TargetInfo.Quantum | - -## 12. Constants inventory - -| Constant | Meaning | Sites | -|---|---|---| -| `0.000199999995f` | universal heading/distance epsilon (same literal as R3's A5/A6) | §3c/§4c/§4d/§5d/§5g/§6b/§6f | -| `20f` / `340f` (=360-20) | aux-turn deadband while move-walking | HandleMoveToPosition @307251-307257 | -| `180f` | turn-direction pick + wrap tests | @306285/307069/307274/307496 | -| `360f` | heading normalization | throughout | -| `1.0` (double) | min progress-check window (s); quantum-retune delta (s) | CheckProgressMade @306389; @307423 | -| `0.25f` | min progress rate (units/s), incremental AND overall | CheckProgressMade @306402/306419 | -| `0.10000000000000001` (double) | min speed for quantum retune | @307400 | -| `0.5f` / `0.0` | set_target radius / initial quantum | @306794/306858 | -| `3.40282347e+38f` | FLT_MAX distance reset | @306496/306499/307858/307861 | -| `0.6f` / `15f` / FLT_MAX / `1f` | params defaults: distance_to_object / walk_run_threshhold / fail_distance / speed | ctor @300513-300519 | -| `0x1EE0F` | params default bitfield (A4 pin; can_charge CLEAR) | @300531 | -| `0x45000005/6`, `0x44000007`, `0x6500000d/e` | WalkForward/WalkBackwards, RunForward, TurnRight/TurnLeft | §5c/§5d/§5e/§4d/§6b | -| `transient_state & 1` | CONTACT gate for UseTime | @307781 | -| `transient_state \|= 0x80` | ACTIVE flag on lazy manager creation | §9a-9c | -| `8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47` | WeenieError codes (NoPhysicsObject, NoMotionInterpreter, ActionCancelled, ObjectGone, NoObject, YouChargedTooFar, InvalidMovementType) | §7a/§7c/§2b | -| `0x1c` / `0xc` / `0x28` | UnPackNet sizes (move/turn) / full Pack size | §2g | -| `0x160` / `0x10` | sizeof MoveToManager / MovementNode | §1b/§4a | - -## 13. Summary table (function → address → raw lines → completeness) - -| Function | Address | Raw lines | Completeness | -|---|---|---|---| -| MoveToManager::MoveToManager (ctor) | 005293b0 | 306554-306593 | full | -| MoveToManager::Create | 00529470 | 306597-306614 | full | -| MoveToManager::InitializeLocalVariables | 00529250 | 306490-306534 | full | -| MoveToManager::Destroy | 005294b0 | 306618-306663 | full | -| MoveToManager::~MoveToManager | 005299d0 | 306945-306953 | full | -| MoveToManager::SetWeenieObject / SetPhysicsObject | 00529230 / 00529240 | 306474-306486 | full | -| MoveToManager::is_moving_to | 00529220 | 306464-306470 | full | -| MoveToManager::PerformMovement | 0052a900 | 307871-307904 | full | -| MoveToManager::MoveToObject | 00529680 | 306756-306817 | full | -| MoveToManager::MoveToPosition | 0052a240 | 307521-307593 | full | -| MoveToManager::TurnToObject | 005297d0 | 306820-306882 | full | -| MoveToManager::TurnToHeading | 0052a630 | 307706-307772 | full | -| MoveToManager::MoveToObject_Internal | 0052a400 | 307597-307663 | full | -| MoveToManager::TurnToObject_Internal | 0052a550 | 307667-307702 | full (fmod plumbing via ACE) | -| MoveToManager::HandleUpdateTarget | 0052a7d0 | 307802-307867 | full | -| MoveToManager::UseTime | 0052a780 | 307776-307798 | full | -| MoveToManager::HandleMoveToPosition | 00529d80 | 307187-307438 | full (Phase-3 x87 via ACE) | -| MoveToManager::HandleTurnToHeading | 0052a0c0 | 307442-307517 | full | -| MoveToManager::BeginNextNode | 00529cb0 | 307123-307171 | full | -| MoveToManager::BeginMoveForward | 00529a00 | 306957-307042 | full | -| MoveToManager::BeginTurnToHeading | 00529b90 | 307046-307120 | full | -| MoveToManager::HitGround | 00529d70 | 307175-307183 | full | -| MoveToManager::AddTurnToHeadingNode / AddMoveToPositionNode | 00529530 / 00529580 | 306667-306706 | full | -| MoveToManager::RemovePendingActionsHead | 00529380 | 306538-306550 | full | -| MoveToManager::CleanUp | 005295c0 | 306710-306736 | full | -| MoveToManager::CleanUpAndCallWeenie | 00529650 | 306740-306752 | full | -| MoveToManager::CancelMoveTo | 00529930 | 306886-306940 | full | -| MoveToManager::_DoMotion / _StopMotion | 00529010 / 00529080 | 306351-306381 | full | -| MoveToManager::CheckProgressMade | 005290f0 | 306385-306431 | full | -| MoveToManager::GetCurrentDistance | 005291b0 | 306435-306460 | full (x87 args via signature) | -| MovementParameters::get_command | 0052aa00 | 307946-308012 | full | -| MovementParameters::towards_and_away | 0052a9a0 | 307917-307942 | full | -| MovementParameters::get_desired_heading | 0052aad0 | 308016-308033 | shape (constants via ACE; garbled) | -| MovementParameters::Pack / UnPack / UnPackNet | 0052ab20 / 0052abc0 / 0052ac50 | 308037-308205 | full | -| heading_greater / heading_diff | 00528f60 / 00528fb0 | 306281-306347 | full | -| MovementManager::MakeMoveToManager | 00524000 | 300124-300129 | full | -| MovementManager::PerformMovement | 005240d0 | 300194-300255 | full | -| MovementManager::CancelMoveTo | 005241b0 | 300277-300288 | full | -| MovementManager::UseTime / HitGround / IsMovingTo / HandleUpdateTarget | 005242f0 / 00524300 / 00524260 / 00524790 | 300352-300440, 300723-300730 | full | -| MovementManager::LeaveGround / ReportExhaustion / HandleEnterWorld | 00524320 / 00524360 / 00524340 | 300444-300506 | full (folded no-ops) | -| MovementManager::Destroy | 005243f0 | 300538-300559 | full | -| MovementManager::unpack_movement | 00524440 | 300563-300719 | full | -| CPhysicsObj::MoveToObject (+_Internal) | 00512860 / 005102e0 | 280598-280655, 278254-278296 | full | -| CPhysicsObj::TurnToObject (+_Internal) | 00512940 / 005103f0 | 280659-280679, 278300-278340 | full | -| CPhysicsObj::TurnToHeading | 00512980 | 280683-280721 | full | -| CPhysicsObj::StopCompletely | 00510180 | 278165-278185 | full | -| CPhysicsObj::interrupt_current_movement | 005101f0 | 278189-278200 | full | -| CPhysicsObj::MakeMovementManager / MakePositionManager | 00510270 / 00510210 | 278204-278250 | full | -| CPhysicsObj::set_target / clear_target / set_target_quantum / get_target_quantum / receive_target_update / HandleUpdateTarget | 0050ed30-0050ede0, 00512bc0 | 276571-276648, 280794-280813 | full (quantum getter garbled, noted) | -| CPhysicsObj::stick_to_object / unstick_from_object / IsInterpolating | 005127e0 / 0050eae0 / 0050eb50 | 280559-280595, 276403-276413, 276464-276474 | call shapes (R5 owns internals) | diff --git a/docs/research/2026-07-03-r4-moveto/r4-port-plan.md b/docs/research/2026-07-03-r4-moveto/r4-port-plan.md deleted file mode 100644 index 37a4b572..00000000 --- a/docs/research/2026-07-03-r4-moveto/r4-port-plan.md +++ /dev/null @@ -1,452 +0,0 @@ -# R4 port work-list — MoveToManager verbatim + the three-approximation cutover - -Inputs: `r4-moveto-decomp.md` (verbatim retail extraction, same dir), -`r4-ace-moveto.md` (ACE cross-ref + blast radius, same dir), plan of record -`docs/plans/2026-07-02-retail-motion-animation-rewrite.md` (stage R4), R3 pins -`docs/research/2026-07-02-r3-motioninterp/W0-pins.md` (A4 MovementParameters masks — -used as-is), current code `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (340 -lines, static), `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (87 lines), -`src/AcDream.App/Input/PlayerMovementController.cs` (1604 lines, post-R3-W6; B.6 -auto-walk block :275-:766 + :896), `src/AcDream.App/Rendering/GameWindow.cs` -(local MoveTo routing :4401-4547, remote seeding :4549-4620, remote per-tick -:9594-9682, speculative install :11915/:12004/:12122), -`src/AcDream.Core.Net/Messages/UpdateMotion.cs` (mt 6/7 parse :252-356), -`src/AcDream.Core/Physics/Motion/MovementParameters.cs` (R3-W1, flags+scalars only), -`src/AcDream.Core/Physics/MotionInterpreter.cs` (R3-complete: DoInterpretedMotion -:2861, StopInterpretedMotion :3016, adjust_motion :1241, MotionsPending :2118, -InterruptCurrentMovement seam :645, UnstickFromObject seam :633, MyRunRate :585). - -**Precondition / state at R4 start:** R1/R2/R3 SHIPPED (R3 through W6 `fb7beb70`; -R2+R3 share one pending visual pass — R4 code work does not block on it). The -R3 seams R4 consumes all exist: `DoInterpretedMotion(uint, MovementParameters)` / -`StopInterpretedMotion(uint, MovementParameters)` / `adjust_motion(ref,ref,HoldKey)` -(the entire retail `_DoMotion` shape), `MotionsPending()`, `TransientStateFlags.Contact` -on `PhysicsObj`, `HitGround`/`LeaveGround` (W4), the `InterruptCurrentMovement` -no-op `Action?` seam (register TS-36 — "R4 is where cancel_moveto lands"), and -`MovementParameters` with retail ctor defaults (0x1EE0F, threshhold 15, CanCharge -false). What does NOT exist: any MoveToManager, the command-selection family -(`get_command`/`towards_and_away`/`get_desired_heading`), MovementType values -0/6/7/8/9, WeenieError 0xB/0x36/0x37/0x38/0x3D, mt 8/9 wire parsing, and any -heading/cylinder-distance helper in Core. MovementManager itself is **R5** — -R4 binds MoveToManager to the interp directly and keeps type-dispatch at the -existing call sites (same "null-guarded relay inserted later without behavior -change" pattern R3 §4 used for MotionDone). - ---- - -## 0. DECOMP AMBIGUITIES TO PIN before porting (the V0 pins commit) - -| # | Ambiguity | Evidence each way | Pin method | -|---|---|---|---| -| P1 | **ACE's companion mt-0 echo vs retail's unpack-head cancel — the V5 landmine.** Retail `MovementManager::unpack_movement` (0x00524440 @300566-300598) calls `CPhysicsObj::interrupt_current_movement` (→ CancelMoveTo 0x36) at the HEAD of EVERY movement event, including case 0. But ACE follows every mt=0x06 MoveToObject with an mt=0x00 InterpretedMotionState echo (cmd=RunForward, fwdSpd≈2.86) one packet later — trace-verified 2026-05-14 (`launch-slice2.log`; the finding is preserved verbatim in GameWindow.cs:4534-4546: "Cancelling on the InterpretedMotionState killed the auto-walk on frame 1"). A verbatim head-interrupt port re-breaks auto-walk on frame 1 against ACE. Retail servers evidently never sent an interpreted UM mid-moveto to the mover (any interpreted UM IS the cancel+replace signal). | decomp §2f head vs GameWindow:4534-4546 trace note; ACE `Player.OnMoveComplete`/MoveToChain broadcast code | (a) Re-capture the echo with `ACDREAM_PROBE_AUTOWALK=1` + `ACDREAM_DUMP_MOTION=1` and record the echo's movement/serverControl sequence stamps + isAutonomous flag vs the mt-6 packet's; (b) read ACE's broadcast path (does the echo bump ServerControlSequence? is it flagged autonomous?) for a principled discriminator; (c) if no wire discriminator exists, the adaptation is: **suppress the head-interrupt for a local-player mt-0 UM whose interpreted ForwardCommand equals the moveto's `current_command` (post-adjust) while `is_moving_to()`** — an ACE-compat register row (AD class, sibling of AD-32), NOT a silent divergence. Whatever the pin, mt 6/7/8/9 arrivals keep the retail unconditional cancel (MoveToManager::PerformMovement re-cancels anyway, §3a). | -| P2 | **`get_desired_heading` (0x0052aad0) 180/0 constants.** BN body is an x87 setcc garble (decomp §5e); ACE gives `WalkForward\|RunForward → movingAway ? 180 : 0`, `WalkBackwards → movingAway ? 0 : 180`, default 0. The command GROUPING is visible in the BN text; the constants are not. | decomp §5e vs ACE MovementParameters.cs:186-198 | One Ghidra MCP decompile of `0x0052aad0` (`/decompile_function?address=0x0052aad0`) when a CodeBrowser is up (was down during extraction — decomp §10). Confidence already high; port ACE's shape if Ghidra stays down, with the pin noted UNVERIFIED-BY-GHIDRA in the pseudocode doc. | -| P3 | **`heading_diff` (0x00528fb0) third-arg mirror — the two research docs CONTRADICT.** r4-moveto-decomp §5g: "arg3 (the turn command) is UNUSED in the body — keep the parameter for signature parity". r4-ace-moveto §1: ACE has `if (result > EPSILON && motion != TurnRight) result = 360 - result` and says the mirror is "invisible in the pseudo-C — verify once in Ghidra". Behavior-bearing: `HandleTurnToHeading` @0052a1bc passes the LIVE `current_command` (can be TurnLeft) into heading_diff for its progress test — with the mirror, a TurnLeft turn measures progress as 360−raw; without it, the progress window `(ε, 180)` reads the wrong side and TurnLeft turns would increment fail_progress_count every tick (harmless in retail — the counter is dead — but the `previous_heading` update path differs). | decomp §5g vs ACE MoveToManager.cs:817-828 | Ghidra decompile of `0x00528fb0` — decisive (the fn is 40 lines). If Ghidra stays down: cdb bp on `acclient!heading_diff` logging arg3+ST0 during a TurnLeft-direction TurnToHeading. Do not port either reading unpinned. | -| P4 | **TargetManager scope — what feeds `HandleUpdateTarget` for type 6/8.** Retail: `set_target` registers the mover as a voyeur on the target (radius 0.5, quantum 0); the target's movement fans back via `receive_target_update` → `HandleUpdateTarget` (decomp §9f; ACE `TargetManager.cs` with a 0.5 s `HandleTargetting` cadence + 10 s timeout). The r4 extraction covers only the CALL SHAPES — TargetManager bodies were NOT extracted. Without SOME feed, `initialized` never flips and type-6/8 moves never start (UseTime gate §6a). | decomp §9f (shapes only) vs ACE TargetManager.cs (full impl, server-side) | DECISION (made here, confirm in V0): R4 ships a **minimal App-side `TargetTracker` adapter**, not a TargetManager port: on `set_target(0, tlid, 0.5, 0)` GameWindow's entity table delivers one immediate `TargetInfo{ObjectId, Ok, target_position=entity pos, interpolated_position=same}`, then re-delivers per tick when the target has moved > 0.5 units since last delivery (the voyeur radius from the call site); `clear_target` unsubscribes; despawn/teleport of the target delivers `status=ExitWorld/Teleported` (→ CancelMoveTo 0x37/0x38 falls out of the verbatim manager). `set_target_quantum` is accepted and recorded but does NOT throttle the tracker (retail uses it to slow updates; over-delivery is convergence-safe). Register row (AP class) replacing AP-8's "no target re-tracking" clause; the full TargetManager port is R5 scope alongside PositionManager. If V0 finds this too loose, the fallback pin is extracting `TargetManager::SetTarget/ReceiveUpdate/HandleTargetting` from the raw (grep `TargetManager::` — est. ~200 lines) and porting verbatim in V4 instead. | -| P5 | **Position::heading convention + coordinate space.** MoveToManager consumes `Position::heading(from, to)` (degrees), `Frame::get_heading/set_heading`, `Position::distance`, `Position::cylinder_distance`. acdream has NO Core heading helper (only `SceneryHelpers.SetHeading`); RemoteMoveToDriver derives yaw from quaternions ad hoc. Wire `DesiredHeading` is degrees in AC convention. The manager will run in acdream's streaming-world space (Vector3 + cell id) via `OriginToWorld`, not retail's block-local Position — distances are equivalent (Euclidean after rebase); headings must match the wire convention exactly or `use_final_heading` faces the wrong way. | acclient Position::heading (not in this extraction); `RemoteMoveToDriver.OriginToWorld` :263-277; L.2b outbound heading packers (already convert yaw→AC heading) | Grep the named raw for `Position::heading` + `Frame::get_heading` and pin the formula (expected: heading = fmod(450 − atan2-degrees, 360) or the L.2b-established equivalent — the outbound AP packer already encodes body yaw→AC heading; REUSE that exact conversion, don't derive a second one). Golden test: heading(a,b) for the four cardinal offsets == {0, 90, 180, 270} per the wire convention ACE round-trips. | -| P6 | **TurnToObject (mt 8) wire layout.** Decomp §2f case 8 reads `object_id` dword, then a `wire_heading` dword, THEN UnPackNet's 3 dwords (bitfield, speed, desired_heading); on unresolvable object it substitutes `desired_heading = wire_heading` and degrades to TurnToHeading. Two heading fields exist on the wire; ACE's server-side writer is the cheap cross-check for which is which. | decomp §2f @300653-300676; ACE MovementEvents TurnToObject writer | Read ACE's `MoveToObject/TurnToObject` GameMessage writers (Network/GameMessages) and byte-diff against the decomp read order; build the V3 parse fixtures from ACE's writer directly (golden bytes). | -| P7 | **Remote contact for the UseTime gate.** `MoveToManager::UseTime` gates on `transient_state & 1` (CONTACT). Remotes' `PhysicsObj.TransientState` is maintained well enough that R3's funnel `contact_allows_move` works for remote Falling dispatch — but confirm the Contact bit specifically is live for grounded NPCs (not just OnWalkable), else remote movetos stall forever. | MotionInterpreter.cs:1979/2089 (funnel reads Contact+OnWalkable for remotes today) | Code read of the remote PhysicsBody state writers + one `ACDREAM_DUMP_MOTION` smoke: a chasing NPC must pass the gate every grounded tick. If remotes lack the bit, fixing the STATE WRITER is in scope (root cause), not softening the gate. | - -**V0 also commits the research docs into the repo** -(`docs/research/2026-07-03-r4-movetomanager/`: the decomp extraction, the ACE -cross-ref, this plan, and a `V0-pins.md` in the W0-pins format), and fixes the -two known stale comments so nobody ports against them: RemoteMoveToDriver.cs:53-57 -("ACE swaps the chase/flee arrival predicates" — WRONG, refuted in r4-ace-moveto §1; -the file dies in V4 but V0-readers must not absorb the claim) — a doc-only touch. - -**V0 cdb capture (optional, non-blocking):** all seven pins are textually -resolvable (P1 needs an ACE trace, not retail cdb). If a retail session happens -anyway: bp `MoveToManager::PerformMovement` / `BeginMoveForward` / -`BeginTurnToHeading` / `HandleMoveToPosition` / `HandleUpdateTarget` / -`MovementParameters::get_command` (args+ret) while the user runs: use a door at -range (MoveToObject + arrival), use while facing away (TurnToHeading node first), -let a monster chase (aux-turn band), outrun it (fail-distance NOT firing — dead -counter), /follow-style sticky if reachable. Feeds V2/V4 goldens; synthetic + -ACE-writer fixtures suffice without it. - ---- - -## 1. ITEMIZED GAPS — current vs retail (R4 scope) - -Severity: **BLOCKER** = stage deliverable impossible without it; **HIGH** = -visible behavior wrongness / blocks R5-R6; **MED** = edge-visible; **LOW** = textual. - -| # | Retail behavior acdream lacks/diverges on | Retail anchor | Current-code anchor | Severity | -|---|---|---|---|---| -| M1 | **No MoveToManager exists** — no state block (movement_type, sought/current_target/starting positions, params copy, progress clocks, current/aux command, moving_away, initialized), no `pending_actions` node queue, none of the 33 members. THREE independent approximations stand in for the one retail mechanism: `DriveServerAutoWalk` (local player), `RemoteMoveToDriver.Drive` (remotes), `ServerControlledLocomotion.PlanMoveToStart` (animation seed). | struct acclient.h:31473; whole extraction §§1-7 | RemoteMoveToDriver.cs; PlayerMovementController.cs:567-766; ServerControlledLocomotion.cs:28-53; GameWindow.cs:4412/4942 | **BLOCKER** | -| M2 | **Command-selection family absent**: `get_command` (0x0052aa00 — move/flee band pick + THE walk-vs-run rule: CanCharge 0x10 fast-path → Run, else CanRun && (!CanWalk \|\| dist−dto > threshhold 15) → Run else walk), `towards_and_away` (0x0052a9a0 — WalkBackwards inside the min band), `get_desired_heading` (0x0052aad0, P2). B.6's one-shot wire-CanCharge decision (#77) approximates the fast-path only — no per-tick re-evaluation, so a running auto-walk never demotes to walk-pace near the target (the R3 visual-pass expected-diff "auto-walk-at-run walk-pace legs (R4)"). `PlanMoveToStart` has NO distance logic at all. | decomp §5c/§5d/§5e | PlayerMovementController.cs:452-487 (`_autoWalkInitiallyRunning` one-shot), :701-706; ServerControlledLocomotion.cs:35-53 | **BLOCKER** | -| M3 | **No node plan** (`[TurnToHeading(face)] → [MoveToPosition] → [TurnToHeading(final)]?` with `BeginNextNode` stepping): auto-walk's turn-first phase + 30° walk-while-turning band are invented substitutes (AD-26's 5°/30°); the `use_final_heading` (0x40) node is entirely missing — wire `DesiredHeading` is parsed and thrown away. | §3c/§4a/§4b/§6f | PlayerMovementController.cs:606-706; CreateObject.MoveToPathData.DesiredHeading unused (r4-ace §5 wire layer) | **BLOCKER** | -| M4 | **No aux-command steering**: retail corrects heading DURING the move by issuing TurnRight/TurnLeft as interpreted motions through `_DoMotion` when \|diff\| leaves the ±20° deadband, and STOPS them re-entering it — observers see actual turn cycles. acdream rotates remotes by quaternion math (`RemoteMoveToDriver` π/2 rad/s + the borrowed ACE snap) and the local player via yaw writes — no turn motions dispatched, wrong legs. | HandleMoveToPosition Phase 1 @307225-307280 (20/340 deadband) | RemoteMoveToDriver.cs:68 (snap tolerance), :236 (ACE set_heading fudge); PlayerMovementController.cs:658-672 | **HIGH** | -| M5 | **Arrival predicate approximated + wrong distance metric**: retail `GetCurrentDistance` uses `cylinder_distance(own r/h, target r/h)` when `use_spheres` (0x400 — set in every ACE MoveTo packet) — edge-to-edge; acdream measures center-distance and compensates with AD-8's `max(minDistance, distanceToObject)` hack + an invented `ArrivalEpsilon 0.05` + the AD-26 facing gate. Retail: `moving_away ? dist ≥ min_distance : dist ≤ distance_to_object`, distance-only. | GetCurrentDistance §5a; arrival @307306-307331 (adjudicated r4-ace §1) | RemoteMoveToDriver.cs:161 (AD-8), :170-254; PlayerMovementController.cs:606-620 (AD-26 gate at :618) | **HIGH** | -| M6 | **No progress detector / fail distance**: `CheckProgressMade` (1 s window, 0.25 units/s incremental AND overall), `fail_distance` → CancelMoveTo(0x3D YouChargedTooFar), the FLT_MAX progress-clock reseeds on retarget. acdream substitutes the AD-9 1.5 s staleness timer (a liveness guard retail doesn't have) and parses FailDistance into `MoveToPathData` without a consumer. | CheckProgressMade §5b; @307300-307331; HandleUpdateTarget retarget @307843-307861 | RemoteMoveToDriver.cs:136 (AD-9); CreateObject.cs MoveToPathData.FailDistance unused | **HIGH** | -| M7 | **Types 8/9 (TurnToObject/TurnToHeading) dropped end-to-end** — the wire parser handles mt 6/7 only (mt 8/9 fall through to an empty Parsed: heading + params silently discarded); no consumer exists. These are the plan-of-record's "dropped D9/DEV-5 commands". | unpack §2f cases 8/9; UnPackNet 0xc-byte form §2g | UpdateMotion.cs:252 (`movementType is 6 or 7` — no 8/9 branch) | **HIGH** | -| M8 | **Target re-tracking absent** (type 6/8 initialization + retarget): no set_target/HandleUpdateTarget chain; the wire target guid (mt 6) is parsed into `MoveToPathData.TargetGuid` and never used — acdream chases the packet-time ORIGIN and relies on ACE's ~1 Hz MoveTo re-emit for freshness. Also no quantum retune (Phase 3, dist/speed ETA). | HandleUpdateTarget §6d; MoveToObject §3b; Phase 3 @307378-307436; §9f seams | RemoteMoveToDriver.cs:44-51 (doc'd skip = AP-8); UpdateMotion.cs:313 (guid parsed, unused) | **HIGH** (P4 pins the R4 answer) | -| M9 | **TS-36 dangling**: `InterruptCurrentMovement` is a no-op Action — `jump()` (:921/:1833 sites), `StopCompletely` (:1062), and DoMotion/StopMotion's cancel_moveto bit interrupt NOTHING. Once a real moveto exists, a jump or user input mid-moveto would leave the moveto running alongside — the exact "silent double-motion" the TS-36 row predicts. | interrupt_current_movement §9e → MovementManager::CancelMoveTo §2c | MotionInterpreter.cs:645/:921/:998/:1062/:1833; register TS-36 | **BLOCKER** for V5 | -| M10 | **PerformMovement discipline absent**: every new moveto must CancelMoveTo(0x36) + `unstick_from_object` FIRST (§3a); MoveToObject/MoveToPosition StopCompletely unconditionally, TurnTo\* only on the stop_completely bit (0x10000); self-target → CleanUp+Stop degenerate. acdream's `BeginServerAutoWalk` just overwrites fields. | PerformMovement §3a; §3b-§3e | PlayerMovementController.cs:452-487 | **HIGH** | -| M11 | **MovementType/MovementStruct too narrow**: enum lacks `Invalid=0, MoveToObject=6, MoveToPosition=7, TurnToObject=8, TurnToHeading=9`; MovementStruct lacks `ObjectId/TopLevelId/Pos/Radius/Height/Params`. | MovementTypes acclient.h:2856; MovementStruct acclient.h:38069 | MotionInterpreter.cs:101-113 (1-5 only), :408-422 | MED (mechanical) | -| M12 | **WeenieError lacks the moveto codes**: 0x0B NoMotionInterpreter, 0x36 ActionCancelled, 0x37 ObjectGone, 0x38 NoObject, 0x3D YouChargedTooFar (0x47 exists). Local-only, safe add. | codes §7c/§7a; A10 table | MotionInterpreter.cs:136-205 | LOW | -| M13 | **`my_run_rate` not fed from the MoveTo wire**: retail unpack cases 6/7 write `minterp->my_run_rate = read_float()`; acdream parses `MoveToRunRate` but only feeds the PlanMoveToStart seed — the interp's `MyRunRate` (:585) never sees it, so `apply_run_to_command`'s speed scale during a moveto uses stale rate. | unpack @300603/@300660 | UpdateMotion.cs:342; GameWindow.cs:4415 | MED | -| M14 | **Sticky discipline absent**: MoveToPosition/TurnToHeading must CLEAR sticky (0x80) on the stored params (`&= 0xffffff7f`); BeginNextNode's empty-queue sticky branch hands off to `PositionManager::StickTo(tlid, radius, height)` — reads the three fields BEFORE CleanUp zeroes them. R4 ships the seam (Action, no-op → R5 StickyManager) + the verbatim ordering. Wire side: the 0xF74C header sticky bit (motionFlags & 0x1 → trailing sticky guid dword) is parsed by nobody (`_motionFlags` dead at UpdateMotion.cs:135). | §3c @0052a3e5; §3e @0052a70c; BeginNextNode §4b; unpack case 0 @0052455d | nothing; UpdateMotion.cs:135 | MED (seam now, body R5) | -| M15 | **Heading/geometry helpers absent in Core**: `Position::heading`, `heading_diff` (P3), `heading_greater`, `Frame::get/set_heading`, `cylinder_distance`. Local yaw↔AC-heading conversion exists only in the outbound packers and ad-hoc quaternion math. | §5f/§5g/§5a; P5 | SceneryHelpers.cs:80 (render-side only); RemoteMoveToDriver quaternion yaw | **BLOCKER** (mechanical) | -| M16 | **`PlanFromVelocity` invented heuristics carry NO register row** (StopSpeed 0.20, RunThreshold 1.25 — acdream constants for the UP-dead-reckoning cycle pick, GameWindow.cs:4942/:5350 "until S6"). Found during this audit: a divergence without a row is a register-rule violation regardless of R4. Its CONSUMER (UP-DR cycle selection) is R6 scope, so the code survives R4 — the row must land now. | none (that's the problem) | ServerControlledLocomotion.cs:54-87; GameWindow.cs:4937-4943, :5350 | MED (bookkeeping, fix in V4) | -| M17 | **Speculative use-turn bypasses the (future) manager**: `InstallSpeculativeTurnToTarget` (GameWindow.cs:12122) locally predicts ACE's MoveTo before the wire packet via the auto-walk machinery + the 7.5 m CanCharge guess + AP-23 radius buckets. Retail's client-side use flow issues local `CPhysicsObj::TurnToObject/MoveToObject` through the SAME manager (§9a/§9b callers). | §9a/§9b | GameWindow.cs:11915/:12004/:12122-12164 | MED (rewire in V5) | - ---- - -## 2. KEEP LIST — already matching retail (do not re-port) - -| Behavior | Retail anchor | acdream anchor | -|---|---|---| -| `MovementParameters` flags+scalars class: A4 masks, ctor defaults 0x1EE0F / dto 0.6 / threshhold 15 / FLT_MAX / speed 1 / HoldKey.Invalid, CanCharge-false + threshold-15 ACE-trap notes | ctor 0x00524380; W0-pins A4 | `Motion/MovementParameters.cs` — V1 EXTENDS with get_command family; no field changes | -| The entire `_DoMotion`/`_StopMotion` seam target: `adjust_motion(ref,ref,HoldKey)` + `DoInterpretedMotion`/`StopInterpretedMotion(uint, MovementParameters)` incl. the retail double-adjust (MoveToManager adjusts, DoInterpretedMotion adjusts again — r4-ace §1 "do NOT fix") | §7a/§7b; 0x00529010/0x00529080 | MotionInterpreter.cs:1241/:2861/:3016 — untouched | -| WeenieError numerics 0x8/0x24/0x3f-0x49/0x47 (R3-W1 renumber) — V1 only ADDS values | A10 table | MotionInterpreter.cs:136-205 | -| mt 6/7 wire parse: UnPackNet field order (bitfield, dto, min, fail, speed, threshhold, desiredHeading) + runRate tail + mt-6 guid head — verified against §2g this session | UnPackNet 0x0052ac50 §2g | UpdateMotion.cs:280-356 + `CreateObject.MoveToPathData` — V3 adds mt 8/9 beside it | -| `OriginToWorld` (landblock-local origin → streaming world) | Position rebase equivalence (P5) | RemoteMoveToDriver.cs:263-277 — MOVES to the manager's home in V4 (the one survivor of the file) | -| MotionSequenceGate (S1) in front of ALL 0xF74C routing — R4 changes nothing upstream of the gate | 0xF74C dispatch pc:357214 | GameWindow OnLiveMotionUpdated head | -| R2 pipeline (MotionTableManager/GetObjectSequence/funnel sink) — MoveToManager's dispatched motions flow through it like any other interpreted motion; the animation side needs ZERO new work | R2/R3 shipped | Motion/ classes + MotionTableDispatchSink | -| Contact/OnWalkable transient-state plumbing (local + remote) — UseTime's `transient_state & 1` gate reads the same source `contact_allows_move` uses (P7 confirms) | @307781 | MotionInterpreter.cs:1979/2089 pattern; PhysicsBody TransientState | -| HitGround/LeaveGround verbatim bodies (R3-W4) — V4/V5 add the moveto HitGround call BESIDE interp.HitGround (retail order: minterp first, then moveto, §2d); LeaveGround has NO moveto side (COMDAT no-op, §2e) | MovementManager::HitGround 0x00524300 | MotionInterpreter.cs HitGround/LeaveGround + their App call sites | -| Jump charge UI (AP-24), run/jump skill defaults (TS-21), AP diff cadence (AP-30), outbound packers — untouched by R4 | — | PlayerMovementController.cs sections 4-8 | -| AD-27 Use/PickUp re-send on arrival (ACE MoveToChain-timeout workaround) — SURVIVES, re-anchored to the new `MoveToComplete` seam (§4); retail notifies nothing on arrival (CleanUpAndCallWeenie is CleanUp+Stop only, §7e) | §7e | GameWindow.cs:11915 subscription; register AD-27 | -| AP-23 per-type use-radius heuristic — survives (ACE's close-branch broadcasts nothing actionable; unchanged by R4), re-anchored | ACE Player_Move.cs:66 | GameWindow.cs:12102-12164; register AP-23 | -| `PlanFromVelocity` (UP-DR cycle heuristic) — survives to R6 with its NEW register row (M16); only `PlanMoveToStart` dies in R4 | retire R6 (per-tick order) | ServerControlledLocomotion.cs:54-87 | - ---- - -## 3. COMMIT SEQUENCE — dependency-sorted, each ONE commit, tests-first - -New code target: `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (+ -`MoveToMath.cs` for the free functions) per plan rule 4 — pure logic, GL-free, -seams only. NAME WATCH: retail's `MoveToManager::MovementNode {type, heading}` -must NOT collide with R2's `Motion/MotionNode.cs` (the pending_motions node) — -name it `MoveToNode`, register-note the rename. Tests: -`tests/AcDream.Core.Tests/Physics/Motion/`. Every commit: build+test green, -register rows added/retired in-commit. - -**V0 — pins + research docs (docs only).** -Commit `docs/research/2026-07-03-r4-movetomanager/` (decomp extraction, ACE -cross-ref, this plan, `V0-pins.md` resolving P1-P7 in the W0-pins format — -P1's ACE trace + source read and P3's Ghidra decompile are load-bearing; P2/P5/P6 -are cheap; P4 is a recorded decision). Fix the RemoteMoveToDriver.cs:53-57 stale -claim in the same commit. Deps: none. - -**V1 — command-selection family + state widening (Core, no consumers).** (closes M2-mechanics, M11, M12, M15) -`MovementParameters` gains `GetCommand(dist, heading, out motion, out holdKey, -out movingAway)` (verbatim §5c INCLUDING the CanCharge 0x10 fast-path ACE dropped -— A13+A15 trap: retail's version of BOTH), `TowardsAndAway` (§5d), -`GetDesiredHeading` (P2 pin), plus a `FromWire(uint bitfield, dto, min, fail, -speed, threshhold, desiredHeading)` factory (UnPackNet semantics — bit masks per -A4) and the field-by-field copy the entry points do. `MoveToMath.cs`: `HeadingDiff` -(P3 pin), `HeadingGreater` (§5f), `PositionHeading` + `GetHeading/SetHeading` -(P5 pin — REUSING the outbound packer's yaw↔heading conversion, one convention -in the codebase), `CylinderDistance` (§5a signature). `MovementType` gains -Invalid/6/7/8/9; `MovementStruct` gains `ObjectId/TopLevelId/Pos(world+cell)/ -Radius/Height/Params`. `WeenieError` += 0x0B/0x36/0x37/0x38/0x3D. -Tests first: get_command truth table (all four flag quadrants × distance bands × -the hold-key cascade incl. CanCharge fast-path, threshold-edge ≤ vs <, -walk-incapable), towards_and_away three bands, heading helpers wrap/epsilon -(0.000199999995f literal) tables, cardinal-heading goldens (P5). -Fixture source: **synthetic + A4/A10 pins**. Deps: V0. - -**V2 — MoveToManager verbatim (Core class + conformance harness; no App wiring).** (closes M1/M3/M4/M5/M6/M10/M14-core) -All 33 members per the extraction: ctor/Create/InitializeLocalVariables (flags -word + context_id zeroed, floats stale, FLT_MAX distance resets — NOT ACE's A2/A3) -/Destroy; PerformMovement (cancel 0x36 + unstick first, 4-way); MoveToObject/ -MoveToPosition/TurnToObject (desired-heading clobber quirk VERBATIM)/TurnToHeading -(immediate BeginNextNode — ACE's A4 gap not copied); MoveToObject_Internal/ -TurnToObject_Internal (fmod, sought-heading read); node factories + `MoveToNode` -(managed List<> — AD-34 wording); BeginNextNode (sticky handoff reads -radius/height/tlid BEFORE CleanUp, StickTo seam no-op); BeginMoveForward -(localParams: clear 0x8000, holdKey from get_command, write-back to stored -params, progress-clock seed); BeginTurnToHeading (empty-head → CancelMoveTo(8) -per A10 — not ACE's throw; PreviousHeading := DIFF quirk verbatim); -UseTime (retail gate polarity: `tlid == 0 || type == Invalid || initialized` — -NOT ACE's A12 negation); HandleMoveToPosition (Phase 1 aux 20°/340° — NO ACE A6 -set_heading snap, NO A8 inRange block; Phase 2 arrival `moving_away ? dist ≥ min -: dist ≤ dto`, fail-distance 0x3D; Phase 3 quantum ETA retune via seam); -HandleTurnToHeading (heading_greater → snap set_heading(node.Heading, send:true) -+ pop + stop + next); HandleUpdateTarget (init/retarget split, 0x37/0x38 codes, -FLT_MAX reseeds); HitGround; CheckProgressMade (1 s / 0.25 both-rates); -GetCurrentDistance (use_spheres → cylinder); CleanUp (stop current+aux, -clear_target gate, InitializeLocalVariables — does NOT drain nodes); -CleanUpAndCallWeenie (CleanUp THEN StopCompletely — reentrancy-safe ordering -+ the `MoveToComplete` seam, §4); CancelMoveTo (drain + CleanUp + Stop; the -WeenieError arg kept-but-unread per §7c); _DoMotion/_StopMotion (§7a — adjust -then dispatch); is_moving_to; fail_progress_count as a write-only field (§8). -Seams (ctor-injected, §4): interp, StopCompletely, position/heading accessors, -own+target radius/height, IsInterpolating, Contact, set_target/clear_target/ -quantum, unstick, StickTo, MoveToComplete. -Tests first: per-function conformance tables from the extraction's constants -inventory (§12); the reentrancy test (CancelMoveTo → CleanUpAndCallWeenie → -interp.StopCompletely → InterruptCurrentMovement → CancelMoveTo no-ops on -Invalid); quirk goldens (TurnToObject heading clobber ⇒ final = face-object; -BeginTurnToHeading stores diff; UseTime gate matrix incl. uninitialized type-6 -stall); a scripted end-to-end table drive (positions fed per tick → expected -node pops + dispatched motion ids/hold keys, incl. run→walk demote inside -threshold 15). -Fixture source: **synthetic + §12 constants (+ V0 cdb goldens if captured)**. -Deps: V1. - -**V3 — wire completion: mt 8/9 + my_run_rate + params exposure.** (closes M7, M13, M14-wire-note) -UpdateMotion parses mt 8 (guid + wire_heading + 3-dword UnPackNet, P6 order) and -mt 9 (3-dword UnPackNet) into a widened `MoveToPathData` (or a sibling -`TurnToPathData`) carrying the DECODED bitfield; mt 6/7 exposure widened so ALL -UnPackNet fields reach the consumer as a `MovementParameters` via `FromWire` -(today only ad-hoc bit properties). Parse the 0xF74C motionFlags sticky-guid -trailer (bit 0x1 → read dword; carried, unconsumed until R5) so the buffer -cursor is honest; standing_longjump bit (0x2) NOTED as an R5 unpack_movement -item, not consumed here. `MoveToRunRate` documented as the `MyRunRate` write -the V4/V5 consumers perform (unpack @300603). -Tests first: golden-byte fixtures generated from ACE's event writers (P6) for -all four types + flag permutations; existing mt 6/7 fixtures green unchanged. -Fixture source: **ACE-writer golden bytes**. Deps: V1 (types), parallel with V2. - -**V4 — REMOTE cutover: per-remote MoveToManager; RemoteMoveToDriver + PlanMoveToStart DELETED.** (closes M1-remote, M4/M5/M6/M8-remote; retires AD-8, AD-9, AP-8, AP-9; adds the M16 row) -Each `RemoteMotion` gains a `MoveTo` manager bound to its existing -`Motion` interp + body (construction beside the R3 sink bind, §4). GameWindow -remote UM routing (:4549-4620): mt 6 → resolve guid against the entity table → -`MovementStruct{MoveToObject, radius/height from the entity's setup}` → -`MoveTo.PerformMovement`; unresolvable → degrade to MoveToPosition(wire origin) -per §2f; mt 7/8/9 likewise; `Motion.MyRunRate = MoveToRunRate`. The P4 -`TargetTracker` adapter feeds `HandleUpdateTarget` (register row). Per-tick: the -:9594-9682 block becomes `rm.MoveTo.UseTime()` (same slot the legacy driver -occupied — see the placement decision below); remote HitGround sites add -`rm.MoveTo.HitGround()` after `rm.Motion.HitGround()` (§2d order). DELETE: -`RemoteMoveToDriver.cs` (OriginToWorld moves to `MoveToMath`; TurnRateFor's -surviving consumers, if any outside the deleted paths, get the constant from the -interp's own apply_run_to_command home), `PlanMoveToStart` + its :4412 seeding -branch (the manager's own `_DoMotion` → funnel sink now produces the cycle, -identical mechanism to every other interpreted motion), the -`HasMoveToDestination/MoveToDestinationWorld/LastMoveToPacketTime/ -ServerMoveToActive` RemoteMotion fields and their :4917/:5329 reads. -Registers in-commit: AD-8/AD-9/AP-8/AP-9 DELETED; NEW AP row "TargetTracker -minimal re-tracking adapter (P4) — full TargetManager port R5"; NEW AP row for -PlanFromVelocity's constants (M16, survives to R6); note that arrival now uses -retail cylinder distance (the AD-8 max() class is GONE — watch melee-range stop -distance in the visual pass). -Tests first: scripted chase/flee/retarget/fail-distance scenario harness driving -a manager against a mocked tracker; dispatched-motion trace conformance (NPC -chase emits WalkForward+HoldKey_Run → aux turns → stop, per V2's table); -existing remote funnel suites green. Live smoke: NPC chase + ACDREAM_DUMP_MOTION. -Fixture source: **V2 harness + live smoke**. Deps: V2+V3. - -**V5 — LOCAL PLAYER cutover: B.6 auto-walk DELETED; TS-36 bound (ONE commit, GameWindow + controller — do NOT fan out, feedback_dont_parallelize_coupled_plan_slices).** (closes M1-local, M9, M10, M17; retires TS-36, AD-26; re-anchors AD-27, AP-23; adds the P1 row if pinned as an adaptation) -`PlayerMovementController` gains a `MoveTo` manager bound to its `Motion` interp -(exposed like `Motion` was for R3-W2's bind). GameWindow local routing -(:4507-4547): the `BeginServerAutoWalk` call becomes MovementStruct → -`MoveTo.PerformMovement` (OriginToWorld unchanged); the P1 pin governs the mt-0 -companion echo (adaptation row if that's the pin). `Motion.InterruptCurrentMovement` -binds to `MoveTo.CancelMoveTo(ActionCancelled)` — TS-36 RETIRED; user input now -cancels the moveto exactly the retail way (input edge → DoMotion with -CancelMoveTo bit → interrupt seam → CancelMoveTo), so the explicit -"user-input cancel" check dies with the block. DELETE the whole B.6 block: -fields :275-:340 (incl. `IsServerAutoWalking`, `_autoWalkTurnDirectionThisFrame` -+ its :1528-1540 consumer), `BeginServerAutoWalk` :452, `EndServerAutoWalk` :495, -`DriveServerAutoWalk` :567-766, the :896 call + `autoWalkConsumedMotion` skip -plumbing (:1028). `AutoWalkArrived` is replaced by the `MoveToComplete(None)` -seam subscription (AD-27 re-anchored, same Use-resend behavior). -`InstallSpeculativeTurnToTarget` (:12122) rewires to a LOCAL -`MovementStruct{TurnToObject/MoveToObject}` through the player's manager -(retail §9a/§9b client-initiated shape; AP-23 buckets survive as the radius -source, row re-anchored). Per-tick: `MoveTo.UseTime()` at the :896 slot -(placement decision below). `[autowalk-*]` probes retarget to the manager -(PhysicsDiagnostics name kept). -Tests first: full suite green; controller edge-driven suites unchanged; -manager-driven use-walk scenario (turn node → walk → demote-to-walk near target -→ arrival → MoveToComplete fires once); reentrancy live test (jump mid-moveto -cancels it — TS-36's predicted failure); outbound golden-byte parity for a -scripted approach (MTS/AP bytes vs pre-cutover capture — the moveto issues -non-autonomous motions, so outbound autonomous traffic must NOT change). -**ONE user visual pass** (with R2/R3's if still pending): use a door at range -(walk-up + arrival + door opens once), use while facing away (visible turn -first), NPC chase legs (turn cycles during corrections, walk-pace close-in), -TurnTo emote-target if reachable. -Fixture source: **pre-cutover traces + golden-byte + V2 harness**. Deps: V4. - -**V6 — register sweep + roadmap + digest (docs/cleanup only).** -Verify retired: AD-8/AD-9/AP-8/AP-9 (V4), TS-36/AD-26 (V5). Verify added: -TargetTracker AP row, PlanFromVelocity AP row (M16), P1 echo AD row (if pinned), -MoveToNode-rename note under AD-34's wording, StickTo/quantum no-op seam rows -(→R5). Re-anchor: AD-25 (:1212 shifts after the controller deletion), AD-27, -AP-23, AP-24, TS-21, AP-30 line numbers. Roadmap stage table (R4 shipped); -milestones check; memory digest note (animation deep-dive: MoveToManager gap -CLOSED; the "three approximations" pattern retired). -Deps: V5. - -Parallelization: V0∥nothing; V1→V2 sequential; V3∥V2 (after V1). V4→V5 -sequential (remotes prove the manager before the local cutover). V4 and V5 both -touch GameWindow — single-agent each. - -**DECISION — B.6 auto-walk dies in R4 (V5), not R5.** Grounds: the plan of -record's R3 keep-list marked it "REPLACED in R4 by MoveToManager"; the mandate -is delete-in-stage; everything DriveServerAutoWalk does is a subset of the -verbatim manager (turn-first = TurnToHeading node; 30° walk-while-turning band = -retail's 20° aux band; arrival = the distance predicate; user cancel = the -cancel_moveto interrupt chain); keeping it alongside a real manager would mean -two writers on the same motion channels — the exact double-motion hazard TS-36 -warns about. What survives R5-ward is only the two seams the manager can't fill -yet: StickTo (R5 StickyManager) and the full TargetManager (P4 row). - -**DECISION — per-tick driver placement pre-R6: the legacy drivers' own slots.** -`MoveTo.UseTime()` runs (a) for the player: in `PlayerMovementController.Update` -at the exact point `DriveServerAutoWalk` runs today (:896, before the -input-driven motion block); (b) for remotes: in the GameWindow per-remote tick -block where `RemoteMoveToDriver.Drive` runs today (:9594). Justification: -least-invasive — both slots already sit at the "after inbound wire, before/with -physics integrate" altitude the legacy approximations were tuned for, so the -cutover changes WHAT steers, not WHEN, and every behavior diff in the visual -pass is attributable to the manager itself rather than to a reordering. Retail's -true slot (`UpdateObjectInternal`: … transition sweep → MovementManager.UseTime → -PositionManager.UseTime, plan module map) is R6's deliverable for ALL managers -at once; R3 set the precedent (r3-port-plan §4 rule 2: "tick placement -provisional until R6"). The provisional placement gets a one-line note in both -call sites, not a register row (ordering-within-tick is R6's audited scope). - ---- - -## 4. WIRING CONTRACT — MoveToManager ↔ the R3 MotionInterpreter (+ GameWindow routing) - -Retail chain being stood in for (R5 inserts MovementManager as a null-guarded -relay WITHOUT behavior change — same rule as R3 §4's MotionDone note): - -``` -retail: unpack_movement / CPhysicsObj entry points - └─ MovementManager::PerformMovement (1-5 → CMotionInterp, 6-9 → MoveToManager) - └─ MoveToManager ── _DoMotion/_StopMotion ──► CMotionInterp - (adjust_motion → DoInterpretedMotion/StopInterpretedMotion) -R4: GameWindow OnLiveMotionUpdated (mt switch — the pre-existing call-site dispatch) - ├─ mt 0 → funnel (unchanged; P1 pin governs the local-player echo) - ├─ mt 1-5 → interp paths (unchanged) - └─ mt 6/7/8/9 → entity.MoveTo.PerformMovement(MovementStruct) [+ MyRunRate write] -``` - -**Construction & binding (per entity, at the same site as the R3 sink bind):** -- Remote: where `RemoteMotion.Motion` + the MotionTableManager sink are wired - (GameWindow entity creation), construct `MoveTo = new MoveToManager(...)` with: - - `interp` = `remoteMot.Motion` (the `_DoMotion` target — adjust_motion + - DoInterpretedMotion/StopInterpretedMotion, NOTHING else; MoveToManager never - calls DoMotion/set_hold_run/raw-state APIs — decomp §7b), - - `stopCompletely` = `() => Motion.StopCompletely()` (retail routes - CPhysicsObj::StopCompletely → MovementStruct type 5 → interp; the direct - call is the same body pre-R5), - - body accessors: position+cell, `GetHeading`/`SetHeading(deg, send)` (P5 - convention; `send` flags the outbound heading snap — remotes: no-op send), - own radius/height (setup shape), Contact (`PhysicsObj.TransientState`), - `IsInterpolating` = InterpolationManager queue-non-empty, - - target seams: `set_target/clear_target` → the P4 TargetTracker; - `get/set_target_quantum` → tracker-recorded value (accepted, non-throttling); - - `unstick` → `Motion.UnstickFromObject` (existing no-op seam, R5); - `stickTo(tlid, radius, height)` → NEW no-op Action seam (R5 StickyManager; - register row); - - `MoveToComplete(WeenieError)` → remote: nothing (retail-faithful); - local player: the AD-27 Use-resend subscription (fires ONLY on `None`). -- Local player: `PlayerMovementController` constructs/exposes `MoveTo` bound to - its own `Motion`; GameWindow routes local mt 6-9 to it. - -**The cancel chain (TS-36 retires here):** -``` -user input edge / jump() / StopCompletely - └─ MotionInterpreter — params.CancelMoveTo bit / hardcoded sites (:921/:998/:1062/:1833) - └─ InterruptCurrentMovement?.Invoke() [R3 seam, was no-op] - └─ R4 bind: entity.MoveTo.CancelMoveTo(WeenieError.ActionCancelled) - └─ drain nodes → CleanUp (stops current+aux via _StopMotion, - clear_target, InitializeLocalVariables → movement_type=Invalid) - → StopCompletely -``` -Reentrancy invariant (MUST be tested, V2): the tail `StopCompletely` re-enters -`InterruptCurrentMovement` → `CancelMoveTo`, which NO-OPS because CleanUp already -reset `movement_type` to Invalid BEFORE the stop (retail ordering §7e; ACE note -r4-ace §3). Do not reorder CleanUp/StopCompletely. - -**HandleUpdateTarget feed (P4):** TargetTracker (App) watches the entity table; -delivery → `entity.MoveTo.HandleUpdateTarget(TargetInfo)`; context 0 only -(retail CPhysicsObj::HandleUpdateTarget @280794 gates on context_id == 0). -Target despawn → `status = ExitWorld` (manager cancels 0x37/0x38 itself). - -**Per-tick + ground events:** `MoveTo.UseTime()` at the two legacy slots -(decision above). Every existing `Motion.HitGround()` call site adds -`MoveTo.HitGround()` AFTER it (retail §2d order: minterp then moveto). -LeaveGround/ReportExhaustion: NO moveto call — COMDAT no-ops (§2e), do not -invent. - -**GameWindow mt 6-9 routing detail (V4/V5):** -1. MotionSequenceGate first (unchanged). -2. `Motion.MyRunRate = MoveToRunRate` (unpack @300603/@300660). -3. mt 6: resolve `TargetGuid` in the entity table → top-level parent id (retail - resolves `parent ?: target`, §9a — acdream: container/parent link if the - entity model has one, else the guid itself) + setup radius/height → - `MovementStruct{MoveToObject, ObjectId, TopLevelId, Radius, Height, - Params=FromWire(...)}`. Unresolvable → `MovementStruct{MoveToPosition, - Pos=OriginToWorld(wire origin)}` (the §2f degrade — NOT an error). -4. mt 7: MoveToPosition(OriginToWorld(origin), FromWire params). -5. mt 8: resolve → TurnToObject; unresolvable → params.DesiredHeading = - wire_heading, TurnToHeading (§2f fallback). -6. mt 9: TurnToHeading(FromWire 3-field params). -7. mt 0 for the local player: per the P1 pin (echo suppression rule or wire - discriminator); for remotes: unchanged funnel + (retail head shape) an - interrupt — remotes' UM streams from ACE already interleave moveto and - interpreted UMs, and V4 must apply the SAME P1 answer to remotes chasing - under ACE's re-emit (a fresh mt-6 re-emit cancels+restarts via - PerformMovement regardless, so remotes are insensitive to the pin either way). - -**What the manager dispatches (animation side — zero new work):** -`_DoMotion(0x45000005 WalkForward, localParams{holdKey})` → adjust_motion -promotes to RunForward×runRate under HoldKey_Run → DoInterpretedMotion → the -R2 sink → MotionTableManager → GetObjectSequence — the identical path a wire -RunForward takes today. `PlanMoveToStart`'s seeding job ceases to exist rather -than being replaced. - ---- - -## 5. NEGATIVE RESULTS / DO-NOT-INVENT (binding on subagents) - -- **`MoveToManager::LeaveGround` / `ReportExhaustion` — COMDAT-folded no-ops** - in this build (decomp §2e); `CMotionInterp::HandleEnterWorld` likewise. No - members, no seams, no behavior. -- **`fail_progress_count` is write-only in RETAIL too** (§8: 6 sites, zero - reads). NO give-up-after-N mechanism exists — the only aborts are - fail_distance (0x3D) and target status (0x37/0x38). Do not invent a stall - timeout to replace AD-9's timer; deleting the timer is the point. -- **`CancelMoveTo`'s WeenieError arg is NEVER READ in the body** (§7c) — every - call site's code (8/0x36/0x37/0x38/0x3D) is dead in this build. Keep the - parameter (R5 parity + logging), wire no behavior to its value. -- **`CleanUpAndCallWeenie` contains NO weenie call in this build** (§7e — the - name is vestigial; body ≡ CleanUp + StopCompletely). acdream's - `MoveToComplete` seam is a documented CLIENT addition standing in for ACE's - server-side `OnMoveComplete` — do not present it as retail. -- **No listener/observer machinery on MoveToManager or MovementManager** - (grep-negative, §10). `MoveToComplete` is the one seam, added consciously. -- **`CPhysicsObj::cancel_moveto` does not exist** — the cancel entry is - `interrupt_current_movement` (§9e). **`CPhysicsObj::MoveToPosition` does not - exist** — position moves enter via unpack case 7 / direct manager call only. -- **Do NOT copy the ACE-isms** (all RETAIL-VERIFIED in r4-ace §4): A1 - stale-member UseFinalHeading read (read the ARGUMENT in MoveToPosition), A2 - InitializeLocalVars zeroing DistanceToObject (retail zeroes the FLAGS word), - A3 missing FLT_MAX resets, A4 missing BeginNextNode in TurnToHeading, A6 - set_heading snap in the aligned branch ("custom: sync for server ticrate"), - A7 `AlwaysTurn`, A8 `inRange` arrival block ("custom for low monster update - rate"), A11 WeenieObj NPE, A12 UseTime gate negation, A13 CanCharge-true - default, A14 threshold 1.0, A15 dropped CanCharge fast-path. -- **Do NOT "fix" the retail quirks**: TurnToObject's desired_heading write is - clobbered before any read (final heading = face-the-object; ACE matches); - BeginTurnToHeading stores the remaining DIFF into previous_heading; the - double adjust_motion (_DoMotion + DoInterpretedMotion-internal); PerformMovement - returning 0 unconditionally (errors surface via CancelMoveTo, not the return). -- **HandleTurnToHeading's `set_heading(node.Heading, true)` snap IS retail** - (0052a146) — the only heading snap in the whole family. Everything else - rotates via dispatched turn motions. -- **BN artifact ledger applies** (decomp §11): the chase-arrival and turn-pick - branch senses in the raw are `test ah,0x41` inversions — the adjudicated - readings (chase arrives at `dist ≤ distance_to_object`; diff ≥ 180 → - TurnLeft) are pinned; do not re-derive from the literal pseudo-C. The - `RemoteMoveToDriver.cs:53-57` "ACE swaps the predicates" doc claim is WRONG - (superseded in-file at :186-199) — fixed in V0. -- **TargetManager / StickyManager / ConstraintManager / PositionManager::StickTo - bodies were NOT extracted** — call shapes only (§9f/§9g). Do not invent - internals beyond the P4 minimal adapter; R5 owns the ports. -- **The older Ghidra chunk `chunk_00520000.c` is from a DIFFERENT build** for - this address region (§10) — function boundaries don't align; unusable for - adjudication. Use the named raw + Ghidra MCP on `patchmem.gpr` only. -- **MovementManager itself is R5** — R4 must not grow a premature facade; the - type dispatch stays at the existing GameWindow/controller call sites, and the - two direct binds (interrupt→CancelMoveTo, HitGround dual-call) are exactly - the relays R5 will absorb. diff --git a/docs/research/2026-07-03-r4-verify-session-handoff.md b/docs/research/2026-07-03-r4-verify-session-handoff.md deleted file mode 100644 index 770d1a50..00000000 --- a/docs/research/2026-07-03-r4-verify-session-handoff.md +++ /dev/null @@ -1,103 +0,0 @@ -# R4 live-verify session handoff — fresh-session entry point (2026-07-03, evening) - -Successor to `2026-07-03-phase-r-session-handoff.md` (whose postscripts -summarize the same arc — this doc is the current entry point). Worktree -`vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`, tree CLEAN -at `3c866f95`. Full suite green: **3,963** (+15 since the R4 handoff). - -## State - -**R4 is SHIPPED and user-verified live** (V0–V6 + five root-cause fixes the -live pass exposed). Confirmed working by the user's eyes: local use-walk -(turn-first, walk/run by distance, arrival, door opens once), door open + -door SWING animation, remote (retail-client) close-range moveto walking, -and remote run-distance movetos at correct animation pace AND speed (#160). - -## The five live fixes (trail, newest first — each is a real mechanism, no adaptations added) - -| Commit | Root cause fixed | -|---|---| -| `41006e79` | **#160 slow-motion remote runs**: remote interps had NO weenie → retail's `apply_run_to_command` rate chain (`weenie ? (InqRunRate() ?: my_run_rate) : 1.0`, raw 305062-305076) took the degenerate 1.0 branch; the wire's `MoveToRunRate` (stored in `MyRunRate` by the mt-6/7 unpack, M13) was never consumed. Fix: `RemoteWeenie` (Core) — retail's per-object ACCWeenieObject stand-in; `InqRunRate` FAILS → `my_run_rate` fallback; `IsThePlayer`=false ends the "null weenie counts as the player" A3 reading. | -| `350fb5e3` | **Door-swing snap** (register TS-40): CMotionInterp's detached-object link-strip guard (`physics_obj->cell == 0`, raw @305627) was proxied by `CellPosition.ObjCellId==0`, seeded only by the local player's `SnapToCell` → every REMOTE body read "detached" and every dispatched transition link was stripped same-tick. Fix: explicit `PhysicsBody.InWorld` set by SnapToCell + RemoteMotion construction. | -| `006cf659` | **Remote-player glide (partial)** + **door UMs dropped**: (a) remote PLAYERS' MoveToManagers were never ticked (the V4 UseTime slot was NPC-only) — extracted `TickRemoteMoveTo`, now also in the grounded player-remote (L.3 M2) pipeline; (b) UP-less entities (doors) never got a RemoteMotion → since the S2b funnel cutover their UM motions were parsed and dropped — rm now created on first UM, with a `LastServerPosTime>0` gate keeping UP-less bodies OUT of the dead-reckoning tick. | -| `24569fd2` | **Moveto stall #2**: login `SetPosition` ran before the DefaultSink bind in `EnterPlayerModeNow` → its StopCompletely A9 node had no completion partner → ONE immortal pending_motions node → wait-for-anims gate never opened. Fix: bind block moved above SetPosition; `LoginQueue_DrainsToEmpty_UnderProductionFeed` pins the invariant. | -| `c2dc1a88` | **Moveto stall #1 (the big one)**: the R3 port MISREAD `StopCompletely_Internal` (0x0050ead0) as a velocity zero — it is `CPartArray::StopCompletelyInternal` (0x00518890) = `MotionTableManager::PerformMovement(type 5)`, the ANIMATION-side stop whose UNCONDITIONALLY-queued entry is the matched pop for the A9 pending_motions node. Fix: `IInterpretedMotionSink.StopCompletely()` → the R2 manager's already-ported type-5 op. Also: retail's per-tick `CheckForCompletedMotions` slot (CPartArray::HandleMovement 0x00517d60) added to the controller tick, and the P1 autonomous-STORE family completed (unpack store 00509730, input-edge stores DoMotion@00510030/StopMotion@005100e0, section-2 stops per-frame stamping, speculative install stores false). | - -## The queue model (the load-bearing mental model — do not relearn) - -`pending_motions` (interp) pops HEAD-ANY on each `MotionDone`; completions -come from the manager's `pending_animations` entries. **Every enqueue must -have a completion partner** (dispatch → manager entry). One orphan = -permanent ≥1 backlog (later completions just relabel it) = `MotionsPending` -true forever = `BeginTurnToHeading`'s verbatim wait-for-anims gate wedges -every moveto. Orphan producers found: StopCompletely-before-sink-bind and -the misread StopCompletely_Internal. The `[autowalk-gate]` probe prints the -whole gate state — it is how both wedges were pinned. - -## OPEN — next session picks up here (full trails in docs/ISSUES.md) - -1. ~~**#161 — remote jump landing stuck in the falling pose**~~ **CLOSED - 2026-07-03 `b1cf0102`, user live-verified.** The prime suspect was - right; the mechanism was a BN decomp artifact: retail REWRITES the - apply pass's params word (raw 305778 smeared store, mask 0x37ff → - `ModifyInterpretedState=false`; ACE MotionInterp.cs:444-449 confirms), - so retail PRESERVES interpreted fwd through the fall and HitGround - re-dispatches it (the landing-link exit). Also: both landing blocks - cleared Gravity BEFORE `Motion.HitGround()` (whose verbatim state&0x400 - gate then no-opped — fixed order, AP-81); K-fix17's SetCycle DID - execute but re-set the clobbered Falling command — both copies deleted. - NOTE: "HitGround raw @305949" below was a mislabel (that line is inside - `move_to_interpreted_state`; real HitGround = 0x00528ac0 → - `apply_current_movement(0,0)`). Full trail: ISSUES Recently-closed #161. -2. ~~**#162 — glide-class adjudication**~~ **CLOSED 2026-07-03, no - adaptation.** User A/B: retail does NOT glide (new-fact branch) — and - post-#161 acdream doesn't either (user-verified walk/run-by-distance + - clean mid-chain key takeover). Mechanism re-audit: head-interrupt + - CancelMoveTo ports are retail-verbatim (retail's observer ALSO loses - the armed moveto on the first reflection); ACE's mt-0 reflections carry - the mover's real locomotion, so the legs stay correct after any cancel. - The old glide was killed by the R4-V5 stack + #161's apply-pass params - fix. Full evidence: ISSUES Recently-closed #162. -3. ~~**#163 — strip the TEMPORARY diagnostics**~~ **DONE 2026-07-03** - (`[autowalk-gate]` + clock, `[autowalk-feed]`, and the short-lived - `[MOVETO-CANCEL]` probe; the durable ProbeAutoWalk family stays). -4. ~~Combined R2+R3+R4 visual-pass remainder~~ **PASSED 2026-07-03, - user-verified** (jump/ledge momentum, run-in-circles blend, stop - settle, retail-observer view of +Acdream — all four "looks good"). - Two observations filed from the pass: **#165** (remote movers get - "swallowed" into walls a bit instead of stopping flush — remote-DR - collision, capture with PROBE_RESOLVE first) and **#166** - (slope-landing glide+bounce polish — the AD-25 + AP-7 + TS-4 - composite, post-R6 by user's call). NEXT SESSION ENTERS AT **R5** - (MovementManager facade + StickyManager + ConstraintManager + full - TargetManager — retires TS-39 + AP-79's adapter). - -## Session gotchas worth keeping - -- **Evidence-first**: two plausible-but-wrong theories (a per-tick - apply_current_movement "pump" — retail's apply callers are EVENT-driven - only; and "ACE chain broadcasts mt-0 locomotion" — it doesn't) were - killed by the raw + wire captures. The `[autowalk-gate]` probe and the - UM raw hex dumps (autonomy byte at offset +14) were the decisive tools. -- **Retail object-model completeness matters**: two of the five fixes were - "retail gives every placed object X and we only gave it to the player" - (a weenie; a cell/InWorld). When a dispatch path behaves differently for - remotes, check what retail's CPhysicsObj carries that our RemoteMotion - lacks BEFORE suspecting the ported logic. -- The launch recipe + graceful-close rules are unchanged (CLAUDE.md); the - client locks builds — ask the user to close it, never kill. -- Register: TS-40 added; TS-33 extended (orientation-diff gap); AP-79 - covers both tracker instances; TS-36/AD-26 retired earlier today. - -## Key files this session touched - -`MotionInterpreter.cs` (StopCompletely sink dispatch, InWorld guards), -`MoveToManager.cs` (MoveToComplete widened to BeginNextNode completion), -`MotionTableDispatchSink.cs` + `InterpretedMotionFunnel.cs` -(StopCompletely sink op), `PhysicsBody.cs` (InWorld), `RemoteWeenie.cs` -(new), `PlayerMovementController.cs` (UseTime slot + per-tick Check + -edge autonomy stores + temp probe), `GameWindow.cs` (P1 gate + store, -RouteServerMoveTo, TickRemoteMoveTo, rm-on-UM, EnterPlayerModeNow bind -order, player tracker feed), tests: `PlayerMoveToCutoverTests`, -`MoveToManagerCompletionSeamTests`, `InWorldLinkGuardTests`, -`RemoteWeenieRunRateTests`, `W6EdgeDrivenMovementTests` (apply-pass form). diff --git a/docs/research/2026-07-03-r5-entry-handoff.md b/docs/research/2026-07-03-r5-entry-handoff.md deleted file mode 100644 index 39968e42..00000000 --- a/docs/research/2026-07-03-r5-entry-handoff.md +++ /dev/null @@ -1,112 +0,0 @@ -# R5 entry handoff — fresh-session entry point (2026-07-03, night) - -Successor to `2026-07-03-r4-verify-session-handoff.md` (now fully closed -out — its items 1-4 are all struck through with outcomes). Worktree -`vigorous-joliot-f0c3ad`, branch `claude/vigorous-joliot-f0c3ad`, tree -CLEAN at `304327b0`. Full suite green: **3,964**. - -## State — the R2-R4 arc is DONE - -R4 shipped + user-verified; the whole follow-up queue closed **today, -all user-verified live**: - -| Item | Outcome | -|---|---| -| #161 landing pose | FIXED `b1cf0102` — retail's apply pass runs with `ModifyInterpretedState=false` (the BN decomp SMEARS the params bitfield store into the mush at raw 305778: `(word & 0x37ff) \| cancelMoveTo<<15 \| disableJump<<17`; ACE MotionInterp.cs:444-449 confirms). Fwd is PRESERVED through a fall; HitGround re-dispatches it → landing link. Plus: HitGround must run BEFORE the DR gravity-bit clear (its verbatim `state&0x400` gate — AP-81); K-fix17 SetCycle blocks deleted; `copy_movement_from` now copies `current_style` (raw 0051e757); ALL apply_current_movement caller polarities fixed (raw arg3 = DisableJumpDuringLink → `(N,0)` = allowJump TRUE). Lesson: [[feedback-bn-decomp-field-names]] (2nd artifact class). | -| #162 glide class | CLOSED, **no adaptation**. User A/B: retail does NOT glide — and post-#161 acdream matches (walk/run-by-distance movetos + clean mid-chain key takeover). Head-interrupt + CancelMoveTo re-audited retail-verbatim; ACE's mt-0 reflections carry the mover's REAL locomotion, so cancels never strand the legs. Evidence: ISSUES Recently-closed #162 + the launch-162 capture analysis. | -| #163 temp diags | STRIPPED `5ebe2be3` ([autowalk-gate]+clock, [autowalk-feed], [MOVETO-CANCEL]). Durable ProbeAutoWalk family kept. | -| R2-R4 visual pass | **PASSED** (jump/ledge momentum, run-in-circles, stop settle, retail-observer view). Filed #165 (remote wall-swallow) + #166 (slope-landing sled — the AD-25+AP-7+TS-4 composite, post-R6 per user). | - -## NEXT: R5 — MovementManager facade + PositionManager (Sticky/Constraint) + TargetManager - -**Retires: TS-39** (MoveToManager `StickTo`/`Unstick` are unbound no-op -seams — sticky movetos complete-and-stop instead of sticking; retail -hands off to `PositionManager::StickTo(tlid, radius, height)`, and every -`PerformMovement` head calls `unstick_from_object` → -`PositionManager::UnStick`) **and AP-79** (the P4 TargetTracker minimal -adapter — GameWindow feeds `HandleUpdateTarget` from the entity table; -retail's TargetManager is a full voyeur-subscription system with -per-target quantums). - -### Retail function inventory (grep'd, addresses verified) - -- **MovementManager** (facade, mostly thin): 00524000 MakeMoveToManager · - 00524020 SetWeenieObject · 005240d0 PerformMovement · 00524170 - move_to_interpreted_state · 005241b0 CancelMoveTo · 005241c0 - EnterDefaultState · 00524260 IsMovingTo · 00524280 motions_pending · - 005242d0 MotionDone · 005242f0 UseTime · 00524300 HitGround · 00524320 - LeaveGround · 00524340/00524350 HandleEnter/ExitWorld · 00524360 - ReportExhaustion · 00524440 unpack_movement · 00524790 - HandleUpdateTarget (routes TargetInfo → MoveToManager). Much of this - facade's BEHAVIOR is already ported piecemeal (funnel, RouteServerMoveTo, - TickRemoteMoveTo, the UseTime/CheckForCompletedMotions slots) — R5's job - is the STRUCTURE: one owner object per entity replacing the GameWindow - wiring sprawl, without changing dispatch behavior pinned by the - 183-case + funnel + moveto suites. -- **StickyManager**: 00555400 UnStick · 00555430 adjust_offset · 00555610 - UseTime · 005556e0 SetPhysicsObject · 00555710 StickTo · 00555780 - HandleUpdateTarget. -- **ConstraintManager**: 00556090 SetPhysicsObject · 005560c0 UnConstrain · - 005560d0 IsFullyConstrained · 00556180 adjust_offset · 00556240 - ConstrainTo. (`jump_is_allowed` already calls an `IsFullyConstrained` - stub — find it before re-porting.) -- **PositionManager** (owns the two): CPhysicsObj::MakePositionManager - 00510210 (also sets transient bit 0x80 + update_time). `stick_to_object` - / `unstick_from_object` on CPhysicsObj are the entry seams (today - Action seams on MotionInterpreter, wired per-entity in GameWindow). -- **TargetManager**: 0051a370 ctor · 0051a4a0 SetTargetQuantum · 0051a4f0 - SendVoyeurUpdate · 0051a5e0 GetInterpolatedPosition · 0051a650 - CheckAndUpdateVoyeur · 0051a6f0 NotifyVoyeurOfEvent · 0051a7e0 - ClearTarget · 0051a830 AddVoyeur · 0051a930 ReceiveUpdate · 0051aa90 - HandleTargetting · 0051ac30 SetTarget · 0051ad90 RemoveVoyeur. - AP-79's seams (`_setTarget`/`_clearTarget`/quantum get/set + - GameWindow's tracker blocks for remotes AND the `_playerMoveToTarget*` - player twin) are the integration points to replace. - -### Already ported — do NOT re-port - -MoveToManager (R4, full incl. the queue model), MotionInterpreter (R3 -full; #161 corrected the apply-pass params), MotionTableManager + -GetObjectSequence (R2), CSequence (R1), RemoteWeenie + PhysicsBody.InWorld -(R4-V5), DefaultSink binding order (bind BEFORE SetPosition — the -`LoginQueue_DrainsToEmpty_UnderProductionFeed` invariant). - -### R5-adjacent verification items (found reading unpack_movement this session) - -1. **Head stance-change dispatch**: raw @00524502-0052452c — before the - type switch, for ALL movement types: `if (InqStyle() != - command_ids[stanceIdx]) CMotionInterp::DoMotion(style, defaults)` (BN - mislabels InqStyle as `CBaseFilter::GetPinVersion`). Verify our UM - path does this for types 6-9 (mt-0 handles style inside the funnel). -2. **mt-0 header flags**: 0x100 → read sticky guid → `stick_to_object` - (raw @00524589); 0x200 → `standing_longjump` store (raw @0052458e). - `UpdateMotion.cs` PARSES both (cited §2f) — verify they're APPLIED - (sticky needs TS-39's machinery = this phase; longjump flag should - reach `MotionInterpreter.StandingLongJump`). -3. **#164** (filed): action-replay dispatches drop the per-action - Autonomous bit (raw 305982 sets params bit 0x1000 → AddAction). Fix - while touching the action path. - -## The load-bearing lessons (compressed) - -- **The queue model** (unchanged from R4): every `pending_motions` - enqueue needs a manager completion partner; one orphan = permanent - MotionsPending wedge. -- **BN smeared bitfield stores**: a fresh local struct + later "mush" - expression = decode the mush BEFORE porting "ctor defaults". ACE's C# - port of the same function is the fastest cross-check. Cost of missing - it: #161 + the wrong W6 entry-cache theory + a wrong pinned test. -- **Tests can pin bug values**: `AirborneBody_NoCycleDispatches_...` - asserted the CLOBBERED fwd (0x40000015) with a comment admitting it was - untested. When flipping behavior, audit what the old assertions PINNED. - -## Session gotchas - -- Launch recipe unchanged (CLAUDE.md). Client LOCKS builds — ask the user - to close it, never kill. Graceful close clears the ACE session in ~5 s. -- PowerShell `Tee-Object` writes UTF-16 → grep the background task's - plain-text output file instead of the tee'd log. -- ACDREAM_DUMP_MOTION=1 + ACDREAM_REMOTE_VEL_DIAG=1 give the full UM/seq - evidence chain ([UM_RAW] hex incl. autonomy byte, [FWD_WIRE], - [SEQSTATE], [MOTIONDONE]); #165's first step is ACDREAM_PROBE_RESOLVE - at a wall. diff --git a/docs/research/2026-07-03-r5-managers/r5-acdream-seams.md b/docs/research/2026-07-03-r5-managers/r5-acdream-seams.md deleted file mode 100644 index b21ad0c3..00000000 --- a/docs/research/2026-07-03-r5-managers/r5-acdream-seams.md +++ /dev/null @@ -1,272 +0,0 @@ -# R5 seam recon — current acdream integration points MovementManager/PositionManager/TargetManager will replace - -Repo root: `C:/Users/erikn/source/repos/acdream/.claude/worktrees/vigorous-joliot-f0c3ad` -Read-only recon. All line numbers verified against files as of this session (2026-07-03). - ---- - -## 1. MoveToManager seams — `src/AcDream.Core/Physics/Motion/MoveToManager.cs` (1605 lines) - -### Ctor-required seam delegates (lines 78-138, ctor 184-222) -All `Action`/`Func` fields, all REQUIRED (non-nullable, thrown in ctor if null): -- `_stopCompletely` (L78) — retail `CPhysicsObj::StopCompletely` -- `_getPosition` (L83) — retail `physics_obj->m_position` -- `_getHeading` (L87) — retail `CPhysicsObj::get_heading` -- `_setHeading` (L94) — retail `CPhysicsObj::set_heading(heading, send)` — the ONE heading snap, fired only from `HandleTurnToHeading` (L1168) -- `_getOwnRadius` (L99), `_getOwnHeight` (L103) — mover's own cylinder dims -- `_contact` (L107) — retail `transient_state & 1` (CONTACT bit) — `UseTime`'s tick gate -- `_isInterpolating` (L112) — retail `CPhysicsObj::IsInterpolating` → PositionManager (R5 body doesn't exist yet) -- `_getVelocity` (L116) — feeds Phase-3 TargetManager quantum retune -- `_getSelfId` (L120) — self-target detection -- `_setTarget` (L126) — retail `CPhysicsObj::set_target` → **TargetManager::SetTarget (R5, call-shape only)** -- `_clearTarget` (L130) — retail `CPhysicsObj::clear_target` → **TargetManager::ClearTarget (R5, call-shape only)** -- `_getTargetQuantum` (L134) / `_setTargetQuantum` (L138) — **TargetManager quantum (R5, call-shape only)** -- `_curTime` (L175, optional param, defaults to a per-call 1/30s stub) - -### AP-79 target seams (setTarget/clearTarget) -- `_setTarget(0, TopLevelObjectId, 0.5f, 0.0)` called from `MoveToObject` (L471) and `TurnToObject` (L592) — always context 0, radius 0.5, quantum 0.0. -- `_clearTarget()` called from `CleanUp` (L1499) when `TopLevelObjectId != 0 && MovementTypeState != Invalid`. -- Consumer of the callback: `HandleUpdateTarget(TargetInfo info)` (L1229-1283) — the P4 TargetTracker feed point. Ignores updates for any `info.ObjectId != TopLevelObjectId` (L1237). Two paths: deferred-start (`Initialized==false`, first callback) vs retarget-while-running. -- **Today's producer of `TargetInfo` is NOT a real TargetManager** — it's `GameWindow.TickRemoteMoveTo` (App-layer, §2c below) polling `_entitiesByServerGuid` each tick and manually diffing against `TrackedTargetRadius`. - -### TS-39 sticky seams (StickTo/Unstick) -- `public Action? Unstick { get; set; }` (L145) — retail `CPhysicsObj::unstick_from_object` → **PositionManager::UnStick (R5 body)**. Called unconditionally at the head of `PerformMovement` (L414). Optional — null = silent no-op. -- `public Action? StickTo { get; set; }` (L151) — retail `PositionManager::StickTo(object_id, radius, height)` (**R5 StickyManager body**). Called from `BeginNextNode`'s sticky arrival handoff (L708) with pre-CleanUp values `(tlid, radius, height)`. Optional — null = silent no-op. -- **Today, NEITHER `Unstick` nor `StickTo` is bound to anything** in GameWindow.cs — grep of `EnsureRemoteMotionBindings` and `EnterPlayerModeNow` shows no `rm.MoveTo.StickTo = ...` or `.Unstick = ...` assignment anywhere. Both are permanently null (silent no-op) in production today. (Separately, `MotionInterpreter.UnstickFromObject` — a DIFFERENT, unrelated seam on the interp, not the MoveToManager — IS bound in some contexts; see §3.) - -### setHeading seam -- `_setHeading: Action` — bound per-manager at construction (remote: `mtBody.Orientation = MoveToMath.SetHeading(...)`, GameWindow.cs:4277-4278; player: `pcMoveTo.Yaw = MoveToMath.YawFromHeading(h)`, GameWindow.cs:12996-12997). The `bool send` (network-echo flag) param is **UNCONSUMED** on both sides — register row TS-33 (GameWindow.cs:12983-12988 comment): a stationary heading snap never reaches the wire. - -### Public surface (entry points + drivers) -- `PerformMovement(MovementStruct mvs)` (L411) — the type-6..9 dispatch entry, called from `RouteServerMoveTo` (GameWindow.cs, §5below) -- `MoveToObject` / `MoveToPosition` / `TurnToObject` / `TurnToHeading` (L450, 501, 560, 613) -- `UseTime()` (L953) — THE TICK GATE: `!_contact()` / no pending node / (object-move `Initialized` gate) → dispatches `HandleMoveToPosition` / `HandleTurnToHeading`. Called from `GameWindow.TickRemoteMoveTo` (L4504) for remotes; not shown called for the player in the same function (player pump is via `_playerController`/`OnUpdateFrame` — see §2c). -- `HandleUpdateTarget(TargetInfo info)` (L1229) — AP-79 feed point (see above) -- `HitGround()` (L1292) — no-op if `MovementTypeState==Invalid`, else `BeginNextNode()`. Called from GameWindow at L5426 (remote landing) and L10070 (another remote landing site — see §2d). -- `CancelMoveTo(WeenieError error)` (L1461) — reentrancy-guarded drain+cleanup+stop; the `error` param is NEVER read in retail's body (kept for parity/logging only) -- `IsMovingTo()` (L397), `Destroy()` (L387), `CleanUp()` (L1485), `CleanUpAndCallWeenie()` (L1516) -- `MoveToComplete` (L168) — **CLIENT ADDITION, not retail** — fires `WeenieError.None` on natural completion only (never on cancel). Bound in `EnterPlayerModeNow` (GameWindow.cs:13024-13030) to re-fire the deferred AD-27 Use/PickUp action. - ---- - -## 2. GameWindow wiring — `src/AcDream.App/Rendering/GameWindow.cs` (13,587 lines) - -### 2a. `EnsureRemoteMotionBindings` (GameWindow.cs:4234-4308) -Binds, once per `RemoteMotion` (guarded by `rm.Sink is not null` early-return, L4239): -- `rm.Sink = new MotionTableDispatchSink(ae.Sequencer)` with `TurnApplied`/`TurnStopped` callbacks (L4243-4255) feeding `rmForSink.ObservedOmega` -- `rm.Motion.DefaultSink = rm.Sink` (L4256) -- `rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations` (L4257) -- `rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState()` (L4258) -- `rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions` (L4261) — R3-W5 per-op zero-tick flush -- `rm.MoveTo = new MoveToManager(...)` (L4270-4300) — full seam wiring: `stopCompletely`, `getPosition` (world-space, `rmT.CellId` + `mtBody.Position/Orientation`), `getHeading`/`setHeading` via `MoveToMath`, `getOwnRadius`/`getOwnHeight` **both hardcoded `0f`** (comment: "pin P4 note — setup cylsphere radius lands with R5's TargetManager port"), `contact: () => mtBody.OnWalkable`, `isInterpolating: () => rmT.Interp.IsActive`, `getVelocity`, `getSelfId: () => serverGuid`, `setTarget`/`clearTarget` writing `rmT.TrackedTargetGuid/Radius/FedOnce` (L4285-4291), `getTargetQuantum`/`setTargetQuantum` on `rmT.TargetQuantum`, real-clock `curTime` (L4294-4300, R4-V5 fix — was per-call stub before) -- `rm.Motion.InterruptCurrentMovement = () => rmT.MoveTo?.CancelMoveTo(WeenieError.ActionCancelled)` (L4304-4306) — TS-36, retail's `interrupt_current_movement → MovementManager::CancelMoveTo(0x36)` chain -- **NOT bound here**: `rm.MoveTo.StickTo`, `rm.MoveTo.Unstick`, `rm.MoveTo.MoveToComplete` — all left null for remotes. - -### 2b. `EnterPlayerModeNow` (GameWindow.cs:12960-13145+) -Player-side construction, mirrors 2a with player-specific deltas (documented at L12970-12988): -- (a) heading via `pcMoveTo.Yaw` bridge, not a quaternion write directly -- (b) `contact: () => pcMoveTo.BodyInContact` — real Contact transient bit, not OnWalkable proxy -- (c) `isInterpolating: () => false` — player has no InterpolationManager -- `playerMoveTo = new MoveToManager(...)` (L12990-13013) -- `playerMoveTo.MoveToComplete = err => { ...; if (err==None) OnAutoWalkArrivedSendDeferredAction(); }` (L13024-13030) — the ONLY MoveToComplete binding in the codebase (AD-27 reanchor) -- `_playerController.MoveTo = playerMoveTo` (L13031) -- `_playerController.Motion.InterruptCurrentMovement = () => playerMoveTo.CancelMoveTo(WeenieError.ActionCancelled)` (L13039-13045) — TS-36 player side -- `_playerController.Motion.CheckForCompletedMotions = playerSeq.Manager.CheckForCompletedMotions` (L13145-13146), inside the `if (_animatedEntities.TryGetValue(...))` sequencer-bind block (L13135+) — the R4-V5 "moveto-stall fix #2" comment (L13126-13134) documents this MUST run before the initial `SetPosition` or the sequencer's pending_motions node orphans. -- **NOT bound**: `playerMoveTo.StickTo`, `playerMoveTo.Unstick`. -- Player MoveToManager tick pump: **not inside `EnterPlayerModeNow`** — search shows no direct `playerMoveTo.UseTime()` call in that function; it's driven per-frame from `OnUpdateFrame` via `_playerMoveToTarget*` fields (see §2c) and the `RouteServerMoveTo` call at L4772 (§2e). - -### 2c. AP-79 tracker block + player pre-Update feed -- **Remote-side tracker**: `TickRemoteMoveTo(RemoteMotion rm)` (GameWindow.cs:4469-4505). Feeds `HandleUpdateTarget(Ok)` from `_entitiesByServerGuid` lookup when the tracked target moved beyond `rm.TrackedTargetRadius` (voyeur-radius diff, L4479-4491), or `ExitWorld` if the guid vanished from the live table (L4493-4502); then unconditionally calls `mtm.UseTime()` (L4504). -- **Player-side fields**: `_playerMoveToTargetGuid` / `_playerMoveToTargetRadius` / `_playerMoveToTargetFedOnce` / `_playerMoveToTargetQuantum` are declared and seeded in `EnterPlayerModeNow` (L13006-13016) via the ctor's `setTarget`/`clearTarget`/`getTargetQuantum`/`setTargetQuantum` closures, but **the actual per-tick "feed HandleUpdateTarget + call UseTime()" loop for the player was NOT found inside `EnterPlayerModeNow`** — grep of `TickRemoteMoveTo`-equivalent player logic did not surface a distinct function; it likely lives in `OnUpdateFrame` (13,587-line file — not fully read this pass). **Flag for R5 authors: locate/confirm the player pump site before assuming parity with `TickRemoteMoveTo`.** - -### 2d. HitGround / LeaveGround / MotionDone / UseTime / CheckForCompletedMotions call sites -- `rm.Motion.LeaveGround()` — GameWindow.cs:5100 (remote ground departure, retail `LeaveGround` 0x00528b00) -- `rmState.Motion.HitGround()` + `rmState.MoveTo?.HitGround()` — GameWindow.cs:5425-5426 (remote landing; comment cites #161, retail `MovementManager::HitGround`) -- `rm.Motion.HitGround()` + `rm.MoveTo?.HitGround()` — GameWindow.cs:10062, 10070 (a SECOND remote-landing site, same pattern — the file has two independent landing branches) -- `ae.Sequencer.MotionDoneTarget = (m, ok) => { ...; interp?.MotionDone(m, ok); }` — GameWindow.cs:10142-10160 (R3-W2, bound once per sequencer; retail `CPhysicsObj::MotionDone` 0x0050fdb0 chain) -- `ae.Sequencer.Manager.UseTime()` — GameWindow.cs:10194 (this is the ANIMATION sequencer's own UseTime, not MoveToManager's — do not conflate) -- `mtm.UseTime()` — GameWindow.cs:4504 inside `TickRemoteMoveTo` (the MoveToManager tick, per remote, per frame) -- `rm.Motion.CheckForCompletedMotions = ...` — GameWindow.cs:4261 (bind site, in `EnsureRemoteMotionBindings`) - -### 2e. RouteServerMoveTo (the type-6..9 funnel entry — see §5 also) -`RouteServerMoveTo(MoveToManager mgr, MotionInterpreter interp, uint cellId, EntityMotionUpdate update)` — GameWindow.cs:4361-4457. Two call sites: -- Player: GameWindow.cs:4772 — `RouteServerMoveTo(playerMoveTo, _playerController.Motion, _playerController.CellId, update)`, inside the `update.Guid == _playerServerGuid` branch (L4716), gated behind `_playerController is not null` (L4753) and preceded by `_playerController.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4768-4769) — the retail unpack_movement head (@300566). -- Remote: GameWindow.cs:4835 — `RouteServerMoveTo(moveMgr, remoteMot.Motion, remoteMot.CellId, update)`, inside the `else` (non-player) branch (L4784), also preceded by `remoteMot.Motion.InterruptCurrentMovement?.Invoke()` + `.UnstickFromObject?.Invoke()` (L4828-4829). - -### 2f. RemoteMotion class — GameWindow.cs:411-510+ (nested `internal sealed class RemoteMotion`, NOT a separate file) -Manager-related fields: -- `PhysicsBody Body`, `MotionInterpreter Motion` (L413-414) -- `MotionTableDispatchSink? Sink` (L419) -- `MoveToManager? MoveTo` (L436) — R4-V4 -- AP-79 tracker state: `TrackedTargetGuid`, `TrackedTargetRadius`, `TrackedTargetLastFedPos`, `TrackedTargetFedOnce`, `TargetQuantum` (L442-446) -- Legacy/parallel `RemoteMoveToDriver`-oriented fields still present: `ServerMoveToActive`, `HasMoveToDestination`, `MoveToDestinationWorld`, `MoveToMinDistance`, `MoveToDistanceToObject`, `MoveToMoveTowards`, `LastMoveToPacketTime` (L448-503) — comments reference `AcDream.Core.Physics.RemoteMoveToDriver`, which per the R4 handoff docs was **retired** in the R4 "three approximations" cleanup (per MEMORY.md: "RemoteMoveToDriver/PlanMoveToStart/B.6-auto-walk all deleted"). These fields may now be dead weight predating that retirement — worth confirming during R5 cleanup. - ---- - -## 3. MotionInterpreter sticky/action seams — `src/AcDream.Core/Physics/MotionInterpreter.cs` (3,298 lines) - -- `StickTo`/`Unstick`/`stick` grep: **NO `StickTo` seam exists on `MotionInterpreter`** — only `UnstickFromObject` (L779: `public Action? UnstickFromObject { get; set; }`, doc L771-807), the R3-W2 no-op seam standing in for retail `CPhysicsObj::unstick_from_object`. This is a DIFFERENT seam from `MoveToManager.Unstick`/`.StickTo` (§1) — MotionInterpreter's copy is called at L2329 and L2375 inside its own dispatch/cancel paths, unrelated to the MoveToManager sticky handoff. -- `StandingLongJump`: field `public bool StandingLongJump;` (L747). Arms ONLY at `ChargeJump` (L25 comment, L1970 `StandingLongJump = true;`). Cleared at L2023 and L2511 (`StandingLongJump = false;`). Consumed in `ApplyInterpretedMovement`'s three-way branch (L2947, quoted below) and in `DoInterpretedMotion`'s verbatim retail body pseudocode (L2995-2997, `label_528440` goto). - -### The #164 action-dispatch loop — `ApplyInterpretedMovement` (MotionInterpreter.cs:2920-2981) -```csharp -public void ApplyInterpretedMovement( - uint currentStyle, IInterpretedMotionSink? sink, - bool cancelMoveTo = false, bool allowJump = false) -{ - if (PhysicsObj is null) return; - - // Retail's rewritten var_2c (raw 305778 decoded; ACE 444-449): - // Speed is re-set per axis below, everything else rides the pass. - var p = new MovementParameters - { - SetHoldKey = false, - ModifyInterpretedState = false, - CancelMoveTo = cancelMoveTo, - DisableJumpDuringLink = !allowJump, - }; - - if (InterpretedState.ForwardCommand == MotionCommand.RunForward) - MyRunRate = InterpretedState.ForwardSpeed; - - p.Speed = 1.0f; - DoInterpretedMotion(currentStyle, p, sink); - - if (!contact_allows_move(InterpretedState.ForwardCommand)) - { - p.Speed = 1.0f; // raw 305728: var_18_2 = 0x3f800000 - DoInterpretedMotion(MotionCommand.Falling, p, sink); - } - else if (StandingLongJump) - { - p.Speed = 1.0f; - DoInterpretedMotion(MotionCommand.Ready, p, sink); - StopInterpretedMotion(MotionCommand.SideStepRight, p, sink); - } - else - { - p.Speed = InterpretedState.ForwardSpeed; - DoInterpretedMotion(InterpretedState.ForwardCommand, p, sink); - if (InterpretedState.SideStepCommand == 0) - { - StopInterpretedMotion(MotionCommand.SideStepRight, p, sink); - } - else - { - p.Speed = InterpretedState.SideStepSpeed; - DoInterpretedMotion(InterpretedState.SideStepCommand, p, sink); - } - } - - if (InterpretedState.TurnCommand != 0) - { - p.Speed = InterpretedState.TurnSpeed; - DoInterpretedMotion(InterpretedState.TurnCommand, p, sink); - return; // retail early return — no idle-stop this call - } - - // Tail: unconditional StopInterpretedMotion(TurnRight, params). - StopInterpretedMotion(MotionCommand.TurnRight, p, sink); -} -``` -**Correction to the task framing**: the dispatched `MovementParameters p` is built ONCE at the top with `Autonomous` implicitly `false` (default, never set) and **explicitly** sets `SetHoldKey=false`, `ModifyInterpretedState=false`, `CancelMoveTo=cancelMoveTo`, `DisableJumpDuringLink=!allowJump` — only `p.Speed` is mutated per-branch (not re-constructed per dispatch as `new MovementParameters { Speed }`). The #161 doc comment (L2892-2918) explicitly discusses this exact bitfield and calls `ModifyInterpretedState=false` "load-bearing." The `Autonomous` bit is never read or written anywhere in this method — confirms the framing that the loop drops/never-carries the Autonomous bit through to per-dispatch `MovementParameters`. - -This is called from `MoveToInterpretedState` (L2820-2866, the funnel entry) at L2843: `ApplyInterpretedMovement(ims.CurrentStyle, sink, cancelMoveTo: true, allowJump: allowJump);` — always `cancelMoveTo: true` for inbound wire state. - ---- - -## 4. UpdateMotion wire parsing — `src/AcDream.Core.Net/Messages/UpdateMotion.cs` (479 lines) - -### mt-0 header flag parsing -- **MotionFlags byte** (wire term "header" in decomp comments) parsed at L161: `byte motionFlags = body[pos]; pos += 1;` — comment (L148-149) documents `0x1 = StickToObject, 0x2 = StandingLongJump` (ACE `MotionFlags` enum names). **This is NOT the same as the "0x100"/"0x200" framing in the task** — those are decomp-doc shorthand for the SAME bits at a different byte-shift (`header & 0x100` in the decomp refers to bit 0 of this byte after the 8-bit `motionFlags` is logically at bit-position 8 of the larger unpack_movement header dword). The actual C# test is `(motionFlags & 0x1) != 0` for sticky (L290) — there is no separate literal `0x100`/`0x200` mask in this file; grep for those literals returns nothing in UpdateMotion.cs. -- **0x1 (StickToObject) parse site**: L279-295, comment block cites ACE `MovementInvalid.Write` + decomp §2f case 0 @0052455d. Code: - ```csharp - if ((motionFlags & 0x1) != 0 && body.Length - pos >= 4) - { - stickyObjectGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); - pos += 4; - } - ``` - Only fires for `movementType == 0` (InterpretedMotionState branch, L188). The trailing u32 is read AFTER the InterpretedMotionState body + Commands list (matches ACE's write order). -- **0x2 (StandingLongJump) — NOT consumed**: comment L153-160 explicitly states "DOCUMENTED here but NOT consumed — retail's `unpack_movement` case 0 writes it onto `motion_interpreter->standing_longjump` (§2f @0052458e), an R5 unpack_movement item (this parser has no MotionInterpreter reference to write it onto...)". No code reads `motionFlags & 0x2` anywhere in the file. - -### Downstream consumers (grep of whole src tree) -- `StickyObjectGuid` (the parsed field, carried on `CreateObject.ServerMotionState`, L317-326 return + `CreateObject.cs:265`): grep across `src/` shows it is **assigned into the record and returned, but never read by any production consumer** — the only non-definition hits are in `tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs` (asserting the parsed value, L662/683/716). No site in `GameWindow.cs`, `MotionInterpreter.cs`, or `MoveToManager.cs` reads `.StickyObjectGuid` off `ServerMotionState`. -- `motionFlags & 0x2` / `MotionFlags.StandingLongJump`: **zero matches anywhere in the repo** outside the UpdateMotion.cs documentation comment itself. -- **Conclusion for R5**: both the sticky-guid trailer and the standing-longjump wire bit are parsed-and-shelved. R5's PositionManager::StickTo (per the UpdateMotion.cs comment, "R5's PositionManager::StickTo body is the eventual consumer") and MovementManager (for standing-longjump) are the intended consumers — neither exists yet. - ---- - -## 5. UM dispatch path for movement types 6-9 — funnel + head-stance question - -- **Funnel/router**: `RouteServerMoveTo` (GameWindow.cs:4361-4457, quoted in part in §2e) is the ONE function that inspects `update.MotionState.IsServerControlledMoveTo`/`IsServerControlledTurnTo` + `MovementType` (6/7 → MoveToObject/MoveToPosition via `path`; 8/9 → TurnToObject/TurnToHeading via `turnPath`) and calls `mgr.PerformMovement(ms)`. Both the player call site (GameWindow.cs:4772) and remote call site (GameWindow.cs:4835) funnel through this SAME function — confirmed shared body, not duplicated per the R4-V5 extraction comment (L4459-4468). -- **Does any site do a head stance-change dispatch (`InqStyle != wire style -> DoMotion(style)`) BEFORE the type-6..9 switch?** **NO separate dispatch exists.** For the remote path, the stance/style value folds directly into `InboundInterpretedState.CurrentStyle` (GameWindow.cs:4867: `CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du`), which is built and consumed only in the `else` branch AFTER `RouteServerMoveTo` returns `false` (i.e., only for mt-0 / non-moveto events) — see GameWindow.cs:4834-4839 (`RouteServerMoveTo` call, early-`return` on true) followed by the `ims` construction at L4865-4874. So: for a wire event that IS a type-6..9 moveto, the style/stance field is **never separately dispatched** at this layer — `MoveToManager.PerformMovement` doesn't take or apply a style at all (it only sets `MovementTypeState` and queues nodes; no `DoMotion(style)` call anywhere in `PerformMovement`/`MoveToObject`/`MoveToPosition`/`TurnToObject`/`TurnToHeading`, confirmed by reading all of §1's L411-637). The style-on-change dispatch is explicitly flagged as unported: `RetailObserverTraceConformanceTests.cs:33` documents an EXCLUSION — "motion==0x80000000 entries (the unpack-level DoMotion(command_ids[0]) style call for wire style index 0) — outside move_to_interpreted_state; **S3 wires the unpack-level style-on-change**" (not yet done). This is a concrete gap for R5/MovementManager: retail's real `unpack_movement` head does an `InqStyle`-vs-wire-style compare and issues a style `DoMotion` unconditionally (independent of movement type 0 vs 6-9), and acdream currently only applies style via the mt-0 `InboundInterpretedState.CurrentStyle` path — mt 6-9 events carry no style application at all today. - ---- - -## 6. PhysicsBody — `src/AcDream.Core/Physics/PhysicsBody.cs` (576 lines) - -- **IsFullyConstrained (TS-35, doc calls it TS-35 but file comment says "R3-W3 stub")**: `public bool IsFullyConstrained { get; set; }` (L263), doc block L251-262. Retail `CPhysicsObj::IsFullyConstrained` (0x0050f730), read by `CMotionInterp::jump_is_allowed` (`if (IsFullyConstrained(physics_obj) != 0) return 0x47`). Comment: "acdream has no equivalent constraint-tracking yet... stubbed false (never fires) — a real port needs the per-cell shadow-list contact accounting the physics digest tracks." **Always false in production** (settable property, but nothing in the grep'd files sets it true). -- **TransientState bits available** (`TransientStateFlags`, L62-70, `[Flags] enum`): - - `Contact = 0x1` (bit 0) — touching any surface - - `OnWalkable = 0x2` (bit 1) — standing on walkable surface - - `Sliding = 0x4` (bit 2) — carry sliding normal into next transition - - `Active = 0x80` (bit 7) — needs per-frame update - - Convenience getters: `OnWalkable` (L286), `IsActive` (L287), `InContact` (L288) properties on `PhysicsBody`. -- **PositionManager**: **does not exist as a class anywhere in the repo.** Grep for `PositionManager` across `src/` returns zero hits outside doc comments referencing it as a future R5 port target (e.g. MoveToManager.cs:24, L109-112 doc, UpdateMotion.cs comment). `IsInterpolating` (the seam MoveToManager consumes) is currently satisfied by ad-hoc booleans: `rmT.Interp.IsActive` for remotes (GameWindow.cs:4282) and a hardcoded `false` for the player (GameWindow.cs:13001) — neither is a real PositionManager. - ---- - -## 7. Test files (locations, not full case enumeration) - -**MoveToManager suite** — `tests/AcDream.Core.Tests/Physics/Motion/`: -- `MoveToManagerTestHarness.cs` — shared harness/fixture -- `MoveToManagerLifecycleTests.cs` -- `MoveToManagerNodePlanTests.cs` -- `MoveToManagerBeginMoveForwardTests.cs` -- `MoveToManagerAuxTurnTests.cs` -- `MoveToManagerArrivalAndProgressTests.cs` -- `MoveToManagerTurnToHeadingTests.cs` -- `MoveToManagerHandleUpdateTargetTests.cs` -- `MoveToManagerStickyAndCancelTests.cs` -- `MoveToManagerUseTimeGateTests.cs` -- `MoveToManagerEndToEndTableDriveTests.cs` -- `MoveToManagerCompletionSeamTests.cs` -- `MovementTypeWideningTests.cs` -- `MoveToMathCylinderDistanceTests.cs` -- `MotionTableDispatchSinkTests.cs` -- `InterpretedMotionStateActionFifoTests.cs` - -**Funnel / 183-case conformance suite**: -- `tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs` (35+) — the live-retail-observer-cdb-trace conformance harness ("183/183" cited in MEMORY.md refers to this file's case count). Contains `LoginQueue_DrainsToEmpty_UnderProductionFeed` at **line 279** (`[Fact]`). -- `tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs` -- `tests/AcDream.Core.Tests/Physics/Motion/` (see above — dispatch-adjacent) - -**DispatchInterpreted / MotionInterpreter core suite** — `tests/AcDream.Core.Tests/Physics/`: -- `MotionInterpreterTests.cs` -- `MotionInterpreterDoMotionFamilyTests.cs` -- `MotionInterpreterGroundLifecycleTests.cs` -- `MotionInterpreterJumpFamilyTests.cs` -- `MotionInterpreterPendingMotionsTests.cs` -- `ServerControlledLocomotionTests.cs` -- `RemoteWeenieRunRateTests.cs` -- `InWorldLinkGuardTests.cs` -- `MotionNormalizationTests.cs` -- `MotionVelocityPipelineTests.cs` -- `AnimationSequencerCutoverTraceTests.cs` -- `WeenieErrorCodeTableTests.cs` - -**Player moveto cutover**: -- `tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs` - ---- - -## Summary answers to the task's specific questions - -**Q4 (UpdateMotion sticky/longjump)**: 0x1 (StickToObject) trailer IS parsed (`UpdateMotion.cs:279-295`, code at 290-293) into `stickyObjectGuid` → `ServerMotionState.StickyObjectGuid`; 0x2 (StandingLongJump) is documented but the bit is never even read. **Neither has a production consumer** — grep-wide, `StickyObjectGuid` is read only by unit tests; `MotionFlags.StandingLongJump`/`0x2` has zero non-comment hits anywhere. - -**Q5 (type 6-9 head-stance dispatch)**: No such dispatch exists. `RouteServerMoveTo` (GameWindow.cs:4361) is the shared funnel for both player and remote; it never touches style/stance. `MoveToManager.PerformMovement`/its four entry points never call `DoMotion(style)`. Style application only happens on the mt-0 path via `InboundInterpretedState.CurrentStyle` folded at GameWindow.cs:4867. `RetailObserverTraceConformanceTests.cs:33`'s exclusion comment explicitly flags the unpack-level style-on-change dispatch as **not yet wired** ("S3 wires the unpack-level style-on-change") — this is a known, documented gap, not an oversight this recon discovered fresh. - -**Q6 (PhysicsBody / PositionManager)**: `IsFullyConstrained` is a settable bool stub, always false in practice (PhysicsBody.cs:263). `TransientStateFlags` has `Contact`/`OnWalkable`/`Sliding`/`Active` (PhysicsBody.cs:63-70). No `PositionManager` class exists anywhere in the repo — `IsInterpolating` is faked per-caller (`Interp.IsActive` for remotes, hardcoded `false` for the player). - -**File path of the written map**: `C:/Users/erikn/AppData/Local/Temp/claude/C--Users-erikn-source-repos-acdream--claude-worktrees-vigorous-joliot-f0c3ad/dd264e44-6468-4b4b-8d17-a8b3b15991c9/scratchpad/r5/acdream-seams.md` diff --git a/docs/research/2026-07-03-r5-managers/r5-ace-crossref.md b/docs/research/2026-07-03-r5-managers/r5-ace-crossref.md deleted file mode 100644 index 90ee2ca1..00000000 --- a/docs/research/2026-07-03-r5-managers/r5-ace-crossref.md +++ /dev/null @@ -1,332 +0,0 @@ -# ACE cross-reference: MovementManager / PositionManager / StickyManager / ConstraintManager / TargetManager - -Source root: `C:/Users/erikn/source/repos/acdream/references/ACE/Source/ACE.Server/Physics/Managers/` -All five files read in full (MovementManager.cs 216 lines, PositionManager.cs 132 lines, -StickyManager.cs 136 lines, ConstraintManager.cs 80 lines, TargetManager.cs 182 lines). - ---- - -## 1. MovementManager.cs (namespace `ACE.Server.Physics.Animation`) - -### Fields -| Field | Type | Initial value | -|---|---|---| -| `MotionInterpreter` | `MotionInterp` | null (set in ctor or lazily) | -| `MoveToManager` | `MoveToManager` | null (set in ctor or lazily) | -| `PhysicsObj` | `PhysicsObj` | null (set in ctor) | -| `WeenieObj` | `WeenieObject` | null (set in ctor) | - -No timers, no quanta — this class is a pure facade/dispatcher with no owned state beyond the two sub-managers + back-refs. Confirms it's a **facade**, not a state machine itself. - -### Constructors -- `MovementManager()` — parameterless, all-null (default-constructed / deserialization shape). -- `MovementManager(PhysicsObj obj, WeenieObject wobj)` (L18-25) — sets `PhysicsObj`/`WeenieObj`, eagerly constructs BOTH `MotionInterpreter = new MotionInterp(obj, wobj)` and `MoveToManager = new MoveToManager(obj, wobj)`. Note: this differs from the lazy-init pattern used everywhere else in the file (see below) — the 2-arg ctor is the only path that eagerly builds both children. - -### Methods (signature + mechanical summary) - -- `CancelMoveTo(WeenieError error)` (L27-31) — null-guards `MoveToManager`, forwards to `MoveToManager.CancelMoveTo(error)`. -- `static Create(PhysicsObj obj, WeenieObject wobj)` (L33-36) — factory, `return new MovementManager(obj, wobj)`. -- `EnterDefaultState()` (L38-46) — early-returns if `PhysicsObj == null`. **Lazy-inits** `MotionInterpreter` via `MotionInterp.Create(PhysicsObj, WeenieObj)` if null, then calls `MotionInterpreter.enter_default_state()`. This is the canonical lazy-init pattern reused in 5 other methods below. -- `HandleEnterWorld()` (L48-52) — **entirely commented out** (`//NoticeHandler.RecvNotice_PrevSpellSelection(MotionInterpreter)`). Dead stub in ACE — no-op. -- `HandleExitWorld()` (L54-58) — null-guards, forwards to `MotionInterpreter.HandleExitWorld()`. Does NOT touch `MoveToManager` (no `MoveToManager.HandleExitWorld` call exists in this class at all). -- `HandleUpdateTarget(TargetInfo targetInfo)` (L60-64) — null-guards `MoveToManager`, forwards to `MoveToManager.HandleUpdateTarget(targetInfo)`. **Only route into MoveToManager for target updates** — MotionInterpreter is not involved. -- `HitGround()` (L66-73) — forwards to BOTH `MotionInterpreter.HitGround()` AND `MoveToManager.HitGround()`, each separately null-guarded. Order: MotionInterpreter first, then MoveToManager. -- `InqInterpretedMotionState()` (L75-84) — lazy-init pattern (create + `enter_default_state()` if `PhysicsObj != null`), returns `MotionInterpreter.InterpretedState`. -- `InqRawMotionState()` (L86-95) — identical lazy-init pattern, returns `MotionInterpreter.RawState`. -- `IsMovingTo()` (L97-102) — `MoveToManager == null` → `false`, else `MoveToManager.is_moving_to()`. -- `LeaveGround()` (L104-110) — null-guarded forward to `MotionInterpreter.LeaveGround()` only. Comment `// NoticeHandler::RecvNotice_PrevSpellSection` — dead/no-op reference, no MoveToManager call (asymmetric vs `HitGround`). -- `MakeMoveToManager()` (L112-116) — if `MoveToManager == null`, `MoveToManager = MoveToManager.Create(PhysicsObj, WeenieObj)`. -- `MotionDone(uint motion, bool success)` (L118-122) — null-guarded forward to `MotionInterpreter.MotionDone(success)`. **Note: the `motion` parameter is accepted but never used/passed through** — only `success` reaches `MotionInterp.MotionDone`. -- `PerformMovement(MovementStruct mvs)` (L124-157) — dispatch hub: - - `PhysicsObj.set_active(true)` unconditionally first. - - `switch (mvs.Type)`: - - `RawCommand`, `InterpretedCommand`, `StopRawCommand`, `StopInterpretedCommand`, `StopCompletely` → lazy-init `MotionInterpreter` (create + enter_default_state), then `return MotionInterpreter.PerformMovement(mvs)`. - - `MoveToObject`, `MoveToPosition`, `TurnToObject`, `TurnToHeading` → lazy-init `MoveToManager` (create only, no default-state analog), then `return MoveToManager.PerformMovement(mvs)`. - - `default` → `return WeenieError.GeneralMovementFailure`. - - This is the **dispatch order** requested: motion-type enum branches to exactly one of the two subsystems, never both. -- `ReportExhaustion()` (L159-165) — null-guarded forward to `MotionInterpreter.ReportExhaustion()`. Comment `// NoticeHandler::RecvNotice_PrevSpellSelection` again (dead). -- `SetWeenieObject(WeenieObject wobj)` (L167-174) — sets `WeenieObj = wobj`, then propagates to both children if non-null: `MotionInterpreter.SetWeenieObject(wobj)`, `MoveToManager.SetWeenieObject(wobj)`. -- `UseTime()` (L176-179) — **only forwards to `MoveToManager.UseTime()`**. Does NOT call anything on `MotionInterpreter`. This is the per-tick pump entry for movement — MotionInterp presumably gets its ticks from elsewhere (not in this file). -- `get_minterp()` (L181-190) — lazy-init identical to `InqInterpretedMotionState`, returns `MotionInterpreter` itself (not a sub-state). -- `motions_pending()` (L195-200) — `MotionInterpreter == null` → `false`, else `MotionInterpreter.motions_pending()`. Doc comment recommends `PhysicsObj.IsAnimating` instead for perf (L192-194). -- `move_to_interpreted_state(InterpretedMotionState state)` (L202-211) — lazy-init pattern, then `MotionInterpreter.move_to_interpreted_state(state)`. -- `unpack_movement(object addr, uint size)` (L213) — **empty body**. Client-only wire-unpack stub; server doesn't need to unpack a movement buffer since it authors movement itself. This is the clearest ACE-adaptation marker in the file (see divergences). - -### Divergences / ACE adaptations -1. **`unpack_movement` is a no-op** (L213) — retail client presumably deserializes a raw movement buffer here (used for network movement replay/interpolation); ACE server doesn't need this path since it's the authoritative source, not a consumer, of movement state. No comment explaining it, just an empty `{ }` body — a clear "removed client-only branch." -2. **`HandleEnterWorld` is fully commented out** (L48-52) — the notice-handler call for previous spell selection was presumably meaningful in the retail client's UI/notice pipeline; ACE has no client-side notice UI so it's dead. -3. `unused `motion` parameter in `MotionDone`** — likely a vestige of a retail signature that carried a motion ID for validation/logging that ACE doesn't use server-side. -4. Uses `ACE.Entity.Enum.WeenieError` (ACE server type) as the return type for `PerformMovement`/`CancelMoveTo` — this is ACE's message-to-client error enum, not something retail's internal MovementManager would return (server-specific plumbing for GameActionFailure-style responses). -5. No `Dispose`/serialization glue for save-to-DB; the two nested managers are always live objects, not lazily persisted — normal for a live-object emulator. - ---- - -## 2. PositionManager.cs (namespace `ACE.Server.Physics.Animation`) - -### Fields -| Field | Type | Initial value | -|---|---|---| -| `InterpolationManager` | `InterpolationManager` | null | -| `StickyManager` | `StickyManager` | null | -| `ConstraintManager` | `ConstraintManager` | null | -| `PhysicsObj` | `PhysicsObj` | null (set via ctor/`SetPhysicsObject`) | - -All three sub-managers are lazily created on first real use (`MakeStickyManager`, `ConstrainTo`, `InterpolateTo`) — none are eagerly constructed in the ctor, unlike `MovementManager`'s eager 2-arg ctor. - -### Constructors -- `PositionManager()` — parameterless/default. -- `PositionManager(PhysicsObj obj)` (L15-18) — calls `SetPhysicsObject(obj)`. - -### Methods - -- **`AdjustOffset(AFrame frame, double quantum)`** (L20-28) — **the requested composition method.** Null-guards each of the three sub-managers independently and calls, **in this exact order**: - 1. `InterpolationManager.adjust_offset(frame, quantum)` - 2. `StickyManager.adjust_offset(frame, quantum)` - 3. `ConstraintManager.adjust_offset(frame, quantum)` - - All three mutate the SAME `frame` (an `AFrame`, passed by reference since it's a class/struct with mutable `Origin`) sequentially — each sub-adjustment composes on top of whatever the previous one wrote into `frame.Origin`/heading. This is a **strict, hard-coded pipeline order**: interpolation offset first, then sticky-follow offset, then constraint clamp — not a generic list of "adjusters." Constraint is explicitly LAST because `ConstraintManager.adjust_offset` scales/clamps `offset.Origin` based on what's already accumulated (it reads `offset.Origin.Length()` at the end to update `ConstraintPosOffset` — see ConstraintManager notes), i.e., it operates on the composed net displacement from both prior systems, not an independent contribution. - -- `ConstrainTo(Position position, float startDistance, float maxDistance)` (L30-36) — lazy-inits `ConstraintManager` via `ConstraintManager.Create(PhysicsObj)` if null, forwards args to `ConstraintManager.ConstrainTo(...)`. -- `static Create(PhysicsObj physicsObj)` (L38-41) — factory. -- `GetStickyObjectID()` (L43-47) — `StickyManager == null` → `0`, else `StickyManager.TargetID`. -- `HandleUpdateTarget(TargetInfo targetInfo)` (L49-53) — null-guarded forward to `StickyManager.HandleUpdateTarget(targetInfo)` only (Constraint/Interpolation don't participate in target updates). -- `InterpolateTo(Position position, bool keepHeading)` (L55-61) — lazy-inits `InterpolationManager` via `InterpolationManager.Create(PhysicsObj)`, forwards to `InterpolationManager.InterpolateTo(position, keepHeading)`. -- `IsFullyConstrained()` (L63-69) — `ConstraintManager == null` → `false`, else `ConstraintManager.IsFullyConstrained()`. -- `IsInterpolating()` (L71-74) — `InterpolationManager != null && InterpolationManager.IsInterpolating()`. -- `MakeStickyManager()` (L76-80) — if `StickyManager == null`, `StickyManager = StickyManager.Create(PhysicsObj)`. -- `SetPhysicsObject(PhysicsObj obj)` (L82-91) — sets `PhysicsObj = obj`, propagates to all three sub-managers if non-null (each gets its own `SetPhysicsObject(obj)` call). -- `StickTo(uint objectID, float radius, float height)` (L93-99) — if `StickyManager == null`, calls `MakeStickyManager()` (note: goes through the helper, unlike `ConstrainTo`/`InterpolateTo` which inline their own lazy-init), then `StickyManager.StickTo(objectID, radius, height)`. -- `StopInterpolating()` (L101-105) — null-guarded forward. -- `Unconstrain()` (L107-111) — null-guarded forward to `ConstraintManager.Unconstrain()`. -- `Unstick()` (L113-117) — null-guarded forward, but calls `StickyManager.HandleExitWorld()` (NOT a method literally named "Unstick" on StickyManager — it reuses the exit-world path, which internally calls `ClearTarget()`). -- `UseTime()` (L119-129) — per-tick pump: forwards to all three sub-managers' `UseTime()` if non-null, in order Interpolation → Sticky → Constraint (same order as `AdjustOffset`). - -### Divergences / ACE adaptations -- None visually flagged with comments — this class is pure composition/delegation, symmetric with how a client would implement it. No server-only branches visible. The `StickTo` method routing through `MakeStickyManager()` rather than inlining (unlike its two siblings) is a minor asymmetry but not a functional divergence. -- `HandleUpdateTarget` only routing to `StickyManager` (not `ConstraintManager` or `InterpolationManager`) matches the design: constraint following is by static target position/radius set once via `ConstrainTo`, not from live target updates; only sticky-follow needs live target position updates. - ---- - -## 3. StickyManager.cs (namespace `ACE.Server.Physics.Animation`) - -### Fields -| Field | Type | Initial value | -|---|---|---| -| `TargetID` | `uint` | 0 | -| `TargetRadius` | `float` | 0 | -| `TargetPosition` | `Position` | null | -| `PhysicsObj` | `PhysicsObj` | null | -| `Initialized` | `bool` | false | -| `StickyTimeoutTime` | `double` | 0 | -| `StickyRadius` | `const float` | **0.3f** (L20) | -| `StickyTime` | `const float` | **1.0f** (L22) | - -### Constructors -- `StickyManager()` — default. -- `StickyManager(PhysicsObj obj)` (L28-31) — calls `SetPhysicsObject(obj)`. - -### Methods - -- `ClearTarget()` (L33-42) — early-return if `TargetID == 0`. Else: `TargetID = 0`, `Initialized = false`, then `PhysicsObj.clear_target()` and `PhysicsObj.cancel_moveto()`. -- `static Create(PhysicsObj obj)` (L44-47) — factory. -- `HandleExitWorld()` (L49-52) — calls `ClearTarget()`. -- `HandleUpdateTarget(TargetInfo targetInfo)` (L54-66) — guards `targetInfo.ObjectID != TargetID` → return (ignore stale/foreign updates). If `targetInfo.Status == TargetStatus.OK` → `Initialized = true`, `TargetPosition = targetInfo.TargetPosition`. Else if `TargetID != 0` → `ClearTarget()` (i.e., any non-OK status for our current target clears it). -- `SetPhysicsObject(PhysicsObj obj)` (L68-71) — trivial setter. -- **`StickTo(uint objectID, float targetRadius, float targetHeight)`** (L73-83) — if already targeting something (`TargetID != 0`), first `ClearTarget()`. Then: - - `TargetID = objectID` - - `Initialized = false` - - `TargetRadius = targetRadius` - - `StickyTimeoutTime = PhysicsTimer.CurrentTime + StickyTime` (i.e., `now + 1.0f`) - - `PhysicsObj.set_target(0, objectID, 0.5f, 0.5f)` — registers with the target-tracking system using **hard-coded radius=0.5f, quantum=0.5f** regardless of the `targetRadius`/`targetHeight` params passed in. **`targetHeight` parameter is accepted but never used anywhere in this method or class.** -- `UseTime()` (L85-89) — if `PhysicsTimer.CurrentTime > StickyTimeoutTime` → `ClearTarget()`. This is the sticky-target watchdog: if no target update refreshes `Initialized`/position before the 1-second timeout, drop the stick. (Note: nothing in this file resets `StickyTimeoutTime` on `HandleUpdateTarget` — it's set once in `StickTo` and never refreshed, meaning a sticky-follow only survives 1 second of wall-clock time total unless re-triggered by a fresh `StickTo` call. This looks intentional given no other write-site exists.) - -- **`adjust_offset(AFrame offset, double quantum)`** (L91-133) — **the requested sticky-position math.** - 1. Guard: `PhysicsObj == null || TargetID == 0 || !Initialized` → return (no-op if not ready). - 2. `target = PhysicsObj.GetObjectA(TargetID)` — resolve live object if in scope; `targetPosition = target == null ? TargetPosition : target.Position` (falls back to last-known cached `TargetPosition` if target object isn't locally resolvable — e.g., out of landblock range). - 3. **Offset vector (world → local, flattened to XY):** - ``` - offset.Origin = PhysicsObj.Position.GetOffset(targetPosition); - offset.Origin = PhysicsObj.Position.GlobalToLocalVec(offset.Origin); - offset.Origin.Z = 0.0f; - ``` - i.e., compute the world-space vector from self to target, rotate it into the object's own local frame, then zero the vertical component — sticky-follow only steers horizontally. - 4. **Distance computation:** - ``` - var radius = PhysicsObj.GetRadius(); - var dist = Position.CylinderDistanceNoZ(radius, PhysicsObj.Position, TargetRadius, targetPosition) - StickyRadius; - ``` - `CylinderDistanceNoZ` = surface-to-surface horizontal distance between two cylinders (self radius vs `TargetRadius`), then subtract the **0.3f StickyRadius** constant — this yields how far past the "stick zone" (0.3m gap) the follower currently is; can be negative if already inside the desired gap. - 5. **Normalize direction** (with small-vector guard): - ``` - if (Vec.NormalizeCheckSmall(ref offset.Origin)) - offset.Origin = Vector3.Zero; - ``` - `NormalizeCheckSmall` normalizes in place and returns true if the vector was too small to normalize meaningfully (near-zero) — in that case zero it out entirely (don't chase jitter at near-zero range). - 6. **Speed selection:** - ``` - var speed = 0.0f; - var minterp = PhysicsObj.get_minterp(); - if (minterp != null) - speed = minterp.get_max_speed() * 5.0f; - - if (speed < PhysicsGlobals.EPSILON) - speed = 15.0f; - ``` - Sticky-follow speed is **5× the object's own max movement speed** (so the follow catches up faster than normal walk/run speed would allow), falling back to a **hard-coded 15.0f** if no motion interpreter is available or computed speed is ~0. - 7. **Delta-clamp to distance:** - ``` - var delta = speed * (float)quantum; - if (delta >= Math.Abs(dist)) - delta = dist; - offset.Origin *= delta; - ``` - Standard "don't overshoot" clamp: proposed per-quantum step is `speed * quantum`; if that step would travel farther than the remaining `dist` (in absolute value), snap the step to exactly `dist` instead (this can produce a *negative* delta scaling — meaning the offset direction gets inverted/scaled backward — when `dist` is negative, i.e., when already past the 0.3m sticky radius and needing to back off). - 8. **Heading alignment:** - ``` - var curHeading = PhysicsObj.Position.Frame.get_heading(); - var targetHeading = PhysicsObj.Position.heading(targetPosition); - var heading = targetHeading - curHeading; - if (Math.Abs(heading) < PhysicsGlobals.EPSILON) heading = 0.0f; - if (heading < -PhysicsGlobals.EPSILON) heading += 360.0f; - offset.set_heading(heading); - ``` - Computes the heading delta needed to face the target (degrees), snapping near-zero deltas to exactly 0, and normalizing negative deltas by wrapping `+360` (note: this wrap only triggers for deltas below `-EPSILON`, not a full `[-180,180]` normalize — deltas in e.g. `(-360, -epsilon)` all get `+360` added once, which is only a correct wrap if `heading` is already constrained to `(-360, 360)` by the subtraction of two `[0,360)` headings, which it is). Result is written into `offset.set_heading(heading)` — i.e., the frame's rotation is set to the RELATIVE turn amount needed this tick, not an absolute heading (consistent with `offset` being a per-tick delta-frame consumed elsewhere, likely integrated by the caller). - - Dead commented-out diagnostic: `//Console.WriteLine($"StickyManager.AdjustOffset(...)")` (L131). - -### Divergences / ACE adaptations -1. **`targetHeight` parameter of `StickTo` is entirely unused** — accepted into the signature (matches the client API surface presumably) but never read. Could be a client-only positional-height computation retail uses that ACE's server-authoritative model doesn't need (server just re-derives Z from the physics/terrain resolve, not from a fixed offset height). -2. **`set_target(0, objectID, 0.5f, 0.5f)` hard-codes context=0, radius=0.5f, quantum=0.5f** — the `targetRadius` argument passed into `StickTo` is stored in `TargetRadius` for use in `adjust_offset`'s distance math, but is NOT what's passed to `set_target`'s tracking-radius parameter; that's a separate fixed 0.5f. This looks like two distinct radii serving different purposes (voyeur/update-triggering radius vs. desired-follow-gap radius) rather than a bug, but it's worth flagging as a spot to verify against the retail decomp — ACE naming makes them look conflatable. -3. No explicit "ACE custom" comments in this file — the divergence is purely inferred from unused-parameter/hardcoded-constant patterns, not documented in-line. - ---- - -## 4. ConstraintManager.cs (namespace `ACE.Server.Physics.Animation`) - -### Fields -| Field | Type | Initial value | -|---|---|---| -| `PhysicsObj` | `PhysicsObj` | null | -| `IsConstrained` | `bool` | false | -| `ConstraintPosOffset` | `float` | 0 | -| `ConstraintPos` | `Position` | null | -| `ConstraintDistanceStart` | `float` | 0 | -| `ConstraintDistanceMax` | `float` | 0 | - -### Constructors -- `ConstraintManager()` — default. -- `ConstraintManager(PhysicsObj obj)` (L17-20) — calls `SetPhysicsObject(obj)`. - -### Methods - -- `static Create(PhysicsObj obj)` (L22-25) — factory. -- `ConstrainTo(Position position, float startDistance, float maxDistance)` (L27-35) — sets `IsConstrained = true`; `ConstraintPos = new Position(position)` (deep copy); `ConstraintDistanceStart = startDistance`; `ConstraintDistanceMax = maxDistance`; **`ConstraintPosOffset = position.Distance(PhysicsObj.Position)`** — i.e., initializes the tracked offset to the CURRENT straight-line distance between the constraint anchor and the object's live position at the moment constraining begins (not zero). -- `IsFullyConstrained()` (L37-40) — `return ConstraintDistanceMax * 0.9f < ConstraintPosOffset;` — **"fully constrained" means the object's tracked offset has exceeded 90% of the max allowed distance.** This is a soft/early trigger, not requiring the offset to hit 100% of max. -- `SetPhysicsObject(PhysicsObj obj)` (L42-50) — if `PhysicsObj != null` (i.e., there was a previous object), reset `IsConstrained = false` and `ConstraintPosOffset = 0.0f` BEFORE reassigning `PhysicsObj = obj`. Net effect: constraint state is cleared whenever the manager is rebound to a (possibly different, possibly the same) physics object — except on the very first bind where `PhysicsObj` starts null and the reset branch is skipped (constraint fields keep their default-initialized zero/false values anyway). -- `Unconstrain()` (L52-55) — `IsConstrained = false` only (does NOT reset `ConstraintPosOffset`, `ConstraintPos`, `ConstraintDistanceStart/Max` — those linger stale until the next `ConstrainTo` call). -- `UseTime()` (L57-60) — **empty body**, comment `// empty`. No time-based ticking logic at all in this manager (unlike Sticky's timeout-watchdog). - -- **`adjust_offset(AFrame offset, double quantum)`** (L62-77) — **the requested constraint spring math.** - 1. Guard: `PhysicsObj == null || !IsConstrained` → return. - 2. **Contact-gated branch:** - ``` - if (PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact)) - { - if (ConstraintPosOffset < ConstraintDistanceMax) - { - if (ConstraintPosOffset > ConstraintDistanceStart) - offset.Origin *= (ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart); - } - else - offset.Origin = Vector3.Zero; - } - ``` - The scaling logic **only runs when the object is in ground/surface contact** (`TransientStateFlags.Contact`) — while airborne, this whole inner block is skipped and `offset.Origin` passes through completely unmodified from whatever `StickyManager`/`InterpolationManager` already wrote into it (constraint has no effect while not in contact). - - When in contact: - - If current offset (`ConstraintPosOffset`, tracked from the PREVIOUS tick's final value — see step 3) is **≥ ConstraintDistanceMax** → hard-clamp: `offset.Origin = Vector3.Zero` (object cannot move further this tick at all — fully pinned). - - Else if offset is **< ConstraintDistanceMax**: - - If offset is **also > ConstraintDistanceStart** (i.e., in the "braking zone" between start and max) → scale the incoming `offset.Origin` (the proposed displacement already computed by prior pipeline stages) by the **linear falloff factor**: - ``` - (ConstraintDistanceMax - ConstraintPosOffset) / (ConstraintDistanceMax - ConstraintDistanceStart) - ``` - This ranges from ~1.0 (when `ConstraintPosOffset` is just past `ConstraintDistanceStart`) down toward 0.0 (as `ConstraintPosOffset` approaches `ConstraintDistanceMax`) — a **linear spring/brake taper** that progressively resists outward motion as the object nears its max leash distance. - - If offset is **≤ ConstraintDistanceStart** (still well within the free-movement zone) → no scaling at all, `offset.Origin` passes through unchanged. - 3. **State update (runs unconditionally, both when in contact and when airborne):** - ``` - ConstraintPosOffset = offset.Origin.Length(); - ``` - **This line is outside the `if (Contact)` block** — meaning `ConstraintPosOffset` is recomputed EVERY call from the magnitude of the (possibly just-scaled) `offset.Origin`, not from the actual distance to `ConstraintPos`/anchor. This is notable: the tracked "offset" is a proxy — the length of the per-tick displacement vector — not a running total distance from the constraint anchor; it's being used as a same-tick feedback value that the NEXT call's contact-branch will compare against Start/Max. Given `AdjustOffset`'s pipeline order (Interpolation → Sticky → Constraint), this length is measuring the magnitude of the net per-tick offset produced by all prior systems, right before constraint clamps it — an odd quantity to call "ConstraintPosOffset" (it looks more like "last tick's step distance" than "distance from anchor"), but that's exactly what the code does — flag this if the acdream port needs to match it precisely; it's easy to misread as "distance from ConstraintPos." - -### Divergences / ACE adaptations -- No explicit ACE-only comments; the whole file reads as a fairly literal, compact port. The main subtlety (not a divergence, but a correctness trap) is the `ConstraintPosOffset = offset.Origin.Length()` semantics noted above — this should be triple-checked against retail decomp before the acdream port trusts the "distance from constraint anchor" mental model that the field name suggests. `ConstraintPos` (the actual anchor position, L11) is stored but **never read anywhere in this file** after being set in `ConstrainTo` — it's write-only in this class, meaning either (a) the true distance-to-anchor math happens in the caller/elsewhere using `ConstraintPos`, or (b) it's vestigial state carried for inspection/debugging only. Worth checking a retail decomp or other ACE call sites for a read of `PositionManager`/`ConstraintManager.ConstraintPos` — none found in this file. - ---- - -## 5. TargetManager.cs (namespace `ACE.Server.Physics.Combat`) - -Note: this one lives in the `Combat` namespace, not `Animation`, unlike the other four. - -### Fields -| Field | Type | Initial value | -|---|---|---| -| `PhysicsObj` | `PhysicsObj` | null | -| `TargetInfo` | `TargetInfo` | null | -| `VoyeurTable` | `Dictionary` | null (lazily created in `AddVoyeur`) | -| `LastUpdateTime` | `double` | 0 | - -### Constructors -- `TargetManager()` — default. -- `TargetManager(PhysicsObj physObj)` (L18-21) — sets `PhysicsObj = physObj` directly (no `SetPhysicsObject` helper here, unlike the other four classes). - -### Methods - -- `SetTarget(uint contextID, uint objectID, float radius, double quantum)` (L23-42) — null-guard `PhysicsObj`. Calls `ClearTarget()` first (always clears any existing target before setting a new one — no early-return-if-same-target check). If `objectID == 0` (clear/cancel request): builds a `TargetInfo` with `Status = TargetStatus.TimedOut` and calls `PhysicsObj.HandleUpdateTarget(failedTargetInfo)` directly, then returns (does NOT set `TargetInfo` field — leaves it null, i.e., "targeting nothing" is represented by `TargetInfo == null`). Otherwise: `TargetInfo = new TargetInfo(contextID, objectID, radius, quantum)`; resolves `target = PhysicsObj.GetObjectA(objectID)`; if resolvable, calls `target.add_voyeur(PhysicsObj.ID, radius, quantum)` — i.e., registers itself as a voyeur ON the target object (bidirectional relationship: I track it, and it tracks me watching it). -- `SetTargetQuantum(double quantum)` (L44-54) — null-guards `PhysicsObj`/`TargetInfo`. Updates `TargetInfo.Quantum = quantum`, resolves the target object, and if found, re-registers via `targetObj.add_voyeur(PhysicsObj.ID, TargetInfo.Radius, quantum)` (refreshes the voyeur registration on the target with the new quantum, keeping the stored `Radius`). - -- **`HandleTargetting()`** (L56-79) — **the requested quantum-scheduling dispatcher** (note British-double-t spelling matches the ACE source literally). Guards: - 1. `PhysicsObj == null` → return. - 2. **Throttle: `PhysicsTimer.CurrentTime - LastUpdateTime < 0.5f` → return.** This whole method only actually runs its body once every **0.5 seconds of wall clock**, regardless of how often it's called (looks like it's called every physics tick from elsewhere, and self-throttles). - 3. If `TargetInfo != null && TargetInfo.TargetPosition == null` → return (target info exists but has no resolved position yet — commented-out diagnostic `//Console.WriteLine("...null position")` at L64). - 4. **Timeout check:** if `TargetInfo != null && TargetInfo.Status == TargetStatus.Undefined && TargetInfo.LastUpdateTime + 10.0f < PhysicsTimer.CurrentTime` → mark `TargetInfo.Status = TargetStatus.TimedOut` and call `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` (passes a COPY, not the live reference — comment `// ref?` at L71 suggests the ACE porter was unsure whether retail passes by value or reference here). **10-second target-info staleness timeout**, separate from `StickyManager`'s 1-second timeout — these are two independently-tuned quanta for two different subsystems. - 5. **Voyeur sweep:** if `VoyeurTable != null`, iterate `VoyeurTable.Values.ToList()` (materializes a copy of the values to iterate safely against in-loop mutation) and call `CheckAndUpdateVoyeur(voyeur)` for each. - 6. Finally, `LastUpdateTime = PhysicsTimer.CurrentTime` — resets the 0.5s throttle window. - -- `CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur)` (L81-89) — **the requested per-voyeur quantum check.** `newPos = GetInterpolatedPosition(voyeur.Quantum)`; if non-null and `newPos.Distance(voyeur.LastSentPosition) > voyeur.Radius` → `SendVoyeurUpdate(voyeur, newPos, TargetStatus.OK)`. I.e., only pushes an update to a voyeur if the tracked object has moved farther than that voyeur's registered `Radius` threshold since the last position sent to THAT voyeur — a dirty/delta-threshold gate, not a blanket broadcast. -- `GetInterpolatedPosition(double quantum)` (L91-98) — null-guards `PhysicsObj`. `pos = new Position(PhysicsObj.Position)` (deep copy); `pos.Frame.Origin += PhysicsObj.get_velocity() * (float)quantum` — **dead-reckoning extrapolation**: current position plus velocity times the requested lookahead quantum. This is the same "quantum" concept used throughout — it's a forward-prediction time horizon, not a physics tick delta. -- `ClearTarget()` (L100-111) — if `TargetInfo == null` → return. Else resolves the CURRENT target object and calls `targetObj.remove_voyeur(PhysicsObj.ID)` (un-registers self as a voyeur on the old target) — note this call is unconditional on `targetObj != null` check (guarded) but NOT unconditional on whether `TargetInfo.ObjectID` was actually valid; then sets `TargetInfo = null` (redundant inner null-check `if (TargetInfo != null)` immediately after already having checked `TargetInfo == null` at entry — harmless dead conditional, definitely true at that point). -- `NotifyVoyeurOfEvent(TargetStatus status)` (L113-119) — null-guards `PhysicsObj`/`VoyeurTable`. Broadcasts `SendVoyeurUpdate(voyeur, PhysicsObj.Position, status)` to EVERY voyeur in the table unconditionally (no distance-threshold gate here, unlike `CheckAndUpdateVoyeur`) — used for discrete events (e.g., death, teleport) rather than routine movement polling. - -- **`ReceiveUpdate(TargetInfo update)`** (L121-136) — **the requested inbound-update handler** (the other half of the "ReceiveUpdate/CheckAndUpdateVoyeur" pair requested). Guards: `PhysicsObj == null || TargetInfo == null || TargetInfo.ObjectID != update.ObjectID` → return (ignore updates for a target we're not currently tracking, or if we have no target at all). - - `TargetInfo = new TargetInfo(update)` (copy-construct from the incoming update — comment `// ref?` again at L125, same porter uncertainty). - - `TargetInfo.LastUpdateTime = PhysicsTimer.CurrentTime` — stamps receipt time (used later by the 10-second-timeout check in `HandleTargetting`). - - **Heading computation:** - ``` - TargetInfo.InterpolatedHeading = PhysicsObj.Position.GetOffset(TargetInfo.InterpolatedPosition); - if (Vec.NormalizeCheckSmall(ref TargetInfo.InterpolatedHeading)) - TargetInfo.InterpolatedHeading = Vector3.UnitZ; - ``` - Computes the direction vector from self to the target's (already-interpolated-by-sender) position, normalizes it in place, and if the vector was too small to normalize (near-coincident positions), falls back to `Vector3.UnitZ` (a fixed "up" vector as a degenerate-case default — presumably any non-zero placeholder direction is acceptable when the target is essentially on top of the observer). - - `PhysicsObj.HandleUpdateTarget(new TargetInfo(TargetInfo))` — forwards a COPY of the now-updated `TargetInfo` to the owning `PhysicsObj` (which presumably routes it onward to `PositionManager.HandleUpdateTarget` → `StickyManager.HandleUpdateTarget`, wiring back to file #3 above). - - If `update.Status == TargetStatus.ExitWorld` → `ClearTarget()` (target left the world; stop tracking it entirely, in addition to whatever `HandleUpdateTarget` propagation already did). - -- `AddVoyeur(uint objectID, float radius, double quantum)` (L138-157) — if `VoyeurTable != null`, try to find an existing entry for `objectID`; if found, just update its `Radius`/`Quantum` in place and return early (no duplicate entries, no re-send of an initial update on refresh). Else (table doesn't exist yet), `VoyeurTable = new Dictionary<...>()`. Creates a new `TargettedVoyeurInfo(objectID, radius, quantum)`, adds it to the table, and **immediately** calls `SendVoyeurUpdate(info, PhysicsObj.Position, TargetStatus.OK)` — new voyeurs get an immediate initial position push (not gated by the distance threshold that `CheckAndUpdateVoyeur` applies for routine updates). -- `SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status)` (L159-172) — sets `voyeur.LastSentPosition = new Position(pos)` (deep copy, records what was just sent for future delta-threshold comparisons — this is what `CheckAndUpdateVoyeur` compares `newPos.Distance(...)` against). Builds an outbound `TargetInfo(0, PhysicsObj.ID, voyeur.Radius, voyeur.Quantum)` (contextID hard-coded to 0), sets `TargetPosition = PhysicsObj.Position` (current authoritative position), `InterpolatedPosition = new Position(pos)` (the possibly-extrapolated position that triggered/represents this update), `Velocity = PhysicsObj.get_velocity()`, `Status = status`. Resolves the voyeur's own physics object (`GetObjectA(voyeur.ObjectID)`) and if found, calls `voyeurObj.receive_target_update(info)` — this is the reverse-direction hop that presumably lands back in the voyeur's own `TargetManager.ReceiveUpdate`. -- `RemoveVoyeur(uint objectID)` (L174-179) — `VoyeurTable == null` → `false`, else `VoyeurTable.Remove(objectID)` (dictionary's own bool-return removal). - -### Divergences / ACE adaptations -1. **Two independent "// ref?" comments** (L71, L125) mark spots where the ACE porter was unsure whether retail passes `TargetInfo` by reference or performs a defensive copy — ACE chose copy-construct (`new TargetInfo(...)`) in both cases as the conservative/safe choice for a multi-threaded server. This is the clearest **explicit uncertainty marker** in the whole set of five files — flag as a spot acdream should verify against the retail decomp directly rather than trust ACE's guess, since ACE itself flagged its own uncertainty. -2. **The voyeur pattern is inherently server-authoritative plumbing** — `SendVoyeurUpdate`/`receive_target_update`/bidirectional `add_voyeur`/`remove_voyeur` registration between two `PhysicsObj`s is exactly the kind of "who-is-watching-whom" bookkeeping a server needs to decide what to push to which client-controlled object, whereas a retail single-player-perspective client would only need to track ITS OWN target, not maintain a reverse table of watchers. This entire voyeur/broadcast half of the class is very plausibly ACE-server infrastructure sitting alongside a more literally-ported "my own target" half (`SetTarget`/`ReceiveUpdate`/`HandleTargetting`'s timeout logic). No comment marks this explicitly, but structurally it's the load-bearing "adaptation" in this file — acdream (a client) likely needs ONLY the target-tracking half (`SetTarget`, `ReceiveUpdate`, the 10s timeout, `GetInterpolatedPosition`), not the voyeur/broadcast half, UNLESS acdream's server-facing code also needs to originate voyeur registrations (unlikely for a pure client). -3. `HandleTargetting`'s two independently-tuned timing constants — **0.5f throttle** (L60) and **10.0f staleness timeout** (L68) — are worth citing precisely if porting; these are likely real retail-tuned quanta, not ACE inventions, but should be cross-checked against `docs/research/named-retail/` before treating as ground truth given the file's own admitted uncertainty elsewhere. -4. Namespace: this class lives under `ACE.Server.Physics.Combat`, while all four other classes here live under `ACE.Server.Physics.Animation` — matches retail's likely split (targeting/voyeur being combat/perception machinery vs. movement/position being animation machinery), not an ACE-specific reorganization, but worth preserving that namespace distinction in acdream's own port structure. - ---- - -## Cross-class composition notes (for the R5 MovementManager/PositionManager facade work) - -- **Dispatch chain confirmed:** `MovementManager.PerformMovement` branches by `MovementStruct.Type` into either `MotionInterp.PerformMovement` (raw/interpreted commands) or `MoveToManager.PerformMovement` (MoveTo/TurnTo variants) — never both, never a shared pre/post hook in this file. -- **`MovementManager.UseTime()` only pumps `MoveToManager`** — `MotionInterp` presumably ticks via a different call site (not shown in these 5 files) — do not assume `MovementManager.UseTime` is the sole per-tick driver for animation state when porting the R5 facade; the "three approximations" pattern retired in R4 pertained to `MoveToManager`-adjacent code, and this file confirms `MoveToManager.UseTime()` is exactly one call, unconditioned, from `MovementManager.UseTime()`. -- **`PositionManager.AdjustOffset` and `PositionManager.UseTime` share the identical fixed pipeline order**: Interpolation → Sticky → Constraint. Any acdream port of `PositionManager` MUST preserve this order — `ConstraintManager.adjust_offset`'s scaling operates on the ALREADY-composed `offset.Origin` written by the two prior stages (confirmed by reading `ConstraintManager.adjust_offset` itself: it treats incoming `offset.Origin` as pre-populated and only clamps/scales it, never zeroes-then-rebuilds it). -- **Quantum is used with (at least) three distinct meanings across these files** — worth flagging for the R5 doc's "quantum model" section: - 1. `AdjustOffset(AFrame frame, double quantum)` — a per-tick **time delta** (seconds since last adjustment), used identically by all three sub-adjusters as a `speed * quantum = distance` integration step. - 2. `TargetInfo.Quantum` / `TargettedVoyeurInfo.Quantum` / `SetTarget(..., double quantum)` — a **lookahead/extrapolation horizon** fed into `GetInterpolatedPosition(quantum)` for dead-reckoning prediction, unrelated to the physics-tick delta above (it's a per-voyeur-registered constant, not a live delta). - 3. Implicit "throttle interval" constants (`0.5f` in `HandleTargetting`, `1.0f` `StickyTime`, `10.0f` staleness) — not literally named "quantum" in code but functionally the same category of scheduling constant the R5 doc should probably fold into the same table. diff --git a/docs/research/2026-07-03-r5-managers/r5-constraintmanager-decomp.md b/docs/research/2026-07-03-r5-managers/r5-constraintmanager-decomp.md deleted file mode 100644 index 92f9201e..00000000 --- a/docs/research/2026-07-03-r5-managers/r5-constraintmanager-decomp.md +++ /dev/null @@ -1,679 +0,0 @@ -# ConstraintManager — retail decomp extract - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named). -Struct source: `docs/research/named-retail/acclient.h`. - -**Address correction:** the task listed `CPhysicsObj::IsFullyConstrained` at `0x0050f730`. -The actual address in the corpus is **`0x0050ec60`**. Verified by grepping the definition -line (`276520:0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(...)`) -and cross-checked against its caller in `CMotionInterp::jump_is_allowed` at `0x005282fd`. - ---- - -## struct ConstraintManager (acclient.h, comment `/* 3467 */`) - -```c -/* 3467 */ -struct __cppobj ConstraintManager -{ - CPhysicsObj *physics_obj; - int is_constrained; - float constraint_pos_offset; - Position constraint_pos; - float constraint_distance_start; - float constraint_distance_max; -}; -``` - -## struct PositionManager (acclient.h, comment `/* 3468 */`) — the owning object - -```c -/* 3468 */ -struct __cppobj PositionManager -{ - InterpolationManager *interpolation_manager; - StickyManager *sticky_manager; - ConstraintManager *constraint_manager; - CPhysicsObj *physics_obj; -}; -``` - -Note the field order in `PositionManager` (`interpolation_manager`, `sticky_manager`, -`constraint_manager`, `physics_obj`) matches the `PositionManager::Create` allocation -writes to offsets `0x0`, `0x4`, `0x8`, `0xc` respectively (see below) and the -`PositionManager::Destroy` teardown order (`interpolation_manager` → `sticky_manager` → -`constraint_manager`). - -`ConstraintManager` field layout maps onto `ConstraintManager::Create`'s raw offset -writes: `physics_obj`=`0x0`, `is_constrained`=`0x4`, `constraint_pos_offset`=`0x8` — wait, -per the decompiled writes below the offsets are actually `0x0`/`0x4`/`0x8`/`0xc` -(`vtable` field of `Position constraint_pos` at `0xc`)/`0x10`.../`0x48` is -`constraint_distance_start`, `0x4c` is `constraint_distance_max`. The compiler emits a -`Position` (which itself embeds a `Frame` with its own vtable-looking sentinel field — -see NOTE in `~ConstraintManager` below) between `constraint_pos_offset` and -`constraint_distance_start`, consistent with the struct's declared member order. - ---- - -## ConstraintManager::SetPhysicsObject — `0x00556090` - -```c -00556090 void __thiscall ConstraintManager::SetPhysicsObject(class ConstraintManager* this, class CPhysicsObj* arg2) - -00556090 { -00556096 if (this->physics_obj == 0) -00556096 { -005560ad this->physics_obj = arg2; -005560af return; -00556096 } -00556096 -00556098 this->physics_obj = 0; -0055609a this->is_constrained = 0; -0055609d this->constraint_pos_offset = 0f; -005560a4 this->physics_obj = arg2; -00556090 } -``` - -## ConstraintManager::UnConstrain — `0x005560c0` - -```c -005560c0 void __fastcall ConstraintManager::UnConstrain(class ConstraintManager* this) - -005560c0 { -005560c0 this->is_constrained = 0; -005560c0 } -``` - -## ConstraintManager::IsFullyConstrained — `0x005560d0` - -```c -005560d0 int32_t __fastcall ConstraintManager::IsFullyConstrained(class ConstraintManager const* this) - -005560d0 { -005560d0 long double x87_r7 = ((long double)this->constraint_pos_offset); -005560d6 long double x87_r6_1 = (((long double)this->constraint_distance_max) * ((long double)0.90000000000000002)); -005560dc (x87_r6_1 - x87_r7); -005560de int32_t eax; -005560de eax = ((((x87_r6_1 < x87_r7) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r6_1, x87_r7))) ? 1 : 0) << 0xa) | ((((x87_r6_1 == x87_r7) ? 1 : 0) << 0xe) | 0)))); -005560e0 bool p = /* bool p = unimplemented {test ah, 0x5} */; -005560e0 -005560e3 if (p) -005560ed return 0; -005560ed -005560ea return 1; -005560d0 } -``` - -NOTE (garbled bitfield mush / x87 flags mush): the `eax = (...)` line is Binary Ninja's -attempt to render the x87 `FCOMI`/`FSTSW`+`SAHF`-style compare-and-test-flags sequence as -bit-packed pseudocode. It is computing `constraint_distance_max * 0.9 <=> constraint_pos_offset` -and then `test ah, 0x5` checks the ZF/CF-equivalent bits packed into `ah` after `fnstsw ax`. -The semantic read: `p` is true when `(constraint_distance_max * 0.9) < constraint_pos_offset` -OR the compare was unordered (NaN) — i.e. `test ah,5` tests bits 0 (C0/"below") and 2 -(C3/"equal") of the FPU status word as loaded into AH, the classic x87 `jbe`-equivalent -pattern. So: **`IsFullyConstrained` returns `false` (0) if `constraint_pos_offset >= -0.9 * constraint_distance_max` (or unordered), else returns `true` (1)**. In plain terms: -the object counts as "fully constrained" while it is still within 90% of the max leash -distance; once it has drifted past 90% of that distance it is no longer "fully" constrained -(this is the gate `CMotionInterp::jump_is_allowed` reads to block jump attempts while -straining at the very end of a constraint leash). - -## ConstraintManager::~ConstraintManager — `0x005560f0` - -```c -005560f0 void __fastcall ConstraintManager::~ConstraintManager(class ConstraintManager* this) - -005560f0 { -005560f2 this->is_constrained = 0; -005560f5 this->constraint_pos_offset = 0f; -005560f8 this->physics_obj = 0; -005560fa this->constraint_pos.vtable = 0x79285c; -005560f0 } -``` - -NOTE: `this->constraint_pos.vtable = 0x79285c` — `constraint_pos` is a `Position` field -(struct member, not a pointer), so this is Binary Ninja's rendering of the embedded -`Frame`'s vtable-pointer slot being reset to its static vtable address as part of the -`Position`/`Frame` subobject's implicit destructor inlining. Not a real "vtable swap"; -just the compiler zeroing/resetting the embedded Frame's identity field during teardown. - -## ConstraintManager::Create — `0x00556110` (factory) - -```c -00556110 class ConstraintManager* ConstraintManager::Create(class CPhysicsObj* arg1) - -00556110 { -00556114 void* result = operator new(0x5c); -00556114 -00556122 if (result == 0) -00556177 return 0; -00556177 -00556124 *(uint32_t*)result = 0; -00556126 *(uint32_t*)((char*)result + 4) = 0; -00556129 *(uint32_t*)((char*)result + 8) = 0; -0055612f *(uint32_t*)((char*)result + 0xc) = 0x796910; -00556136 *(uint32_t*)((char*)result + 0x10) = 0; -00556139 *(uint32_t*)((char*)result + 0x14) = 0x3f800000; -0055613f *(uint32_t*)((char*)result + 0x18) = 0; -00556142 *(uint32_t*)((char*)result + 0x1c) = 0; -00556145 *(uint32_t*)((char*)result + 0x20) = 0; -00556148 *(uint32_t*)((char*)result + 0x48) = 0; -0055614b *(uint32_t*)((char*)result + 0x4c) = 0; -0055614e *(uint32_t*)((char*)result + 0x50) = 0; -00556151 Frame::cache(((char*)result + 0x14)); -00556156 *(uint32_t*)((char*)result + 0x54) = 0; -00556159 *(uint32_t*)((char*)result + 0x58) = 0; -00556159 -0055615e if (*(uint32_t*)result != 0) -0055615e { -00556160 *(uint32_t*)((char*)result + 4) = 0; -00556163 *(uint32_t*)((char*)result + 8) = 0; -00556166 *(uint32_t*)result = 0; -0055615e } -0055615e -0055616c *(uint32_t*)result = arg1; -00556172 return result; -00556110 } -``` - -NOTE: allocation is `0x5c` (92) bytes — `sizeof(ConstraintManager)`. Field-offset mapping -against the struct decl: `+0x0`=`physics_obj`, `+0x4`=`is_constrained`, -`+0x8`=`constraint_pos_offset`, `+0xc..0x50`=`constraint_pos` (embedded `Position`, whose -own `objcell_id`/`frame` subfields explain the `0x796910` vtable-like constant at `+0xc` -and the `Frame::cache` call seeding the rotation quaternion identity `w=1.0` -i.e. `0x3f800000` at `+0x14`), `+0x48`=`constraint_distance_start`, -`+0x4c`=`constraint_distance_max`. The trailing `+0x54`/`+0x58` zeroing is past the -declared struct fields in the header excerpt we have — likely padding/alignment or a -field the header comment block truncated; not load-bearing for the port (all consumed -fields — `physics_obj`, `is_constrained`, `constraint_pos_offset`, `constraint_pos`, -`constraint_distance_start`, `constraint_distance_max` — are accounted for). - -The odd `if (*(uint32_t*)result != 0) { zero everything }` right after construction reads -as dead/defensive code from an inlined check that can't actually trigger here (the fields -were all just zeroed above) — flagging as NOTE, not altering. - -## ConstraintManager::adjust_offset — `0x00556180` - -```c -00556180 void __thiscall ConstraintManager::adjust_offset(class ConstraintManager* this, class Frame* arg2, double arg3) - -00556180 { -00556186 class CPhysicsObj* physics_obj = this->physics_obj; -00556186 -0055618a if (physics_obj != 0) -0055618a { -00556190 int32_t is_constrained = this->is_constrained; -00556190 -00556195 if (is_constrained != 0) -00556195 { -005561a7 if ((physics_obj->transient_state & 1) != 0) -005561a7 { -005561a9 long double x87_r7_1 = ((long double)this->constraint_pos_offset); -005561ac long double temp1_1 = ((long double)this->constraint_distance_max); -005561ac (x87_r7_1 - temp1_1); -005561af physics_obj = ((((x87_r7_1 < temp1_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_1, temp1_1))) ? 1 : 0) << 0xa) | ((((x87_r7_1 == temp1_1) ? 1 : 0) << 0xe) | 0)))); -005561af -005561b4 if ((*(uint8_t*)((char*)physics_obj)[1] & 1) != 0) -005561b4 { -005561e7 long double x87_r7_2 = ((long double)this->constraint_pos_offset); -005561ea long double temp2_1 = ((long double)this->constraint_distance_start); -005561ea (x87_r7_2 - temp2_1); -005561ed physics_obj = ((((x87_r7_2 < temp2_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_2, temp2_1))) ? 1 : 0) << 0xa) | ((((x87_r7_2 == temp2_1) ? 1 : 0) << 0xe) | 0)))); -005561ef bool p_1 = /* bool p_1 = unimplemented {test ah, 0x41} */; -005561ef -005561f2 if (p_1) -005561f2 { -005561f7 int32_t is_constrained_1 = is_constrained; -00556209 Vector3::operator*=(&arg2->m_fOrigin, ((float)((((long double)this->constraint_distance_max) - ((long double)this->constraint_pos_offset)) / (((long double)this->constraint_distance_max) - ((long double)this->constraint_distance_start))))); -005561f2 } -005561b4 } -005561b4 else -005561b4 { -005561c2 arg2->m_fOrigin.x = 0; -005561c2 arg2->m_fOrigin.y = 0f; -005561c2 arg2->m_fOrigin.z = 0f; -005561b4 } -005561a7 } -005561a7 -0055620e arg2->m_fOrigin; -00556211 arg2->m_fOrigin; -00556233 this->constraint_pos_offset = ((float)(((long double)arg2->m_fOrigin.x) + ((long double)this->constraint_pos_offset))); -00556195 } -0055618a } -00556180 } -``` - -NOTE (garbled bitfield mush + BN variable-reuse artifact): `physics_obj` gets *reused* as -a scratch int32 to hold the packed x87 comparison-flags value at `005561af` and again at -`005561ed` — this is Binary Ninja recycling the SSA slot, NOT a real reassignment of the -`CPhysicsObj*` pointer. The `this->physics_obj` local captured at `00556186` is what's -actually read at `005561a7` (`physics_obj->transient_state`) and `005561b4` -(`*(uint8_t*)((char*)physics_obj)[1] & 1` — this is checking a byte of `transient_state` -again, offset `+1`, i.e. a second flag byte inside the same bitfield/word). Do not port -the reused `physics_obj` int32 as if it becomes a different physics object; it's the same -pointer, just overwritten as dead-value scratch space by the decompiler's register -allocator view. - -`bool p_1 = /* unimplemented {test ah, 0x41} */` is the same x87-flags-in-AH pattern as -`IsFullyConstrained` above, testing bits 0 and 6 this time (C0 + C6/C2 combo depending on -encoding) — the classic `jbe`-vs-`jae` variant. Given the surrounding compare -(`constraint_pos_offset < constraint_distance_start` or equal), and that the guarded body -computes a **lerp fraction** `(constraint_distance_max - constraint_pos_offset) / -(constraint_distance_max - constraint_distance_start)` applied to `arg2->m_fOrigin` via -`*=`, the semantic read is: **when the object has NOT yet reached (or has just reached) -`constraint_distance_start`, scale the frame's offset delta by how far through the -start→max leash band the object currently sits** (a ramp/taper multiplier — presumably -smoothly reducing how much of the requested frame delta gets applied as the leash -tightens). `test ah,0x41` semantically reads as "less-than-or-unordered-or-equal" -(`p_1` true when NOT clearly greater), so the ramp only applies while still inside the -band; once past `constraint_distance_start` typical port intent should skip the scale -(leave `arg2->m_fOrigin` alone) — consistent with the `else` branch two levels up which -zeroes `m_fOrigin` outright when `transient_state`'s second flag bit is clear. - -Mechanically, regardless of the exact flag polarity (worth a live cdb single-step check -if the port's leash-taper feel diverges from retail), the function's shape is: -1. No-op if no `physics_obj` or not `is_constrained`. -2. If `transient_state & 1` (some "active"/"in contact" style flag): - - If a second transient-state flag byte's bit 0 is set: scale the incoming frame delta - `arg2->m_fOrigin` by a lerp fraction based on `(max - pos_offset) / (max - start)`, - gated by a comparison of `pos_offset` vs `constraint_distance_start`. - - Else: zero the incoming frame delta entirely (fully clamp movement). -3. Unconditionally (after the above), accumulate: `constraint_pos_offset += - arg2->m_fOrigin.x` (note: only the `.x` component is added — `arg2->m_fOrigin` is read - twice at `0055620e`/`00556211` with no visible effect, likely a debug/no-op dead read - from the decompiler, or hints there's a per-component variant BN collapsed; only the - final `.x`-add survived as an observable store). - -## ConstraintManager::ConstrainTo — `0x00556240` - -```c -00556240 void __thiscall ConstraintManager::ConstrainTo(class ConstraintManager* this, class Position const* arg2, float arg3, float arg4) - -00556240 { -00556248 this->is_constrained = 1; -00556259 this->constraint_pos.objcell_id = arg2->objcell_id; -0055625c Frame::operator=(&this->constraint_pos.frame, &arg2->frame); -00556271 this->constraint_distance_start = arg3; -00556274 this->constraint_distance_max = arg4; -0055627c this->constraint_pos_offset = ((float)Position::distance(arg2, &this->physics_obj->m_position)); -00556240 } -``` - -Straightforward: pins the leash anchor (`constraint_pos` = copy of `arg2`'s cell+frame), -sets `is_constrained = true`, sets the start/max leash-band radii from `arg3`/`arg4`, and -initializes `constraint_pos_offset` to the CURRENT distance from the anchor to the -physics object's live position (`Position::distance(arg2, &physics_obj->m_position)`) — -i.e. the leash starts already "extended" to wherever the object presently is relative to -the constraint anchor, not to zero. - ---- - -## PositionManager-level seams (the actual public API — ConstraintManager is -## lazily-created and private underneath) - -`ConstraintManager` is never touched directly from outside `PositionManager`. -`PositionManager` lazily creates it on first `ConstrainTo` call and forwards through it. - -```c -00555190 void __thiscall PositionManager::adjust_offset(class PositionManager* this, class Frame* arg2, double arg3) -00555190 { -00555191 int32_t ebx = arg3; -0055519d class InterpolationManager* interpolation_manager = this->interpolation_manager; -005551a2 int32_t edi = *(uint32_t*)((char*)arg3)[4]; -005551a2 -005551a6 if (interpolation_manager != 0) -005551a6 { -005551a8 int32_t var_14_1 = edi; -005551ab InterpolationManager::adjust_offset(interpolation_manager, arg2, ebx); -005551a6 } -005551a6 -005551b0 class StickyManager* sticky_manager = this->sticky_manager; -005551b0 -005551b5 if (sticky_manager != 0) -005551b5 { -005551b7 int32_t var_14_2 = edi; -005551ba StickyManager::adjust_offset(sticky_manager, arg2, ebx); -005551b5 } -005551b5 -005551bf class ConstraintManager* constraint_manager = this->constraint_manager; -005551bf -005551c4 if (constraint_manager != 0) -005551c4 { -005551c6 int32_t var_14_3 = edi; -005551c9 ConstraintManager::adjust_offset(constraint_manager, arg2, ebx); -005551c4 } -00555190 } -``` - -Chains ALL THREE sub-managers' `adjust_offset` in a fixed order: -`InterpolationManager` → `StickyManager` → `ConstraintManager`, each optional (only -called if that sub-manager exists). This is the per-frame(?) offset-adjustment -dispatcher `PositionManager` uses to let interpolation/sticky/constraint all have a say -in shaping a `Frame` delta before it's applied. - -```c -00555280 void __thiscall PositionManager::ConstrainTo(class PositionManager* this, class Position const* arg2, float arg3, float arg4) -00555280 { -00555288 if (this->constraint_manager == 0) -00555296 this->constraint_manager = ConstraintManager::Create(this->physics_obj); -00555296 -00555299 class ConstraintManager* constraint_manager = this->constraint_manager; -00555299 -0055529f if (constraint_manager == 0) -005552a6 return; -005552a6 -005552a1 /* tailcall */ -005552a1 return ConstraintManager::ConstrainTo(constraint_manager, arg2, arg3, arg4); -00555280 } - -005552b0 void __fastcall PositionManager::UnConstrain(class PositionManager* this) -005552b0 { -005552b0 class ConstraintManager* constraint_manager = this->constraint_manager; -005552b0 -005552b5 if (constraint_manager == 0) -005552bc return; -005552bc -005552b7 /* tailcall */ -005552b7 return ConstraintManager::UnConstrain(constraint_manager); -005552b0 } - -005552c0 int32_t __fastcall PositionManager::IsFullyConstrained(class PositionManager const* this) -005552c0 { -005552c0 class ConstraintManager* constraint_manager = this->constraint_manager; -005552c0 -005552c5 if (constraint_manager == 0) -005552ce return 0; -005552ce -005552c7 /* tailcall */ -005552c7 return ConstraintManager::IsFullyConstrained(constraint_manager); -005552c0 } -``` - -`PositionManager::Create` (`0x005552d0`) wires a freshly-allocated `PositionManager`'s -`physics_obj` back-pointer into any already-non-null sub-managers (only relevant for -copy/re-init paths since a fresh `PositionManager` starts with all-null sub-managers): - -```c -005552d0 class PositionManager* PositionManager::Create(class CPhysicsObj* arg1) -005552d0 { -005552d3 void* result = operator new(0x10); -... -0055531d class ConstraintManager* ecx_2 = *(uint32_t*)((char*)result + 8); -0055531d -00555322 if (ecx_2 != 0) -00555325 ConstraintManager::SetPhysicsObject(ecx_2, arg1); -00555325 -0055532e return result; -005552d0 } -``` - -`PositionManager::Destroy` (`0x00555340`) tears down and `delete`s all three -sub-managers, `ConstraintManager` last: - -```c -00555340 void __fastcall PositionManager::Destroy(class PositionManager* this) -00555340 { -... -00555377 class ConstraintManager* constraint_manager = this->constraint_manager; -0055537c this->sticky_manager = nullptr; -0055537c -00555383 if (constraint_manager != 0) -00555383 { -00555387 ConstraintManager::~ConstraintManager(constraint_manager); -0055538d operator delete(constraint_manager); -00555383 } -00555383 -00555396 this->constraint_manager = nullptr; -00555340 } -``` - ---- - -## CPhysicsObj-level seams (public API callers actually use) - -```c -0050ec10 void __fastcall CPhysicsObj::GetMaxConstraintDistance(class CPhysicsObj const* this) -0050ec10 { -0050ec16 if (this == CPhysicsObj::player_object) -0050ec16 { -0050ec18 this->m_position; -0050ec2d return; -0050ec16 } -0050ec16 -0050ec35 this->m_position; -0050ec10 } - -0050ebc0 void __fastcall CPhysicsObj::GetStartConstraintDistance(class CPhysicsObj const* this) -0050ebc0 { -0050ebc6 if (this == CPhysicsObj::player_object) -0050ebc6 { -0050ebc8 this->m_position; -0050ebdd return; -0050ebc6 } -0050ebc6 -0050ebe5 this->m_position; -0050ebc0 } -``` - -NOTE (BN decompilation artifact / x87 return-value elision): both functions have `void` -signatures per BN's guessed prototype but are clearly meant to RETURN a float (they're -called as `ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2)` immediately followed by -`(float)st0_6` casts at the call sites — an x87 FPU return value living in `st0` that -Binary Ninja failed to attach to the declared return type). Body-wise all we get is -`this->m_position;` as a bare expression on both the player-branch and fallthrough -paths — BN elided the actual field read/constant selection (this is likely -`this->m_position.something` or a per-type constant lookup that got collapsed to a -dead-looking statement). **This function needs a live cdb read of `st0` after the call, -or a Ghidra re-decompile with a corrected float-return signature, to recover the actual -values.** Given the call-site pattern (constraining the player and other movers to a -"home" position after teleport/movement-timeout in `SmartBox::HandleReceivedPosition`), -the two functions almost certainly return small fixed-radius constants (a "start easing" -radius and a "max leash" radius), likely DIFFERENT for the player vs. non-player case -(hence the `this == CPhysicsObj::player_object` branch in both). Do not guess the literal -values — flag as an open research item before porting numeric constants. - -```c -00510520 void __thiscall CPhysicsObj::ConstrainTo(class CPhysicsObj* this, class Position const* arg2, float arg3, float arg4) -00510520 { -00510523 CPhysicsObj::MakePositionManager(this); -00510528 class PositionManager* position_manager = this->position_manager; -00510528 -00510531 if (position_manager == 0) -00510538 return; -00510538 -00510533 /* tailcall */ -00510533 return PositionManager::ConstrainTo(position_manager, arg2, arg3, arg4); -00510520 } -``` - -`CPhysicsObj::ConstrainTo` lazily ensures a `PositionManager` exists -(`MakePositionManager`) then forwards. This is the entry point external code calls. - -```c -0050ec60 int32_t __fastcall CPhysicsObj::IsFullyConstrained(class CPhysicsObj const* this) -0050ec60 { -0050ec60 class PositionManager* position_manager = this->position_manager; -0050ec60 -0050ec68 if (position_manager == 0) -0050ec71 return 0; -0050ec71 -0050ec6a /* tailcall */ -0050ec6a return PositionManager::IsFullyConstrained(position_manager); -0050ec60 } -``` - -(Address correction noted at top of doc: this is `0x0050ec60`, not the task-supplied -`0x0050f730`.) - -There is no separate `CPhysicsObj::UnConstrain` — callers go straight to -`PositionManager::UnConstrain(this->position_manager)` (see caller list below); only -`ConstrainTo` and `IsFullyConstrained` got a `CPhysicsObj`-level convenience wrapper. - ---- - -## CALLERS — when does retail actually constrain an object? - -### 1. `SmartBox::HandleReceivedPosition` (`0x00453fd0`) — THE constrain call site - -All three live `CPhysicsObj::ConstrainTo` calls in the entire corpus are inside this one -function, at three different branches of its position-update-reconciliation logic: - -**Branch A — non-player mover, after a successful move/teleport resolve (`0x00454254`):** -```c -0045414d if (arg2 != this->player) -0045414d { -00454254 if (CPhysicsObj::MoveOrTeleport(arg2, &var_48, arg8, arg5, arg6) != 0) -00454254 { -00454258 int32_t ecx_26; -00454258 ecx_26 = CPhysicsObj::GetMaxConstraintDistance(arg2); -0045425d int32_t var_68_14 = ecx_26; -00454263 ecx_28 = CPhysicsObj::GetStartConstraintDistance(arg2); -00454268 int32_t var_6c_9 = ecx_28; -00454272 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ((float)st0_7), ((float)st0_6)); -00454254 } -00454254 -00454254 return; -0045414d } -``` -For a non-player object (`arg2`), once `MoveOrTeleport` succeeds, it is constrained -**to its own current position** (`&arg2->m_position` as the anchor) with start/max radii -from `GetStartConstraintDistance`/`GetMaxConstraintDistance`. - -**Branch B — player, on a fresh TELEPORT timestamp event (`0x0045415f`):** -```c -0045415f if (CPhysicsObj::newer_event(arg2, TELEPORT_TS, arg8) != 0) -0045415f { -00454168 SmartBox::TeleportPlayer(this, &var_48); -0045416f ecx_14 = CPhysicsObj::GetMaxConstraintDistance(arg2); -0045417a ecx_16 = CPhysicsObj::GetStartConstraintDistance(arg2); -0045418a CPhysicsObj::ConstrainTo(arg2, &var_48, ((float)st0_2), ((float)st0_1)); -0045418f class CPhysicsObj* player_2 = this->player; -0045419c int32_t var_54 = 0; // zero vector -004541b4 CPhysicsObj::set_velocity(player_2, &var_54, 1); -004541c0 return; -0045415f } -``` -On a server teleport of the local player, `SmartBox::TeleportPlayer` snaps position, then -the player is constrained **to the newly-received server position** (`&var_48`, the -decoded incoming `Position`), and velocity is zeroed. - -**Branch C — player, fallthrough / non-teleport received-position path (`0x004541c9`):** -```c -004541c9 ecx_19 = CPhysicsObj::GetMaxConstraintDistance(this->player); -004541d8 ecx_21 = CPhysicsObj::GetStartConstraintDistance(this->player); -004541ec CPhysicsObj::ConstrainTo(this->player, &var_48, ((float)st0_5), ((float)st0_4)); -004541f1 class CommandInterpreter* cmdinterp_1 = this->cmdinterp; -0045420a if ((cmdinterp_1->vtable->UsePositionFromServer(cmdinterp_1) != 0 && arg5 != 0)) -0045420a { -... CPhysicsObj::InterpolateTo(arg2, &var_48, ...); -``` -Every OTHER received server position update for the local player (not a teleport-flagged -event) ALSO constrains the player to the received position (`&var_48`), and then — -depending on `UsePositionFromServer`/autonomy settings — may additionally kick off -`InterpolateTo`. So the leash gets re-anchored on essentially every server position -correction, whether or not interpolation is used to visually smooth toward it. - -**Summary for Branch A/B/C:** retail constrains an object to a `Position` (self or -server-received) with a start/max leash-band pair **every time `SmartBox` processes an -inbound position update for that object** — this is the "rubber-band" leash mechanism -that keeps the client's locally-simulated position from drifting too far from the -server-authoritative position between updates. It's re-armed (re-`ConstrainTo`'d) on -every inbound position packet, not set once. - -### 2. `CPhysicsObj::teleport_hook` (`0x00514ed0`) — THE unconstrain call site - -```c -00514ed0 void __fastcall CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2) -00514ed0 { -00514ed3 class MovementManager* movement_manager = this->movement_manager; -00514edb if (movement_manager != 0) -00514edb MovementManager::CancelMoveTo(movement_manager, edx); -00514ee4 class PositionManager* position_manager = this->position_manager; -00514eec if (position_manager != 0) -00514eee PositionManager::UnStick(position_manager); -00514ef3 class PositionManager* position_manager_1 = this->position_manager; -00514efb if (position_manager_1 != 0) -00514efd PositionManager::StopInterpolating(position_manager_1); -00514f02 class PositionManager* position_manager_2 = this->position_manager; -00514f0a if (position_manager_2 != 0) -00514f0c PositionManager::UnConstrain(position_manager_2); -00514f11 class TargetManager* target_manager = this->target_manager; -00514f19 if (target_manager != 0) -00514f19 { -00514f1b TargetManager::ClearTarget(target_manager); -00514f28 TargetManager::NotifyVoyeurOfEvent(this->target_manager, Teleported_TargetStatus); -00514f19 } -00514f31 CPhysicsObj::report_collision_end(this, 1); -00514ed0 } -``` -The ONLY `UnConstrain` call in the corpus. `teleport_hook` is a general "this object just -got relocated in a way that invalidates all continuity state" cleanup: it cancels any -active `MoveTo`, un-sticks (StickyManager), stops interpolation, **un-constrains**, clears -target tracking, and ends collision reporting. So the leash is torn down whenever an -object teleports (any teleport, not just the player's) — makes sense, since a teleport by -definition means "the position just legitimately jumped," so the anti-drift leash from -the PREVIOUS anchor must be dropped rather than fight the teleport. - -### 3. `CMotionInterp::jump_is_allowed` (`0x005282b0`) — THE read call site - -```c -005282b0 uint32_t __thiscall CMotionInterp::jump_is_allowed(class CMotionInterp* this, float arg2, int32_t* arg3) -005282b0 { -005282b8 if (this->physics_obj != 0) -005282b8 { -... -005282fd if (CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0) -00528305 return 0x47; -00528305 -00528308 class LListData* head_ = this->pending_motions.head_; -... -``` -`jump_is_allowed` reads `IsFullyConstrained` and, if true, immediately returns error code -`0x47` (rejecting the jump attempt) before even checking pending motions / jump-charge -state. This is the ONLY read-site of `IsFullyConstrained` in the corpus. Ties back to the -mechanical read of `ConstraintManager::IsFullyConstrained` above: while the object is -still within 90% of its max leash distance from the constraint anchor, it counts as -"fully constrained" and jumping is blocked outright — i.e. **you cannot jump while the -client's simulated position is being actively rubber-banded back toward a server-received -position inside the tight leash band.** Only once you've drifted past 90% of the leash -(or the leash has been dropped via `UnConstrain`/teleport) does the jump-blocking gate -open. - ---- - -## CPhysicsObj-level "constrain" seam grep (exhaustive) - -Full result of `grep "::ConstrainTo(\|::UnConstrain(\|::IsFullyConstrained("` across the -corpus — every call site, no filtering: - -``` -93007 CPhysicsObj::ConstrainTo(arg2, &arg2->m_position, ...) [SmartBox::HandleReceivedPosition, Branch A] -93024 CPhysicsObj::ConstrainTo(arg2, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch B] -93041 CPhysicsObj::ConstrainTo(this->player, &var_48, ...) [SmartBox::HandleReceivedPosition, Branch C] -276520 CPhysicsObj::IsFullyConstrained (definition) -276529 -> PositionManager::IsFullyConstrained (tailcall) -278353 CPhysicsObj::ConstrainTo (definition) -278363 -> PositionManager::ConstrainTo (tailcall) -283140 PositionManager::UnConstrain(position_manager_2) [CPhysicsObj::teleport_hook] -305524 CPhysicsObj::IsFullyConstrained(this->physics_obj) != 0 [CMotionInterp::jump_is_allowed] -352186 PositionManager::ConstrainTo (definition) -352198 -> ConstraintManager::ConstrainTo (tailcall) -352203 PositionManager::UnConstrain (definition) -352212 -> ConstraintManager::UnConstrain (tailcall) -352217 PositionManager::IsFullyConstrained (definition) -352226 -> ConstraintManager::IsFullyConstrained (tailcall) -353405 ConstraintManager::UnConstrain (definition) -353413 ConstraintManager::IsFullyConstrained (definition) -353528 ConstraintManager::ConstrainTo (definition) -``` - -No other call sites exist anywhere in the 1.4M-line corpus. The entire constrain -mechanism is used EXCLUSIVELY by: -- `SmartBox::HandleReceivedPosition` to arm/re-arm the leash on inbound position updates - (3 branches: self-anchor for remote movers, server-anchor for player teleport, server- - anchor for player non-teleport updates), and -- `CPhysicsObj::teleport_hook` to disarm it on any teleport, and -- `CMotionInterp::jump_is_allowed` to read it as a jump-blocking gate. - -This is a narrow, special-purpose "server position rubber-band leash," NOT a general -physics constraint/joint system. diff --git a/docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md b/docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md deleted file mode 100644 index d60f2a5d..00000000 --- a/docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md +++ /dev/null @@ -1,1015 +0,0 @@ -# Retail decomp extract: MovementManager facade - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (line numbers as of -this extraction), struct from `docs/research/named-retail/acclient.h`. - -## Struct: `MovementManager` (acclient.h line 30942-30949, comment `/* 3463 */`) - -```c -/* 3463 */ -struct __cppobj MovementManager -{ - CMotionInterp *motion_interpreter; - MoveToManager *moveto_manager; - CPhysicsObj *physics_obj; - CWeenieObject *weenie_obj; -}; -``` - -Field offsets (inferred from `MovementManager::Create` below, all uint32_t/pointer, 0x10 total size): -- `+0x0` motion_interpreter -- `+0x4` moveto_manager -- `+0x8` physics_obj -- `+0xc` weenie_obj - -## Struct: `PositionManager` (acclient.h line 30951-30958, comment `/* 3468 */`) — adjacent, R5's other target, NOT extracted here (out of scope) - -```c -/* 3468 */ -struct __cppobj PositionManager -{ - InterpolationManager *interpolation_manager; - StickyManager *sticky_manager; - ConstraintManager *constraint_manager; - CPhysicsObj *physics_obj; -}; -``` - -## Ownership: `CPhysicsObj` fields (acclient.h ~line 30715-30717) - -```c - MovementManager *movement_manager; - PositionManager *position_manager; - int last_move_was_autonomous; - int jumped_this_frame; -``` - -`CPhysicsObj` owns exactly one `MovementManager*` and one `PositionManager*`, both lazily -constructed. No eager allocation at `CPhysicsObj` construction time was found in this pass. - ---- - -## `MovementManager::MakeMoveToManager` — 00524000 - -```c -00524000 void __fastcall MovementManager::MakeMoveToManager(class MovementManager* this) - -00524000 { -00524008 if (this->moveto_manager == 0) -0052401a this->moveto_manager = MoveToManager::Create(this->physics_obj, this->weenie_obj); -00524000 } -``` - -Lazy-construct `moveto_manager` if null, via `MoveToManager::Create(physics_obj, weenie_obj)`. -No-op if already present. Called from `unpack_movement` cases 6/7/8/9 before touching -`moveto_manager`. - ---- - -## `MovementManager::SetWeenieObject` — 00524020 - -```c -00524020 void __thiscall MovementManager::SetWeenieObject(class MovementManager* this, class CWeenieObject* arg2) - -00524020 { -00524023 class CMotionInterp* motion_interpreter = this->motion_interpreter; -0052402c this->weenie_obj = arg2; -0052402c -0052402f if (motion_interpreter != 0) -00524032 CMotionInterp::SetWeenieObject(motion_interpreter, arg2); -00524032 -00524037 class MoveToManager* moveto_manager = this->moveto_manager; -00524037 -0052403c if (moveto_manager != 0) -0052403f MoveToManager::SetWeenieObject(moveto_manager, arg2); -00524020 } -``` - -Stores `weenie_obj` on `this`, then forwards to both children (`motion_interpreter`, -`moveto_manager`) if they exist yet. Pure propagate-to-children setter. - ---- - -## `MovementManager::Create` (factory / constructor seam) — 00524050 - -Not in the requested list by name but is the actual "constructor" — grep found no -`MovementManager::MovementManager` ctor; all construction goes through this static factory. - -```c -00524050 class MovementManager* MovementManager::Create(class CPhysicsObj* arg1, class CWeenieObject* arg2) - -00524050 { -00524054 void* result_1 = operator new(0x10); -0052405e void* result; -0052405e -0052405e if (result_1 == 0) -0052407f result = nullptr; -0052405e else -0052405e { -00524060 *(uint32_t*)result_1 = 0; -00524066 *(uint32_t*)((char*)result_1 + 4) = 0; -0052406d *(uint32_t*)((char*)result_1 + 8) = 0; -00524074 *(uint32_t*)((char*)result_1 + 0xc) = 0; -0052407b result = result_1; -0052405e } -0052405e -00524081 class CMotionInterp* ecx = *(uint32_t*)result; -00524089 *(uint32_t*)((char*)result + 8) = arg1; -00524089 -0052408c if (ecx != 0) -0052408f CMotionInterp::SetPhysicsObject(ecx, arg1); -0052408f -00524094 class MoveToManager* ecx_1 = *(uint32_t*)((char*)result + 4); -00524094 -00524099 if (ecx_1 != 0) -0052409c MoveToManager::SetPhysicsObject(ecx_1, arg1); -0052409c -005240a1 class CMotionInterp* ecx_2 = *(uint32_t*)result; -005240a9 *(uint32_t*)((char*)result + 0xc) = arg2; -005240a9 -005240ac if (ecx_2 != 0) -005240af CMotionInterp::SetWeenieObject(ecx_2, arg2); -005240af -005240b4 class MoveToManager* ecx_3 = *(uint32_t*)((char*)result + 4); -005240b4 -005240b9 if (ecx_3 != 0) -005240bc MoveToManager::SetWeenieObject(ecx_3, arg2); -005240bc -005240c5 return result; -00524050 } -``` - -`operator new(0x10)` (16 bytes, matches 4 pointer fields), zero-fills all 4 fields, then -sets `physics_obj = arg1` / `weenie_obj = arg2` and — since `motion_interpreter` and -`moveto_manager` are freshly zeroed — the `if (ecx != 0)` / `if (ecx_1 != 0)` branches are -dead on a fresh object (always false right after the zero-fill in this call path; they only -matter if this same field-setting logic is reused elsewhere). Effectively: **plain-old-data -zero-init, no real constructor logic beyond storing the two pointers.** No standalone -`MovementManager::SetPhysicsObject` exists — the physics_obj is set once here, at Create time, -and never independently. - -NOTE: this reads like dead/degenerate branches (checking a field it just zeroed two lines -earlier) — likely because Binary Ninja inlined a shared "SetPhysicsObject/SetWeenieObject -propagate" helper that's also called from non-fresh contexts (matches the pattern seen in -`SetWeenieObject` above). Keep verbatim; not garbled bitfield mush, just dead-code-looking -symmetry from inlining. - ---- - -## `MovementManager::PerformMovement` — 005240d0 - -```c -005240d0 uint32_t __thiscall MovementManager::PerformMovement(class MovementManager* this, class MovementStruct const* arg2) - -005240d0 { -005240d9 CPhysicsObj::set_active(this->physics_obj, 1); -005240e4 void* eax_1 = (arg2->type - 1); -005240e4 -005240e8 if (eax_1 > 8) -00524159 return 0x47; -00524159 -005240f1 switch (eax_1) -005240f1 { -005240fb case nullptr: -005240fb case 1: -005240fb case 2: -005240fb case 3: -005240fb case 4: -005240fb { -005240fb if (this->motion_interpreter == 0) -005240fb { -00524105 class CMotionInterp* eax_3 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); -00524110 bool cond:0_1 = this->physics_obj == 0; -00524112 this->motion_interpreter = eax_3; -00524112 -00524114 if (!(cond:0_1)) -00524118 CMotionInterp::enter_default_state(eax_3); -005240fb } -005240fb -00524127 return CMotionInterp::PerformMovement(this->motion_interpreter, arg2); -005240fb break; -005240fb } -0052412f case 5: -0052412f case 6: -0052412f case 7: -0052412f case 8: -0052412f { -0052412f if (this->moveto_manager == 0) -00524141 this->moveto_manager = MoveToManager::Create(this->physics_obj, this->weenie_obj); -00524141 -00524148 MoveToManager::PerformMovement(this->moveto_manager, arg2); -0052414f return 0; -0052412f break; -0052412f } -005240f1 } -005240d0 } - -0052415c uint32_t jump_table_52415c[0x2] = -0052415c { -0052415c [0x0] = 0x005240f8 -00524160 [0x1] = 0x0052412a -00524164 } -00524164 uint8_t lookup_table_524164[0x9] = -00524164 { -00524164 [0x0] = 0x00 -00524165 [0x1] = 0x00 -00524166 [0x2] = 0x00 -00524167 [0x3] = 0x00 -00524168 [0x4] = 0x00 -00524169 [0x5] = 0x01 -0052416a [0x6] = 0x01 -0052416b [0x7] = 0x01 -0052416c [0x8] = 0x01 -0052416d } -``` - -`arg2->type` is 1-based; `eax_1 = type - 1` is the 0-based dispatch index, range-checked -against 8 (types 1..9 valid, else return error code `0x47`). Two-way split via -`lookup_table_524164`: types 1-5 (index 0-4, i.e. `arg2->type` 1..5) route through -`CMotionInterp` (lazy-create + `enter_default_state` if not yet built, then delegate -`CMotionInterp::PerformMovement`); types 6-9 (index 5-8) route through `MoveToManager` -(lazy-create, delegate `MoveToManager::PerformMovement`, always return 0 — return value of -the MoveToManager path is NOT propagated, unlike the CMotionInterp path which returns -whatever `CMotionInterp::PerformMovement` returns). Always marks the physics object active -first (`CPhysicsObj::set_active(this->physics_obj, 1)`). - ---- - -## `MovementManager::move_to_interpreted_state` — 00524170 - -```c -00524170 void __thiscall MovementManager::move_to_interpreted_state(class MovementManager* this, class InterpretedMotionState const* arg2) - -00524170 { -00524176 if (this->motion_interpreter == 0) -00524176 { -00524180 class CMotionInterp* eax_2 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); -0052418b bool cond:0_1 = this->physics_obj == 0; -0052418d this->motion_interpreter = eax_2; -0052418d -0052418f if (!(cond:0_1)) -00524193 CMotionInterp::enter_default_state(eax_2); -00524176 } -00524176 -0052419f CMotionInterp::move_to_interpreted_state(this->motion_interpreter, arg2); -00524170 } -``` - -Lazy-create `motion_interpreter` (same idiom as everywhere else: create, then -`enter_default_state` ONLY if `physics_obj != 0`), then delegate to -`CMotionInterp::move_to_interpreted_state(interp, arg2)`. Called from `unpack_movement` -case 0 (the raw/interpreted network unpack path). - ---- - -## `MovementManager::CancelMoveTo` — 005241b0 - -```c -005241b0 void __fastcall MovementManager::CancelMoveTo(class MovementManager* this, uint32_t arg2) - -005241b0 { -005241b0 class MoveToManager* moveto_manager = this->moveto_manager; -005241b0 -005241b5 if (moveto_manager == 0) -005241bc return; -005241bc -005241b7 uint32_t edx; -005241b7 /* tailcall */ -005241b7 return MoveToManager::CancelMoveTo(moveto_manager, edx); -005241b0 } -``` - -No-op if `moveto_manager` is null; else tailcalls `MoveToManager::CancelMoveTo`. - -NOTE: `arg2` is loaded but the tailcall passes an **uninitialized** local `edx` instead of -`arg2` — decompiler register-tracking artifact (arg2 IS in edx per `__fastcall` ABI, this -is BN failing to alias the parameter register to the "edx" pseudo-var name); functionally -it's `MoveToManager::CancelMoveTo(moveto_manager, arg2)`. - ---- - -## `MovementManager::EnterDefaultState` — 005241c0 - -```c -005241c0 void __fastcall MovementManager::EnterDefaultState(class MovementManager* this) - -005241c0 { -005241c3 class CPhysicsObj* physics_obj = this->physics_obj; -005241c3 -005241c8 if (physics_obj == 0) -005241f5 return; -005241f5 -005241cd if (this->motion_interpreter == 0) -005241cd { -005241d4 class CMotionInterp* eax = CMotionInterp::Create(physics_obj, this->weenie_obj); -005241df bool cond:0_1 = this->physics_obj == 0; -005241e1 this->motion_interpreter = eax; -005241e1 -005241e3 if (!(cond:0_1)) -005241e7 CMotionInterp::enter_default_state(eax); -005241cd } -005241cd -005241ef /* tailcall */ -005241ef return CMotionInterp::enter_default_state(this->motion_interpreter); -005241c0 } -``` - -Early-return no-op if `physics_obj == 0` (i.e. never called meaningfully before the -MovementManager is attached to a physics object). Otherwise lazy-create -`motion_interpreter` (same idiom), then **unconditionally** tailcalls -`CMotionInterp::enter_default_state` again at the end — meaning on the fresh-create path -`enter_default_state` runs twice in a row (once inside the lazy-create block, once at the -tail). Verbatim as decompiled; flagging as a NOTE since double-invoke looks odd but matches -the repeated idiom seen in every other lazy-create call site in this file (all of them -gate the inner call on `physics_obj != 0` which is already guaranteed true here since we -already early-returned above). - ---- - -## `MovementManager::IsMovingTo` — 00524260 - -```c -00524260 int32_t __fastcall MovementManager::IsMovingTo(class MovementManager const* this) - -00524260 { -00524260 class MoveToManager* moveto_manager = this->moveto_manager; -00524260 -00524265 if ((moveto_manager != 0 && MoveToManager::is_moving_to(moveto_manager) != 0)) -00524275 return 1; -00524275 -00524278 return 0; -00524260 } -``` - -Returns 1 iff `moveto_manager` exists AND `MoveToManager::is_moving_to()` is true, else 0. - ---- - -## `MovementManager::motions_pending` — 00524280 - -```c -00524280 int32_t __fastcall MovementManager::motions_pending(class MovementManager const* this) - -00524280 { -00524280 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524280 -00524284 if ((motion_interpreter != 0 && CMotionInterp::motions_pending(motion_interpreter) != 0)) -00524294 return 1; -00524294 -00524297 return 0; -00524280 } -``` - -Returns 1 iff `motion_interpreter` exists AND `CMotionInterp::motions_pending()` is true, -else 0. Mirror-shape of `IsMovingTo` but checks the interp side instead of the moveto side. - ---- - -## `MovementManager::get_minterp` — 005242a0 (bonus, referenced by callers; not in the -original ask but needed for context — CPhysicsObj::get_minterp tailcalls into it) - -```c -005242a0 class CMotionInterp* __fastcall MovementManager::get_minterp(class MovementManager* this) - -005242a0 { -005242a6 if (this->motion_interpreter == 0) -005242a6 { -005242b0 class CMotionInterp* eax_2 = CMotionInterp::Create(this->physics_obj, this->weenie_obj); -005242bb bool cond:0_1 = this->physics_obj == 0; -005242bd this->motion_interpreter = eax_2; -005242bd -005242bf if (!(cond:0_1)) -005242c3 CMotionInterp::enter_default_state(eax_2); -005242a6 } -005242a6 -005242cb return this->motion_interpreter; -005242a0 } -``` - -Same lazy-create idiom; returns the (possibly freshly created) `motion_interpreter`. - ---- - -## `MovementManager::MotionDone` — 005242d0 - -```c -005242d0 void __thiscall MovementManager::MotionDone(class MovementManager* this, uint32_t arg2, int32_t arg3) - -005242d0 { -005242d0 class CMotionInterp* motion_interpreter = this->motion_interpreter; -005242d0 -005242d4 if (motion_interpreter != 0) -005242d4 { -005242da int32_t var_4_1 = arg3; -005242db int32_t edx; -005242db CMotionInterp::MotionDone(motion_interpreter, edx); -005242d4 } -005242d0 } -``` - -No-op if `motion_interpreter` is null; else forwards to `CMotionInterp::MotionDone`. - -NOTE: same register-aliasing artifact as `CancelMoveTo` — `arg2` is stashed but the call -passes an uninitialized-looking local `edx` (and `arg3` is stored to `var_4_1` but that -local is never read/passed either); functionally this is -`CMotionInterp::MotionDone(motion_interpreter, arg2, arg3)` — BN's `__thiscall` register -tracking dropped the second/third args' names. Not evidence of a bug in the real function; -just decompiler noise on a two/three-arg thiscall forward. - ---- - -## `MovementManager::UseTime` — 005242f0 - -```c -005242f0 void __fastcall MovementManager::UseTime(class MovementManager* this) - -005242f0 { -005242f0 class MoveToManager* moveto_manager = this->moveto_manager; -005242f0 -005242f5 if (moveto_manager == 0) -005242fc return; -005242fc -005242f7 /* tailcall */ -005242f7 return MoveToManager::UseTime(moveto_manager); -005242f0 } -``` - -No-op if `moveto_manager` null; else tailcalls `MoveToManager::UseTime`. Does NOT touch -`motion_interpreter` at all (unlike `HitGround`/`LeaveGround`/`ReportExhaustion` below, -which forward to both children). - ---- - -## `MovementManager::HitGround` — 00524300 - -```c -00524300 void __fastcall MovementManager::HitGround(class MovementManager* this) - -00524300 { -00524303 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524303 -00524307 if (motion_interpreter != 0) -00524309 CMotionInterp::HitGround(motion_interpreter); -00524309 -0052430e class MoveToManager* moveto_manager = this->moveto_manager; -0052430e -00524314 if (moveto_manager == 0) -0052431b return; -0052431b -00524316 /* tailcall */ -00524316 return MoveToManager::HitGround(moveto_manager); -00524300 } -``` - -Fans out to BOTH children unconditionally-if-present: `CMotionInterp::HitGround` first, -then tailcalls `MoveToManager::HitGround`. - ---- - -## `MovementManager::LeaveGround` — 00524320 - -```c -00524320 void __fastcall MovementManager::LeaveGround(class MovementManager* this) - -00524320 { -00524323 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524323 -00524327 if (motion_interpreter != 0) -00524329 CMotionInterp::LeaveGround(motion_interpreter); -00524329 -0052432e class MoveToManager* moveto_manager = this->moveto_manager; -0052432e -00524334 if (moveto_manager == 0) -0052433b return; -0052433b -00524336 /* tailcall */ -00524336 return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(moveto_manager); -00524320 } -``` - -Same shape as `HitGround`: fan out to `CMotionInterp::LeaveGround` then tailcall the -moveto-manager equivalent. - -**NOTE (BN mislabel, HIGH CONFIDENCE):** the tail call target is decompiled as -`IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(moveto_manager)` — a destructor for an -unrelated ID-wrapper template class. This is obviously wrong for the context (nothing is -being destroyed here; the pattern is identical to `HitGround`/`UseTime`/`ReportExhaustion` -which all call the matching `MoveToManager::XxxMethod`). Binary Ninja's static analysis -matched the call target address to the wrong overload/thunk. The real call is almost -certainly `MoveToManager::LeaveGround(moveto_manager)`. Keep the raw decompiled text above -for the record; the lead should treat the semantic target as `MoveToManager::LeaveGround`. - ---- - -## `MovementManager::HandleEnterWorld` — 00524340 - -```c -00524340 void __fastcall MovementManager::HandleEnterWorld(class MovementManager* this) - -00524340 { -00524340 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524340 -00524344 if (motion_interpreter == 0) -0052434b return; -0052434b -00524346 /* tailcall */ -00524346 return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(motion_interpreter); -00524340 } -``` - -No-op if `motion_interpreter` null; else tailcalls what should be -`CMotionInterp::HandleEnterWorld(motion_interpreter)`. - -**NOTE (BN mislabel, HIGH CONFIDENCE):** same spurious `IDClass<...>::~IDClass<...>` -destructor mislabel as in `LeaveGround` above. Given the neighboring function -`HandleExitWorld` (below) correctly shows `CMotionInterp::HandleExitWorld`, the real target -here is almost certainly `CMotionInterp::HandleEnterWorld(motion_interpreter)`. Notably, -this function does NOT touch `moveto_manager` at all (unlike HitGround/LeaveGround/ -ReportExhaustion) — only the motion interpreter gets the enter-world notification. - ---- - -## `MovementManager::HandleExitWorld` — 00524350 - -```c -00524350 void __fastcall MovementManager::HandleExitWorld(class MovementManager* this) - -00524350 { -00524350 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524350 -00524354 if (motion_interpreter == 0) -0052435b return; -0052435b -00524356 /* tailcall */ -00524356 return CMotionInterp::HandleExitWorld(motion_interpreter); -00524350 } -``` - -No-op if `motion_interpreter` null; else tailcalls `CMotionInterp::HandleExitWorld`. This -one resolved cleanly (no mislabel) — cross-check anchor confirming the sibling functions' -correct semantic targets. Also does NOT touch `moveto_manager`. - ---- - -## `MovementManager::ReportExhaustion` — 00524360 - -```c -00524360 void __fastcall MovementManager::ReportExhaustion(class MovementManager* this) - -00524360 { -00524363 class CMotionInterp* motion_interpreter = this->motion_interpreter; -00524363 -00524367 if (motion_interpreter != 0) -00524369 CMotionInterp::ReportExhaustion(motion_interpreter); -00524369 -0052436e class MoveToManager* moveto_manager = this->moveto_manager; -0052436e -00524374 if (moveto_manager == 0) -0052437b return; -0052437b -00524376 /* tailcall */ -00524376 return IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(moveto_manager); -00524360 } -``` - -Fans out to both children like `HitGround`: `CMotionInterp::ReportExhaustion` first -(resolved cleanly), then tailcalls the moveto-manager equivalent. - -**NOTE (BN mislabel, HIGH CONFIDENCE):** same spurious `IDClass<...>::~IDClass<...>` on the -`moveto_manager` tail call — real target is almost certainly -`MoveToManager::ReportExhaustion(moveto_manager)`, matching the pattern of every other -fan-out function (HitGround, LeaveGround) where the CMotionInterp call resolves correctly -but the MoveToManager tailcall gets the same wrong destructor label. This looks like a -systematic BN issue with one specific MoveToManager vtable slot / thunk rather than three -independent misreads. - ---- - -## `MovementManager::Destroy` — 005243f0 (bonus — the matching teardown for `Create`; -not originally requested but directly relevant to the facade's lifecycle) - -```c -005243f0 void __fastcall MovementManager::Destroy(class MovementManager* this) - -005243f0 { -005243f4 class CMotionInterp* motion_interpreter = this->motion_interpreter; -005243f4 -005243f8 if (motion_interpreter != 0) -005243f8 { -005243fc CMotionInterp::~CMotionInterp(motion_interpreter); -00524402 operator delete(motion_interpreter); -005243f8 } -005243f8 -0052440a class MoveToManager* moveto_manager = this->moveto_manager; -0052440f this->motion_interpreter = 0; -0052440f -00524415 if (moveto_manager != 0) -00524415 { -00524419 MoveToManager::~MoveToManager(moveto_manager); -0052441f operator delete(moveto_manager); -00524415 } -00524415 -00524428 this->moveto_manager = nullptr; -005243f0 } -``` - -Destroys+frees both children if present, nulls both pointers. Does NOT free `this` itself -(matches — `MovementManager::Create` used `operator new`, but `Destroy` is presumably -called before the owning `CPhysicsObj` does its own `operator delete(movement_manager)` -elsewhere; that final delete call wasn't in this extraction's scope). - ---- - -## `MovementManager::unpack_movement` — 00524440 (FULL FUNCTION — the movement-type switch) - -```c -00524440 int32_t __thiscall MovementManager::unpack_movement(class MovementManager* this, void** arg2, uint32_t arg3) - -00524440 { -0052444f if (this->motion_interpreter != 0) -0052444f { -00524455 class CPhysicsObj* physics_obj = this->physics_obj; -00524455 -0052445a if (physics_obj != 0) -0052445a { -00524460 CPhysicsObj::interrupt_current_movement(physics_obj); -00524468 CPhysicsObj::unstick_from_object(this->physics_obj); -00524471 int32_t var_70 = 0x796910; -00524479 int32_t var_6c_1 = 0; -00524481 int32_t var_68 = 0x3f800000; -00524489 int32_t var_64_1 = 0; -00524491 int32_t var_60_1 = 0; -00524499 int32_t var_5c_1 = 0; -005244a1 int32_t var_34_1 = 0; -005244ac int32_t var_30_1 = 0; -005244b7 int32_t var_2c_1 = 0; -005244c2 Frame::cache(&var_68); -005244cb void var_9c; -005244cb MovementParameters::MovementParameters(&var_9c); -005244d7 void var_28; -005244d7 InterpretedMotionState::InterpretedMotionState(&var_28); -005244e3 void* eax_1 = *(uint32_t*)arg2; -005244e7 int16_t ecx_4 = *(uint16_t*)eax_1; -005244ed *(uint32_t*)arg2 = ((char*)eax_1 + 2); -005244ef uint32_t ebp_1 = ((uint32_t)ecx_4); -005244f5 int16_t var_a4_1 = ecx_4; -005244f9 ecx_4 = *(uint16_t*)((char*)eax_1 + 2); -005244fd *(uint32_t*)arg2 = ((char*)eax_1 + 4); -00524502 uint32_t ecx_5 = command_ids[((uint32_t)ecx_4)]; -00524502 -00524522 if (CBaseFilter::GetPinVersion(this->motion_interpreter) != ecx_5) -0052452c CMotionInterp::DoMotion(this->motion_interpreter, ecx_5, &var_9c); -0052452c -0052453a void* var_b8_15; -0052453a -0052453a switch (((uint32_t)ebp_1)) -0052453a { -00524551 case 0: -00524551 { -00524551 InterpretedMotionState::UnPack(&var_28, arg2, arg3); -0052455d uint32_t ebx_3; -0052455d -0052455d if ((*(uint8_t*)((char*)var_a4_1)[1] & 1) == 0) -0052456a ebx_3 = 0; -0052455d else -0052455d { -0052455f uint32_t* eax_8 = *(uint32_t*)arg2; -00524561 ebx_3 = *(uint32_t*)eax_8; -00524566 *(uint32_t*)arg2 = &eax_8[1]; -0052455d } -0052455d -0052457c MovementManager::move_to_interpreted_state(this, &var_28); -0052457c -00524583 if (ebx_3 != 0) -00524589 CPhysicsObj::stick_to_object(this->physics_obj, ebx_3); -00524589 -0052458e this->motion_interpreter->standing_longjump = (ebp_1 & 0x200); -0052459a InterpretedMotionState::~InterpretedMotionState(&var_28); -005245ae return 1; -00524551 break; -00524551 } -005245b3 case 6: -005245b3 { -005245b3 MovementManager::MakeMoveToManager(this); -005245b8 void* eax_11 = *(uint32_t*)arg2; -005245ba uint32_t ebx_4 = *(uint32_t*)eax_11; -005245cc *(uint32_t*)arg2 = ((char*)eax_11 + 4); -005245ce Position::UnPackOrigin(&var_70, arg2, arg3); -005245db MovementParameters::UnPackNet(&var_9c, MoveToObject, arg2, arg3); -005245e0 void* eax_13 = *(uint32_t*)arg2; -005245e2 long double x87_r7 = ((long double)*(uint32_t*)eax_13); -005245e7 *(uint32_t*)arg2 = ((char*)eax_13 + 4); -005245e9 this->motion_interpreter->my_run_rate = ((float)x87_r7); -005245e9 -005245f9 if (CPhysicsObj::GetObjectA(ebx_4) == 0) -005245f9 goto label_524668; -005245f9 -00524604 CPhysicsObj::MoveToObject(this->physics_obj, ebx_4, &var_9c); -00524610 InterpretedMotionState::~InterpretedMotionState(&var_28); -00524624 return 1; -005245b3 break; -005245b3 } -00524629 case 7: -00524629 { -00524629 MovementManager::MakeMoveToManager(this); -0052463b Position::UnPackOrigin(&var_70, arg2, arg3); -00524648 MovementParameters::UnPackNet(&var_9c, MoveToPosition, arg2, arg3); -0052464d void* eax_18 = *(uint32_t*)arg2; -0052464f long double x87_r7_1 = ((long double)*(uint32_t*)eax_18); -00524654 *(uint32_t*)arg2 = ((char*)eax_18 + 4); -00524656 this->motion_interpreter->my_run_rate = ((float)x87_r7_1); -00524668 label_524668: -00524668 MoveToManager::MoveToPosition(this->moveto_manager, &var_70, &var_9c); -00524674 InterpretedMotionState::~InterpretedMotionState(&var_28); -00524688 return 1; -00524629 break; -00524629 } -0052468d case 8: -0052468d { -0052468d MovementManager::MakeMoveToManager(this); -00524692 void* eax_21 = *(uint32_t*)arg2; -00524694 uint32_t ebx_6 = *(uint32_t*)eax_21; -005246a0 *(uint32_t*)arg2 = ((char*)eax_21 + 4); -005246a2 int32_t ecx_25 = *(uint32_t*)((char*)eax_21 + 4); -005246b3 *(uint32_t*)arg2 = ((char*)eax_21 + 8); -005246b5 MovementParameters::UnPackNet(&var_9c, TurnToObject, arg2, arg3); -005246b5 -005246c5 if (CPhysicsObj::GetObjectA(ebx_6) == 0) -005246c5 { -005246fb int32_t var_8c_1 = ecx_25; -005246ff var_b8_15 = &var_9c; -005246c5 goto label_524725; -005246c5 } -005246c5 -005246d0 CPhysicsObj::TurnToObject(this->physics_obj, ebx_6, &var_9c); -005246dc InterpretedMotionState::~InterpretedMotionState(&var_28); -005246f0 return 1; -0052468d break; -0052468d } -00524704 case 9: -00524704 { -00524704 MovementManager::MakeMoveToManager(this); -00524718 MovementParameters::UnPackNet(&var_9c, TurnToHeading, arg2, arg3); -00524721 var_b8_15 = &var_9c; -00524725 label_524725: -00524725 MoveToManager::TurnToHeading(this->moveto_manager, var_b8_15); -00524731 InterpretedMotionState::~InterpretedMotionState(&var_28); -00524745 return 1; -00524704 break; -00524704 } -0052453a } -0052453a -0052474f InterpretedMotionState::~InterpretedMotionState(&var_28); -0052445a } -0052444f } -0052444f -00524760 return 0; -00524440 } - -00524764 uint32_t jump_table_524764[0xa] = -00524764 { -00524764 [0x0] = 0x00524541 // case 0 -00524768 [0x1] = 0x00524748 // (unused index -> falls to default-exit path) -0052476c [0x2] = 0x00524748 -00524770 [0x3] = 0x00524748 -00524774 [0x4] = 0x00524748 -00524778 [0x5] = 0x00524748 -0052477c [0x6] = 0x005245b1 // case 6 -00524780 [0x7] = 0x00524627 // case 7 -00524784 [0x8] = 0x0052468b // case 8 -00524788 [0x9] = 0x00524702 // case 9 -0052478c } -``` - -**Function-level structure (top of function, before the type switch):** - -1. Entire function is a no-op (falls through to `return 0`) unless `this->motion_interpreter != 0` - AND `this->physics_obj != 0`. **This means `unpack_movement` requires the interpreter to - already exist** — unlike every OTHER MovementManager method, this one does NOT lazily - construct `motion_interpreter` on demand. If the interpreter hasn't been created yet - (e.g. via `EnterDefaultState`/`PerformMovement`/`get_minterp`), an inbound network - movement packet is silently dropped (returns 0, meaning presumably "0 bytes consumed" or - "not handled" to the caller). -2. On the happy path: `CPhysicsObj::interrupt_current_movement(physics_obj)` then - `CPhysicsObj::unstick_from_object(physics_obj)` — every unpacked movement command first - cancels any in-flight movement and un-sticks the object from whatever it was stuck to - (relevant to the R4-era sticky-guid work). -3. Builds a stack `Frame` (`var_70`..`var_2c_1`, `Frame::cache(&var_68)` — identity-ish frame - init, `0x3f800000` = 1.0f, rest zeroed) and default-constructs a `MovementParameters` - (`var_9c`) and an `InterpretedMotionState` (`var_28`) as scratch locals for the switch - below. -4. Reads a 16-bit **header word** `ebp_1` from the wire (`*(uint16_t*)eax_1`, advances - `arg2` by 2) — this is the `mt` (movement-type) value the switch dispatches on. A SECOND - 16-bit value `ecx_4` is read right after (advances `arg2` by another 2) and used as an - index into a `command_ids[]` table to get a motion-command id `ecx_5`; if that differs - from the interpreter's current pin/version (`CBaseFilter::GetPinVersion` — **BN - mislabel, see NOTE below**), it calls `CMotionInterp::DoMotion(interp, ecx_5, &var_9c)`. - This happens **unconditionally before the switch**, for every movement type. -5. `switch (ebp_1)` dispatches on the FULL 16-bit header word, not a masked/shifted - sub-field — cases match `0`, `6`, `7`, `8`, `9` literally. **This directly answers the - task's ask about 0x100/0x200 header-flag handling: those bits are NOT separate switch - cases or pre-switch branches.** They are only consumed in the `case 0` body (see below). - No case for 0x100 or 0x200 as a distinct dispatch value exists — the jump table only has - 9 accounted-for indices (0,6,7,8,9 real; 1-5 fall through to the same "no case" exit at - `0x524748`, which is the shared post-switch cleanup + `return 0`... actually the decompiled - text shows those return 0 via falling out of the switch to `InterpretedMotionState::~InterpretedMotionState(&var_28)` then `return 0`, since no case body ran). - -**`case 0` (mt == 0) — the raw/interpreted-motion unpack path, WITH the header-flag handling:** - -- `InterpretedMotionState::UnPack(&var_28, arg2, arg3)` — unpacks the actual motion state - payload from the wire buffer. -- **Sticky-guid extraction (this is where a header-derived flag conditionally reads an - extra guid off the wire):** `if ((*(uint8_t*)((char*)var_a4_1)[1] & 1) == 0) ebx_3 = 0; - else { read a uint32_t off the wire into ebx_3, advance arg2 by 4 }`. NOTE: the condition - reads byte 1 of `var_a4_1` (the ORIGINAL 16-bit header value, stored earlier as - `int16_t var_a4_1 = ecx_4` where `ecx_4` was the first 16-bit read == same value as - `ebp_1`) and tests bit 0 of that HIGH byte — i.e. bit 8 of the 16-bit header, which is - **`0x100`**. This is the "sticky guid" bit the task asked about: `mt & 0x100` gates - whether an extra `uint32_t` object-guid is read off the wire right after - `InterpretedMotionState::UnPack`. - - **NOTE (garbled-looking but NOT bitfield mush — it's a byte-address cast):** - `*(uint8_t*)((char*)var_a4_1)[1]` looks bizarre (casting a 16-bit local's VALUE to a - `char*` and indexing) — this is Binary Ninja's clumsy way of expressing "take the address - of the local `var_a4_1`, then read byte offset 1 of it" (i.e. the high byte of the - 16-bit `short`, since x86 is little-endian). Read literally it would be UB (treating - the int16 VALUE as a pointer), so this is almost certainly BN mis-rendering - `*((uint8_t*)&var_a4_1 + 1) & 1` (high byte of the header word, bit 0 of that byte = - bit 8 of the word = `0x100`). Keeping the raw text per instructions, but the lead should - read this as "bit `0x100` of the mt header word." - -- `MovementManager::move_to_interpreted_state(this, &var_28)` — feeds the unpacked state - into the interpreter (see that function's extract above; itself has a redundant lazy-create - guard even though we already know `motion_interpreter != 0` at this point since the whole - function is gated on that at the top). -- `if (ebx_3 != 0) CPhysicsObj::stick_to_object(this->physics_obj, ebx_3)` — if the sticky - bit was set AND the guid we read is non-zero, stick the physics object to that target. - This is the **0x100 sticky-guid handling** the task asked about, confirmed. -- `this->motion_interpreter->standing_longjump = (ebp_1 & 0x200)` — **the 0x200 - standing_longjump bit, confirmed.** Stored directly as a raw masked int (not normalized - to 0/1) into `CMotionInterp::standing_longjump` on the (already-guaranteed-non-null) - interpreter. This is a plain field write, not mush — the field just stores the - raw-masked-bit value (nonzero-but-not-necessarily-1 when set) rather than a boolean 0/1. -- destructs the scratch `InterpretedMotionState`, returns 1 (success/consumed). - -**`case 6` (MoveToObject):** -- `MovementManager::MakeMoveToManager(this)` — ensures `moveto_manager` exists. -- reads a target-object guid (`ebx_4`) off the wire, then `Position::UnPackOrigin(&var_70, ...)`, - then `MovementParameters::UnPackNet(&var_9c, MoveToObject, arg2, arg3)` (the network-unpack - overload, taking a `MovementType`/context enum `MoveToObject` as a literal tag), then reads - a float `my_run_rate` off the wire (via x87 float-load pattern, `long double` round-trip) - and stores it on `motion_interpreter->my_run_rate`. -- `if (CPhysicsObj::GetObjectA(ebx_4) == 0) goto label_524668` — if the target object can't be - resolved (not currently visible/known?), falls through to the shared `MoveToPosition` tail - (reusing the just-unpacked `var_70`/`var_9c` as a position-based fallback) instead of the - object-based move. -- else `CPhysicsObj::MoveToObject(physics_obj, ebx_4, &var_9c)` — object exists, move directly - to it. -- returns 1 either way. - -**`case 7` (MoveToPosition):** -- `MakeMoveToManager`, `Position::UnPackOrigin`, `MovementParameters::UnPackNet(..., MoveToPosition, ...)`, - reads `my_run_rate` float, falls straight into `label_524668` (shared with case 6's - object-not-found fallback): `MoveToManager::MoveToPosition(moveto_manager, &var_70, &var_9c)`. -- returns 1. - -**`case 8` (TurnToObject):** -- `MakeMoveToManager`, reads target guid `ebx_6` + an extra dword `ecx_25` (context_id?) off - the wire, `MovementParameters::UnPackNet(&var_9c, TurnToObject, arg2, arg3)`. -- `if (CPhysicsObj::GetObjectA(ebx_6) == 0)`: object not resolvable — falls through to the - shared `label_524725` tail (`var_b8_15 = &var_9c`, i.e. degrades to a TurnToHeading-style - call using just the unpacked params) instead of the object-based turn. -- else `CPhysicsObj::TurnToObject(physics_obj, ebx_6, &var_9c)` directly. -- returns 1 either way. - -**`case 9` (TurnToHeading):** -- `MakeMoveToManager`, `MovementParameters::UnPackNet(&var_9c, TurnToHeading, arg2, arg3)`, - falls into shared `label_524725`: `MoveToManager::TurnToHeading(moveto_manager, var_b8_15)`. -- returns 1. - -**Fall-through / no matching case (mt in {1,2,3,4,5} or any other 16-bit value not 0/6/7/8/9):** -- switch body produces no case match, control falls to - `InterpretedMotionState::~InterpretedMotionState(&var_28)` then `return 0` — i.e. silently - treated as "0 bytes handled" / not consumed, same as the "interpreter doesn't exist yet" - early-out at the top. - -**NOTE (BN mislabel, MEDIUM CONFIDENCE):** `CBaseFilter::GetPinVersion(this->motion_interpreter)` -at line 300597 — `CBaseFilter` is a DirectShow filter-graph base class, wildly out of place -for a `CMotionInterp*` argument. This is almost certainly a mislabeled call to some -`CMotionInterp` accessor (a "get current motion id / pin version"-shaped getter, maybe -`CMotionInterp::InqPendingMotion` or similar) that BN matched to the wrong vtable-slot -symbol. Kept verbatim per instructions; flagging so the lead doesn't chase DirectShow. - -**NOTE (data table, not extracted in full):** `command_ids[]` (referenced at line 300595, -`command_ids[(uint32_t)ecx_4]`) is a lookup table mapping a wire-encoded small integer to a -`MotionCommand` enum id. Not dumped here — out of scope for this extraction pass, but the -lead may want it if porting the exact `DoMotion` pre-switch call. - ---- - -## `MovementManager::HandleUpdateTarget` — 00524790 - -```c -00524790 void __fastcall MovementManager::HandleUpdateTarget(class MovementManager* this, class TargetInfo arg2) - -00524790 { -00524790 class MoveToManager* moveto_manager = this->moveto_manager; -00524790 -00524795 if (moveto_manager != 0) -0052479c MoveToManager::HandleUpdateTarget(moveto_manager, &arg2); -00524790 } -``` - -No-op if `moveto_manager` null; else forwards `arg2` (a `TargetInfo`, passed by value into -this function but forwarded by address) to `MoveToManager::HandleUpdateTarget`. Does not -touch `motion_interpreter`. - ---- - -## `CPhysicsObj::unpack_movement` — 00512040 (the CALLER seam / where `movement_manager` -gets lazily constructed from the `CPhysicsObj` side — directly relevant context, not in the -original list but requested implicitly via "CPhysicsObj's creation seam") - -```c -00512040 void __thiscall CPhysicsObj::unpack_movement(class CPhysicsObj* this, void** arg2, uint32_t arg3) - -00512040 { -0051204b if (this->movement_manager == 0) -0051204b { -0051205a this->movement_manager = MovementManager::Create(this, this->weenie_obj); -00512060 class MovementManager* eax_2; -00512060 eax_2 = this->state; -00512060 -0051206b if ((eax_2 & 1) == 0) -0051206b { -0051206d uint32_t transient_state = this->transient_state; -0051206d -00512075 if (transient_state >= 0) -00512075 { -00512083 this->update_time = (*(uint32_t*)Timer::cur_time); -00512089 *(uint32_t*)((char*)this->update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4); -00512075 } -00512075 -00512094 this->transient_state = (transient_state | 0x80); -0051206b } -0051204b } -0051204b -005120aa MovementManager::unpack_movement(this->movement_manager, arg2, arg3); -00512040 } -``` - -Lazy-creates `this->movement_manager` via `MovementManager::Create(this, this->weenie_obj)` -if null (note: does NOT call `EnterDefaultState` here, unlike `get_minterp`'s lazy-create -below — just `Create` then straight into `unpack_movement`). If bit 0 of `state` is clear -(NOT static?), stamps `update_time = Timer::cur_time` (conditionally, if -`transient_state >= 0`, i.e. sign bit clear) and ORs `0x80` into `transient_state` — -looks like a "wake up / mark dynamic-and-recently-updated" side effect that happens on -first-ever movement-manager creation for this physics object, gated behind the same -`(state & 1) == 0` check seen again below. Then unconditionally tailcalls -`MovementManager::unpack_movement(movement_manager, arg2, arg3)` (the function extracted -above) — this is THE entry point that feeds inbound wire movement bytes into the facade. - -## `CPhysicsObj::get_minterp` — 005120c0 (sibling lazy-create seam, for contrast) - -```c -005120c0 class CMotionInterp* __fastcall CPhysicsObj::get_minterp(class CPhysicsObj* this) - -005120c0 { -005120cb if (this->movement_manager == 0) -005120cb { -005120d5 class MovementManager* eax_2 = MovementManager::Create(this, this->weenie_obj); -005120df this->movement_manager = eax_2; -005120e5 MovementManager::EnterDefaultState(eax_2); -005120e5 -005120f1 if ((this->state & 1) == 0) -005120f1 { -005120f3 uint32_t transient_state = this->transient_state; -005120f3 -005120fb if (transient_state >= 0) -005120fb { -00512109 this->update_time = (*(uint32_t*)Timer::cur_time); -0051210f *(uint32_t*)((char*)this->update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4); -00512109 } -005120fb -0051211a this->transient_state = (transient_state | 0x80); -005120f1 } -005120cb } -005120cb -00512127 /* tailcall */ -00512127 return MovementManager::get_minterp(this->movement_manager); -005120c0 } -``` - -Same lazy-create-and-stamp idiom, but this path DOES call `MovementManager::EnterDefaultState` -right after `Create` (unlike `unpack_movement`'s seam above, which skips it). Confirms -`CPhysicsObj` has (at least) two independent lazy-construction call sites for -`movement_manager`, each with a slightly different post-create step — `unpack_movement` -skips `EnterDefaultState` (presumably because `unpack_movement` itself, or the subsequent -`MovementManager::unpack_movement` call, drives the interpreter into the right state via -`move_to_interpreted_state`/`PerformMovement`'s own lazy-create+enter-default-state guards), -while `get_minterp` needs the interpreter immediately ready to answer a query and so forces -`EnterDefaultState` explicitly. - -**No standalone `MovementManager::SetPhysicsObject` exists anywhere in the corpus** — grep -for the literal symbol returned zero matches. `physics_obj` is set exactly once, inside -`MovementManager::Create`, at construction time, and never reassigned. diff --git a/docs/research/2026-07-03-r5-managers/r5-port-plan.md b/docs/research/2026-07-03-r5-managers/r5-port-plan.md deleted file mode 100644 index b0ec1b2a..00000000 --- a/docs/research/2026-07-03-r5-managers/r5-port-plan.md +++ /dev/null @@ -1,338 +0,0 @@ -# R5 port plan — MovementManager facade + PositionManager (Sticky/Constraint) + TargetManager - -Synthesis of the R5 recon (2026-07-03). Verbatim retail decomp lives in the -sibling files (`r5-*-decomp.md`); ACE cross-reference in `r5-ace-crossref.md`; -current acdream integration seams in `r5-acdream-seams.md`. This doc is the -**pseudocode + integration + sub-slice plan** (mandatory workflow step 3). - -Retirement targets: **TS-39** (sticky no-op seams) and **AP-79** (the P4 -TargetTracker adapter). ConstraintManager is ported for structural completeness -of PositionManager but its arming stays unported (see §Constraint scope) — **TS-35 -stays open**. - ---- - -## 0. The retail structure vs acdream's reality - -**Retail** (`CPhysicsObj` owns three managers, all lazily created): - -``` -CPhysicsObj -├── movement_manager : MovementManager (facade → MotionInterp + MoveToManager) -├── position_manager : PositionManager (facade → Interpolation + Sticky + Constraint) -└── target_manager : TargetManager (voyeur subscription system) -``` - -Per-tick, `CPhysicsObj::UpdateObjectInternal` (0x005156b0) fans out in THIS order -(decomp §positionmanager-sticky callers): - -1. `DetectionManager::CheckDetection` -2. `TargetManager::HandleTargetting` ← voyeur tick (no separate UseTime) -3. `MovementManager::UseTime` → `MoveToManager::UseTime` only -4. `CPartArray::HandleMovement` -5. `PositionManager::UseTime` → Interp/Sticky/Constraint UseTime - -And `UpdatePositionInternal` (0x00512c30) composes the frame delta: -`Frame::cache(&d)` → `CPartArray::Update(d)` (animation) → -`PositionManager::adjust_offset(&d, quantum)` (interp+sticky+constraint ADD into -the SAME delta `d`) → `Frame::combine(out, m_position.frame, d)` → transition -(collision) → commit. **Sticky/constraint steering composes with animation motion -in the same per-tick delta frame, before collision resolves it.** - -**acdream** has no per-entity `CPhysicsObj`. The per-entity owner today is: - -| Retail | acdream (remote) | acdream (player) | -|---|---|---| -| `CPhysicsObj` | `GameWindow.RemoteMotion` (nested class, GameWindow.cs:411) | `_playerController` (`PlayerMovementController`) + GameWindow fields | -| `movement_manager` | `rm.Motion` (MotionInterpreter) + `rm.MoveTo` (MoveToManager) | `_playerController.Motion` + `playerMoveTo` | -| `position_manager` | **absent** | **absent** | -| `target_manager` | **AP-79 adapter**: `rm.TrackedTarget*` fields + `TickRemoteMoveTo` | **AP-79 adapter**: `_playerMoveToTarget*` fields + `OnUpdateFrame` block | -| `UpdateObjectInternal` tick | `TickRemoteMoveTo(rm)` (GameWindow.cs:4469) | `OnUpdateFrame` block (GameWindow.cs:7994) + `_playerController.Update` | - -R5 adds the two missing managers per-entity. The MovementManager facade (collapsing -`Motion`+`MoveTo` into one owner) is the optional capstone (§V4) — the retirement -targets don't require it, so it lands last and only if the arc has budget. - ---- - -## 1. Struct/field map (acclient.h → C#) - -``` -MovementManager (0x10): motion_interpreter, moveto_manager, physics_obj, weenie_obj -PositionManager (0x10): interpolation_manager, sticky_manager, constraint_manager, physics_obj -StickyManager (0x60): target_id:uint, target_radius:float, target_position:Position, - physics_obj, initialized:int, sticky_timeout_time:double -ConstraintManager(0x5c): physics_obj, is_constrained:int, constraint_pos_offset:float, - constraint_pos:Position, constraint_distance_start:float, - constraint_distance_max:float -TargetManager (0x18): physobj, target_info:TargetInfo*, voyeur_table:Hash, - last_update_time:double -TargetInfo (0xd0): context_id:uint, object_id:uint, radius:float, quantum:double, - target_position:Position, interpolated_position:Position, - interpolated_heading:Vec3, velocity:Vec3, status:TargetStatus, - last_update_time:double -TargettedVoyeurInfo(0x60): object_id:uint, quantum:double, radius:float, - last_sent_position:Position -``` - -acdream already has `TargetInfo` (4-field record) + `TargetStatus` enum in -`MoveToManager.cs` — R5 EXTENDS `TargetInfo` to the full 10-field shape (add -`ContextId`, `Radius`, `Quantum`, `InterpolatedHeading`, `Velocity`, -`LastUpdateTime`). The existing `MoveToManager.HandleUpdateTarget` only reads -`ObjectId`/`Status`/`InterpolatedPosition`, so the extension is additive-safe. - ---- - -## 2. The math — decoded (ACE is the oracle for the x87 mush) - -All three `adjust_offset` bodies and both timeout gates were dense x87 mush in the -BN decomp. ACE's ports resolve them cleanly and were confirmed against the mush -structure (fld/fmul/fsqrt/fcomp shapes match). **Port ACE's structure; cite the -retail address.** - -### 2a. StickyManager.adjust_offset (retail 0x00555430, ACE StickyManager.cs:91) - -``` -// Guard: no-op unless PhysicsObj && target_id != 0 && initialized -target = GetObjectA(target_id) // live resolve -targetPos = target != null ? target.Position : cached target_position // fallback to last-known -offset = self.Position.GetOffset(targetPos) // world vector self→target -offset = self.Position.GlobalToLocalVec(offset) // rotate into own frame -offset.Z = 0 // horizontal-only - -radius = self.GetRadius() -dist = Position.CylinderDistanceNoZ(radius, self.Position, target_radius, targetPos) - - 0.3f // StickyRadius const (ACE StickyRadius=0.3f) - -if NormalizeCheckSmall(ref offset): offset = Vector3.Zero // too close → don't chase jitter - -speed = 0 -minterp = self.get_minterp() -if minterp != null: speed = minterp.get_max_speed() * 5.0f // 5× own max speed (catch up) -if speed < EPSILON: speed = 15.0f // fallback - -delta = speed * quantum -if delta >= abs(dist): delta = dist // don't overshoot (dist can be NEGATIVE) -offset.Origin *= delta - -curHeading = self.Position.Frame.get_heading() -targetHeading = self.Position.heading(targetPos) -h = targetHeading - curHeading -if abs(h) < EPSILON: h = 0 -if h < -EPSILON: h += 360 -offset.set_heading(h) // per-tick RELATIVE turn toward target -``` - -Net: a speed-clamped horizontal steer + bounded turn toward the stuck target, -written into the shared per-tick delta frame. `StickyRadius=0.3f`, `StickyTime=1.0f` -are the two named constants. - -### 2b. ConstraintManager.adjust_offset (retail 0x00556180, ACE ConstraintManager.cs:62) - -``` -// Guard: no-op unless PhysicsObj && is_constrained -if (self.TransientState & Contact): // ONLY clamps while grounded - if constraint_pos_offset < constraint_distance_max: - if constraint_pos_offset > constraint_distance_start: - offset.Origin *= (constraint_distance_max - constraint_pos_offset) - / (constraint_distance_max - constraint_distance_start) // linear brake taper - else: - offset.Origin = Vector3.Zero // fully pinned -// UNCONDITIONAL (grounded or airborne): -constraint_pos_offset = offset.Origin.Length() // NB: length of the per-tick step, NOT distance-to-anchor -``` - -`ConstraintPos` (the anchor) is stored by ConstrainTo but never read by -adjust_offset — the taper uses `constraint_pos_offset` (last tick's step length) -vs start/max. See §ConstraintScope for why acdream never arms this. - -### 2c. ConstraintManager.IsFullyConstrained (retail 0x005560d0, ACE:37) - -``` -return constraint_distance_max * 0.9f < constraint_pos_offset; // ≥90% of leash → "fully constrained" -``` - -Read by `jump_is_allowed`: if true → return 0x47 (block jump). Since acdream never -arms the constraint, this stays false → jump never blocked here (= TS-35 current -behavior). - -### 2d. TargetManager quantum gates (retail 0x0051aa90 / 0x0051a650) - -``` -HandleTargetting(): // per-tick, self-throttled - if PhysicsTimer.CurrentTime - last_update_time < 0.5f: return // 0.5s throttle - if target_info != null && target_info.target_position == null: return - if target_info != null && target_info.status == Undefined - && target_info.last_update_time + 10.0f < Timer.cur_time: // 10s staleness - target_info.status = TimedOut - PhysicsObj.HandleUpdateTarget(copy(target_info)) - for voyeur in voyeur_table.Values.ToList(): - CheckAndUpdateVoyeur(voyeur) - last_update_time = PhysicsTimer.CurrentTime - -CheckAndUpdateVoyeur(voyeur): - newPos = GetInterpolatedPosition(voyeur.quantum) // self.pos + velocity*quantum (dead-reckon) - if newPos.Distance(voyeur.last_sent_position) > voyeur.radius: // send-on-significant-move - SendVoyeurUpdate(voyeur, newPos, Ok) - -GetInterpolatedPosition(quantum): - pos = copy(self.Position); pos.Origin += self.get_velocity() * quantum; return pos -``` - -`0.5f` throttle, `10.0f` staleness — retail-tuned quanta (cross-checked ACE; verify -against named-retail before treating literally, per ACE's own `// ref?` caveats). - -### 2e. The voyeur round-trip (peer-to-peer position notification) - -``` -Watcher W wants to track target T: - W.SetTarget(ctx=0, T.id, radius=0.5, quantum=q) - → W.target_info = new TargetInfo(...) - → T.add_voyeur(W.id, 0.5, q) // W subscribes ON T's voyeur_table - → T immediately SendVoyeurUpdate(W-voyeur, T.pos, Ok) // initial snapshot - → W.receive_target_update(info) → W.ReceiveUpdate(info) - → W.target_info updated; W.HandleUpdateTarget(info) - → W.movement_manager.HandleUpdateTarget (MoveToManager steers) - → W.position_manager.HandleUpdateTarget (StickyManager follows) - -Each tick, T.HandleTargetting → CheckAndUpdateVoyeur(W-voyeur): - if T drifted > radius since last sent → SendVoyeurUpdate → W.ReceiveUpdate → W.HandleUpdateTarget - -W stops: W.ClearTarget → T.remove_voyeur(W.id) -T despawns: T.exit_world → NotifyVoyeurOfEvent(ExitWorld) → all watchers get ExitWorld -T teleports: T.teleport_hook → NotifyVoyeurOfEvent(Teleported) -``` - -This is exactly what the AP-79 adapter approximates (poll target pos, feed -HandleUpdateTarget when drift>radius). The voyeur port is a **faithful superset**: -same moveto behavior + correct sticky + timeout/exit/teleport events. - ---- - -## 3. Two consumers of set_target (both radius 0.5) - -| Caller | quantum | Meaning | -|---|---|---| -| `MoveToManager.MoveToObject/TurnToObject` | **0.0** | resend as fast as the 0.5s tick allows | -| `StickyManager.StickTo` | **0.5** | throttled resend | - -Both go through the SAME `CPhysicsObj::set_target → TargetManager::SetTarget`, and -both receive updates through `CPhysicsObj::HandleUpdateTarget → -{MovementManager, PositionManager}::HandleUpdateTarget`. In acdream the -`_setTarget`/`_clearTarget` seams (already on MoveToManager) get repointed at the -real TargetManager; StickyManager gets its own seam to the same TargetManager. - ---- - -## 4. acdream integration seams (delegate contracts) - -The Core classes stay engine-agnostic via ctor-injected seams (same convention as -MoveToManager). Per entity the App layer supplies: - -- `getPosition : () → Position` (world-space, cell + frame) -- `getVelocity : () → Vector3` -- `getRadius : () → float`, `getHeight : () → float` (from setup cylsphere — **finally - needed here; today MoveToManager passes 0** — see the P4 note) -- `getMinterpMaxSpeed : () → float?` (null if no interp) — StickyManager speed -- `getContact : () → bool` (transient Contact bit) — ConstraintManager gate -- `getObjectA : uint guid → IPhysicsTargetHandle?` — cross-entity resolve (the guid→entity - seam; drives voyeur delivery + sticky live-target). Backed by GameWindow's - `_entitiesByServerGuid`. -- `handleUpdateTarget : TargetInfo → void` — fans to MoveToManager + PositionManager(Sticky) -- `clearTarget`/`interruptCurrentMovement` — teardown (already exist on MoveToManager/Motion) -- clock : `() → double` (real clock, as R4-V5 fixed) - -`getObjectA` returns a small handle exposing the OTHER entity's -`receive_target_update(TargetInfo)` (→ its TargetManager.ReceiveUpdate) + its live -Position — enough for `SendVoyeurUpdate` and sticky's live-target resolve. - ---- - -## 5. Sub-slice plan - -### R5-V1 — Core classes + conformance tests (NO wiring; low risk) -New files under `src/AcDream.Core/Physics/Motion/` (Position/Sticky/Constraint) and -`src/AcDream.Core/Physics/Combat/` (TargetManager — retail namespace split): -- `PositionManager` (facade: adjust_offset chain interp→sticky→constraint, UseTime, - StickTo/Unstick/ConstrainTo/UnConstrain/IsFullyConstrained/HandleUpdateTarget). -- `StickyManager` (§2a math, StickTo/UnStick/UseTime timeout/HandleUpdateTarget/adjust_offset). -- `ConstraintManager` (§2b/§2c, ConstrainTo/UnConstrain/IsFullyConstrained/adjust_offset). -- `TargetManager` (§2d/§2e full voyeur system) + `TargettedVoyeurInfo` + extended `TargetInfo`. -- InterpolationManager: acdream already has a remote `Interp` (PhysicsBody interp) — - keep it; PositionManager's interp slot delegates to a thin adapter or is left null - for the player. (Interp is NOT an R5 retirement target; don't re-port it.) -- Seam interfaces + a test harness with a fake `getObjectA` wiring two managers to - each other. Golden values from ACE + the decoded math above. Tests: - sticky steer/clamp/heading, sticky 1s timeout, constraint taper + IsFullyConstrained - 90% gate, voyeur subscribe/immediate-snapshot/distance-gate/10s-timeout/exit/teleport, - ReceiveUpdate match+heading-fallback, the full W↔T round-trip. -- `dotnet build`+`dotnet test` green. Commit. **No behavior change** (nothing wired). - -### R5-V2 — Wire TargetManager per-entity; retire AP-79 -- Add `TargetManager` to `RemoteMotion` + a player-side owner; construct beside the - MoveToManager binds. Repoint MoveToManager's `_setTarget`/`_clearTarget`/quantum - seams at `TargetManager.SetTarget`/`ClearTarget`/quantum. -- `getObjectA` backed by `_entitiesByServerGuid` (returns a handle to the target's - TargetManager). Per-tick: call `TargetManager.HandleTargetting()` in - `TickRemoteMoveTo` + the player block, BEFORE `MoveToManager.UseTime()` (retail order). -- Delete the AP-79 tracker fields (`TrackedTarget*` / `_playerMoveToTarget*`) and the - manual poll blocks — the voyeur round-trip replaces them. -- Register: delete AP-79 row same commit. **Visual gate**: a server-directed creature - chasing the player still tracks + moves identically (the exact behavior AP-79 drove). - -### R5-V3 — Wire PositionManager (Sticky); retire TS-39; apply mt-0 sticky guid -- Add `PositionManager` to each entity. Bind `MoveToManager.StickTo → - PositionManager.StickTo`, `MoveToManager.Unstick → PositionManager.UnStick`. -- Integrate `PositionManager.adjust_offset` into the per-frame body integration - (the composed delta-frame chokepoint — retail's UpdatePositionInternal). This is - the load-bearing wiring: sticky steer must ADD to the body's per-tick motion. -- Per-tick `PositionManager.UseTime()` AFTER MoveToManager.UseTime (retail order). -- Apply the mt-0 wire flags (UpdateMotion.cs already parses them, unconsumed): - `0x1 StickToObject` → `stick_to_object(guid)` → `PositionManager.StickTo`; - `0x2 StandingLongJump` → `MotionInterpreter.StandingLongJump`. -- Register: delete TS-39 row same commit. **Visual gate**: a sticky scenario (server - /follow-style sticky moveto or a moving-platform stick) holds the target instead of - stopping. - -### R5-V4 (capstone, optional) — MovementManager facade + #164 + head stance dispatch + docs -- Collapse per-entity `Motion`+`MoveTo` into a `MovementManager` owner (structural; - must keep the 183-case + funnel + moveto suites green UNMODIFIED). -- #164: action-replay Autonomous bit (params 0x1000 from per-action autonomy flag, - raw 305982) in `MotionInterpreter.MoveToInterpretedState` action loop. -- Head stance-change dispatch for mt 6-9 (verification item 1 / the S3 exclusion at - `RetailObserverTraceConformanceTests.cs:33`). -- Final register/ISSUES/roadmap/memory + successor handoff. - ---- - -## Constraint scope (why TS-35 stays open) - -ConstraintManager is the **server-position rubber-band leash**, armed ONLY from -`SmartBox::HandleReceivedPosition` (arm on every inbound position packet, anchor = -self or server-received position) with two constants from `GetStart/MaxConstraintDistance` -(0x0050ebc0 / 0x0050ec10) that are **x87 float returns BN elided — literal values -unknown** (need a live cdb read or Ghidra float-return fix). acdream has no SmartBox -leash (its position reconciliation is separate), so: -- Port the class + wire it into PositionManager (IsFullyConstrained routes through it; - adjust_offset participates in the chain) for structural faithfulness. -- **Do NOT arm it** (no ConstrainTo call site) — the leash + jump-blocking + the two - unknown constants are out of R5 scope. IsFullyConstrained stays false = TS-35's - current behavior. Update TS-35's `where` to point at the now-real-but-unarmed - ConstraintManager; keep the row OPEN. Filing an issue for the leash port + the two - unknown constants. - -## Open verification items (carry into V1 tests / cdb) -1. `GetStart/MaxConstraintDistance` x87 return constants — unknown (constraint arming, - deferred). File as issue. -2. `HandleTargetting`'s 0.5s/10s quanta + `StickyTime` 1.0s / `StickyRadius` 0.3f / - sticky speed `×5` + `15.0f` fallback — ACE values; verify against named-retail. -3. mt-0 header flags: confirm `0x1`/`0x2` (wire) == decomp `0x100`/`0x200` shift - (recon §4 confirms: same bits, 8-bit motionFlags at bit-8 of the unpack header dword). -4. `TargetInfo` pass-by-value-vs-ref (ACE's two `// ref?` markers) — retail copy-constructs - (`TargetInfo::TargetInfo(©, src)` at every fan-out); port as copy. - -## Do-NOT-re-port (already shipped) -MoveToManager (R4, full incl. queue + HandleUpdateTarget consumer), MotionInterpreter -(R3, #161 apply-pass), MotionTableManager+GetObjectSequence (R2), CSequence (R1), -RemoteWeenie + PhysicsBody.InWorld (R4-V5), InterpolationManager (acdream's remote -Interp — not an R5 target). diff --git a/docs/research/2026-07-03-r5-managers/r5-positionmanager-sticky-decomp.md b/docs/research/2026-07-03-r5-managers/r5-positionmanager-sticky-decomp.md deleted file mode 100644 index c3285b6f..00000000 --- a/docs/research/2026-07-03-r5-managers/r5-positionmanager-sticky-decomp.md +++ /dev/null @@ -1,1533 +0,0 @@ -# Retail decomp extract: PositionManager + StickyManager + CPhysicsObj sticky seams - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Binary Ninja pseudo-C, -Sept 2013 EoR build, PDB-named). Struct defs from `docs/research/named-retail/acclient.h`. -All function bodies below are VERBATIM (unedited) except for the surrounding-context -excerpts marked as such. - ---- - -## Struct definitions (acclient.h) - -### `struct PositionManager` (acclient.h:30952, type id 3468) - -```c -/* 3468 */ -struct __cppobj PositionManager -{ - InterpolationManager *interpolation_manager; - StickyManager *sticky_manager; - ConstraintManager *constraint_manager; - CPhysicsObj *physics_obj; -}; -``` - -### `struct StickyManager` (acclient.h:31518, type id 3466) - -```c -/* 3466 */ -struct __cppobj StickyManager -{ - unsigned int target_id; - float target_radius; - Position target_position; - CPhysicsObj *physics_obj; - int initialized; - long double sticky_timeout_time; -}; -``` - -### `struct ConstraintManager` (acclient.h:31529, type id 3467, for context — sibling manager) - -```c -/* 3467 */ -struct __cppobj ConstraintManager -{ - CPhysicsObj *physics_obj; - int is_constrained; - float constraint_pos_offset; - Position constraint_pos; - float constraint_distance_start; - float constraint_distance_max; -}; -``` - -NOTE: `PositionManager` is a small 4-pointer facade/dispatcher struct (0x10 bytes — -matches `PositionManager::Create`'s `operator new(0x10)`). It owns three independently -lazily-created sub-managers: `InterpolationManager` (network position interpolation), -`StickyManager` (parent-object stick/follow), `ConstraintManager` (radius-bounded tether). -`StickyManager` itself is 0x60 bytes (`StickyManager::Create`'s `operator new(0x60)`) and -holds a single sticky target: id, radius, cached target position (a full `Position`, -which embeds a `Frame`), back-pointer to owner `CPhysicsObj`, an `initialized` bool gating -whether `target_position` is valid yet, and an `x87 long double` timeout timestamp -(`sticky_timeout_time`) compared against `Timer::cur_time`. - ---- - -## PositionManager methods - -### `PositionManager::UseTime` — 0x00555160 - -```c -00555160 void __fastcall PositionManager::UseTime(class PositionManager* this) - -00555160 { -00555163 class InterpolationManager* interpolation_manager = this->interpolation_manager; -00555163 -00555167 if (interpolation_manager != 0) -00555169 InterpolationManager::UseTime(interpolation_manager); -00555169 -0055516e class ConstraintManager* constraint_manager = this->constraint_manager; -0055516e -00555173 if (constraint_manager != 0) -00555175 IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(constraint_manager); -00555175 -0055517a class StickyManager* sticky_manager = this->sticky_manager; -0055517a -00555180 if (sticky_manager == 0) -00555187 return; -00555187 -00555182 /* tailcall */ -00555182 return StickyManager::UseTime(sticky_manager); -00555160 } -``` - -NOTE: the `constraint_manager != 0` branch calls -`IDClass<_tagDataID,32,0>::~IDClass<_tagDataID,32,0>(constraint_manager)` — this is -almost certainly a Binary Ninja mis-decompilation / bad-symbol-resolution artifact -(the destructor of an unrelated template ID class being called on a `ConstraintManager*` -makes no structural sense). The real call is very likely `ConstraintManager::UseTime` -(mirroring the `InterpolationManager::UseTime` / `StickyManager::UseTime` pattern on -either side of it) — flagging for the lead rather than silently correcting. - -### `PositionManager::adjust_offset` — 0x00555190 - -```c -00555190 void __thiscall PositionManager::adjust_offset(class PositionManager* this, class Frame* arg2, double arg3) - -00555190 { -00555191 int32_t ebx = arg3; -0055519d class InterpolationManager* interpolation_manager = this->interpolation_manager; -005551a2 int32_t edi = *(uint32_t*)((char*)arg3)[4]; -005551a2 -005551a6 if (interpolation_manager != 0) -005551a6 { -005551a8 int32_t var_14_1 = edi; -005551ab InterpolationManager::adjust_offset(interpolation_manager, arg2, ebx); -005551a6 } -005551a6 -005551b0 class StickyManager* sticky_manager = this->sticky_manager; -005551b0 -005551b5 if (sticky_manager != 0) -005551b5 { -005551b7 int32_t var_14_2 = edi; -005551ba StickyManager::adjust_offset(sticky_manager, arg2, ebx); -005551b5 } -005551b5 -005551bf class ConstraintManager* constraint_manager = this->constraint_manager; -005551bf -005551c4 if (constraint_manager != 0) -005551c4 { -005551c6 int32_t var_14_3 = edi; -005551c9 ConstraintManager::adjust_offset(constraint_manager, arg2, ebx); -005551c4 } -00555190 } -``` - -NOTE: the `int32_t edi = *(uint32_t*)((char*)arg3)[4];` line is BN garbling `arg3` -(a `double` passed as quantum/elapsed-time) — this looks like a decompiler artifact from -the double being passed partly in a register pair; the actual semantic is simply "pass -`arg3` (the quantum) through to all three sub-manager `adjust_offset` calls unchanged." -Each sub-manager gets the SAME `Frame*` (`arg2`, an in/out accumulator) and the SAME -quantum. This mirrors `UseTime`'s dispatch pattern: PositionManager is a pure fan-out -facade over its three sub-managers, called once per tick. - -### `PositionManager::UnStick` — 0x005551e0 - -```c -005551e0 void __fastcall PositionManager::UnStick(class PositionManager* this) - -005551e0 { -005551e0 class StickyManager* sticky_manager = this->sticky_manager; -005551e0 -005551e5 if (sticky_manager == 0) -005551ec return; -005551ec -005551e7 /* tailcall */ -005551e7 return StickyManager::UnStick(sticky_manager); -005551e0 } -``` - -### `PositionManager::InterpolateTo` — 0x005551f0 (context — sibling method) - -```c -005551f0 void __thiscall PositionManager::InterpolateTo(class PositionManager* this, class Position const* arg2, int32_t arg3) - -005551f0 { -005551f6 if (this->interpolation_manager == 0) -00555204 this->interpolation_manager = InterpolationManager::Create(this->physics_obj); -00555204 -00555212 InterpolationManager::InterpolateTo(this->interpolation_manager, arg2, arg3); -005551f0 } -``` - -### `PositionManager::StopInterpolating` — 0x00555220 (context) - -```c -00555220 void __fastcall PositionManager::StopInterpolating(class PositionManager* this) - -00555220 { -00555220 class InterpolationManager* interpolation_manager = this->interpolation_manager; -00555220 -00555224 if (interpolation_manager == 0) -0055522b return; -0055522b -00555226 /* tailcall */ -00555226 return InterpolationManager::StopInterpolating(interpolation_manager); -00555220 } -``` - -### `PositionManager::StickTo` — 0x00555230 - -```c -00555230 void __thiscall PositionManager::StickTo(class PositionManager* this, uint32_t arg2, float arg3, float arg4) - -00555230 { -00555238 if (this->sticky_manager == 0) -00555246 this->sticky_manager = StickyManager::Create(this->physics_obj); -00555246 -0055525b StickyManager::StickTo(this->sticky_manager, arg2, arg3, arg4); -00555230 } -``` - -Lazy-creates the `StickyManager` on first stick, then delegates. `arg2` = target object -id, `arg3` = radius, `arg4` = height (per callers below). - -### `PositionManager::GetStickyObjectID` — 0x00555270 (context) - -```c -00555270 uint32_t __fastcall PositionManager::GetStickyObjectID(class PositionManager const* this) - -00555270 { -00555270 class StickyManager* sticky_manager = this->sticky_manager; -00555270 -00555275 if (sticky_manager == 0) -0055527e return 0; -0055527e -00555277 /* tailcall */ -00555277 return CommandList::GetHead(sticky_manager); -00555270 } -``` - -NOTE: `CommandList::GetHead(sticky_manager)` is an obviously wrong callee name for a -tailcall that's supposed to read `sticky_manager->target_id` (a plain `uint32_t` at -offset 0 of `StickyManager`). This looks like a BN symbol-resolution mixup (the real -function returns `this->sticky_manager->target_id` directly — likely inlined/no -separate symbol, and BN picked a spurious nearby symbol for the tailcall target). -Flagging, not correcting. - -### `PositionManager::ConstrainTo` — 0x00555280 (context — sibling method) - -```c -00555280 void __thiscall PositionManager::ConstrainTo(class PositionManager* this, class Position const* arg2, float arg3, float arg4) - -00555280 { -00555288 if (this->constraint_manager == 0) -00555296 this->constraint_manager = ConstraintManager::Create(this->physics_obj); -00555296 -00555299 class ConstraintManager* constraint_manager = this->constraint_manager; -00555299 -0055529f if (constraint_manager == 0) -005552a6 return; -005552a6 -005552a1 /* tailcall */ -005552a1 return ConstraintManager::ConstrainTo(constraint_manager, arg2, arg3, arg4); -00555280 } -``` - -### `PositionManager::UnConstrain` — 0x005552b0 (context) - -```c -005552b0 void __fastcall PositionManager::UnConstrain(class PositionManager* this) - -005552b0 { -005552b0 class ConstraintManager* constraint_manager = this->constraint_manager; -005552b0 -005552b5 if (constraint_manager == 0) -005552bc return; -005552bc -005552b7 /* tailcall */ -005552b7 return ConstraintManager::UnConstrain(constraint_manager); -005552b0 } -``` - -### `PositionManager::IsFullyConstrained` — 0x005552c0 - -```c -005552c0 int32_t __fastcall PositionManager::IsFullyConstrained(class PositionManager const* this) - -005552c0 { -005552c0 class ConstraintManager* constraint_manager = this->constraint_manager; -005552c0 -005552c5 if (constraint_manager == 0) -005552ce return 0; -005552ce -005552c7 /* tailcall */ -005552c7 return ConstraintManager::IsFullyConstrained(constraint_manager); -005552c0 } -``` - -### `PositionManager::Create` — 0x005552d0 (ctor/factory) - -```c -005552d0 class PositionManager* PositionManager::Create(class CPhysicsObj* arg1) - -005552d0 { -005552d3 void* result = operator new(0x10); -005552d3 -005552df if (result == 0) -00555332 return 0; -00555332 -005552e1 *(uint32_t*)result = 0; -005552e7 *(uint32_t*)((char*)result + 4) = 0; -005552ee *(uint32_t*)((char*)result + 8) = 0; -005552f5 *(uint32_t*)((char*)result + 0xc) = 0; -005552fc class QuickWindow* ecx = *(uint32_t*)result; -00555305 *(uint32_t*)((char*)result + 0xc) = arg1; -00555305 -00555308 if (ecx != 0) -0055530b QuickWindow::SetWindowID(ecx, arg1); -0055530b -00555310 class StickyManager* ecx_1 = *(uint32_t*)((char*)result + 4); -00555310 -00555315 if (ecx_1 != 0) -00555318 StickyManager::SetPhysicsObject(ecx_1, arg1); -00555318 -0055531d class ConstraintManager* ecx_2 = *(uint32_t*)((char*)result + 8); -0055531d -00555322 if (ecx_2 != 0) -00555325 ConstraintManager::SetPhysicsObject(ecx_2, arg1); -00555325 -0055532e return result; -005552d0 } -``` - -NOTE: `QuickWindow* ecx = *(uint32_t*)result;` / `QuickWindow::SetWindowID(ecx, arg1)` -is another BN symbol-mixup artifact — `ecx` here is really the freshly-zeroed -`interpolation_manager` field (offset 0, which was just set to 0 two lines above), so -this branch is structurally dead at construction time (the null-check always fails right -after `operator new` zeroes the block) and the "QuickWindow::SetWindowID" callee name is -spurious. Read this constructor as: `operator new(0x10)` → zero all 4 fields -(interpolation_manager, sticky_manager, constraint_manager @ offsets 0/4/8) → set -`physics_obj` (offset 0xc) = `arg1` → then three no-op "if member != 0, call -member::SetPhysicsObject(arg1)" guards that are all unreachable immediately after the -zero-fill (they'd only fire if a sub-manager pointer were already non-null, which can't -happen on a freshly allocated struct). This is very likely how the retail source -literally reads even though the guards are dead code at this call site — the same -"if (member) member->SetPhysicsObject(this)" idiom recurs in `StickyManager::Create`. - -### `PositionManager::Destroy` — 0x00555340 (dtor helper) - -```c -00555340 void __fastcall PositionManager::Destroy(class PositionManager* this) - -00555340 { -00555344 class InterpolationManager* interpolation_manager = this->interpolation_manager; -00555344 -00555348 if (interpolation_manager != 0) -00555348 { -0055534c InterpolationManager::~InterpolationManager(interpolation_manager); -00555352 operator delete(interpolation_manager); -00555348 } -00555348 -0055535a class StickyManager* sticky_manager = this->sticky_manager; -0055535f this->interpolation_manager = 0; -0055535f -00555365 if (sticky_manager != 0) -00555365 { -00555369 StickyManager::~StickyManager(sticky_manager); -0055536f operator delete(sticky_manager); -00555365 } -00555365 -00555377 class ConstraintManager* constraint_manager = this->constraint_manager; -0055537c this->sticky_manager = nullptr; -0055537c -00555383 if (constraint_manager != 0) -00555383 { -00555387 ConstraintManager::~ConstraintManager(constraint_manager); -0055538d operator delete(constraint_manager); -00555383 } -00555383 -00555396 this->constraint_manager = nullptr; -00555340 } -``` - -### `PositionManager::~PositionManager` — 0x005553a0 (dtor, tailcalls Destroy) - -```c -005553a0 void __fastcall PositionManager::~PositionManager(class PositionManager* this) - -005553a0 { -005553a0 /* tailcall */ -005553a0 return PositionManager::Destroy(this); -005553a0 } -``` - -### `PositionManager::IsInterpolating` — 0x005553b0 (context) - -```c -005553b0 int32_t __fastcall PositionManager::IsInterpolating(class PositionManager const* this) - -005553b0 { -005553b0 class InterpolationManager* interpolation_manager = this->interpolation_manager; -005553b0 -005553b4 if (interpolation_manager == 0) -005553c4 return 0; -005553c4 -005553bc int32_t result; -005553bc result = interpolation_manager->position_queue.head_ != 0; -005553c1 return result; -005553b0 } -``` - -### `PositionManager::HandleUpdateTarget` — 0x005553d0 - -```c -005553d0 void __fastcall PositionManager::HandleUpdateTarget(class PositionManager* this, class TargetInfo arg2) - -005553d0 { -005553d8 if (this->sticky_manager != 0) -005553d8 { -005553ea void var_d4; -005553ea TargetInfo::TargetInfo(&var_d4, &arg2); -005553f2 StickyManager::HandleUpdateTarget(this->sticky_manager, var_d4); -005553d8 } -005553d0 } -``` - -Only forwards to `StickyManager` (the `InterpolationManager` / `ConstraintManager` -siblings don't care about target-info updates — only sticky-follow does, since it needs -the live position of the object it's stuck to). - ---- - -## StickyManager methods - -### `StickyManager::UnStick` — 0x00555400 - -```c -00555400 void __fastcall StickyManager::UnStick(class StickyManager* this) - -00555400 { -00555406 if (this->target_id == 0) -00555427 return; -00555427 -00555408 class CPhysicsObj* physics_obj = this->physics_obj; -0055540b this->target_id = 0; -00555411 this->initialized = 0; -00555418 CPhysicsObj::clear_target(physics_obj); -00555421 /* tailcall */ -00555421 return CPhysicsObj::interrupt_current_movement(this->physics_obj); -00555400 } -``` - -No-op if not currently stuck. Otherwise: clear `target_id` + `initialized`, tell the -owning `CPhysicsObj` to clear its target-tracking registration -(`CPhysicsObj::clear_target`), then interrupt whatever movement is in flight -(`CPhysicsObj::interrupt_current_movement`) — this is the standard -"unstick invalidates in-progress movement" pattern repeated in every unstick path -below (`UseTime`'s timeout branch, `StickTo`'s re-stick branch, -`HandleUpdateTarget`'s failure branch all do the exact same 4-line sequence). - -### `StickyManager::adjust_offset` — 0x00555430 - -```c -00555430 void __thiscall StickyManager::adjust_offset(class StickyManager* this, class Frame* arg2, double arg3) - -00555430 { -00555436 uint32_t target_id = this->target_id; -00555436 -00555445 if ((target_id != 0 && this->initialized != 0)) -00555445 { -00555459 class Position* edi_2 = &this->physics_obj->m_position; -0055545c class CPhysicsObj* eax = CPhysicsObj::GetObjectA(target_id); -00555466 class Position* ebp_1 = &eax->m_position; -00555466 -00555469 if (eax == 0) -0055546b ebp_1 = &this->target_position; -0055546b -00555476 int32_t __return; -00555476 class AC1Legacy::Vector3* eax_1 = Position::get_offset(edi_2, &__return, ebp_1); -00555456 arg2->m_fOrigin.x = eax_1->x; -00555456 arg2->m_fOrigin.y = eax_1->y; -00555456 arg2->m_fOrigin.z = eax_1->z; -00555495 class AC1Legacy::Vector3* eax_3 = Position::globaltolocalvec(edi_2, &__return, &arg2->m_fOrigin); -00555456 arg2->m_fOrigin.x = eax_3->x; -00555456 arg2->m_fOrigin.y = eax_3->y; -00555456 arg2->m_fOrigin.z = eax_3->z; -00555456 arg2->m_fOrigin.z = 0f; -005554b3 float target_radius = this->target_radius; -005554c1 int32_t var_34_1 = CPhysicsObj::GetRadius(this->physics_obj); -005554d5 long double x87_r0; -005554d5 float var_14_1 = ((float)(Position::cylinder_distance_no_z(((float)x87_r0), edi_2, target_radius, ebp_1) - ((long double)0.300000012f))); -005554d5 int16_t top_1 = 1; -005554d5 -005554e0 if (AC1Legacy::Vector3::normalize_check_small(&arg2->m_fOrigin) != 0) -005554e0 { -005554e2 __return = 0; -00555456 arg2->m_fOrigin.x = __return; -00555456 arg2->m_fOrigin.y = 0f; -00555456 arg2->m_fOrigin.z = 0f; -005554e0 } -005554e0 -00555522 class CMotionInterp* eax_7; -00555522 long double st0_2; -00555522 -00555522 if (CPhysicsObj::get_minterp(this->physics_obj) == 0) -0055553b top_1 = 0; -00555522 else -0055552e eax_7 = CMotionInterp::get_max_speed(CPhysicsObj::get_minterp(this->physics_obj)); -0055553f long double temp1_1 = ((long double)0.000199999995f); -0055553f (/* unimplemented {fcom st0, dword [&F_EPSILON]} f- temp1_1 */ - temp1_1); -0055553f bool c0_1 = /* bool c0_1 = unimplemented {fcom st0, dword [&F_EPSILON]} f< temp1_1 */ < temp1_1; -0055553f bool c2_1 = (FCMP_UO(/* bool c2_1 = is_unordered.t(unimplemented {fcom st0, dword [&F_EPSILON]}, temp1_1) */, temp1_1)); -0055553f bool c3_1 = /* bool c3_1 = unimplemented {fcom st0, dword [&F_EPSILON]} f== temp1_1 */ == temp1_1; -00555545 eax_7 = ((((c0_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_1) ? 1 : 0) << 0xa) | ((((c3_1) ? 1 : 0) << 0xe) | ((top_1 & 7) << 0xb))))); -00555547 bool p_1 = /* bool p_1 = unimplemented {test ah, 0x5} */; -00555547 -0055554a if (!(p_1)) -0055554a { -0055554c /* unimplemented {fstp st0, st0} */; -0055554c /* unimplemented {fstp st0, st0} */; -0055554e /* unimplemented {fld st0, dword [&MAX_VELOCITY]} */; -0055554a } -0055554a -00555554 /* unimplemented {fmul st0, qword [esp+0x2c]} */; -00555558 /* unimplemented {fld st0, dword [esp+0x10]} */; -0055555c /* unimplemented {fabs } */; -0055555e /* unimplemented {fld st0, st1} */; -00555560 (/* unimplemented {fcompp } f- unimplemented {fcompp } */ - /* unimplemented {fcompp } f- unimplemented {fcompp } */); -00555560 bool c0_2 = /* bool c0_2 = unimplemented {fcompp } f< unimplemented {fcompp } */ < /* bool c0_2 = unimplemented {fcompp } f< unimplemented {fcompp } */; -00555560 bool c2_2 = (FCMP_UO(/* bool c2_2 = is_unordered.t(unimplemented {fcompp }, unimplemented {fcompp }) */, /* bool c2_2 = is_unordered.t(unimplemented {fcompp }, unimplemented {fcompp }) */)); -00555560 bool c3_2 = /* bool c3_2 = unimplemented {fcompp } f== unimplemented {fcompp } */ == /* bool c3_2 = unimplemented {fcompp } f== unimplemented {fcompp } */; -00555560 /* unimplemented {fcompp } */; -00555560 /* unimplemented {fcompp } */; -00555562 eax_7 = ((((c0_2) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_2) ? 1 : 0) << 0xa) | ((((c3_2) ? 1 : 0) << 0xe) | ((top_1 & 7) << 0xb))))); -00555564 bool p_2 = /* bool p_2 = unimplemented {test ah, 0x5} */; -00555567 int16_t top_9; -00555567 -00555567 if (p_2) -00555567 { -00555579 /* unimplemented {fstp st0, st0} */; -00555579 /* unimplemented {fstp st0, st0} */; -0055557b /* unimplemented {fld st0, dword [esp+0x10]} */; -0055557f /* unimplemented {fmul st0, dword [esi]} */; -00555456 arg2->m_fOrigin.x = ((float)/* arg2->m_fOrigin.x = fconvert.s(unimplemented {fstp dword [esi], st0}) */); -00555581 /* unimplemented {fstp dword [esi], st0} */; -00555583 /* unimplemented {fld st0, dword [esp+0x10]} */; -00555587 /* unimplemented {fmul st0, dword [esi+0x4]} */; -00555456 arg2->m_fOrigin.y = ((float)/* arg2->m_fOrigin.y = fconvert.s(unimplemented {fstp dword [esi+0x4], st0}) */); -0055558a /* unimplemented {fstp dword [esi+0x4], st0} */; -0055558d top_9 = top_1; -0055558d /* unimplemented {fld st0, dword [esp+0x10]} */; -00555567 } -00555567 else -00555567 { -00555569 /* unimplemented {fld st0, st0} */; -0055556b /* unimplemented {fmul st0, dword [esi]} */; -00555456 arg2->m_fOrigin.x = ((float)/* arg2->m_fOrigin.x = fconvert.s(unimplemented {fstp dword [esi], st0}) */); -0055556d /* unimplemented {fstp dword [esi], st0} */; -0055556f /* unimplemented {fld st0, st0} */; -00555571 /* unimplemented {fmul st0, dword [esi+0x4]} */; -00555456 arg2->m_fOrigin.y = ((float)/* arg2->m_fOrigin.y = fconvert.s(unimplemented {fstp dword [esi+0x4], st0}) */); -00555574 /* unimplemented {fstp dword [esi+0x4], st0} */; -00555574 top_9 = top_1; -00555567 } -00555567 -00555591 /* unimplemented {fmul st0, dword [esi+0x8]} */; -00555456 arg2->m_fOrigin.z = ((float)/* arg2->m_fOrigin.z = fconvert.s(unimplemented {fstp dword [esi+0x8], st0}) */); -00555597 /* unimplemented {fstp dword [esi+0x8], st0} */; -0055559a Position::heading(edi_2, ebp_1); -005555a2 arg3 = ((float)/* arg3.d = fconvert.s(unimplemented {fstp dword [esp+0x2c], st0}) */); -005555a2 /* unimplemented {fstp dword [esp+0x2c], st0} */; -005555a6 Frame::get_heading(&edi_2->frame); -005555a6 /* unimplemented {call Frame::get_heading} */; -005555ab /* unimplemented {fsubr st0, dword [esp+0x2c]} */; -005555b1 arg3 = ((float)/* arg3.d = fconvert.s(unimplemented {fst dword [esp+0x24], st0}) */); -005555b6 /* unimplemented {fabs } */; -005555b8 /* unimplemented {fld st0, dword [&F_EPSILON]} */; -005555be (/* unimplemented {fcompp } f- unimplemented {fcompp } */ - /* unimplemented {fcompp } f- unimplemented {fcompp } */); -005555be bool c0_3 = /* bool c0_3 = unimplemented {fcompp } f< unimplemented {fcompp } */ < /* bool c0_3 = unimplemented {fcompp } f< unimplemented {fcompp } */; -005555be bool c2_3 = (FCMP_UO(/* bool c2_3 = is_unordered.t(unimplemented {fcompp }, unimplemented {fcompp }) */, /* bool c2_3 = is_unordered.t(unimplemented {fcompp }, unimplemented {fcompp }) */)); -005555be bool c3_3 = /* bool c3_3 = unimplemented {fcompp } f== unimplemented {fcompp } */ == /* bool c3_3 = unimplemented {fcompp } f== unimplemented {fcompp } */; -005555be /* unimplemented {fcompp } */; -005555be /* unimplemented {fcompp } */; -005555c5 if ((*(uint8_t*)((char*)((((c0_3) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_3) ? 1 : 0) << 0xa) | ((((c3_3) ? 1 : 0) << 0xe) | (((top_9 + 3) & 7) << 0xb))))))[1] & 0x41) == 0) -005555c7 arg3 = 0; -005555c7 -005555cf /* unimplemented {fld st0, dword [&F_EPSILON]} */; -005555d5 /* unimplemented {fchs } */; -005555d7 long double temp2_1 = ((long double)arg3); -005555d7 (/* unimplemented {fcomp st0, dword [esp+0x20]} f- temp2_1 */ - temp2_1); -005555d7 bool c0_4 = /* bool c0_4 = unimplemented {fcomp st0, dword [esp+0x20]} f< temp2_1 */ < temp2_1; -005555d7 bool c2_4 = (FCMP_UO(/* bool c2_4 = is_unordered.t(unimplemented {fcomp st0, dword [esp+0x20]}, temp2_1) */, temp2_1)); -005555d7 bool c3_4 = /* bool c3_4 = unimplemented {fcomp st0, dword [esp+0x20]} f== temp2_1 */ == temp2_1; -005555d7 /* unimplemented {fcomp st0, dword [esp+0x20]} */; -005555d7 -005555e0 if ((*(uint8_t*)((char*)((((c0_4) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | ((((c2_4) ? 1 : 0) << 0xa) | ((((c3_4) ? 1 : 0) << 0xe) | (((top_9 + 3) & 7) << 0xb))))))[1] & 0x41) == 0) -005555e0 { -005555e2 /* unimplemented {fld st0, dword [esp+0x20]} */; -005555e6 /* unimplemented {fadd dword [&data_79bc60]} */; -005555ec arg3 = ((float)/* arg3.d = fconvert.s(unimplemented {fstp dword [esp+0x20], st0}) */); -005555ec /* unimplemented {fstp dword [esp+0x20], st0} */; -005555e0 } -005555e0 -005555f9 Frame::set_heading(arg2, arg3); -00555445 } -00555430 } -``` - -NOTE (heavy x87 mush, flagging for lead decode — do not treat as porting-ready without -decode): the whole back half of this function (from `005554d5` on) is a dense block of -`/* unimplemented */` x87 FPU stack operations (fcom/fcomp/fcompp/fabs/fchs/fld/fstp) that -BN could not lift to structured expressions, interleaved with FCOM-condition-code -decoding boilerplate (`c0`/`c2`/`c3` bit extraction feeding a fake "ah test 0x45" parity -check — this is the compiler's expansion of `if (x < y)` / `if (fabs(x) < EPSILON)` style -float comparisons via `fcomp` + `fnstsw ax` + `sahf`, which BN failed to fold back into a -plain comparison). Mechanically, best-effort reading of what's happening despite the mush: - -1. Early-out unless `target_id != 0 && initialized != 0` (mirrors `UnStick`'s guard). -2. Resolve the live target object via `CPhysicsObj::GetObjectA(target_id)`; if it's gone, - fall back to the cached `this->target_position` (the last known position from - `HandleUpdateTarget`). -3. Compute `Position::get_offset(my_position, &out, target_position)` — the offset vector - from self to target — store into `arg2->m_fOrigin` (the output `Frame`'s origin, - i.e. this becomes the STICK MOVEMENT DELTA for the frame). -4. Convert that offset to local space via `Position::globaltolocalvec`, then FORCE - `.z = 0` — sticky movement is horizontal-only (no auto-adjust of vertical offset; - presumably z is handled by normal physics/ground snap). -5. Compute `target_radius` (cached) + `CPhysicsObj::GetRadius(physics_obj)` (own radius) - feeding `Position::cylinder_distance_no_z(...)` minus a `0.3f` constant — a horizontal - cylinder-distance-to-target minus a 0.3-unit buffer, producing a "how far past the - desired follow-distance are we" scalar (`var_14_1`). -6. `AC1Legacy::Vector3::normalize_check_small(&arg2->m_fOrigin)` — normalizes the offset - direction in place; if the vector was too small to normalize (near-zero), the offset - is zeroed out entirely (no stick movement this tick — already at the target distance). -7. The x87 mush (`005554d5`–`005555f9`) computes a **clamped speed-scaled offset**: reads - `CMotionInterp::get_max_speed` from the owning object's `CMotionInterp` (or falls back - to a `MAX_VELOCITY` constant if no motion interpreter), clamps the per-tick offset - magnitude by that speed, applies it to `x`/`y` (still `z = 0`), then separately - computes a target HEADING via `Position::heading(edi_2, ebp_1)` (heading from self to - target position) minus the object's current heading (`Frame::get_heading`), applies an - epsilon-based snap-to-target-heading (small residual heading errors get zeroed; - large ones get consumed additively — likely turning the stuck object to face the - target it's following at a bounded turn rate, akin to a max-turn-rate clamp), and - finally writes the resulting heading via `Frame::set_heading(arg2, arg3)`. - -**Net semantic despite the mush**: `StickyManager::adjust_offset` computes, for THIS -tick's quantum, a bounded (speed-clamped, distance-buffered) horizontal position delta -plus a bounded heading delta that steers the sticking object toward its `target_id`'s -position, writing both into the `Frame*` accumulator (`arg2`) that -`PositionManager::adjust_offset` shares across all three sub-managers, which -`CPhysicsObj::UpdatePositionInternal` then combines into `this->m_position.frame` via -`Frame::combine`. This is a follow/leash steering behavior, not a hard position clamp — -it moves gradually per-tick toward the target, speed- and turn-rate-limited. - -### `StickyManager::UseTime` — 0x00555610 - -```c -00555610 void __fastcall StickyManager::UseTime(class StickyManager* this) - -00555610 { -00555616 if (this->target_id != 0) -00555616 { -00555618 long double x87_r7_1 = ((long double)Timer::cur_time); -0055561e long double temp0_1 = ((long double)this->sticky_timeout_time); -0055561e (x87_r7_1 - temp0_1); -0055561e -00555626 if ((*(uint8_t*)((char*)((((x87_r7_1 < temp0_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_1, temp0_1))) ? 1 : 0) << 0xa) | ((((x87_r7_1 == temp0_1) ? 1 : 0) << 0xe) | 0)))))[1] & 0x41) == 0) -00555626 { -00555628 class CPhysicsObj* physics_obj = this->physics_obj; -0055562b this->target_id = 0; -00555631 this->initialized = 0; -00555638 CPhysicsObj::clear_target(physics_obj); -00555641 /* tailcall */ -00555641 return CPhysicsObj::interrupt_current_movement(this->physics_obj); -00555626 } -00555616 } -00555610 } -``` - -NOTE: same FCOM-condition-code mush pattern as `adjust_offset`, but decodable cleanly -this time — the whole block is `if (!(Timer::cur_time < sticky_timeout_time)) { unstick }`, -i.e. **`if (Timer::cur_time >= this->sticky_timeout_time) { UnStick-equivalent }`**. This -is a per-tick sticky-TIMEOUT check: `StickTo` (below) sets `sticky_timeout_time = -Timer::cur_time + 1.0f` at stick time, and every subsequent call to `UseTime` re-checks -whether that 1-second deadline has passed; if so it force-clears the stick (same 4-step -teardown as `UnStick`: clear `target_id`/`initialized`, `CPhysicsObj::clear_target`, -`CPhysicsObj::interrupt_current_movement`). Since `StickTo` is the ONLY writer of -`sticky_timeout_time` and it's always `+1.0f` from stick-time, and `initialized` is -separately set by `HandleUpdateTarget` on receiving a fresh target position — this reads -as **"if we haven't heard a target-position update within 1 second of sticking, give -up the stick."** The deadline is NOT refreshed by `HandleUpdateTarget` in the code we -pulled — worth double-checking against a wider window, but as extracted, the timeout is -a one-shot "1 second grace period to get initialized" rather than a rolling heartbeat. - -### `StickyManager::Destroy` — 0x00555650 - -```c -00555650 void __fastcall StickyManager::Destroy(class StickyManager* this) - -00555650 { -00555659 if (this->target_id != 0) -0055565e CPhysicsObj::clear_target(this->physics_obj); -0055565e -00555667 this->target_id = 0; -0055566d int32_t var_48 = 0x796910; -0055567d int32_t var_40 = 0x3f800000; -00555685 int32_t var_3c = 0; -0055568d int32_t var_38 = 0; -00555695 int32_t var_34 = 0; -0055569d int32_t var_c = 0; -005556a5 int32_t var_8 = 0; -005556ad int32_t var_4 = 0; -005556b5 Frame::cache(&var_40); -005556c6 this->target_position.objcell_id = 0; -005556c9 Frame::operator=(&this->target_position.frame, &var_40); -005556ce this->initialized = 0; -00555650 } -``` - -Note this does NOT call `interrupt_current_movement` (unlike `UnStick`/`UseTime`'s -timeout branch/`StickTo`'s re-stick branch/`HandleUpdateTarget`'s failure branch) — -`Destroy` just tears down state (clear target id, reset cached `target_position` to an -identity `Frame` via `Frame::cache`, clear `initialized`) without touching movement. It's -called from `SetPhysicsObject` (when re-parenting) and the destructor. - -### `StickyManager::SetPhysicsObject` — 0x005556e0 - -```c -005556e0 void __thiscall StickyManager::SetPhysicsObject(class StickyManager* this, class CPhysicsObj* arg2) - -005556e0 { -005556e8 if (this->physics_obj == 0) -005556e8 { -005556fe this->physics_obj = arg2; -00555702 return; -005556e8 } -005556e8 -005556ea StickyManager::Destroy(this); -005556f3 this->physics_obj = arg2; -005556e0 } -``` - -First-time set is a plain assignment; re-set (physics_obj already non-null) tears down -any existing stick state first via `Destroy`, then reassigns. - -### `StickyManager::StickTo` — 0x00555710 - -```c -00555710 void __thiscall StickyManager::StickTo(class StickyManager* this, uint32_t arg2, float arg3, float arg4) - -00555710 { -00555716 if (this->target_id != 0) -00555716 { -00555718 class CPhysicsObj* physics_obj = this->physics_obj; -0055571b this->target_id = 0; -00555721 this->initialized = 0; -00555728 CPhysicsObj::clear_target(physics_obj); -00555730 CPhysicsObj::interrupt_current_movement(this->physics_obj); -00555716 } -00555716 -00555749 this->target_radius = arg3; -0055574c class CPhysicsObj* physics_obj_1 = this->physics_obj; -0055574f this->target_id = arg2; -00555751 long double x87_r7_1 = (((long double)1f) + ((long double)Timer::cur_time)); -0055575a this->initialized = 0; -00555761 this->sticky_timeout_time = ((double)x87_r7_1); -00555771 CPhysicsObj::set_target(physics_obj_1, 0, arg2, 0.5f, ((double)((long double)0.5f))); -00555710 } -``` - -NOTE: `arg4` (the 4th parameter, `height`, per callers) is READ NOWHERE in this function -body as extracted — only `arg2` (target id) and `arg3` (radius) are used; -`target_radius = arg3` and the hardcoded `set_target(..., 0.5f, 0.5)` constants are used -instead of `arg4`. Either `height` genuinely goes unused by `StickTo` itself (it's only -consumed elsewhere, e.g. by `PositionManager::StickTo`'s caller-side height computation -in `CPhysicsObj::stick_to_object`, purely for logging/display), or BN dropped a store. -Flagging — worth a targeted look at whether `arg4` should feed the `set_target` call's -`0.5f` radius/height-tolerance constants. - -If already stuck to something, first tears down the existing stick (same 4-step sequence -as `UnStick`). Then: cache `target_radius`, set `target_id = arg2`, reset -`initialized = 0` (fresh stick — no cached target position yet, must wait for -`HandleUpdateTarget`), set `sticky_timeout_time = Timer::cur_time + 1.0` (the 1-second -grace window `UseTime` checks), and call `CPhysicsObj::set_target(physics_obj, 0, arg2, -0.5f, 0.5)` — registers with the OWN physics object's target-tracking system (context id -0, target = arg2, with `0.5f`/`0.5` as some pair of radius/height-delta-tolerance -constants) so that server/game updates about the target's position get routed back -through `HandleUpdateTarget`. - -### `StickyManager::HandleUpdateTarget` — 0x00555780 - -```c -00555780 void __thiscall StickyManager::HandleUpdateTarget(class StickyManager* this, class TargetInfo arg2) - -00555780 { -00555783 uint32_t target_id = this->target_id; -00555783 -00555789 if (arg2.object_id == target_id) -00555789 { -00555799 if (arg2.status == Ok_TargetStatus) -00555799 { -0055579b uint32_t objcell_id = arg2.target_position.objcell_id; -0055579f this->initialized = 1; -005557aa this->target_position.objcell_id = objcell_id; -005557ad Frame::operator=(&this->target_position.frame, &arg2.target_position.frame); -005557b3 return; -00555799 } -00555799 -005557b8 if (target_id != 0) -005557b8 { -005557ba class CPhysicsObj* physics_obj = this->physics_obj; -005557bd this->target_id = 0; -005557c3 this->initialized = 0; -005557ca CPhysicsObj::clear_target(physics_obj); -005557d2 CPhysicsObj::interrupt_current_movement(this->physics_obj); -005557b8 } -00555789 } -00555780 } -``` - -Only reacts if the incoming `TargetInfo.object_id` matches our current `target_id` -(stale/mismatched updates from a previously-stuck target are ignored). On -`Ok_TargetStatus`: cache the target's `objcell_id` + `Frame` into `this->target_position` -and set `initialized = 1` — this is what `adjust_offset` reads when the live -`CPhysicsObj::GetObjectA(target_id)` lookup fails, and what gates `UseTime`'s "haven't -heard back within 1s" cleanup path since `initialized` starts false. On any OTHER status -(target lost/out of range/error), tears down the stick with the same 4-step sequence. - -### `StickyManager::~StickyManager` — 0x005557e0 (dtor) - -```c -005557e0 void __fastcall StickyManager::~StickyManager(class StickyManager* this) - -005557e0 { -005557e3 StickyManager::Destroy(this); -005557e8 this->target_position.vtable = 0x79285c; -005557e0 } -``` - -NOTE: `this->target_position.vtable = 0x79285c` — `target_position` is a `Position` -struct which embeds a `Frame`; the `Frame` type apparently starts with (or BN is -interpreting an early field as) a vtable-looking pointer that gets reset to a static -value at destruction (likely resetting an embedded `Frame`'s internal vtable-like tag -back to the base `Frame` type's identity, undoing any polymorphic Frame subtype). Kept -verbatim per instructions. - -### `StickyManager::Create` — 0x00555800 (ctor/factory) - -```c -00555800 class StickyManager* StickyManager::Create(class CPhysicsObj* arg1) - -00555800 { -00555804 void* result = operator new(0x60); -00555804 -00555812 if (result == 0) -00555866 return 0; -00555866 -00555814 *(uint32_t*)result = 0; -00555816 *(uint32_t*)((char*)result + 4) = 0; -0055581c *(uint32_t*)((char*)result + 8) = 0x796910; -00555823 *(uint32_t*)((char*)result + 0xc) = 0; -00555826 *(uint32_t*)((char*)result + 0x10) = 0x3f800000; -0055582c *(uint32_t*)((char*)result + 0x14) = 0; -0055582f *(uint32_t*)((char*)result + 0x18) = 0; -00555832 *(uint32_t*)((char*)result + 0x1c) = 0; -00555835 *(uint32_t*)((char*)result + 0x44) = 0; -00555838 *(uint32_t*)((char*)result + 0x48) = 0; -0055583b *(uint32_t*)((char*)result + 0x4c) = 0; -0055583e Frame::cache(((char*)result + 0x10)); -00555847 *(uint32_t*)((char*)result + 0x50) = 0; -0055584a *(uint32_t*)((char*)result + 0x54) = 0; -0055585a *(uint32_t*)((char*)result + 0x50) = arg1; -00555861 return result; -00555800 } -``` - -Allocates 0x60 bytes, zero-fills `target_id` (off 0) / `target_radius` (off 4), sets -`target_position.objcell_id` (off 8) = `0x796910` — NOTE: this is almost certainly a -vtable pointer for the embedded `Frame`/`Position` substructure rather than a literal -objcell id (0x796910 recurs as a "vtable"-looking constant elsewhere in this file, e.g. -`StickyManager::~StickyManager`'s `target_position.vtable = 0x79285c` uses a DIFFERENT -constant for a similarly-named field, and `PositionManager::Destroy`/`MoveToObject_*` -functions stack-allocate local `Frame`s with the same `0x796910` / `0x3f800000` pair as -identity-transform sentinels). `Frame::cache(result + 0x10)` then initializes the -embedded `Frame` (identity transform: the `0x3f800000` = `1.0f` at offset 0x10 lines up -with a scale/quaternion-w identity component) at the `target_position.frame` sub-offset. -Finally `physics_obj` (off 0x50) = `arg1`, `initialized` (off 0x54, zeroed) stays 0, -`sticky_timeout_time` (off 0x58, implicitly zero from the earlier ops covering 0x44-0x4c) -stays 0. Net: constructs an unstuck, uninitialized `StickyManager` bound to `arg1`. - ---- - -## CPhysicsObj sticky/position seams - -### `CPhysicsObj::MakePositionManager` — 0x00510210 - -```c -00510210 void __fastcall CPhysicsObj::MakePositionManager(class CPhysicsObj* this) - -00510210 { -0051021b if (this->position_manager == 0) -00510226 this->position_manager = PositionManager::Create(this); -00510226 -00510233 if ((this->state & 1) == 0) -00510233 { -00510235 uint32_t transient_state = this->transient_state; -00510235 -0051023d if (transient_state >= 0) -0051023d { -0051024b this->update_time = (*(uint32_t*)Timer::cur_time); -00510251 *(uint32_t*)((char*)this->update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4); -0051023d } -0051023d -0051025c this->transient_state = (transient_state | 0x80); -00510233 } -00510210 } -``` - -Lazy factory: creates `this->position_manager` only if not already present (idempotent — -safe to call unconditionally, which every caller below does). The second half -(`state & 1`, `transient_state`, `update_time` stamping, `transient_state |= 0x80`) is -NOT specific to PositionManager — it's the same "mark object as needing an update tick / -stamp last-update-time" boilerplate that recurs verbatim in `MakeMovementManager`, -`MoveToObject_Internal`, `TurnToObject_Internal`, `MoveToObject`, `TurnToHeading` (all -seen in the extracted callers below) — i.e. lazily creating ANY manager also flags the -object as active/dirty for the next tick's `UpdateObjectInternal` sweep. `state & 1` is -presumably an "already in the active/dirty set" bit being checked before re-flagging. - -### `CPhysicsObj::get_position_manager` — 0x00512130 - -```c -00512130 class PositionManager* __fastcall CPhysicsObj::get_position_manager(class CPhysicsObj* this) - -00512130 { -00512133 CPhysicsObj::MakePositionManager(this); -0051213f return this->position_manager; -00512130 } -``` - -Accessor that guarantees lazy-creation before returning — callers never need their own -null check. - -### `CPhysicsObj::stick_to_object` — 0x005127e0 - -```c -005127e0 void __thiscall CPhysicsObj::stick_to_object(class CPhysicsObj* this, uint32_t arg2) - -005127e0 { -005127e0 class CPhysicsObj* this_1 = this; -005127e4 CPhysicsObj::MakePositionManager(this); -005127e9 class CObjectMaint* CPhysicsObj::obj_maint_1 = CPhysicsObj::obj_maint; -005127e9 -005127f1 if (CPhysicsObj::obj_maint_1 != 0) -005127f1 { -005127f8 class CPhysicsObj* parent_2 = CObjectMaint::GetObjectA(CPhysicsObj::obj_maint_1, arg2); -005127f8 -005127ff if (parent_2 != 0) -005127ff { -00512802 class CPhysicsObj* parent_1 = parent_2; -00512804 class CPhysicsObj* parent = parent_2->parent; -00512804 -00512809 if (parent != 0) -0051280b parent_1 = parent; -0051280b -0051280d class CPartArray* part_array = parent_1->part_array; -0051280d -00512812 if (part_array == 0) -0051281f arg2 = 0; -00512812 else -00512819 arg2 = ((float)CPartArray::GetHeight(part_array)); -00512819 -00512827 class CPartArray* part_array_1 = parent_1->part_array; -00512827 -0051282c if (part_array_1 == 0) -00512839 this_1 = nullptr; -0051282c else -00512833 this_1 = ((float)CPartArray::GetRadius(part_array_1)); -00512833 -00512855 PositionManager::StickTo(this->position_manager, parent_1->id, this_1, arg2); -005127ff } -005127f1 } -005127e0 } -``` - -`arg2` comes in as a target object id, resolved via the global `CObjectMaint` -(`CObjectMaint::GetObjectA`); if the resolved object has its OWN `parent` (e.g. it's a -sub-part of a multi-part object like a wielded item), the stick target is redirected to -the TOP-LEVEL parent instead (`parent_1 = parent`). Radius/height for the stick are -pulled from that top-level parent's `CPartArray` (`GetRadius`/`GetHeight`) — this is -where `StickTo`'s `arg3`(radius)/`arg4`(height) parameters actually originate: -geometry of the object being stuck to, not the sticking object itself. Finally calls -`PositionManager::StickTo(this->position_manager, parent_1->id, radius, height)` — note -it sticks to `parent_1->id` (the resolved top-level id), NOT the original `arg2` id -passed in. - -### `CPhysicsObj::unstick_from_object` — 0x0050eae0 - -```c -0050eae0 void __fastcall CPhysicsObj::unstick_from_object(class CPhysicsObj* this) - -0050eae0 { -0050eae0 class PositionManager* position_manager = this->position_manager; -0050eae0 -0050eae8 if (position_manager == 0) -0050eaef return; -0050eaef -0050eaea /* tailcall */ -0050eaea return PositionManager::UnStick(position_manager); -0050eae0 } -``` - -Thin wrapper: null-safe forward to `PositionManager::UnStick` (which itself forwards to -`StickyManager::UnStick`). Unlike `MakePositionManager`, this does NOT lazily create — -if there's no `position_manager` yet, there's nothing to unstick. - ---- - -## Callers — where retail invokes these in the per-tick physics chain - -### `CPhysicsObj::UpdatePositionInternal` (0x00512c30) — calls `PositionManager::adjust_offset` - -```c -00512c30 void __thiscall CPhysicsObj::UpdatePositionInternal(class CPhysicsObj* this, float arg2, class Frame* arg3) - -00512c30 { -00512c3c int32_t var_40 = 0x3f800000; -00512c44 int32_t var_3c = 0; -00512c4c int32_t var_38 = 0; -00512c54 int32_t var_34 = 0; -00512c5c float var_c = 0f; -00512c64 float var_8 = 0f; -00512c6c float var_4 = 0f; -00512c74 Frame::cache(&var_40); -00512c74 -00512c86 if ((*(uint8_t*)((char*)((int16_t)this->state))[1] & 0x40) == 0) -00512c86 { -00512c88 class CPartArray* part_array = this->part_array; -00512c88 -00512c8d if (part_array != 0) -00512c95 CPartArray::Update(part_array, arg2, &var_40); -00512c95 -00512ca1 if ((this->transient_state & 2) == 0) -00512ca1 { -00512cd5 float var_c_2 = ((float)(((long double)var_c) * ((long double)0f))); -00512ce3 float var_8_2 = ((float)(((long double)var_8) * ((long double)0f))); -00512cf1 float var_4_2 = ((float)(((long double)var_4) * ((long double)0f))); -00512ca1 } -00512ca1 else -00512ca1 { -00512ca3 long double x87_r7_1 = ((long double)this->m_scale); -00512caf float var_c_1 = ((float)(((long double)var_c) * x87_r7_1)); -00512cb9 float var_8_1 = ((float)(((long double)var_8) * x87_r7_1)); -00512cc3 float var_4_1 = ((float)(((long double)var_4) * x87_r7_1)); -00512ca1 } -00512c86 } -00512c86 -00512cf5 class PositionManager* position_manager = this->position_manager; -00512cf5 -00512cfd if (position_manager != 0) -00512cfd { -00512d0a float var_54; -00512d0a var_54 = ((double)((long double)arg2)); -00512d0e PositionManager::adjust_offset(position_manager, &var_40, var_54); -00512cfd } -00512cfd -00512d22 Frame::combine(arg3, &this->m_position.frame, &var_40); -00512d22 -00512d30 if ((*(uint8_t*)((char*)((int16_t)this->state))[1] & 0x40) == 0) -00512d36 CPhysicsObj::UpdatePhysicsInternal(this, arg2, arg3); -00512d36 -00512d3d CPhysicsObj::process_hooks(this); -00512c30 } -``` - -**This is the per-tick chokepoint.** `arg2` is the frame's elapsed-time quantum. A local -identity `Frame` (`var_40`, cached via `Frame::cache`) is built up: first -`CPartArray::Update(part_array, quantum, &var_40)` (animation-driven part-array motion -contributes to the frame delta), then — if a `position_manager` exists — -`PositionManager::adjust_offset(position_manager, &var_40, quantum)` ADDS the -sticky/interpolation/constraint contributions into the SAME `var_40` accumulator -(this is the call that fans out to `StickyManager::adjust_offset`, -`InterpolationManager::adjust_offset`, `ConstraintManager::adjust_offset` in sequence). -Finally `Frame::combine(arg3, &this->m_position.frame, &var_40)` composes the -accumulated delta frame onto the object's actual position, producing the output frame -`arg3` (this is the frame that gets fed into `UpdatePhysicsInternal` next). So sticky -steering literally competes/composes with animation-driven part-array movement in the -SAME per-tick delta-frame before physics/collision resolves it. - -### `CPhysicsObj::UpdateObjectInternal` (0x005156b0) — calls `PositionManager::UseTime`, and transitively `UpdatePositionInternal` - -Full function extracted (contains the `UpdatePositionInternal` call + the `UseTime` call -later in the same tick): - -```c -005156b0 void __thiscall CPhysicsObj::UpdateObjectInternal(class CPhysicsObj* this, float arg2) - -005156b0 { -005156b6 uint16_t transient_state = ((int16_t)this->transient_state); -005156b6 -005156bf if (transient_state >= 0) -005156bf { -005159b8 label_5159b8: -005159b8 class ParticleManager* particle_manager = this->particle_manager; -005159b8 -005159c0 if (particle_manager != 0) -005159c2 ParticleManager::UpdateParticles(particle_manager); -005159c2 -005159c7 class ScriptManager* script_manager = this->script_manager; -005159c7 -005159cc if (script_manager != 0) -005159d0 ScriptManager::UpdateScripts(script_manager); -005156bf } -005156bf else if (this->cell != 0) -005156cf { -005156d8 if ((*(uint8_t*)((char*)transient_state)[1] & 1) != 0) -005156de CPhysicsObj::set_ethereal(this, 0, 0); -005156de -005156e7 this->jumped_this_frame = 0; -005156ed int32_t var_48 = 0x796910; -005156f5 int32_t var_44_1 = 0; -005156f9 int32_t var_40 = 0x3f800000; -00515701 int32_t var_3c_1 = 0; -00515709 int32_t var_38_1 = 0; -00515711 int32_t var_34_1 = 0; -00515719 float x = 0f; -00515721 int32_t var_8_1 = 0; -00515729 int32_t var_4_1 = 0; -00515731 Frame::cache(&var_40); -00515745 uint32_t objcell_id = this->m_position.objcell_id; -00515749 long double st0_1 = CPhysicsObj::UpdatePositionInternal(this, arg2, &var_40); -0051574e class CPartArray* part_array = this->part_array; -00515753 uint32_t eax_1; -00515753 -00515753 if (part_array != 0) -00515755 eax_1 = CPartArray::GetNumSphere(part_array); -00515755 -0051575c int32_t __return; -0051575c -0051575c if ((part_array != 0 && eax_1 != 0)) -0051575c { -005157ec if (AC1Legacy::Vector3::operator==(&x, &this->m_position.frame.m_fOrigin) == 0) -005157ec { -00515846 uint32_t state = this->state; -00515846 -0051584f if ((*(uint8_t*)((char*)state)[1] & 1) != 0) -0051584f { -0051585b AC1Legacy::Vector3::operator-(&x, &__return, &this->m_position.frame.m_fOrigin); -00515864 Vector3::Normalize(&__return); -00515872 Frame::set_vector_heading(&var_40, &__return); -0051584f } -0051584f else if (((state & "activation type (%s) with '%s' b…") != 0 && AC1Legacy::Vector3::is_zero(&this->m_velocityVector) == 0)) -0051587e { -00515898 int32_t var_74_5 = AC1Legacy::Vector3::get_heading(&this->m_velocityVector); -005158a0 Frame::set_heading(&var_40, ((float)st0_1)); -0051587e } -0051587e -005158b2 class CTransition* eax_10 = CPhysicsObj::transition(this, &this->m_position, &var_48, 0); -005158b2 -005158bb if (eax_10 == 0) -005158bb { -00515924 x = this->m_position.frame.m_fOrigin.x; -0051592c float z_2 = this->m_position.frame.m_fOrigin.z; -00515933 float y_2 = this->m_position.frame.m_fOrigin.y; -00515937 CPhysicsObj::set_frame(this, &var_40); -0051593c __return = 0; -00515948 this->cached_velocity.x = __return; -00515948 this->cached_velocity.y = 0f; -00515948 this->cached_velocity.z = 0f; -005158bb } -005158bb else -005158bb { -005158cb Position::get_offset(&this->m_position, &__return, &eax_10->sphere_path.curr_pos); -005158de void __return_1; -005158de int32_t* eax_12 = Vector3::operator/(&__return, &__return_1, arg2); -005158e5 float ecx_20 = eax_12[1]; -005158e8 __return = *(uint32_t*)eax_12; -005158ff this->cached_velocity.x = __return; -005158ff this->cached_velocity.y = ecx_20; -005158ff this->cached_velocity.z = eax_12[2]; -00515914 CPhysicsObj::SetPositionInternal(this, eax_10); -005158bb } -005157ec } -005157ec else -005157ec { -005157f7 x = this->m_position.frame.m_fOrigin.x; -00515802 float y_1 = this->m_position.frame.m_fOrigin.y; -00515806 float z_1 = this->m_position.frame.m_fOrigin.z; -0051580a CPhysicsObj::set_frame(this, &var_40); -0051580f __return = 0; -0051581b this->cached_velocity.x = __return; -0051581b this->cached_velocity.y = 0f; -0051581b this->cached_velocity.z = 0f; -005157ec } -0051575c } -0051575c else -0051575c { -00515764 if (this->movement_manager == 0) -00515764 { -00515766 uint32_t transient_state_1 = this->transient_state; -00515766 -0051576e if ((transient_state_1 & 2) != 0) -00515775 this->transient_state = (transient_state_1 & 0xffffff7f); -00515764 } -00515764 -00515789 x = this->m_position.frame.m_fOrigin.x; -00515794 float y = this->m_position.frame.m_fOrigin.y; -00515798 float z = this->m_position.frame.m_fOrigin.z; -0051579c CPhysicsObj::set_frame(this, &var_40); -005157a1 __return = 0; -005157ad this->cached_velocity.x = __return; -005157ad this->cached_velocity.y = 0f; -005157ad this->cached_velocity.z = 0f; -005157ec } -00515970 class DetectionManager* detection_manager = this->detection_manager; -00515970 -00515978 if (detection_manager != 0) -0051597a DetectionManager::CheckDetection(detection_manager); -0051597a -0051597f class TargetManager* target_manager = this->target_manager; -0051597f -00515987 if (target_manager != 0) -00515989 TargetManager::HandleTargetting(target_manager); -00515989 -0051598e class MovementManager* movement_manager = this->movement_manager; -0051598e -00515996 if (movement_manager != 0) -00515998 MovementManager::UseTime(movement_manager); -00515998 -0051599d class CPartArray* part_array_1 = this->part_array; -0051599d -005159a2 if (part_array_1 != 0) -005159a4 CPartArray::HandleMovement(part_array_1); -005159a4 -005159a9 class PositionManager* position_manager = this->position_manager; -005159a9 -005159b1 if (position_manager != 0) -005159b3 PositionManager::UseTime(position_manager); -005159b3 -00515753 goto label_5159b8; -005156cf } -005156b0 } -``` - -**Ordering within one tick (the `else if (this->cell != 0)` branch — the "object is in -the world" path):** -1. `CPhysicsObj::UpdatePositionInternal(this, arg2, &var_40)` — builds the delta frame - (part-array animation + `PositionManager::adjust_offset` sticky/interp/constraint - contributions), returns a heading-ish scalar in `st0_1`. -2. If the object has sphere-collision parts (`part_array->GetNumSphere() != 0`): compute - a facing/heading update, run `CPhysicsObj::transition(...)` (collision/movement - resolution against the delta frame), then either `set_frame` directly (transition - failed/no-op) or `SetPositionInternal` with the transition's resolved sphere-path - position (transition succeeded) — this is where the sticky-computed delta actually - gets validated against collision before being committed. -3. Else (no collision parts): just `set_frame` directly with the delta frame — no - collision check. -4. THEN: `DetectionManager::CheckDetection`, `TargetManager::HandleTargetting`, - `MovementManager::UseTime`, `CPartArray::HandleMovement`, and finally - **`PositionManager::UseTime(position_manager)`** — the sticky-timeout check runs - LAST in the tick, AFTER the position has already been moved/collision-resolved for - this frame. So a stick that times out this tick still got one more frame of - steered movement + collision resolution before being cleared. - -### `CPhysicsObj::HandleUpdateTarget` (0x00512bc0) — calls `PositionManager::HandleUpdateTarget` - -```c -00512bc0 void __thiscall CPhysicsObj::HandleUpdateTarget(class CPhysicsObj* this, class TargetInfo arg2) - -00512bc0 { -00512bc9 if (arg2.context_id == 0) -00512bc9 { -00512bd3 void var_d4; -00512bd3 -00512bd3 if (this->movement_manager != 0) -00512bd3 { -00512be5 TargetInfo::TargetInfo(&var_d4, &arg2); -00512bf0 MovementManager::HandleUpdateTarget(this->movement_manager, var_d4); -00512bd3 } -00512bd3 -00512bfd if (this->position_manager != 0) -00512bfd { -00512c0f TargetInfo::TargetInfo(&var_d4, &arg2); -00512c1a PositionManager::HandleUpdateTarget(this->position_manager, var_d4); -00512bfd } -00512bc9 } -00512bc0 } -``` - -Top-level entry point for target-position updates arriving from elsewhere (server -messages / target-tracking system), gated on `context_id == 0` (context 0 presumably -means "default/self" target tracking vs. some other numbered context). Fans the SAME -`TargetInfo` out to BOTH `MovementManager::HandleUpdateTarget` (move-to logic) AND -`PositionManager::HandleUpdateTarget` (sticky logic) if each manager exists. This is the -producer for the `StickyManager::HandleUpdateTarget` consumer described above. - -### `CPhysicsObj::exit_world` (0x00514e60) — calls `PositionManager::UnStick` - -```c -00514e60 void __fastcall CPhysicsObj::exit_world(class CPhysicsObj* this) - -00514e60 { -00514e63 class CPartArray* part_array = this->part_array; -00514e63 -00514e68 if (part_array != 0) -00514e6a CPartArray::HandleExitWorld(part_array); -00514e6a -00514e6f class MovementManager* movement_manager = this->movement_manager; -00514e6f -00514e77 if (movement_manager != 0) -00514e79 MovementManager::HandleExitWorld(movement_manager); -00514e79 -00514e7e class PositionManager* position_manager = this->position_manager; -00514e7e -00514e86 if (position_manager != 0) -00514e88 PositionManager::UnStick(position_manager); -00514e88 -00514e8d class TargetManager* target_manager = this->target_manager; -00514e8d -00514e95 if (target_manager != 0) -00514e95 { -00514e97 TargetManager::ClearTarget(target_manager); -00514ea4 TargetManager::NotifyVoyeurOfEvent(this->target_manager, ExitWorld_TargetStatus); -00514e95 } -00514e95 -00514ea9 class DetectionManager* detection_manager = this->detection_manager; -00514ea9 -00514eb1 if (detection_manager != 0) -00514eb5 DetectionManager::DestroyDetectionCylsphere(detection_manager, 0); -00514eb5 -00514ebe CPhysicsObj::report_collision_end(this, 1); -00514e60 } -``` - -Object leaving the world (despawn/logout): unstick unconditionally (only `UnStick`, not -full teardown — `position_manager` itself is kept alive). - -### `CPhysicsObj::teleport_hook` (0x00514ed0) — calls `PositionManager::UnStick` + `StopInterpolating` + `UnConstrain` - -```c -00514ed0 void __fastcall CPhysicsObj::teleport_hook(class CPhysicsObj* this, int32_t arg2) - -00514ed0 { -00514ed3 class MovementManager* movement_manager = this->movement_manager; -00514ed3 -00514edb if (movement_manager != 0) -00514edb { -00514edd int32_t var_8_1 = 0x3c; -00514edf uint32_t edx; -00514edf MovementManager::CancelMoveTo(movement_manager, edx); -00514edb } -00514edb -00514ee4 class PositionManager* position_manager = this->position_manager; -00514ee4 -00514eec if (position_manager != 0) -00514eee PositionManager::UnStick(position_manager); -00514eee -00514ef3 class PositionManager* position_manager_1 = this->position_manager; -00514ef3 -00514efb if (position_manager_1 != 0) -00514efd PositionManager::StopInterpolating(position_manager_1); -00514efd -00514f02 class PositionManager* position_manager_2 = this->position_manager; -00514f02 -00514f0a if (position_manager_2 != 0) -00514f0c PositionManager::UnConstrain(position_manager_2); -00514f0c -00514f11 class TargetManager* target_manager = this->target_manager; -00514f11 -00514f19 if (target_manager != 0) -00514f19 { -00514f1b TargetManager::ClearTarget(target_manager); -00514f28 TargetManager::NotifyVoyeurOfEvent(this->target_manager, Teleported_TargetStatus); -00514f19 } -00514f19 -00514f31 CPhysicsObj::report_collision_end(this, 1); -00514ed0 } -``` - -Teleport is the ONE place all three PositionManager sub-behaviors get explicitly torn -down together (cancel any move-to, unstick, stop interpolating, unconstrain) — makes -sense: after a teleport, none of the three "gradually approach some reference" behaviors -should still be steering toward a pre-teleport reference frame. - -### `MovementManager::unpack_movement` (0x00524440) — calls `CPhysicsObj::unstick_from_object` - -Context excerpt (full function is long; showing the relevant unstick call site inside the -inbound-motion-unpacking case-0 branch): - -```c -00524440 int32_t __thiscall MovementManager::unpack_movement(class MovementManager* this, void** arg2, uint32_t arg3) - -00524440 { -0052444f if (this->motion_interpreter != 0) -0052444f { -00524455 class CPhysicsObj* physics_obj = this->physics_obj; -00524455 -0052445a if (physics_obj != 0) -0052445a { -00524460 CPhysicsObj::interrupt_current_movement(physics_obj); -00524468 CPhysicsObj::unstick_from_object(this->physics_obj); -... -00524551 case 0: -00524551 { -00524551 InterpretedMotionState::UnPack(&var_28, arg2, arg3); -... -0052457c MovementManager::move_to_interpreted_state(this, &var_28); -0052457c -00524583 if (ebx_3 != 0) -00524589 CPhysicsObj::stick_to_object(this->physics_obj, ebx_3); -00524589 -0052458e this->motion_interpreter->standing_longjump = (ebp_1 & 0x200); -... -``` - -Two things happen in `unpack_movement`: (1) unconditionally at the top of the whole -function, EVERY inbound motion packet interrupts current movement AND unsticks -(`unstick_from_object`) before any of the packet's specific motion state is applied — -i.e. any new motion command from the network clears a prior stick; (2) later, inside -`case 0` (one specific unpacked-motion sub-case), if the unpacked state included a -sticky-target object id (`ebx_3`, read conditionally from the packed stream when a state -flag bit is set), `stick_to_object` is called to establish a NEW stick to that id. This -is the network-driven "server told the client to stick this object to another object" -path (e.g. mounting, carrying, or similar attach behaviors). - -### `CMotionInterp::MotionDone` (0x00527ec0) — calls `CPhysicsObj::unstick_from_object` - -```c -00527ec0 void __fastcall CMotionInterp::MotionDone(class CMotionInterp* this, int32_t arg2) - -00527ec0 { -00527ec3 class CPhysicsObj* physics_obj = this->physics_obj; -00527ec3 -00527ec8 if (physics_obj != 0) -00527ec8 { -00527eca class LListData* head_ = this->pending_motions.head_; -00527eca -00527ed2 if (head_ != 0) -00527ed2 { -00527edb if ((*(int32_t*)((char*)head_ + 8) & 0x10000000) != 0) -00527edb { -00527edd CPhysicsObj::unstick_from_object(physics_obj); -00527ee5 InterpretedMotionState::RemoveAction(&this->interpreted_state); -00527eed RawMotionState::RemoveAction(&this->raw_state); -00527edb } -00527edb -00527ef2 class LListData* head__1 = this->pending_motions.head_; -00527ef2 -00527efa if (head__1 != 0) -00527efa { -00527efc class LListData* llist_next = head__1->llist_next; -00527f00 this->pending_motions.head_ = llist_next; -00527f00 -00527f06 if (llist_next == 0) -00527f08 this->pending_motions.tail_ = llist_next; -00527f08 -00527f0f head__1->llist_next = 0; -00527f15 operator delete(head__1); -00527efa } -00527ed2 } -00527ec8 } -00527ec0 } -``` - -And its sibling `CMotionInterp::HandleExitWorld` (0x00527f30) has the identical -unstick-on-flag-bit pattern (queue-head motion's flag `0x10000000` set → unstick) when -draining `pending_motions` on exit-world. The `0x10000000` bit on a pending motion's -flags field marks "this motion action implies/requires a stick, so completing or -force-flushing it must unstick." Consistent with `unpack_movement`'s pattern: sticks are -tied to the lifecycle of a specific motion action, not held independently. - -### `MoveToManager::PerformMovement` (0x0052a900) — calls `CPhysicsObj::unstick_from_object` - -```c -0052a900 uint32_t __thiscall MoveToManager::PerformMovement(class MoveToManager* this, class MovementStruct const* arg2) - -0052a900 { -0052a901 int32_t var_8 = 0x36; -0052a905 uint32_t edx; -0052a905 MoveToManager::CancelMoveTo(this, edx); -0052a910 CPhysicsObj::unstick_from_object(this->physics_obj); -0052a910 -0052a923 switch ((arg2->type - 6)) -0052a923 { -0052a940 case 0: -0052a940 { -0052a940 MoveToManager::MoveToObject(this, arg2->object_id, arg2->top_level_id, arg2->radius, arg2->height, arg2->params); -0052a940 break; -0052a955 case 1: -0052a955 { -0052a955 MoveToManager::MoveToPosition(this, &arg2->pos, arg2->params); -0052a955 break; -... -``` - -Every new `MoveToManager` movement command starts by cancelling any in-flight move-to AND -unsticking — same "new movement intent clears prior stick" pattern as -`unpack_movement`. - -### `MoveToManager::BeginNextNode` (0x00529cb0) — calls `PositionManager::StickTo` via `get_position_manager` - -```c -00529ccb return; -00529cbe } -00529cbe -00529ce5 head_ = *(uint8_t*)((char*)this->movement_params.__inner0 + 0); -00529ce5 -00529ced if (head_ < 0) -00529ced { -00529cef float sought_object_height = this->sought_object_height; -00529cf5 float sought_object_radius = this->sought_object_radius; -00529d00 uint32_t top_level_object_id = this->top_level_object_id; -00529d0c int32_t edx_3 = MoveToManager::CleanUp(this); -00529d11 class CPhysicsObj* physics_obj = this->physics_obj; -00529d11 -00529d19 if (physics_obj != 0) -00529d19 { -00529d1b int32_t var_14_1 = 0; -00529d1d CPhysicsObj::StopCompletely(physics_obj, edx_3); -00529d19 } -00529d19 -00529d3a PositionManager::StickTo(CPhysicsObj::get_position_manager(this->physics_obj), top_level_object_id, sought_object_radius, sought_object_height); -00529d44 return; -00529ced } -``` - -This is the OTHER stick-establishment path, distinct from `stick_to_object`: when a -`MoveToManager` move-to-object node completes and a "sticky" flag is set in -`movement_params` (the `head_ < 0` branch — a sign-bit check on a packed byte field, -likely a `CanStick`/`Sticky` movement-parameter bit), the mover: cleans up the move-to -state, stops movement completely (`CPhysicsObj::StopCompletely`), then sticks to -`top_level_object_id` (the object it was moving toward) using the CACHED -`sought_object_radius`/`sought_object_height` (computed earlier when the move-to node was -set up, not re-derived from `CPartArray` like `stick_to_object` does). This is the -"arrived at destination object, now hold position relative to it" transition — -e.g. finishing a move-to-object command that has a stick-on-arrival semantic. - ---- - -## Files referenced - -- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 352066–352644 - (PositionManager + StickyManager method bodies, contiguous block) -- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 276403–276426, 278204–278222, - 278344–278364, 280236–280241, 280559–280595, 280794–280866, 283079–283151, 283611–283757 - (CPhysicsObj seams + UpdatePositionInternal/UpdateObjectInternal per-tick chain) -- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 300563–300621 (MovementManager::unpack_movement) -- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 305238–305271 (CMotionInterp::MotionDone) -- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 307123–307161 (MoveToManager::BeginNextNode) -- `docs/research/named-retail/acclient_2013_pseudo_c.txt` lines 307871–307878 (MoveToManager::PerformMovement) -- `docs/research/named-retail/acclient.h` lines 30952–30958 (struct PositionManager) -- `docs/research/named-retail/acclient.h` lines 31518–31526 (struct StickyManager) -- `docs/research/named-retail/acclient.h` lines 31529–31537 (struct ConstraintManager, context) diff --git a/docs/research/2026-07-03-r5-managers/r5-targetmanager-decomp.md b/docs/research/2026-07-03-r5-managers/r5-targetmanager-decomp.md deleted file mode 100644 index 4c461cd0..00000000 --- a/docs/research/2026-07-03-r5-managers/r5-targetmanager-decomp.md +++ /dev/null @@ -1,1256 +0,0 @@ -# TargetManager (voyeur-subscription system) — retail decomp extract - -Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR build, PDB-named). -Structs: `docs/research/named-retail/acclient.h`. - -## Struct definitions (acclient.h) - -### `enum TargetStatus` (acclient.h:7264) - -```c -enum TargetStatus -{ - Undef_TargetStatus = 0x0, - Ok_TargetStatus = 0x1, - ExitWorld_TargetStatus = 0x2, - Teleported_TargetStatus = 0x3, - Contained_TargetStatus = 0x4, - Parented_TargetStatus = 0x5, - TimedOut_TargetStatus = 0x6, - FORCE_TargetStatus_32_BIT = 0x7FFFFFFF, -}; -``` - -### `struct TargetManager` (acclient.h:31024, id 3484) - -```c -struct __cppobj TargetManager -{ - CPhysicsObj *physobj; - TargetInfo *target_info; - LongNIHash *voyeur_table; - long double last_update_time; -}; -``` - -### `struct TargetInfo` (acclient.h:31591) - -```c -struct __cppobj TargetInfo -{ - unsigned int context_id; - unsigned int object_id; - float radius; - long double quantum; - Position target_position; - Position interpolated_position; - AC1Legacy::Vector3 interpolated_heading; - AC1Legacy::Vector3 velocity; - TargetStatus status; - long double last_update_time; -}; -``` - -### `struct TargettedVoyeurInfo` (acclient.h:52807, id 5801, 8-byte aligned) - -```c -struct __cppobj __declspec(align(8)) TargettedVoyeurInfo -{ - unsigned int object_id; - long double quantum; - float radius; - Position last_sent_position; -}; -``` - -### `struct LongNIHash` (acclient.h:31606, id 3483) - -```c -struct __cppobj LongNIHash -{ - LongNIHashData **buckets; - int table_size; -}; -``` - -### `struct LongNIHashIter` (acclient.h:57648) - -```c -struct __cppobj LongNIHashIter -{ - LongNIHash *hash; - /* + bucketNo, curDat, fDone per the operator++ body below */ -}; -``` - -NOTE: field layout for `LongNIHashIter` beyond `hash` isn't in the struct -dump captured, but `operator++`'s pseudo-C shows `bucketNo`, `curDat`, -`fDone` members (see below). - ---- - -## Functions - -### `TargetManager::TargetManager` (ctor) — 0051a370 - -```c -0051a370 void __thiscall TargetManager::TargetManager(class TargetManager* this, class CPhysicsObj* arg2) - -0051a370 { -0051a376 this->physobj = arg2; -0051a37a this->target_info = nullptr; -0051a37d this->voyeur_table = nullptr; -0051a380 this->last_update_time = 0f; -0051a383 *(uint32_t*)((char*)this->last_update_time)[4] = 0; -0051a370 } -``` - -NOTE: `*(uint32_t*)((char*)this->last_update_time)[4] = 0` is BN's mangled -way of zeroing the high dword of the 80-bit `long double last_update_time` -field (two-dword store split across a `long double`). Same pattern recurs -throughout this file for every `long double` field/parameter assignment — -not a bug, just how BN represents x87 80-bit extended stores split into a -32-bit low/high pair. Flagging once here; not re-flagging every occurrence -below. - -### `TargetInfo::TargetInfo` (ctor, bonus — used pervasively by callers) — 0051a420 - -```c -0051a420 void __fastcall TargetInfo::TargetInfo(class TargetInfo* this) - -0051a420 { -0051a429 this->context_id = 0; -0051a42b this->object_id = 0; -0051a42e this->radius = 0f; -0051a431 this->quantum = 0f; -0051a434 *(uint32_t*)((char*)this->quantum)[4] = 0; -0051a437 this->target_position.vtable = 0x796910; -0051a43e this->target_position.objcell_id = 0; -0051a423 this->target_position.frame.qw = 0x3f800000; -0051a423 this->target_position.frame.qx = 0f; -0051a423 this->target_position.frame.qy = 0f; -0051a423 this->target_position.frame.qz = 0f; -0051a423 this->target_position.frame.m_fOrigin.x = 0; -0051a423 this->target_position.frame.m_fOrigin.y = 0f; -0051a423 this->target_position.frame.m_fOrigin.z = 0f; -0051a459 Frame::cache(&this->target_position.frame); -0051a461 this->interpolated_position.vtable = 0x796910; -0051a468 this->interpolated_position.objcell_id = 0; -0051a45e this->interpolated_position.frame.qw = 0x3f800000; -0051a45e this->interpolated_position.frame.qx = 0f; -0051a45e this->interpolated_position.frame.qy = 0f; -0051a45e this->interpolated_position.frame.qz = 0f; -0051a45e this->interpolated_position.frame.m_fOrigin.x = 0; -0051a45e this->interpolated_position.frame.m_fOrigin.y = 0f; -0051a45e this->interpolated_position.frame.m_fOrigin.z = 0f; -0051a483 Frame::cache(&this->interpolated_position.frame); -0051a488 this->status = Undef_TargetStatus; -0051a48e this->last_update_time = 0f; -0051a494 *(uint32_t*)((char*)this->last_update_time)[4] = 0; -0051a420 } -``` - -Default-constructs both `Position` members to identity quaternion at -origin, `status = Undef_TargetStatus`, everything else zeroed. -`velocity`/`interpolated_heading` are NOT explicitly zeroed here (they're -POD `AC1Legacy::Vector3` — likely left uninitialized by this particular -ctor overload, or zeroed by a base/member ctor not captured in this -pseudo-C region). - -### `TargetManager::SetTargetQuantum` — 0051a4a0 - -```c -0051a4a0 void __thiscall TargetManager::SetTargetQuantum(class TargetManager* this, double arg2) - -0051a4a0 { -0051a4a3 class TargetInfo* target_info = this->target_info; -0051a4a3 -0051a4a8 if (target_info != 0) -0051a4a8 { -0051a4b2 target_info->quantum = arg2; -0051a4b5 *(uint32_t*)((char*)target_info->quantum)[4] = *(uint32_t*)((char*)arg2)[4]; -0051a4bf class CPhysicsObj* eax_1 = CPhysicsObj::GetObjectA(this->target_info->object_id); -0051a4bf -0051a4c9 if (eax_1 != 0) -0051a4c9 { -0051a4cb class TargetInfo* target_info_1 = this->target_info; -0051a4e6 CPhysicsObj::add_voyeur(eax_1, this->physobj->id, ((float)((long double)target_info_1->radius)), ((float)((long double)target_info_1->quantum))); -0051a4c9 } -0051a4a8 } -0051a4a0 } -``` - -Only acts if we currently HAVE a target (`target_info != 0`). Updates our -own `quantum` (the resend interval), then re-registers ourselves as a -voyeur of the target object with the new quantum — i.e. quantum changes -propagate by re-adding the voyeur subscription (not a separate "update -quantum" RPC on the remote side). - -### `TargetManager::SendVoyeurUpdate` — 0051a4f0 - -```c -0051a4f0 void __thiscall TargetManager::SendVoyeurUpdate(class TargetManager* this, class TargettedVoyeurInfo* arg2, class Position const* arg3, enum TargetStatus arg4) - -0051a4f0 { -0051a503 int32_t* esi = arg2; -0051a514 esi[6] = arg3->objcell_id; -0051a517 Frame::operator=(&esi[7], &arg3->frame); -0051a520 int32_t var_d0; -0051a520 TargetInfo::TargetInfo(&var_d0); -0051a525 class CPhysicsObj* physobj = this->physobj; -0051a527 int32_t eax_1 = esi[2]; -0051a52a int32_t edx = esi[4]; -0051a52d var_d0 = 0; -0051a538 uint32_t id = physobj->id; -0051a53f int32_t var_c0 = eax_1; -0051a543 int32_t var_bc = esi[3]; -0051a547 int32_t var_c8 = edx; -0051a556 uint32_t objcell_id = physobj->m_position.objcell_id; -0051a55a void var_b0; -0051a55a Frame::operator=(&var_b0, &physobj->m_position.frame); -0051a562 uint32_t objcell_id_1 = arg3->objcell_id; -0051a571 void var_68; -0051a571 Frame::operator=(&var_68, &arg3->frame); -0051a57d void __return; -0051a57d class AC1Legacy::Vector3* eax_3 = CPhysicsObj::get_velocity(physobj, &__return); -0051a586 float x = eax_3->x; -0051a597 float y = eax_3->y; -0051a5a2 float z = eax_3->z; -0051a5a9 enum TargetStatus var_10 = arg4; -0051a5b0 class CPhysicsObj* eax_5 = CPhysicsObj::GetObjectA(*(uint32_t*)esi); -0051a5b0 -0051a5be if (eax_5 != 0) -0051a5c7 CPhysicsObj::receive_target_update(eax_5, &var_d0); -0051a4f0 } -``` - -Mechanically: (1) stashes `arg3` (the caller-computed interpolated -position) into the voyeur record's `last_sent_position` field (`esi[6]` -= objcell_id, `esi[7..]` = frame — matches `TargettedVoyeurInfo::last_sent_position` -being the 3rd/4th field after `object_id`/`quantum`/`radius`); (2) builds -a fresh `TargetInfo` on the stack (`var_d0`) populated with: our own -`context_id`? (actually `esi[2]`/`esi[3]`/`esi[4]` = voyeur's own -`quantum`/`radius` fields per struct layout — field-name binding here is -approximate since BN shows raw offsets, not member names), our physobj's -`id` as `object_id`, our physobj's CURRENT position (not the interpolated -`arg3`!) as `target_position`, `arg3`'s position as `interpolated_position`, -our CURRENT velocity, and `arg4` as `status`; (3) looks up the voyeur's -`object_id` (`*(uint32_t*)esi`, i.e. `esi[0]`) via `CPhysicsObj::GetObjectA` -and if resolved, tail-calls `CPhysicsObj::receive_target_update` on THAT -object — delivering the update directly into the voyeur's own -`TargetManager::ReceiveUpdate`. - -NOTE (field-offset ambiguity): the exact mapping of `esi[2]`/`esi[3]`/`esi[4]` -into `var_c0`/`var_bc`/`var_c8` (populating fields of the constructed -`TargetInfo`) is inferred from struct order, not verified field-by-field — -BN shows raw stack offsets (`var_c0`, `var_bc`, `var_c8`) copied from raw -struct offsets (`esi[2]`, `esi[3]`, `esi[4]`) with no field names attached. -Cross-check against `TargettedVoyeurInfo` layout (`object_id`@0, `quantum`@4 -(long double, 12 bytes on this compiler?), `radius`@0x10, `last_sent_position`@0x14) -before porting exact field semantics. - -### `TargetManager::GetInterpolatedPosition` — 0051a5e0 - -```c -0051a5e0 void __thiscall TargetManager::GetInterpolatedPosition(class TargetManager* this, double arg2, class Position* arg3) - -0051a5e0 { -0051a5e4 class Position* esi = arg3; -0051a5eb class CPhysicsObj* physobj = this->physobj; -0051a5f6 esi->objcell_id = physobj->m_position.objcell_id; -0051a5fd Frame::operator=(&esi->frame, &physobj->m_position.frame); -0051a608 arg3 = ((float)((long double)arg2)); -0051a611 void __return; -0051a611 class AC1Legacy::Vector3* eax_2 = CPhysicsObj::get_velocity(this->physobj, &__return); -0051a634 esi->frame.m_fOrigin.x = ((float)((((long double)arg3) * ((long double)eax_2->x)) + ((long double)esi->frame.m_fOrigin.x))); -0051a63a esi->frame.m_fOrigin.y = ((float)((((long double)arg3) * ((long double)eax_2->y)) + ((long double)esi->frame.m_fOrigin.y))); -0051a644 esi->frame.m_fOrigin.z = ((float)(((long double)((float)(((long double)arg3) * ((long double)eax_2->z)))) + ((long double)esi->frame.m_fOrigin.z))); -0051a5e0 } -``` - -Straightforward dead-reckoning extrapolation: `out = physobj.position`, -then `out.origin += arg2 (a time-delta / quantum, as double) * velocity` -per-axis. This is our own physobj's position extrapolated forward by -`arg2` seconds using our own current velocity — used to predict where WE -will be by the time a voyeur update actually lands, so the voyeur update -carries a "where I'll be at quantum-from-now" position rather than -"where I am right now." - -### `TargetManager::CheckAndUpdateVoyeur` — 0051a650 - -```c -0051a650 void __thiscall TargetManager::CheckAndUpdateVoyeur(class TargetManager* this, class TargettedVoyeurInfo* arg2) - -0051a650 { -0051a65b int32_t var_48 = 0x796910; -0051a663 int32_t var_44 = 0; -0051a66b int32_t var_40 = 0x3f800000; -0051a673 int32_t var_3c = 0; -0051a67b int32_t var_38 = 0; -0051a683 int32_t var_34 = 0; -0051a68b int32_t var_c = 0; -0051a693 int32_t var_8 = 0; -0051a69b int32_t var_4 = 0; -0051a6a3 Frame::cache(&var_40); -0051a6b7 int32_t var_58 = *(uint32_t*)((char*)arg2->quantum)[4]; -0051a6bb TargetManager::GetInterpolatedPosition(this, arg2->quantum, &var_48); -0051a6c8 long double st0 = Position::distance(&var_48, &arg2->last_sent_position); -0051a6cd long double temp1 = ((long double)arg2->radius); -0051a6cd (st0 - temp1); -0051a6cd -0051a6d5 if ((*(uint8_t*)((char*)((((st0 < temp1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(st0, temp1))) ? 1 : 0) << 0xa) | ((((st0 == temp1) ? 1 : 0) << 0xe) | 0)))))[1] & 0x41) == 0) -0051a6e1 TargetManager::SendVoyeurUpdate(this, arg2, &var_48, Ok_TargetStatus); -0051a650 } -``` - -NOTE (x87 FCMP mush): the trailing `if (...)` block is a garbled x87 -`FCOMI`/`fnstsw`+`sahf`-style comparison of `st0` (the computed distance) -against `temp1` (`arg2->radius`), reconstructed by BN into a synthetic -FLAGS byte then masked with `0x41` (ZF|CF). The condition as literally -written computes `(distance < radius) OR unordered OR (distance == radius)` -into bits 8/0xa/0xe of a byte, then tests bits `0x41` = `0b0100_0001` -(bit0 + bit6) of the HIGH byte of that packed value — which does not -line up cleanly with the bits set (8/0xa/0xe are all ABOVE bit 7, so the -high byte holds bits 8,10,14 shifted down by 8 → i.e. bit0=orig bit8=`st0* voyeur_table = this->voyeur_table; -0051a6f6 -0051a6fb if (voyeur_table != 0) -0051a6fb { -0051a703 int32_t* var_10; -0051a703 LongNIHashIter::LongNIHashIter(&var_10, voyeur_table); -0051a708 int32_t i_1; -0051a708 int32_t i = i_1; -0051a708 -0051a70e if (i == 0) -0051a70e { -0051a711 void** j_1; -0051a711 void** j = j_1; -0051a716 int32_t var_c; -0051a716 int32_t edi_1 = var_c; -0051a716 -0051a762 do -0051a762 { -0051a722 class TargettedVoyeurInfo* eax; -0051a722 -0051a722 if (j == 0) -0051a729 eax = nullptr; -0051a722 else -0051a724 eax = j[1]; -0051a724 -0051a72d if (i == 0) -0051a72d { -0051a733 int32_t* ecx_1; -0051a733 -0051a733 for (j = *(uint32_t*)j; j == 0; j = *(uint32_t*)(*(uint32_t*)ecx_1 + (edi_1 << 2))) -0051a733 { -0051a735 ecx_1 = var_10; -0051a73c edi_1 += 1; -0051a73c -0051a73f if (edi_1 >= ecx_1[1]) -0051a73f { -0051a748 i = 1; -0051a748 break; -0051a73f } -0051a733 } -0051a72d } -0051a72d -0051a75b TargetManager::SendVoyeurUpdate(this, eax, &this->physobj->m_position, arg2); -0051a762 } while (i == 0); -0051a70e } -0051a6fb } -0051a6f0 } -``` - -Iterates every entry in `voyeur_table` (all subscribers watching US) and -calls `SendVoyeurUpdate` for each, using our CURRENT (non-interpolated) -position and the caller-supplied `arg2` status (e.g. `ExitWorld_TargetStatus`, -`Teleported_TargetStatus`). This is the "broadcast a status event to -everyone watching me" path, distinct from the per-tick distance-gated -`CheckAndUpdateVoyeur` path. - -NOTE (BN mislabel): the ctor call `LongNIHashIter::LongNIHashIter(&var_10, voyeur_table)` -is BN reusing the `LongNIHashIter` template -instantiation's mangled name for what is actually iterating -`this->voyeur_table`, a `LongNIHash*`. The real -type is `LongNIHashIter` (confirmed: the struct -exists at acclient.h:57648 and `TargetManager::HandleTargetting` below -uses the identical pattern with the same mislabeled ctor name). Treat -every `LongNIHashIter` symbol in this file's -TargetManager functions as actually `LongNIHashIter`. - -### `TargetManager::ClearTarget` — 0051a7e0 - -```c -0051a7e0 void __fastcall TargetManager::ClearTarget(class TargetManager* this) - -0051a7e0 { -0051a7e3 class TargetInfo* target_info = this->target_info; -0051a7e3 -0051a7e8 if (target_info != 0) -0051a7e8 { -0051a7ee class CPhysicsObj* eax_1 = CPhysicsObj::GetObjectA(target_info->object_id); -0051a7ee -0051a7f8 if (eax_1 != 0) -0051a802 CPhysicsObj::remove_voyeur(eax_1, this->physobj->id); -0051a802 -0051a807 class TargetInfo* target_info_1 = this->target_info; -0051a807 -0051a80c if (target_info_1 != 0) -0051a80c { -0051a814 target_info_1->interpolated_position.vtable = 0x79285c; -0051a817 target_info_1->target_position.vtable = 0x79285c; -0051a81a operator delete(target_info_1); -0051a80c } -0051a80c -0051a822 this->target_info = nullptr; -0051a7e8 } -0051a7e0 } -``` - -If we currently have a target: resolve the target object, if resolved -tell IT to `remove_voyeur(our_id)` (unsubscribe us from their voyeur -table), then free our local `TargetInfo` and null the pointer. Mirror -image of `SetTarget`'s subscribe path. - -### `TargetManager::AddVoyeur` — 0051a830 - -```c -0051a830 void __thiscall TargetManager::AddVoyeur(class TargetManager* this, uint32_t arg2, float arg3, double arg4) - -0051a830 { -0051a838 class LongNIHash* voyeur_table = this->voyeur_table; -0051a838 -0051a840 if (voyeur_table == 0) -0051a840 { -0051a88f void* eax_5 = operator new(8); -0051a899 class LongNIHash* eax_6; -0051a899 -0051a899 if (eax_5 == 0) -0051a8a6 eax_6 = nullptr; -0051a899 else -0051a89f eax_6 = LongNIHash::LongNIHash(eax_5, 4); -0051a89f -0051a8a8 this->voyeur_table = eax_6; -0051a840 } -0051a840 else -0051a840 { -0051a850 void** edx_3 = voyeur_table->buckets[(COMBINE(0, ((arg2 >> 0x10) ^ arg2)) % voyeur_table->table_size)]; -0051a850 -0051a855 if (edx_3 != 0) -0051a855 { -0051a85a while (edx_3[2] != arg2) -0051a85a { -0051a860 edx_3 = *(uint32_t*)edx_3; -0051a860 -0051a864 if (edx_3 == 0) -0051a864 goto label_51a8b3; -0051a85a } -0051a85a -0051a86b void* edx_4 = edx_3[1]; -0051a86b -0051a870 if (edx_4 != 0) -0051a870 { -0051a87b *(uint32_t*)((char*)edx_4 + 0x10) = arg3; -0051a883 *(uint32_t*)((char*)edx_4 + 8) = arg4; -0051a886 *(uint32_t*)((char*)edx_4 + 0xc) = *(uint32_t*)((char*)arg4)[4]; -0051a88a return; -0051a870 } -0051a855 } -0051a840 } -0051a840 -0051a8b3 label_51a8b3: -0051a8b3 void* esi = operator new(0x60); -0051a8b3 -0051a8ba if (esi == 0) -0051a8f3 esi = nullptr; -0051a8ba else -0051a8ba { -0051a8bc *(uint32_t*)esi = 0; -0051a8be *(uint32_t*)((char*)esi + 8) = 0; -0051a8c1 *(uint32_t*)((char*)esi + 0xc) = 0; -0051a8c4 *(uint32_t*)((char*)esi + 0x10) = 0; -0051a8ca *(uint32_t*)((char*)esi + 0x14) = 0x796910; -0051a8d1 *(uint32_t*)((char*)esi + 0x18) = 0; -0051a8d4 *(uint32_t*)((char*)esi + 0x1c) = 0x3f800000; -0051a8da *(uint32_t*)((char*)esi + 0x20) = 0; -0051a8dd *(uint32_t*)((char*)esi + 0x24) = 0; -0051a8e0 *(uint32_t*)((char*)esi + 0x28) = 0; -0051a8e3 *(uint32_t*)((char*)esi + 0x50) = 0; -0051a8e6 *(uint32_t*)((char*)esi + 0x54) = 0; -0051a8e9 *(uint32_t*)((char*)esi + 0x58) = 0; -0051a8ec Frame::cache(((char*)esi + 0x1c)); -0051a8ba } -0051a8ba -0051a902 *(uint32_t*)esi = arg2; -0051a904 *(uint32_t*)((char*)esi + 0x10) = arg3; -0051a907 *(uint32_t*)((char*)esi + 8) = arg4; -0051a90a *(uint32_t*)((char*)esi + 0xc) = *(uint32_t*)((char*)arg4)[4]; -0051a911 LongNIHash::add(this->voyeur_table, esi, arg2); -0051a922 TargetManager::SendVoyeurUpdate(this, esi, &this->physobj->m_position, Ok_TargetStatus); -0051a830 } -``` - -`arg2` = voyeur's object id, `arg3` = radius (float), `arg4` = quantum -(double). Lazily creates `voyeur_table` (4 buckets) on first use. -Hand-rolled hash-bucket lookup: if an existing `TargettedVoyeurInfo` for -`arg2` is found, just updates its `radius`/`quantum` in place and -returns early (no immediate send). Otherwise allocates a fresh -`TargettedVoyeurInfo` (0x60 bytes), zero-inits it (including -`last_sent_position` via `Frame::cache`), sets `object_id`/`radius`/`quantum`, -inserts into the hash, then immediately calls `SendVoyeurUpdate` with our -CURRENT position and `Ok_TargetStatus` — i.e. brand-new voyeurs get an -immediate snapshot rather than waiting for the next `HandleTargetting` tick. - -NOTE (BN mislabel): `LongNIHash::LongNIHash` -and `LongNIHash::add` are BN reusing the `DetectionInfo` -hash template instantiation's mangled symbol for what is actually -`LongNIHash`'s ctor/add — same class of mislabel as -above (`voyeur_table` is declared `LongNIHash*`). - -### `TargetManager::ReceiveUpdate` — 0051a930 - -```c -0051a930 void __thiscall TargetManager::ReceiveUpdate(class TargetManager* this, class TargetInfo const* arg2) - -0051a930 { -0051a936 class TargetInfo* target_info = this->target_info; -0051a936 -0051a93c if (target_info != 0) -0051a93c { -0051a946 uint32_t object_id = arg2->object_id; -0051a946 -0051a94c if (object_id == target_info->object_id) -0051a94c { -0051a952 target_info->object_id = object_id; -0051a955 this->target_info->radius = arg2->radius; -0051a961 class TargetInfo* target_info_2 = this->target_info; -0051a964 target_info_2->quantum = arg2->quantum; -0051a96a *(uint32_t*)((char*)target_info_2->quantum)[4] = *(uint32_t*)((char*)arg2->quantum)[4]; -0051a973 class Position* eax_3 = &this->target_info->target_position; -0051a97d eax_3->objcell_id = arg2->target_position.objcell_id; -0051a980 Frame::operator=(&eax_3->frame, &arg2->target_position.frame); -0051a98b class Position* eax_5 = &this->target_info->interpolated_position; -0051a995 eax_5->objcell_id = arg2->interpolated_position.objcell_id; -0051a998 Frame::operator=(&eax_5->frame, &arg2->interpolated_position.frame); -0051a9a0 class AC1Legacy::Vector3* eax_7 = &this->target_info->velocity; -0051a9ad eax_7->x = arg2->velocity.x; -0051a9b2 eax_7->y = arg2->velocity.y; -0051a9b8 eax_7->z = arg2->velocity.z; -0051a9bb this->target_info->status = arg2->status; -0051a9cf class TargetInfo* target_info_3 = this->target_info; -0051a9d8 target_info_3->last_update_time = (*(uint32_t*)Timer::cur_time); -0051a9de *(uint32_t*)((char*)target_info_3->last_update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4); -0051a9f5 int32_t __return; -0051a9f5 class AC1Legacy::Vector3* eax_10 = Position::get_offset(&this->physobj->m_position, &__return, &this->target_info->interpolated_position); -0051a9ff class AC1Legacy::Vector3* ecx_13 = &this->target_info->interpolated_heading; -0051aa05 ecx_13->x = eax_10->x; -0051aa0a ecx_13->y = eax_10->y; -0051aa10 ecx_13->z = eax_10->z; -0051aa10 -0051aa23 if (AC1Legacy::Vector3::normalize_check_small(&this->target_info->interpolated_heading) != 0) -0051aa23 { -0051aa25 class TargetInfo* target_info_1 = this->target_info; -0051aa28 __return = 0; -0051aa34 target_info_1->interpolated_heading.x = __return; -0051aa34 target_info_1->interpolated_heading.y = 0f; -0051aa34 target_info_1->interpolated_heading.z = 1f; -0051aa23 } -0051aa23 -0051aa66 void var_e4; -0051aa66 TargetInfo::TargetInfo(&var_e4, this->target_info); -0051aa6d CPhysicsObj::HandleUpdateTarget(this->physobj, var_e4); -0051aa6d -0051aa79 if (arg2->status == ExitWorld_TargetStatus) -0051aa7d TargetManager::ClearTarget(this); -0051a94c } -0051a93c } -0051a930 } -``` - -This is the "I am a voyeur and just got an update FROM the thing I'm -watching" handler — called via `CPhysicsObj::receive_target_update` from -`SendVoyeurUpdate`'s tail-call on the SENDER side. Only processes if we -still have an active `target_info` AND the incoming `object_id` matches -what we're tracking (stale/mismatched updates dropped silently). Copies -`radius`/`quantum`/`target_position`/`interpolated_position`/`velocity`/`status` -wholesale from the wire payload, stamps `last_update_time = Timer::cur_time`, -then recomputes `interpolated_heading` as the normalized offset from OUR -position to the target's `interpolated_position` (falls back to -`(0,0,1)` — i.e. forward/+Z — if the offset vector is too small to -normalize, via `normalize_check_small`). Finally fans the whole -`TargetInfo` snapshot out to `CPhysicsObj::HandleUpdateTarget` (which -forwards to `MovementManager::HandleUpdateTarget` + -`PositionManager::HandleUpdateTarget` — see below), and if the target's -status says `ExitWorld_TargetStatus`, immediately clears our own -subscription (target left the world → give up watching it). - -### `TargetManager::HandleTargetting` — 0051aa90 - -```c -0051aa90 void __fastcall TargetManager::HandleTargetting(class TargetManager* this) - -0051aa90 { -0051aa9d long double x87_r7 = (((long double)PhysicsTimer::curr_time) - ((long double)this->last_update_time)); -0051aaa0 long double temp0 = ((long double)0.5); -0051aaa0 (x87_r7 - temp0); -0051aaa6 int32_t eax; -0051aaa6 eax = ((((x87_r7 < temp0) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7, temp0))) ? 1 : 0) << 0xa) | ((((x87_r7 == temp0) ? 1 : 0) << 0xe) | 0)))); -0051aaa8 bool p = /* bool p = unimplemented {test ah, 0x5} */; -0051aaa8 -0051aaab if (p) -0051aaab { -0051aab1 class TargetInfo* target_info = this->target_info; -0051aab1 -0051aac0 if ((target_info != 0 && target_info->status == Undef_TargetStatus)) -0051aac0 { -0051aac8 long double x87_r7_2 = (((long double)10.0) + ((long double)target_info->last_update_time)); -0051aace long double temp1_1 = ((long double)Timer::cur_time); -0051aace (x87_r7_2 - temp1_1); -0051aad4 enum TargetStatus eax_1; -0051aad4 eax_1 = ((((x87_r7_2 < temp1_1) ? 1 : 0) << 8) | ((((0) ? 1 : 0) << 9) | (((((FCMP_UO(x87_r7_2, temp1_1))) ? 1 : 0) << 0xa) | ((((x87_r7_2 == temp1_1) ? 1 : 0) << 0xe) | 0)))); -0051aad6 bool p_1 = /* bool p_1 = unimplemented {test ah, 0x5} */; -0051aad6 -0051aad9 if (!(p_1)) -0051aad9 { -0051aadb target_info->status = TimedOut_TargetStatus; -0051aaf1 void var_e8; -0051aaf1 TargetInfo::TargetInfo(&var_e8, this->target_info); -0051aaf8 CPhysicsObj::HandleUpdateTarget(this->physobj, var_e8); -0051aad9 } -0051aac0 } -0051aac0 -0051aafd class LongNIHash* voyeur_table = this->voyeur_table; -0051aafd -0051ab02 if (voyeur_table != 0) -0051ab02 { -0051ab09 void var_10; -0051ab09 int32_t edx_1 = LongNIHashIter::LongNIHashIter(&var_10, voyeur_table); -0051ab14 int32_t i; -0051ab14 -0051ab14 while (i == 0) -0051ab14 { -0051ab1c void* var_8; -0051ab1c class TargettedVoyeurInfo* esi_1; -0051ab1c -0051ab1c if (var_8 == 0) -0051ab23 esi_1 = nullptr; -0051ab1c else -0051ab1e esi_1 = *(uint32_t*)((char*)var_8 + 4); -0051ab1e -0051ab25 int32_t var_1c_2 = 0; -0051ab2b LongNIHashIter::operator++(&var_10, edx_1); -0051ab33 edx_1 = TargetManager::CheckAndUpdateVoyeur(this, esi_1); -0051ab14 } -0051ab02 } -0051ab02 -0051ab4c this->last_update_time = (*(uint32_t*)PhysicsTimer::curr_time); -0051ab4f *(uint32_t*)((char*)this->last_update_time)[4] = *(int32_t*)((char*)PhysicsTimer::curr_time + 4); -0051aaab } -0051aa90 } -``` - -**This is the per-tick driver entry point** — called unconditionally -once per physics tick from `CPhysicsObj::UpdateObjectInternal` (see -Callers below) whenever `this->target_manager != 0`. There is **no -`TargetManager::UseTime` function** — `HandleTargetting` IS the tick -driver, gated by its own internal quantum check rather than an outer -`UseTime` wrapper (unlike `MovementManager::UseTime` / `PositionManager::UseTime` -/ `StickyManager::UseTime` which sit as siblings in the same call chain). - -NOTE (x87 FCMP mush, two occurrences): both `p` and `p_1` are -`unimplemented {test ah, 0x5}` — BN couldn't lower the FLAGS test after -the synthetic FCOMI byte pack. Bit pattern is IDENTICAL shape to the one -decoded in `CheckAndUpdateVoyeur` above: `test ah, 0x5` tests bits -`0b101` = bit0+bit2 of the high byte, i.e. (in the same bit-index scheme -as before) orig-bit-8 (`<`) and orig-bit-0xa (unordered). So: -`p = (x87_r7 < temp0) OR unordered(x87_r7, temp0)`, i.e. -**`p = !(PhysicsTimer::curr_time - last_update_time >= 0.5)`** ... wait, -sign check: `x87_r7 = curr_time - last_update_time`, compared `< 0.5`. -`p` true means `elapsed < 0.5` (or unordered) → guard body runs when `p` -is true. Net: **the whole per-tick body (voyeur re-check + timeout-status -loop) only runs when `elapsed_since_last_update < 0.5s`... ** that reads -backwards for a "tick every N seconds" gate (normally you'd expect the -body to run when elapsed >= threshold, then reset the timer). **FLAG FOR -LEAD**: re-derive this comparison by hand against x86 FCOMI/SAHF -semantics before porting — my bit-algebra above may have the sense -inverted (in `CheckAndUpdateVoyeur` the same `0x41`-style mask decoded -to `<=`, but here it's a bare `test ah,0x5` i.e. `0x05` mask = bit0+bit2, -different mask than `CheckAndUpdateVoyeur`'s `0x41` = bit0+bit6 — these -are NOT the same test and I do not have confident hand-verified parity -between them). The inner 10-second timeout check (`p_1`, same `0x5` mask -pattern on `(10.0 + last_update_time) vs Timer::cur_time`, guarded by -`!p_1`) reads as "target status still Undef 10s after last update → -promote to TimedOut" which is directionally sane (timeout-if-stale), so -by symmetry `p` at the top is very likely the equivalent "run this tick's -logic once quantum-time has elapsed" gate — but get x86 semantics -verified rather than trusting this narrative. - -NOTE (BN mislabel): same `LongNIHashIter::LongNIHashIter` -mislabel as in `NotifyVoyeurOfEvent` — actually `LongNIHashIter`. -The subsequent `LongNIHashIter::operator++` call -(correctly named this time) confirms the iterator's real element type. - -Per-voyeur, the tick body calls `CheckAndUpdateVoyeur` (the -distance-threshold gated sender) for every entry, then stamps -`this->last_update_time = PhysicsTimer::curr_time` at the end (used for -next tick's `p` gate). - -### `TargetManager::SetTarget` — 0051ac30 - -```c -0051ac30 void __thiscall TargetManager::SetTarget(class TargetManager* this, uint32_t arg2, uint32_t arg3, float arg4, double arg5) - -0051ac30 { -0051ac39 class TargetInfo* target_info = this->target_info; -0051ac39 -0051ac3f if (target_info != 0) -0051ac3f { -0051ac45 class CPhysicsObj* eax_1 = CPhysicsObj::GetObjectA(target_info->object_id); -0051ac45 -0051ac4f if (eax_1 != 0) -0051ac59 CPhysicsObj::remove_voyeur(eax_1, this->physobj->id); -0051ac59 -0051ac5e class TargetInfo* target_info_1 = this->target_info; -0051ac5e -0051ac63 if (target_info_1 != 0) -0051ac63 { -0051ac6b target_info_1->interpolated_position.vtable = 0x79285c; -0051ac6e target_info_1->target_position.vtable = 0x79285c; -0051ac71 operator delete(target_info_1); -0051ac63 } -0051ac63 -0051ac79 this->target_info = nullptr; -0051ac3f } -0051ac3f -0051ac89 if (arg3 == 0) -0051ac89 { -0051ad3d uint32_t var_d0; -0051ad3d TargetInfo::TargetInfo(&var_d0); -0051ad59 var_d0 = arg2; -0051ad60 int32_t var_cc_1 = 0; -0051ad6b int32_t var_10_1 = 6; -0051ad76 void var_1a8; -0051ad76 TargetInfo::TargetInfo(&var_1a8, &var_d0); -0051ad7d CPhysicsObj::HandleUpdateTarget(this->physobj, var_1a8); -0051ad7d return; -0051ac89 } -0051ac89 -0051ac94 void* eax_2 = operator new(0xd0); -0051ac9e uint32_t* eax_3; -0051ac9e -0051ac9e if (eax_2 == 0) -0051aca9 eax_3 = nullptr; -0051ac9e else -0051aca2 eax_3 = TargetInfo::TargetInfo(eax_2); -0051aca2 -0051acb2 this->target_info = eax_3; -0051acb5 *(uint32_t*)eax_3 = arg2; -0051acb7 this->target_info->object_id = arg3; -0051acc4 this->target_info->radius = arg4; -0051acca class TargetInfo* target_info_3 = this->target_info; -0051acdb target_info_3->quantum = arg5; -0051acde *(uint32_t*)((char*)target_info_3->quantum)[4] = *(uint32_t*)((char*)arg5)[4]; -0051ace6 class TargetInfo* target_info_4 = this->target_info; -0051acef target_info_4->last_update_time = (*(uint32_t*)Timer::cur_time); -0051acf5 *(uint32_t*)((char*)target_info_4->last_update_time)[4] = *(int32_t*)((char*)Timer::cur_time + 4); -0051ad02 class CPhysicsObj* eax_8 = CPhysicsObj::GetObjectA(this->target_info->object_id); -0051ad02 -0051ad0c if (eax_8 != 0) -0051ad0c { -0051ad0e class TargetInfo* target_info_2 = this->target_info; -0051ad29 CPhysicsObj::add_voyeur(eax_8, this->physobj->id, ((float)((long double)target_info_2->radius)), ((float)((long double)target_info_2->quantum))); -0051ad0c } -0051ac30 } -``` - -`arg2` = context_id, `arg3` = new target object_id, `arg4` = radius, -`arg5` = quantum. First unconditionally tears down any EXISTING target -(unsubscribe voyeur from old target, free old `TargetInfo`) — same body -as `ClearTarget` inlined. Then: **if `arg3 == 0`** (clearing to "no -target"), synthesizes a `TargetInfo` with `context_id = arg2`, -`status = 6` (`TimedOut_TargetStatus`, NOTE: value 6 is written directly -as a raw int at `var_10_1 = 6` rather than the named enum constant — same -value as `TimedOut_TargetStatus`, so this "clear" path reports itself as -a timeout-status update to `HandleUpdateTarget`) and returns early -WITHOUT allocating `this->target_info` (stays null). Otherwise -(`arg3 != 0`): allocates a fresh heap `TargetInfo` (0xd0 bytes), -populates `context_id`/`object_id`/`radius`/`quantum`/`last_update_time -= Timer::cur_time`, resolves the target object, and if found calls -`add_voyeur` on it to subscribe. Note this does NOT immediately fetch/ -apply the target's current position — that arrives asynchronously via -the target's own `SendVoyeurUpdate` → `receive_target_update` → -`ReceiveUpdate` round-trip. - -### `TargetManager::RemoveVoyeur` — 0051ad90 - -```c -0051ad90 int32_t __thiscall TargetManager::RemoveVoyeur(class TargetManager* this, uint32_t arg2) - -0051ad90 { -0051ad90 class LongNIHash* voyeur_table = this->voyeur_table; -0051ad90 -0051ad95 if (voyeur_table != 0) -0051ad95 { -0051ad9c class DetectionCylsphere* eax_2 = LongNIHash::remove(voyeur_table, arg2); -0051ad9c -0051ada3 if (eax_2 != 0) -0051ada3 { -0051ada6 eax_2->detection_type = 0x79285c; -0051adad operator delete(eax_2); -0051adba return 1; -0051ada3 } -0051ad95 } -0051ad95 -0051adbf return 0; -0051ad90 } -``` - -Removes voyeur `arg2` from `voyeur_table` and frees the -`TargettedVoyeurInfo` record; returns 1 if one was found/removed, 0 -otherwise. - -NOTE (BN mislabel): `LongNIHash::remove` — same -mislabel pattern, actually operating on `LongNIHash` -(confirmed by the `this` parameter type `LongNIHash*`). -Also `eax_2->detection_type = 0x79285c` is writing a vtable-pointer-looking -constant into a field named by BN as `detection_type` — this is almost -certainly the vtable-poison pattern used elsewhere in this file right -before `operator delete` (see `ClearTarget`'s `->vtable = 0x79285c` and -`SetTarget`'s identical pattern) applied to what should be -`TargettedVoyeurInfo`'s embedded `Position last_sent_position`'s vtable -slot, mislabeled as `detection_type` because BN is treating the freed -object as a `DetectionCylsphere*` per the mislabel above. - ---- - -## `TargetManager::~TargetManager` (dtor, bonus) — 0051abd0 - -```c -0051abd0 void __fastcall TargetManager::~TargetManager(class TargetManager* this) - -0051abd0 { -0051abd3 class TargetInfo* target_info = this->target_info; -0051abd3 -0051abd8 if (target_info != 0) -0051abd8 { -0051abe0 target_info->interpolated_position.vtable = 0x79285c; -0051abe3 target_info->target_position.vtable = 0x79285c; -0051abe6 operator delete(target_info); -0051abee this->target_info = nullptr; -0051abd8 } -0051abd8 -0051abf5 class LongNIHash* voyeur_table = this->voyeur_table; -0051abf5 -0051abfa if (voyeur_table != 0) -0051abfa { -0051abfc LongNIHash::destroy_contents(voyeur_table); -0051ac01 class LongNIHash* voyeur_table_1 = this->voyeur_table; -0051ac01 -0051ac06 if (voyeur_table_1 != 0) -0051ac06 { -0051ac0a LongNIHash::flush(voyeur_table_1); -0051ac12 operator delete[](voyeur_table_1->buckets); -0051ac18 voyeur_table_1->buckets = 0; -0051ac1e operator delete(voyeur_table_1); -0051ac06 } -0051abfa } -0051abd0 } -``` - -Frees `target_info` (if any), then destroys+flushes+frees the entire -`voyeur_table` hash (all subscriber records). Does NOT notify anyone -(no `NotifyVoyeurOfEvent` call) — pure teardown, unlike `exit_world` -which explicitly notifies before the `TargetManager` itself is destroyed -elsewhere (line 281877, `CPhysicsObj`'s own dtor/cleanup path). - -NOTE (BN mislabel): `LongNIHash::flush` at 0051ac0a -— actually `LongNIHash::flush` (`voyeur_table_1` is -typed `LongNIHash*`). - ---- - -## `CPhysicsObj`-level seams (no dedicated `MakeTargetManager`) - -Grep for `MakeTargetManager` returned nothing — **retail does not have a -factory function by that name.** Instead, `TargetManager` construction is -**lazily inlined** at the two call sites that first need it: -`CPhysicsObj::set_target` (becoming a voyeur OF something) and -`CPhysicsObj::add_voyeur` (gaining a voyeur watching us). Both follow the -same `if (this->target_manager == 0) { alloc + placement-new -TargetManager(this) }` pattern. - -### `CPhysicsObj::set_target` — 0050ed30 - -```c -0050ed30 void __thiscall CPhysicsObj::set_target(class CPhysicsObj* this, uint32_t arg2, uint32_t arg3, float arg4, double arg5) - -0050ed30 { -0050ed3b if (this->target_manager == 0) -0050ed3b { -0050ed3f void* eax_1 = operator new(0x18); -0050ed49 class TargetManager* eax_2; -0050ed49 -0050ed49 if (eax_1 == 0) -0050ed55 eax_2 = nullptr; -0050ed49 else -0050ed4e eax_2 = TargetManager::TargetManager(eax_1, this); -0050ed4e -0050ed57 this->target_manager = eax_2; -0050ed3b } -0050ed3b -0050ed69 int32_t var_8_2 = *(uint32_t*)((char*)arg5)[4]; -0050ed7c TargetManager::SetTarget(this->target_manager, arg2, arg3, arg4, arg5); -0050ed30 } -``` - -Lazy-construct (0x18 = 24 bytes, matches `TargetManager`'s 4 fields: -ptr+ptr+ptr+long double(?) — actually `physobj`+`target_info`+`voyeur_table` -= 12 bytes + `last_update_time` long double = likely 8-12 bytes padded to -24), then forward to `TargetManager::SetTarget`. - -### `CPhysicsObj::clear_target` — 0050ed90 - -```c -0050ed90 void __fastcall CPhysicsObj::clear_target(class CPhysicsObj* this) - -0050ed90 { -0050ed90 class TargetManager* target_manager = this->target_manager; -0050ed90 -0050ed98 if (target_manager == 0) -0050ed9f return; -0050ed9f -0050ed9a /* tailcall */ -0050ed9a return TargetManager::ClearTarget(target_manager); -0050ed90 } -``` - -No-op if no `target_manager` exists yet (does NOT lazily construct just -to clear). Tail-calls `TargetManager::ClearTarget` otherwise. - -### `CPhysicsObj::set_target_quantum` — 0050eda0 - -```c -0050eda0 void __thiscall CPhysicsObj::set_target_quantum(class CPhysicsObj* this, double arg2) - -0050eda0 { -0050eda0 class TargetManager* target_manager = this->target_manager; -0050eda0 -0050eda8 if (target_manager != 0) -0050eda8 { -0050edb2 int32_t var_4_1 = *(uint32_t*)((char*)arg2)[4]; -0050edb4 TargetManager::SetTargetQuantum(target_manager, arg2); -0050eda8 } -0050eda0 } -``` - -### `CPhysicsObj::get_target_quantum` — 0050edc0 - -```c -0050edc0 class TargetManager* __fastcall CPhysicsObj::get_target_quantum(class CPhysicsObj const* this) - -0050edc0 { -0050edc0 class TargetManager* result = this->target_manager; -0050edc0 -0050edc8 if (result != 0) -0050edc8 { -0050edca result = result->target_info; -0050edca -0050edcf if (result != 0) -0050edd1 result->last_update_time; -0050edc8 } -0050edc8 -0050eddb return result; -0050edc0 } -``` - -NOTE: despite the name `get_target_quantum`, the return-type/body as -decompiled reads `target_info->last_update_time` in an expression-statement -with the result DISCARDED, then returns `result` which by that point -holds `this->target_manager->target_info` (a pointer!), not a `quantum` -double. This strongly looks like **BN mis-decompiling a `long double` -return through EAX/x87** — the real function almost certainly returns -`target_info->quantum` (a `long double`, returned via `st0`, which BN's -pseudo-C sometimes fails to model and instead shows the last pointer left -in a GPR). The caller at `MoveToManager::UseTime` (0052a07e, -`fsubr st0, [esp+8]` immediately after the call) treats the return value -as an x87 float on the FPU stack, confirming this reads a `long double` -via `st0`, NOT a pointer. **FLAG FOR LEAD: this function's decompiled -body/return type is unreliable; trust the CALLER's x87 usage (treats -return as `long double quantum`) over the shown C.** - -### `CPhysicsObj::receive_target_update` — 0050ede0 - -```c -0050ede0 void __thiscall CPhysicsObj::receive_target_update(class CPhysicsObj* this, class TargetInfo const* arg2) - -0050ede0 { -0050ede0 class TargetManager* target_manager = this->target_manager; -0050ede0 -0050ede8 if (target_manager == 0) -0050edef return; -0050edef -0050edea /* tailcall */ -0050edea return TargetManager::ReceiveUpdate(target_manager, arg2); -0050ede0 } -``` - -No-op if no manager. This is the entry point `SendVoyeurUpdate` tail-calls -into on the recipient object. - -### `CPhysicsObj::add_voyeur` — 0050ee00 - -```c -0050ee00 void __thiscall CPhysicsObj::add_voyeur(class CPhysicsObj* this, uint32_t arg2, float arg3, float arg4) - -0050ee00 { -0050ee00 int32_t __saved_esi_1; -0050ee00 int32_t __saved_esi = __saved_esi_1; -0050ee00 -0050ee0b if (this->target_manager == 0) -0050ee0b { -0050ee0d int64_t var_c; -0050ee0d *(uint32_t*)((char*)var_c)[4] = 0x18; -0050ee0f int32_t eax_1 = operator new(); -0050ee19 class TargetManager* eax_2; -0050ee19 -0050ee19 if (eax_1 == 0) -0050ee25 eax_2 = nullptr; -0050ee19 else -0050ee19 { -0050ee1b *(uint32_t*)((char*)var_c)[4] = this; -0050ee1e eax_2 = TargetManager::TargetManager(eax_1); -0050ee19 } -0050ee19 -0050ee27 this->target_manager = eax_2; -0050ee0b } -0050ee0b -0050ee47 TargetManager::AddVoyeur(this->target_manager, arg2, arg3, ((double)((long double)arg4))); -0050ee00 } -``` - -Same lazy-construct pattern as `set_target`, then forward to -`TargetManager::AddVoyeur`. `arg2` = voyeur object id, `arg3` = radius, -`arg4` = quantum (float, widened to double for the call). - -### `CPhysicsObj::remove_voyeur` — 0050ee50 - -```c -0050ee50 int32_t __thiscall CPhysicsObj::remove_voyeur(class CPhysicsObj* this, uint32_t arg2) - -0050ee50 { -0050ee50 class TargetManager* target_manager = this->target_manager; -0050ee50 -0050ee58 if (target_manager == 0) -0050ee61 return 0; -0050ee61 -0050ee5a /* tailcall */ -0050ee5a return TargetManager::RemoveVoyeur(target_manager, arg2); -0050ee50 } -``` - ---- - -## Callers (per-tick driver + `SetTarget` producers) - -### Per-tick driver: `CPhysicsObj::UpdateObjectInternal` — 005156b0 (excerpt) - -```c -005159a2 if (part_array_1 != 0) -005159a4 CPartArray::HandleMovement(part_array_1); -005159a4 -005159a9 class PositionManager* position_manager = this->position_manager; -005159a9 -005159b1 if (position_manager != 0) -005159b3 PositionManager::UseTime(position_manager); -``` - -and immediately preceding (full excerpt, lines 283730-283753): - -```c -00515970 class DetectionManager* detection_manager = this->detection_manager; -00515970 -00515978 if (detection_manager != 0) -0051597a DetectionManager::CheckDetection(detection_manager); -0051597a -0051597f class TargetManager* target_manager = this->target_manager; -0051597f -00515987 if (target_manager != 0) -00515989 TargetManager::HandleTargetting(target_manager); -00515989 -0051598e class MovementManager* movement_manager = this->movement_manager; -0051598e -00515996 if (movement_manager != 0) -00515998 MovementManager::UseTime(movement_manager); -00515998 -0051599d class CPartArray* part_array_1 = this->part_array; -0051599d -005159a2 if (part_array_1 != 0) -005159a4 CPartArray::HandleMovement(part_array_1); -005159a4 -005159a9 class PositionManager* position_manager = this->position_manager; -005159a9 -005159b1 if (position_manager != 0) -005159b3 PositionManager::UseTime(position_manager); -``` - -**Confirms the per-tick fan-out order inside `CPhysicsObj::UpdateObjectInternal` -(the retail per-object physics-tick function, called every physics tick -per live `CPhysicsObj`):** -1. `DetectionManager::CheckDetection` -2. `TargetManager::HandleTargetting` ← the voyeur-subscription tick (no separate `UseTime`) -3. `MovementManager::UseTime` -4. `CPartArray::HandleMovement` -5. `PositionManager::UseTime` - -This ordering matters for R5's facade design: TargetManager ticks BEFORE -MovementManager and PositionManager each frame, so any target-driven -`HandleUpdateTarget` callback into those two managers (see next section) -happens with THIS tick's fresh distance-check data, ahead of movement/position -processing the same tick. - -### `CPhysicsObj::HandleUpdateTarget` — 00512bc0 (fan-out target) - -```c -00512bc0 void __thiscall CPhysicsObj::HandleUpdateTarget(class CPhysicsObj* this, class TargetInfo arg2) - -00512bc0 { -00512bc9 if (arg2.context_id == 0) -00512bc9 { -00512bd3 void var_d4; -00512bd3 -00512bd3 if (this->movement_manager != 0) -00512bd3 { -00512be5 TargetInfo::TargetInfo(&var_d4, &arg2); -00512bf0 MovementManager::HandleUpdateTarget(this->movement_manager, var_d4); -00512bd3 } -00512bd3 -00512bfd if (this->position_manager != 0) -00512bfd { -00512c0f TargetInfo::TargetInfo(&var_d4, &arg2); -00512c1a PositionManager::HandleUpdateTarget(this->position_manager, var_d4); -00512bfd } -00512bc9 } -00512bc0 } -``` - -Only fans out when `arg2.context_id == 0` (context_id nonzero presumably -reserved for a different consumer not wired here, e.g. quest/AI scripted -targeting — not confirmed in this extract). Copy-constructs a fresh -`TargetInfo` per recipient and forwards to **both** -`MovementManager::HandleUpdateTarget` and `PositionManager::HandleUpdateTarget` -unconditionally (both get the callback if both managers exist — not an -either/or). `TargetManager::ReceiveUpdate`, `TargetManager::HandleTargetting` -(timeout path), and `TargetManager::SetTarget` (the `arg3==0` clear path) -are the three call sites feeding this fan-out. - -### `SetTarget` producers - -**`MoveToManager::MoveToObject`** — 00529680 (excerpt, the `set_target` call): - -```c -00529791 if (arg3 == physics_obj_2->id) -00529791 { -00529795 edx = MoveToManager::CleanUp(this); -00529795 goto label_52979a; -00529791 } -00529791 -005297b8 int32_t var_50_4 = 0; -005297c4 CPhysicsObj::set_target(physics_obj_2, 0, this->top_level_object_id, 0.5f, 0f); -``` - -**`MoveToManager::TurnToObject`** — 005297d0 (excerpt): - -```c -00529900 int32_t __saved_edi_2 = 0; -0052990c this->initialized = 0; -00529916 CPhysicsObj::set_target(physics_obj_1, 0, arg3, 0.5f, 0f); -``` - -Both `MoveToManager` entry points that establish "move to / turn to a -specific object" call `CPhysicsObj::set_target(physobj, context_id=0, -target_object_id, radius=0.5, quantum=0.0)` — a **zero quantum**, -meaning (per `HandleTargetting`'s gate) the tick-driven resend is -effectively "as fast as the 0.5s-ish quantum-check tick allows" rather -than throttled further; radius is a fixed 0.5 (game units — i.e. "notify -me if the target moves more than half a unit from what I last knew"). - -**`MoveToManager::CleanUp`** — clears the target when movement ends -(excerpt, line 306731-306732): - -```c -0052962c if ((this->top_level_object_id != 0 && this->movement_type != Invalid)) -00529634 CPhysicsObj::clear_target(this->physics_obj); -``` - -**`MoveToManager::UseTime`** — adaptive quantum re-tuning (heavily -x87-mangled excerpt, lines 307376-307434): computes a candidate quantum -from `Position::distance(starting_position, physics_obj->m_position)` and -`get_velocity`-derived speed via a sqrt-based formula (`fsqrt` visible at -0052a057), compares the delta between the new candidate and the current -`CPhysicsObj::get_target_quantum()` value against a `1.0` threshold and -`0.1` epsilon guard, and if the change is significant enough calls -`CPhysicsObj::set_target_quantum(physics_obj, var_88_3)`. **NOTE: this -whole block is FCMP/x87-mangled past reliable hand-decoding** — several -lines are literal `/* unimplemented {fld/fmul/fsqrt/...} */` placeholders -BN could not lower into pseudo-C at all. Treat as "adaptive quantum -retuning happens somewhere in `MoveToManager::UseTime` based on distance -traveled since move-start and current speed" — do NOT port the exact -formula from this extract without a fresh, careful manual disassembly -pass; the pseudo-C here is not trustworthy enough to line-port. - -### `StickyManager` — a second, independent `TargetManager` consumer - -Not explicitly requested but discovered while tracing `clear_target` -callers — **directly relevant to R5's PositionManager/Sticky scope**: - -**`StickyManager::StickTo`** — 00555710 (excerpt): tears down any -existing target then calls -`CPhysicsObj::set_target(physics_obj_1, 0, arg2, 0.5f, 0.5)` — same -radius (0.5) as `MoveToManager` but a **nonzero 0.5s quantum** this time -(throttled resend, unlike MoveToManager's 0.0). - -**`StickyManager::UseTime`** — 00555610: on a `Timer::cur_time >= -sticky_timeout_time` gate (mangled FCMP, same `0x41`-mask shape as -`CheckAndUpdateVoyeur` → reads as `>=`), clears `target_id` and calls -`CPhysicsObj::clear_target` + `CPhysicsObj::interrupt_current_movement`. - -**`StickyManager::Destroy`** — 00555650: unconditional `clear_target` if -`target_id != 0` during teardown. - -**`StickyManager::HandleUpdateTarget`** — 00555780: the `TargetInfo` -consumer callback fanned out from `CPhysicsObj::HandleUpdateTarget`. -Ignores updates whose `object_id` doesn't match `this->target_id`. On a -match: if `status == Ok_TargetStatus`, copies `target_position` into its -own tracked position and marks `initialized = 1`; otherwise (any other -status, including timeout/exit/teleport) tears itself down -(`clear_target` + `interrupt_current_movement`) exactly like `UseTime`'s -timeout path. - -This confirms **two independent per-`CPhysicsObj` consumers of the same -`TargetManager`/voyeur machinery**: `MoveToManager` (move/turn-to-object, -quantum=0, immediate-as-tick-allows resend) and `StickyManager` -(quantum=0.5s throttled resend, timeout-driven unstick). Both receive -their updates through the identical `CPhysicsObj::HandleUpdateTarget` → -`{MovementManager,PositionManager}::HandleUpdateTarget` fan-out — i.e. -`StickyManager::HandleUpdateTarget` is presumably reached VIA -`PositionManager::HandleUpdateTarget` (StickyManager is a PositionManager -sub-component per the project's existing R5 handoff doc), not directly -off `CPhysicsObj`. diff --git a/docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md b/docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md deleted file mode 100644 index c59e2a16..00000000 --- a/docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md +++ /dev/null @@ -1,191 +0,0 @@ -# R5 wiring handoff — ARC DONE 2026-07-05 (V1-V5 all shipped) - -> **ARC CLOSE-OUT 2026-07-05.** All five slices landed: V1 (Core classes, -> `3d89446d`), V2 (TargetManager voyeur wiring, AP-79 retired, `fffe90b3`), -> V3 (sticky melee #171, three slices, TS-39 retired, gate PASSED), V4 -> behavioral items (head stance dispatch + #164 + mt-0 flags, `f423884b`, -> gate PASSED), **V5 (MovementManager facade — structural, zero behavior -> change)**: `src/AcDream.Core/Physics/Motion/MovementManager.cs` (retail -> acclient.h `/* 3463 */`; MakeMoveToManager 0x00524000, PerformMovement -> 0x005240d0, UseTime 0x005242f0, HitGround 0x00524300, HandleExitWorld -> 0x00524350, CancelMoveTo 0x005241b0, HandleUpdateTarget 0x00524790, -> IsMovingTo 0x00524260) owns each entity's interp+moveto pair. The three -> wiring sites (`EnsureRemoteMotionBindings`, `EnterPlayerModeNow`, the -> chase harness) construct through `MoveToFactory` + `MakeMoveToManager()` -> — the factory closure is the acdream stand-in for retail's -> physics_obj/weenie_obj backpointers; `RemoteMotion.Motion/.MoveTo` and -> `PlayerMovementController.MoveTo` are now child VIEWS of the facade -> (`RemoteMotion.Movement` / `PlayerMovementController.Movement`), so the -> comment-dense call sites read unchanged. Relay call sites repointed: -> both landing HitGround pairs + the player landing pair, despawn -> HandleExitWorld, TickRemoteMoveTo + the player Update UseTime, -> RouteServerMoveTo (now takes the facade; routes through the retail -> PerformMovement dispatch), InstallSpeculativeTurnToTarget, both host -> HandleUpdateTarget/InterruptCurrentMovement closures, TS-36 interrupt -> chains. NOT absorbed (per slice spec): unpack_movement stays App -> (RouteServerMoveTo + the UM heads), TS-42 per-tick order untouched (R6), -> #170/#171 gate-passed machinery untouched. 15 facade conformance tests -> (`MovementManagerTests.cs`); suite 4052 green, protected suites -> unmodified. Register: TS-41/TS-42 wording freshened, AD-36 retire note -> corrected (facade half closed; no new rows). **Open follow-ons: #167 -> ConstraintManager arming (TS-35), R6 per-tick order (TS-42), TS-43 -> remote teleport hook.** Next work per the milestones doc: M1.5 critical -> path — #137 dungeon collision, #138 teleport-OUT, A7 dungeon lighting. - -(Original handoff below, kept for the trail.) - -Successor to `2026-07-03-r5-entry-handoff.md`. **R5-V1 shipped** (`3d89446d`): -the retail movement-manager family is ported to Core as faithful, fully-tested, -UNWIRED classes. The remaining slices (V2/V3/V4) are GameWindow integration -whose acceptance is **visual verification against the running client** — the one -thing the CLAUDE.md says requires the user. Do them one at a time, visual-gate -each, do NOT stack. - -## What V1 landed (`3d89446d`, full suite 4006 green) - -New Core (`src/AcDream.Core/Physics/Motion/`), all with conformance tests: -- `StickyManager`, `ConstraintManager`, `PositionManager` (facade), - `TargetManager` + `TargettedVoyeurInfo`, `IPhysicsObjHost` (the CPhysicsObj - back-pointer seam the App implements per entity), `MotionDeltaFrame`. -- `TargetInfo` extended to the full retail 10-field struct (additive defaults). -- `MoveToMath`: `CylinderDistanceNoZ` (signed), `NormalizeCheckSmall`, - `GlobalToLocalVec`. -- **Rename**: `AcDream.Core.Physics.PositionManager` (the misnamed remote - anim+interp combiner) → `RemoteMotionCombiner`. This freed the name for the - faithful facade and removed the ambiguity that breaks any file importing both - `AcDream.Core.Physics` + `.Motion` — **GameWindow imports both**, so the - wiring below could not compile without this rename. - -Decomp + ACE cross-ref + decoded math: this directory (`r5-*-decomp.md`, -`r5-ace-crossref.md`, `r5-port-plan.md`). The port plan §2 has the decoded x87 -mush (sticky steer, constraint taper, voyeur gates) — trust it over the raw BN. - -## The wiring model (all slices) - -acdream has no per-entity `CPhysicsObj`. Introduce one `IPhysicsObjHost` per -entity — **in a dedicated App class `EntityPhysicsHost`, NOT inline in -GameWindow** (code-structure rule #1). GameWindow keeps a -`Dictionary _physicsHosts`; each host's `GetObjectA` -is `id => _physicsHosts.GetValueOrDefault(id)` (cross-entity resolve for the -voyeur round-trip). Construct a host in `EnsureRemoteMotionBindings` (remotes) -and `EnterPlayerModeNow` (player); remove it + `NotifyVoyeurOfEvent(ExitWorld)` -on despawn/exit-world. - -Host accessor mapping (behavioral-equivalence anchor — match AP-79 exactly): -- `Position` = the entity's **`WorldEntity.Position`** (world-space) via - `_entitiesByServerGuid[id].Position`. This is EXACTLY the source the AP-79 - poll used for a target (`trackedEnt.Position`), so for the moveto case - (quantum 0, `GetInterpolatedPosition` = Position) the voyeur system delivers - the identical position the adapter did. -- `Velocity` = body velocity (only used for quantum>0, i.e. sticky). -- `Radius` = setup cylsphere radius (today MoveToManager passes 0 — the P4 note; - wiring the real radius here is the "finally needed" bit). -- `InContact` = transient Contact bit (constraint gate; V3). -- `MinterpMaxSpeed` = `interp.GetMaxSpeed()` or null (sticky speed; V3). -- `CurTime`/`PhysicsTimerTime` = the real clocks (R4-V5 fixed the remote clock; - reuse the same epoch-seconds source; the player uses `SimTimeSeconds`). -- `HandleUpdateTarget` = fan to the entity's `MoveToManager.HandleUpdateTarget` - (+ `PositionManager.HandleUpdateTarget` once V3 adds it). - -### V2 — wire TargetManager, retire AP-79 (behaviorally a no-op refactor) -Repoint MoveToManager's `setTarget`/`clearTarget`/`getTargetQuantum`/ -`setTargetQuantum` seams at `host.TargetManager`. Per tick, call -`host.TargetManager.HandleTargetting()` BEFORE `mtm.UseTime()` (retail -`UpdateObjectInternal` order) — in `TickRemoteMoveTo` (remotes, GameWindow.cs -~4469, both call sites ~9688/9911) and the player pre-Update block -(GameWindow.cs ~7994). **Every entity that can be a target must tick -HandleTargetting** — the creature-chase target is the player, so the player's -host MUST tick it (that's what pushes updates to the chasing creatures). -Delete the AP-79 fields (`RemoteMotion.TrackedTarget*`, the `_playerMoveToTarget*` -GameWindow fields) and the two manual poll blocks. **Delete the AP-79 register -row same commit.** Keep the 183-case/funnel/moveto suites green (unmodified). - -Behavioral-equivalence: the voyeur radius = MoveToManager's `set_target` radius -(0.5) = AP-79's `TrackedTargetRadius`; both deliver `HandleUpdateTarget(Ok)` -when the target drifts >radius; both deliver ExitWorld on despawn. Identical for -the moveto case. **VISUAL GATE**: a server-directed creature still chases the -player, and player auto-walk-to-object still tracks — no visible change, confirm -nothing broke. - -Lifecycle watchouts: (1) when a watcher despawns, its host removal must let the -target drop the dead voyeur (RemoveVoyeur on ClearTarget, or prune on send -failure). (2) The immediate-snapshot-on-subscribe (retail AddVoyeur) means the -watcher gets one `HandleUpdateTarget(Ok)` the instant it sets a target — matches -AP-79's `!FedOnce` first delivery. (3) HandleTargetting self-throttles to 0.5s; -the AP-79 poll ran every frame but only DELIVERED on drift — same effective -cadence for delivery, but a target moving fast between 0.5s ticks sends less -often. If chase feels choppier than AP-79, that's the throttle — retail-faithful, -but note it. - -### V3 — wire PositionManager (sticky), retire TS-39, apply mt-0 flags - -> **STATUS 2026-07-04: SHIPPED + visual gate PASSED** ("Looks good, ship it"; -> three slices: `5bd2b8bc` binding+radii, `7a823176` UP-snap suppression while -> stuck TS-44, `69966950` deep-overlap sign pin AP-82) -> — sticky seams bound (StickTo/Unstick/UnstickFromObject, remote + player), -> AdjustOffset at the UpdatePositionInternal slot (combiner chained as the -> interp stage in the remote-player branch; pre-sweep compose in the NPC -> branch + player physics tick), UseTime at the UpdateObjectInternal tail, -> real setup cylsphere radii threaded, exit-world/teleport teardown (remote -> teleport gap = TS-43), SERVERVEL yields to a stuck entity (TS-41). TS-39 -> retired. **The mt-0 wire flags (0x1 StickToObject / 0x2 StandingLongJump) -> were NOT part of the user-approved #171 scope — they move to V4.** -Add a `PositionManager` to each host. Bind `MoveToManager.StickTo → -host.PositionManager.StickTo`, `MoveToManager.Unstick → host.PositionManager.UnStick`. -The load-bearing part: integrate `host.PositionManager.AdjustOffset(deltaFrame, -quantum)` into the per-frame body integration at the composed-delta chokepoint -(retail `UpdatePositionInternal` — in acdream that's where `RemoteMotionCombiner. -ComputeOffset` runs, GameWindow ~9716; sticky steer must ADD to the body's -per-tick motion). Per tick `host.PositionManager.UseTime()` AFTER -`mtm.UseTime()`. Apply the mt-0 wire flags (`UpdateMotion.cs` parses both, -unconsumed): `0x1 StickToObject` → `host.stick_to_object(guid)` → -`PositionManager.StickTo`; `0x2 StandingLongJump` → -`MotionInterpreter.StandingLongJump`. **Delete the TS-39 register row same -commit.** **VISUAL GATE**: a sticky scenario (server /follow-style sticky moveto -or a moving-platform stick) HOLDS the target instead of stopping-and-drifting. - -### V4 (capstone) — MovementManager facade + #164 + head-stance dispatch + docs - -> **STATUS 2026-07-04: the three BEHAVIORAL items SHIPPED + visual gate -> PASSED** ("looks good", `f423884b`): head style-on-change at both GameWindow routing heads -> (@00524502-0052452c, all movement types), #164 action-replay Autonomous -> bit (raw 305982), mt-0 wire flags consumed (0x1 stick_to_object -> 0x005127e0 → PositionManager.StickTo at both case-0 tails; 0x2 → -> `Motion.StandingLongJump`, unconditional per @0052458e). Conformance: -> stance-on-arm harness scenario + the autonomy funnel test; suite 4041 -> green. **The MovementManager facade DEFERRED to its own slice** (the -> handoff's own "optional if the arc runs long" clause — this arc ran two -> full gate cycles). **→ SHIPPED 2026-07-05 as R5-V5; see the arc -> close-out banner at the top of this doc.** -- Collapse per-entity `Motion`+`MoveTo` into a `MovementManager` owner - (structural; keep 183-case/funnel/moveto green UNMODIFIED). Optional if the - arc runs long — the retirements above don't need it. -- #164: action-replay Autonomous bit (params 0x1000 from per-action autonomy - flag, raw 305982) in `MotionInterpreter.MoveToInterpretedState` action loop. -- Head stance-change dispatch for mt 6-9 (the `RetailObserverTraceConformance - Tests.cs:33` "S3 wires the unpack-level style-on-change" exclusion — retail's - `unpack_movement` head does `InqStyle != wire-style → DoMotion(style)` - independent of movement type; acdream applies style only on the mt-0 path). -- Final register/ISSUES/roadmap/memory + successor handoff. - -## Open items carried -- **#167**: ConstraintManager leash-arming + the two unknown x87 constants - (`GetStart/MaxConstraintDistance`) — deferred; needs cdb/Ghidra. TS-35 stays - open (register corrected: the mechanism is the leash, not doorway-jamming). -- Verify against named-retail before treating as ground truth: the sticky/target - constants (StickyRadius 0.3 / StickyTime 1.0 / follow ×5 / fallback 15; - HandleTargetting 0.5s throttle / 10s staleness) — ACE values, cross-checked - against the mush but ACE flagged its own `// ref?` uncertainty on the - TargetInfo copy semantics (ported as copy — retail copy-constructs at every - fan-out). - -## Load-bearing lessons (from V1) -- The `Physics.PositionManager` name collision was a REAL compile break for any - file importing both namespaces, not cosmetic — the recon agent missed the - existing (misnamed) class entirely (grepped for retail semantics). Renaming it - was the "do it right" fix. Lesson: grep for the NAME too, not just the concept. -- ConstraintManager is NOT a general joint/constraint system — it's a narrow - server-position rubber-band leash (3 call sites total: SmartBox arm, teleport - disarm, jump-gate read). Don't over-scope it. -- The voyeur system is peer-to-peer intra-client: it needs EVERY entity to be a - host + tick HandleTargetting, or cross-entity delivery silently no-ops. diff --git a/docs/research/2026-07-03-session-handoff.md b/docs/research/2026-07-03-session-handoff.md deleted file mode 100644 index 91571e0a..00000000 --- a/docs/research/2026-07-03-session-handoff.md +++ /dev/null @@ -1,111 +0,0 @@ -# Session handoff — R5-V1/V2 + world-loading fixes (2026-07-03, late) - -Fresh session enters HERE. Worktree `vigorous-joliot-f0c3ad`, branch -`claude/vigorous-joliot-f0c3ad`, tree CLEAN at `64f83d7c`. Full suite **4007** -green (+ the R5-V1 conformance tests + the #168 relocate test). Everything below -is committed. - -## What landed this session - -| Commit | What | -|---|---| -| `3d89446d` | **R5-V1** — PositionManager facade + StickyManager + ConstraintManager + the full **TargetManager voyeur system** ported to Core, fully tested, UNWIRED. Renamed the misnamed `Physics.PositionManager` combiner → `RemoteMotionCombiner`. Decomp/ACE/plan in `docs/research/2026-07-03-r5-managers/`. | -| `2b5e8a67` | R5-V1 docs: wiring handoff, register TS-35 correction, ISSUES #167 (ConstraintManager leash unarmed). | -| `fffe90b3` | **R5-V2** — wired the TargetManager voyeur system per-entity via `EntityPhysicsHost` (App), retired the AP-79 poll adapter. **Verified live** — this is why remote creatures now chase the player. | -| `315af02f` | **fix #168** — the pending-bucket trap (`RelocateEntity` couldn't recover an entity stranded in `_pendingByLandblock`). Invisible player / disappear-on-run-out. | -| `9b06a9b8` | **fix #169** — the cold-spawn streaming "hole" (far login-spawn diffs against the half-loaded startup window; residence marked before loads land → hole). Now `ForceReloadWindow`s on a far login-spawn. | -| `7c5bc97c` | ISSUES #168/#169 filed DONE. | -| `64f83d7c` | ISSUES #170 filed (creature chase/attack animation divergence). | - -**The user's headline ask this session** — "the world doesn't load / character -disappears" — is FIXED and verified live (`0xADAF` loads, player transitions -`PENDING → DRAWSET PRESENT`). Root-caused to two pre-existing streaming bugs; -R5-V2 was verified line-by-line to be read-only w.r.t. position/cell/landblock/ -streaming and RULED OUT as the cause. Durable lesson: `feedback_streaming_residence_race`. - -## NEXT — two ready work streams (Claude picks; both are teed up) - -### Option A — #170: remote creature chase+attack renders wrong vs retail -**Well-scoped, user-confirmed via retail side-by-side (the oracle).** A monster -chasing+attacking the player glides, over-plays attacks, and shows uniform/wrong -attack animations; retail (same local ACE) renders it correctly → the divergence -is CLIENT-side. Motion trace (`ACDREAM_DUMP_MOTION`, guid 0x80000244): ACE sends -`mt-0 stance 0x3C` → `mt-6 chase spd 2.03` → a stream of `mt-0` attacks -`0x62/0x63/0x64 spd 0.97`; acdream shows a MOTIONDONE `pending=True` loop. -Decomposes into THREE sub-bugs (full detail in ISSUES #170): -1. **Wrong/uniform attack anims** → likely **#159** (CombatAnimationPlanner uses - 2013-decomp command numbering, not ACE/DRW — `0x10000062/63/64` misclassify). - This one has a KNOWN fix path (renumber `CombatAnimationMotionCommands` to - `DatReaderWriter.Enums.MotionCommand` values; parity test). -2. **Over-frequency / stuck** — the `pending_motions` queue completes+re-queues - in a tight loop. R3/R4 animation-sequencer / MotionDone territory (grep the - named decomp for how retail's `CMotionInterp` handles an attack UM stream - arriving over an active moveto — attack one-shots should play over locomotion - and RETURN to it, not wedge). -3. **Glide** — position moves (mt-6 + UP dead-reckon) but no locomotion legs play - (attacks override) → smooth slide. AP-80 / #160 dead-reckon-vs-animation family. -Entry points: `MotionInterpreter`/`AnimationSequencer` attack dispatch + -`pending_motions`; `CombatAnimationPlanner` (#159); `ServerControlledLocomotion`. -Start with #159 (bounded), then trace the pending-motions loop with the motion -dump. **Retail is the oracle — the user runs it side-by-side; ask for a -frame-by-frame retail comparison, don't guess.** - -### Option B — R5-V3: wire PositionManager sticky, retire TS-39, apply mt-0 flags -The paused R5 continuation. Full plan in -`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` (§V3). Bind -`MoveToManager.StickTo/Unstick → PositionManager` (now that V2 gives each entity -an `EntityPhysicsHost`, adding a `PositionManager` to it is the natural next -step), integrate `adjust_offset` into the body tick, apply the UpdateMotion mt-0 -flags (0x1 sticky-guid → `stick_to_object`, 0x2 → `StandingLongJump`). Retires -TS-39. Then R5-V4 (MovementManager facade + #164 + head-stance dispatch). - -**Recommendation:** #170 is more user-visible (they just watched it break) and is -now well-scoped; R5-V3 is the "finish what we started" path. Either is fine — -Claude's call per work-order autonomy. #170's part 1 (#159) is the smallest -concrete win. - -## R5 arc status -- **V1** (Core managers + tests) — DONE `3d89446d`. -- **V2** (voyeur wiring, retire AP-79) — DONE `fffe90b3`, verified live. -- **V3** (sticky, retire TS-39, mt-0 flags) — PENDING. `r5-wiring-handoff.md` §V3. -- **V4** (MovementManager facade + #164 + head-stance dispatch) — PENDING. §V4. -- Open R5-adjacent: #167 (ConstraintManager leash unarmed + 2 unknown x87 consts), - TS-35 (stays open until the leash is armed), #164 (action-replay Autonomous bit). - -## Debug apparatus that proved decisive (reuse for #170) -- `ACDREAM_PROBE_ENT=1` — `EntityVanishProbe`: `[ent] APPEND → PENDING/LOADED`, - `DRAWSET PRESENT/ABSENT`, `[dyn] player DRAWN/CULLED`. Separates "not in draw - set" from "present-but-culled." A one-line `[lb] ADD` in `GpuWorldState.AddLandblock` - (added then stripped) exposed the streaming hole as a missing Y-band — re-add - it if you chase another streaming gap. -- `ACDREAM_DUMP_MOTION=1` — every inbound `UM` (guid, stance, cmd, speed) + the - resulting `SetCycle` + `[MOTIONDONE]`. THE tool for #170 — it's how the - attack-stream-over-chase was found. - -## Session gotchas (environment) -- **The test character `+Acdream` (0x5000000A) is saved OUT in the wilderness** - (landblock ~`0xADAF`/`0xAEAE`, ~1 km SE of Holtburg), from an earlier run of - the #168 disappear bug. It spawns there every login. **With the #169 fix that - now loads correctly** (far login-spawn ForceReloadWindows), so it's testable — - and there are Mite Scamps + a Mosswart Feeder right at the spawn (handy for - #170). To get back to Holtburg, the user GM-teleports. -- **Client lifecycle: the USER manages it.** Launch with plain `dotnet run - --no-build` in the background (never pre-close/kill). If a rebuild is locked by - a running client (`The file is locked by AcDream.App`), ASK the user to close - it — don't kill it. -- **ACE stale session:** a hard-closed or rapidly-relaunched client leaves ACE - thinking the character is still in-world → `session failed: CharacterList not - received` for ~3 min. Graceful close (window X / WM_CLOSE, exit 0) clears in - ~5 s. Don't retry-spam; wait ~160 s if it's stuck. This session hit it once - from rapid relaunches. -- PowerShell `Tee-Object`/`Out-File` write UTF-16 → the plain-text grep needs - `tr -d '\000'` first (the launch logs read as UTF-16). Used throughout. - -## Pointers -- R5 decomp/plan: `docs/research/2026-07-03-r5-managers/` (decomp per manager, - ACE cross-ref, port plan, wiring handoff). -- ISSUES: #167 (constraint leash), #168/#169 (DONE this session), #170 (creature - animation), #159 (combat command numbering), #160/#165/#166 (remote-motion). -- Memory: `feedback_streaming_residence_race`, and the R5 research index entry in - `MEMORY.md` (animation-sequencer deep-dive line). -- Roadmap Phase R: R5-V1/V2 shipped; V3/V4 next. diff --git a/docs/research/2026-07-04-170-creature-run-handoff.md b/docs/research/2026-07-04-170-creature-run-handoff.md deleted file mode 100644 index 590bba83..00000000 --- a/docs/research/2026-07-04-170-creature-run-handoff.md +++ /dev/null @@ -1,188 +0,0 @@ -# Handoff — #170 creature chase renders as slide (PARTIAL FIX landed; residual = "sustain the run") — 2026-07-04 - -> **⚠ SUPERSEDED (2026-07-04, second session): the residual is FIXED — pending visual gate.** -> The "Ready stop-node backlog drains a beat slower" framing below was DISPROVEN by a -> full-stack offline harness (`tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs`) -> plus corrected per-guid attribution of the launch-drainq.log evidence. The Core -> drain/turn/run machinery is healthy; both handoff hypotheses ((a) tick counts, -> (b) drain trigger rate) are moot. The real mechanism: the per-tick branch arbitration -> sent any UP-receiving NPC down the SERVERVEL leg, which **skips `MoveToManager.UseTime`** -> — the armed moveto was starved for the whole server-side chase (funnel: 16 arms → -> 11 dispatched turns → 1 run install), legs stayed Ready while the body glided on -> UP-synthesized velocity. Retail runs `MovementManager::UseTime` unconditionally -> (`UpdateObjectInternal` 0x005156b0 @0x00515998). Fix: armed movetos always take the -> MOVETO leg (GameWindow `TickAnimations`, `moveToArmed` gate; register TS-41 narrowed, -> TS-42 added for the one-frame drain-order divergence). Current status + next steps -> live in `docs/ISSUES.md` #170. This doc remains as the evidence record for the flood -> fix (`427332ac`) and the cdb apparatus. - -Fresh session picks up HERE for #170. Worktree `vigorous-joliot-f0c3ad`, branch -`claude/vigorous-joliot-f0c3ad`. Tree CLEAN at **`427332ac`** (the partial fix + -env-gated probes). This session root-caused #170 end-to-end with **live retail cdb -tracing** + acdream runtime probes and landed a **verified partial fix**. One -residual remains (the run isn't fully sustained). This doc is the SSOT; the paste -prompt is `docs/research/2026-07-04-170-pickup-prompt.md`. - -## TL;DR - -A monster chasing a fleeing player renders as a **slide** in acdream (glides toward -you in an idle/attack pose) vs **runs to close, stops to swing** in retail (same -local ACE). Root cause, proven: - -- The chase **run cycle is manufactured client-side** from the `mt-6` MoveToObject - stream: `HandleUpdateTarget → MoveToObject_Internal → (TurnToHeading node - completes via UseTime) → BeginMoveForward → _DoMotion(RunForward)` sets the - motion-table **substate = RunForward** (the legs). `RunForward` is NEVER on the - wire — the server only sends mt-6 + the attack UMs. -- `MoveToManager.BeginTurnToHeading` (retail `0x00529b90`) bails while - `CMotionInterp.motions_pending` is non-empty (**retail-faithful guard**). -- acdream's `pending_motions` **exploded to ~1.3M entries** because the NPC - per-tick called `rm.Motion.apply_current_movement` **every frame**, re-dispatching - the whole interpreted state (stance + attack + stops) and appending a - `pending_motions` node each time. `MotionsPending()` stayed permanently true → - the chase turn never started → `BeginMoveForward/RunForward` ~never fired → slide. -- **FIX (landed `427332ac`):** delete the per-frame `apply_current_movement` in the - grounded remote-NPC path (`GameWindow.cs` ~9992). Retail dispatches per motion - **event** (per UM), never per frame. - -**Result (verified live):** flood 1.3M → depth ~1 (add≈done); "stuck in attack -animation" GONE (user-confirmed); run cycle installs (`BeginMoveForward` 1→10, -`RunForward` held 0→7). **PARTIAL** — still not fully sustained (below). - -## The one remaining residual (= the next session's job) - -The run isn't sustained: `BeginTurnToHeading` is still blocked -`motionsPending=True` **256/272 (94%)** of the time, because a small **`Ready` -(0x41000003) stop-node backlog** keeps `pending_motions` from ever fully emptying -between swings. acdream gets ~10 run-starts to **retail's 21**, so it now -**twitches forward + glides** (short run bursts + idle) instead of a clean -run-then-stop. - -**Where the `Ready` backlog comes from (traced, not guessed):** -- `MotionInterpreter.StopInterpretedMotion` (`MotionInterpreter.cs:3254`) appends a - `Ready` `pending_motions` node (line 3292) whenever the stop **succeeds** - (`sink.StopMotion` → `CMotionTable.StopSequenceMotion` returns true). -- `StopSequenceMotion` (`CMotionTable.cs:559`) returns **false** for a *redundant* - sidestep/turn stop (Case A: not our substate; Case B: no matching modifier) — so - those correctly DON'T queue `Ready`. The `Ready` nodes that DO accumulate come - from stops that **succeed**: chiefly the per-UM tail - `StopInterpretedMotion(TurnRight)` (`MotionInterpreter.cs:3008`) stopping the - MoveTo's OWN steering turn, plus the MoveTo-cancel `StopCompletely`. -- Those `Ready` nodes drain via `MotionDone` (fired 1:1 by `MotionTableManager` - per completed `_pendingAnimations` entry — `AnimationDone` per AnimDone hook - `GameWindow.cs:10306` + `UseTime`→`CheckForCompletedMotions` 0-tick sweep at - `10309`). They drain **a beat slower** than they add → backlog grows (lag 1→10 - over a chase). Retail hits `add_to_queue == MotionDone` **exactly** (cdb-proven). - -**The decisive open question for the fix:** WHY do acdream's `Ready` stop-nodes -drain slower than retail's? Two concrete hypotheses to test with a retail cdb -trace (retail is the oracle; do NOT guess in this verbatim-ported R2/R3 machinery): -1. **Tick count.** acdream's `Ready` stop entries have `outTicks > 0` (from - `StopSequenceMotion` Case A → `GetObjectSequence(styleDefault, stopCall:true, - out outTicks)`), so they wait for `AnimationDone` instead of draining - immediately via the per-frame 0-tick `CheckForCompletedMotions`. Retail's may be - 0-tick. → cdb: break `CMotionTable::StopObjectMotion`/`GetObjectSequence` on a - live chasing monster, read the returned tick count for a turn-stop. -2. **Drain trigger rate.** Retail may fire `CheckForCompletedMotions`/`MotionDone` - more aggressively than acdream's once-per-frame `Manager.UseTime`. → cdb: count - `CMotionInterp::MotionDone` vs `add_to_queue` (already have the script: - `scratchpad/cdb-drain.cdb` — retail = add==done) and add - `CPhysicsObj::CheckForCompletedMotions`/`MotionTableManager::UseTime` counts. - -Target acceptance: acdream's `pending_motions` drains to `add==done` (fully empties -between swings like retail) → `BeginTurnToHeading` proceeds per mt-6 arm → -`BeginMoveForward ≈ MoveToObject` (retail was 21≈22) → the creature runs to close -distance, plants to swing. **Visual gate:** user runs acdream + retail side-by-side. - -## Evidence table (live traces this session) - -| Metric | retail (cdb) | acdream BEFORE fix | acdream AFTER fix (`427332ac`) | -|---|---|---|---| -| `pending_motions` add vs done | **254 == 254** | 1.37M vs 5.7K | 425 vs 424 | -| max `pending_motions` depth | shallow | **1,332,575** | ~1–2 | -| `BeginMoveForward` (run installs) / chase | 21 | 1 | 10 | -| `MoveToObject` arms / chase | 22 | (7 in an attack-heavy cap) | 32 | -| `HandleUpdateTarget` (voyeur re-drive) | 689 | 44 | fires | -| `BeginTurnToHeading` motionsPending True/False | (empties often) | n/a | **256 / 16** | -| lingering queue contents | — | 0x8000003C×671K (stance) | **0x41000003 (Ready) backlog** | - -## Apparatus (all in place at `427332ac`; reuse, then strip when #170 closes) - -**acdream env-gated probes** (`ACDREAM_MVTO_DIAG=1`, alongside `ACDREAM_DUMP_MOTION=1`): -- `MoveToManager.cs` `s_mvtoDiag`: `[mvto] BeginMoveForward cmd=…`, `HandleUpdateTarget - … match=… init=… mtState=…`, `CancelMoveTo (real) wasType=…`, `UseTime enter/dispatch - node=…`, `BeginTurnToHeading motionsPending=… curCmd=…`. -- `MotionInterpreter.cs` `s_drainDiag`: `[drain] add=… done=… lag=… depth=…` (aggregate - add_to_queue vs MotionDone) + `[drainq] depth=… q=[…]` (queue CONTENTS when depth≥2 — - this is what named `Ready` as the lingering node). -- `GameWindow.cs`: `[npc-tick] guid=… branch=SERVERVEL|MOVETO` (which per-tick branch), - + the earlier `UM ↳ actions …` inbound-action dump (capture aid). - -**Launch (user manages lifecycle; graceful close):** -``` -$env:ACDREAM_DUMP_MOTION="1"; $env:ACDREAM_MVTO_DIAG="1" # + ACDREAM_LIVE/DAT_DIR/host/port/user/pass -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object launch.log -``` -In-world: **aggro a Mite Scamp at the wilderness spawn (~0xADAF), RUN AWAY so it -CHASES** (the bug is the chase, not attack-in-place — no chase ⇒ no `[mvto]` lines). -Logs are UTF-16 → `tr -d '\000'` before grep. - -**Retail cdb toolchain** (Step -1; retail is the oracle for the residual): -- Binary `C:\Turbine\Asheron's Call\acclient.exe` MATCHES `refs/acclient.pdb` - (`py tools/pdb-extract/check_exe_pdb.py "…/acclient.exe"`). cdb at - `C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`. -- Working scripts saved in the scratchpad this session: `cdb-lookup.cdb` (symbol - names), `cdb-count.cdb` / `cdb-chase.cdb` (BeginMoveForward/MoveToObject/ - HandleUpdateTarget/RunForward-dispatch counts), `cdb-drain.cdb` - (add_to_queue vs MotionDone vs motions_pending). Pattern: count + `gc`, - auto-`qd` after N `move_to_interpreted_state` hits (user makes a monster chase - retail during the trace). Key addrs: `MoveToManager::BeginMoveForward 0x00529a00`, - `_DoMotion 0x00529010`, `CancelMoveTo 0x00529930`, `MoveToObject 0x00529680`, - `HandleUpdateTarget 0x0052a7d0`, `UseTime 0x0052a780`; - `CMotionInterp::move_to_interpreted_state 0x005289c0`, `DoInterpretedMotion - 0x00528360`, `add_to_queue 0x00527b80`, `MotionDone 0x00527ec0`, `motions_pending - 0x00527fe0`, `get_state_velocity 0x00527d50`. - -## DO-NOT-RETRY / superseded - -- **The `eb423fb7` "MovementManager coexistence / R5-V4" hypothesis is WRONG** — the - attack UM cancelling the MoveTo is retail-faithful and NOT the cause. Superseded by - this doc. (Register/ISSUES updated.) -- The `d2ccc80e` velocity fix (`get_state_velocity` → `set_local_velocity` each - grounded tick) is CORRECT but **position-only** — it does NOT fix the legs; keep it. -- `CombatAnimationPlanner` (#159, fixed `2de5a011`) was a **red herring** for the - Mite Scamp (its 0x62/63/64 attacks were always in the correct block; the planner - is unwired). Real #170 is the MoveTo/pending_motions chain above. -- `BeginTurnToHeading`'s `if (motions_pending) return` guard is **retail-faithful** — - do NOT patch it. Fix the DRAIN so the queue empties, don't remove the guard. -- The per-frame `apply_current_movement` deletion is the ROOT fix for the flood - (retail dispatches per UM). Do NOT re-add it. - -## Key file:line map - -- `GameWindow.cs` ~9982 (grounded remote-NPC branch; the deleted per-frame - apply_current_movement + the kept `get_state_velocity` velocity refresh); - ~10306/10309 (the pending-motions DRAIN: `Manager.AnimationDone` per AnimDone - hook + `Manager.UseTime`); ~4251 `EnsureRemoteMotionBindings` (MoveToManager + - TargetManager voyeur wiring, `getSelfId=serverGuid` correct). -- `MoveToManager.cs`: `BeginMoveForward:741`, `BeginTurnToHeading:821` - (`if (_interp.MotionsPending()) return` at ~833), `HandleTurnToHeading:1164`, - `HandleUpdateTarget:1229`, `MoveToObject_Internal:1333`, `UseTime:953`, - `CancelMoveTo:1475`. -- `MotionInterpreter.cs`: `AddToQueue:2297` (pending_motions add), - `MotionsPending:2298`, `MotionDone:2318` (pops one head), - `ApplyInterpretedMovement:2920` (style→forward→sidestep-stop→turn-stop), - `StopInterpretedMotion:3254` (adds `Ready` on success, line 3292). -- `CMotionTable.cs`: `StopSequenceMotion:559` (false for redundant stop), - `StopObjectMotion:640`, `GetObjectSequence` (Branch 1 substate write; Branch 3 - action overlay — verified faithful). -- `MotionTableManager.cs`: `AnimationDone:290`, `CheckForCompletedMotions:322`, - `UseTime:342`, `PerformMovement:409` (StopInterpreted → AddToQueue(Ready,ticks)). - -## Session gotchas -- Client + retail both hit the SAME local ACE (127.0.0.1:9000). acdream char - `+Acdream` (0x5000000A) spawns in the wilderness (~0xADAF) with Mite Scamps. -- Two workflow tracers returned junk/schema-cap this session; the synthesis+verify - carried it BUT its "run cycle from mt-6/BeginMoveForward" map was later REFUTED for - attack-in-place (BeginMoveForward=0) and CONFIRMED only for the CHASE — always - trace the CHASE scenario, and trust the live cdb over the decomp synthesis. diff --git a/docs/research/2026-07-04-170-pickup-prompt.md b/docs/research/2026-07-04-170-pickup-prompt.md deleted file mode 100644 index 2e66cb1f..00000000 --- a/docs/research/2026-07-04-170-pickup-prompt.md +++ /dev/null @@ -1,55 +0,0 @@ -# Pickup prompt — #170 "sustain the creature run" (paste into a fresh session) - -Read `docs/research/2026-07-04-170-creature-run-handoff.md` first — it is the SSOT -for this task and carries the full evidence, the retail cdb numbers, the apparatus, -and the DO-NOT-RETRY list. Then continue #170. - -**Where we are:** the primary #170 bug is FIXED and user-verified at `427332ac` -(branch `claude/vigorous-joliot-f0c3ad`). A per-frame `apply_current_movement` -re-dispatch was flooding `CMotionInterp.pending_motions` to ~1.3M entries, which -kept `MotionsPending()` permanently true and blocked the MoveTo chase turn -(`BeginTurnToHeading`), so a chasing creature slid in an idle+attack pose instead -of running. Deleting that per-frame call (GameWindow ~9992) dropped the queue to -depth ~1 (add≈done), killed the "stuck in attack animation", and the run cycle now -installs (`BeginMoveForward` 1→10). The "stuck attack" is confirmed gone by the user. - -**Your job — the ONE remaining residual: "sustain the run."** The run isn't -sustained yet: `BeginTurnToHeading` is still blocked `motionsPending=True` ~94% of -the time because a small **`Ready` (0x41000003) stop-node backlog** keeps -`pending_motions` from ever fully emptying between swings. acdream gets ~10 -run-starts vs retail's 21, so the creature now twitches-forward + glides instead of -a clean run-then-stop. Retail hits `add_to_queue == MotionDone` EXACTLY (cdb-proven); -acdream's `Ready` stop-nodes drain a beat slower. - -**Do this, in order:** -1. Re-read the handoff's "residual" + "DO-NOT-RETRY" sections. In particular: the - `BeginTurnToHeading` `if (motions_pending) return` guard is retail-faithful — DO - NOT patch it; fix the DRAIN so the queue empties like retail. -2. **Retail is the oracle** (Step -1). Retail's cdb is available; the binary matches - `refs/acclient.pdb`. Trace the `Ready`-stop drain on a live chasing monster to - settle the two hypotheses in the handoff: (a) do acdream's `Ready` stop entries - carry `outTicks > 0` (so they wait for `AnimationDone` instead of the per-frame - 0-tick `CheckForCompletedMotions`) while retail's are 0-tick? (b) does retail fire - `CheckForCompletedMotions`/`MotionDone` more aggressively than acdream's - once-per-frame `Manager.UseTime`? Reuse `scratchpad/cdb-drain.cdb` + - the addrs in the handoff. Ask the user to make a monster chase their retail char - during the trace. -3. The env-gated acdream probes are already in place (`ACDREAM_MVTO_DIAG=1`) — - `[drainq]` dumps the queue contents (this is what named `Ready` as the lingering - node). Capture a chase (aggro + RUN AWAY) to compare against retail. -4. Implement the faithful drain fix (verbatim-ported R2/R3 machinery — get it EXACTLY - right, no redesign; this is the revert-prone area). Acceptance: acdream - `add==done` (queue fully empties between swings) → `BeginMoveForward ≈ MoveToObject` - per chase → creature runs to close distance, plants to swing. **Visual-gate with - the user (retail side-by-side)** — that is the acceptance test. -5. When it lands + is visually confirmed: **STRIP all the temporary #170 probes** - (`s_mvtoDiag` in MoveToManager.cs, `s_drainDiag` in MotionInterpreter.cs, the - `[npc-tick]` lines + the `UM ↳ actions` dump in GameWindow.cs), move #170 to - Recently-closed in `docs/ISSUES.md` with the SHAs, and update the animation - research/memory index. - -**Gotchas:** both acdream (+Acdream char) and retail hit the same local ACE -(127.0.0.1:9000); trace the CHASE (player fleeing), not attack-in-place — no chase ⇒ -no `[mvto]` lines; PowerShell Tee logs are UTF-16 (`tr -d '\000'` before grep); user -manages client lifecycle (graceful close). Trust the live cdb over the earlier -workflow synthesis (its "run from mt-6" map was refuted for attack-in-place). diff --git a/docs/research/2026-07-04-171-pickup-prompt.md b/docs/research/2026-07-04-171-pickup-prompt.md deleted file mode 100644 index fdebbae2..00000000 --- a/docs/research/2026-07-04-171-pickup-prompt.md +++ /dev/null @@ -1,52 +0,0 @@ -# Pickup prompt — #171 sticky melee / R5-V3 (paste into a fresh session) - -Read `docs/research/2026-07-04-171-sticky-melee-handoff.md` first — it is the -SSOT for this task (root causes, retail anchors, approved scope, gotchas). -Then implement R5-V3. - -**Context:** #170 (chase slide) is CLOSED — gate passed at `4cad626f`; do not -reopen that machinery. During its gate the user observed #171: in a pack -melee, monsters sit partly inside each other with slightly stale facings vs -retail on the same ACE. Investigated + **fix approved by the user 2026-07-04**. - -**The two causes (both R5-V3 scope, already register-tracked):** -1. Sticky melee is a no-op (TS-39): ACE arms melee chases with `Sticky`; - retail's arrival hands off to `PositionManager::StickTo` → - `StickyManager::adjust_offset` (0x00555430, per-tick 0.3 m gap + facing - tracking). Our `MoveToManager.StickTo`/`Unstick` seams are unbound — - attackers freeze at stale arrival poses. -2. Arrival radii are zero: `getOwnRadius: () => 0f` (the R5-V3 pin in - `EnsureRemoteMotionBindings`) + `RouteServerMoveTo` never sets - `MovementStruct.Radius/Height` — retail/ACE arrive edge-to-edge with - setup-derived radii (ACE `PhysicsObj.MoveToObject` reads the TARGET's - PartArray radius/height). - -**Do, in order:** -1. Grep the named decomp for `StickyManager::` + `PositionManager::` bodies - (adjust_offset 0x00555430; per-tick anchors already pinned: - `PositionManager::adjust_offset` @0x00512d0e inside UpdatePositionInternal, - `PositionManager::UseTime` @0x005159b3 in UpdateObjectInternal). - Pseudocode, then port verbatim; cross-check - `references/ACE/Source/ACE.Server/Physics/Managers/{StickyManager,PositionManager}.cs`. - Groundwork already in tree: `MoveToMath.CylinderDistanceNoZ` / - `GlobalToLocalVec` / `NormalizeCheckSmall`; `ConstraintManager` (R5-V1) is - the port-style reference. Do NOT arm ConstraintManager (#167 separate). -2. Bind `StickTo`/`UnStick` in `EnsureRemoteMotionBindings` AND the player - wiring; wire PositionManager into the tick at the retail-matching points. -3. Thread real cylsphere radii (own from the entity's Setup; target resolved - at `RouteServerMoveTo`'s MoveToObject branch). Sweep remote + player. -4. Same-commit register bookkeeping: retire TS-39, update the radius pin. -5. Extend `RemoteChaseEndToEndHarnessTests` with a sticky scenario (arrive → - target strafes → follower tracks gap + facing; UnStick on next - PerformMovement). Harness bodies MUST mirror the live RemoteMotion - construction (InWorld=true, RemoteWeenie — see RemoteChaseDrainBisectTests). -6. `dotnet build` + full `dotnet test` green → commit → launch for the user's - visual gate: pack melee side-by-side vs retail, strafe around the pack; - attackers should reshuffle + keep facing like retail. Acceptance is retail - PARITY — some overlap is ACE-server-side and shows on retail too. - -**Gotchas:** revert-prone remote-motion area — verbatim port, no redesign; -per-entity probes must print the guid (memory -`feedback_probe_identity_attribution`); PowerShell Tee logs are UTF-16 -(`tr -d '\000'` before grep); the user manages client lifecycle (graceful -close); both clients hit the same local ACE (127.0.0.1:9000). diff --git a/docs/research/2026-07-04-171-sticky-melee-handoff.md b/docs/research/2026-07-04-171-sticky-melee-handoff.md deleted file mode 100644 index 3fede6af..00000000 --- a/docs/research/2026-07-04-171-sticky-melee-handoff.md +++ /dev/null @@ -1,126 +0,0 @@ -# Handoff — #171 group-melee interpenetration + facing drift (R5-V3: StickyManager + arrival radii) — 2026-07-04 - -Fresh session picks up HERE for #171. Worktree `vigorous-joliot-f0c3ad`, branch -`claude/vigorous-joliot-f0c3ad`, tree CLEAN after the #170 close-out -(`4cad626f`). The fix scope below is USER-APPROVED ("Ok I approve", -2026-07-04). Pickup prompt: `docs/research/2026-07-04-171-pickup-prompt.md`. - -## Symptom (user report, #170 gate session) - -In a pack melee vs the player, acdream monsters end up **partly inside each -other** with **slightly stale facings**, vs retail on the same local ACE. -The #170 chase itself (turn → sustained run → plant → swing) is CLOSED and -gate-passed — do not reopen that machinery. - -## Root causes (code-grounded, investigated 2026-07-04) - -1. **Sticky melee is a no-op — register TS-39.** ACE arms EVERY melee chase - with `Sticky | UseFinalHeading | MoveAway | FailWalk` - (`references/ACE/Source/ACE.Server/WorldObjects/Monster_Navigation.cs:406-419`). - The retail arrival chain: `MoveToManager::BeginNextNode` (sticky bit set, - queue empty) reads `SoughtObjectId/Radius/Height` BEFORE `CleanUp` zeroes - them (that ordering IS already ported — `MoveToManager.cs` ~700-714) and - calls `PositionManager::StickTo(tlid, radius, height)`; from then on - `StickyManager::adjust_offset` (0x00555430) runs per tick and holds a - ~0.3 m edge gap + facing against the moving target. Every - `MovementManager::PerformMovement` head calls `unstick_from_object` → - `PositionManager::UnStick` (our `MoveToManager.PerformMovement:414` - invokes the seam). acdream's `StickTo`/`Unstick` seams are UNBOUND - (GameWindow `EnsureRemoteMotionBindings` + the player wiring bind - neither) → attackers complete-and-freeze at stale arrival poses until the - next wire re-arm. This is the direct cause of BOTH observed symptoms. - -2. **Arrival radii are zero.** Retail/ACE arrive EDGE-TO-EDGE: ACE - `PhysicsObj.MoveToObject` (`references/ACE/…/Physics/PhysicsObj.cs:936-956`) - reads the TARGET's `PartArray.GetRadius()/GetHeight()` and passes them into - `MoveToObject_Internal`; the mover's own radius feeds - `GetCurrentDistance`'s `cylinder_distance` when `UseSpheres` (ACE default - TRUE, `DistanceToObject` default 0.6). acdream: - - `GameWindow.EnsureRemoteMotionBindings` binds `getOwnRadius/getOwnHeight - : () => 0f` — the comment IS the R5-V3 pin ("setup cylsphere radius lands - with R5-V3"); - - `GameWindow.RouteServerMoveTo` never sets `MovementStruct.Radius/Height` - (target radii) → 0. - Net: every attacker closes ~one body-radius deeper than retail → dogpile. - -3. **Server-side caveat:** ACE has no monster-vs-monster avoidance; UP - hard-snaps plant bodies where ACE says (retail hard-snaps identically). - Some overlap is server-authoritative and visible on retail too — - **acceptance is retail parity, not zero overlap.** - -Ruled out: the between-snap collision sweep. Remotes run the full -`ResolveWithTransition` (GameWindow ~10100) and `CollisionExemption` keeps -creature cylinders collidable for non-viewer movers. - -## Approved fix scope (= the R5-V3 slice, roadmap-aligned) - -1. **Port `StickyManager` verbatim** (grep `docs/research/named-retail/ - acclient_2013_pseudo_c.txt` for `StickyManager::` first — `StickTo`, - `UnStick`, `adjust_offset` 0x00555430, `HandleUpdateTarget`; cross-check - `references/ACE/Source/ACE.Server/Physics/Managers/StickyManager.cs`). - Groundwork ALREADY in tree (`MoveToMath`): `CylinderDistanceNoZ` (signed — - the adjust_offset gap math), `GlobalToLocalVec`, `NormalizeCheckSmall` — - all documented as adjust_offset consumers. `ConstraintManager` (the sibling - PositionManager member) was ported R5-V1 (TS-35, unarmed — #167 is - SEPARATE, do not arm it here). -2. **PositionManager**: the facade that owns Sticky + Constraint - (`references/ACE/…/Managers/PositionManager.cs`). Retail per-tick anchors - pinned this session from the named decomp: - - `PositionManager::adjust_offset` called inside - `CPhysicsObj::UpdatePositionInternal` @0x00512d0e (BEFORE process_hooks); - - `PositionManager::UseTime` called in `UpdateObjectInternal` @0x005159b3 - (AFTER `CPartArray::HandleMovement`). - Wire the acdream equivalents at the matching points of the remote tick - (GameWindow `TickAnimations`) and the player path. -3. **Bind the seams**: `MoveToManager.StickTo` → PositionManager.StickTo; - `MoveToManager.Unstick` → PositionManager.UnStick — in - `EnsureRemoteMotionBindings` AND the player wiring (`EnterPlayerModeNow` - area, ~13128). The TargetManager voyeur fan-out already delivers target - info; the R5-V2 comment at GameWindow ~4327 says exactly this: - "PositionManager sticky joins the fan-out in V3" — StickyManager is a - voyeur consumer in retail (its own HandleUpdateTarget). -4. **Thread real radii**: own radius/height from the entity's Setup (the - spawn path already reads `setup.Radius`/`setup.Height` for collision - registration, GameWindow ~4187); target radius/height at the - `RouteServerMoveTo` MoveToObject branch (resolve the target entity's - setup — retail reads the TARGET object, not the wire). Sweep BOTH the - remote and player MoveToManager bindings. -5. **Register bookkeeping (same commit):** retire TS-39; retire/narrow the - radius pin note; new rows for any adaptation introduced. -6. **Conformance:** extend `RemoteChaseEndToEndHarnessTests` with a sticky - scenario — arm a sticky MoveToObject, arrive, then MOVE the target - sideways: assert the follower tracks (gap ≈ stick distance, facing follows) - and `UnStick` fires on the next PerformMovement head. The harness already - models the full tick order; add a PositionManager step where retail has it. - -## Gotchas - -- **Revert-prone area** (remote motion). Follow the workflow: grep named - decomp FIRST, pseudocode, port verbatim, ACE cross-check, harness, then - wire. No redesigns; the two prior #170 reverts were redesigns. -- The harness gotcha from #170: any new harness body MUST replicate the live - `RemoteMotion` construction (`InWorld=true`, `RemoteWeenie`, - Contact|OnWalkable|Active) or the TS-40 guard strips links and everything - wedges (`RemoteChaseDrainBisectTests` documents the shape). -- Per-entity probes must print the guid (memory: - `feedback_probe_identity_attribution` — the #170 misread). -- `StickyManager::adjust_offset` sign semantics: inside the gap the signed - distance goes NEGATIVE and the per-tick delta inverts (backs off) — ACE - StickyManager.cs:156, already noted on `MoveToMath.CylinderDistanceNoZ`. -- Do NOT arm ConstraintManager (#167 — two unknown x87 constants). -- Visual gate at the end: pack melee side-by-side vs retail; the user strafes - around the pack; attackers should reshuffle + keep facing like retail. - -## Where things are (quick map) - -- `MoveToManager.cs`: `PerformMovement:411` (Unstick head), `BeginNextNode` - sticky arrival ~700-714, `GetCurrentDistance` (UseSpheres cylinder math). -- `MoveToMath.cs`: `CylinderDistance`, `CylinderDistanceNoZ`, - `GlobalToLocalVec`, `NormalizeCheckSmall`. -- `GameWindow.cs`: `EnsureRemoteMotionBindings` ~4251 (seams + radius 0f pin), - `RouteServerMoveTo` ~4457 (MovementStruct — Radius/Height unset), remote - tick `TickAnimations` (PositionManager wiring points), player wiring ~13128. -- `ConstraintManager.cs` (R5-V1) — PositionManager sibling, reference for the - port style. -- Register rows: TS-39 (sticky seams), TS-35 (ConstraintManager), TS-41/42 - (#170 rows, adjacent machinery). diff --git a/docs/research/2026-07-04-r5v5-facade-pickup-prompt.md b/docs/research/2026-07-04-r5v5-facade-pickup-prompt.md deleted file mode 100644 index 4bf469b6..00000000 --- a/docs/research/2026-07-04-r5v5-facade-pickup-prompt.md +++ /dev/null @@ -1,61 +0,0 @@ -# Pickup prompt — R5-V5: MovementManager facade (paste into a fresh session) - -> **DONE 2026-07-05.** Implemented as specified; suite 4052 green with the -> protected suites unmodified. Arc close-out lives in -> `2026-07-03-r5-managers/r5-wiring-handoff.md` (top banner). - -Read `docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md` first (the -§V4 status note + the facade paragraph are the SSOT), then -`docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md` (the -retail struct + method inventory is already decompiled — do NOT re-decompile). -Then implement R5-V5. - -**Context:** the R5 arc is one slice from done. Shipped + user-gated -2026-07-04: V3 sticky melee (#171, three slices `5bd2b8bc`/`7a823176`/ -`69966950` — TS-39 retired, TS-43/TS-44/AP-82 added) and the V4 behavioral -items (`f423884b` — head style-on-change for all movement types, #164 -action-replay Autonomous bit, mt-0 wire flags 0x1 stick_to_object / 0x2 -StandingLongJump). Worktree `vigorous-joliot-f0c3ad`, branch -`claude/vigorous-joliot-f0c3ad`. - -**The slice (STRUCTURAL — zero behavior change):** retail gives every -entity ONE `MovementManager` (acclient.h; decomp 0x00524xxx) that owns the -`motion_interpreter` + `moveto_manager` the R4/R5 work ported. acdream -carries them as loose per-entity objects (`RemoteMotion.Motion`/`.MoveTo`, -`PlayerMovementController.Motion`/`.MoveTo`) wired by hand at three sites -(`EnsureRemoteMotionBindings`, `EnterPlayerModeNow`, the harness). Build the -owner class in `src/AcDream.Core/Physics/Motion/MovementManager.cs`: - -1. Owns MotionInterpreter + MoveToManager (lazy `MakeMoveToManager` per - retail 0x005245b3) + the relay methods whose call shapes are already in - the decomp doc: `UseTime` (minterp → moveto order), `HitGround` (minterp - FIRST then moveto — 0x00524300, already wired inline at 3 sites), - `HandleExitWorld`, `CancelMoveTo`, `HandleUpdateTarget` (→ moveto). -2. Repoint the three wiring sites to construct/hold ONE MovementManager per - entity; the existing seam bindings (sinks, StickTo/Unstick, - UnstickFromObject, PositionManager handoff) move onto/through it - unchanged. GameWindow keeps the wire unpack (Core.Net types stay out of - Core.Physics). -3. Do NOT reshuffle the per-tick order (TS-42's documented inversion is R6 - scope, not this slice), do NOT touch the #170/#171 gate-passed machinery, - do NOT absorb RouteServerMoveTo/the routing heads into Core. - -**Acceptance:** `dotnet build` + full `dotnet test` green with the -183-case/funnel/moveto/chase/sticky suites UNMODIFIED (a structural slice -that needs a test edit is a red flag — stop and reassess). Register: no new -rows expected; update row source columns that name the old wiring shape. -No visual gate needed (zero behavior change) — but launch once and confirm -a creature still chases + a door still opens before calling it done. - -**After this slice:** the R5 arc is DONE — update the roadmap/milestones -line, write the arc close-out in the wiring handoff, then return to M1.5's -critical path (#137 dungeon collision, #138 teleport-OUT, A7 lighting). - -**Gotchas:** revert-prone area — structural moves only, no "while I'm -here" improvements; the three wiring sites are load-bearing and -comment-dense (keep the retail-anchor comments with the code they -describe); the harness (`RemoteChaseEndToEndHarnessTests`) mirrors -GameWindow's wiring field-for-field — update its construction to the -facade in the SAME commit or the mirror lies; PowerShell Tee logs are -UTF-16 (`tr -d '\000'` before grep); the user manages client lifecycle -(graceful close; if a rebuild is locked by a running client, ask). diff --git a/docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md b/docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md deleted file mode 100644 index a68144fb..00000000 --- a/docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md +++ /dev/null @@ -1,236 +0,0 @@ -# CCylSphere collision family — retail pseudocode (port prep) - -**Date:** 2026-07-05 · **Trigger:** the Holtburg town-network portal platform -(stab `0xC0A9B465`, Setup `0x020019E3`, CylSphere r=2.597 m h=0.256 m) blocks -the player with an endless rim slide instead of the retail step-up-onto-top. -Surfaced the moment #149 (`4cf6eeb`) started registering BSP-less stab -CylSpheres — the collision SHAPE is right; the RESPONSE family was never -ported. Feeds #137 (dungeon door feet flow through the same dispatcher). - -**Sources:** named-retail pseudo-C (addresses below) = ground truth; -`references/ACE/Source/ACE.Server/Physics/CylSphere.cs` = cross-reference -(settles BN x87 garbles; one ACE bug found, noted in §8). - -## Retail function inventory - -| Function | Address | pseudo-C line | -|---|---|---| -| `CCylSphere::intersects_sphere(CTransition*)` — dispatcher | `0x0053b440` | :324558 | -| `CCylSphere::intersects_sphere(Position*, float scale, CTransition*)` — wrapper | `0x0053b8f0` | :324744 | -| `CCylSphere::collides_with_sphere` | `0x0053a880` | :323943 | -| `CCylSphere::normal_of_collision` | `0x0053ab50` | :324102 | -| `CCylSphere::collide_with_point` | `0x0053acb0` | :324173 | -| `CCylSphere::slide_sphere` | `0x0053b2a0` | :324502 | -| `CCylSphere::step_sphere_up` | `0x0053b310` | :324516 | -| `CCylSphere::land_on_cylinder` | `0x0053b3d0` | :324542 | -| `CCylSphere::step_sphere_down` | `0x0053a9b0` | :324032 | -| `COLLISIONINFO::set_contact_plane(plane, is_water)` | `0x00509d80` | :271925 | - -## 1. Wrapper (0x0053b8f0) — globalize the cylinder - -``` -intersects_sphere(cyl, Position* objPos, float scale, CTransition* t): - SPHEREPATH::cache_localspace_sphere(&t->sphere_path, objPos, 1f) - world_cyl = { low_pt: objPos.localtoglobal(cyl.low_pt * scale), - radius: cyl.radius * scale, - height: cyl.height * scale } - return world_cyl.intersects_sphere(t) // axis stays world-Z -``` - -**acdream mapping:** `ShadowEntry` already stores the globalized base point -(`Position` = entity pos + rotated scaled local offset, registration sites in -`GameWindow.cs`) and pre-scaled Radius/CylHeight — the wrapper's work is done -at registration. `cache_localspace_sphere` matters only for -`localspace_pos` (used by step_sphere_up's normal rotation, §6). - -## 2. collides_with_sphere (0x0053a880) — pure overlap test - -``` -collides_with_sphere(cyl, CSphere* sphere, Vector3* disp, float radsum): - // disp = sphere.center − cyl.low_pt (caller computes) - if (disp.x² + disp.y² <= radsum²) // XY overlap - halfH = cyl.height * 0.5 - if (|halfH − disp.z| <= sphere.radius − F_EPSILON + halfH) // Z band - return 1 - return 0 -``` - -`radsum` at every call site = `cyl.radius − F_EPSILON + sphere.radius` -(ε shaved ONCE, in the dispatcher preamble). The ε is what makes "resting -exactly on the top" a non-overlap, so landings settle instead of re-colliding. - -## 3. Dispatcher (0x0053b440) - -``` -intersects_sphere(cyl, CTransition* t): // cyl in world frame - sp = t.sphere_path; oi = t.object_info - s0 = sp.global_sphere[0]; disp0 = s0.center − low_pt - if sp.num_sphere > 1: s1 = sp.global_sphere[1]; disp1 = s1.center − low_pt - radsum = cyl.radius − F_EPSILON + s0.radius - - // ── branch 1: placement / ethereal — detection only ── - if (sp.insert_type == PLACEMENT_INSERT || sp.obstruction_ethereal): - if collides(s0, disp0) → COLLIDED - if num_sphere>1 && collides(s1, disp1) → COLLIDED - return OK - - // ── branch 2: step-down probe — land on the top ── - if (sp.step_down): return step_sphere_down(t, s0, disp0, radsum) - - // ── branch 3: walkable probe — cylinder occupancy blocks ── - if (sp.check_walkable): - if collides(s0, disp0) → COLLIDED - if num_sphere>1 && collides(s1, disp1) → COLLIDED - return OK - - // ── branch 4: normal sweep (collide flag clear) ── - if (!sp.collide): - if (oi.state & (CONTACT|ON_WALKABLE)): // grounded - if collides(s0, disp0) → step_sphere_up(t, s0, disp0, radsum) - if num_sphere>1 && collides(s1, disp1) - → slide_sphere(t, s1, disp1, radsum, sphereNum=1) // §8: retail passes disp1 - elif (oi.state & PATH_CLIPPED): - if collides(s0, disp0) → collide_with_point(t, s0, disp0, radsum, 0) - else: // airborne - if collides(s0, disp0) → land_on_cylinder(t, s0, disp0, radsum) - if num_sphere>1 && collides(s1, disp1) - → collide_with_point(t, s1, disp1, radsum, 1) - return OK - - // ── branch 5: collide-flag re-test — exact-TOI cap landing ── - if collides(s0,disp0) || (num_sphere>1 && collides(s1,disp1)): - movement = sp.global_curr_center[0] − s0.center − block_offset(cur→check) - if |movement.z| < F_EPSILON → COLLIDED - timecheck = (height + s0.radius − disp0.z) / movement.z - offset = movement * timecheck - if radsum² < |xy(offset + disp0)|² → OK // rewound off the cap - t2 = (1 − timecheck) * sp.walk_interp - if t2 >= sp.walk_interp || t2 < −0.1 → COLLIDED - pt = s0.center + offset; pt.z −= s0.radius - ci.set_contact_plane(Plane(n=(0,0,1), d=−pt.z), is_water=1) // literal 1, §7 - ci.contact_plane_cell_id = sp.check_pos.objcell_id - sp.walk_interp = t2 - sp.add_offset_to_check_pos(offset) - return ADJUSTED - return OK -``` - -State bits (verified against our `ObjectInfoState`): CONTACT=0x1, -ON_WALKABLE=0x2, PATH_CLIPPED=0x8, PERFECT_CLIP=0x40. - -## 4. step_sphere_down (0x0053a9b0) — land on the top during a step-down probe - -``` -step_sphere_down(t, s0, disp0, radsum): - if !collides(s0,disp0) && !(num_sphere>1 && collides(s1,disp1)) → OK - stepScale = sp.step_down_amt * sp.walk_interp - if |stepScale| < F_EPSILON → COLLIDED - deltaz = height + s0.radius − disp0.z // lift so bottom rests on top - interp = (1 − deltaz / stepScale) * sp.walk_interp // divisor = stepScale (BN garbled; ACE) - if interp >= sp.walk_interp || interp < −0.1 → COLLIDED - contactPt = (s0.center.x, s0.center.y, s0.center.z + deltaz − s0.radius) - ci.set_contact_plane(Plane(n=(0,0,1), d=−contactPt.z), is_water=1) // §7 - ci.contact_plane_cell_id = sp.check_pos.objcell_id - sp.walk_interp = interp - sp.add_offset_to_check_pos((0,0,deltaz)) - return ADJUSTED -``` - -This is THE missing piece that made step-up-onto-a-wide-cylinder impossible: -`CTransition::step_up`'s internal step-down probe needs branch 2 to produce a -walkable contact plane ON the cylinder top. - -## 5. normal_of_collision (0x0053ab50) - -``` -normal_of_collision(cyl, sp, sphere, dispCheck, radsum, sphereNum, out n) → bool definite: - dispCurr = sp.global_curr_center[sphereNum] − low_pt - if (radsum² < dispCurr.x² + dispCurr.y²): // curr was XY-OUTSIDE → side hit - n = (dispCurr.x, dispCurr.y, 0) // radial, horizontal - // definite unless the contact could actually be a diagonal cap hit: - zBandOverlapAtCurr = |halfH − dispCurr.z| <= sphere.radius − F_EPSILON + halfH - noZMovement = |dispCurr.z − dispCheck.z| <= F_EPSILON - return zBandOverlapAtCurr || noZMovement - // curr was XY-INSIDE the footprint → cap hit - n = (0, 0, (dispCheck.z − dispCurr.z <= 0) ? +1 : −1) // descending → top (+1) - return true -``` - -Cap polarity settled by ACE + geometry (BN's x87 branch rendering is -untrustworthy here — [[feedback_bn_decomp_field_names]] class 2). - -## 6. step_sphere_up (0x0053b310) / land_on_cylinder (0x0053b3d0) / slide_sphere (0x0053b2a0) - -``` -step_sphere_up(t, s0, disp0, radsum): - if (oi.step_up_height < s0.radius + height − disp0.z) // too tall - → slide_sphere(t, s0, disp0, radsum, 0) - definite = normal_of_collision(..., 0, out n) - if normalize_check_small(n) → COLLIDED - nWorld = localspace_pos.localtoglobalvec(n) // rotate by the OBJECT's frame - if CTransition::step_up(t, nWorld) → OK - else → sp.step_up_slide(t) - -land_on_cylinder(t, s0, disp0, radsum): // airborne foot hit - normal_of_collision(..., 0, out n) - if normalize_check_small(n) → COLLIDED - sp.set_collide(n) // backup + Collide flag - sp.walkable_allowance = LANDING_Z (0.0871557) - return ADJUSTED - -slide_sphere(t, sphere, disp, radsum, sphereNum): - normal_of_collision(..., sphereNum, out n) - if normalize_check_small(n) → COLLIDED - return CSphere::slide_sphere(sphere, sp, ci, n, sp.global_curr_center[sphereNum]) -``` - -The airborne landing closes through the retry loop: land_on_cylinder -(ADJUSTED, sets `sp.collide`) → next attempt → branch 5 exact-TOI rests the -sphere on the top + CP → next attempt → ε-shaved overlap now misses → OK → -TransitionalInsert Phase 3 `sp.Collide` placement re-test validates on the -CP → landing completes. - -## 7. collide_with_point (0x0053acb0) — PathClipped / head-sphere hits - -Port per ACE `CylSphere.CollideWithPoint` verbatim (self-contained TOI math): -non-PerfectClip movers → `set_collision_normal` + COLLIDED. PerfectClip → -exact time-of-impact reposition (`add_offset_to_check_pos`) + ADJUSTED, with -the not-definite branch deriving cap-vs-side from the movement. - -## 8. Divergences + settled ambiguities (register-relevant) - -1. **`is_water=1` on cylinder-top contact planes is RETAIL** (literal 1 at - 0x0053aae2 and the branch-5 site; `set_contact_plane` 0x00509d80 stores - arg3 → `contact_plane_is_water`). Port verbatim; do not "fix". -2. **ACE bug (do NOT copy):** ACE's grounded head-sphere leg passes the FOOT - disp to `SlideSphere`; retail 0x0053b843 passes the HEAD disp (`x_2`). - Retail wins. (Class: [[feedback_bn_decomp_field_names]] #3 — ACE decode - wrong in a branch ACE rarely exercises.) -3. **Block offset in branch 5:** retail subtracts the cur→check landblock - offset; acdream's physics frame is continuous world-space → offset = 0. - Standing frame adaptation (same as SlideSphere's gDelta note). -4. **Ethereal targets:** branch 1 returns COLLIDED on overlap even for - ethereal; passability comes from the caller's Layer-2 override - (pc:276961-276989, non-static + !step_down → forced OK) plus the #150 - step-down skip. The previous port consumed ObstructionEthereal with an - early OK before any test — response-equivalent for non-static targets, - but branch 1 is the faithful shape and also gives placement inserts the - retail blocked-by-cylinder semantics. Ported faithfully now. -5. **`normalize_check_small`** = normalize; returns true (fail) when |v| < ε - before normalizing — maps to `LengthSquared() < EpsilonSq` guard. -6. **step_sphere_up normal rotation:** retail rotates the collision normal by - the target OBJECT's frame (`localspace_pos` = the object's Position cached - by the wrapper) before `CTransition::step_up`. For yaw-only AC objects - this only affects yawed radial normals; ported faithfully via - `Vector3.Transform(n, obj.Rotation)`. - -## 9. acdream port surface - -`Transition.CylinderCollision` (TransitionTypes.cs) becomes the branch-4/5 -dispatcher body; new private siblings `CylCollidesWithSphere`, -`CylNormalOfCollision`, `CylStepSphereUp`, `CylStepSphereDown`, -`CylSlideSphere`, `CylLandOnCylinder`, `CylCollideWithPoint`. Callers -unchanged (`FindObjCollisionsInCell` Cylinder branch; the BspOnlyDispatch -gate and the #150 ethereal step-down skip sit ABOVE this dispatch and are -unaffected). `DoStepUp` (= CTransition::step_up, A6.P6) and -`SpherePath.StepUpSlide` are reused as-is. diff --git a/docs/research/2026-07-05-m15-dungeon-pickup-prompt.md b/docs/research/2026-07-05-m15-dungeon-pickup-prompt.md deleted file mode 100644 index e01bcf6f..00000000 --- a/docs/research/2026-07-05-m15-dungeon-pickup-prompt.md +++ /dev/null @@ -1,56 +0,0 @@ -# Pickup prompt — M1.5 dungeon track: #137 collision first (paste into a fresh session) - -Read `claude-memory/project_physics_collision_digest.md` FIRST (the collision -SSOT + DO-NOT-RETRY table — binding), then the ISSUES entries for **#137**, -**#153**, **#138** (in that order), plus the prior-art closed fixes **#150** -(ethereal door step-down skip), **#151** (portal-less wall shells), **#152** -(building-cache re-base). Then work the track below. - -**Context:** the R5 movement-manager arc is DONE and merged — `main` == -`claude/vigorous-joliot-f0c3ad` @ `aa734f34` (facade close-out: -`docs/research/2026-07-03-r5-managers/r5-wiring-handoff.md`). M1.5's remaining -critical path is the dungeon track: **#137 dungeon collision → #153/#138 -teleport-out residuals → A7 dungeon lighting** (A7 last — its 5-dungeon-site -capture protocol needs dungeons walkable first). - -**Slice 1 — #137 (dungeon collision at doors + wall openings), oracle-first:** -1. AUTONOMOUS PREP: wire nothing yet. Re-read the EnvCell collision path - (`CellTransit`, the door apparatus, `ShadowObjectRegistry` per-cell - registration — A6.P4 architecture) and check whether the #150 ethereal - step-down skip (`TransitionTypes.cs`, retail pc:276795-276806) already - covers EnvCell dungeon doors or only building doors. Grep named-retail - before any fresh decompilation. -2. REPRO (needs the user in-world): the 0x0007 dungeon, with - `ACDREAM_PROBE_RESOLVE=1` (+ `ACDREAM_CAPTURE_RESOLVE=` if a replay - diff is warranted). Characterize per site: which door / which opening, - expected vs actual, open-vs-closed state. The issue text says symptoms are - NOT yet characterized — do not fix ahead of the repro. -3. Fix per the digest workflow (retail decomp is the oracle; ACE second). - Acceptance: doors block/pass per open/closed state, wall openings pass, - solid walls block — matching retail in 0x0007. Register row per deviation, - same commit. - -**Slice 2 — #153 (portals only work once / teleport-onto-unstreamed-edge -runaway), apparatus-first:** the issue is REOPENED with a narrowed residual -(arrival on a NOT-yet-streamed landblock NEAR AN EDGE → cell-march + Z -free-fall + garbage outbound cell). The issue text itself says **DO NOT -guess-patch** — the original #145 burned 5 attempts. Build the capture + -anchor/guard diagnostic at the crossing first. ⚠️ The issue's "likely fix -shape" is a streaming-gap HOLD — but the user REVERTED a previous teleport -hold as "shaky and bandaid" (`feedback_no_holds_for_slow_foundation`). If the -evidence really does point at a hold (as retail's synchronous-load -equivalent), STOP and get explicit user approval with the retail anchor in -hand; do not ship it on your own authority. - -**Then:** the #138 acceptance run (portal out → world + objects + avatar all -present across REPEATED round-trips, collision working — its component fixes -are shipped; #151/#152 closed the wall residuals; what's left is the gate), -and A7 lighting (#154 dungeon-dim, #142 windowed interiors, #143 portal -swirl, #140 Fix D) as the M1.5 closer. - -**Gotchas:** visual gates + repro sessions need the user (plan autonomous -decomp/probe prep around their availability); PowerShell Tee logs are UTF-16 -(`tr -d '\000'` before grep); the user manages client lifecycle (plain -`dotnet run`, graceful close, ask if a rebuild is locked); probes: -`ACDREAM_PROBE_RESOLVE` / `ACDREAM_PROBE_CELL` / `ACDREAM_CAPTURE_RESOLVE` / -`ACDREAM_PROBE_ENT` are all wired and DebugPanel-toggleable. diff --git a/docs/research/2026-07-05-mp0-baseline.md b/docs/research/2026-07-05-mp0-baseline.md deleted file mode 100644 index 581e9f3d..00000000 --- a/docs/research/2026-07-05-mp0-baseline.md +++ /dev/null @@ -1,94 +0,0 @@ -# MP0 baseline — frame-profiler capture, attribution verdict, gate decision - -**Date:** 2026-07-05. **Build:** Release, `ACDREAM_FRAME_PROF=1`, vsync off. -**Character:** `+Horan` (`0x5000000B`, testaccount2). **Logs:** `mp0-baseline.log` -(primary run: full route with 4 teleports, ~2 min) + `mp0-baseline-run3.log` -(supplemental: longer town session, same build) — both untracked in the worktree -root; the numbers below are transcribed from them. - -**Route (reconstructed from log markers):** login → Holtburg outdoor -(`0xA9B4`) → movement/turning → teleport 1 → **dungeon `0x0007`** (dungeon -collapse streaming path) → walk → teleport 2 → **outdoor town `0xCE94`** → -pan/walk → teleport 3 → **dungeon again** → walk → teleport 4 → Holtburg → -close (graceful). - -## The numbers (per 5-second `[frame-prof]` window) - -| State | cpu_ms p50 | p95 | p99 | max | gpu_ms p50 | alloc/frame p50 | GC gen0/gen1/gen2 per 5 s | -|---|---|---|---|---|---|---|---| -| Login settle (Holtburg) | 2.1 | 10.2 | **52.8** | **123.3** | 0.4 | 1.57 MB | 93 / **50** / **36** | -| Holtburg steady/turning | 1.6–2.0 | 2.2–2.6 | 2.8–3.7 | 15–20 | 0.4 | 1.58–1.60 MB | ~80 / 3–13 / 3–5 | -| Dungeon `0x0007` walk | **0.4–0.5** | 0.5–0.6 | 0.9 | 1.2–1.5 | 0.1 | 0.50–0.63 MB | 119 / **0** / **0** | -| Town `0xCE94` pan/walk | 2.9–3.6 | 4.0–5.0 | 7.2–15.7 | 26–37 | 1.2–1.6 | 2.5–3.0 MB | ~60 / 5–22 / 5–11 | -| Teleport→dungeon window | 2.8 | 3.3 | 14.1 | **211.2** | 1.3 | (max **75.7 MB** in one frame) | 62 / 11 / 10 | -| Teleport→outdoor windows | 1.2–2.9 | 3.4–3.8 | 4.5–7.0 | 33 | 0.2–1.5 | (max 20–31 MB single frame) | ~80–93 / 15–26 / 5 | -| Holtburg end-of-run moving | 2.6–2.9 | 3.3–5.1 | 8.5–**23.0** | 20–**87** | 2.5–2.7 | 2.14 MB | ~50–58 / 24–32 / 7–8 | -| (run 3) town views | 1.7–2.4 | 2.7–3.4 | 7.9–15.8 | 29–39 | 0.4–2.2 | 1.7–2.2 MB | ~70–109 / 17–32 / 6–11 | - -Stage attribution (`upd` / `upl` / `imgui`): **≈ 0.0 ms p50 in every window** -(occasional upd p95 0.1–1.7 during streaming). GPU never exceeded p95 3.7 ms. - -## Attribution verdict - -1. **Steady-state medians are far better than the spec §1 assumption.** The - spec assumed ~6 ms / ~165 FPS dense-town CPU. Captured: worst steady town - view is p50 3.3–3.6 ms (~280–300 FPS); Holtburg ~1.6–2.4 ms (~400–600 FPS); - dungeons 0.4–0.5 ms (~2000 FPS). **Caveat:** the canonical Fort - Tethana/Arwic "dense worst-case axiom view" was NOT in this route — the - §1 numbers came from there. The 300-FPS throughput goal is close to met in - the captured views but must be re-verified at that view before any - "target met" claim. -2. **The frame is CPU-render-side, as assumed.** GPU ≤ ~2.7 ms everywhere; - upd/upl/imgui ≈ 0 → virtually all CPU time is unattributed render-side - submission. The spec's MP3 thesis stands. -3. **The smoothness gap is real, and it is GC.** Every town window violates - the ≤16 ms goal at max (20–87 ms) and several at p99 (up to 23 ms), while - dungeon windows — the ONLY windows with **zero gen1/gen2 collections** — - are spike-free (max 1.5 ms). Steady-state allocation is **1.5–3.0 MB per - frame** (≈ 0.6–1.2 GB/s at these frame rates), driving gen2 collections - roughly 1–2 per second in towns. The correlation is airtight across all - 28 windows: spikes appear exactly where gen1/gen2 counts are non-zero. -4. **The hitch scenario is confirmed and quantified.** Teleport-into-dungeon - produced the worst frame of the session (**211 ms**) with a **75.7 MB** - single-frame allocation (decode + hydrate storm); login settle hit 123 ms - max with 36 gen2 collections in one window. This is exactly the class MP1 - (bake — load becomes mmap reads, not managed decode) targets. - -## Gate decision (spec §5) - -**PROCEED to MP1, with one amendment.** The assumed cost split (render-side -CPU dominant, GPU idle, hitches at load boundaries) is CONFIRMED, so the -phase order stands. The amendment: the capture elevates **steady-state -allocation churn** from "MP4 cleanup" to a first-class smoothness lever — -1.5–3 MB/frame of per-frame garbage is the direct cause of the town p99/max -violations, and it is NOT load-related (it persists in steady windows). - -**Amendment (recorded in the spec):** after MP1 ships, run a bounded -**steady-state allocation triage** (one session: `dotnet-trace` / -allocation-sampling on the live client, identify the top ~5 per-frame churn -sites). Sites *inside* the MP3 rewrite surface (per-frame submission -rebuild — the prime suspect for MB-scale per-frame lists) are left for MP3 -to remove structurally; sites *outside* it get targeted fixes immediately. -The full zero-alloc pass remains MP4. This front-loads the biggest -smoothness win without letting "fix allocations" sprawl into an unphased -rewrite. - -## Follow-ups noted (not gates) - -- Re-measure the Fort Tethana axiom view at the MP2 and MP3 gates (the - degrade port materially changes exactly that view). -- `upd ≈ 0.0` everywhere is suspicious-adjacent but plausible (light sim in - these scenes); if a future capture shows heavy OnUpdate work still reading - 0.0, verify the stage scope wasn't bypassed by an early return upstream. -- Dungeon at ~2000 FPS with zero GC pressure is the existence proof that the - engine's frame loop CAN run allocation-free at four-digit FPS — towns are - paying for churn, not for fundamentals. - -## Apparatus state - -`[frame-prof]` (env `ACDREAM_FRAME_PROF=1`, DebugPanel "Frame profiler" -checkbox) is PERMANENT — it is the measurement instrument for every MP gate. -Implementation: `src/AcDream.App/Diagnostics/` (FrameStatsBuffer / -GpuFrameTimer / FrameProfiler), plan -`docs/superpowers/plans/2026-07-05-mp0-frame-profiler.md`, commits -`7d74c68c`..`4b44a152`. diff --git a/docs/research/2026-07-06-137-closeout-render-pair-pickup.md b/docs/research/2026-07-06-137-closeout-render-pair-pickup.md deleted file mode 100644 index bccd8a17..00000000 --- a/docs/research/2026-07-06-137-closeout-render-pair-pickup.md +++ /dev/null @@ -1,92 +0,0 @@ -# Pickup prompt — post-#137-corridor session: the #176/#177 render pair (paste into a fresh session) - -**Read `claude-memory/project_render_pipeline_digest.md` FIRST** (Option A — -one DrawInside(viewer_cell); binding DO-NOT-RETRY table), then **ISSUES #176 -and #177**, then this file. The physics digest -(`claude-memory/project_physics_collision_digest.md`) carries the full -2026-07-06 collision saga if background is needed — do NOT reopen it. - -## Where we are (2026-07-06 end of session) - -**The #137 Facility Hub corridor collision arc is DONE, user-gated** ("not -collision anymore. Good." / "Looks good"). Branch -`claude/vigorous-joliot-f0c3ad` (worktree), 10 commits ahead of `e73e45da`, -NOT merged to main. All suites green (Core 2562 / App 713 / UI 425 / Net 385). - -| Commit | Fix | -|---|---| -| `a11df5b8` | BSPQuery Contact-branch stub slide responses leaked sliding normals (the absorbing wedge). Retail's BSP layer never writes `collision_info.sliding_normal` — only `validate_transition` 0x0050ac21; body persistence success-only (`SetPositionInternal` 0x005154c2). | -| `e8651b38` | `slide_sphere` opposing-normals branch returned OK; retail returns COLLIDED_TS (0x0053762c). The "phantom wall" normal was SYNTHETIC (negated movement). PortalSide-poly theory refuted. | -| `d4869154` | `CheckOtherCells` queried remaining cells at a stale pre-climb center (P2 lesson one loop deeper) — the seam shake. Per-iteration `footCenter = sp.GlobalSphere[0].Origin` refresh. | -| `aa96d7ad` | The collision capsule topped out at 1.2 m (callers passed `sphereHeight: 1.2f`; head sphere center 0.72). Dat Setup 0x02000001: spheres (0,0,0.475)+(0,0,1.350) r=0.48, top 1.83 = Height 1.835. Callers now pass 1.835. Register TS-46. The window climb. | - -**#137 stays OPEN for the DOORS half only** (block/pass per open state). -The #175 door-pose fix (2026-07-05) still needs its user gate — ask for it -whenever the user is next at the hub double door (closed blocks AT the -visual panels from both sides, no embed, no phantom wall). - -## NEXT ARC: #176 + #177 (render, both filed 2026-07-06 from the gate session) - -- **#176 — purple flashing on dungeon floors at cell seams, camera-angle - dependent.** Survives all physics fixes → render-side. Magenta/purple = - the placeholder-texture class ([[feedback_ui_resolve_zero_magenta]]). -- **#177 — stairs between levels pop in/out.** Invisible from the corridor - looking into the stair room, appear on entering, vanish on the last step - running down. The #119 visibility class, dungeon edition. Anchor cells: - the transit `0x8A020182 → 0x8A020183` drops z −6 → −9 on stairs - (launch-137-gate2.log). - -**The load-bearing topology fact both issues share (discovered this -session):** Facility Hub corridor FLOORS are portal polygons — PortalSide -floor-portals to under-rooms (e.g. 0x8A02016E visual polys 1/3/5 → 0x011E, -horizontal at z=−6, spanning the whole floor; 0x011E is a hall at z=−12). -Level connections run through these floor-portals. "Purple at the seams" is -purple exactly where portal surfaces meet, and the stairs' rooms hang off -the same portal graph — suspect the render portal-flood/portal-surface -handling of HORIZONTAL portals. - -**⚠️ The id-space trap (cost this saga a wrong mechanism):** -`CellPortal.PolygonId` indexes the VISUAL polygon table (`CellStruct.Polygons`), -NOT `PhysicsPolygons`. Same ids in both tables are UNRELATED polygons. - -## Tooling built this session (reuse, don't rebuild) - -- `Issue137CorridorSeamInspectionTests` — dat-inspection theories (add - `InlineData` cells as needed): portal spans (`CorridorCell_PortalPolygonWorldSpans`), - full-vertex poly dumps (`WindowShaft_FullPolyDump`), physics-BSP leaf - membership, hit-normal candidate sweep (use |align| — winding flips), - `HumanSetup_CollisionSpheres_DatTruth`. -- `Issue137CorridorSeamReplayTests` — dat-backed `PhysicsEngine` corridor - harness (`BuildCorridorEngine`: hydrate THREE portal rings or ring-3 - cells are invisible to the flood — why clean-room replays kept passing). - In-test probe capture pattern: `Console.SetOut(StringWriter)` + - `PhysicsDiagnostics.Probe*Enabled = true` → line-diff offline vs live. -- Live probe logs (worktree root, PowerShell Tee = UTF-16, `tr -d '\000'` - before grep): `launch-137-seam-probes.log` (790 MB, step-level), - `launch-137-gate2.log`, `launch-137-gate3.log`, - `resolve-137-seam-capture.jsonl` (body snapshots, untracked). - -## Physics DO-NOT-RETRY highlights from today (full table in the digest) - -- No `SetSlidingNormal` in the BSP/sphere layer; opposing branch returns - Collided; failed transitions never write body sliding state. -- The absorbed exactly-anti-parallel frame against a persisted sliding - normal is RETAIL behavior — fix normal PROVENANCE, not the abort. -- No height-budget check in the step-down accept — retail's climb cap is - `adjust_sphere_to_plane`'s walk_interp −0.5 overshoot bound (0x00538210) - + the placement insert rejecting the HEAD in solids. -- Probe-field misreads: `[neg-poly]`/`[neg-poly-dispatch]` print `stepUp=` - = NegStepUp (dispatch class), NOT sp.StepUp. `[walkable-nearest]` is a - logger, not the decision-maker. -- Remaining registered leaks (rows exist, fix later): TS-45 - (`SphereCollision`'s SetSlidingNormal tail), TS-4 (Path-6 steep-tangent), - TS-46 (scalar sphere approximation; remotes use human dims). - -## Launch protocol (unchanged) - -Build green first; PowerShell launch with the env block from CLAUDE.md -(+ `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1` for gates), background + -Tee to `launch-*.log`. The user manages client lifecycle. Graceful close → -ACE session clears in ~5 s; hard kill → ~3 min. The test character may be -saved in odd places after collision testing (last session it was inside the -window alcove and ACE bounced it to Holtburg — the user portals back). diff --git a/docs/research/2026-07-06-137-corridor-phantom-pickup-prompt.md b/docs/research/2026-07-06-137-corridor-phantom-pickup-prompt.md deleted file mode 100644 index 3c6c96a2..00000000 --- a/docs/research/2026-07-06-137-corridor-phantom-pickup-prompt.md +++ /dev/null @@ -1,87 +0,0 @@ -# Pickup prompt — #137 corridor phantom collision (paste into a fresh session) - -> **SUPERSEDED 2026-07-06.** The corridor phantom is FIXED (visual gate -> pending) — see `docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`. -> Mechanism 2 was real (BSPQuery stub slide responses leaked sliding -> normals; fixed). Mechanism 1's framing was WRONG: the recorded wall -> normal was SYNTHETIC (slide_sphere's opposing branch + a `return OK` vs -> retail's COLLIDED_TS misport — fixed); the PortalSide polys are ±Y -> planes perpendicular to the run, directionally culled, tested by -> retail's own BSP leaves too, and plausibly legitimately solid -> (window/grate class). The step −1 cdb session below is NOT needed for -> this repro. Kept for the audit trail only. - -Read `claude-memory/project_physics_collision_digest.md` FIRST (binding -DO-NOT-RETRY table), then **ISSUES #137** (the 2026-07-05 CHARACTERIZED -section — the full evidence chain lives there), then this file. The 2026-07-05 -session's ledger for context: #172 (CCylSphere family port), #173 (remote -ceiling bounce), #174 (motion-queue drain — doors work after jumping), #175 -(door collision at the motion-table closed pose, two takes). - -**The bug (user-verified repro, Facility Hub 0x8A02):** running down a -corridor, an INVISIBLE blocker stops the player mid-corridor; the player can -walk around it. Two stacked mechanisms, both evidence-pinned: - -## Mechanism 1 — PortalSide portal polygons are solid for us - -- Live: crossing corridor cells `0x8A02016E → 0x8A02017A` at world x≈85.25 - recorded ONE wall hit, normal (−1,0,0) — straight against the movement - (`launch-175-verify2.log:42858`, worktree root). -- Dat (`Issue137CorridorSeamInspectionTests`, committed): cell 0x8A02016E's - three portals to 0x011E (polys 1/3/5, flags=**PortalSide**, NOT ExactMatch) - are PRESENT in `CellStruct.PhysicsPolygons`; every ExactMatch portal in the - same cell is absent from the physics set. The cell's rotation maps those - portal planes into the world −X wall the player hit. -- Oracle greps DONE (do not repeat): `CCellStruct::UnPack` 0x00533d00 loads - physics polys + BSP verbatim (no portal stripping); - `CPolygon::pos_hits_sphere`/`hits_sphere`/`polygon_hits_sphere_slow_but_sure` - (0x005394f0/0x00539540/0x00538a10) are pure geometry; - `CCellPortal` (0x0053bab0) carries portal→CPolygon + portal_side + - exact_match but the BSP test chain never consults it. -- **NEXT (step −1 protocol, needs the user):** cdb-attach a live retail - client at this exact corridor (Facility Hub, the 016E↔011E portals) and - trace which path lets retail through: breakpoint - `BSPTREE::find_collisions` / `BSPLEAF::sphere_intersects_poly` / - `CPolygon::pos_hits_sphere` and see whether the portal polys are ever - TESTED (candidate A: sidedness/stippling — the polys carry stip=NoPos — - or the pos_hits_sphere tail's directional cull) or never REACHED - (candidate B: transit/membership order hands the sphere to the neighbor - cell whose geometry has a real hole). Toolchain crib: - `claude-memory/project_retail_debugger.md` + the CLAUDE.md - "Retail debugger toolchain" section. Verify the binary with - `py tools/pdb-extract/check_exe_pdb.py` first. -- ⚠️ Do NOT ship a "skip portal polys in the physics BSP" filter on - assumption — if retail's answer is sidedness or test order, a blanket - skip opens holes (some PortalSide polys may be legitimately solid from - one side — one-way drops etc.). - -## Mechanism 2 — the sliding-normal absorbing wedge (fix independently) - -- After the single seam hit, EVERY forward resolve returns `ok=False - hit=no` with zero advance: the body-persisted SlidingNormal (−1,0,0) - projects the +X offset to exactly ZERO in `Transition.AdjustOffset`, and - the stepping loop's abort-small-offset fires at step 0 (TransitionTypes - `FindValidPosition` loop, `return i != 0 && …`) — BEFORE any collision - test could refresh the state. An ABSORBING wedge; strafing escapes it - (the user's "push through on the side"). -- Retail re-derives slide state per frame — `OBJECTINFO::get_object_info` - pc:279992 "governs only the NEXT frame" (#116 notes in the digest). - AUDIT: who writes the body's persisted SlidingNormal - (PhysicsEngine.ResolveWithTransition seed ~:995-1040 + the writeback), - and where retail CLEARS it when contact does not recur. This is the #116 - slide-response family — check ISSUES #116 before changing anything - (oracle-first; the digest's DO-NOT-RETRY table applies). -- Likely the bigger playability win: without the wedge, mechanism 1 alone - would be a momentary stutter, not a dead stop. - -**Order:** mechanism 2 first (pure acdream-side audit + fix, testable with a -replay-style unit test: seed a body with a stale sliding normal, resolve -forward with no obstruction in range, assert the step is NOT zeroed), then -the mechanism-1 cdb session when the user can run retail side-by-side. - -**Gotchas:** PowerShell Tee logs are UTF-16 (`tr -d '\000'` before grep); -the user manages client lifecycle; probes RESOLVE/CELL/BUILDING are -DebugPanel-toggleable (ACDREAM_DEVTOOLS=1); the [shape-pose] line -(ACDREAM_DUMP_MOTION=1) prints each BSP registration's pose source. -Register rows to touch if fixes ship: none exist yet for either mechanism — -add per the same-commit rule; #116's row interactions per the digest. diff --git a/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md b/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md deleted file mode 100644 index 5df6df93..00000000 --- a/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md +++ /dev/null @@ -1,115 +0,0 @@ -# #137 mechanism 2 — the sliding-normal lifecycle audit (2026-07-06) - -The pickup prompt (`2026-07-06-137-corridor-phantom-pickup-prompt.md`) asked: -*who writes the body's persisted SlidingNormal, and where does retail CLEAR -it when contact does not recur?* This note is the complete decomp-verified -answer, the divergence map it produced, and the fix that shipped. - -## Retail lifecycle (every site, named-retail-verified) - -`collision_info.sliding_normal` (per-transition) and -`CPhysicsObj::sliding_normal` + `SLIDING_TS` (transient_state bit 4, -body-persisted) form a two-level cache: - -| Step | Function | Address / pc line | What it does | -|---|---|---|---| -| seed | `CPhysicsObj::get_object_info` | 0x00511cc0, seed @0x00511d44 | `if (transient_state & 4) CTransition::init_sliding_normal(&this->sliding_normal)` — last frame's persisted normal seeds the new transition | -| consume | `CTransition::adjust_offset` | 0x0050a370 | `dot(offset, sliding_normal) < 0` → project the per-step offset (crease `cross(contact_plane.N, sliding_normal)` when grounded, `offset −= n·dot` otherwise); `dot >= 0` (moving away) → `sliding_normal_valid = 0` | -| step gate | `CTransition::find_transitional_position` | 0x0050bdf0, small-offset @0x0050bf83-0x0050bfb7 | adjusted offset `|off|² < EPSILON²` at step 0 → transition FAILS; at step i>0 → succeed iff last validate state OK. **The absorbed frame is retail-faithful** — the persisted normal is a "still pressed against this wall" cache that suppresses re-testing | -| per-step clear | same | @0x0050c010 | `sliding_normal_valid = 0` (+ contact plane) BEFORE each step's `transitional_insert` — a step that runs and does not re-collide leaves the transition clean | -| in-transition write | `CTransition::validate_transition` | 0x0050aa70, write @0x0050ac21-ac30 | `if (collision_normal_valid) set_sliding_normal(collision_normal)` — **the ONLY in-transition writer**. Fires when a step needed collision handling | -| body writeback | `CPhysicsObj::SetPositionInternal` | copy @0x005154c2, bit sync @0x005154e1 | `sliding_normal = transition's; SLIDING_TS ⇔ sliding_normal_valid`. **Success-only** — a failed `find_valid_position` discards the transition whole; the body keeps its prior state | -| NOT writers | `CSphere::slide_sphere` 0x00537440, `CCylSphere::slide_sphere` 0x0053b2a0, `BSPTREE::slide_sphere`/`step_sphere_up`/`find_collisions` | pc:321400+, 323700+ | grep-verified: **zero** `sliding_normal` references in the whole sphere/BSP layer (nothing between pc 283518 and 1155326). The sphere-level slide is IN-FRAME (`add_offset_to_check_pos`) | - -So the answer to "where does retail clear it": **the success writeback** -(bit 4 syncs to the transition's final `sliding_normal_valid`, which the -per-step clear leaves false unless the last step's validate re-recorded a -collision) plus `adjust_offset`'s moving-away invalidation. On a FAILED -transition nothing clears it — and nothing needs to, because a persisted -normal can only have come from a validate write against real geometry -(pressed-at-a-wall is a correct absorbed state; any oblique input escapes -via the tangential projection remainder and the escape frame's writeback -clears the bit). - -ACE mirrors all of it: seed `PhysicsObj.cs:2611`, writeback -`PhysicsObj.cs:1249-1251`, validate write `Transition.cs:1027`, the only -`SetSlidingNormal` call sites in ACE's whole physics tree. - -## The wedge (live evidence, launch-175-verify2.log:42858) - -The seam-hit frame **succeeded with full advance** (`ok=True`, -`out == tgt` in XY, +8 mm step-up settle, crossing 0x8A02016E→0x8A02017A) -and still recorded `hit=yes n=(−1.00,0.03,−0.03)`. Retail ending that frame -would write back `sliding_normal_valid=0` (no blocked step at the end → the -per-step clear wins) and the bit would CLEAR. We persisted a normal anyway — -because our BSP Contact branch carried **stub** slide responses -(`SetCollisionNormal + SetSlidingNormal + return Slid`) at the sites where -retail dispatches the real `slide_sphere`. Every following forward resolve -then seeded the stale normal, `adjust_offset` projected the -exactly-anti-parallel corridor push to zero, and the step-0 abort returned -`ok=False hit=no` with zero advance — before any collision test could -refresh the state. An absorbing wedge; strafing escapes because an oblique -offset keeps a tangential remainder. - -## Divergence map → what shipped - -| Site | Was | Retail | Action | -|---|---|---|---| -| `BSPQuery` Contact foot full-hit, step-up unavailable (recursion guard / engine-null) | stub | blocked step-up funnels to `step_up_slide` → `CSphere::slide_sphere` | **FIXED** — routes through `Transition.SlideSphereInternal` (the real port, #116-verified thresholds) | -| `BSPQuery` Contact head full-hit | stub | `BSPTREE::slide_sphere` @0x0053a697 (ACE BSPTree.cs:202, 310-316 — slides GlobalSphere[0]) | **FIXED** — same routing; the dead private stub rewritten as the faithful `BSPTREE::slide_sphere` wrapper | -| `PhysicsEngine.ResolveWithTransition` sliding writeback | unconditional (ran on `ok=False`) | `SetPositionInternal` success-only | **FIXED** — gated on `ok` (behaviorally latent today: a failed transition's ci always still holds the seed, so gate-vs-rewrite is value-identical; the gate removes the class) | -| `BSPQuery` Path-6 steep slide-tangent (2 sites) | in-frame projection + `SetSlidingNormal` | no BSP-layer write | left (documented deviation TS-4 — row amended to name the sliding write); L.5+ retail-strict follow-up | -| `Transition.SphereCollision` (shadow Sphere objects) | hand-rolled slide + `SetSlidingNormal` | `CSphere::intersects_sphere` → `slide_sphere`, no write | left — **new register row TS-45**; fix = route the tail through `SlideSphere` like `CylSlideSphere` (#172) does | -| seed / step loop / `AdjustOffset` / validate write @TransitionTypes:4317 / real `SlideSphere` port | — | — | verified faithful, unchanged | - -Tests: `Issue137SlidingNormalLifecycleTests` — two site pins (Contact -foot-fallback + head full-hit must not write the sliding normal; face-on -grounded → `Collided` per the degenerate crease projection) + the -engine-level wall lifecycle pin (persist-on-block via validate → -absorbed exactly-anti-parallel frame → oblique escape CLEARS the body -state). Full solution suite green (Core 2545 / App 713 / UI 425 / Net 385). - -## Mechanism 1 RESOLVED the same session — the "phantom wall" never existed - -Follow-up dat + decomp work (same day) dissolved the PortalSide-poly theory -entirely; **no cdb session needed for this repro**: - -1. **The recorded hit normal matches NO polygon.** A world-space sweep of - both seam cells + every portal-adjacent neighbor - (`Issue137CorridorSeamInspectionTests.CorridorSeam_FindPolygonMatchingLiveHit`) - found zero physics polygons within 18° of `(−1.00,0.03,−0.03)` near the - hit point. The normal is the player's **negated movement direction** — a - SYNTHETIC value from `slide_sphere`'s opposing-normals branch - (`reversed = −gDelta` → `set_collision_normal`). -2. **The PortalSide polys were a red herring for this hit.** Cell - 0x8A02016E has IDENTITY rotation (the prior session's "rotation maps - them into the −X wall" was wrong); polys 1/3/5 are ±Y-normal planes at - world y≈−38.33, 1.4 m beside the player's track and PERPENDICULAR to - the +X run — `pos_hits_sphere`'s directional cull (dot ≥ 0 → culled, - 0x005394f0 tail) rejects them for that movement outright. They ARE - referenced by a physics-BSP leaf (`CorridorCell_PhysicsBspLeafMembership`), - so retail tests them too when approached INTO their plane — most likely - they are legitimately solid one-way/window-class geometry (which is why - the dat keeps PortalSide-only portal polys in the physics set while - removing every ExactMatch one). The pickup's warning against a blanket - "skip portal polys" filter stands — no filter is needed at all. -3. **A second slide_sphere port bug found and fixed:** the opposing-normals - branch returned OK where retail returns COLLIDED_TS - (0x005375d7-0x0053762c: `*normal = −gDelta; normalize; - set_collision_normal; return 2`). Our OK let the step complete as-is - while carrying the synthetic reversed-movement collision normal — - `validate_transition`'s epilogue then converted it into the sliding - normal the wedge absorbed on. Fixed at the same TransitionTypes site; - pinned by `SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal`. -4. **The dat-backed corridor replay reproduces the live frame and runs - clean** (`Issue137CorridorSeamReplayTests`): same input, same full - advance to (85.253, −39.776, −5.992), same 016E→017A transit — now - `hit=no`, no sliding normal persisted, and six further forward frames - advance freely. (The pre-fix code did NOT reproduce the wedge in the - replay — the live entry chain involved session state beyond the - replay's reach — so the replay is the CLEAN-corridor pin, not a - red/green falsification; the site-level pins in - `Issue137SlidingNormalLifecycleTests` are the red→green proof.) - -Remaining for #137: the user's corridor re-run (visual gate) + the issue's -door half (doors block/pass per open state — separate acceptance). diff --git a/docs/research/2026-07-06-176-177-handoff-A7-lighting.md b/docs/research/2026-07-06-176-177-handoff-A7-lighting.md deleted file mode 100644 index 54dceac5..00000000 --- a/docs/research/2026-07-06-176-177-handoff-A7-lighting.md +++ /dev/null @@ -1,152 +0,0 @@ -# Pickup prompt — #176/#177 root-caused, deferred to A7 dungeon lighting (paste into a fresh session) - -**Read `claude-memory/project_render_pipeline_digest.md` FIRST** (top banner -is the #176/#177 outcome + DO-NOT-RETRY), then **ISSUES #176 and #177**, then -this file. Then read **roadmap Phase A7** (`docs/plans/2026-04-11-roadmap.md` -§"Phase A7 — Indoor lighting fidelity") — this session effectively pre-paid -A7's analysis for the light-cap slice. - -## Where we are (2026-07-06 end of session) - -**Currently working toward: M1.5 — Indoor world feels right.** Critical path: -#137 dungeon collision (DONE, gated), #138 teleport-OUT, **A7 dungeon lighting -(#79/#93 + now #176/#177)**. - -HEAD = `d591e3bb` on `main` AND branch `claude/vigorous-joliot-f0c3ad` -(fast-forwarded, in sync). Working tree clean. All suites green -(Core 2591 + 3 skip / App 719 + 2 skip / UI 425 / Net 385). - -Three commits this session: - -| Commit | What | -|---|---| -| `b8e9e204` | #176/#177 investigation: 12 mechanisms refuted, apparatus shipped, probe protocol staged | -| `4d25e04d` | fix attempt: `MaxGlobalLights` 128→1024 (stops the pops) — **REVERTED** | -| `d591e3bb` | revert to 128 + full deferral docs (register AP-85 rewritten, ISSUES back to OPEN, digest DO-NOT-RETRY) | - -A client may still be running (`launch-176-revert-check.log`) — the user -manages lifecycle. It is on the reverted 128 baseline (rooms normal, seam -flashes present = the still-open issue). - -## THE ROOT CAUSE (confirmed, not a hypothesis) - -**#176 and #177 are ONE bug: per-cell 8-light SET COMPOSITION churning under a -camera-nearest snapshot cap.** `LightManager.BuildPointLightSnapshot` keeps only -the `MaxGlobalLights=128` point lights nearest THE CAMERA; the Facility Hub -registers **366** fixtures, so 238 are evicted per frame by camera distance. -`SelectForObject` (the faithful per-object 8-cap, retail -`minimize_object_lighting` 0x0054d480) can only choose from the surviving 128 — -so an in-range torch of a VISIBLE cell that ranks past the cap drops out of that -cell's 8-set, and the cell's per-vertex Gouraud lighting flips as the camera -moves. - -- **#176** — the flipping unit is a CELL → discontinuities at exactly cell-seam - granularity; camera-angle dependent (the chase boom swings the camera position, - re-ranking the 128); the dominant flipping light is the under-room PORTALS' - purple → purple flashes on the floor at seams. -- **#177** — a stair room whose fixtures ALL rank past the cap renders at bare - 0.2 ambient (near-black = "not visible from the corridor"); approach - re-admits them ("pops into existence"); the eviction boundary sweeping during - the descent strips the ramp's lights ("disappears on the last step"). **The - geometry never vanished — its LIGHTS did.** - -**How it was confirmed (the discriminator):** the user reproduced the flash -while `[light]` (ambient branch — stable 0.2 grey) AND `[pv-input]` (portal -flood — zero drops in 54k frames) read provably healthy in the probe log. That -eliminated every CPU signal the probes COULD see and left the one they can't: -set composition (`[light]` prints counts, not membership). The log's headline -number — `registeredLights=366` vs cap 128 — closed it. - -## WHY THE FIX IS DEFERRED (do not re-raise the cap alone) - -Uncapping (128→1024, `4d25e04d`) stopped the pops but the full 366-fixture pool -exposed three UNPORTED retail lighting semantics that then dominated the Hub — -this is why it was reverted (user: "rooms have no textures" → actually a magenta -light wash over intact textures; then "purple stripes… something fighting to -draw the purple lightning over the floor"): - -1. **Light-through-solid-floors.** Retail registers lights per-CELL - (`insert_light` 0x0054d1b0); a light belongs to a cell and only lights that - cell's geometry. Our snapshot is a flat world-space sphere-overlap with NO - reach/occlusion notion → the under-room portals' purple light washed the - corridors ABOVE them. **This is the big one.** The 128 cap accidentally - MASKED it by keeping the pool camera-local (far under-room lights fell off - the list before they could reach up). -2. **Fixture falloff curve misassignment.** Stationary weenie fixtures - (ACE serves dungeon lanterns/braziers as CreateObject weenies) register via - the `isDynamic:true` path → D3D 1/d falloff (`LightInfoLoader.cs:89`, - GameWindow weenie-light block ~3688). Retail bakes STATIONARY fixture light - with the static 1/d³ curve (`calc_point_light` 0x0059c8b0, static_light_factor - 1.3). 1/d is ~9× stronger at 3 m → every pool over-broad + over-saturated. - The `isDynamic` flag should be reserved for genuinely MOVING lights (portal - swirls, projectiles); a stationary fixture — even server-spawned — is static. -3. **Striped floor z-fight-like artifact.** User's 2nd screenshot: regular - magenta bands across one floor region, "like something is fighting to draw - the purple over the floor." **NOT attributed.** Ruled out: not coincident dat - geometry (the `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` sweep - found only the legit z=−12 under-hall floor quad-fan, nothing near the −6 - corridor floor); not a striped texture (all corridor surfaces are plain - `Base1Image` stone 0x08000375/6/7/8). Leading guess: two draws of the same - floor with DIFFERENT light sets (the per-cell-vs-per-something set assignment - splitting), or an MDI instance-order/light-set-index desync exposed only when - the purple light is stably present. **Hunt this in A7 with the full pool - temporarily on** — it's invisible at cap 128. - -## THE A7 FIX SHAPE (the real fix, in order) - -1. **Port per-cell light registration** (`insert_light` 0x0054d1b0 + the - per-cell light list retail keeps). A light lights its OWN cell's geometry + - cells reachable through portals — NOT arbitrary world-space overlap. This - kills #1 (through-floor) and makes the global pool cap irrelevant (per-cell - sets are naturally bounded), which is what actually lets #176/#177 close. -2. **Static curve for stationary fixtures.** Decide `isDynamic` by whether the - light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern - is stationary → static 1/d³. (Register AP-67/AP-44 are the weenie-light path; - AP-85 is the pool cap; #143 is the curve-by-path decision to revisit.) -3. **Hunt the stripes** with the full pool on (see #3 above). -4. **THEN uncap** `MaxGlobalLights` — un-skip - `LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` - (it asserts the retail end-state: an in-range light of a cell is never - camera-evicted). - -## Tooling built this session (reuse, don't rebuild) - -- **`tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs`** - — dat truth for the Hub: portal-poly draw verdicts, reciprocal coincidence, - stair geometry owner (`0x8A020182`'s ramp shell, vertical portals, ZERO - statics), CellBSP containment (partitions exactly at portal planes), - under-hall + corridor drawn-poly surface colors, DXT1 alpha histograms (0 - transparent texels), and `CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs` - (the stripe-geometry sweep — came back empty for the −6 floor). -- **`tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs`** - — production-matched portal-flood replays (approach/descent/gaze-sweep/walk + - the ScenarioE incoherent-root sensitivity pin). Flood is HEALTHY — do not - re-investigate it for these issues. -- **`tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs`** - — resolver cell-flip is plane-exact (membership is NOT the bug). -- **`LightManagerTests.PointSnapshot_HubScaleLightCount_*`** — Skip'd - end-state pin (RED@128, GREEN@1024). -- **Ledger**: `docs/research/2026-07-06-176-177-render-pair-investigation.md` - (13 refuted mechanisms + the probe-run discriminator + the OUTCOME banner). - -## Live probe env (all zero-cost off; use for the A7 spike) - -``` -ACDREAM_PROBE_LIGHT=1 # [light] insideCell/ambient/sun/registeredLights/activeLights — rate-limited -ACDREAM_PROBE_PVINPUT=1 # [pv-input] one line/frame: exact flood inputs + count -ACDREAM_PROBE_CELL=1 # [cell-transit] timeline anchors -ACDREAM_PROBE_TEXFLUSH=1 # [tex-flush] staged-upload drain (proves #105 healthy: after=0) -``` - -A7.L1's planned `[indoor-light]` probe (per-cell active-light dump: position, -color, attenuation, direction) is the natural next apparatus — it prints exactly -the SET COMPOSITION the current `[light]` counts can't. Build it FIRST. - -## Launch protocol (unchanged) - -`dotnet build` green first; PowerShell launch with the CLAUDE.md env block -(+ the probes above), background + Tee to `launch-*.log`. User manages client -lifecycle (graceful close → ACE clears in ~5 s; hard kill → ~3 min). Strip -`\000` before grep (PowerShell Tee = UTF-16): `tr -d '\000' < launch.log | grep ...`. -Test char spawns near `0x8A020179` (the ramp corridor); the 015E↔017A corridor -loop is the #176 repro; look-into + descend the 0178→0182→0183 stairs for #177. diff --git a/docs/research/2026-07-06-176-177-render-pair-investigation.md b/docs/research/2026-07-06-176-177-render-pair-investigation.md deleted file mode 100644 index 238cb533..00000000 --- a/docs/research/2026-07-06-176-177-render-pair-investigation.md +++ /dev/null @@ -1,181 +0,0 @@ -# #176/#177 render pair — investigation ledger (2026-07-06, session 2) - -## ✅ OUTCOME (same day, after the probe launch): ROOT CAUSE FOUND + FIX SHIPPED - -**The probe run discriminated it.** The user reproduced the purple floor -flash while BOTH surviving CPU theories read provably healthy in the log — -`[light]` insideCell/ambient rock-stable (one pre-spawn outdoor line, then -flat 0.2 grey through 36 transits), `[pv-input]` flood stable (54k frames, -zero collapses). That eliminated T-A and T-B and exposed the one channel -the probes were structurally blind to: **per-cell 8-light SET COMPOSITION.** - -The log's own headline number told the story: `registeredLights=366` — -against `MaxGlobalLights = 128`. `BuildPointLightSnapshot` kept the 128 -lights nearest THE CAMERA and evicted 238 every frame; `SelectForObject` -(camera-independent, faithfully retail — and unit-PINNED as such) could -only choose from the surviving 128. An in-range torch of a VISIBLE cell -that ranked past the cap dropped out of that cell's 8-set → the cell's -per-vertex Gouraud lighting flipped as the camera moved (the chase boom -swings the camera position by meters, re-ranking the 128): - -- **#176:** the flipping unit is A CELL → discontinuity lines at exactly - cell-seam granularity; a torch-losing floor drops to dim blue-grey - stone (0.2 ambient × stone = the perceived purple); camera-angle - dependent by construction. -- **#177:** a stair room whose torches ALL ranked past the cap rendered - at bare 0.2 ambient — near-black in a dungeon = "not visible from the - corridor"; approaching re-admitted them = "pops into existence"; the - boundary sweeping during the descent dropped the ramp's lights = - "disappears on the last step". The geometry never vanished — its - LIGHTS did. - -Retail anchor: `minimize_object_lighting` (0x0054d480) selects from the -cell-registered reaching set (`insert_light` 0x0054d1b0) — **no global -camera-nearest pool cap exists in retail.** - -**Fix:** `MaxGlobalLights` 128 → 1024 (a non-biting safety valve; the -GPU packer grows to fit — 64 B/light). Register row **AP-85**. TDD pin: -`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant` -(RED at 128 with a Hub-scale 401-light layout, GREEN at 1024). All four -suites green. **Pending the user visual gate.** - -Process note: the pre-existing test -`SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre` was written -to pin "the property that kills the lights-up-as-I-approach popping" — it -proved the SELECTOR camera-independent while the SNAPSHOT it selects from -was camera-capped. The pop re-entered one stage upstream of the pin. - ---- - -**Pre-launch status below (kept as the audit trail): mechanism NOT yet pinned — but the hypothesis space is now razor-thin.** -Twelve candidate mechanisms refuted by direct evidence (dat dumps, headless -replays, production-log analysis, code reads). Every layer that can be checked -offline is verified HEALTHY at the anchor cells. The surviving discriminator -requires ONE live probe launch (protocol at the bottom — piggyback on the -pending #175 door gate). - -## The issues - -- **#176** — purple flashing on dungeon floors at cell seams, camera-angle - dependent (Facility Hub). -- **#177** — stairs pop in/out across levels: (a) vanish on the last step - running down, (b) invisible looking into the stair room from the corridor, - (c) pop into existence on entering. - -## Anchor-cell dat truth (Issue176177DungeonSeamInspectionTests) - -- Corridor `0x8A02016E` floor = three abutting TEXTURED drawn portal polys - (polys 1/3/5, surface 0x08000377 → DXT1 tex 0x050026F7, `[PortalSide]`) → - under-hall `0x011E` (z=−12). Reciprocal ceiling poly = NoPos (not drawn). -- The "stairs" = a RAMP owned by `0x8A020182` (inclined drawn polys, z −9…−6 - floor + −6…−3 ceiling); `0x0183` = flat lower cell. Connections are - VERTICAL wall portals (NOT floor-portals). PortalSide flag asymmetry: - 0x0182→0x0183 carries PortalSide; the back-portal does not. -- **Zero StaticObjects in all five anchor cells** (no #119-class statics, no - torch-bearing stabs → no registered point lights except the viewer fill). -- CellBSP volumes partition EXACTLY at the portal planes (no overlap zone). -- All surfaces resolve; DXT1 textures contain **zero** transparent-mode - texels (all 3-color-mode blocks, index 3 never used). - -## REFUTED mechanisms (each by direct evidence — do NOT retry) - -| # | Hypothesis | Killed by | -|---|---|---| -| 1 | Placeholder/missing texture (magenta class) | All surfaces resolve; drawn-poly sweep 0 misses | -| 2 | Reciprocal portal-poly z-fight | Reciprocal is NoPos (never drawn) | -| 3 | Seal depth-stamp z-fights the drawn floor-portal | Seals fire ONLY for `OtherCellId==0xFFFF` (GameWindow:11437); a sealed dungeon draws zero seals | -| 4 | Root/eye incoherence (viewer root lags the eye across portal planes) | Production camera sweep publishes coherent pairs; out-cell flips at x=85.001/88.335 — mm-exact at the planes (gate2 log). The flood DOES collapse to 1 cell under artificially incoherent inputs (ScenarioE pin) — but production inputs are coherent | -| 5 | Membership transit lag (0.33–0.47 m in [cell-transit]) as the render-root lag | The resolver flips within one tick-step of the plane in the harness (Issue176177SeamTransitLagTests); the logged "lag" is speed×tick quantization of the PLAYER probe, and the camera root (probe out-cell) is plane-exact | -| 6 | Flood bistability at the anchors | ScenarioC gaze sweep (2° steps, 4 pitches): 0 one-step drops; ScenarioA stair approach: ramp+lower admitted at all tested eyes/pitches | -| 7 | Staircase = EnvCell static culled by viewcone (#119 class) | Zero statics in the anchor cells; the stairs are shell geometry | -| 8 | Undefined DXT mip levels (compressed arrays skip GenerateMipmap) | Both ObjectMeshManager texture paths DECODE DXT→RGBA8 (BcDecoder) — the compressed-array branch of ManagedGLTextureArray is dead WB-heritage code; RGBA8 arrays get real mips | -| 9 | DXT1 3-color-mode alpha=0 texels + opaque-pass `discard a<0.05` / A2C | Block histogram: 0 transparent texels in all Hub floor/wall textures | -| 10 | Fog mix toward purple FogColor at distance | Fog ramp starts at `_nearRadius×192×0.7 ≈ 538 m` (radii stay 4/12 in dungeon mode); Hub sightlines ≤ ~100 m → fog term ≡ 0 | -| 11 | Lightning-flash additive (`uFogParams.z × (0.6,0.6,0.75)`) leaking indoors | `WeatherState._flashLevel` is 0 in production ("Production never TriggerFlashes") — dormant test hook. (The missing indoor gate is still real debt for when storm strobes ship.) | -| 12 | Viewer-light per-cell/per-vertex pops (hard range edge or 8-set membership flips) | The point ramp is `(1−d/range)` — smoothly ZERO at the range boundary; set-membership beyond range is a zero-contribution no-op. No torches exist in the Hub cells to churn the 8-cap | - -## VERIFIED-HEALTHY layers (offline pins, keep as regression assets) - -- `PortalVisibilityBuilder` at the anchors: approach/descent/gaze-sweep/walk - scenarios (`Issue176177FacilityHubFloodReplayTests`) — admissions correct - and stable with coherent inputs; the ScenarioE incoherent-input collapse is - the sensitivity pin (1-cell flood when root≠eye side). -- Membership: `ResolveWithTransition` flips cells within one tick of the - portal plane, both directions (`Issue176177SeamTransitLagTests`). -- Mesh path: `CellMesh.Build` (production, GameWindow:7013) draws the - textured floor-portal strips; snapshot frustum gate (WbFrustum) is the - standard conservative p-vertex test; per-cell AABBs are vertex-derived, - 8-corner transformed. -- Per-instance light sets are truly per-instance (EnvCellRenderer MDI). - -## SURVIVING theories (need the live discriminator) - -- **T-A (ambient flip):** any frame where `playerRoot` resolves null (or - `playerSeenOutside` defaults true) runs the OUTDOOR lighting branch — - purple sky ambient + full sun. Sun is directional-from-above → floors - (N·L≈1) catch it, walls (N·L≈0) barely → a FLOOR-selective purple-bright - flash, temporally lockable to whatever gaps CurrCell/TryGetCell. Desk - analysis found no per-frame gap trigger during plain corridor runs - (UpdatePlayerCurrCell is stale-beats-null; the registry is stable in - AP-36 dungeon mode) — but the branch inputs are exactly probed by - `[light] insideCell=` lines, so one run settles it. -- **T-B (flood with REAL production inputs):** my harness feeds synthetic - viewProj/eye. If the real per-frame inputs at the artifact moments differ - (collided camera pressed into walls near seams, damped-forward gaze), the - flood could still misbehave in configurations the sweeps missed. - `[pv-input]` prints the exact inputs + flood count per frame. -- **T-C:** unknown-unknown (GPU state, driver, MSAA resolve…). If T-A/T-B - both read clean at a flash moment → RenderDoc frame capture next. - -## Also found (real, filed, not these bugs) - -- **A8 double-sided shells stopgap still live**: `EnvCellRenderer.cs` - RenderModernMDIInternal maps `CullMode.Landblock → CullMode.None` - ("while the architectural cause is isolated") — all cell shells draw - two-sided. Perf + correctness debt; retire under the A7/A8 lighting arc. -- **Lightning indoor gate missing** (dormant): when weather strobes ship, - `SceneLightingUbo.Build` needs the `playerInsideCell` gate or dungeons - will strobe blue-violet. -- **Flood follows floor-portals DOWNWARD from above** (ScenarioC: under-hall - network admitted at down-pitches). Retail's `portal_side` side test - (0x005a59a0: portal_side≠0 → NEGATIVE side only; ==0 → POSITIVE only; - IN_PLANE(±0.0002) → refused) *appears* to refuse this direction, but my - plane-sign reading had unresolved contradictions (CCellPortal::UnPack - normalizes portal_side at load, 0x0053ba1c/0x0053bc6a). OPEN QUESTION — - harmless-looking (extra admitted cells draw below the opaque floor, - z-buffer wins) but worth settling when the flood is next touched. - -## The live probe protocol (piggyback on the #175 door gate launch) - -Env (add to the standard launch block): - -``` -ACDREAM_PROBE_LIGHT=1 # [light] insideCell/ambient/sun — rate-limited -ACDREAM_PROBE_PVINPUT=1 # [pv-input] one line/frame: exact flood inputs + count -ACDREAM_PROBE_CELL=1 # [cell-transit] timeline anchors -``` - -User reproduces in Facility Hub (~2 min): run the corridor across several -seams until the purple flash shows; approach the stair room from the -corridor, walk in, run down. Then close. Discrimination: - -- Flash moments + `[light]` shows `insideCell=False` blips or ambient jumps - → **T-A confirmed** (then root-cause the gap trigger from the same log). -- `[pv-input]` flood count drops (e.g. 12→1) at flash/pop moments while - `[light]` stays clean → **T-B confirmed** (the line carries the exact - inputs to replay in `Issue176177FacilityHubFloodReplayTests`). -- Stairs invisible while the flood contains 0x0182/0x0183 → draw-side hunt - (RenderDoc next); absent → flood-side with the printed inputs. -- All clean → T-C: RenderDoc frame capture of a flash frame. - -## Apparatus shipped this session - -- `tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs` - — dat truth: portal polys/surfaces/draw verdicts, reciprocal coincidence, - stair geometry owner, CellBSP containment, under-hall surface colors, - DXT1 alpha histograms. -- `tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs` - — production-matched flood replays: stair approach, descent, gaze sweep, - corridor walk, and the ScenarioE incoherent-root sensitivity pin. -- `tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs` - — resolver cell-flip position at the seam (plane-exact pin). diff --git a/docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md b/docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md deleted file mode 100644 index abc4040a..00000000 --- a/docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md +++ /dev/null @@ -1,268 +0,0 @@ -# A7 dungeon lighting — retail per-cell light model (source-confirmed pseudocode) - -**Date:** 2026-07-06 (continuation of the #176/#177 arc) -**Purpose:** the mandated `grep named → decompile → pseudocode → port` step 3 for -the A7 per-cell lighting fix. Captures the RETAIL light-selection model exactly as -read from `docs/research/named-retail/acclient_2013_pseudo_c.txt`, so the port can -match it line-for-line. - -> ⚠️ **This document CORRECTS the #176/#177 handoff's framing.** The handoff -> (`2026-07-06-176-177-handoff-A7-lighting.md`) and the digest banner state that -> "retail registers lights per-CELL via `insert_light` 0x0054d1b0" and that -> "retail's `minimize_object_lighting` has NO global camera-nearest pool cap." -> **Both are imprecise.** Reading the source: `insert_light` maintains a GLOBAL -> player-nearest sorted pool with a SMALL cap (40 static + 7 dynamic), functionally -> analogous to acdream's `BuildPointLightSnapshot`. The real per-cell mechanism is -> the *collection phase*: retail rebuilds that global pool **each frame from only -> the currently-VISIBLE cells** (`CEnvCell::add_*_lights` walks the portal-flood -> `visible_cell_table`). That is why retail's tiny cap never bites — the candidate -> pool is pre-scoped by visibility, not by camera distance over the whole dungeon. -> This is a *better* fit for acdream than the handoff's framing, because acdream -> already computes the visible-cell set every frame (the portal flood). - ---- - -## 1. The retail model, as source-confirmed - -### 1.1 Each cell owns a light list (`CObjCell` / `CEnvCell`) - -- `CObjCell::add_light(this, LIGHTOBJ*)` (`0x0052b1d0`) — appends a light to the - cell's own `light_list` (a `DArray`), `num_lights` counter. - Populated at cell load: `CEnvCell::UnPack` (`0x0052d470`) unpacks `num_lights` - (line ~310877) and the light list straight from the dat CellStruct; the outdoor - path feeds it from the landblock's static object lights (caller at line ~285976, - `CObjCell::add_light(cell, lights->lightobj + i)`). -- So a light is DATA owned by the cell it sits in — dungeon torches live in the - EnvCell's `light_list`; a landblock's lamp-posts live in the LandCell's list. - -### 1.2 A cell pushes its own lights to the global pool - -``` -CObjCell::add_static_to_global_lights(cell): # 0x0052b350 - for lightobj in cell.light_list[0 .. cell.num_lights): - if (lightobj.flags & 1) != 0: # bit 0 set = STATIC light - Render::add_static_light(lightobj.info, cell.m_DID.id, lightobj.frame) - -CObjCell::add_dynamic_to_global_lights(cell): # 0x0052b390 - for lightobj in cell.light_list[0 .. cell.num_lights): - if (lightobj.flags & 1) == 0: # bit 0 clear = DYNAMIC light - Render::add_dynamic_light(lightobj.info, cell.m_DID.id, lightobj.frame) -``` - -The cell id (`cell.m_DID.id`) is passed through as `arg6` so the light carries its -owning cell (stored at `+0x6c` on the RenderLight; used by `insert_light` for the -block-offset distance math). - -### 1.3 Per frame, ONLY visible cells contribute (the crux) - -``` -CEnvCell::add_dynamic_lights(): # 0x0052d410 - for cell in CEnvCell::visible_cell_table: # the PORTAL-FLOOD visible set - CObjCell::add_dynamic_to_global_lights(cell) - -# static counterpart — same function that ends at 0x0052def0 (line ~311650): -for cell in CEnvCell::visible_cell_table: # SAME visible set - cell.init_static_objects() - CObjCell::init_objects(cell) - CObjCell::add_static_to_global_lights(cell) -``` - -`visible_cell_table` is the set of cells reached by the portal flood from the -viewer's cell (retail `CEnvCell::find_visible_cells` / the `PView` gather). **A -dungeon with 366 fixtures but only 5 visible cells contributes only those 5 cells' -lights to the global pool.** This is the entire reason retail doesn't churn. - -### 1.4 The global pool is small and player-sorted (`insert_light`) - -``` -Render::insert_light(maxCount, &num, lights[], sorted[], info, cellId, frame, base): # 0x0054d1b0 - distsq = 0 - if info.type == 0: # point light - # squared distance from THIS light to the PLAYER, across the cell block offset - blockOff = LandDefs::get_block_offset(player_pos.objcell_id, cellId) - distsq = |(frame.origin + blockOff) - player_pos.frame.origin|² - # ... write RenderLight fields (color/255, intensity, falloff, cone, distancesq=distsq) - # insertion-sort into sorted[] ascending by distancesq (nearest player first), - # capped at maxCount; when full, evict the farthest-from-player. - -Render::add_static_light(info, cellId, frame): # 0x0054d3e0 - insert_light(max_static_lights, &world_lights.num_static_lights, - world_lights.static_lights, world_lights.sorted_static_lights, - info, cellId, frame, max_dynamic_lights + 1) - -Render::add_dynamic_light(info, cellId, frame): # 0x0054d420 - insert_light(max_dynamic_lights, &world_lights.num_dynamic_lights, - world_lights.dynamic_lights, world_lights.sorted_dynamic_lights, - info, cellId, frame, 1) -``` - -**Cap values:** `max_static_lights` / `max_dynamic_lights` (`0x0081ec94` / `0x0081ec98`) -init to **0x28 = 40** and **0x7 = 7**. Recomputed in `Render::SetDegradeLevelInternal` -(`0x0054c3c0`) as a function of the graphics degrade level (constants 25/50/8/16) — -always small (tens of static, single-digit dynamic). Retail deliberately keeps the -global pool tiny; it can, because §1.3 pre-scopes the input by visibility. - -### 1.5 Per-object selection (`minimize_object_lighting`) — this IS acdream's `SelectForObject` - -``` -Render::minimize_object_lighting(): # 0x0054d480 - reset_active_lights_state() - used = 0 - # DYNAMIC lights first (priority), pre-sorted nearest-player: - for i in 0 .. num_dynamic_lights: - if used < 8 and remove_object_light(sorted_dynamic_lights[i].info) == keep: - add_active_light(i, 2); used += 1 - else: dynamic_light_used[i] = 0 - # STATIC lights fill remaining slots: - for i in 0 .. num_static_lights: - if used >= 8: static_light_used[i] = 0; continue - L = sorted_static_lights[i] - if L.info.type != 0: # non-point (directional): always use - add_active_light(i, 1); used += 1 - else: # point: sphere-overlap test - reach = L.range + local_object_radius - if |L.pos - local_object_center|² - reach² < 0.0002: # spheres overlap - add_active_light(i, 1); used += 1 - else: static_light_used[i] = 0 - enable_active_lights() -``` - -acdream's `LightManager.SelectForObject` already does the sphere-overlap + 8-cap. -The one fidelity gap: retail fills **dynamic-first (priority), then static**, from two -separate player-sorted arrays; acdream selects from one camera-sorted snapshot. -Minor — parity item, not the #176/#177 cause. - -### 1.6 Static falloff curve (`calc_point_light`) — fix #2 reference - -`calc_point_light` (`0x0059c8b0`) is retail's CPU per-vertex software lighting for -static geometry (accumulates into `CUSTOM_D3D_VERTEX2` r/g/b). Structure: - -``` -calc_point_light(vertex, &r, &g, &b, info): - d = |info.offset.origin - vertex.pos| - range = info.falloff * static_light_factor # static_light_factor ≈ 1.3 - if d < range: - # N·L diffuse gate: 0.5*d + dot(vertex.normal, info.pos - vertex.pos) > 0 - if faces_light: - atten = <1/d-ish curve, x87 — SEE WARNING> - f = atten * (1 - d/range) * info.intensity - r += clamp(f * info.color.r, .. info.color.r) # per-channel clamp to the light's own colour - g += clamp(f * info.color.g, ..) - b += clamp(f * info.color.b, ..) -``` - -> ⚠️ **Do NOT port the exact `atten` curve from this BN pseudo-C.** Lines -> 425331–425341 are dense x87 FPU register juggling (`distsq/dist` vs -> `1.5/(distsq·dist)` branch on `distsq ≷ 1`), exactly the "x87 dropout / misread" -> class the project has been burned by twice (see `feedback_bn_decomp_field_names`, -> `feedback_retail_binary_dispatch`). When implementing fix #2, cross-reference a -> SECOND source (ACE / ACViewer static-light port, or the Ghidra decomp) and pin -> the curve with a conformance test before trusting it. The STRUCTURE above -> (range = falloff × static_light_factor, per-vertex N·L, intensity scale, colour -> clamp) is solid; the attenuation exponent is the part to verify. - ---- - -## 2. Why #176/#177 happen in acdream (refined root cause) - -acdream `LightManager` registers **every** fixture permanently into `_all` (server -weenie spawns + EnvCell static hydration), then `BuildPointLightSnapshot` caps at -`MaxGlobalLights=128` **nearest-CAMERA** over the WHOLE registered set. In the -Facility Hub (366 fixtures) that evicts 238/frame by camera distance; `SelectForObject` -can only choose from the surviving 128, so an in-range torch of a *visible* cell that -ranks past the cap drops from that cell's 8-set and the per-cell Gouraud lighting pops -as the camera moves (#176 seam flash / #177 stair-room pop-in). - -**Retail never has 366 candidates.** It rebuilds `world_lights` each frame from ONLY -the visible cells' `light_list`s (§1.3), so the candidate pool is a handful of cells — -under the 40+7 cap — and nothing gets evicted. The camera-distance cap is a backstop -that essentially never fires because the input is already visibility-scoped. - -This also explains the **through-floor purple wash** the cap-raise exposed: acdream's -flat world-space sphere-overlap of all 366 lights let an under-room portal light reach -up through a solid floor. Retail's under-room cell isn't in the corridor's -`visible_cell_table` (the flood doesn't pass through the solid floor), so its light -never enters the pool. Per-cell reach = *the light is only a candidate when its cell -is visibly flooded.* - ---- - -## 3. The fix (materially different from "just uncap MaxGlobalLights") - -**Port the visibility-scoped per-frame collection**, not a bigger cap: - -1. **Tag each `LightSource` with its owning cell id** (add `CellId` to `LightSource`; - populate at every registration site from the cell/landblock in scope). Retail's - `add_*_light(info, cellId, frame)` carries exactly this. -2. **Build the per-frame point-light pool from ONLY the currently-visible cells** — - the portal-flood set the renderer already computes — instead of the whole `_all` - set. This is retail's `add_*_lights over visible_cell_table`. The pool is then - naturally bounded; `MaxGlobalLights` stops biting (can keep 128 or adopt retail's - 40+7 as a documented backstop). The Skip'd end-state pin - (`LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`) - asserts exactly this: an in-range light of a visible cell is never camera-evicted. -3. **Fix #2 — static curve for stationary fixtures.** Decide `isDynamic` by whether - the light MOVES, not by dat-static-vs-weenie origin. A server-spawned wall lantern - is stationary → static 1/d³ (range × 1.3), reserving `isDynamic` (range × 1.5, 1/d) - for genuinely moving lights (portal swirls, projectiles). See §1.6 warning. -4. **Fix #3 — hunt the striped floor artifact** with the full (now visibility-scoped) - pool on. Invisible at cap 128; see the handoff for the two leading guesses. -5. **THEN uncap / adopt the retail cap** and un-skip the end-state pin. - -### 3.1 acdream integration surface — as SHIPPED (slice 1: visible-cell scoping) - -The renderers already select per-cell (`EnvCellRenderer.cs:1088`) and per-object -(`WbDrawDispatcher.cs:2095`) from `LightManager.PointSnapshot`; the ONLY defect was -that `PointSnapshot` was built by capping the whole `_all` set at 128 nearest-CAMERA. -The fix scopes that pool to visible cells. Concretely: - -1. **`LightSource.CellId`** (new `uint`, 0 = cell-less/global). Retail's per-light cell - (insert_light arg6 → RenderLight +0x6c). -2. **`LightInfoLoader.Load(..., uint cellId = 0)`** propagates it onto each light. -3. **Both registration sites tag the owning cell** from `entity.ParentCellId`: - - Site A live weenie fixtures — `GameWindow.cs:~3682` (`cellId: entity.ParentCellId ?? 0u`). - - Site B dat EnvCell statics — `GameWindow.cs:~7696` (same). - - Viewer fill light keeps `CellId == 0` (always in the pool — retail's per-frame - `add_dynamic_light(&viewer_light, objcell_id)` is unconditional). -4. **`LightManager.BuildPointLightSnapshot(camPos, IReadOnlySet? visibleCells)`** — - a light joins the pool iff `CellId == 0` OR `visibleCells == null` (outdoor) OR - `visibleCells.Contains(CellId)`. The 128 cap stays as a now-non-biting backstop. -5. **The seam.** The per-frame order is `UpdateViewerLight → Tick → BuildPointLightSnapshot - (null-scope) → SceneLightingUbo.Build → Upload` (`GameWindow.cs:9058-9095`), and the - portal flood + all cell/entity draws happen LATER, INSIDE - `RetailPViewRenderer.DrawInside`. So the scoped rebuild is threaded via a new context - callback: `RetailPViewDrawContext.RebuildScopedLights`, invoked in `DrawInside` right - after `prepareCells` (every cell drawn this frame) is finalized and BEFORE - `PrepareRenderBatches` / the draws (`RetailPViewRenderer.cs:~131`). GameWindow wires it - to `visible => Lighting.BuildPointLightSnapshot(camPos, visible)` (`GameWindow.cs:~9371`). - The renderers hold a reference to the same `_pointSnapshot` list (rebuilt in place), and - `EnvCellRenderer._cellLightSetCache` is `.Clear()`'d every pass, so no stale indices. - `SceneLightingUbo.Build` reads `lights.Active` (Tick), not the snapshot, so it is - unaffected by the relocation. The outdoor `else` path (clipRoot == null: pre-login / - fly) never invokes the callback and keeps the legacy null-scope full pool. -6. **Validation apparatus** — `ACDREAM_PROBE_INDOOR_LIGHT=1` → one rate-limited - `[indoor-light]` line per second with the scoped-pool SET COMPOSITION - (`RenderingDiagnostics.EmitIndoorLight`): `visibleCells / pool / cellLess / registered / - droppedNonVisible / byCell[]`. This is the discriminator the `[light]` COUNTS couldn't - give (#176/#177 lived in set membership). - -Fixes #2 (static curve) + #3 (stripe hunt) + the cap decision are follow-on slices. - ---- - -## 4. Source anchors (for the register + future sessions) - -| Retail fn | Addr | Role | -|---|---|---| -| `CObjCell::add_light` | 0x0052b1d0 | append light to a cell's own list | -| `CObjCell::add_static_to_global_lights` | 0x0052b350 | push a cell's static lights to the global pool | -| `CObjCell::add_dynamic_to_global_lights` | 0x0052b390 | push a cell's dynamic lights to the global pool | -| `CEnvCell::add_dynamic_lights` | 0x0052d410 | per-frame: walk `visible_cell_table`, collect dynamic | -| (static collector, ends) | 0x0052def0 | per-frame: walk `visible_cell_table`, collect static | -| `CEnvCell::UnPack` | 0x0052d470 | unpack a cell's `num_lights` + `light_list` from dat | -| `Render::insert_light` | 0x0054d1b0 | player-nearest sorted insert into `world_lights`, capped | -| `Render::add_static_light` / `add_dynamic_light` | 0x0054d3e0 / 0x0054d420 | thin wrappers → insert_light | -| `Render::minimize_object_lighting` | 0x0054d480 | per-object ≤8 pick (dynamic-priority, then static sphere-overlap) | -| `Render::SetDegradeLevelInternal` | 0x0054c3c0 | recomputes `max_static/dynamic_lights` from degrade level | -| `calc_point_light` | 0x0059c8b0 | CPU per-vertex static light curve (fix #2 ref) | -| `max_static_lights` / `max_dynamic_lights` | 0x0081ec94 / 0x0081ec98 | init 40 / 7 | diff --git a/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md b/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md deleted file mode 100644 index 4391fca3..00000000 --- a/docs/superpowers/plans/2026-06-13-dungeon-support-g3a.md +++ /dev/null @@ -1,633 +0,0 @@ -# G.3a — Core Teleport-Into-Dungeon Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make teleporting into a dungeon land the player standing in the dungeon cell (on the floor, walls blocking) instead of snapping to ocean — by holding the player in portal space until the destination landblock/cell streams in, then placing via the existing validated-claim path. - -**Architecture:** Replace the unconditional snap in `GameWindow.OnLivePositionUpdated` with a small, pure, unit-tested `TeleportArrivalController` state machine. On a teleport arrival the handler recenters streaming (kicks off the load) but **defers** the snap; a per-frame `Tick` reuses the #107 login readiness triplet (`SampleTerrainZ` ∧ (`outdoor` ∨ `IsSpawnCellReady`); `IsSpawnClaimUnhydratable` short-circuits impossible claims) and places the player via the unchanged `PhysicsEngine.Resolve` once the destination is ready. A coarse frame-count timeout fails loudly rather than freezing. Plus a small decouple of EnvCell physics/visibility hydration from the render-mesh guard. - -**Tech Stack:** C# .NET 10, xUnit, Silk.NET (App layer). No new dependencies. - -**Spec:** [`docs/superpowers/specs/2026-06-13-dungeon-support-design.md`](../specs/2026-06-13-dungeon-support-design.md) (§3.1, §4, §5). - -**Scope:** This plan is **G.3a only** — the gated core that ends at the visual acceptance test. G.3b (#95 stab_list bounding, *conditional* on the gate showing a blowup), G.3c (faithful `TeleportAnimState` tunnel FSM), and G.3d (recall game-actions) each get their own plan **after** the G.3a gate passes. - ---- - -## File Structure - -| File | Responsibility | Action | -|---|---|---| -| `src/AcDream.App/World/TeleportArrivalController.cs` | Pure state machine: hold a teleport arrival until ready, then place (or force-place on impossible/timeout). No GL/dat/network — readiness + placement are injected delegates. | **Create** | -| `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs` | Unit tests for the state machine (all transitions, timeout, re-arm). | **Create** | -| `src/AcDream.App/Rendering/GameWindow.cs` | Wire the controller in: construct lazily, the readiness + placement callbacks, replace the unconditional arrival snap (`:4877-4961`) with recenter + `BeginArrival`, add per-frame `Tick` (after `:6838`). Decouple EnvCell physics/visibility hydration from the render-mesh guard (`:5601-5652`). | **Modify** | - -`TeleportArrivalController` is deliberately a *pure* unit (App layer, `System.Numerics` only) so it is testable without standing up the renderer. GameWindow keeps only the wiring + closures over its runtime state (Code Structure Rule 1). - ---- - -## Task 1: `TeleportArrivalController` (pure state machine, TDD) - -**Files:** -- Create: `src/AcDream.App/World/TeleportArrivalController.cs` -- Test: `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs`: - -```csharp -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.World; -using Xunit; - -namespace AcDream.App.Tests.World; - -public class TeleportArrivalControllerTests -{ - // Records each Place(destPos, destCell, forced) call. - private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced); - - private static TeleportArrivalController Make( - ArrivalReadiness verdict, - List placed, - int maxHoldFrames = TeleportArrivalController.DefaultMaxHoldFrames) - => new( - readiness: (_, _) => verdict, - place: (pos, cell, forced) => placed.Add(new PlaceCall(pos, cell, forced)), - maxHoldFrames: maxHoldFrames); - - [Fact] - public void BeginArrival_EntersHolding() - { - var placed = new List(); - var c = Make(ArrivalReadiness.NotReady, placed); - - c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); - - Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); - Assert.Empty(placed); - } - - [Fact] - public void Tick_WhenIdle_IsNoOp() - { - var placed = new List(); - var c = Make(ArrivalReadiness.Ready, placed); - - c.Tick(); // never began - - Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); - Assert.Empty(placed); - } - - [Fact] - public void Tick_NotReady_KeepsHolding_DoesNotPlace() - { - var placed = new List(); - var c = Make(ArrivalReadiness.NotReady, placed); - c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); - - c.Tick(); - c.Tick(); - - Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); - Assert.Empty(placed); - } - - [Fact] - public void Tick_Ready_PlacesUnforced_AndIdles() - { - var placed = new List(); - var c = Make(ArrivalReadiness.Ready, placed); - c.BeginArrival(new Vector3(30, -60, 6.005f), 0x01250126u); - - c.Tick(); - - Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); - var call = Assert.Single(placed); - Assert.False(call.Forced); - Assert.Equal(0x01250126u, call.Cell); - Assert.Equal(new Vector3(30, -60, 6.005f), call.Pos); - } - - [Fact] - public void Tick_Impossible_PlacesForced_AndIdles() - { - var placed = new List(); - var c = Make(ArrivalReadiness.Impossible, placed); - c.BeginArrival(new Vector3(1, 2, 3), 0x0125FF00u); - - c.Tick(); - - Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); - var call = Assert.Single(placed); - Assert.True(call.Forced); - } - - [Fact] - public void Tick_Timeout_PlacesForced_AfterMaxHoldFrames() - { - var placed = new List(); - var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3); - c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); - - c.Tick(); // 1 - c.Tick(); // 2 - Assert.Empty(placed); - Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); - - c.Tick(); // 3 -> timeout - - var call = Assert.Single(placed); - Assert.True(call.Forced); - Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); - } - - [Fact] - public void BeginArrival_AfterPlace_ReArms() - { - var placed = new List(); - var c = Make(ArrivalReadiness.Ready, placed); - - c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u); - c.Tick(); // places #1, idle - c.BeginArrival(new Vector3(2, 0, 0), 0x01250127u); - c.Tick(); // places #2, idle - - Assert.Equal(2, placed.Count); - Assert.Equal(0x01250127u, placed[1].Cell); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"` -Expected: FAIL — `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` do not exist (compile error). - -- [ ] **Step 3: Write the implementation** - -Create `src/AcDream.App/World/TeleportArrivalController.cs`: - -```csharp -using System; -using System.Numerics; - -namespace AcDream.App.World; - -/// Verdict from the per-frame readiness probe for a held teleport arrival. -public enum ArrivalReadiness -{ - /// Destination not yet hydrated; keep holding. - NotReady, - - /// Destination terrain + cell are ready; place now. - Ready, - - /// The claim can never hydrate (e.g. an indoor cell id outside the dat's - /// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net - /// demote rather than hold forever. - Impossible, -} - -/// Lifecycle of a single teleport arrival. -public enum TeleportArrivalPhase { Idle, Holding } - -/// -/// G.3a (#133) — holds a teleport arrival in portal space until the destination -/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the -/// unconditional snap in GameWindow.OnLivePositionUpdated that resolved the -/// arrival against the resident (old) landblocks before the destination hydrated -/// and landed the player in ocean. -/// -/// The controller is pure: readiness and placement are injected delegates, -/// so it carries no GL / dat / network dependency and is fully unit-testable. The -/// player stays input-frozen while this is Holding because the GameWindow keeps -/// PlayerState.PortalSpace until the placement delegate flips it back to -/// InWorld. -/// -/// The timeout is a coarse frame count (not wall-clock) so the controller -/// needs no external clock; it is a loud safety net for a never-hydrating -/// destination, not a precise deadline. -/// -public sealed class TeleportArrivalController -{ - /// ~10 s at 60 fps. Coarse safety net for a destination that never streams. - public const int DefaultMaxHoldFrames = 600; - - private readonly Func _readiness; - private readonly Action _place; // (destPos, destCell, forced) - private readonly int _maxHoldFrames; - - private Vector3 _destPos; - private uint _destCell; - private int _heldFrames; - - public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle; - - public TeleportArrivalController( - Func readiness, - Action place, - int maxHoldFrames = DefaultMaxHoldFrames) - { - _readiness = readiness ?? throw new ArgumentNullException(nameof(readiness)); - _place = place ?? throw new ArgumentNullException(nameof(place)); - _maxHoldFrames = maxHoldFrames; - } - - /// Begin holding for a teleport arrival. Called from OnLivePositionUpdated - /// AFTER the streaming origin has been recentered on the destination landblock. - /// Re-calling with a fresh server position resets the hold (server-authoritative). - public void BeginArrival(Vector3 destPos, uint destCell) - { - _destPos = destPos; - _destCell = destCell; - _heldFrames = 0; - Phase = TeleportArrivalPhase.Holding; - } - - /// Per-frame: evaluate readiness and place when ready / impossible / timed out. - /// No-op when Idle. - public void Tick() - { - if (Phase != TeleportArrivalPhase.Holding) return; - _heldFrames++; - - ArrivalReadiness verdict = _readiness(_destPos, _destCell); - if (verdict == ArrivalReadiness.Ready) - { - Place(forced: false); - return; - } - - if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames) - { - Place(forced: true); - } - // else NotReady -> keep holding - } - - private void Place(bool forced) - { - _place(_destPos, _destCell, forced); - Phase = TeleportArrivalPhase.Idle; - } -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"` -Expected: PASS (7 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/World/TeleportArrivalController.cs tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs -git commit -m "feat(G.3a): TeleportArrivalController hold-until-hydration state machine (#133) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: Wire `TeleportArrivalController` into GameWindow - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add field; lazy construct + 2 callbacks; replace the arrival snap at `:4877-4961`; per-frame `Tick` after `:6838`) - -This task has no isolated unit test (it edits the 10k-line runtime god-object). It is verified by `dotnet build` + `dotnet test` green and the Task 4 visual gate. Make the edits exactly as shown. - -- [ ] **Step 1: Add the field + the lazy-construct helper + the two callbacks** - -Add near the other player/teleport fields in `GameWindow.cs` (anywhere in the field region; e.g. just above `OnTeleportStarted` at `:4971`): - -```csharp -// G.3a (#133): holds a teleport arrival in portal space until the destination -// dungeon landblock/cell has hydrated, then places the player via the unchanged -// validated-claim Resolve path. Lazily constructed on the first teleport (all -// runtime deps are wired by then). -private AcDream.App.World.TeleportArrivalController? _teleportArrival; -private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity; - -private void EnsureTeleportArrivalController() -{ - if (_teleportArrival is not null) return; - _teleportArrival = new AcDream.App.World.TeleportArrivalController( - readiness: TeleportArrivalReadiness, - place: PlaceTeleportArrival); -} - -// Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated -// against the teleport's (destPos, destCell): an impossible indoor claim short- -// circuits to immediate placement; otherwise hold until terrain is sampled and, -// for an indoor cell, the cell struct has hydrated. -private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness( - System.Numerics.Vector3 destPos, uint destCell) -{ - if (IsSpawnClaimUnhydratable(destCell)) - return AcDream.App.World.ArrivalReadiness.Impossible; - if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null) - return AcDream.App.World.ArrivalReadiness.NotReady; - bool indoor = (destCell & 0xFFFFu) >= 0x0100u; - if (indoor && !_physicsEngine.IsSpawnCellReady(destCell)) - return AcDream.App.World.ArrivalReadiness.NotReady; - return AcDream.App.World.ArrivalReadiness.Ready; -} - -// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only -// once the destination is ready (or force-run on impossible/timeout, logged loud). -private void PlaceTeleportArrival( - System.Numerics.Vector3 destPos, uint destCell, bool forced) -{ - var resolved = _physicsEngine.Resolve( - destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight); - var snappedPos = new System.Numerics.Vector3( - resolved.Position.X, resolved.Position.Y, resolved.Position.Z); - - if (forced) - Console.WriteLine( - $"live: teleport HOLD gave up (impossible/timeout) — force-snapping " + - $"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}"); - - if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) - { - pe.SetPosition(snappedPos); - pe.ParentCellId = resolved.CellId; - pe.Rotation = _pendingTeleportRot; - } - _playerController.SetPosition(snappedPos, resolved.CellId); - - _chaseCamera?.Update(snappedPos, _playerController.Yaw); - _retailChaseCamera?.Update(snappedPos, _playerController.Yaw, - playerVelocity: System.Numerics.Vector3.Zero, - isOnGround: true, - contactPlaneNormal: System.Numerics.Vector3.UnitZ, - dt: 1f / 60f); - - _playerController.State = AcDream.App.Input.PlayerState.InWorld; - Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); - - // Tell the server the client finished loading the new landblock (holtburger - // client/messages.rs:434 — re-send LoginComplete after each portal transition). - _liveSession?.SendGameAction( - AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); -} -``` - -- [ ] **Step 2: Construct the controller when a teleport starts** - -In `OnTeleportStarted` (`GameWindow.cs:4971-4976`), add the ensure-call after setting PortalSpace: - -```csharp -private void OnTeleportStarted(uint sequence) -{ - if (_playerController is not null) - _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; - EnsureTeleportArrivalController(); - Console.WriteLine($"live: teleport started (seq={sequence})"); -} -``` - -- [ ] **Step 3: Replace the unconditional arrival snap with recenter + BeginArrival** - -Replace the entire arrival block at `GameWindow.cs:4877-4961` (from `// Phase B.3: portal-space arrival detection.` through its closing brace) with: - -```csharp - // Phase B.3 / G.3a (#133): portal-space arrival detection. - // Only runs for our own player character while in PortalSpace. - if (_playerController is not null - && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace - && update.Guid == _playerServerGuid) - { - // Compute old landblock coords from controller position (using the - // current streaming origin as the reference center). - var oldPos = _playerController.Position; - int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f); - int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f); - - bool differentLandblock = (lbX != oldLbX || lbY != oldLbY); - - Console.WriteLine( - $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + - $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); - - System.Numerics.Vector3 newWorldPos; - if (differentLandblock) - { - // Recenter the streaming controller on the new landblock NOW (kick - // off the dungeon load). After recentering, the destination is - // (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin. - _liveCenterX = lbX; - _liveCenterY = lbY; - newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); - } - else - { - newWorldPos = worldPos; - } - - // G.3a: do NOT snap here. The destination dungeon landblock has not - // streamed in yet; an immediate Resolve falls back to the resident - // (old) landblocks and lands the player in ocean (#133). HOLD the snap - // in portal space — TeleportArrivalController.Tick (per frame) places - // the player via PlaceTeleportArrival once the destination cell - // hydrates (TeleportArrivalReadiness == Ready), or force-places on an - // impossible claim / timeout. PortalSpace keeps input frozen meanwhile. - EnsureTeleportArrivalController(); - _pendingTeleportRot = rot; - _teleportArrival!.BeginArrival(newWorldPos, p.LandblockId); - } -``` - -- [ ] **Step 4: Add the per-frame Tick after the live-session drain** - -In `OnUpdate`, immediately after `_liveSessionController?.Tick();` (`GameWindow.cs:6838`), add: - -```csharp - // G.3a (#133): advance any held teleport arrival. Runs AFTER streaming - // (which applies the destination landblock) and the live-session drain - // (which may have just called BeginArrival), so a destination that - // hydrated this frame is placed the same frame. - _teleportArrival?.Tick(); -``` - -- [ ] **Step 5: Build + run the full suites** - -Run: `dotnet build` -Expected: build succeeds (0 errors). - -Run: `dotnet test` -Expected: all suites green (App / Core / UI / Net) — no regressions. (Counts at baseline: App 264+1skip / Core 1445+2skip / UI 420 / Net 294.) - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(G.3a): hold teleport arrival until dungeon hydrates, then place (#133) - -Replaces the unconditional OnLivePositionUpdated snap (which resolved against -the resident old landblocks before the destination streamed in -> ocean) with a -recenter + deferred BeginArrival; per-frame Tick places via the unchanged #111 -validated-claim Resolve once SampleTerrainZ + IsSpawnCellReady report ready, or -force-snaps loudly on an impossible claim / ~10s timeout. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: Decouple EnvCell physics/visibility hydration from the render-mesh guard - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs:5601-5652` - -**Why:** `BuildLoadedCell` (the portal-visibility node) and `CacheCellStruct` (the physics BSP) currently sit *inside* `if (cellSubMeshes.Count > 0)`. A collision cell with an empty render mesh would silently get no collision and no visibility node — retail couples neither to visible geometry. This is insurance for any geometry-less dungeon cell. **It touches the shared (building) hydration path**, so its acceptance includes a no-regression check on the frozen building/cellar demo. - -- [ ] **Step 1: Make the edit** - -In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5601-5652`), the current shape is: - -```csharp -var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); -if (cellSubMeshes.Count > 0) -{ - _pendingCellMeshes[envCellId] = cellSubMeshes; - var physicsCellOrigin = envCell.Position.Origin + lbOffset; - var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( - 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); - var cellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); - var physicsCellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); - - _envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... */); - BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); - _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); -} -``` - -Restructure so the transforms + physics/visibility hydration run unconditionally (they don't depend on visible geometry), and only the render registration stays behind the submesh-count guard: - -```csharp -var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); - -// G.3a (#133) hydration decouple: the cell transforms and the physics + -// visibility hydration are INDEPENDENT of whether the cell has drawable -// geometry. Retail couples neither collision nor portal visibility to a render -// mesh. Previously these sat behind `cellSubMeshes.Count > 0`, which silently -// dropped collision (CellTransit.GetCellStruct -> null -> fall through floor) -// and the visibility node for any geometry-less collision cell. CacheCellStruct -// self-gates on a null PhysicsBSP (PhysicsDataCache.cs:172), so this is safe for -// cells that genuinely have no physics. -var physicsCellOrigin = envCell.Position.Origin + lbOffset; -var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( - 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); -var cellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); -var physicsCellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); - -BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); -_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); - -// Render registration only when the cell actually has drawable submeshes. -if (cellSubMeshes.Count > 0) -{ - _pendingCellMeshes[envCellId] = cellSubMeshes; - _envCellRenderer?.RegisterCell(/* ... cellTransform, cellOrigin ... — UNCHANGED args ... */); -} -``` - -Keep the `_envCellRenderer?.RegisterCell(...)` call's argument list exactly as it is today (`cellTransform`, `cellOrigin`, etc.) — only its position in the block changes (now inside the `Count > 0` guard, with the transforms hoisted above). - -- [ ] **Step 2: Build + run the full suites** - -Run: `dotnet build` -Expected: build succeeds. - -Run: `dotnet test` -Expected: all suites green — in particular no regression in any existing EnvCell / streaming / membership test. - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "fix(G.3a): hydrate EnvCell physics + visibility independent of render mesh (#133) - -BuildLoadedCell + CacheCellStruct were gated behind cellSubMeshes.Count > 0, so a -geometry-less collision cell got no collision (fall-through) and no visibility -node. Retail couples neither to visible geometry; CacheCellStruct self-gates on a -null PhysicsBSP, so this is safe. Render registration stays behind the submesh -guard. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: Visual acceptance gate (STOP — user verification) - -This is the M1.5 dungeon-demo gate and the empirical test of #95 + the hydration decouple. It cannot be automated; hand the running client to the user. - -- [ ] **Step 1: Build green** - -Run: `dotnet build` -Expected: 0 errors. - -- [ ] **Step 2: Launch against the live ACE server** - -```powershell -$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" -$env:ACDREAM_LIVE = "1" -$env:ACDREAM_TEST_HOST = "127.0.0.1" -$env:ACDREAM_TEST_PORT = "9000" -$env:ACDREAM_TEST_USER = "testaccount" -$env:ACDREAM_TEST_PASS = "testpassword" -$env:ACDREAM_PROBE_CELL = "1" -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-g3a-gate.log" -``` - -Run in the background; give it ~8 s to reach in-world. Use the meeting-hall portal (or `/ls` once G.3d lands) to teleport into the dungeon. - -- [ ] **Step 2: User verifies (the acceptance criteria)** - -The user confirms, in the running client: -- Player **stands in the dungeon cell**, on the floor — not ocean, not falling. -- The dungeon renders; the user can **navigate 3-5 rooms**; **walls block** movement. -- **No ocean / no ACE `failed transition` spam** (check the ACE console + `launch-g3a-gate.log`). -- **#95 check:** no see-through-walls, no other-dungeon geometry rendering inside the current dungeon (if it DOES blow up → proceed to the G.3b plan). -- **Hydration-decouple no-regression:** re-walk a Holtburg building + cellar (the frozen M1.5 demo) — walls still block, no new phantom collisions, interiors render as before. - -- [ ] **Step 3: On pass — record the milestone progress** - -- Move #133 to **Recently closed** in `docs/ISSUES.md` with the G.3a commit SHAs. -- If #95 did NOT reproduce, add a one-line note closing #95 as superseded (its repro was the T4-deleted WB cell-cache path); if it DID, leave #95 open and start the G.3b plan. -- Update the roadmap G.3 row + the milestones doc (G.3a core landed). -- Then proceed to the G.3c (faithful `TeleportAnimState`) and G.3d (recalls) plans. - ---- - -## Self-Review - -**Spec coverage (against `2026-06-13-dungeon-support-design.md` §3.1):** -- Hold-until-hydration on the arrival path → Task 2 (BeginArrival + Tick). -- Reuse #107 `IsSpawnCellReady` + `IsSpawnClaimUnhydratable` → Task 2 `TeleportArrivalReadiness`. -- #111 validated-claim EnvCell placement → Task 2 `PlaceTeleportArrival` (unchanged `Resolve`). -- Readiness predicate reuses `SampleTerrainZ` (the synced refinement) → Task 2. -- Dest-coord validation → handled by the Impossible (indoor) + timeout (outdoor) paths; **no separate task** (YAGNI — the timeout IS the malformed-dest safety net; noted in spec §10.3). -- Timeout safety (fail loudly, never freeze) → Task 1 `_maxHoldFrames` + Task 2 forced-place loud log. -- Decouple physics/visibility hydration from the render-mesh guard → Task 3. -- Visual gate (also settles #95 + hydration coupling) → Task 4. - -**Placeholder scan:** Task 1 + its tests are complete code. Task 2/3 are exact edits with full code; the only `/* ... */` is the deliberately-unchanged `RegisterCell(...)` arg list (instruction: keep verbatim, only move it) — not a content gap. Task 4 is a manual gate (correctly not code). - -**Type consistency:** `TeleportArrivalController` / `ArrivalReadiness` / `TeleportArrivalPhase` and the delegate shapes `Func` + `Action` match between Task 1's class, its tests, and Task 2's `EnsureTeleportArrivalController` / `TeleportArrivalReadiness` / `PlaceTeleportArrival`. `BeginArrival(Vector3,uint)` and `Tick()` signatures match across all three. - -**Deferred to other plans (out of G.3a scope):** #95 stab_list bounding (G.3b, conditional), `TeleportAnimState` tunnel FSM (G.3c), recall game-actions (G.3d). diff --git a/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md b/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md deleted file mode 100644 index 5fff7b20..00000000 --- a/docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md +++ /dev/null @@ -1,1322 +0,0 @@ -# D.2b Retail Panel Frame + Live Vitals — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Render a retail-shaped Vitals window (8-piece dat-sprite frame + live HP/Stam/Mana bars) by wiring the dormant `AcDream.App/UI` retained-mode toolkit and adding a markup/stylesheet/sprite layer, gated behind `ACDREAM_RETAIL_UI=1`. - -**Architecture:** The retail UI is the **existing `UiRoot`/`UiElement` tree** driven by `UiHost` (dormant today) — a separate system from the ImGui devtools path. Spec 1 wires `UiHost` into `GameWindow`, extends the shared `TextRenderer` with a textured-sprite path, adds `UiNineSlicePanel` (chrome) + `UiMeter` (bar) widgets, a `MarkupDocument` that instantiates a `UiElement` subtree from XML, and a `controls.ini` stylesheet loader. Render-only (input integration deferred). Spec: [`docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md`](../specs/2026-06-14-d2b-retail-panel-frame-design.md). - -**Tech Stack:** C# / .NET 10, Silk.NET OpenGL, xUnit 2.9.3. Dat assets via the existing `TextureCache` + `SurfaceDecoder`. - ---- - -## File Structure - -**New files:** -- `src/AcDream.App/UI/UiNineSlicePanel.cs` — `UiPanel` subclass drawing the 8-piece dat-sprite frame + center fill. -- `src/AcDream.App/UI/UiMeter.cs` — `UiElement` vital bar (bg + partial fill). -- `src/AcDream.App/UI/RetailChromeSprites.cs` — confirmed chrome sprite DataIDs + sizes + insets (filled by Step 0). -- `src/AcDream.App/UI/ControlsIni.cs` — flat INI stylesheet parser (`#AARRGGBB`, `font://`). -- `src/AcDream.App/UI/MarkupDocument.cs` — XML → `UiElement` subtree builder + `{Binding}` resolution. -- `src/AcDream.App/UI/assets/vitals.xml` — the first-party vitals markup (copied to output). -- `src/AcDream.Plugin.Abstractions/IUiRegistry.cs` — plugin-facing UI registration surface. -- `src/AcDream.App/Plugins/BufferedUiRegistry.cs` — buffers `AddMarkupPanel` until `UiHost` exists. -- `tests/AcDream.App.Tests/UI/ControlsIniTests.cs`, `MarkupDocumentTests.cs`, `UiMeterTests.cs`, `UiNineSlicePanelTests.cs` -- `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs` -- `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs` - -**Modified files:** -- `src/AcDream.App/RuntimeOptions.cs` — add `RetailUi`, `AcDir`. -- `src/AcDream.App/Rendering/Shaders/ui_text.frag` — add `uUseTexture==2` RGBA branch. -- `src/AcDream.App/Rendering/TextRenderer.cs` — add `DrawSprite` + per-texture batch + `DepthMask`. -- `src/AcDream.App/Rendering/TextureCache.cs` — add `GetOrUpload(id, out w, out h)` size overload. -- `src/AcDream.App/UI/UiRenderContext.cs` — add `DrawSprite` forwarder. -- `src/AcDream.App/Rendering/GameWindow.cs` — wire `UiHost` + vitals subtree (render-only). -- `src/AcDream.Plugin.Abstractions/IPluginHost.cs` + `src/AcDream.App/Plugins/AppPluginHost.cs` — add `Ui`. -- `src/AcDream.App/Program.cs` — construct `BufferedUiRegistry`, pass to host + window. -- `docs/architecture/retail-divergence-register.md` — delete TS-30, add IA row (in the chrome commit). - ---- - -## Task 1: RuntimeOptions — add RetailUi + AcDir toggles - -**Files:** -- Modify: `src/AcDream.App/RuntimeOptions.cs` -- Test: `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs`: - -```csharp -using System.Collections.Generic; -using AcDream.App; - -namespace AcDream.App.Tests; - -public class RuntimeOptionsRetailUiTests -{ - [Fact] - public void Parse_ReadsRetailUiAndAcDir() - { - var env = new Dictionary - { - ["ACDREAM_RETAIL_UI"] = "1", - ["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call", - }; - var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); - Assert.True(opts.RetailUi); - Assert.Equal(@"C:\Turbine\Asheron's Call", opts.AcDir); - } - - [Fact] - public void Parse_DefaultsRetailUiOffAndAcDirNull() - { - var opts = RuntimeOptions.Parse("dats", _ => null); - Assert.False(opts.RetailUi); - Assert.Null(opts.AcDir); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter RuntimeOptionsRetailUiTests` -Expected: FAIL to **compile** — `RetailUi` / `AcDir` are not members of `RuntimeOptions`. - -- [ ] **Step 3: Add the fields** - -In `src/AcDream.App/RuntimeOptions.cs`, add two parameters at the **end** of the record (line 42, after `int? LegacyStreamRadius`): - -```csharp - int? LegacyStreamRadius, - bool RetailUi, - string? AcDir) -``` - -And in `Parse` (after the `LegacyStreamRadius:` line, before the closing `);`): - -```csharp - LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), - RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), - AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); -``` - -- [ ] **Step 4: Fix any positional construction sites** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj` -If any `new RuntimeOptions(...)` positional call site fails to compile (missing 2 args), append `, RetailUi: false, AcDir: null` to it. (`Program.cs` uses `FromEnvironment`→`Parse` with named args and is unaffected.) - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter RuntimeOptionsRetailUiTests` -Expected: PASS (2 tests). - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/RuntimeOptions.cs tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs -git commit -m "feat(D.2b): RuntimeOptions.RetailUi + AcDir toggles" -``` - ---- - -## Task 2: Dat-sprite render capability - -GL code — verified by build + the Step-3 visual, not unit tests. - -**Files:** -- Modify: `src/AcDream.App/Rendering/Shaders/ui_text.frag` -- Modify: `src/AcDream.App/Rendering/TextRenderer.cs` -- Modify: `src/AcDream.App/Rendering/TextureCache.cs` -- Modify: `src/AcDream.App/UI/UiRenderContext.cs` - -- [ ] **Step 1: Add the RGBA branch to the fragment shader** - -In `src/AcDream.App/Rendering/Shaders/ui_text.frag`, replace the `main()` body's branch: - -```glsl -void main() { - if (uUseTexture == 1) { - // Font atlas is a single-channel R8 texture; red = coverage alpha. - float coverage = texture(uTex, vUv).r; - FragColor = vec4(vColor.rgb, vColor.a * coverage); - } else if (uUseTexture == 2) { - // RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha. - FragColor = texture(uTex, vUv) * vColor; - } else { - FragColor = vColor; - } - if (FragColor.a < 0.005) discard; -} -``` - -- [ ] **Step 2: Add a size-returning overload to TextureCache** - -In `src/AcDream.App/Rendering/TextureCache.cs`, add a size cache field next to `_handlesBySurfaceId` (top-of-class field region): - -```csharp - private readonly Dictionary _sizeBySurfaceId = new(); -``` - -And add this method directly after `GetOrUpload(uint surfaceId)` (after line 81): - -```csharp - /// - /// Like but also returns the decoded - /// pixel dimensions. UI 9-slice geometry needs the source size to - /// compute slice UVs. Cached alongside the handle. - /// - public uint GetOrUpload(uint surfaceId, out int width, out int height) - { - if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing) - && _sizeBySurfaceId.TryGetValue(surfaceId, out var sz)) - { - width = sz.w; height = sz.h; - return existing; - } - - var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); - uint h = UploadRgba8(decoded); - _handlesBySurfaceId[surfaceId] = h; - _sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height); - width = decoded.Width; height = decoded.Height; - return h; - } -``` - -- [ ] **Step 3: Add the textured-sprite path to TextRenderer** - -In `src/AcDream.App/Rendering/TextRenderer.cs`, add a per-texture sprite buffer field (next to `_textBuf`/`_rectBuf`, ~line 31): - -```csharp - private readonly Dictionary> _spriteBufs = new(); -``` - -Clear it in `Begin` (inside the existing `Begin`, after `_rectBuf.Clear();`): - -```csharp - foreach (var b in _spriteBufs.Values) b.Clear(); -``` - -Add the public draw method (after `DrawString`, ~line 130): - -```csharp - /// - /// Draw a textured sprite quad in screen pixel space with an explicit - /// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per - /// GL texture handle; flushed with uUseTexture=2 (RGBA modulate). - /// - public void DrawSprite(uint texture, float x, float y, float w, float h, - float u0, float v0, float u1, float v1, Vector4 tint) - { - if (!_spriteBufs.TryGetValue(texture, out var buf)) - { - buf = new List(256); - _spriteBufs[texture] = buf; - } - AppendQuad(buf, x, y, w, h, u0, v0, u1, v1, tint); - } -``` - -In `Flush`, (a) change the early-out so sprites alone still draw, (b) set `DepthMask(false)` + restore, (c) draw the sprite batches. Replace the existing `Flush` body's guard and state block down through the text draw: - -Replace: -```csharp - if (_textVerts == 0 && _rectVerts == 0) return; -``` -with: -```csharp - bool hasSprites = false; - foreach (var b in _spriteBufs.Values) if (b.Count > 0) { hasSprites = true; break; } - if (_textVerts == 0 && _rectVerts == 0 && !hasSprites) return; -``` - -Replace the state-save block: -```csharp - bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); - bool wasBlend = _gl.IsEnabled(EnableCap.Blend); - bool wasCull = _gl.IsEnabled(EnableCap.CullFace); - _gl.Disable(EnableCap.DepthTest); - _gl.Disable(EnableCap.CullFace); - _gl.Enable(EnableCap.Blend); - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); -``` -with (adds DepthMask off; restored to true below): -```csharp - bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); - bool wasBlend = _gl.IsEnabled(EnableCap.Blend); - bool wasCull = _gl.IsEnabled(EnableCap.CullFace); - _gl.Disable(EnableCap.DepthTest); - _gl.Disable(EnableCap.CullFace); - _gl.DepthMask(false); - _gl.Enable(EnableCap.Blend); - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); -``` - -Add the sprite-draw block immediately **after** the text-glyph block (after the `if (_textVerts > 0 && font is not null) { ... }` block, before "Restore GL state"): - -```csharp - // RGBA dat sprites — one draw call per distinct GL texture. - if (hasSprites) - { - _shader.SetInt("uUseTexture", 2); - _gl.ActiveTexture(TextureUnit.Texture0); - _shader.SetInt("uTex", 0); - foreach (var kv in _spriteBufs) - { - if (kv.Value.Count == 0) continue; - _gl.BindTexture(TextureTarget.Texture2D, kv.Key); - UploadBuffer(kv.Value); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(kv.Value.Count / FloatsPerVertex)); - } - } -``` - -Add DepthMask restore in the "Restore GL state" block (after the existing three restores). Restore to `true` — the next frame's depth *clear* requires depth writes enabled, so `true` is the correct (and only safe) post-UI value: -```csharp - _gl.DepthMask(true); -``` - -- [ ] **Step 4: Add the DrawSprite forwarder to UiRenderContext** - -In `src/AcDream.App/UI/UiRenderContext.cs`, after the `DrawRectOutline` forwarder (line 54): - -```csharp - public void DrawSprite(uint texture, float x, float y, float w, float h, - float u0, float v0, float u1, float v1, Vector4 tint) - => TextRenderer.DrawSprite(texture, - _current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, tint); -``` - -- [ ] **Step 5: Build** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj` -Expected: build succeeds. - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/Rendering/Shaders/ui_text.frag src/AcDream.App/Rendering/TextRenderer.cs src/AcDream.App/Rendering/TextureCache.cs src/AcDream.App/UI/UiRenderContext.cs -git commit -m "feat(D.2b): textured-sprite path in TextRenderer + UV-rect DrawSprite" -``` - ---- - -## Task 3: Step-0 chrome sprite prove-out (HUMAN-IN-THE-LOOP) - -Resolves the unverified chrome sprite IDs empirically (spec §6). Requires the user to run the client and eyeball candidates. - -**Files:** -- Create: `src/AcDream.App/UI/RetailChromeSprites.cs` -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (temporary prove-out block) - -- [ ] **Step 1: Create the constants file (empty placeholders to be filled by the run)** - -Create `src/AcDream.App/UI/RetailChromeSprites.cs`: - -```csharp -namespace AcDream.App.UI; - -/// -/// Confirmed retail window-chrome RenderSurface DataIDs + decoded sizes + -/// 9-slice insets. Values are filled by the Step-0 prove-out run (see -/// docs/superpowers/plans/2026-06-14-d2b-retail-panel-frame-plan.md, Task 3) -/// — do NOT trust pre-run values. Candidates dumped by the prove-out harness. -/// -public static class RetailChromeSprites -{ - // Candidate IDs to try in the Step-0 prove-out. Edit this list as needed. - public static readonly uint[] Candidates = - { - 0x06004CC2, 0x060074BF, 0x060074C0, 0x060074C1, 0x060074C2, - 0x060074C3, 0x060074C4, 0x060074C5, 0x060074C6, 0x0600129C, - }; - - // === FILLED BY STEP 0 (placeholder = magenta until confirmed) === - /// The single 9-sliceable frame sprite (or the body/center fill). - public static uint FrameSurfaceId = 0; // TODO Step 0: set to confirmed id - /// Corner inset in pixels (left/top/right/bottom assumed equal until LayoutDesc parse). - public static int Inset = 6; // TODO Step 0: tune to the real bevel -} -``` - -- [ ] **Step 2: Add a temporary prove-out block to OnRender** - -In `src/AcDream.App/Rendering/GameWindow.cs`, in `OnRender` after the 3D passes (just before the ImGui block at ~line 8158), add: - -```csharp - // Step-0 prove-out (D.2b Task 3): draw candidate chrome sprites in a - // labelled row so we can eyeball which decode to frame art. Gated by - // ACDREAM_RETAIL_UI_PROVEOUT=1. TEMPORARY — delete after Step 0. - if (System.Environment.GetEnvironmentVariable("ACDREAM_RETAIL_UI_PROVEOUT") == "1" - && _textureCache is not null && _textRenderer is not null) - { - _textRenderer.Begin(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); - float px = 20f; - foreach (var id in AcDream.App.UI.RetailChromeSprites.Candidates) - { - uint tex = _textureCache.GetOrUpload(id, out int tw, out int th); - _textRenderer.DrawSprite(tex, px, 60f, 96f, 96f, 0, 0, 1, 1, - System.Numerics.Vector4.One); - if (_debugFont is not null) - _textRenderer.DrawString(_debugFont, $"0x{id:X8}\n{tw}x{th}", px, 160f, - System.Numerics.Vector4.One); - px += 110f; - } - _textRenderer.Flush(_debugFont); - } -``` - -- [ ] **Step 3: Build + run the prove-out (manual)** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj` -Then launch with the prove-out flag (PowerShell): - -```powershell -$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" -$env:ACDREAM_RETAIL_UI_PROVEOUT = "1" -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath proveout.log -``` - -**Manual:** the user reports which candidate IDs render as frame/border art (vs magenta vs unrelated sprites) and their printed sizes. If the frame is a single 9-sliceable sprite, note that ID + size. If it's separate corner/edge sprites, note each. Tune `Candidates` and re-run if none match (widen the `0x0600xxxx` range near `0x060074xx`). - -- [ ] **Step 4: Record the confirmed values** - -Edit `RetailChromeSprites.cs`: set `FrameSurfaceId` to the confirmed id and `Inset` to the eyeballed bevel thickness. Add a comment with the decoded `WxH` and the date. - -- [ ] **Step 5: Remove the temporary prove-out block** - -Delete the `ACDREAM_RETAIL_UI_PROVEOUT` block from `GameWindow.cs` (it was scaffolding). - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/RetailChromeSprites.cs src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(D.2b): Step-0 chrome sprite prove-out + confirmed RetailChromeSprites ids" -``` - ---- - -## Task 4: UiNineSlicePanel - -**Files:** -- Create: `src/AcDream.App/UI/UiNineSlicePanel.cs` -- Test: `tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs` - -- [ ] **Step 1: Write the failing geometry test** - -Create `tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs`: - -```csharp -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class UiNineSlicePanelTests -{ - [Fact] - public void ComputeSliceRects_ProducesNinePatchesCoveringTheFrame() - { - // 100x80 frame, 32x32 source texture, 8px inset. - var rects = UiNineSlicePanel.ComputeSliceRects( - frameW: 100, frameH: 80, texW: 32, texH: 32, inset: 8); - - Assert.Equal(9, rects.Length); - - // Top-left corner: dst (0,0,8,8); src uv (0,0)-(8/32, 8/32). - var tl = rects[0]; - Assert.Equal(0f, tl.dstX); Assert.Equal(0f, tl.dstY); - Assert.Equal(8f, tl.dstW); Assert.Equal(8f, tl.dstH); - Assert.Equal(0f, tl.u0); Assert.Equal(0f, tl.v0); - Assert.Equal(8f / 32f, tl.u1, 5); Assert.Equal(8f / 32f, tl.v1, 5); - - // Center: dst (8,8, 100-16, 80-16); src uv inset..(tex-inset). - var center = rects[4]; - Assert.Equal(8f, center.dstX); Assert.Equal(8f, center.dstY); - Assert.Equal(84f, center.dstW); Assert.Equal(64f, center.dstH); - Assert.Equal(8f / 32f, center.u0, 5); - Assert.Equal(24f / 32f, center.u1, 5); - - // Bottom-right corner dst origin at (100-8, 80-8). - var br = rects[8]; - Assert.Equal(92f, br.dstX); Assert.Equal(72f, br.dstY); - Assert.Equal(8f, br.dstW); Assert.Equal(8f, br.dstH); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiNineSlicePanelTests` -Expected: FAIL to compile — `UiNineSlicePanel` does not exist. - -- [ ] **Step 3: Implement UiNineSlicePanel** - -Create `src/AcDream.App/UI/UiNineSlicePanel.cs`: - -```csharp -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// A whose background is a 9-sliced dat RenderSurface: -/// 4 fixed corners, 4 stretched edges, 1 stretched center. Retires the flat -/// translucent rect (divergence row TS-30). Insets come from -/// until the LayoutDesc importer supplies -/// per-panel metrics. -/// -public sealed class UiNineSlicePanel : UiPanel -{ - /// One slice patch: destination rect (local px) + source UVs (0..1). - public readonly record struct Slice( - float dstX, float dstY, float dstW, float dstH, - float u0, float v0, float u1, float v1); - - private readonly System.Func _resolve; - private readonly uint _surfaceId; - private readonly int _inset; - - /// Surface id → (GL handle, decoded width, height). - /// In production: id => { var t = cache.GetOrUpload(id, out var w, out var h); return (t, w, h); }. - public UiNineSlicePanel(System.Func resolve, - uint surfaceId, int inset) - { - _resolve = resolve; - _surfaceId = surfaceId; - _inset = inset; - BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill - BorderColor = Vector4.Zero; - } - - /// - /// Compute the 9 patches for a frame of x - /// from a x - /// source with a uniform . - /// Order: TL, TC, TR, ML, MC, MR, BL, BC, BR (index 4 = center). - /// - public static Slice[] ComputeSliceRects( - float frameW, float frameH, int texW, int texH, int inset) - { - float i = inset; - // destination column/row edges - float[] dx = { 0, i, frameW - i, frameW }; - float[] dy = { 0, i, frameH - i, frameH }; - // source UV column/row edges (0..1) - float[] ux = { 0, i / texW, (texW - i) / texW, 1f }; - float[] uy = { 0, i / texH, (texH - i) / texH, 1f }; - - var slices = new Slice[9]; - int n = 0; - for (int row = 0; row < 3; row++) - for (int col = 0; col < 3; col++) - slices[n++] = new Slice( - dx[col], dy[row], dx[col + 1] - dx[col], dy[row + 1] - dy[row], - ux[col], uy[row], ux[col + 1], uy[row + 1]); - return slices; - } - - protected override void OnDraw(UiRenderContext ctx) - { - var (tex, tw, th) = _resolve(_surfaceId); - if (tex == 0 || tw == 0 || th == 0) return; - foreach (var s in ComputeSliceRects(Width, Height, tw, th, _inset)) - ctx.DrawSprite(tex, s.dstX, s.dstY, s.dstW, s.dstH, - s.u0, s.v0, s.u1, s.v1, Vector4.One); - } -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiNineSlicePanelTests` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/UiNineSlicePanel.cs tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs -git commit -m "feat(D.2b): UiNineSlicePanel (9-slice dat chrome) + geometry tests" -``` - ---- - -## Task 5: UiMeter - -**Files:** -- Create: `src/AcDream.App/UI/UiMeter.cs` -- Test: `tests/AcDream.App.Tests/UI/UiMeterTests.cs` - -- [ ] **Step 1: Write the failing fill-geometry test** - -Create `tests/AcDream.App.Tests/UI/UiMeterTests.cs`: - -```csharp -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class UiMeterTests -{ - [Fact] - public void ComputeFillRect_HalfFillIsHalfWidth() - { - var (x, y, w, h) = UiMeter.ComputeFillRect(0.5f, 200f, 12f); - Assert.Equal(0f, x); Assert.Equal(0f, y); - Assert.Equal(100f, w); Assert.Equal(12f, h); - } - - [Theory] - [InlineData(-1f, 0f)] // clamps below 0 - [InlineData(2f, 200f)] // clamps above 1 - [InlineData(0f, 0f)] - [InlineData(1f, 200f)] - public void ComputeFillRect_ClampsFraction(float pct, float expectedW) - { - var (_, _, w, _) = UiMeter.ComputeFillRect(pct, 200f, 12f); - Assert.Equal(expectedW, w); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiMeterTests` -Expected: FAIL to compile — `UiMeter` does not exist. - -- [ ] **Step 3: Implement UiMeter** - -Create `src/AcDream.App/UI/UiMeter.cs`: - -```csharp -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// A horizontal vital bar: an empty background rect with a partial-width -/// fill. returns 0..1 (or null = no data → empty bar). -/// Solid-color for Spec 1; the retail orb sprite + scissor crop is a later -/// sub-phase. -/// -public sealed class UiMeter : UiElement -{ - /// Fill fraction provider; null result draws an empty bar. - public System.Func Fill { get; set; } = () => 0f; - public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f); - public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); - - public UiMeter() { ClickThrough = true; } - - /// Clamp to [0,1] and return the fill - /// rect (local px) for a bar of x . - public static (float x, float y, float w, float h) ComputeFillRect( - float pct, float w, float h) - { - if (pct < 0f) pct = 0f; - if (pct > 1f) pct = 1f; - return (0f, 0f, w * pct, h); - } - - protected override void OnDraw(UiRenderContext ctx) - { - ctx.DrawRect(0, 0, Width, Height, BgColor); - float? pct = Fill(); - if (pct is float p) - { - var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height); - if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor); - } - } -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiMeterTests` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/UiMeter.cs tests/AcDream.App.Tests/UI/UiMeterTests.cs -git commit -m "feat(D.2b): UiMeter vital bar + fill-geometry tests" -``` - ---- - -## Task 6: Wire UiHost + hand-built vitals subtree (render-only) + retire TS-30 - -Visual-acceptance task. First on-screen retail panel. - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` -- Modify: `docs/architecture/retail-divergence-register.md` - -- [ ] **Step 1: Add the UiHost field** - -In `GameWindow.cs`, next to `_vitalsVm` (~line 614): - -```csharp - // Phase D.2b — retail-look UI tree. Null unless ACDREAM_RETAIL_UI=1. - private AcDream.App.UI.UiHost? _uiHost; -``` - -- [ ] **Step 2: Construct UiHost + the vitals subtree in OnLoad** - -In `GameWindow.cs` OnLoad, **after** `_textureCache` is constructed (after line 1724) and after `_vitalsVm` is available, add. Note: `_vitalsVm` is built today only inside the DevTools block (line 1330). Hoist its construction so it exists for the retail path too — change line 1330's block so the VM is created when `DevToolsEnabled || _options.RetailUi`. Concretely, ensure this runs regardless of DevTools: - -```csharp - _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); -``` - -**Also ungate the GUID setter:** the `_vitalsVm.SetLocalPlayerGuid(...)` call at EnterWorld (~line 1984) must run whenever `_vitalsVm` is non-null — not only under DevTools — or retail-only mode reads HP=1.0 forever. Change any `if (DevToolsEnabled)` guard around that call to `if (_vitalsVm is not null)` (use the null-conditional `_vitalsVm?.SetLocalPlayerGuid(guid);` if simpler). Verify the exact guard at the call site before editing. - -Then add the retail wiring (after `_textureCache` exists): - -```csharp - if (_options.RetailUi) - { - string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); - _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); - - var cache = _textureCache!; - (uint, int, int) Resolve(uint id) - { - uint t = cache.GetOrUpload(id, out int w, out int h); - return (t, w, h); - } - - var panel = new AcDream.App.UI.UiNineSlicePanel( - Resolve, - AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, - AcDream.App.UI.RetailChromeSprites.Inset) - { Left = 10, Top = 30, Width = 220, Height = 96 }; - - var title = new AcDream.App.UI.UiLabel - { Text = "Vitals", Left = 8, Top = 4, - TextColor = new System.Numerics.Vector4(1, 1, 1, 1) }; - panel.AddChild(title); - - var vm = _vitalsVm!; - panel.AddChild(new AcDream.App.UI.UiMeter - { Left = 8, Top = 24, Width = 200, Height = 13, - BarColor = new System.Numerics.Vector4(1f, 0f, 0f, 1f), - Fill = () => vm.HealthPercent }); - panel.AddChild(new AcDream.App.UI.UiMeter - { Left = 8, Top = 44, Width = 200, Height = 13, - BarColor = new System.Numerics.Vector4(0.063f, 0.94f, 0.94f, 1f), - Fill = () => vm.StaminaPercent }); - panel.AddChild(new AcDream.App.UI.UiMeter - { Left = 8, Top = 64, Width = 200, Height = 13, - BarColor = new System.Numerics.Vector4(0f, 0f, 1f, 1f), - Fill = () => vm.ManaPercent }); - - _uiHost.Root.AddChild(panel); - } -``` - -(`UiLabel` draws via the stb `BitmapFont` `_debugFont`; if `_debugFont` is null the title simply doesn't draw — acceptable for Spec 1.) - -- [ ] **Step 3: Draw the retail UI each frame** - -In `GameWindow.cs` OnRender, after the 3D passes and near the ImGui block (~line 8233, after `_imguiBootstrap` block or before it — order is deterministic either way; place it just before the ImGui `if` at line 8158 so ImGui composites on top in dev): - -```csharp - // Phase D.2b — retail-look UI tree (render-only; input integration deferred). - if (_options.RetailUi && _uiHost is not null) - { - _uiHost.Tick(deltaSeconds); - _uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); - } -``` - -- [ ] **Step 4: Dispose UiHost on shutdown** - -In `GameWindow.cs`'s dispose/shutdown path (near where `_textRenderer`/`_debugFont` are disposed, ~line 12043): - -```csharp - _uiHost?.Dispose(); -``` - -- [ ] **Step 5: Build + visual verify (manual)** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj` -Launch with `ACDREAM_RETAIL_UI=1` (+ the live-connection env from CLAUDE.md). **User confirms:** the Vitals window renders with the dat-sprite frame + three bars that track HP/Stam/Mana as the character takes damage/regens. Also launch with `ACDREAM_DEVTOOLS=1` (retail off) and confirm the ImGui panels are unchanged. - -- [ ] **Step 6: Retire TS-30 + add the IA row** - -In `docs/architecture/retail-divergence-register.md`: delete the **TS-30** row (line ~166). Add one new **IA** row (next sequential IA number) for the markup/serialization layer: - -``` -| IA-NN | D.2b retail UI is our own UiRoot tree + XML markup + controls.ini stylesheet, not a byte-port of keystone.dll's LayoutDesc binary tree (keystone.dll has no PDB/decomp) | src/AcDream.App/UI/UiNineSlicePanel.cs + MarkupDocument.cs | keystone.dll is outside decomp coverage — a byte-port is impossible by definition; we mirror retail's LayoutDesc/ElementDesc field model + controls.ini token vocabulary | Layout semantics the research under-specifies (anchor resolution at non-800x600, controls.ini cascade corners) differ silently with no oracle | LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary; keystone.dll layout evaluation (no PDB) | -``` - -(Replace `IA-NN` with the actual next number; verify against the register head — there were 14 IA rows at the 2026-06-12 count, so likely `IA-15`.) - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md -git commit -m "feat(D.2b): wire UiHost + live Vitals panel (render-only); retire TS-30, add IA row" -``` - ---- - -## Task 7: controls.ini stylesheet loader - -**Files:** -- Create: `src/AcDream.App/UI/ControlsIni.cs` -- Test: `tests/AcDream.App.Tests/UI/ControlsIniTests.cs` -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (apply title color/font tokens) - -- [ ] **Step 1: Write the failing parser tests** - -Create `tests/AcDream.App.Tests/UI/ControlsIniTests.cs`: - -```csharp -using System.Numerics; -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class ControlsIniTests -{ - [Fact] - public void Parse_ReadsSectionTokens() - { - var ini = ControlsIni.Parse( - "[title]\nheight=19\ncolor=#FFFFFFFF\nfont=font://Verdana-10-bold\n" + - "[body]\nbgcolor=#00000000\ncolor_border=#FF4F657D\n"); - - Assert.Equal("19", ini.Get("title", "height")); - Assert.Equal("font://Verdana-10-bold", ini.Get("title", "font")); - Assert.Null(ini.Get("title", "missing")); - Assert.Null(ini.Get("nosuch", "height")); - } - - [Fact] - public void TryColor_ParsesAlphaFirstHex() - { - var ini = ControlsIni.Parse("[body]\ncolor_border=#FF4F657D\n"); - Assert.True(ini.TryColor("body", "color_border", out Vector4 c)); - Assert.Equal(0xFF / 255f, c.W, 5); // alpha - Assert.Equal(0x4F / 255f, c.X, 5); // red - Assert.Equal(0x65 / 255f, c.Y, 5); // green - Assert.Equal(0x7D / 255f, c.Z, 5); // blue - } - - [Fact] - public void Parse_MissingFileReturnsEmptyNotThrow() - { - var ini = ControlsIni.Load(@"Z:\does\not\exist\controls.ini"); - Assert.Null(ini.Get("title", "height")); // empty, no throw - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter ControlsIniTests` -Expected: FAIL to compile — `ControlsIni` does not exist. - -- [ ] **Step 3: Implement ControlsIni** - -Create `src/AcDream.App/UI/ControlsIni.cs`: - -```csharp -using System.Collections.Generic; -using System.Globalization; -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// Minimal reader for retail's controls.ini — a flat INI with one -/// [section] per element type. Colors are #AARRGGBB (alpha -/// first). Optional: a missing file yields an empty sheet (callers fall -/// back to hardcoded defaults). See spec §7. -/// -public sealed class ControlsIni -{ - private readonly Dictionary> _sections; - - private ControlsIni(Dictionary> s) => _sections = s; - - /// Load from disk; returns an empty sheet if the file is absent. - public static ControlsIni Load(string path) - => System.IO.File.Exists(path) - ? Parse(System.IO.File.ReadAllText(path)) - : new ControlsIni(new()); - - public static ControlsIni Parse(string text) - { - var sections = new Dictionary>(System.StringComparer.OrdinalIgnoreCase); - Dictionary? cur = null; - foreach (var raw in text.Split('\n')) - { - var line = raw.Trim(); - if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue; - if (line[0] == '[' && line[^1] == ']') - { - var name = line[1..^1].Trim(); - cur = new Dictionary(System.StringComparer.OrdinalIgnoreCase); - sections[name] = cur; - continue; - } - int eq = line.IndexOf('='); - if (eq <= 0 || cur is null) continue; - cur[line[..eq].Trim()] = line[(eq + 1)..].Trim(); - } - return new ControlsIni(sections); - } - - public string? Get(string section, string key) - => _sections.TryGetValue(section, out var s) && s.TryGetValue(key, out var v) ? v : null; - - /// Parse a #AARRGGBB token into an RGBA . - public bool TryColor(string section, string key, out Vector4 color) - { - color = default; - var v = Get(section, key); - if (v is null || v.Length != 9 || v[0] != '#') return false; - if (!uint.TryParse(v.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb)) - return false; - float a = ((argb >> 24) & 0xFF) / 255f; - float r = ((argb >> 16) & 0xFF) / 255f; - float g = ((argb >> 8) & 0xFF) / 255f; - float b = (argb & 0xFF) / 255f; - color = new Vector4(r, g, b, a); - return true; - } -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter ControlsIniTests` -Expected: PASS (3 tests). - -- [ ] **Step 5: Apply the stylesheet to the title label** - -In `GameWindow.cs`'s retail wiring (Task 6 Step 2), before building `title`, load the sheet and use the `[title]` color with a fallback: - -```csharp - string? acDir = _options.AcDir; - var controls = acDir is not null - ? AcDream.App.UI.ControlsIni.Load(Path.Combine(acDir, "controls", "controls.ini")) - : AcDream.App.UI.ControlsIni.Parse(string.Empty); - var titleColor = controls.TryColor("title", "color", out var tc) - ? tc : new System.Numerics.Vector4(1, 1, 1, 1); -``` - -Then set `TextColor = titleColor` on the `title` label. - -- [ ] **Step 6: Build + commit** - -```bash -dotnet build src/AcDream.App/AcDream.App.csproj -git add src/AcDream.App/UI/ControlsIni.cs tests/AcDream.App.Tests/UI/ControlsIniTests.cs src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(D.2b): controls.ini stylesheet loader (optional) + apply title color" -``` - ---- - -## Task 8: MarkupDocument — XML → UiElement subtree - -**Files:** -- Create: `src/AcDream.App/UI/MarkupDocument.cs` -- Create: `src/AcDream.App/UI/assets/vitals.xml` -- Test: `tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs` -- Modify: `src/AcDream.App/AcDream.App.csproj` (copy `UI/assets/*.xml` to output) -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (build the subtree from markup) - -- [ ] **Step 1: Write the failing parser test** - -Create `tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs`: - -```csharp -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class MarkupDocumentTests -{ - private sealed class FakeBinding - { - public float HealthPercent => 0.5f; - public float? ManaPercent => null; - } - - [Fact] - public void Build_CreatesPanelWithMeterChildrenAndGeometry() - { - const string xml = - "" + - " " + - ""; - - var resolve = (uint id) => ((uint)1, 32, 32); - var panel = MarkupDocument.Build(xml, new FakeBinding(), resolve, - frameSurfaceId: 0x06000000, inset: 8); - - Assert.IsType(panel); - Assert.Equal(10f, panel.Left); - Assert.Equal(220f, panel.Width); - - // One UiMeter child whose fill resolves to the binding's 0.5. - Assert.Single(panel.Children); - var meter = Assert.IsType(panel.Children[0]); - Assert.Equal(8f, meter.Left); - Assert.Equal(200f, meter.Width); - Assert.Equal(0.5f, meter.Fill()); - } - - [Fact] - public void Build_NullBindingPropertyYieldsNullFill() - { - const string xml = - "" + - " " + - ""; - var panel = MarkupDocument.Build(xml, new FakeBinding(), - id => ((uint)1, 32, 32), 0x06000000, 8); - var meter = Assert.IsType(panel.Children[0]); - Assert.Null(meter.Fill()); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter MarkupDocumentTests` -Expected: FAIL to compile — `MarkupDocument` does not exist. - -- [ ] **Step 3: Implement MarkupDocument** - -Create `src/AcDream.App/UI/MarkupDocument.cs`: - -```csharp -using System; -using System.Globalization; -using System.Numerics; -using System.Xml.Linq; - -namespace AcDream.App.UI; - -/// -/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields) -/// into a live subtree. {Binding} attribute -/// values resolve against a supplied object by property name (reflection). -/// This is the format the future LayoutDesc importer will emit. See spec §7. -/// -public static class MarkupDocument -{ - /// Surface id → (GL handle, width, height). - public static UiNineSlicePanel Build( - string xml, object binding, Func resolve, - uint frameSurfaceId, int inset) - { - var root = XDocument.Parse(xml).Root - ?? throw new FormatException("empty markup"); - if (root.Name.LocalName != "panel") - throw new FormatException($"root must be , got <{root.Name.LocalName}>"); - - var panel = new UiNineSlicePanel(resolve, frameSurfaceId, inset) - { - Left = F(root, "x"), Top = F(root, "y"), - Width = F(root, "w"), Height = F(root, "h"), - }; - - string? title = (string?)root.Attribute("title"); - if (!string.IsNullOrEmpty(title)) - panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4 }); - - foreach (var el in root.Elements()) - { - switch (el.Name.LocalName) - { - case "meter": - panel.AddChild(new UiMeter - { - Left = F(el, "x"), Top = F(el, "y"), - Width = F(el, "w"), Height = F(el, "h"), - BarColor = Color((string?)el.Attribute("color")), - Fill = BindFloat((string?)el.Attribute("fill"), binding), - }); - break; - // future: case "label", "button", "image" ... - } - } - return panel; - } - - private static float F(XElement e, string attr) - => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, - CultureInfo.InvariantCulture, out var v) ? v : 0f; - - private static Vector4 Color(string? hex) - { - if (hex is { Length: 9 } && hex[0] == '#' - && uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb)) - { - return new Vector4( - ((argb >> 16) & 0xFF) / 255f, ((argb >> 8) & 0xFF) / 255f, - (argb & 0xFF) / 255f, ((argb >> 24) & 0xFF) / 255f); - } - return Vector4.One; - } - - /// Resolve "{Prop}" to a live getter against the binding; "" → constant 0. - private static Func BindFloat(string? expr, object binding) - { - if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') - return () => 0f; - string prop = expr[1..^1]; - var pi = binding.GetType().GetProperty(prop); - if (pi is null) return () => null; - return () => - { - object? v = pi.GetValue(binding); - return v switch - { - float f => f, - null => (float?)null, - _ => Convert.ToSingle(v, CultureInfo.InvariantCulture), - }; - }; - } -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter MarkupDocumentTests` -Expected: PASS (2 tests). - -- [ ] **Step 5: Add the vitals markup asset + copy-to-output** - -Create `src/AcDream.App/UI/assets/vitals.xml`: - -```xml - - - - - -``` - -In `src/AcDream.App/AcDream.App.csproj`, add an `ItemGroup` to copy UI assets to output: - -```xml - - - -``` - -- [ ] **Step 6: Replace the hand-built subtree with the markup build** - -In `GameWindow.cs`'s retail wiring (Task 6 Step 2), replace the hand-built `panel`/`title`/`UiMeter` block with: - -```csharp - string vitalsXmlPath = Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml"); - var panel = AcDream.App.UI.MarkupDocument.Build( - System.IO.File.ReadAllText(vitalsXmlPath), - _vitalsVm!, Resolve, - AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, - AcDream.App.UI.RetailChromeSprites.Inset); - _uiHost.Root.AddChild(panel); -``` - -(The `controls.ini` title color from Task 7 can be applied by setting the title-`UiLabel`'s color after the build, or deferred — the markup path owns the title now.) - -- [ ] **Step 7: Build + visual verify + commit** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` -Launch with `ACDREAM_RETAIL_UI=1`; **user confirms** the markup-built panel renders identically to the hand-built one (frame + 3 live bars). - -```bash -git add src/AcDream.App/UI/MarkupDocument.cs src/AcDream.App/UI/assets/vitals.xml src/AcDream.App/AcDream.App.csproj tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(D.2b): MarkupDocument (XML -> UiElement tree) + vitals.xml; build panel from markup" -``` - ---- - -## Task 9: Plugin UI registry (capstone — designed-now, first consumer first-party) - -**Files:** -- Create: `src/AcDream.Plugin.Abstractions/IUiRegistry.cs` -- Modify: `src/AcDream.Plugin.Abstractions/IPluginHost.cs` -- Create: `src/AcDream.App/Plugins/BufferedUiRegistry.cs` -- Modify: `src/AcDream.App/Plugins/AppPluginHost.cs`, `src/AcDream.App/Program.cs`, `src/AcDream.App/Rendering/GameWindow.cs` -- Test: `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs` - -- [ ] **Step 1: Define the registry interface** - -Create `src/AcDream.Plugin.Abstractions/IUiRegistry.cs`: - -```csharp -namespace AcDream.Plugin.Abstractions; - -/// -/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) -/// + a binding object exposing the data properties the markup binds to, and -/// registers it here from Enable(). Registrations made before the GL -/// window opens are buffered and drained once the UI host exists. -/// -public interface IUiRegistry -{ - /// Absolute path to the plugin's panel markup. - /// Object whose properties the markup's {Bindings} read. - void AddMarkupPanel(string markupPath, object binding); -} -``` - -- [ ] **Step 2: Add `Ui` to IPluginHost** - -In `src/AcDream.Plugin.Abstractions/IPluginHost.cs`: - -```csharp -public interface IPluginHost -{ - IPluginLogger Log { get; } - IGameState State { get; } - IEvents Events { get; } - IUiRegistry Ui { get; } -} -``` - -- [ ] **Step 3: Write the failing buffered-registry test** - -Create `tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs`: - -```csharp -using AcDream.App.Plugins; - -namespace AcDream.App.Tests.Plugins; - -public class BufferedUiRegistryTests -{ - [Fact] - public void Drain_YieldsBufferedRegistrationsOnce() - { - var reg = new BufferedUiRegistry(); - reg.AddMarkupPanel("a.xml", new object()); - reg.AddMarkupPanel("b.xml", new object()); - - var drained = reg.Drain(); - Assert.Equal(2, drained.Count); - Assert.Equal("a.xml", drained[0].MarkupPath); - - // Second drain is empty (consumed). - Assert.Empty(reg.Drain()); - } -} -``` - -- [ ] **Step 4: Run the test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter BufferedUiRegistryTests` -Expected: FAIL to compile — `BufferedUiRegistry` does not exist. - -- [ ] **Step 5: Implement BufferedUiRegistry** - -Create `src/AcDream.App/Plugins/BufferedUiRegistry.cs`: - -```csharp -using System.Collections.Generic; -using AcDream.Plugin.Abstractions; - -namespace AcDream.App.Plugins; - -/// -/// Buffers plugin calls (which run in -/// Program.cs before the GL window opens) until GameWindow drains them into -/// the UiHost tree after construction. -/// -public sealed class BufferedUiRegistry : IUiRegistry -{ - public readonly record struct Pending(string MarkupPath, object Binding); - - private readonly List _pending = new(); - - public void AddMarkupPanel(string markupPath, object binding) - => _pending.Add(new Pending(markupPath, binding)); - - /// Return + clear all buffered registrations. - public IReadOnlyList Drain() - { - var copy = _pending.ToArray(); - _pending.Clear(); - return copy; - } -} -``` - -- [ ] **Step 6: Wire it through AppPluginHost + Program + GameWindow** - -`src/AcDream.App/Plugins/AppPluginHost.cs` — add the `Ui` member: - -```csharp - public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui) - { - Log = log; State = state; Events = events; Ui = ui; - } - - public IPluginLogger Log { get; } - public IGameState State { get; } - public IEvents Events { get; } - public IUiRegistry Ui { get; } -``` - -`src/AcDream.App/Program.cs` — construct the registry and pass it to host + window (replace lines 26 + 59): - -```csharp -var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); -var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry); -``` -```csharp - using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry); -``` - -`GameWindow` — add a constructor parameter `AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null`, store it in a field, and in the retail wiring (after `_uiHost.Root.AddChild(panel)`), drain it: - -```csharp - if (_uiRegistry is not null) - { - foreach (var p in _uiRegistry.Drain()) - { - var pluginPanel = AcDream.App.UI.MarkupDocument.Build( - System.IO.File.ReadAllText(p.MarkupPath), p.Binding, Resolve, - AcDream.App.UI.RetailChromeSprites.FrameSurfaceId, - AcDream.App.UI.RetailChromeSprites.Inset); - _uiHost.Root.AddChild(pluginPanel); - } - } -``` - -(Fix the `StubHost` in `tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs:28` to implement the new `Ui` member — return a throwaway `BufferedUiRegistry` or a stub.) - -- [ ] **Step 7: Run tests + build** - -Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter BufferedUiRegistryTests` -Expected: PASS. Fix any compile breaks in plugin-host implementors surfaced by the new interface member. - -- [ ] **Step 8: Commit** - -```bash -git add src/AcDream.Plugin.Abstractions/IUiRegistry.cs src/AcDream.Plugin.Abstractions/IPluginHost.cs src/AcDream.App/Plugins/BufferedUiRegistry.cs src/AcDream.App/Plugins/AppPluginHost.cs src/AcDream.App/Program.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs -git commit -m "feat(D.2b): IUiRegistry plugin UI surface + buffered drain into UiHost" -``` - ---- - -## Final verification - -- [ ] `dotnet build` green (whole solution: `dotnet build AcDream.slnx`). -- [ ] `dotnet test` green (all test projects). -- [ ] `ACDREAM_RETAIL_UI=1`: retail Vitals window (frame + 3 live bars) renders; bars track damage/regen. -- [ ] `ACDREAM_DEVTOOLS=1` (retail off): ImGui panels unchanged. -- [ ] TS-30 deleted; one new IA row present. -- [ ] Update the roadmap: mark D.2b Spec 1 (retail panel frame + vitals) shipped in [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md). diff --git a/docs/superpowers/plans/2026-06-15-chat-window-redrive.md b/docs/superpowers/plans/2026-06-15-chat-window-redrive.md deleted file mode 100644 index ab96b033..00000000 --- a/docs/superpowers/plans/2026-06-15-chat-window-redrive.md +++ /dev/null @@ -1,1484 +0,0 @@ -# Chat-window re-drive Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the hand-authored retail chat window with a data-driven one built from dat `LayoutDesc 0x21000006` (`gmMainChatUI`), with faithful behavioral widgets ported from the named retail decomp and the dat font. - -**Architecture:** The existing `LayoutImporter` builds the generic frame (bg sprites, resize bar, grip chrome, tabs, send button) from the dat. A new `ChatWindowController` (the `ChatInterface`/`gmMainChatUI::PostInit` analogue) binds behavior by element id: it swaps the transcript/input placeholder nodes for new behavioral widgets, wires the scrollbar/menu/send/max-min, and routes inbound chat (from `ChatVM`) and outbound (through a shared `ChatCommandRouter`). New widgets port `UIElement_Text`/`_Scrollable`/`_Scrollbar`/`_Menu`. - -**Tech Stack:** C# / .NET 10, Silk.NET (GL), the in-tree retained-mode UI toolkit (`src/AcDream.App/UI/`), `DatReaderWriter` (dat reads), xUnit (`tests/`). - -**Spec:** `docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md` — read it first. It has the element→role map, decomp citations, and the divergence rows. The decomp is `docs/research/named-retail/acclient_2013_pseudo_c.txt`. - ---- - -## File Structure - -**Create:** -- `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` — shared submit pipeline (client-command intercept → unknown-verb guard → `ChatInputParser.Parse` → `Publish(SendChatCmd)`). Pure, no GL. -- `src/AcDream.App/UI/UiScrollable.cs` — pixel-scroll coordinator (ports `UIElement_Scrollable` math). Pure, no GL. -- `src/AcDream.App/UI/UiChatInput.cs` — editable one-line text widget (ports `UIElement_Text` edit path). -- `src/AcDream.App/UI/UiChatScrollbar.cs` — right-side scrollbar widget (track + thumb + up/down) driving a `UiScrollable`. -- `src/AcDream.App/UI/UiChannelMenu.cs` — channel-selector dropdown (ports `UIElement_Menu`). -- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — import + bind-by-id + route (the `ChatInterface`/`gmMainChatUI` analogue). -- Tests: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`, - `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`, - `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`, - `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`. - -**Modify:** -- `src/AcDream.App/UI/UiChatView.cs` — add `UiDatFont? DatFont`; dat-font measure/advance/draw; wheel = 1 line/notch; `UiScrollable` integration. -- `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` — call `ChatCommandRouter` instead of the inline submit block. -- `src/AcDream.App/Rendering/GameWindow.cs` — replace the hand-authored chat block (~line 1836) with `ChatWindowController`. -- `docs/architecture/retail-divergence-register.md` — add the 6 deferral rows. -- `docs/plans/2026-04-11-roadmap.md` — mark the chat re-drive landed. - ---- - -## Task A: `ChatCommandRouter` (shared submit pipeline) - -Extract the submit + client-command logic from `ChatPanel` so both the ImGui chat and the retail chat dispatch identically. `ChatPanel` currently hardcodes `ChatChannelKind.Say`; the router parameterizes the default channel (the retail chat passes the channel-menu selection). - -**Files:** -- Create: `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs` -- Test: `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs` -- Modify: `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` (call the router) - -- [ ] **Step 1: Write the failing tests** - -Create `tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs`: - -```csharp -using AcDream.Core.Chat; -using AcDream.UI.Abstractions; -using AcDream.UI.Abstractions.Panels.Chat; -using Xunit; - -namespace AcDream.UI.Abstractions.Tests.Panels.Chat; - -public class ChatCommandRouterTests -{ - // Minimal in-memory command bus capturing the last published SendChatCmd. - private sealed class CaptureBus : ICommandBus - { - public SendChatCmd? Last; - public void Publish(T command) where T : notnull - { - if (command is SendChatCmd c) Last = c; - } - } - - private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture() - { - var log = new ChatLog(); - var vm = new ChatVM(log, displayLimit: 50); - return (vm, log, new CaptureBus()); - } - - [Fact] - public void PlainText_PublishesOnDefaultChannel() - { - var (vm, _, bus) = Fixture(); - var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say); - Assert.Equal(SubmitOutcome.Sent, outcome); - Assert.NotNull(bus.Last); - Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); - Assert.Equal("hello there", bus.Last.Text); - } - - [Fact] - public void DefaultChannel_IsHonored() - { - var (vm, _, bus) = Fixture(); - ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship); - Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel); - } - - [Fact] - public void ClearCommand_DrainsLog_DoesNotPublish() - { - var (vm, log, bus) = Fixture(); - log.OnSystemMessage("x", 0); - var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say); - Assert.Equal(SubmitOutcome.ClientHandled, outcome); - Assert.Null(bus.Last); - Assert.Empty(log.Snapshot()); - } - - [Fact] - public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish() - { - var (vm, log, bus) = Fixture(); - var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say); - Assert.Equal(SubmitOutcome.UnknownCommand, outcome); - Assert.Null(bus.Last); - Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command")); - } - - [Fact] - public void EmptyInput_DoesNothing() - { - var (vm, _, bus) = Fixture(); - var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say); - Assert.Equal(SubmitOutcome.Empty, outcome); - Assert.Null(bus.Last); - } -} -``` - -> Verify the `ChatLog` / `ICommandBus` / `ChatVM` APIs used above match the real -> types before running (`ChatLog.OnSystemMessage(string, int)`, `ChatLog.Snapshot()`, -> `ChatLog.Clear()`, `ICommandBus.Publish`). Adjust the fixture if signatures differ. - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests` -Expected: FAIL — `ChatCommandRouter` / `SubmitOutcome` do not exist. - -- [ ] **Step 3: Implement `ChatCommandRouter`** - -Create `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`. Move the -client-command + unknown-verb + parse + publish logic out of `ChatPanel` -(`ChatPanel.TryHandleClientCommand` + the submit block at `ChatPanel.cs:191-242`): - -```csharp -using System; -using AcDream.UI.Abstractions.Panels.Chat; - -namespace AcDream.UI.Abstractions.Panels.Chat; - -/// What a submit did, so the caller can clear its input + give feedback. -public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } - -/// -/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100 -/// analogue). Both the ImGui devtools and the retail -/// chat window route through here so command handling stays in one place. -/// -/// Order mirrors the prior inline flow: -/// client-command intercept → unknown-slash-verb guard → -/// → Publish(SendChatCmd). -/// -public static class ChatCommandRouter -{ - public static SubmitOutcome Submit( - string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel) - { - ArgumentNullException.ThrowIfNull(vm); - ArgumentNullException.ThrowIfNull(bus); - var trimmed = (raw ?? string.Empty).Trim(); - if (trimmed.Length == 0) return SubmitOutcome.Empty; - - if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; - - // A '/' prefix is a command, never speech — unknown ones get local feedback - // instead of leaking to the server as chat. (@ verbs pass through to ACE.) - if (trimmed[0] == '/') - { - var verb = ChatInputParser.GetVerbToken(trimmed); - if (!ChatInputParser.IsKnownVerb(verb)) - { - vm.ShowSystemMessage( - $"Unknown command: {verb}. Type /help for the list of supported commands."); - return SubmitOutcome.UnknownCommand; - } - } - - var parsed = ChatInputParser.Parse( - trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); - if (parsed is { } p) - { - bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); - return SubmitOutcome.Sent; - } - return SubmitOutcome.Dropped; // e.g. "/t Name" with no message - } - - private static bool TryHandleClientCommand(string trimmed, ChatVM vm) - { - if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) - { vm.ShowSystemMessage(BuildHelpText()); return true; } - if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) - { vm.Clear(); return true; } - if (EqAny(trimmed, "/framerate", "@framerate")) - { vm.ShowFps(); return true; } - if (EqAny(trimmed, "/loc", "@loc")) - { vm.ShowLocation(); return true; } - return false; - } - - private static bool EqAny(string s, params string[] options) - { - for (int i = 0; i < options.Length; i++) - if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; - return false; - } - - private static string BuildHelpText() => - "Note: / and @ are equivalent prefixes.\n" + - "Chat: /say (default), /tell , /reply, /retell\n" + - "Channels: /general /trade /fellowship /allegiance\n" + - " /patron /vassals /monarch /covassals\n" + - " /lfg /roleplay /society /olthoi\n" + - "Client: /help (this) /clear /framerate /loc\n" + - "Server: type @acehelp or @acecommands for ACE's full list."; -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.UI.Abstractions.Tests --filter ChatCommandRouterTests` -Expected: PASS (5 tests). - -- [ ] **Step 5: Repoint `ChatPanel` at the router** - -In `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs`, replace the submit body -(`ChatPanel.cs:194-241`, the `var trimmed = submitted.Trim();` block through -`_input = string.Empty;`) with a single call, and delete the now-dead -`TryHandleClientCommand` / `EqAny` / `BuildHelpText` helpers (they moved to the router): - -```csharp -if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) - && submitted is not null) -{ - ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); - _input = string.Empty; - renderer.EndChild(); - renderer.End(); - return; -} -``` - -- [ ] **Step 6: Verify the full suite still passes** - -Run: `dotnet test tests/AcDream.UI.Abstractions.Tests` -Expected: PASS — including the existing `ChatPanelInputTests` (they assert the same submit behavior, now via the router). If any assert on a private `ChatPanel` member, redirect it to `ChatCommandRouter`. - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs \ - src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs \ - tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs -git commit -m "feat(D.2b): extract ChatCommandRouter — shared chat submit pipeline - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task B: `UiChatView` dat-font seam + 1-line wheel - -Make the transcript render in the dat font and scroll one line per wheel notch -(retail `HandleMouseWheel @0x471450`), keeping bottom-pin, drag-select, Ctrl+C. - -**Files:** -- Modify: `src/AcDream.App/UI/UiChatView.cs` -- Test: `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs`. `UiChatView.CharIndexAt` -is already a pure static taking a `Func` advance lookup — assert the -dat-font advance (`UiDatFont.GlyphAdvance`) drives caret hit-testing: - -```csharp -using AcDream.App.UI; -using DatReaderWriter.Types; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class UiChatViewDatFontTests -{ - // Synthetic per-char advance: each glyph 10px wide (Before=2,Width=6,After=2). - private static FontCharDesc Glyph(char c) => new() - { - Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2, - OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0, - }; - - [Fact] - public void CharIndexAt_UsesDatGlyphAdvance() - { - // "abc" with 10px advances -> midpoints at 5,15,25. x=12 -> caret before 'b' (index 1). - float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c)); - Assert.Equal(0, UiChatView.CharIndexAt("abc", Adv, 4f)); - Assert.Equal(1, UiChatView.CharIndexAt("abc", Adv, 12f)); - Assert.Equal(3, UiChatView.CharIndexAt("abc", Adv, 100f)); - } - - [Fact] - public void GlyphAdvance_MatchesRetailFormula() - { - // HorizontalOffsetBefore + Width + HorizontalOffsetAfter = 2+6+2 = 10. - Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x'))); - } -} -``` - -- [ ] **Step 2: Run to verify it fails or passes-trivially** - -Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests` -Expected: PASS for `GlyphAdvance_MatchesRetailFormula` (it's existing), FAIL only if -`FontCharDesc` field names differ — fix the `Glyph(...)` initializer to match the -real `DatReaderWriter.Types.FontCharDesc` (verify via the type before running). The -first test should already pass since `CharIndexAt` is font-agnostic; this test pins -the dat-font advance as the lookup. - -- [ ] **Step 3: Add the dat-font draw + scroll path to `UiChatView`** - -In `src/AcDream.App/UI/UiChatView.cs`: - -1. Add a property next to `Font`: -```csharp -/// Retail dat font (0x40000000) for the transcript. When set, glyphs -/// render via the two-pass dat-font blit and measure/hit-test use the dat glyph -/// advance; when null, the debug BitmapFont path is used. Set by the controller. -public UiDatFont? DatFont { get; set; } -``` -2. Change the wheel quantum to one line per notch (retail `HandleMouseWheel`): -```csharp -private const float WheelLines = 1f; // retail: 1 line per wheel notch (was 3) -``` -3. In `OnDraw`, branch on `DatFont`: use `DatFont.LineHeight` for `lh`, draw each - line with `ctx.DrawStringDat(DatFont, text, Padding, y, color)`, and measure the - selection-highlight span with `DatFont.MeasureWidth(...)`. Keep the `BitmapFont` - branch unchanged as the fallback. Cache `_lastDatFont` alongside `_lastFont` so - `HitChar` uses the same advance source it drew with. -4. In `HitChar`, when `_lastDatFont` is set, build the advance lookup from it: -```csharp -int col = _lastDatFont is { } df - ? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f, - localX - _lastPadding) - : (_lastFont is { } bf - ? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f, - localX - _lastPadding) - : 0); -``` -5. In the `Scroll` event, use the dat-font line height when present: -```csharp -float lh = DatFont?.LineHeight ?? (Font ?? _lastFont)?.LineHeight ?? 16f; -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests --filter UiChatViewDatFontTests` -Expected: PASS. - -- [ ] **Step 5: Build the App project** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Expected: 0 errors. - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/UiChatView.cs tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs -git commit -m "feat(D.2b): UiChatView dat-font transcript + 1-line wheel quantum - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task C: `UiScrollable` (pixel-scroll coordinator) - -Port `UIElement_Scrollable`'s pixel-scroll math: a pure, GL-free coordinator the -transcript and scrollbar both read. No `UiElement` inheritance — it is held by -`UiChatView` and queried by `UiChatScrollbar`. - -**Files:** -- Create: `src/AcDream.App/UI/UiScrollable.cs` -- Test: `tests/AcDream.App.Tests/UI/UiScrollableTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/AcDream.App.Tests/UI/UiScrollableTests.cs`: - -```csharp -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class UiScrollableTests -{ - [Fact] - public void Clamp_KeepsScrollWithinContent() - { - var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; - s.SetScrollY(500); // over max - Assert.Equal(200, s.ScrollY); // max = 300-100 - s.SetScrollY(-50); - Assert.Equal(0, s.ScrollY); - } - - [Fact] - public void FitsView_PinsToZero() - { - var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 }; - s.SetScrollY(40); - Assert.Equal(0, s.ScrollY); // content <= view => no scroll - Assert.False(s.HasOverflow); - } - - [Fact] - public void ThumbRatio_IsViewOverContent_ClampedToOne() - { - var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; - Assert.Equal(0.25f, s.ThumbRatio, 3); // 100/400 - var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; - Assert.Equal(1f, full.ThumbRatio, 3); // content < view => full thumb - } - - [Fact] - public void PositionRatio_MapsScrollToZeroOne() - { - var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; - s.SetScrollY(100); // half of max(200) - Assert.Equal(0.5f, s.PositionRatio, 3); - s.SetScrollY(200); - Assert.Equal(1f, s.PositionRatio, 3); - } - - [Fact] - public void SetPositionRatio_IsInverseOfPositionRatio() - { - var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; - s.SetPositionRatio(0.5f); - Assert.Equal(100, s.ScrollY); // 0.5 * max(200) - } - - [Fact] - public void ScrollByLines_AdvancesByLineHeight() - { - var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; - s.ScrollByLines(-2); // retail: negative = toward older/top - Assert.Equal(0, s.ScrollY); // already at top, clamped - s.SetScrollY(50); - s.ScrollByLines(2); - Assert.Equal(82, s.ScrollY); // 50 + 2*16 - } - - [Fact] - public void ScrollByPage_AdvancesByViewHeight() - { - var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; - s.SetScrollY(200); - s.ScrollByPage(1); - Assert.Equal(300, s.ScrollY); // 200 + view(100) - } -} -``` - -- [ ] **Step 2: Run to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests` -Expected: FAIL — `UiScrollable` does not exist. - -- [ ] **Step 3: Implement `UiScrollable`** - -Create `src/AcDream.App/UI/UiScrollable.cs`. Ports `UIElement_Scrollable` -(`SetScrollableXY @0x4740c0`, `UpdateScrollbarSize_ @0x4741a0`, -`UpdateScrollbarPosition_ @0x473f20`, `InqScrollDelta @0x4689b0`): - -```csharp -using System; - -namespace AcDream.App.UI; - -/// -/// Pixel-based vertical scroll model. Port of retail UIElement_Scrollable: -/// the scroll offset is an integer pixel value (m_iScrollableY) clamped to -/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position -/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and -/// shared by the transcript (UiChatView) and the scrollbar (UiChatScrollbar). -/// -public sealed class UiScrollable -{ - /// Total wrapped content height in px (UIElement_Scrollable m_iScrollableHeight). - public int ContentHeight { get; set; } - /// Visible viewport height in px. - public int ViewHeight { get; set; } - /// Pixels per text line (the scroll quantum). UIElement_Text::InqScrollDelta line case. - public int LineHeight { get; set; } = 16; - - private int _scrollY; - /// Current scroll offset in px from the top of the content. - public int ScrollY => _scrollY; - - /// Max scroll = max(0, content - view). - public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight); - - /// True when content exceeds the view (a scrollbar is warranted). - public bool HasOverflow => ContentHeight > ViewHeight; - - /// True when the offset is at (or past) the bottom — used for bottom-pin. - public bool AtEnd => _scrollY >= MaxScroll; - - /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp). - public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); - - /// Pin to the bottom (newest content visible). - public void ScrollToEnd() => _scrollY = MaxScroll; - - /// Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_). - public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight); - - /// Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_). - public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll; - - /// Inverse of PositionRatio — used when the user drags the thumb. - public void SetPositionRatio(float ratio) - => SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll)); - - /// Scroll by whole lines (sign: +down/newer, -up/older). - public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight); - - /// Scroll by a page = one view height (InqScrollDelta page case). - public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight); -} -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests --filter UiScrollableTests` -Expected: PASS (7 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/UiScrollable.cs tests/AcDream.App.Tests/UI/UiScrollableTests.cs -git commit -m "feat(D.2b): UiScrollable — pixel scroll model (UIElement_Scrollable port) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task C2: Wire `UiScrollable` into `UiChatView` - -Replace `UiChatView`'s ad-hoc `_scroll` float with a `UiScrollable`, so the -transcript's content/view height + bottom-pin + line-scroll flow through the -shared model (and the scrollbar in Task D can read the same instance). - -**Files:** -- Modify: `src/AcDream.App/UI/UiChatView.cs` - -- [ ] **Step 1: Hold a `UiScrollable` + expose it** - -Add to `UiChatView`: -```csharp -/// The scroll model — also read by the linked UiChatScrollbar. -public UiScrollable Scroll { get; } = new(); -``` - -- [ ] **Step 2: Drive it from `OnDraw`** - -In `OnDraw`, after computing `lh`, `contentH`, `innerH`, set the model and read back -the offset instead of the local `_scroll`: -```csharp -Scroll.LineHeight = (int)MathF.Round(lh); -Scroll.ContentHeight = (int)MathF.Ceiling(contentH); -Scroll.ViewHeight = (int)MathF.Floor(innerH); -// Bottom-pin: if the user was at the end before content grew, stay pinned. -if (_pinBottom) Scroll.ScrollToEnd(); -float baseY = bottom - contentH + Scroll.ScrollY; // ScrollY is px from top; baseY shifts content -``` -Keep a `private bool _pinBottom = true;` that is set false when the user scrolls up -(in the `Scroll` event, `_pinBottom = Scroll.AtEnd;` after applying the delta) and -true again when they return to the end. - -> The existing `ClampScroll` static + `_scroll` field are superseded by -> `UiScrollable`. Keep `ClampScroll` if other tests reference it; otherwise remove it -> and update `UiChatView`'s scroll-offset reads to `Scroll.ScrollY`. - -- [ ] **Step 3: Route the wheel through the model** - -In the `Scroll` event handler: -```csharp -case UiEventType.Scroll: -{ - // Silk wheel +Y = scroll up = reveal older. Retail: 1 line per notch. - Scroll.ScrollByLines((int)(-e.Data0 * WheelLines)); - _pinBottom = Scroll.AtEnd; - return true; -} -``` - -- [ ] **Step 4: Build + run the App tests** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug && dotnet test tests/AcDream.App.Tests --filter UiChatView` -Expected: build clean; `UiChatViewDatFontTests` still PASS. Adjust any test that -referenced the removed `_scroll`/`ClampScroll` to use `Scroll`. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/UiChatView.cs -git commit -m "feat(D.2b): UiChatView drives the shared UiScrollable model - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task D: `UiChatScrollbar` (track + thumb + up/down) - -A `UiElement` that renders the right-side scrollbar and drives a `UiScrollable`. -Follows the `UiMeter` sprite pattern (`SpriteResolve` + `ctx.DrawSprite`). - -**Files:** -- Create: `src/AcDream.App/UI/UiChatScrollbar.cs` - -> **First, locate the scroll up/down button ids in the dat.** Run -> `dotnet run --project src/AcDream.Cli -- dump-vitals-layout "" 0x21000006` -> and inspect the children of track `0x10000012` (and the gold caps seen at the -> top/bottom of the scrollbar in the retail screenshot). Record the up-button and -> down-button element ids + their sprite ids in a comment. If the track has no -> button children, the up/down are part of the track sprite and clicks are handled -> by hit-region (top 16px = up, bottom 16px = down). - -- [ ] **Step 1: Implement the widget** - -Create `src/AcDream.App/UI/UiChatScrollbar.cs`: - -```csharp -using System; -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// Right-side chat scrollbar: a track sprite, a draggable thumb sized to the -/// content/view ratio, and up/down step buttons. Drives a linked -/// . Ports retail UIElement_Scrollbar::UpdateLayout -/// @0x4710d0 (thumb size = trackLen * ThumbRatio, min 8px; thumb pos from -/// PositionRatio) and HandleButtonClick @0x470e90 (step ±1 line). -/// -public sealed class UiChatScrollbar : UiElement -{ - /// The scroll model this bar reflects + drives (shared with the transcript). - public UiScrollable? Model { get; set; } - /// RenderSurface id → (GL tex, w, h). 0 id = skip. - public Func? SpriteResolve { get; set; } - - public uint TrackSprite { get; set; } // 0x10000012 face - public uint ThumbSprite { get; set; } // 0x1000048c face - public uint UpSprite { get; set; } - public uint DownSprite { get; set; } - - private const float MinThumb = 8f; // retail attribute 0x89 floor - private const float ButtonH = 16f; // up/down button square - private bool _draggingThumb; - private float _dragOffsetY; - - public UiChatScrollbar() { CapturesPointerDrag = true; } - - /// Thumb rect in local space (between the two end buttons). - public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen) - { - float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); - float travel = trackLen - h; - float y = trackTop + travel * m.PositionRatio; - return (y, h); - } - - protected override void OnDraw(UiRenderContext ctx) - { - if (Model is not { } m || SpriteResolve is not { } resolve) return; - // Track fills the full height; buttons cap top/bottom; thumb floats between. - DrawSprite(ctx, resolve, TrackSprite, 0, 0, Width, Height); - DrawSprite(ctx, resolve, UpSprite, 0, 0, Width, ButtonH); - DrawSprite(ctx, resolve, DownSprite, 0, Height - ButtonH, Width, ButtonH); - if (m.HasOverflow) - { - float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; - var (ty, th) = ThumbRect(m, trackTop, trackLen); - DrawSprite(ctx, resolve, ThumbSprite, 0, ty, Width, th); - } - } - - private void DrawSprite(UiRenderContext ctx, Func resolve, - uint id, float x, float y, float w, float h) - { - if (id == 0) return; - var (tex, _, _) = resolve(id); - if (tex == 0) return; - ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); - } - - public override bool OnEvent(in UiEvent e) - { - if (Model is not { } m) return false; - switch (e.Type) - { - case UiEventType.MouseDown: - { - float ly = e.Data2; // local Y (UiRoot delivers target-local) - if (ly <= ButtonH) { m.ScrollByLines(-1); return true; } // up button - if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; } // down button - float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; - var (ty, th) = ThumbRect(m, trackTop, trackLen); - if (ly >= ty && ly <= ty + th) { _draggingThumb = true; _dragOffsetY = ly - ty; } - else m.ScrollByPage(ly < ty ? -1 : 1); // click in track half = page - return true; - } - case UiEventType.MouseMove when _draggingThumb: - { - float trackTop = ButtonH, trackLen = Height - 2 * ButtonH; - float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); - float travel = MathF.Max(1f, trackLen - h); - m.SetPositionRatio((e.Data2 - _dragOffsetY - trackTop) / travel); - return true; - } - case UiEventType.MouseUp: _draggingThumb = false; return true; - } - return false; - } -} -``` - -- [ ] **Step 2: Build the App project** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Expected: 0 errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.App/UI/UiChatScrollbar.cs -git commit -m "feat(D.2b): UiChatScrollbar — track/thumb/buttons driving UiScrollable - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task E: `UiChatInput` (editable one-line field) - -Port the `UIElement_Text` edit path: caret, insert/delete, 100-entry history, -focus sprite, dat-font draw, submit callback. Caret math reuses `UiDatFont`. - -**Files:** -- Create: `src/AcDream.App/UI/UiChatInput.cs` -- Test: `tests/AcDream.App.Tests/UI/UiChatInputTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/AcDream.App.Tests/UI/UiChatInputTests.cs`. The pure, testable seams are -text editing + history navigation (no GL). The widget exposes them as instance state: - -```csharp -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class UiChatInputTests -{ - [Fact] - public void InsertChar_AdvancesCaret() - { - var input = new UiChatInput(); - input.InsertChar('h'); input.InsertChar('i'); - Assert.Equal("hi", input.Text); - Assert.Equal(2, input.CaretPos); - } - - [Fact] - public void Backspace_DeletesBeforeCaret() - { - var input = new UiChatInput(); - foreach (var c in "abc") input.InsertChar(c); - input.MoveCaret(-1); // caret between 'b' and 'c' - input.Backspace(); // deletes 'b' - Assert.Equal("ac", input.Text); - Assert.Equal(1, input.CaretPos); - } - - [Fact] - public void Submit_FiresCallback_ClearsText_PushesHistory() - { - string? sent = null; - var input = new UiChatInput { OnSubmit = t => sent = t }; - foreach (var c in "hello") input.InsertChar(c); - input.Submit(); - Assert.Equal("hello", sent); - Assert.Equal("", input.Text); - Assert.Equal(0, input.CaretPos); - } - - [Fact] - public void EmptySubmit_DoesNotFire() - { - int n = 0; - var input = new UiChatInput { OnSubmit = _ => n++ }; - input.Submit(); - Assert.Equal(0, n); - } - - [Fact] - public void History_UpDownBrowsesPreviousSubmissions() - { - var input = new UiChatInput { OnSubmit = _ => {} }; - foreach (var c in "first") input.InsertChar(c); input.Submit(); - foreach (var c in "second") input.InsertChar(c); input.Submit(); - input.HistoryPrev(); // most recent - Assert.Equal("second", input.Text); - input.HistoryPrev(); - Assert.Equal("first", input.Text); - input.HistoryNext(); - Assert.Equal("second", input.Text); - input.HistoryNext(); // back to live (empty) - Assert.Equal("", input.Text); - } - - [Fact] - public void History_CapsAt100() - { - var input = new UiChatInput { OnSubmit = _ => {} }; - for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); } - Assert.True(input.HistoryCount <= 100); - } -} -``` - -- [ ] **Step 2: Run to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests` -Expected: FAIL — `UiChatInput` does not exist. - -- [ ] **Step 3: Implement `UiChatInput`** - -Create `src/AcDream.App/UI/UiChatInput.cs`. Ports `UIElement_Text` editable mode -(`CharacterHandler`, `MoveCursor @0x468d00`, `FindPixelsFromPos @0x472b40`) + -`ChatInterface` history (`ProcessCommand @0x4f5100`, `SelectCommandFromHistory`, -sentinel `-1` = live): - -```csharp -using System; -using System.Collections.Generic; -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// Editable one-line chat input. Port of retail UIElement_Text in editable -/// one-line mode + ChatInterface's 100-entry command history. Caret is a -/// glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the caret. -/// Submit (Enter / Send) fires , clears, and pushes history. -/// -public sealed class UiChatInput : UiElement -{ - public UiDatFont? DatFont { get; set; } - public BitmapFont? Font { get; set; } - public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); - public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.35f); - public float Padding { get; set; } = 4f; - public int MaxCharacters { get; set; } = 0xFFFF; // retail m_nMaxCharacters default - - /// Called on Enter/Send with the (non-empty) text. The widget clears after. - public Action? OnSubmit { get; set; } - - private string _text = ""; - private int _caret; - public string Text => _text; - public int CaretPos => _caret; - - private readonly List _history = new(); - private int _historyIndex = -1; // -1 = live line (not browsing) - public int HistoryCount => _history.Count; - - public UiChatInput() - { - AcceptsFocus = true; - IsEditControl = true; - CapturesPointerDrag = true; - } - - // ── Pure editing seams (unit-tested) ───────────────────────────────── - public void InsertChar(char c) - { - if (c < 0x20 || c == 0x7F) return; // skip controls (retail CharacterHandler) - if (_text.Length >= MaxCharacters) return; - _text = _text.Insert(_caret, c.ToString()); - _caret++; - _historyIndex = -1; // editing returns to the live line - } - - public void Backspace() - { - if (_caret == 0) return; - _text = _text.Remove(_caret - 1, 1); - _caret--; - } - - public void DeleteForward() - { - if (_caret >= _text.Length) return; - _text = _text.Remove(_caret, 1); - } - - public void MoveCaret(int delta) => _caret = Math.Clamp(_caret + delta, 0, _text.Length); - public void CaretHome() => _caret = 0; - public void CaretEnd() => _caret = _text.Length; - - public void Submit() - { - var t = _text; - if (t.Trim().Length == 0) { Clear(); return; } - OnSubmit?.Invoke(t); - PushHistory(t); - Clear(); - } - - private void Clear() { _text = ""; _caret = 0; _historyIndex = -1; } - - private void PushHistory(string t) - { - _history.Add(t); - if (_history.Count > 100) _history.RemoveAt(0); // retail cap 100, drop oldest - _historyIndex = -1; - } - - public void HistoryPrev() // Up arrow — toward older - { - if (_history.Count == 0) return; - _historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1); - SetTextFromHistory(); - } - - public void HistoryNext() // Down arrow — toward newer, then live - { - if (_historyIndex < 0) return; - _historyIndex++; - if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; } - SetTextFromHistory(); - } - - private void SetTextFromHistory() - { - _text = _history[_historyIndex]; - _caret = _text.Length; - } - - /// Caret pixel-X from the text start (FindPixelsFromPos): Σ advances to caret. - public float CaretPixelX() - => DatFont is { } df ? df.MeasureWidth(_text.Substring(0, _caret)) - : Font is { } bf ? bf.MeasureWidth(_text.Substring(0, _caret)) : 0f; - - // ── Rendering + input ──────────────────────────────────────────────── - protected override void OnDraw(UiRenderContext ctx) - { - ctx.DrawRect(0, 0, Width, Height, BackgroundColor); - float ty = (Height - (DatFont?.LineHeight ?? Font?.LineHeight ?? 14f)) * 0.5f; - if (DatFont is { } df) ctx.DrawStringDat(df, _text, Padding, ty, TextColor); - else if (Font is not null || ctx.DefaultFont is not null) ctx.DrawString(_text, Padding, ty, TextColor, Font); - - // Caret: 1px vertical line at the caret X (blink left to a follow-up; draw solid for now). - if (HasKeyboardFocus()) - { - float cx = Padding + CaretPixelX(); - float ch = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; - ctx.DrawRect(cx, ty, 1f, ch, TextColor); - } - } - - private bool HasKeyboardFocus() - => (Parent is not null) && FindRoot()?.KeyboardFocus == this; - - private UiRoot? FindRoot() - { - UiElement? e = this; - while (e is not null) { if (e is UiRoot r) return r; e = e.Parent; } - return null; - } - - public override bool OnEvent(in UiEvent e) - { - switch (e.Type) - { - case UiEventType.Char: - InsertChar((char)e.Data0); - return true; - case UiEventType.KeyDown: - { - var key = (Silk.NET.Input.Key)e.Data0; - switch (key) - { - case Silk.NET.Input.Key.Enter: - case Silk.NET.Input.Key.KeypadEnter: Submit(); return true; - case Silk.NET.Input.Key.Backspace: Backspace(); return true; - case Silk.NET.Input.Key.Delete: DeleteForward(); return true; - case Silk.NET.Input.Key.Left: MoveCaret(-1); return true; - case Silk.NET.Input.Key.Right: MoveCaret(1); return true; - case Silk.NET.Input.Key.Home: CaretHome(); return true; - case Silk.NET.Input.Key.End: CaretEnd(); return true; - case Silk.NET.Input.Key.Up: HistoryPrev(); return true; - case Silk.NET.Input.Key.Down: HistoryNext(); return true; - } - return false; - } - } - return false; - } -} -``` - -> **Note on focus access:** the snippet walks to the `UiRoot` to read `KeyboardFocus`. -> If `UiRoot.KeyboardFocus` is not reachable that way at runtime, add a -> `bool Focused` flag set from `UiEventType.FocusGained`/`FocusLost` in `OnEvent` -> instead (the `UiElement` event model delivers both — see `UiRoot.SetKeyboardFocus`). - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests --filter UiChatInputTests` -Expected: PASS (6 tests). - -- [ ] **Step 5: Build the App project** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Expected: 0 errors. (If `e.Data0` for `Char` is the codepoint per `UiRoot.OnChar`, -the `(char)e.Data0` cast is correct.) - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/UiChatInput.cs tests/AcDream.App.Tests/UI/UiChatInputTests.cs -git commit -m "feat(D.2b): UiChatInput — editable field, caret, 100-entry history (UIElement_Text port) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task F: `UiChannelMenu` (channel selector) - -The `Chat ▸` selector: a button showing the active channel; clicking opens a popup -list of channels; selecting one fires a channel-changed callback. Ports -`UIElement_Menu` minimally (a button + a popup item list). - -**Files:** -- Create: `src/AcDream.App/UI/UiChannelMenu.cs` - -- [ ] **Step 1: Implement the widget** - -Create `src/AcDream.App/UI/UiChannelMenu.cs`. The 13 channels map to -`ChatChannelKind` (retail `InitTalkFocusMenu @0x4cdc50` enum: 1=Say, 4=Fellowship, -5=Patron, 6=Trade, 7=Allegiance, …). The popup is a vertical list drawn on click; -selection updates `Selected` + fires `OnChannelChanged`. - -```csharp -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.UI.Abstractions; - -namespace AcDream.App.UI; - -/// -/// Chat channel selector (the "Chat ▸" button). Port of retail -/// UIElement_Menu as used by gmMainChatUI::InitTalkFocusMenu @0x4cdc50: -/// a button whose label is the active channel; clicking opens a popup of channels; -/// selecting one calls SetTalkFocus (here: ). -/// -public sealed class UiChannelMenu : UiElement -{ - public readonly record struct Item(string Label, ChatChannelKind Channel); - - /// Retail talk-focus channels (subset acdream's ChatInputParser routes). - public static readonly Item[] Channels = - { - new("Say", ChatChannelKind.Say), - new("General", ChatChannelKind.General), - new("Trade", ChatChannelKind.Trade), - new("LFG", ChatChannelKind.Lfg), - new("Fellowship", ChatChannelKind.Fellowship), - new("Allegiance", ChatChannelKind.Allegiance), - new("Patron", ChatChannelKind.Patron), - new("Vassals", ChatChannelKind.Vassals), - new("Monarch", ChatChannelKind.Monarch), - new("Roleplay", ChatChannelKind.Roleplay), - new("Society", ChatChannelKind.Society), - new("Olthoi", ChatChannelKind.Olthoi), - }; - - public ChatChannelKind Selected { get; private set; } = ChatChannelKind.Say; - public Action? OnChannelChanged { get; set; } - - public UiDatFont? DatFont { get; set; } - public BitmapFont? Font { get; set; } - public Func? SpriteResolve { get; set; } - public uint NormalSprite { get; set; } // 0x06004D65 - public uint PressedSprite { get; set; } // 0x06004D66 - public Vector4 TextColor { get; set; } = new(1f, 0.85f, 0.4f, 1f); - - private bool _open; - private const float ItemH = 16f; - - public UiChannelMenu() { CapturesPointerDrag = true; } - - private string Label => FindLabel(Selected); - private static string FindLabel(ChatChannelKind k) - { - foreach (var it in Channels) if (it.Channel == k) return it.Label; - return "Chat"; - } - - protected override void OnDraw(UiRenderContext ctx) - { - // Button face. - if (SpriteResolve is { } resolve) - { - var (tex, _, _) = resolve(_open ? PressedSprite : NormalSprite); - if (tex != 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - DrawLabel(ctx, Label + " >", 2f, (Height - LineH()) * 0.5f); - - // Popup list above the button (chat is at screen bottom). - if (_open) - { - float h = Channels.Length * ItemH; - float top = -h; - ctx.DrawRect(0, top, MathF.Max(Width, 90f), h, new(0f, 0f, 0f, 0.85f)); - for (int i = 0; i < Channels.Length; i++) - DrawLabel(ctx, Channels[i].Label, 2f, top + i * ItemH); - } - } - - private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; - private void DrawLabel(UiRenderContext ctx, string s, float x, float y) - { - if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, TextColor); - else ctx.DrawString(s, x, y, TextColor, Font); - } - - protected override bool OnHitTest(float lx, float ly) - => _open ? (lx >= 0 && lx < MathF.Max(Width, 90f) && ly >= -Channels.Length * ItemH && ly < Height) - : base.OnHitTest(lx, ly); - - public override bool OnEvent(in UiEvent e) - { - if (e.Type == UiEventType.MouseDown) - { - float ly = e.Data2; - if (_open && ly < 0) // clicked an item in the popup - { - int idx = (int)((ly + Channels.Length * ItemH) / ItemH); - if (idx >= 0 && idx < Channels.Length) - { - Selected = Channels[idx].Channel; - OnChannelChanged?.Invoke(Selected); - } - _open = false; - return true; - } - _open = !_open; // toggle on button click - return true; - } - return false; - } -} -``` - -- [ ] **Step 2: Build the App project** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Expected: 0 errors. (Verify `ChatChannelKind` has the members used; adjust the -`Channels` table to the real enum names if any differ.) - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.App/UI/UiChannelMenu.cs -git commit -m "feat(D.2b): UiChannelMenu — channel selector popup (UIElement_Menu port) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task G: `ChatWindowController` (import + bind + route) - -The `ChatInterface`/`gmMainChatUI::PostInit` analogue: import `0x21000006`, bind by -id, swap the transcript/input placeholders for the behavioral widgets, wire the -scrollbar/menu/send/max-min, and route inbound (`ChatVM`) + outbound -(`ChatCommandRouter`). - -**Files:** -- Create: `src/AcDream.App/UI/Layout/ChatWindowController.cs` - -- [ ] **Step 1: Implement the controller** - -Create `src/AcDream.App/UI/Layout/ChatWindowController.cs`: - -```csharp -using System; -using AcDream.UI.Abstractions; -using AcDream.UI.Abstractions.Panels.Chat; - -namespace AcDream.App.UI.Layout; - -/// -/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream -/// analogue of retail ChatInterface + gmMainChatUI::PostInit. It -/// FindElement(id)s each role, swaps the transcript/input placeholders for the -/// behavioral widgets, wires the scrollbar/menu/send/max-min, and routes chat. -/// -public sealed class ChatWindowController -{ - public const uint LayoutId = 0x21000006u; - public const uint TranscriptId = 0x10000011u; - public const uint InputId = 0x10000016u; - public const uint TrackId = 0x10000012u; - public const uint ThumbId = 0x1000048Cu; - public const uint MenuId = 0x10000014u; - public const uint SendId = 0x10000019u; - public const uint MaxMinId = 0x1000046Fu; - - public UiChatView Transcript { get; private set; } = null!; - public UiChatInput Input { get; private set; } = null!; - public UiChatScrollbar Scrollbar { get; private set; } = null!; - public UiChannelMenu Menu { get; private set; } = null!; - - /// Bind an imported chat layout. Returns the controller, or null if the - /// required role elements are missing. - public static ChatWindowController? Bind( - ImportedLayout layout, ChatVM vm, ICommandBus bus, - UiDatFont? datFont, BitmapFont? debugFont, - Func resolve) - { - var transcriptPh = layout.FindElement(TranscriptId); - var inputPh = layout.FindElement(InputId); - if (transcriptPh is null || inputPh is null) return null; - - var c = new ChatWindowController(); - - // Transcript — swap placeholder for UiChatView at the same rect/anchors. - c.Transcript = new UiChatView - { - Left = transcriptPh.Left, Top = transcriptPh.Top, - Width = transcriptPh.Width, Height = transcriptPh.Height, - Anchors = transcriptPh.Anchors, - DatFont = datFont, Font = debugFont, - LinesProvider = () => BuildLines(vm), - }; - ReplaceInParent(transcriptPh, c.Transcript); - - // Input — swap placeholder for UiChatInput. - c.Input = new UiChatInput - { - Left = inputPh.Left, Top = inputPh.Top, - Width = inputPh.Width, Height = inputPh.Height, - Anchors = inputPh.Anchors, - DatFont = datFont, Font = debugFont, - }; - ReplaceInParent(inputPh, c.Input); - - // Menu — swap placeholder for UiChannelMenu (label tracks the active channel). - var menuPh = layout.FindElement(MenuId); - c.Menu = new UiChannelMenu { DatFont = datFont, Font = debugFont, SpriteResolve = resolve }; - if (menuPh is not null) - { - c.Menu.Left = menuPh.Left; c.Menu.Top = menuPh.Top; - c.Menu.Width = menuPh.Width; c.Menu.Height = menuPh.Height; - c.Menu.Anchors = menuPh.Anchors; - ReplaceInParent(menuPh, c.Menu); - } - - // Scrollbar — swap the track placeholder for the scrollbar widget driving the - // transcript's UiScrollable. - var trackPh = layout.FindElement(TrackId); - c.Scrollbar = new UiChatScrollbar { Model = c.Transcript.Scroll, SpriteResolve = resolve }; - if (trackPh is not null) - { - c.Scrollbar.Left = trackPh.Left; c.Scrollbar.Top = trackPh.Top; - c.Scrollbar.Width = trackPh.Width; c.Scrollbar.Height = trackPh.Height; - c.Scrollbar.Anchors = trackPh.Anchors; - // Sprite ids: read from the imported track/thumb nodes (TrackSprite, ThumbSprite). - ReplaceInParent(trackPh, c.Scrollbar); - } - - // Routing: input submit -> ChatCommandRouter with the menu's active channel. - c.Input.OnSubmit = text => - ChatCommandRouter.Submit(text, vm, bus, c.Menu.Selected); - c.Menu.OnChannelChanged = _ => { /* active channel read live from Menu.Selected */ }; - - // Send button -> submit (alternate trigger, retail ListenToElementMessage 0x10000019). - var send = layout.FindElement(SendId); - if (send is not null) send.ClickThrough = false; // ensure it receives clicks - // (wire send click -> c.Input.Submit() in the controller's event hook or via a - // small click handler subclass; if FindElement returns a UiDatElement, attach - // an OnClick delegate — add one to UiDatElement if absent.) - - return c; - } - - private static void ReplaceInParent(UiElement placeholder, UiElement widget) - { - var parent = placeholder.Parent; - if (parent is null) return; - parent.RemoveChild(placeholder); - parent.AddChild(widget); - } - - private static System.Collections.Generic.IReadOnlyList BuildLines(ChatVM vm) - { - var detailed = vm.RecentLinesDetailed(); - var result = new UiChatView.Line[detailed.Count]; - for (int i = 0; i < detailed.Count; i++) - result[i] = new UiChatView.Line(detailed[i].Text, RetailChatColor(detailed[i].Kind)); - return result; - } - - // Per-ChatKind palette (moved from GameWindow.RetailChatColor in Task H). - private static System.Numerics.Vector4 RetailChatColor(AcDream.Core.Chat.ChatKind kind) => kind switch - { - AcDream.Core.Chat.ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), - AcDream.Core.Chat.ChatKind.RangedSpeech => new(1f, 0.95f, 0.8f, 1f), - AcDream.Core.Chat.ChatKind.Channel => new(0.6f, 0.8f, 1f, 1f), - AcDream.Core.Chat.ChatKind.Tell => new(1f, 0.5f, 1f, 1f), - AcDream.Core.Chat.ChatKind.System => new(1f, 1f, 0.45f, 1f), - AcDream.Core.Chat.ChatKind.Popup => new(1f, 0.85f, 0.4f, 1f), - AcDream.Core.Chat.ChatKind.Emote => new(0.8f, 0.8f, 0.7f, 1f), - AcDream.Core.Chat.ChatKind.SoulEmote => new(0.8f, 0.8f, 0.7f, 1f), - AcDream.Core.Chat.ChatKind.Combat => new(1f, 0.6f, 0.25f, 1f), - _ => new(0.9f, 0.9f, 0.9f, 1f), - }; -} -``` - -> **Send-button + max/min click wiring:** `LayoutImporter` builds those as -> `UiDatElement` sprite nodes. If `UiDatElement` has no click hook, add an -> `Action? OnClick` invoked from `OnEvent(UiEventType.Click)` (small change, generic -> + reusable). Wire `send.OnClick = () => Input.Submit();` and -> `maxmin.OnClick = ToggleMaximize;`. The max/min toggle ports -> `gmMainChatUI::HandleMaximizeButton @0x4cce50` (swap between authored height and -> full-parent height, storing old Y/height). If that grows large, file it as a -> follow-up and leave the button inert this pass (note in a divergence row). - -- [ ] **Step 2: Build the App project** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Expected: 0 errors. Resolve the sprite-id reads for the scrollbar (`TrackSprite`/ -`ThumbSprite`) by pulling them from the imported track/thumb `ElementInfo.StateMedia` -(or `UiDatElement`), following the `DatWidgetFactory.SliceIds` pattern. - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.App/UI/Layout/ChatWindowController.cs -git commit -m "feat(D.2b): ChatWindowController — bind chat LayoutDesc, route in/outbound - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task H: `GameWindow` cutover + register + roadmap - -Replace the hand-authored chat block with the controller; default placement; remove -dead code; add divergence rows; mark the work landed. - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` -- Modify: `docs/architecture/retail-divergence-register.md` -- Modify: `docs/plans/2026-04-11-roadmap.md` - -- [ ] **Step 1: Swap the chat block in `GameWindow`** - -In `src/AcDream.App/Rendering/GameWindow.cs`, in the `if (_options.RetailUi)` block, -replace the "Retail chat window" section (`GameWindow.cs:1836-1887`, the -`retailChatVm` + `UiNineSlicePanel` + `UiChatView` + `BuildRetailChatLines` + -`RetailChatColor` block) with: - -```csharp -// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI), -// the same importer path as vitals. ChatWindowController binds the transcript, -// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter. -var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); -AcDream.App.UI.Layout.ImportedLayout? chatLayout; -lock (_datLock) - chatLayout = AcDream.App.UI.Layout.LayoutImporter.Import( - _dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId, ResolveChrome, vitalsDatFont); -if (chatLayout is not null) -{ - var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind( - chatLayout, retailChatVm, _commandBus, vitalsDatFont, _debugFont, ResolveChrome); - if (chatController is not null) - { - var chatRoot = chatLayout.Root; - chatRoot.Left = 10; chatRoot.Top = 432; // bottom-left default; user adjusts visually - chatRoot.Anchors = AcDream.App.UI.AnchorEdges.None; - chatRoot.Draggable = true; - chatRoot.Resizable = true; - chatRoot.MinWidth = 200f; chatRoot.MinHeight = 80f; - _uiHost.Root.AddChild(chatRoot); - Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); - } - else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); -} -else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found."); -``` - -> `_commandBus` must be the live `ICommandBus` the chat `SendChatCmd` handler is -> registered on. Confirm the field name in `GameWindow` (grep `ICommandBus` / -> `LiveCommandBus` — it is the same bus the ImGui `ChatPanel` publishes to). If the -> chat window root needs `vitalsDatFont` loaded first, this block already runs after -> the vitals block where `vitalsDatFont` is created — keep that ordering. - -- [ ] **Step 2: Build + run the full suite** - -Run: `dotnet build && dotnet test` -Expected: build clean; all tests green. Remove any now-unused `using`/helpers left in -`GameWindow` (the old `BuildRetailChatLines`/`RetailChatColor` local statics). - -- [ ] **Step 3: Add divergence-register rows** - -In `docs/architecture/retail-divergence-register.md`, add one row each (cite -`file:line`): (1) two-class transcript/input split [Adaptation]; (2) no in-element -word-wrap [Approximation]; (3) one color per line [Approximation]; (4) chat tabs -render but don't switch/filter [Stopgap]; (5) squelch + name-tags absent [Stopgap]; -(6) single default opacity, default font face/size [Approximation]. - -- [ ] **Step 4: Visual verification (user)** - -Launch live and confirm against the retail screenshot: -```powershell -$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" -$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" -$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword" -$env:ACDREAM_RETAIL_UI="1" -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath chat-redrive.log -``` -Confirm: transcript scrolls in the dat font; scrollbar thumb sizes + drags; type + -Enter/Send dispatch; channel menu switches; window moves/resizes; translucent frame. - -- [ ] **Step 5: Update the roadmap + commit** - -Mark the chat re-drive landed in `docs/plans/2026-04-11-roadmap.md` (D.2b importer -Plan 2 — chat). Commit: -```bash -git add src/AcDream.App/Rendering/GameWindow.cs \ - docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md -git commit -m "feat(D.2b): cut GameWindow over to the data-driven chat window - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Self-Review checklist (done while writing) - -- **Spec coverage:** §4 components ↔ Tasks A–H (router→A, transcript dat-font→B, - scrollable→C/C2, scrollbar→D, input→E, menu→F, controller→G, cutover→H). Deferred - items (§2/§6) → register rows in H Step 3. ✓ -- **Placeholders:** the two forward-discoveries (scroll up/down button ids in D; send/ - max-min click hook in G) are explicit, scoped implementation tasks with a fallback, - not hand-waves. ✓ -- **Type consistency:** `UiScrollable` API (`ScrollY`, `ThumbRatio`, `PositionRatio`, - `SetPositionRatio`, `ScrollByLines/Page`) used consistently in C, C2, D. `UiChatView.Scroll` - exposed in C2, consumed in D/G. `ChatCommandRouter.Submit(raw, vm, bus, channel)` defined - in A, called in E-wiring/G. `UiChatInput.OnSubmit`/`Submit()` consistent E↔G. ✓ diff --git a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md b/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md deleted file mode 100644 index 33afb841..00000000 --- a/docs/superpowers/plans/2026-06-15-layoutdesc-importer.md +++ /dev/null @@ -1,760 +0,0 @@ -# LayoutDesc Importer — Implementation Plan (Plan 1: foundation + vitals conformance) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Read the retail vitals `LayoutDesc` (`0x2100006C`) from the dat and build a `UiElement` tree that reproduces the hand-built vitals window — proving a data-driven importer that needs no per-window graphics code. - -**Architecture:** A `LayoutImporter` reads a layout, resolves `BaseElement`/`BaseLayoutId` inheritance, and walks the `ElementDesc` tree. A hybrid factory maps each element's `Type` to either a dedicated behavioral widget (meter → `UiMeter`, text → dat-font label) or a generic `UiDatElement` that draws any element's media by draw-mode (reusing the proven tiling primitive). A per-window `VitalsController` binds live data to elements by id, mirroring retail's `gmVitalsUI`. Everything renders through the existing `UiRoot` + primitives — nothing is deleted. - -**Tech Stack:** C# .NET 10, Silk.NET, `Chorizite.DatReaderWriter` 2.1.7, xUnit. Spec: `docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`. - -**Scope of Plan 1:** rollout steps 1–6 (enumeration → importer → inheritance → generic renderer → factory → vitals controller → conformance). NOT in Plan 1: window manager, chat re-drive, the full long-tail of element types (Plan 2). The generic renderer's fallback means un-widgeted types still draw their sprites. - ---- - -## File structure - -``` -src/AcDream.App/UI/Layout/ ← new namespace for the importer - ElementReader.cs — typed read of ElementDesc fields + inheritance merge (pure, GL-free) - LayoutImporter.cs — read a LayoutDesc, walk the tree, build the UiElement tree - UiDatElement.cs — generic element: draws its state media by DrawMode (tile/blend) - DatWidgetFactory.cs — Type → widget (UiMeter / dat-font label) else UiDatElement - VitalsController.cs — bind live data to elements by id (mirrors gmVitalsUI) -src/AcDream.App/Rendering/GameWindow.cs ← wire importer under a flag, alongside the existing path -docs/research/2026-06-15-layoutdesc-format.md ← Task 1 enumeration reference -tests/AcDream.App.Tests/UI/Layout/ ← new test folder - ElementReaderTests.cs — inheritance merge, edge-flags → anchors (pure) - DatWidgetFactoryTests.cs— Type → widget mapping - VitalsBindingTests.cs — bind-by-id wiring - LayoutConformanceTests.cs — vitals tree golden checks (uses a committed fixture) -tests/AcDream.App.Tests/UI/Layout/fixtures/ - vitals_2100006C.json — dumped vitals layout tree (so tests need no dats) -``` - -Pure logic (inheritance merge, anchor mapping, factory decision, draw-mode UV) is GL-free and dat-free so it unit-tests without the user's dats. The dat-reading shell is exercised by the headless conformance tool + the committed fixture. - ---- - -### Task 1: Format enumeration reference doc (research) - -Pins down the exact `DatReaderWriter` API and the format vocabulary the later tasks depend on. No production code. - -**Files:** -- Create: `docs/research/2026-06-15-layoutdesc-format.md` - -- [ ] **Step 1: Enumerate the DatReaderWriter types** - -Run (PowerShell), capturing output: -``` -dotnet run --project src\AcDream.Cli\AcDream.Cli.csproj --no-build -- dump-vitals-layout "$env:USERPROFILE\Documents\Asheron's Call" 0x2100006C -``` -From this + the package, record the exact member names/types of `ElementDesc` (confirm `ElementId, Type, X, Y, Width, Height, LeftEdge, TopEdge, RightEdge, BottomEdge, ZLevel, BaseElement, BaseLayoutId, StateDesc, States, Children`), `StateDesc` (its `Media` collection + how properties like font `0x1A` / fill `0x69` are stored), and `MediaDescImage` (`File, DrawMode`) / `MediaDescCursor`. - -- [ ] **Step 2: Enumerate the Type + DrawMode vocabulary from the decomp** - -Grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` for the `UIElement_*` class names + their render methods, the `DrawModeType` values, and the KSML keyword registrations (`KW_*` near `0x71b540`). Record each element `Type` value → meaning + render method, and each `DrawMode` value → behavior (Normal=tile, Alphablend, Stretch, …). - -- [ ] **Step 3: Cross-check against real layouts** - -Dump `0x21000014`, `0x21000075`, and `0x2100003F` (the vitals number-text base layout) and confirm which Types/DrawModes/properties actually occur. Note the inheritance chain for the vitals number-text element. - -- [ ] **Step 4: Write the reference doc** - -Write `docs/research/2026-06-15-layoutdesc-format.md` with sections: ElementDesc API, StateDesc/properties, MediaDesc kinds, the Type table (value → meaning → render method → generic-or-widget bucket), the DrawMode table, and the inheritance rules. Mark which types/draw-modes the vitals window uses (Plan 1 surface) vs the long tail (Plan 2). - -- [ ] **Step 5: Commit** - -``` -git add docs/research/2026-06-15-layoutdesc-format.md -git commit -m "docs(D.2b): LayoutDesc format enumeration (importer groundwork)" -``` - ---- - -### Task 2: ElementReader — inheritance merge + edge-flags → anchors (pure) - -**Files:** -- Create: `src/AcDream.App/UI/Layout/ElementReader.cs` -- Test: `tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs` - -`ElementReader` holds the pure, GL-free, dat-free transforms the importer needs. Model the element as a small POCO `ElementInfo` so the pure logic is testable without constructing `DatReaderWriter.ElementDesc`. - -- [ ] **Step 1: Write the failing tests** - -```csharp -using AcDream.App.UI; -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI.Layout; - -public class ElementReaderTests -{ - [Fact] - public void EdgeFlagsToAnchors_LeftRight_Stretches() - { - // Edge flag value 4 = "anchor to that side" per the format doc; left+right both anchored ⇒ width stretches. - var a = ElementReader.ToAnchors(left: 4, top: 1, right: 4, bottom: 1); - Assert.True(a.HasFlag(AnchorEdges.Left)); - Assert.True(a.HasFlag(AnchorEdges.Right)); - Assert.False(a.HasFlag(AnchorEdges.Bottom)); - } - - [Fact] - public void Merge_BaseThenOverride_DerivedWins() - { - var base_ = new ElementInfo { Type = 0, FontDid = 0x40000000, Width = 150, Height = 16 }; - var derived = new ElementInfo { Type = 0, Width = 200 }; // overrides width, inherits font + height - var merged = ElementReader.Merge(base_, derived); - Assert.Equal(200, merged.Width); // override - Assert.Equal(16, merged.Height); // inherited - Assert.Equal(0x40000000u, merged.FontDid);// inherited - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests"` -Expected: FAIL — `ElementReader` / `ElementInfo` not defined. - -- [ ] **Step 3: Implement ElementReader + ElementInfo** - -```csharp -namespace AcDream.App.UI.Layout; - -/// GL-free, dat-free snapshot of a resolved layout element. Populated by the -/// importer from DatReaderWriter.ElementDesc (after inheritance); the pure transforms -/// below operate on it so they unit-test without the dats. -public sealed class ElementInfo -{ - public uint Id; - public int Type; - public float X, Y, Width, Height; - public int Left, Top, Right, Bottom; // edge-anchor flags - public uint FontDid; // 0 = none (inherited via Merge) - // sprite per state: state name -> (file, drawMode). "" = DirectState. - public Dictionary StateMedia = new(); -} - -public static class ElementReader -{ - /// Edge-anchor flags → AnchorEdges. Flag value 4 (per format doc) = "pinned - /// to that side"; any other value = not pinned. Left+Right ⇒ width stretches. - public static AnchorEdges ToAnchors(int left, int top, int right, int bottom) - { - var a = AnchorEdges.None; - if (left == 4) a |= AnchorEdges.Left; - if (top == 4) a |= AnchorEdges.Top; - if (right == 4) a |= AnchorEdges.Right; - if (bottom == 4) a |= AnchorEdges.Bottom; - if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left - return a; - } - - /// Merge a base element with a derived override: start from base, apply any - /// non-default field the derived element sets. Mirrors BaseElement/BaseLayoutId. - public static ElementInfo Merge(ElementInfo base_, ElementInfo derived) - { - var m = new ElementInfo - { - Id = derived.Id != 0 ? derived.Id : base_.Id, - Type = derived.Type != 0 ? derived.Type : base_.Type, - X = derived.X, Y = derived.Y, // position is the derived placement - Width = derived.Width != 0 ? derived.Width : base_.Width, - Height = derived.Height != 0 ? derived.Height : base_.Height, - Left = derived.Left, Top = derived.Top, Right = derived.Right, Bottom = derived.Bottom, - FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, - StateMedia = new Dictionary(base_.StateMedia), - }; - foreach (var kv in derived.StateMedia) m.StateMedia[kv.Key] = kv.Value; // derived overrides - return m; - } -} -``` -> NOTE: confirm the edge-flag "pinned" value (4) and the font-property key against Task 1's doc; adjust the `== 4` test if the doc says otherwise. - -- [ ] **Step 4: Run to verify pass** - -Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~ElementReaderTests"` -Expected: PASS (2 tests). - -- [ ] **Step 5: Commit** - -``` -git add src/AcDream.App/UI/Layout/ElementReader.cs tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs -git commit -m "feat(D.2b): ElementReader — layout inheritance merge + edge-flag anchors" -``` - ---- - -### Task 3: UiDatElement — generic element + draw-mode render - -**Files:** -- Create: `src/AcDream.App/UI/Layout/UiDatElement.cs` - -Generic widget: holds an `ElementInfo` + the active state name, draws that state's media by draw-mode. Reuses the proven tiling render (UV-repeat at native width; UI textures are `GL_REPEAT`-wrapped). - -- [ ] **Step 1: Write the failing test (active-state selection is pure)** - -```csharp -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI.Layout; - -public class UiDatElementTests -{ - [Fact] - public void ActiveMedia_PrefersNamedStateOverDirect() - { - var info = new ElementInfo(); - info.StateMedia[""] = (0x06000001, 0); // DirectState - info.StateMedia["ShowDetail"] = (0x06000002, 1); // named - var e = new UiDatElement(info, (_, _) => (0, 0, 0)) { ActiveState = "ShowDetail" }; - Assert.Equal(0x06000002u, e.ActiveMedia().File); - e.ActiveState = ""; - Assert.Equal(0x06000001u, e.ActiveMedia().File); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"` -Expected: FAIL — `UiDatElement` not defined. - -- [ ] **Step 3: Implement UiDatElement** - -```csharp -using System; -using System.Numerics; - -namespace AcDream.App.UI.Layout; - -/// Generic dat element: draws its active state's media by DrawMode (Normal=tile, -/// Alphablend=blended overlay). The fallback renderer for every element type without a -/// dedicated behavioral widget; faithful because retail's base element render is exactly -/// "stamp the media per draw-mode". -public sealed class UiDatElement : UiElement -{ - private readonly ElementInfo _info; - private readonly Func _resolve; - public string ActiveState { get; set; } = ""; - - public UiDatElement(ElementInfo info, Func resolve) - { - _info = info; _resolve = resolve; - ClickThrough = true; // generic decoration; behavioral widgets opt back in - } - - public (uint File, int DrawMode) ActiveMedia() - => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m - : _info.StateMedia.TryGetValue("", out var d) ? d - : (0u, 0); - - protected override void OnDraw(UiRenderContext ctx) - { - var (file, drawMode) = ActiveMedia(); - if (file == 0) return; - var (tex, tw, th) = _resolve(file); - if (tex == 0 || tw == 0 || th == 0) return; - // DrawMode 0 = Normal → TILE at native size (UV-repeat; GL_REPEAT-wrapped UI texture), - // matching ImgTex::TileCSI. (Alphablend/others are the same blit with a blend state; - // the sprite shader already alpha-blends, so the quad is identical here.) - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); - } -} -``` -> NOTE: confirm `DrawMode` enum values against Task 1; if a value needs a non-tiled blit (e.g. a true Stretch), branch here. For the vitals surface (Normal + Alphablend) the tiled UV-repeat quad is correct. - -- [ ] **Step 4: Run to verify pass** - -Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~UiDatElementTests"` -Expected: PASS. - -- [ ] **Step 5: Commit** - -``` -git add src/AcDream.App/UI/Layout/UiDatElement.cs tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs -git commit -m "feat(D.2b): UiDatElement — generic per-drawmode element renderer" -``` - ---- - -### Task 4: DatWidgetFactory — Type → widget (else generic) - -**Files:** -- Create: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` -- Test: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` - -- [ ] **Step 1: Write the failing tests** - -```csharp -using AcDream.App.UI; -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI.Layout; - -public class DatWidgetFactoryTests -{ - private static (uint, int, int) NoTex(uint _) => (0, 0, 0); - - [Fact] - public void Type7_Meter_MakesUiMeter() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null); - Assert.IsType(e); - } - - [Fact] - public void UnknownType_FallsBackToGeneric() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null); - Assert.IsType(e); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"` -Expected: FAIL — `DatWidgetFactory` not defined. - -- [ ] **Step 3: Implement DatWidgetFactory** - -```csharp -using System; - -namespace AcDream.App.UI.Layout; - -/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim -/// algorithm ports); everything else (and unknown Types) falls back to UiDatElement. -/// The Type→bucket assignment comes from the format enumeration (Task 1). -public static class DatWidgetFactory -{ - /// RenderSurface id → (GL tex, w, h). - /// Retail UI font for text elements (may be null pre-load). - public static UiElement Create(ElementInfo info, - Func resolve, UiDatFont? datFont) - { - var e = info.Type switch - { - 7 => BuildMeter(info, resolve), // UIElement_Meter - _ => new UiDatElement(info, resolve), - }; - e.Left = info.X; e.Top = info.Y; e.Width = info.Width; e.Height = info.Height; - e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom); - return e; - } - - private static UiElement BuildMeter(ElementInfo info, Func resolve) - => new UiMeter { SpriteResolve = resolve }; // back/front slice ids + binding set by the controller -} -``` -> NOTE: text (Type 0) keeps using the generic element for now; the dat-font label binding happens in the controller via `UiDatFont`. Add a dedicated text widget in Plan 2 if the enumeration shows behavior beyond "draw a bound string". - -- [ ] **Step 4: Run to verify pass** - -Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~DatWidgetFactoryTests"` -Expected: PASS. - -- [ ] **Step 5: Commit** - -``` -git add src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs -git commit -m "feat(D.2b): DatWidgetFactory — Type→widget hybrid mapping" -``` - ---- - -### Task 5: LayoutImporter — read layout, resolve inheritance, build tree - -**Files:** -- Create: `src/AcDream.App/UI/Layout/LayoutImporter.cs` - -Reads a `LayoutDesc` via `DatCollection`, converts each `ElementDesc` to `ElementInfo` (resolving `BaseElement`/`BaseLayoutId` via `ElementReader.Merge`), builds the widget tree via the factory, and recurses into children. Exposes `FindElement(uint id)`. - -- [ ] **Step 1: Write the failing test (uses the committed fixture, no dats)** - -Create `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` by serializing the dumped tree (a list of `ElementInfo`-shaped records). Test that the importer's pure `BuildFromInfos` produces the right tree: -```csharp -using AcDream.App.UI; -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI.Layout; - -public class LayoutImporterTests -{ - [Fact] - public void BuildFromInfos_HealthMeter_IsUiMeterAtRect() - { - // health meter element 0x100000E6: X=5,Y=5,150x16,Type=7 - var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 }; - var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 }; - var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, (_, _) => (0, 0, 0), null); - var found = tree.FindElement(0x100000E6); - Assert.IsType(found); - Assert.Equal(5f, found!.Left); Assert.Equal(150f, found.Width); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"` -Expected: FAIL — `LayoutImporter` not defined. - -- [ ] **Step 3: Implement LayoutImporter** - -```csharp -using System; -using System.Collections.Generic; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -namespace AcDream.App.UI.Layout; - -/// Reads a retail LayoutDesc into a UiElement tree. Pure tree-building -/// (BuildFromInfos) is dat-free + testable; Import(dats, id, ...) is the dat shell. -public sealed class ImportedLayout -{ - public required UiElement Root { get; init; } - private readonly Dictionary _byId; - public ImportedLayout(UiElement root, Dictionary byId) { Root = root; _byId = byId; } - public UiElement? FindElement(uint id) => _byId.TryGetValue(id, out var e) ? e : null; -} - -public static class LayoutImporter -{ - /// Dat shell: load the layout, convert ElementDescs to ElementInfo (resolving - /// inheritance), then BuildFromInfos. Returns null if the layout is missing. - public static ImportedLayout? Import(DatCollection dats, uint layoutId, - Func resolve, UiDatFont? datFont) - { - var ld = dats.Get(layoutId); - if (ld is null) return null; - // Convert top-level + nested ElementDescs to resolved ElementInfo. - ElementInfo Convert(ElementDesc d) => Resolve(dats, d); - // Build a synthetic root that holds the top-level elements as children. - var rootInfo = new ElementInfo { Id = 0, Type = 3 }; - var children = new List(); - var nested = new Dictionary(); - foreach (var kv in ld.Elements) { var info = Convert(kv.Value); children.Add(info); nested[info] = kv.Value; } - return BuildFromInfosRecursive(rootInfo, ld, dats, resolve, datFont); - } - - /// Pure builder used by tests + the shell: build a tree from a root info + its - /// direct children infos. (The recursive dat variant handles real nested trees.) - public static ImportedLayout BuildFromInfos(ElementInfo rootInfo, IEnumerable children, - Func resolve, UiDatFont? datFont) - { - var byId = new Dictionary(); - var root = DatWidgetFactory.Create(rootInfo, resolve, datFont); - if (rootInfo.Id != 0) byId[rootInfo.Id] = root; - foreach (var c in children) - { - var w = DatWidgetFactory.Create(c, resolve, datFont); - root.AddChild(w); - if (c.Id != 0) byId[c.Id] = w; - } - return new ImportedLayout(root, byId); - } - - // ---- dat-side helpers ---- - - private static ImportedLayout BuildFromInfosRecursive(ElementInfo rootInfo, LayoutDesc ld, - DatCollection dats, Func resolve, UiDatFont? datFont) - { - var byId = new Dictionary(); - var root = DatWidgetFactory.Create(rootInfo, resolve, datFont); - foreach (var kv in ld.Elements) - AddElement(root, kv.Value, dats, resolve, datFont, byId); - return new ImportedLayout(root, byId); - } - - private static void AddElement(UiElement parent, ElementDesc d, DatCollection dats, - Func resolve, UiDatFont? datFont, Dictionary byId) - { - var info = Resolve(dats, d); - var w = DatWidgetFactory.Create(info, resolve, datFont); - parent.AddChild(w); - if (info.Id != 0) byId[info.Id] = w; - foreach (var kv in d.Children) - AddElement(w, kv.Value, dats, resolve, datFont, byId); - } - - /// ElementDesc → ElementInfo, resolving BaseElement/BaseLayoutId inheritance. - private static ElementInfo Resolve(DatCollection dats, ElementDesc d) - { - var self = ToInfo(d); - if (d.BaseElement != 0 && d.BaseLayoutId != 0) - { - var baseLd = dats.Get(d.BaseLayoutId); - var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement); - if (baseDesc is not null) return ElementReader.Merge(Resolve(dats, baseDesc), self); // recursive base chain - } - return self; - } - - private static ElementDesc? FindDesc(LayoutDesc ld, uint id) - { - foreach (var kv in ld.Elements) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; } - return null; - } - private static ElementDesc? FindDescIn(ElementDesc d, uint id) - { - if (d.ElementId == id) return d; - foreach (var kv in d.Children) { var f = FindDescIn(kv.Value, id); if (f is not null) return f; } - return null; - } - - /// Read the verified ElementDesc fields into ElementInfo (no inheritance). - private static ElementInfo ToInfo(ElementDesc d) - { - var info = new ElementInfo - { - Id = d.ElementId, Type = (int)d.Type, - X = d.X, Y = d.Y, Width = d.Width, Height = d.Height, - Left = (int)d.LeftEdge, Top = (int)d.TopEdge, Right = (int)d.RightEdge, Bottom = (int)d.BottomEdge, - }; - if (d.StateDesc is not null) ReadState(d.StateDesc, "", info); - foreach (var s in d.States) ReadState(s.Value, s.Key, info); - return info; - } - - private static void ReadState(StateDesc sd, string name, ElementInfo info) - { - foreach (var m in sd.Media) - if (m is MediaDescImage img && img.File != 0) - info.StateMedia[name] = (img.File, (int)img.DrawMode); - // font DID (property 0x1A) read here once the format doc confirms the property API. - } -} -``` -> NOTE: the exact `ElementDesc`/`StateDesc` member access (`d.X`, `d.Type`, `d.States`, `sd.Media`, `img.DrawMode`, the font property) must match Task 1's verified API; `dump-vitals-layout` confirms these members exist. Adjust casts/names to the real API. - -- [ ] **Step 4: Run to verify pass** - -Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutImporterTests"` -Expected: PASS. - -- [ ] **Step 5: Commit** - -``` -git add src/AcDream.App/UI/Layout/LayoutImporter.cs tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json -git commit -m "feat(D.2b): LayoutImporter — read layout + resolve inheritance + build tree" -``` - ---- - -### Task 6: VitalsController — bind live data by id - -**Files:** -- Create: `src/AcDream.App/UI/Layout/VitalsController.cs` -- Test: `tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs` - -Mirrors `gmVitalsUI`: grab the meter elements by id and wire their fill + numbers + the correct per-vital sprite slice ids (which are dat-driven, but the back/front-slice split + the live data binding are the controller's job). - -- [ ] **Step 1: Write the failing test** - -```csharp -using AcDream.App.UI; -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI.Layout; - -public class VitalsBindingTests -{ - [Fact] - public void Bind_SetsHealthMeterFillFromProvider() - { - var health = new UiMeter(); - var layout = FakeLayout(("0x100000E6", health)); - float hp = 0.42f; - VitalsController.Bind(layout, healthPct: () => hp, staminaPct: () => 1, manaPct: () => 1, - healthText: () => "42/100", staminaText: () => "", manaText: () => ""); - Assert.Equal(0.42f, health.Fill()); - } - - private static ImportedLayout FakeLayout(params (string idHex, UiElement e)[] items) - { - var dict = new System.Collections.Generic.Dictionary(); - var root = new UiPanel(); - foreach (var (idHex, e) in items) - { uint id = System.Convert.ToUInt32(idHex, 16); root.AddChild(e); dict[id] = e; } - return new ImportedLayout(root, dict); - } -} -``` - -- [ ] **Step 2: Run to verify failure** - -Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"` -Expected: FAIL — `VitalsController` not defined. - -- [ ] **Step 3: Implement VitalsController** - -```csharp -using System; - -namespace AcDream.App.UI.Layout; - -/// Per-window controller for the vitals layout (0x2100006C). Mirrors retail -/// gmVitalsUI::PostInit: grab the meter elements by id and bind live data. The ONLY -/// per-window code — data wiring, not graphics. -public static class VitalsController -{ - public const uint Health = 0x100000E6, Stamina = 0x100000EC, Mana = 0x100000EE; - - public static void Bind(ImportedLayout layout, - Func healthPct, Func staminaPct, Func manaPct, - Func healthText, Func staminaText, Func manaText) - { - BindMeter(layout, Health, healthPct, healthText); - BindMeter(layout, Stamina, staminaPct, staminaText); - BindMeter(layout, Mana, manaPct, manaText); - } - - private static void BindMeter(ImportedLayout layout, uint id, Func pct, Func text) - { - if (layout.FindElement(id) is UiMeter m) - { - m.Fill = () => pct(); - m.Label = () => text(); - } - } -} -``` -> NOTE: the per-vital back/front 3-slice sprite ids live on the meter's child image elements in the dat; the importer sets them on the `UiMeter` (extend `DatWidgetFactory.BuildMeter` to read the meter's `E8/E9/EA` + back/front child sprites once the tree is built). For Plan 1 conformance, the controller binds the dynamic data; the static slice ids come from the dat via the importer. - -- [ ] **Step 4: Run to verify pass** - -Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~VitalsBindingTests"` -Expected: PASS. - -- [ ] **Step 5: Commit** - -``` -git add src/AcDream.App/UI/Layout/VitalsController.cs tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs -git commit -m "feat(D.2b): VitalsController — bind live vitals data by element id" -``` - ---- - -### Task 7: Wire the importer into GameWindow behind a flag - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `_options.RetailUi` block where the vitals panel is built) -- Modify: `src/AcDream.App/RuntimeOptions.cs` (add `RetailUiImporter` flag from `ACDREAM_RETAIL_UI_IMPORTER`) - -Run the importer-built vitals window when `ACDREAM_RETAIL_UI_IMPORTER=1`, ALONGSIDE the existing hand-authored path (which stays the default). This is the conformance harness + the eventual switch-over. - -- [ ] **Step 1: Add the RuntimeOptions flag** - -In `RuntimeOptions.cs`, add `public bool RetailUiImporter { get; init; }` and read it in `Program.cs` from `ACDREAM_RETAIL_UI_IMPORTER == "1"` (follow the existing `RetailUi` pattern). - -- [ ] **Step 2: Wire the importer in the RetailUi block** - -In `GameWindow.cs`, in the `if (_options.RetailUi)` block, after the existing vitals panel is built, add: -```csharp -if (_options.RetailUiImporter) -{ - var imported = AcDream.App.UI.Layout.LayoutImporter.Import( - _dats, 0x2100006Cu, ResolveChrome, _datFont); - if (imported is not null) - { - AcDream.App.UI.Layout.VitalsController.Bind(imported, - healthPct: () => _vitalsVm!.HealthPercent ?? 0f, - staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f, - manaPct: () => _vitalsVm!.ManaPercent ?? 0f, - healthText: () => $"{_vitalsVm!.HealthCurrent}/{_vitalsVm.HealthMax}", - staminaText: () => $"{_vitalsVm!.StaminaCurrent}/{_vitalsVm.StaminaMax}", - manaText: () => $"{_vitalsVm!.ManaCurrent}/{_vitalsVm.ManaMax}"); - imported.Root.Left = 240; imported.Root.Top = 30; // offset so it sits beside the hand-built one for A/B - _uiHost.Root.AddChild(imported.Root); - Console.WriteLine("[D.2b] importer vitals window active (A/B vs hand-authored)."); - } -} -``` -> NOTE: confirm `_dats` (the `DatCollection`) + `_datFont` (the `UiDatFont`) field names in `GameWindow`; both already exist (the chrome resolve + the dat-font load use them). - -- [ ] **Step 3: Build** - -Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug` -Expected: 0 errors. - -- [ ] **Step 4: Commit** - -``` -git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.App/RuntimeOptions.cs src/AcDream.App/Program.cs -git commit -m "feat(D.2b): run importer-built vitals window under ACDREAM_RETAIL_UI_IMPORTER (A/B)" -``` - ---- - -### Task 8: Vitals conformance — golden tree checks + headless render diff - -**Files:** -- Create: `tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs` -- Modify: `src/AcDream.Cli/VitalsMockup.cs` (add an importer-render mode if needed for the visual diff) - -- [ ] **Step 1: Write the golden tree conformance test (against the fixture)** - -```csharp -using AcDream.App.UI; -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI.Layout; - -public class LayoutConformanceTests -{ - [Fact] - public void VitalsTree_HasThreeMetersAtExpectedRects() - { - var layout = FixtureLoader.LoadVitals(); // deserializes vitals_2100006C.json → ImportedLayout via BuildFromInfos - (uint id, float y)[] expected = { (0x100000E6, 5), (0x100000EC, 21), (0x100000EE, 37) }; - foreach (var (id, y) in expected) - { - var m = layout.FindElement(id); - Assert.IsType(m); - Assert.Equal(5f, m!.Left); - Assert.Equal(150f, m.Width); - Assert.Equal(16f, m.Height); - Assert.Equal(y, m.Top); - } - } -} -``` -Add a tiny `FixtureLoader` that reads the committed JSON into `ElementInfo`s and calls `LayoutImporter.BuildFromInfos`. - -- [ ] **Step 2: Run to verify failure, then implement FixtureLoader, then pass** - -Run: `dotnet test tests\AcDream.App.Tests --filter "FullyQualifiedName~LayoutConformanceTests"` -Expected: FAIL → implement `FixtureLoader` → PASS. - -- [ ] **Step 3: Headless visual diff** - -Launch the client with both windows (`ACDREAM_RETAIL_UI=1 ACDREAM_RETAIL_UI_IMPORTER=1`, testaccount2) and confirm the importer window (offset) is pixel-identical to the hand-authored one. (Manual visual gate — the user confirms. No assertion.) - -- [ ] **Step 4: Full test sweep** - -Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --filter "FullyQualifiedName~UI"` -Expected: PASS (all prior UI tests + the new Layout tests). - -- [ ] **Step 5: Commit** - -``` -git add tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs -git commit -m "test(D.2b): vitals importer conformance (golden tree + A/B render gate)" -``` - ---- - -## After Plan 1 - -**Plan 1 status: SHIPPED 2026-06-15, pixel-identical.** - -**Default flip DONE 2026-06-15 (`bf77a23`):** the importer is now the default vitals window at `ACDREAM_RETAIL_UI=1`. The hand-authored `vitals.xml` and the `ACDREAM_RETAIL_UI_IMPORTER` flag were retired (`vitals.xml` is recoverable from git history). The window is movable (Anchors=None + Draggable) AND horizontally resizable (Resizable/ResizeX, `8aa643f`): on a width change the dat edge-anchors reflow the pieces (top/bottom edges + bars stretch, corners fixed 5px, right side tracks) per retail `UIElement::UpdateForParentSizeChange @0x00462640`. (The earlier "fixed-size" note was wrong — it came from an inverted edge-flag reading, now corrected; stretch is `RightEdge==1`.) Faithful grip/dragbar-*driven* drag/resize INPUT for the whole toolkit is Plan 2. Post-flip number-render fixes (`43064ba`, `34243f2`): submission-order sprite draw (stamina/mana numbers had been overpainted by their own bar sprites) + glyph pixel-snap (numbers stay sharp at all resize widths). `MarkupDocument`/`UiNineSlicePanel` remain for the chat window + plugin panels. - -**Plan 2** covers: the `WindowManager` (open/close/z-order/persist, drag via Type-2 drag bars, resize via Type-9 resize grips for the whole toolkit), re-driving the chat window (`ChatController`), and extending the factory/renderer to the full long-tail of element types per the Task 1 enumeration. Register Plan 2 in the roadmap before starting it. - -## Self-review - -- **Spec coverage:** enumeration (Task 1) ✓, importer + inheritance (Tasks 2,5) ✓, generic renderer (Task 3) ✓, hybrid factory (Task 4) ✓, controller/binding (Task 6) ✓, coexistence/flag (Task 7) ✓, conformance (Task 8) ✓. Window manager + chat + full long-tail = explicitly deferred to Plan 2 (spec rollout 7–8). -- **Placeholder scan:** every code step has concrete code; `NOTE`s flag where Task 1's verified API must confirm a member name/value — that's a real dependency, not a vague requirement. -- **Type consistency:** `ElementInfo`, `ImportedLayout`, `LayoutImporter.BuildFromInfos`/`Import`, `DatWidgetFactory.Create`, `UiDatElement.ActiveMedia`, `VitalsController.Bind` are used consistently across tasks; `UiMeter.Fill`/`Label`/`SpriteResolve` match the existing widget. diff --git a/docs/superpowers/plans/2026-06-16-d2b-toolbar-phase1.md b/docs/superpowers/plans/2026-06-16-d2b-toolbar-phase1.md deleted file mode 100644 index 1a083dbd..00000000 --- a/docs/superpowers/plans/2026-06-16-d2b-toolbar-phase1.md +++ /dev/null @@ -1,1104 +0,0 @@ -# D.5.1 Toolbar (action bar) — Phase 1 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Ship the retail action bar (`gmToolbarUI`, `LayoutDesc 0x21000016`) as acdream's first data-driven game panel: 18 shortcut slots populated from the persisted `PlayerDescription` shortcut block, each pinned item rendering its real composited icon, with click-to-use. - -**Architecture:** Reuse the shipped D.2b assembly pattern (dat `LayoutDesc` → `LayoutImporter` → `DatWidgetFactory` → thin find-by-id controller). Two new shared widgets (`UiItemSlot`, `UiItemList`) + a CPU icon-composite pipeline (`IconComposer`) + the wire plumbing to carry `IconId` from `CreateObject` into `ItemRepository` and to persist the shortcut list. The 18 toolbar slots already resolve to `UIElement_ItemList` (class `0x10000031`) through the dat `BaseElement`/`BaseLayoutId` chain (slot → `0x100001B2` → `0x10000339`@`0x2100003D`, Type `0x10000031`), so one `DatWidgetFactory` branch makes them `UiItemList`s automatically; the item cell is created procedurally by the list. - -**Tech Stack:** C# .NET 10, Silk.NET OpenGL, the in-tree `AcDream.App/UI` retained-mode toolkit, `DatCollection` for RenderSurface decode, xUnit. - -**Spec:** [`docs/superpowers/specs/2026-06-16-d2b-toolbar-phase1-design.md`](../specs/2026-06-16-d2b-toolbar-phase1-design.md). -**Research anchors:** [`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](../../research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md), [`docs/research/2026-06-16-action-bar-toolbar-deep-dive.md`](../../research/2026-06-16-action-bar-toolbar-deep-dive.md). - -**Spec deltas discovered during planning (elaboration, not contradiction):** -- Spec §4.4 assumed "just capture IconId." Reality: acdream's `CreateObject.TryParse` discards IconId (`CreateObject.cs:516`) AND there is **no** `CreateObject`→`ItemRepository` wiring at all — the repo is populated only from `PlayerDescription` with stub `ItemInstance`s (`ObjectId`+`WeenieClassId`). So Tasks 2–4 add: capture IconId, enrich the repo from the spawn event, and persist `Parsed.Shortcuts` (currently parsed then discarded in `GameEventWiring`). -- IconId source is CONFIRMED to be `CreateObject` for contained pack items (ACE `WorldObject_Networking.cs:79` writes `WritePackedDwordOfKnownType(IconId, 0x6000000)` unconditionally; Chorizite `PublicWeenieDesc` reads `Icon` with no flag gate). No fallback needed. -- Phase-1 `IconComposer` scope: CPU-composite the layers whose source data `ItemInstance` already exposes (custom underlay `IconUnderlayId` + base `IconId` + custom overlay `IconOverlayId`, alpha-over). The retail `IconData::RenderIcons` (decomp 407524) `GetByEnum` type-default-underlay, the overlay `ReplaceColor` tint, and the effect overlay need wire data not yet parsed (overlay tint color, `IconEffects`) — DEFERRED with divergence rows (Task 12). This keeps Approach A (faithful CPU pre-composite) while scoping to available data. - ---- - -## Task 0: Register D.5.1 in the roadmap - -**Files:** -- Modify: `docs/plans/2026-04-11-roadmap.md` (the D.5 entry, ~line 433) - -- [ ] **Step 1: Add the D.5.1 sub-phase entry under D.5** - -In `docs/plans/2026-04-11-roadmap.md`, immediately after the `D.5 — Core panels` bullet (the one at ~line 433), add: - -```markdown -- **D.5.1 — Toolbar (action bar) [IN PROGRESS].** First D.5 sub-phase. `gmToolbarUI` (`LayoutDesc 0x21000016`) as the first data-driven game panel: 18 shortcut slots from the persisted `PlayerDescription` SHORTCUT block, real composited icons, click-to-use. New shared widgets `UiItemSlot` (`UIElement_UIItem` 0x10000032, procedural) + `UiItemList` (`UIElement_ItemList` 0x10000031, factory-registered) + `IconComposer` (CPU 5-layer composite, `IconData::RenderIcons` @407524) + the `CreateObject`→`ItemRepository` IconId wiring. Spec/plan: `docs/superpowers/{specs,plans}/2026-06-16-d2b-toolbar-phase1*.md`. Deferred to later D.5 sub-phases: drag/reorder, the AddShortcut/RemoveShortcut mutate wire, meters/slider, spell shortcuts, faithful window manager, inventory, paperdoll. -``` - -- [ ] **Step 2: Commit** - -```bash -git add docs/plans/2026-04-11-roadmap.md -git commit -m "docs(D.5.1): register toolbar phase-1 in the roadmap" -``` - ---- - -## Task 1: Capture `IconId` in `CreateObject.Parsed` - -**Files:** -- Modify: `src/AcDream.Core.Net/Messages/CreateObject.cs` (the `Parsed` struct ~lines 105-142; the parse at lines 515-516) -- Test: `tests/AcDream.Core.Net.Tests/CreateObjectTests.cs` (add a test; create the file if no CreateObject test exists — verify with `Glob tests/AcDream.Core.Net.Tests/*reate*bject*`) - -- [ ] **Step 1: Write the failing test** - -Add to `tests/AcDream.Core.Net.Tests/CreateObjectTests.cs` (mirror an existing CreateObject test's byte-buffer construction; if none exists, build a minimal body using the same field order as `TryParse`). The assertion that matters: - -```csharp -[Fact] -public void TryParse_capturesIconId() -{ - // A CreateObject body for a simple contained item. Build the bytes with the - // exact field order TryParse reads (guid, ... name, packed WeenieClassId, - // packed-of-known-type IconId 0x06xxxxxx, u32 itemType, ...). Reuse the helper - // that an existing CreateObject test uses to assemble a body; the new assertion: - var parsed = CreateObject.TryParse(BuildContainedItemBody(iconId: 0x06001234u)); - - Assert.NotNull(parsed); - Assert.Equal(0x06001234u, parsed!.Value.IconId); -} -``` - -- [ ] **Step 2: Run the test, verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests --filter TryParse_capturesIconId` -Expected: FAIL — `Parsed` has no member `IconId` (compile error), or `IconId` is 0. - -- [ ] **Step 3: Add `IconId` to the `Parsed` struct and capture it** - -In `src/AcDream.Core.Net/Messages/CreateObject.cs`, add a field to the `Parsed` struct (the readonly struct around lines 105-142): - -```csharp -public uint IconId; // 0x06xxxxxx RenderSurface id of the item icon (0 = none) -``` - -In `TryParse`, change the discard at line 516 to capture, and assign it into the returned `Parsed`. Replace: - -```csharp -_ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); -``` -with: -```csharp -uint iconId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); -``` -Then add `IconId = iconId,` to the object/struct initializer where `Parsed` is constructed (the `return new Parsed { ... }` near the end of `TryParse`). Leave the `WeenieClassId` discard at line 515 as-is for now (the spawn event already carries it separately; capturing it is out of phase-1 scope). - -- [ ] **Step 4: Run the test, verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests --filter TryParse_capturesIconId` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/Messages/CreateObject.cs tests/AcDream.Core.Net.Tests/CreateObjectTests.cs -git commit -m "feat(D.5.1): capture IconId in CreateObject.Parsed (was discarded at cs:516)" -``` - ---- - -## Task 2: `ItemRepository.EnrichItem` (icon enrichment, enrich-existing) - -**Files:** -- Modify: `src/AcDream.Core/Items/ItemRepository.cs` (add a method; events already exist at lines 49-59) -- Test: `tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs` (verify the dir with `Glob tests/AcDream.Core.Tests/**/*ItemRepository*`; if absent, create it) - -- [ ] **Step 1: Write the failing test** - -```csharp -[Fact] -public void EnrichItem_updatesIconOnExistingStub_andRaisesUpdated() -{ - var repo = new ItemRepository(); - repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 42u }); // stub from PlayerDescription - ItemInstance? updated = null; - repo.ItemPropertiesUpdated += i => updated = i; - - bool hit = repo.EnrichItem(0x5001u, iconId: 0x06001234u, name: "Mana Stone", type: ItemType.Misc); - - Assert.True(hit); - Assert.Equal(0x06001234u, repo.GetItem(0x5001u)!.IconId); - Assert.Equal("Mana Stone", repo.GetItem(0x5001u)!.Name); - Assert.NotNull(updated); -} - -[Fact] -public void EnrichItem_returnsFalse_whenItemUnknown() -{ - var repo = new ItemRepository(); - Assert.False(repo.EnrichItem(0x9999u, 0x06001234u, "x", ItemType.Misc)); -} -``` - -- [ ] **Step 2: Run the test, verify it fails** - -Run: `dotnet test tests/AcDream.Core.Tests --filter EnrichItem` -Expected: FAIL — `EnrichItem` not defined. - -- [ ] **Step 3: Implement `EnrichItem`** - -Add to `src/AcDream.Core/Items/ItemRepository.cs` (near `AddOrUpdate`): - -```csharp -/// -/// Enrich an already-known item (a stub created from PlayerDescription) with the -/// fuller data carried by its CreateObject (icon, name, type). Returns false if the -/// item isn't tracked yet — phase 1 enriches existing items only; full -/// CreateObject ingestion of newly-acquired items is the inventory phase. -/// Raises ItemPropertiesUpdated on success so bound widgets (the toolbar) re-render. -/// -public bool EnrichItem(uint objectId, uint iconId, string name, ItemType type) -{ - if (!_items.TryGetValue(objectId, out var item)) return false; - if (iconId != 0) item.IconId = iconId; - if (!string.IsNullOrEmpty(name)) item.Name = name; - if (type != default) item.Type = type; - ItemPropertiesUpdated?.Invoke(item); - return true; -} -``` - -- [ ] **Step 4: Run the test, verify it passes** - -Run: `dotnet test tests/AcDream.Core.Tests --filter EnrichItem` -Expected: PASS (both). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core/Items/ItemRepository.cs tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs -git commit -m "feat(D.5.1): ItemRepository.EnrichItem (icon/name/type from CreateObject)" -``` - ---- - -## Task 3: Thread `IconId` through the spawn event into `ItemRepository` - -This is integration wiring (no new pure unit; covered by Task 1/2 units + the visual gate). Three edits. - -**Files:** -- Modify: the `EntitySpawn` record (locate: `Grep "record EntitySpawn" src/AcDream.Core.Net` — likely `src/AcDream.Core.Net/WorldSession.cs` or a sibling) -- Modify: `src/AcDream.Core.Net/WorldSession.cs:701-719` (the `EntitySpawned?.Invoke(new EntitySpawn(...))`) -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `OnLiveEntitySpawned` handler — subscribed at line 2216) - -- [ ] **Step 1: Add `IconId` to the `EntitySpawn` record** - -Run: `Grep "record EntitySpawn" src/AcDream.Core.Net -n`. Add a `uint IconId` parameter to the record's positional parameter list (append it at the end to minimize call-site churn; note the one constructor call in WorldSession is updated next). - -- [ ] **Step 2: Pass `parsed.Value.IconId` at the invoke site** - -In `src/AcDream.Core.Net/WorldSession.cs`, in the `EntitySpawned?.Invoke(new EntitySpawn(...))` block (lines 701-719), add `parsed.Value.IconId` as the final constructor argument (matching the new record parameter position). - -- [ ] **Step 3: Enrich the repo in the spawn handler** - -In `src/AcDream.App/Rendering/GameWindow.cs`, find `OnLiveEntitySpawned` (the handler subscribed at line 2216). Add, near the top of the handler body (after the `EntitySpawn` arg is in scope, call it `e`): - -```csharp -// D.5.1: enrich a known inventory/equipped item (stubbed from PlayerDescription) -// with the icon/name/type its CreateObject carries, so the toolbar can render it. -Items.EnrichItem(e.Guid, e.IconId, e.Name, e.ItemType); -``` - -(`Items` is the `ItemRepository` field at `GameWindow.cs:598`. `EnrichItem` is a no-op returning false for non-item spawns — players, NPCs, furniture — because they aren't in the repo, so this is safe to call unconditionally.) - -- [ ] **Step 4: Build + run the full suite** - -Run: `dotnet build` then `dotnet test` -Expected: green (no behavior regression; the new arg threads through). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/WorldSession.cs src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(D.5.1): thread CreateObject IconId into ItemRepository via spawn event" -``` - ---- - -## Task 4: Persist `Parsed.Shortcuts` (the durable holder) - -`Parsed.Shortcuts` is parsed in `GameEventWiring.WireAll`'s PlayerDescription handler then discarded. Surface it to a durable holder the toolbar reads. - -**Files:** -- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (the `WireAll` signature + the PlayerDescription lambda) -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add a `Shortcuts` field; pass a callback at the `WireAll(...)` call ~line 2269) -- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` - -- [ ] **Step 1: Write the failing test** - -In `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs`, add a test that feeds a `PlayerDescription` message carrying a SHORTCUT block through `WireAll` and asserts the new `onShortcuts` callback receives the parsed list. Mirror an existing `GameEventWiringTests` PlayerDescription test for the message-construction + dispatch harness: - -```csharp -[Fact] -public void WireAll_PlayerDescription_invokesOnShortcuts() -{ - IReadOnlyList? got = null; - // ... build the same harness an existing PD test uses, but pass the new - // onShortcuts callback into WireAll: onShortcuts: list => got = list - // then dispatch a PD message whose SHORTCUT block has one entry (idx=0, guid=0x5001, spell=0, layer=0). - - Assert.NotNull(got); - Assert.Single(got!); - Assert.Equal(0x5001u, got![0].ObjectGuid); -} -``` - -- [ ] **Step 2: Run the test, verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests --filter WireAll_PlayerDescription_invokesOnShortcuts` -Expected: FAIL — `WireAll` has no `onShortcuts` parameter. - -- [ ] **Step 3: Add the callback to `WireAll` and invoke it** - -In `src/AcDream.Core.Net/GameEventWiring.cs`: -- Add a parameter to `WireAll`: `Action>? onShortcuts = null` (optional, so existing callers/tests compile unchanged). -- In the PlayerDescription handler lambda (where `Parsed` is in scope, ~lines 281-433), after the existing inventory population, add: - -```csharp -onShortcuts?.Invoke(parsed.Shortcuts); -``` - -- [ ] **Step 4: Run the test, verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests --filter WireAll_PlayerDescription_invokesOnShortcuts` -Expected: PASS. - -- [ ] **Step 5: Store the shortcuts in GameWindow** - -In `src/AcDream.App/Rendering/GameWindow.cs`: -- Add a field near `Items` (line 598): - -```csharp -/// Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source). -public IReadOnlyList Shortcuts { get; private set; } - = System.Array.Empty(); -``` -- At the `GameEventWiring.WireAll(...)` call (~line 2269), pass `onShortcuts: list => Shortcuts = list`. - -- [ ] **Step 6: Build + commit** - -Run: `dotnet build` then `dotnet test` -Expected: green. - -```bash -git add src/AcDream.Core.Net/GameEventWiring.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs -git commit -m "feat(D.5.1): persist PlayerDescription shortcuts (were parsed then discarded)" -``` - ---- - -## Task 5: `IconComposer` — CPU icon composite + cache - -**Files:** -- Create: `src/AcDream.App/UI/IconComposer.cs` -- Modify: `src/AcDream.App/Rendering/TextureCache.cs` (add a public `UploadRgba8` wrapper — it's currently private) -- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs` - -The pure compositing core is testable; the dat-decode + GL-upload is a thin shell exercised by the visual gate. - -- [ ] **Step 1: Write the failing test (pure composite)** - -`tests/AcDream.App.Tests/UI/IconComposerTests.cs`: - -```csharp -using AcDream.App.UI; -using Xunit; - -public class IconComposerTests -{ - private static byte[] Solid(int w, int h, byte r, byte g, byte b, byte a) - { - var px = new byte[w * h * 4]; - for (int i = 0; i < w * h; i++) { px[i*4]=r; px[i*4+1]=g; px[i*4+2]=b; px[i*4+3]=a; } - return px; - } - - [Fact] - public void Compose_alphaOver_topOpaqueLayerWins() - { - var bottom = (Solid(2, 2, 255, 0, 0, 255), 2, 2); // red, opaque - var top = (Solid(2, 2, 0, 0, 255, 255), 2, 2); // blue, opaque - var (rgba, w, h) = IconComposer.Compose(new[] { bottom, top }); - Assert.Equal(2, w); Assert.Equal(2, h); - Assert.Equal(0, rgba[0]); // R - Assert.Equal(0, rgba[1]); // G - Assert.Equal(255, rgba[2]); // B — top layer won - Assert.Equal(255, rgba[3]); // A - } - - [Fact] - public void Compose_alphaOver_transparentTopKeepsBottom() - { - var bottom = (Solid(1, 1, 255, 0, 0, 255), 1, 1); - var top = (Solid(1, 1, 0, 0, 255, 0), 1, 1); // fully transparent blue - var (rgba, _, _) = IconComposer.Compose(new[] { bottom, top }); - Assert.Equal(255, rgba[0]); // bottom red preserved - Assert.Equal(0, rgba[2]); - } -} -``` - -- [ ] **Step 2: Run the test, verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests --filter IconComposer` -Expected: FAIL — `IconComposer` not defined. - -- [ ] **Step 3: Implement `IconComposer`** - -`src/AcDream.App/UI/IconComposer.cs`: - -```csharp -using System; -using System.Collections.Generic; -using AcDream.App.Rendering; -using DatReaderWriter; -using DatReaderWriter.DBObjs; - -namespace AcDream.App.UI; - -/// -/// Builds an item icon by alpha-compositing its RenderSurface layers into one 32×32 -/// texture, mirroring retail IconData::RenderIcons (decomp 407524). Each layer is a -/// 0x06 RenderSurface decoded DIRECTLY (the D.2b RenderSurface-vs-Surface rule). -/// Phase 1 composites the layers ItemInstance exposes (custom underlay + base + -/// custom overlay); the GetByEnum type-default underlay, the overlay ReplaceColor -/// tint, and the effect overlay are deferred (see plan Task 12 / divergence rows). -/// Composited textures are cached by their layer-id tuple. -/// -public sealed class IconComposer -{ - private readonly DatCollection _dats; - private readonly TextureCache _cache; - private readonly Dictionary<(uint, uint, uint), uint> _byTuple = new(); - - public IconComposer(DatCollection dats, TextureCache cache) - { - _dats = dats; - _cache = cache; - } - - /// Pure alpha-over composite, bottom→top. Layers may differ in size; - /// the result is sized to the FIRST (bottom) layer and upper layers are sampled - /// top-left aligned (all icon layers are 32×32 in practice). - public static (byte[] rgba, int w, int h) Compose(IReadOnlyList<(byte[] rgba, int w, int h)> layers) - { - if (layers.Count == 0) return (Array.Empty(), 0, 0); - var (baseRgba, w, h) = layers[0]; - var outp = (byte[])baseRgba.Clone(); - for (int li = 1; li < layers.Count; li++) - { - var (src, sw, sh) = layers[li]; - int cw = Math.Min(w, sw), ch = Math.Min(h, sh); - for (int y = 0; y < ch; y++) - for (int x = 0; x < cw; x++) - { - int di = (y * w + x) * 4, si = (y * sw + x) * 4; - float sa = src[si + 3] / 255f; - if (sa <= 0f) continue; - float da = 1f - sa; - outp[di] = (byte)(src[si] * sa + outp[di] * da); - outp[di + 1] = (byte)(src[si + 1] * sa + outp[di + 1] * da); - outp[di + 2] = (byte)(src[si + 2] * sa + outp[di + 2] * da); - outp[di + 3] = (byte)Math.Min(255f, src[si + 3] + outp[di + 3] * da); - } - } - return (outp, w, h); - } - - /// Resolve (and cache) the composited GL texture for an item's icon - /// layers. Returns 0 if no base icon is available. - public uint GetIcon(uint iconId, uint underlayId, uint overlayId) - { - if (iconId == 0) return 0; - var key = (iconId, underlayId, overlayId); - if (_byTuple.TryGetValue(key, out var tex)) return tex; - - var layers = new List<(byte[] rgba, int w, int h)>(); - AddLayer(layers, underlayId); - AddLayer(layers, iconId); - AddLayer(layers, overlayId); - if (layers.Count == 0) return 0; - - var (rgba, w, h) = Compose(layers); - uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true); - _byTuple[key] = handle; - return handle; - } - - private void AddLayer(List<(byte[], int, int)> layers, uint renderSurfaceId) - { - if (renderSurfaceId == 0) return; - if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) && - !_dats.HighRes.TryGet(renderSurfaceId, out rs)) - return; - var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); - layers.Add((decoded.Rgba8, decoded.Width, decoded.Height)); - } -} -``` - -- [ ] **Step 4: Add the public `UploadRgba8` wrapper to `TextureCache`** - -In `src/AcDream.App/Rendering/TextureCache.cs`, expose the existing private upload (the one `GetOrUploadRenderSurface` calls). Add: - -```csharp -/// Upload raw RGBA8 bytes as a GL texture (used by IconComposer for -/// CPU-composited icons). Returns the GL handle. -public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest) - => UploadRgba8Internal(rgba, width, height, nearest); // rename the existing private method to *Internal if needed, or call it directly if it already has this shape -``` - -(Verify the existing private upload's name/signature with `Grep "UploadRgba8" src/AcDream.App/Rendering/TextureCache.cs`; if it already takes `(byte[], int, int, bool)`, just change its accessibility to `public` instead of adding a wrapper.) - -- [ ] **Step 5: Run the tests, verify they pass; build** - -Run: `dotnet test tests/AcDream.App.Tests --filter IconComposer` then `dotnet build` -Expected: PASS + green build. - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/IconComposer.cs src/AcDream.App/Rendering/TextureCache.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs -git commit -m "feat(D.5.1): IconComposer — CPU alpha-over icon composite + cache" -``` - ---- - -## Task 6: `UiItemSlot` widget (the item cell) - -**Files:** -- Create: `src/AcDream.App/UI/UiItemSlot.cs` -- Test: `tests/AcDream.App.Tests/UI/UiItemSlotTests.cs` - -- [ ] **Step 1: Write the failing test** - -```csharp -using AcDream.App.UI; -using Xunit; - -public class UiItemSlotTests -{ - [Fact] - public void IsLeafWidget() - => Assert.True(new UiItemSlot().ConsumesDatChildren); - - [Fact] - public void DefaultEmptySprite_isToolbarBorder() - => Assert.Equal(0x060074CFu, new UiItemSlot().EmptySprite); - - [Fact] - public void Empty_whenNoItem() - { - var s = new UiItemSlot(); - Assert.Equal(0u, s.ItemId); - Assert.Equal(0u, s.IconTexture); - } - - [Fact] - public void SetItem_setsIdAndTexture() - { - var s = new UiItemSlot(); - s.SetItem(0x5001u, 0x99u); - Assert.Equal(0x5001u, s.ItemId); - Assert.Equal(0x99u, s.IconTexture); - } -} -``` - -- [ ] **Step 2: Run the test, verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests --filter UiItemSlot` -Expected: FAIL — `UiItemSlot` not defined. - -- [ ] **Step 3: Implement `UiItemSlot`** - -`src/AcDream.App/UI/UiItemSlot.cs`: - -```csharp -using System; -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// One item-in-a-slot cell (port of retail UIElement_UIItem, class 0x10000032). -/// A behavioral LEAF: it draws the empty-slot sprite when unbound, else a -/// pre-composited icon texture (set by the controller). Holds the bound weenie -/// guid (retail UIElement_UIItem::itemID, +0x5FC). -/// -public sealed class UiItemSlot : UiElement -{ - public UiItemSlot() { ClickThrough = false; } - - public override bool ConsumesDatChildren => true; - - /// Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID. - public uint ItemId { get; private set; } - - /// Pre-composited icon GL texture for the bound item (0 = none). - public uint IconTexture { get; private set; } - - /// Empty-slot sprite. Default = the generic toolbar empty-slot border - /// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so - /// paperdoll equip slots can use their per-slot silhouettes later. - public uint EmptySprite { get; set; } = 0x060074CFu; - - /// RenderSurface id → (GL texture, w, h). Set by the factory/controller. - public Func? SpriteResolve { get; set; } - - public void SetItem(uint itemId, uint iconTexture) - { - ItemId = itemId; - IconTexture = iconTexture; - } - - public void Clear() { ItemId = 0; IconTexture = 0; } - - protected override void OnDraw(UiRenderContext ctx) - { - if (ItemId != 0 && IconTexture != 0) - { - ctx.DrawSprite(IconTexture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - return; - } - if (SpriteResolve is not null && EmptySprite != 0) - { - var (tex, _, _) = SpriteResolve(EmptySprite); - if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - } -} -``` - -- [ ] **Step 4: Run the tests, verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests --filter UiItemSlot` -Expected: PASS (all four). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/UiItemSlotTests.cs -git commit -m "feat(D.5.1): UiItemSlot widget (UIElement_UIItem cell port)" -``` - ---- - -## Task 7: `UiItemList` widget + `DatWidgetFactory` branch - -**Files:** -- Create: `src/AcDream.App/UI/UiItemList.cs` -- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (the `Create` switch, lines 63-71) -- Test: `tests/AcDream.App.Tests/UI/UiItemListTests.cs` + `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` (verify the factory-test file exists with `Glob tests/AcDream.App.Tests/**/*WidgetFactory*`; if absent, create it) - -- [ ] **Step 1: Write the failing tests** - -`tests/AcDream.App.Tests/UI/UiItemListTests.cs`: - -```csharp -using AcDream.App.UI; -using Xunit; - -public class UiItemListTests -{ - [Fact] - public void IsLeafWidget() => Assert.True(new UiItemList().ConsumesDatChildren); - - [Fact] - public void StartsWithOneCell_forSingleCellSlot() - { - var list = new UiItemList(); - Assert.Equal(1, list.GetNumUIItems()); - Assert.NotNull(list.GetItem(0)); - } - - [Fact] - public void Cell_returnsTheFirstSlot() - { - var list = new UiItemList(); - Assert.Same(list.GetItem(0), list.Cell); - } -} -``` - -Add to the factory test file: - -```csharp -[Fact] -public void Create_buildsUiItemList_forItemListClassId() -{ - var info = new AcDream.App.UI.Layout.ElementInfo { Id = 0x100001A7u, Type = 0x10000031u, Width = 32, Height = 32 }; - var w = AcDream.App.UI.Layout.DatWidgetFactory.Create(info, _ => (0u, 0, 0), null); - Assert.IsType(w); -} -``` - -- [ ] **Step 2: Run the tests, verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests --filter "UiItemList|UiItemList_forItemListClassId"` -Expected: FAIL — `UiItemList` not defined / factory returns `UiDatElement`. - -- [ ] **Step 3: Implement `UiItemList`** - -`src/AcDream.App/UI/UiItemList.cs`: - -```csharp -using System; -using System.Collections.Generic; - -namespace AcDream.App.UI; - -/// -/// A container of item cells (port of retail UIElement_ItemList, class 0x10000031). -/// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the -/// LayoutImporter must NOT build dat children. The toolbar uses single-cell -/// instances (one slot); the inventory phase will grow this to an N-cell grid. -/// -public sealed class UiItemList : UiElement -{ - private readonly List _cells = new(); - - public UiItemList(Func? spriteResolve = null) - { - SpriteResolve = spriteResolve; - // Single-cell default: every toolbar slot always shows one cell (empty or filled). - AddItem(new UiItemSlot { SpriteResolve = spriteResolve }); - } - - public override bool ConsumesDatChildren => true; - - public Func? SpriteResolve { get; set; } - - /// Convenience for single-cell slots (the toolbar): the first cell. - public UiItemSlot Cell => _cells[0]; - - public int GetNumUIItems() => _cells.Count; - - public UiItemSlot? GetItem(int index) - => index >= 0 && index < _cells.Count ? _cells[index] : null; - - public void AddItem(UiItemSlot cell) - { - cell.SpriteResolve ??= SpriteResolve; - cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height; - _cells.Add(cell); - AddChild(cell); - } - - public void Flush() - { - foreach (var c in _cells) RemoveChild(c); - _cells.Clear(); - } - - protected override void OnDraw(UiRenderContext ctx) - { - // The factory sets THIS list's Width/Height AFTER construction, so the cell - // (added in the ctor) starts 0x0. For the single-cell toolbar slot, keep the - // cell sized to the list each frame; the cell paints itself in the children - // pass that follows. (N-cell grid layout is the inventory phase.) - if (_cells.Count > 0) - { - var cell = _cells[0]; - cell.Left = 0; cell.Top = 0; cell.Width = Width; cell.Height = Height; - } - } -} -``` - -Note: `ConsumesDatChildren` stops the IMPORTER from adding dat children, but the list still draws its own `UiItemSlot` children (added via `AddChild`) through the normal `DrawSelfAndChildren` traversal — `ConsumesDatChildren` only gates the importer, not runtime children. The cell's `Width`/`Height` are synced in the list's `OnDraw` (which runs before the children pass), so the cell is correctly sized + hit-testable from the first rendered frame. - -- [ ] **Step 4: Add the factory branch** - -In `src/AcDream.App/UI/Layout/DatWidgetFactory.cs`, add to the `Create` switch (lines 63-71), before the `_` fallback: - -```csharp -0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots -``` - -(The item *cell* class `0x10000032` is created procedurally by `UiItemList`, not via a static dat element in the toolbar, so it needs no factory branch this phase.) - -- [ ] **Step 5: Run the tests, verify they pass; build** - -Run: `dotnet test tests/AcDream.App.Tests --filter "UiItemList|ItemListClassId"` then `dotnet build` -Expected: PASS + green. - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/UiItemList.cs src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/UiItemListTests.cs tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs -git commit -m "feat(D.5.1): UiItemList widget + factory branch for class 0x10000031" -``` - ---- - -## Task 8: `ToolbarController` (the `gmToolbarUI::PostInit` analogue) - -**Files:** -- Create: `src/AcDream.App/UI/Layout/ToolbarController.cs` -- Test: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` - -- [ ] **Step 1: Write the failing test** - -```csharp -using System; -using System.Collections.Generic; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using AcDream.Core.Items; -using AcDream.Core.Net.Messages; -using Xunit; - -public class ToolbarControllerTests -{ - private static readonly uint[] Row1 = - { 0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF }; - private static readonly uint[] Row2 = - { 0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF }; - - private static (ImportedLayout layout, Dictionary slots) FakeToolbar() - { - var dict = new Dictionary(); - var slots = new Dictionary(); - var root = new UiPanel(); - foreach (var id in Row1) AddSlot(id); - foreach (var id in Row2) AddSlot(id); - return (new ImportedLayout(root, dict), slots); - - void AddSlot(uint id) - { - var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 }; - dict[id] = list; slots[id] = list; root.AddChild(list); - } - } - - [Fact] - public void Populate_bindsShortcutToCorrectSlot() - { - var (layout, slots) = FakeToolbar(); - var repo = new ItemRepository(); - repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new List - { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; - - ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_) => 0x77u, useItem: _ => { }); - - Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId); - Assert.Equal(0x77u, slots[Row1[0]].Cell.IconTexture); - Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); // others empty - } - - [Fact] - public void DeferredRebind_whenItemArrivesLate() - { - var (layout, slots) = FakeToolbar(); - var repo = new ItemRepository(); // item NOT present yet - var shortcuts = new List - { new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; - - ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_) => 0x88u, useItem: _ => { }); - Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet - - repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); - - Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded - } - - [Fact] - public void Click_emitsUseForBoundItem() - { - var (layout, slots) = FakeToolbar(); - var repo = new ItemRepository(); - repo.AddOrUpdate(new ItemInstance { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new List - { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; - uint used = 0; - - ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_) => 0x77u, useItem: g => used = g); - slots[Row1[0]].Cell.OnEvent(new UiEvent { Type = UiEventType.MouseDown }); - - Assert.Equal(0x5001u, used); - } -} -``` - -(Adapt `UiEvent` construction + the click-emit seam to the toolkit's actual event shape — see Step 3; if `UiItemSlot` needs a `Clicked` callback rather than handling `OnEvent`, wire that in Step 3 and update this assertion to invoke it.) - -- [ ] **Step 2: Run the tests, verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests --filter ToolbarController` -Expected: FAIL — `ToolbarController` not defined. - -- [ ] **Step 3: Implement `ToolbarController`** - -`src/AcDream.App/UI/Layout/ToolbarController.cs`: - -```csharp -using System; -using System.Collections.Generic; -using AcDream.Core.Items; -using AcDream.Core.Net.Messages; - -namespace AcDream.App.UI.Layout; - -/// -/// Binds the imported gmToolbarUI window (LayoutDesc 0x21000016) to live data — -/// the gm*UI::PostInit analogue. Finds the 18 shortcut slots (UiItemList) by id, -/// populates them from the persisted PlayerDescription shortcuts (UpdateFromPlayerDesc), -/// re-binds deferred slots when an item's CreateObject arrives (SetDelayedShortcutNum), -/// and on click uses the bound item (UseShortcut → ItemHolder::UseObject → use-item). -/// -public sealed class ToolbarController -{ - // Slot element ids, in slot-index order (toolbar pre-dump 0x21000016). - private static readonly uint[] SlotIds = - { - 0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF, - 0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF, - }; - // Hidden-by-default elements (gmToolbarUI::PostInit): selected-object meters + stack slider. - private static readonly uint[] HiddenIds = { 0x100001A1, 0x100001A2, 0x100001A4 }; - - private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length]; - private readonly ItemRepository _repo; - private readonly Func> _shortcuts; - private readonly Func _iconIds; // (iconId, underlay, overlay) → GL texture - private readonly Action _useItem; - - private ToolbarController(ImportedLayout layout, ItemRepository repo, - Func> shortcuts, - Func iconIds, Action useItem) - { - _repo = repo; _shortcuts = shortcuts; _iconIds = iconIds; _useItem = useItem; - - for (int i = 0; i < SlotIds.Length; i++) - { - _slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; - if (_slots[i] is { } list) - WireClick(list); - } - foreach (var id in HiddenIds) - if (layout.FindElement(id) is { } e) e.Visible = false; - - repo.ItemAdded += _ => Populate(); - repo.ItemPropertiesUpdated += _ => Populate(); - } - - public static ToolbarController Bind(ImportedLayout layout, ItemRepository repo, - Func> shortcuts, - Func iconIds, Action useItem) - { - var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem); - c.Populate(); - return c; - } - - /// Port of gmToolbarUI::UpdateFromPlayerDesc — flush then bind each shortcut. - public void Populate() - { - foreach (var list in _slots) list?.Cell.Clear(); - - foreach (var sc in _shortcuts()) - { - if (sc.ObjectGuid == 0) continue; // spell shortcuts — deferred phase - if (sc.Index >= _slots.Length) continue; - var list = _slots[(int)sc.Index]; - if (list is null) continue; - var item = _repo.GetItem(sc.ObjectGuid); - if (item is null) continue; // SetDelayedShortcutNum: re-bound on ItemAdded - uint tex = _iconIds(item.IconId, item.IconUnderlayId, item.IconOverlayId); - list.Cell.SetItem(sc.ObjectGuid, tex); - } - } - - private void WireClick(UiItemList list) - { - list.Cell.Clicked = () => - { - if (list.Cell.ItemId != 0) _useItem(list.Cell.ItemId); - }; - } -} -``` - -This requires a `Clicked` callback on `UiItemSlot`. Add to `UiItemSlot` (Task 6 file) and have `OnEvent` invoke it on mouse-down: - -```csharp -public Action? Clicked { get; set; } - -public override bool OnEvent(in UiEvent e) -{ - if (e.Type == UiEventType.MouseDown) { Clicked?.Invoke(); return true; } - return false; -} -``` - -(If `UiEvent`/`UiEventType` member names differ, match the toolkit's actual definitions — `Grep "enum UiEventType" src/AcDream.App/UI`.) - -- [ ] **Step 4: Run the tests, verify they pass; build** - -Run: `dotnet test tests/AcDream.App.Tests --filter ToolbarController` then `dotnet build` -Expected: PASS (all three) + green. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs -git commit -m "feat(D.5.1): ToolbarController — bind 18 slots, populate, deferred rebind, click-to-use" -``` - ---- - -## Task 9: Wire the toolbar into `GameWindow` - -Integration (covered by the visual gate). Mirror the vitals import + mount. - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `if (_options.RetailUi)` block, ~lines 1761-1898, after the chat block) - -- [ ] **Step 1: Construct the IconComposer once** - -In the `if (_options.RetailUi)` block (after `_uiHost` + the sprite resolver `ResolveChrome` exist, ~line 1778), add: - -```csharp -var iconComposer = new AcDream.App.UI.IconComposer(_dats!, cache); -``` - -- [ ] **Step 2: Import the toolbar layout + bind the controller** - -After the chat block (~line 1898), add (mirroring the vitals import at 1800-1828): - -```csharp -AcDream.App.UI.Layout.ImportedLayout? toolbarLayout; -lock (_datLock) - toolbarLayout = AcDream.App.UI.Layout.LayoutImporter.Import( - _dats!, 0x21000016u, ResolveChrome, vitalsDatFont); -if (toolbarLayout is not null) -{ - AcDream.App.UI.Layout.ToolbarController.Bind( - toolbarLayout, Items, - () => Shortcuts, - iconIds: (icon, under, over) => iconComposer.GetIcon(icon, under, over), - useItem: guid => UseItemByGuid(guid)); // existing use-item path (see Step 3) - - var toolbarRoot = toolbarLayout.Root; - toolbarRoot.Left = 10; toolbarRoot.Top = 300; // initial position; user-draggable - toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top; - toolbarRoot.Draggable = true; - _uiHost.Root.AddChild(toolbarRoot); -} -``` - -- [ ] **Step 3: Provide `UseItemByGuid`** - -acdream already builds + sends use-item at `GameWindow.cs:11577-11579` (`InteractRequests.BuildUse(seq, guid)` → `_liveSession.SendGameAction`). Extract that into a small helper if it isn't already callable by guid: - -```csharp -private void UseItemByGuid(uint guid) -{ - if (_liveSession is null) return; - var seq = _liveSession.NextGameActionSequence(); - var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); - _liveSession.SendGameAction(body); -} -``` - -(If a guid-based use helper already exists near line 11577, call it instead of duplicating.) - -- [ ] **Step 4: Build** - -Run: `dotnet build` -Expected: green. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(D.5.1): mount the toolbar window under ACDREAM_RETAIL_UI" -``` - ---- - -## Task 10: Full suite + manual smoke gate - -- [ ] **Step 1: Build + full test suite** - -Run: `dotnet build` then `dotnet test` -Expected: all green. - -- [ ] **Step 2: Launch + visual verification (the user's gate)** - -Launch per CLAUDE.md (PowerShell, background, Tee to `launch.log`): - -```powershell -$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" -$env:ACDREAM_LIVE = "1" -$env:ACDREAM_RETAIL_UI = "1" -$env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000" -$env:ACDREAM_TEST_USER = "testaccount"; $env:ACDREAM_TEST_PASS = "testpassword" -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log" -``` - -Acceptance (user confirms by looking): -- An 18-slot action bar (2 rows of 9) renders with the dat chrome + empty-slot sprites. -- Any persisted `+Acdream` shortcuts show their real composited item icons. -- Clicking a pinned item uses it (observe server-side / in-world effect). -- The bar drags as a whole window. - -(If `+Acdream` has no persisted shortcuts, the empty-slot render is still a valid gate; pinning real items to test icons may need the inventory phase or a server-side pre-pin — note this to the user.) - ---- - -## Task 11: Bookkeeping — divergence register, roadmap shipped, memory - -**Files:** -- Modify: `docs/architecture/retail-divergence-register.md` -- Modify: `docs/plans/2026-04-11-roadmap.md` -- Modify: `claude-memory/project_d2b_retail_ui.md` (durable lesson, if any) - -- [ ] **Step 1: Add divergence rows** - -Add rows to `docs/architecture/retail-divergence-register.md` for the phase-1 icon deferrals + the empty-sprite constant: - -- Icon composite omits the retail `GetByEnum` type-default underlay (`IconData::RenderIcons` 407524, enum 0x10000004), the overlay `ReplaceColor` tint, and the effect overlay (enum 0x10000005) — their source data (overlay tint color, `IconEffects`) isn't parsed yet. Risk: items with a material/effect overlay render without it. Retire when the inventory phase parses the full `PublicWeenieDesc`. -- `UiItemSlot.EmptySprite` defaults to the constant `0x060074CF` instead of importing the empty-slot state from the uiitem template `0x21000037`. Risk: paperdoll equip-slot silhouettes need per-slot empty sprites (already configurable). Retire when the cell imports its template states. -- (Reuse the existing IA-12 row for whole-window-drag — no new row.) - -- [ ] **Step 2: Flip the roadmap entry to shipped** - -In `docs/plans/2026-04-11-roadmap.md`, change the D.5.1 entry from `[IN PROGRESS]` to `✓ SHIPPED` with the commit range, mirroring the other D.2b shipped entries. - -- [ ] **Step 3: Commit** - -```bash -git add docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md claude-memory/project_d2b_retail_ui.md -git commit -m "docs(D.5.1): divergence rows + roadmap shipped + memory for the toolbar" -``` - ---- - -## Task 12 (FOLLOW-UP, optional within phase): faithful icon layers - -Deferred from Task 5 to keep phase 1 shippable; do only if the toolbar icons visibly lack the standard background. NOT required for the phase-1 acceptance gate. - -- Port `DBObj::GetByEnum(0x10000004, lsb(itemType)+1)` (the type-default underlay) — first confirm what `0x10000004` maps to (dump an EnumMapper DBObj) to decide whether it's the universal icon background. Add it as the bottom layer in `IconComposer.GetIcon`. -- Parse `IconEffects`/`IconOverlay` tint from `CreateObject` (extend `CreateObject.Parsed` + `ItemInstance`), then add the `ReplaceColor` overlay tint + the effect overlay (`GetByEnum 0x10000005`). -- Delete the corresponding divergence rows from Task 11 Step 1 as each layer lands. - ---- - -## Self-review notes (author) - -- **Spec coverage:** spec §2 widgets → Tasks 6,7; §4.3 icon → Task 5 (+12); §4.4 CreateObject/ItemInstance → Tasks 1,2,3; §4.5 ToolbarController → Task 8; §4.6 wiring/gating → Task 9; §5 testing → per-task TDD + Task 10; §6 acceptance → Task 10; §8 bookkeeping → Tasks 0,11. The shortcut-holder (Task 4) was implicit in §4.5's "reads Parsed.Shortcuts" and is made explicit here. -- **Type consistency:** `UiItemSlot.SetItem(uint,uint)` / `.Clear()` / `.ItemId` / `.IconTexture` / `.EmptySprite` / `.Clicked`; `UiItemList.Cell` / `.GetItem(int)` / `.GetNumUIItems()` / `.AddItem(UiItemSlot)` / `.Flush()`; `IconComposer.Compose(IReadOnlyList<(byte[],int,int)>)` / `.GetIcon(uint,uint,uint)`; `ItemRepository.EnrichItem(uint,uint,string,ItemType)`; `ToolbarController.Bind(ImportedLayout, ItemRepository, Func>, Func, Action)`. These match across Tasks 5-9. -- **Known executor confirmations (grep-to-confirm, not placeholders):** the exact name/signature of `TextureCache`'s private RGBA upload (Task 5 Step 4); the `EntitySpawn` record location (Task 3 Step 1); the `UiEvent`/`UiEventType` member shape (Tasks 6/8); whether a guid-based use helper already exists near `GameWindow.cs:11577` (Task 9 Step 3). Each step names the grep + the change. diff --git a/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md b/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md deleted file mode 100644 index e68c745f..00000000 --- a/docs/superpowers/plans/2026-06-16-d2b-widget-generalization.md +++ /dev/null @@ -1,992 +0,0 @@ -# D.2b Widget Generalization Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Refactor the hand-named chat widgets and the Send/Max-Min click-wiring into generic, Type-registered widgets built by `DatWidgetFactory`, collapsing the controllers to a thin retail `gm*UI::PostInit`-style find-by-id binder. - -**Architecture:** `DatWidgetFactory.Create` grows a faithful `switch(Type)` registering the real retail `UIElement` classes (Button=1, Field=3, Menu=6, Meter=7, Scrollbar=11, Text=12) as generic widgets; everything else stays `UiDatElement`. The importer's base-chain Type resolution already surfaces each element's real Type, so this is a *registration* task. The chat-specific knowledge (channel list, colors, command routing) moves out of widgets into `ChatWindowController`. Migrate one widget per commit; chat stays visually identical through Tasks 2–7; vitals is rewired last (Task 8) behind a visual gate. - -**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter` (Chorizite), Silk.NET (GL/input). Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. - -**Spec:** `docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md`. - ---- - -## Conventions - -- **Repo root** = the worktree dir. All paths below are relative to it. -- **Build:** `dotnet build` (builds `AcDream.slnx`). Must be green before every commit. -- **Test (all UI):** `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` -- **Test (filtered):** add `--filter "FullyQualifiedName~"`. -- **Commit style:** `feat(D.2b): ` / `test(D.2b): …` / `refactor(D.2b): …`, ending with the project's `Co-Authored-By: Claude Opus 4.8 (1M context) ` trailer. -- **Every generic widget cites its retail class + `RegisterElementClass` line** in a doc comment (per spec §8). -- **Divergence register:** `docs/architecture/retail-divergence-register.md` — amend AP-37 / re-check AP-41 in the same commit that lands the relevant widget (per spec §7). - ---- - -## File Structure - -**Created:** -- `src/AcDream.App/UI/UiButton.cs` — generic Type-1 button (Task 3). -- `src/AcDream.App/UI/UiText.cs` — generic Type-12 scrollable colored-line text (rename of `UiChatView`, Task 5). -- `src/AcDream.App/UI/UiField.cs` — generic Type-3 editable one-line field (rename of `UiChatInput`, Task 6). -- `src/AcDream.App/UI/UiScrollbar.cs` — generic Type-11 scrollbar (rename of `UiChatScrollbar`, Task 2). -- `src/AcDream.App/UI/UiMenu.cs` — generic Type-6 dropdown menu (genericized `UiChannelMenu`, Task 4). -- `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` — golden resolved chat tree (Task 1). -- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs` — skip-by-default fixture generator (Task 1). -- `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` — resolved-tree + factory-class conformance (Task 1, grown per widget). -- `tests/AcDream.App.Tests/UI/UiButtonTests.cs` (Task 3). - -**Renamed (git mv + class/namespace-internal rename):** -- `UiChatScrollbar.cs` → `UiScrollbar.cs`; `UiChatScrollbarTests.cs` → `UiScrollbarTests.cs` (Task 2). -- `UiChatView.cs` → `UiText.cs`; `UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs` (Task 5). -- `UiChatInput.cs` → `UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs` (Task 6). -- `UiChannelMenu.cs` → `UiMenu.cs`; `UiChannelMenuTests.cs` → `UiMenuTests.cs` (Task 4). - -**Modified:** -- `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` — the `switch(Type)` + `BuildButton`/`BuildMenu`/`BuildText`/`BuildField`/`BuildScrollbar` (Tasks 2–6). -- `src/AcDream.App/UI/Layout/ChatWindowController.cs` — construction → find-by-id binding; channel-item population (Tasks 2–7). -- `src/AcDream.App/UI/Layout/VitalsController.cs` — bind `UiText` numbers (Task 8). -- `src/AcDream.App/Rendering/GameWindow.cs` — only property-type follow-through (`.Transcript`/`.Input` types change) if needed (Tasks 5–6). -- `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs` — new per-Type asserts; flip the two Type-12 tests (Tasks 2–6). -- `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs` — add `LoadChat()` (Task 1). - ---- - -## Task 1: Chat golden fixture + conformance test (also resolves the input's Type empirically) - -**Files:** -- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs` -- Create: `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` (generated, committed) -- Modify: `tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs` -- Create: `tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs` - -The generator runs once against the live dat (it is `[Fact(Skip=…)]` so CI never runs it). The committed JSON is dat-free, like `vitals_2100006C.json`. The fixture's resolved `Type` per element **answers spec verification #1** (does input `0x10000016` resolve to 3 or 12?). - -- [ ] **Step 1: Write the generator (skip-by-default).** - -`ChatLayoutFixtureGenerator.cs`: -```csharp -using System.IO; -using System.Runtime.CompilerServices; -using System.Text.Json; -using AcDream.App.UI.Layout; -using DatReaderWriter; -using DatReaderWriter.Options; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// One-off generator for the committed chat golden fixture. Skipped by default — -/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate -/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made. -/// -public class ChatLayoutFixtureGenerator -{ - [Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")] - public void GenerateChatFixture() - { - var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - using var dats = new DatCollection(datDir, DatAccessType.Read); - var info = LayoutImporter.ImportInfos(dats, 0x21000006u); - Assert.NotNull(info); - - var json = JsonSerializer.Serialize(info, new JsonSerializerOptions - { - IncludeFields = true, - WriteIndented = true, - }); - File.WriteAllText(FixturePath(), json); - } - - // Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path. - private static string FixturePath([CallerFilePath] string thisFile = "") - => Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json"); -} -``` - -- [ ] **Step 2: Generate the fixture (manual, dats present).** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutFixtureGenerator.GenerateChatFixture" -e ACDREAM_DAT_DIR="%USERPROFILE%\Documents\Asheron's Call"` after temporarily removing the `Skip` (or use an IDE run). Confirm `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` is written and non-empty, then restore the `Skip`. -Expected: a JSON tree rooted at id `0x10000006`-family with the chat elements. **Record the resolved `Type` of `0x10000016` (input) and `0x10000011` (transcript)** — these drive Task 5/6 decisions. - -- [ ] **Step 3: Add `FixtureLoader.LoadChat()` + `LoadChatInfos()`.** - -In `FixtureLoader.cs`, add (mirroring `LoadVitals`/`LoadVitalsInfos`): -```csharp - public static ImportedLayout LoadChat() - => LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null); - - public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos() - => LoadInfos("chat_21000006.json"); - - // Shared loader (refactor LoadVitalsInfos to call this with "vitals_2100006C.json"). - private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) - { - var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName); - if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}"); - var bytes = File.ReadAllBytes(path); - ReadOnlySpan span = bytes; - if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) span = span[3..]; - return JsonSerializer.Deserialize(span, _opts) - ?? throw new InvalidOperationException($"fixture deserialized to null: {path}"); - } -``` -Then make `LoadVitalsInfos()` delegate: `public static ElementInfo LoadVitalsInfos() => LoadInfos("vitals_2100006C.json");` - -- [ ] **Step 4: Write the resolved-tree conformance test (fails until the fixture exists).** - -`ChatLayoutConformanceTests.cs`: -```csharp -using System.Collections.Generic; -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI.Layout; - -public class ChatLayoutConformanceTests -{ - private static ElementInfo Find(ElementInfo n, uint id) - { - if (n.Id == id) return n; - foreach (var c in n.Children) { var f = Find(c, id); if (f is not null) return f; } - return null!; - } - - [Fact] - public void ChatFixture_ResolvesKnownElements() - { - var root = FixtureLoader.LoadChatInfos(); - // These ids come from ChatWindowController; the resolved Type proves the base-chain merge. - Assert.NotNull(Find(root, 0x10000011u)); // transcript - Assert.NotNull(Find(root, 0x10000016u)); // input - Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track - Assert.NotNull(Find(root, 0x10000014u)); // channel menu - Assert.NotNull(Find(root, 0x10000019u)); // send button - Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button - } - - [Fact] - public void ChatFixture_ResolvedTypes_MatchRetailRegistry() - { - var root = FixtureLoader.LoadChatInfos(); - Assert.Equal(6u, Find(root, 0x10000014u).Type); // Menu - Assert.Equal(11u, Find(root, 0x10000012u).Type); // Scrollbar - Assert.Equal(1u, Find(root, 0x10000019u).Type); // Button (Send) - Assert.Equal(1u, Find(root, 0x1000046Fu).Type); // Button (Max/Min) - // transcript + input: assert the ACTUAL resolved Type recorded in Step 2. - // From the Map trace both resolve to 12 (Text); if Step 2 shows otherwise, update these. - Assert.Equal(12u, Find(root, 0x10000011u).Type); // Text (transcript) - Assert.Equal(12u, Find(root, 0x10000016u).Type); // Text (input — see Task 6 wrinkle) - } -} -``` - -- [ ] **Step 5: Run the conformance tests.** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ChatLayoutConformanceTests"` -Expected: PASS. If `ChatFixture_ResolvedTypes_MatchRetailRegistry` shows input `0x10000016` Type ≠ 12, **update the assert to the real value and note it in Task 6 Step 1** (decides factory-built vs controller-placed `UiField`). - -- [ ] **Step 6: Commit.** -```bash -git add tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs \ - tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json \ - tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs \ - tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs -git commit -m "test(D.2b): chat golden fixture + resolved-Type conformance (widget-generalization Task 1)" -``` - ---- - -## Task 2: `UiScrollbar` (Type 11) — promote the already-generic scrollbar - -`UiChatScrollbar` has zero chat-specific code; this is a rename + factory registration. - -**Files:** -- Rename: `src/AcDream.App/UI/UiChatScrollbar.cs` → `src/AcDream.App/UI/UiScrollbar.cs` -- Rename: `tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs` → `tests/AcDream.App.Tests/UI/UiScrollbarTests.cs` -- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` -- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs` -- Modify: `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`, `ChatLayoutConformanceTests.cs` - -- [ ] **Step 1: Rename the widget file + class.** -```bash -git mv src/AcDream.App/UI/UiChatScrollbar.cs src/AcDream.App/UI/UiScrollbar.cs -git mv tests/AcDream.App.Tests/UI/UiChatScrollbarTests.cs tests/AcDream.App.Tests/UI/UiScrollbarTests.cs -``` -In `UiScrollbar.cs`: rename `class UiChatScrollbar` → `class UiScrollbar`; update the doc summary to "Generic scrollbar. Ports retail `UIElement_Scrollbar` (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137)…"; keep all body/fields/methods unchanged. -In `UiScrollbarTests.cs`: rename the test class to `UiScrollbarTests`; replace every `UiChatScrollbar` with `UiScrollbar`. (Keep the test bodies.) - -- [ ] **Step 2: Write the failing factory test.** - -In `DatWidgetFactoryTests.cs` add: -```csharp - [Fact] - public void Type11_Scrollbar_MakesUiScrollbar() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null); - Assert.IsType(e); - } -``` - -- [ ] **Step 3: Run it — verify it fails.** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests.Type11_Scrollbar_MakesUiScrollbar"` -Expected: FAIL (`Create` returns `UiDatElement`, not `UiScrollbar`). - -- [ ] **Step 4: Register Type 11 in the factory.** - -In `DatWidgetFactory.Create`, add to the switch (before `_`): -```csharp - 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) -``` - -- [ ] **Step 5: Build + run factory + scrollbar tests.** - -Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~UiScrollbarTests"` -Expected: PASS. - -- [ ] **Step 6: Point the controller at the factory-built scrollbar (still functional).** - -The factory now builds a `UiScrollbar` for the Type-11 track element. In `ChatWindowController.cs`, in the "Scrollbar — replace the imported track placeholder" block, change the construction to bind the factory widget instead of building a fresh one. Replace the block (currently `c.Scrollbar = new UiChatScrollbar { … }; trackParent.RemoveChild(track); trackParent.AddChild(c.Scrollbar);`) with: -```csharp - // The factory built the Type-11 track element as a UiScrollbar. Find it, bind it. - if (layout.FindElement(TrackId) is UiScrollbar bar) - { - bar.Top = 0f; // pull up to the panel top (resize-bar reclaim) - bar.Height = bar.Height + bar.Top; // NOTE: capture old Top before zeroing — see Step 6a - bar.Model = c.Transcript.Scroll; - bar.SpriteResolve = resolve; - bar.TrackSprite = TrackSprite; - bar.ThumbSprite = ThumbSprite; - bar.ThumbTopSprite = ThumbTopSprite; - bar.ThumbBotSprite = ThumbBotSprite; - bar.UpSprite = UpSprite; - bar.DownSprite = DownSprite; - c.Scrollbar = bar; - } -``` -- [ ] **Step 6a: Fix the Top/Height order bug introduced above.** The old code added `track.Top` to height *before* zeroing Top. Write it correctly: -```csharp - if (layout.FindElement(TrackId) is UiScrollbar bar) - { - float oldTop = bar.Top; - bar.Top = 0f; - bar.Height = bar.Height + oldTop; - bar.Model = c.Transcript.Scroll; - bar.SpriteResolve = resolve; - bar.TrackSprite = TrackSprite; bar.ThumbSprite = ThumbSprite; - bar.ThumbTopSprite = ThumbTopSprite; bar.ThumbBotSprite = ThumbBotSprite; - bar.UpSprite = UpSprite; bar.DownSprite = DownSprite; - c.Scrollbar = bar; - } -``` -Change the `Scrollbar` property type: `public UiScrollbar Scrollbar { get; private set; } = null!;` - -- [ ] **Step 7: Update the conformance Type assert (already Type 11) + run full UI suite.** - -`ChatLayoutConformanceTests` already asserts Type 11 for the track. Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` -Expected: PASS (whole UI suite). - -- [ ] **Step 8: Re-check AP-41 in the divergence register.** - -The controller passes `ThumbTopSprite`/`ThumbBotSprite` (3-slice caps), so AP-41 ("thumb single stretched sprite") is stale. In `docs/architecture/retail-divergence-register.md`, update the AP-41 `file:line` from `UiChatScrollbar.cs:37` to `UiScrollbar.cs` and narrow/retire it (the 3-slice path now draws caps; retire only if the fallback single-tile path is no longer reachable — it is reachable when caps are 0, so narrow the wording to "fallback only"). - -- [ ] **Step 9: Commit.** -```bash -git add -A -git commit -m "feat(D.2b): UiScrollbar (Type 11) — promote the generic chat scrollbar (widget-generalization Task 2)" -``` - ---- - -## Task 3: `UiButton` (Type 1) — Send + Max/Min - -The factory currently builds Send/Max-Min as `UiDatElement` and the controller sets `OnClick`/`Label`. Introduce a dedicated `UiButton` mirroring that behavior exactly (so clicks don't regress) and register Type 1. - -**Files:** -- Create: `src/AcDream.App/UI/UiButton.cs` -- Create: `tests/AcDream.App.Tests/UI/UiButtonTests.cs` -- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs` - -- [ ] **Step 1: Write the failing button-behavior test.** - -`UiButtonTests.cs`: -```csharp -using System.Numerics; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI; - -public class UiButtonTests -{ - private static (uint, int, int) NoTex(uint _) => (0, 0, 0); - - [Fact] - public void Click_InvokesOnClick() - { - var info = new ElementInfo { Type = 1, Width = 46, Height = 18 }; - var b = new UiButton(info, NoTex) { OnClick = () => Clicked = true }; - b.OnEvent(new UiEvent(UiEventType.Click, 0, 0, 0)); - Assert.True(Clicked); - } - private bool Clicked; - - [Fact] - public void NotClickThrough_SoItReceivesClicks() - { - var b = new UiButton(new ElementInfo { Type = 1 }, NoTex); - Assert.False(b.ClickThrough); - } -} -``` -> Confirm the `UiEvent` constructor signature in `src/AcDream.App/UI/UiEvent.cs` before finalizing the `new UiEvent(...)` call; adjust arg order if needed. - -- [ ] **Step 2: Run it — verify it fails (UiButton does not exist).** - -Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"` -Expected: FAIL (compile error: `UiButton` not found). - -- [ ] **Step 3: Write `UiButton`.** - -`UiButton.cs`: -```csharp -using System; -using System.Numerics; -using AcDream.App.UI.Layout; - -namespace AcDream.App.UI; - -/// -/// Generic clickable button. Ports retail UIElement_Button -/// (RegisterElementClass(1, UIElement_Button::Create) @ acclient_2013_pseudo_c.txt:125828): -/// a per-state sprite face + an optional centered caption + a click action. Built by -/// DatWidgetFactory for Type-1 elements (chat Send 0x10000019, Max/Min 0x1000046F). -/// The controller binds OnClick and the caption. State selection mirrors UiDatElement -/// so existing Send/Max-Min behavior is preserved exactly. -/// -public sealed class UiButton : UiElement -{ - private readonly ElementInfo _info; - private readonly Func _resolve; - - public Action? OnClick { get; set; } - public string? Label { get; set; } - public UiDatFont? LabelFont { get; set; } - public Vector4 LabelColor { get; set; } = Vector4.One; - - /// Active state name, runtime-settable (e.g. Max/Min toggling Normal↔Minimized). - public string ActiveState { get; set; } = ""; - - public UiButton(ElementInfo info, Func resolve) - { - _info = info; - _resolve = resolve; - ClickThrough = false; // buttons are interactive - if (!string.IsNullOrEmpty(info.DefaultStateName)) ActiveState = info.DefaultStateName; - else if (info.StateMedia.ContainsKey("Normal")) ActiveState = "Normal"; - } - - private uint ActiveFile() - => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File - : _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u; - - protected override void OnDraw(UiRenderContext ctx) - { - uint file = ActiveFile(); - if (file != 0) - { - var (tex, tw, th) = _resolve(file); - if (tex != 0 && tw != 0 && th != 0) - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); - } - if (Label is { Length: > 0 } label && LabelFont is { } lf) - { - float tx = (Width - lf.MeasureWidth(label)) * 0.5f; - float ty = (Height - lf.LineHeight) * 0.5f; - ctx.DrawStringDat(lf, label, tx, ty, LabelColor); - } - } - - public override bool OnEvent(in UiEvent e) - { - if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; } - return false; - } -} -``` - -- [ ] **Step 4: Run the button tests — verify they pass.** - -Run: `dotnet test … --filter "FullyQualifiedName~UiButtonTests"` -Expected: PASS. - -- [ ] **Step 5: Write the failing factory test + register Type 1.** - -In `DatWidgetFactoryTests.cs`: -```csharp - [Fact] - public void Type1_Button_MakesUiButton() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null); - Assert.IsType(e); - } -``` -In `DatWidgetFactory.Create` switch: -```csharp - 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) -``` - -- [ ] **Step 6: Update the controller to bind the factory-built buttons.** - -In `ChatWindowController.cs`, the Send block currently does `if (layout.FindElement(SendId) is UiDatElement sendEl) { sendEl.ClickThrough = false; sendEl.OnClick = …; sendEl.Label = "Send"; sendEl.LabelFont = datFont; sendEl.LabelColor = …; }`. Change the cast to `UiButton`: -```csharp - if (layout.FindElement(SendId) is UiButton sendEl) - { - sendEl.OnClick = () => c.Input.Submit(); - sendEl.Label = "Send"; - sendEl.LabelFont = datFont; - sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f); - } -``` -And the Max/Min block: change `if (layout.FindElement(MaxMinId) is UiDatElement maxMinEl)` → `is UiButton maxMinEl`, drop the now-unneeded `maxMinEl.ClickThrough = false;` (UiButton is interactive by construction), keep the `maxMinEl.Left = track.Left - maxMinEl.Width;` and `maxMinEl.OnClick = c.ToggleMaximize;`. - -- [ ] **Step 7: Build + run the full UI suite.** - -Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` -Expected: PASS. - -- [ ] **Step 8: Commit.** -```bash -git add -A -git commit -m "feat(D.2b): UiButton (Type 1) — Send + Max/Min as generic buttons (widget-generalization Task 3)" -``` - ---- - -## Task 4: `UiMenu` (Type 6) — genericize the channel menu - -`UiChannelMenu` is the one heavy genericization: move `ChatChannelKind`, the 14-item array, the button-text map, and the availability defaults into `ChatWindowController`; keep all drawing/geometry/event mechanics in a generic `UiMenu` keyed on `object? Payload`. - -**Files:** -- Rename: `src/AcDream.App/UI/UiChannelMenu.cs` → `src/AcDream.App/UI/UiMenu.cs` -- Rename: `tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs` → `tests/AcDream.App.Tests/UI/UiMenuTests.cs` -- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs` - -- [ ] **Step 1: Rename file + class.** -```bash -git mv src/AcDream.App/UI/UiChannelMenu.cs src/AcDream.App/UI/UiMenu.cs -git mv tests/AcDream.App.Tests/UI/UiChannelMenuTests.cs tests/AcDream.App.Tests/UI/UiMenuTests.cs -``` - -- [ ] **Step 2: Replace the chat-specific members with the generic surface.** - -In `UiMenu.cs`, rename `class UiChannelMenu` → `class UiMenu`; remove `using AcDream.UI.Abstractions;`. Replace the chat-specific members — the `Item` record, the static `Items` array, `Selected` (ChatChannelKind), `OnChannelChanged`, `AvailabilityProvider`, `IsAvailable`, and `ButtonText` — with these generic members: -```csharp - /// One menu row: its label + an opaque payload the controller maps back. - public readonly record struct MenuItem(string Label, object? Payload); - - /// The rows, populated by the controller. Laid out column-major: - /// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc. - public IReadOnlyList Items { get; set; } = System.Array.Empty(); - - /// The currently-selected payload (drives the highlighted row). - public object? Selected { get; set; } - - /// Fired with the picked item's payload when a row is chosen. - public Action? OnSelect { get; set; } - - /// Per-payload enabled gate (disabled rows render greyed + are inert). - /// Null ⇒ all rows enabled. - public Func? EnabledProvider { get; set; } - - /// Button-face caption (the active target). Null ⇒ blank face. - public Func? ButtonLabelProvider { get; set; } -``` -Make the geometry constants settable so a controller/factory can match the dat: -```csharp - public int RowsPerColumn { get; set; } = 7; // items per column (dat item template) - public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17 - public float ColumnWidth { get; set; } = 191f; // dat item template W=191 -``` -Replace the `private const int Rows`/`ItemH`/`ColW` usages with `RowsPerColumn`/`RowHeight`/`ColumnWidth`, and make the derived sizes instance members: -```csharp - private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn); - private float InteriorW => ColumnCount * ColumnWidth; - private float InteriorH => RowsPerColumn * RowHeight; - private float OuterW => InteriorW + 2 * Border; - private float OuterH => InteriorH + 2 * Border; -``` - -- [ ] **Step 3: Genericize the draw/event logic (mechanical swaps).** - -In the same file, in `OnDrawOverlay`, `OnEvent`, `OnHitTest`, and `DrawButtonFace`/label: -- Replace `Items[i].Channel is { } c && c == Selected` (selected-row test) with `Equals(Items[i].Payload, Selected)`. -- Replace `Items[i].Channel is not { } c || IsAvailable(c)` (availability) with `EnabledProvider?.Invoke(Items[i].Payload) ?? true`. -- Replace the button caption `ButtonText` with `ButtonLabelProvider?.Invoke() ?? ""` in both `OnDraw` (the `DrawLabel(ctx, ButtonText, …)` call) and `NaturalButtonWidth()` (the `MeasureWidth(ButtonText)`). -- In `OnEvent`'s pick branch, replace the channel-specific selection - ```csharp - if (… && Items[idx].Channel is { } ch && IsAvailable(ch)) { Selected = ch; OnChannelChanged?.Invoke(ch); } - ``` - with - ```csharp - if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count - && (EnabledProvider?.Invoke(Items[idx].Payload) ?? true)) - { - Selected = Items[idx].Payload; - OnSelect?.Invoke(Selected); - } - ``` -- Replace the column/row math `int col = i / Rows, row = i % Rows;` with `RowsPerColumn` and `Items.Length` → `Items.Count`. -Keep `DrawBevel`, `DrawButtonFace`, `DrawSprite`, `DrawLabel`, the sprite-id properties, the colors, and `NaturalButtonWidth()` otherwise unchanged. Update the doc comment to cite `UIElement_Menu (RegisterElementClass(6) @ :120163)` + `MakePopup @0x46d310`. - -- [ ] **Step 4: Update the menu tests for the generic surface.** - -In `UiMenuTests.cs`, rename the class to `UiMenuTests`, replace `UiChannelMenu` → `UiMenu`. Where tests referenced `ChatChannelKind`/`Selected`/`OnChannelChanged`, rewrite them against the generic surface, e.g.: -```csharp - [Fact] - public void ClickingRow_FiresOnSelect_WithPayload() - { - object? picked = null; - var m = new UiMenu - { - Width = 46, Height = 18, - Items = new UiMenu.MenuItem[] { new("Chat to All", "say"), new("Trade", "trade") }, - OnSelect = p => picked = p, - }; - // open, then click row 0 (geometry per RowsPerColumn/RowHeight — mirror the - // existing test's click coords, which used the same 17px rows). - m.OnEvent(new UiEvent(UiEventType.MouseDown, 0, 0, 0)); // toggle open - // … click into row 0 of the open popup (reuse the prior test's local coords) … - Assert.Equal("say", picked); - } -``` -> Reuse the exact open/click coordinates from the original `UiChannelMenuTests` (they map into the same popup geometry); only the payload/selection assertions change. - -- [ ] **Step 5: Run the menu tests — green.** - -Run: `dotnet test … --filter "FullyQualifiedName~UiMenuTests"` -Expected: PASS. - -- [ ] **Step 6: Failing factory test + register Type 6.** - -In `DatWidgetFactoryTests.cs`: -```csharp - [Fact] - public void Type6_Menu_MakesUiMenu() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null); - Assert.IsType(e); - } -``` -In `DatWidgetFactory.Create` switch: -```csharp - 6 => new UiMenu(), // UIElement_Menu (reg :120163) -``` - -- [ ] **Step 7: Move the channel knowledge into `ChatWindowController`.** - -In `ChatWindowController.cs`, add the channel item table + maps (ported verbatim from the old `UiChannelMenu`): -```csharp - // Talk-focus channels (ported from the old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50). - private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = - { - ("Squelch (ignore)", null), - ("Tell to Selected", null), - ("Chat to All", ChatChannelKind.Say), - ("Tell to Fellows", ChatChannelKind.Fellowship), - ("Tell to General Chat", ChatChannelKind.General), - ("Tell to LFG Chat", ChatChannelKind.Lfg), - ("Tell to Society Chat", ChatChannelKind.Society), - ("Tell to Monarch", ChatChannelKind.Monarch), - ("Tell to Patron", ChatChannelKind.Patron), - ("Tell to Vassals", ChatChannelKind.Vassals), - ("Tell to Allegiance", ChatChannelKind.Allegiance), - ("Tell to Trade Chat", ChatChannelKind.Trade), - ("Tell to Roleplay Chat", ChatChannelKind.Roleplay), - ("Tell to Olthoi Chat", ChatChannelKind.Olthoi), - }; - - private static string ChannelButtonLabel(ChatChannelKind k) => k switch - { - ChatChannelKind.Say => "Chat", ChatChannelKind.General => "General", - ChatChannelKind.Trade => "Trade", ChatChannelKind.Lfg => "LFG", - ChatChannelKind.Fellowship => "Fellow", ChatChannelKind.Allegiance => "Alleg", - ChatChannelKind.Patron => "Patron", ChatChannelKind.Vassals => "Vassals", - ChatChannelKind.Monarch => "Monarch", ChatChannelKind.Roleplay => "Roleplay", - ChatChannelKind.Society => "Society", ChatChannelKind.Olthoi => "Olthoi", - _ => "Chat", - }; - - private static bool ChannelAvailable(ChatChannelKind k) - => k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg; -``` -Replace the "Channel menu — replace the imported menu placeholder" block. The factory now builds the Type-6 element as a `UiMenu`; find it and populate it: -```csharp - if (layout.FindElement(MenuId) is UiMenu menu) - { - menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve; - menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed; - menu.PopupBgSprite = MenuPopupBg; - menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected; - menu.Items = System.Array.ConvertAll(ChannelItems, - t => new UiMenu.MenuItem(t.Label, (object?)t.Channel)); - menu.Selected = (object?)c._activeChannel; - menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch); - menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel); - menu.OnSelect = p => - { - if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; } - }; - c.Menu = menu; - } -``` -Update the `Menu` property type: `public UiMenu Menu { get; private set; } = null!;` Update the reflow block (`ReflowInputRow`) — it calls `c.Menu.NaturalButtonWidth()`, `c.Menu.ResetAnchorCapture()` (both still exist on `UiMenu`), and wraps `c.Menu.OnChannelChanged`. Replace the `OnChannelChanged` wrap with the generic `OnSelect`: -```csharp - var onSelect = c.Menu.OnSelect; - c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); }; -``` -> `_activeChannel` already exists on the controller; the old per-menu `OnChannelChanged = k => c._activeChannel = k;` is now folded into `OnSelect`. - -- [ ] **Step 8: Build + run the full UI suite.** - -Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` -Expected: PASS. - -- [ ] **Step 9: Add a divergence row if the generic menu lost fidelity.** - -The generic `UiMenu` item model is flat (label+payload, no submenu/hierarchical popup). If that is a new approximation vs `UIElement_Menu::MakePopup`'s nested popups, add a row to `docs/architecture/retail-divergence-register.md` (Adaptation) citing `src/AcDream.App/UI/UiMenu.cs` + `MakePopup @0x46d310`. (The chat menu is single-level, so this is a latent note, not a behavior change.) - -- [ ] **Step 10: Commit.** -```bash -git add -A -git commit -m "feat(D.2b): UiMenu (Type 6) — generic dropdown; channel knowledge moves to controller (widget-generalization Task 4)" -``` - ---- - -## Task 5: `UiText` (Type 12) — transcript + the Type-12 flip - -Rename `UiChatView` → `UiText`, default its background to transparent + add an optional dat state-sprite background (so any Type-12-with-sprite element keeps rendering its sprite), register Type 12, flip the two factory Type-12 tests, and have the controller bind the factory-built transcript. An **unbound `UiText` must draw nothing** so vitals stays frozen. - -**Files:** -- Rename: `src/AcDream.App/UI/UiChatView.cs` → `src/AcDream.App/UI/UiText.cs` -- Rename: `tests/AcDream.App.Tests/UI/UiChatViewTests.cs` → `UiTextTests.cs`; `UiChatViewDatFontTests.cs` → `UiTextDatFontTests.cs` -- Modify: `DatWidgetFactory.cs`, `LayoutImporter.cs` (none needed — Text recurses normally), `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs` - -- [ ] **Step 1: Rename file + class + tests.** -```bash -git mv src/AcDream.App/UI/UiChatView.cs src/AcDream.App/UI/UiText.cs -git mv tests/AcDream.App.Tests/UI/UiChatViewTests.cs tests/AcDream.App.Tests/UI/UiTextTests.cs -git mv tests/AcDream.App.Tests/UI/UiChatViewDatFontTests.cs tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs -``` -In `UiText.cs`: rename `class UiChatView` → `class UiText`; the nested `Line`/`Pos` records, `LinesProvider`, selection, and scroll stay. Update the doc to cite `UIElement_Text (RegisterElementClass(0xc) @ :115655)`. In the test files, rename classes + replace `UiChatView` → `UiText`. - -- [ ] **Step 2: Default the background to transparent (so an unbound UiText is invisible).** - -In `UiText.cs`, change: -```csharp - public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); // transparent by default -``` -(was `(0,0,0,0.35)`). `OnDraw`'s `ctx.DrawFill(0,0,Width,Height,BackgroundColor)` then draws nothing when transparent. The chat controller will set the translucent value explicitly (Step 6). - -- [ ] **Step 3: Add an optional dat state-sprite background (faithful UIElement_Text media).** - -So a Type-12 element that carries its own sprite (currently rendered by `UiDatElement`) does not lose it. Add to `UiText`: -```csharp - /// Optional dat state-sprite background (the element's own media), drawn - /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. - public uint BackgroundSprite { get; set; } - public Func? SpriteResolve { get; set; } -``` -At the very top of `OnDraw`, before `DrawFill`: -```csharp - if (BackgroundSprite != 0 && SpriteResolve is { } sr) - { - var (tex, tw, th) = sr(BackgroundSprite); - if (tex != 0 && tw != 0 && th != 0) - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); - } -``` - -- [ ] **Step 4: Write the failing factory test (and flip the two existing Type-12 tests).** - -In `DatWidgetFactoryTests.cs`: -- Add: -```csharp - [Fact] - public void Type12_Text_MakesUiText() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null); - Assert.IsType(e); - } -``` -- Replace `Type12_StylePrototype_ReturnsNull` (delete it — Type 12 is no longer skipped). -- Replace `DatWidgetFactory_Type12WithMedia_Renders` body to assert `UiText` for both media and no-media: -```csharp - [Fact] - public void DatWidgetFactory_Type12_AlwaysMakesUiText() - { - var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16, - StateMedia = { ["Normal"] = (0x00001234u, 1) } }; - Assert.IsType(DatWidgetFactory.Create(withMedia, NoTex, null)); - Assert.IsType(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null)); - } -``` - -- [ ] **Step 5: Run — verify the new/flipped tests fail.** - -Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests"` -Expected: FAIL on the Type-12 asserts (factory still returns null / UiDatElement). - -- [ ] **Step 6: Register Type 12 + add `BuildText`; remove the skip.** - -In `DatWidgetFactory.cs`: -- Delete the skip line `if (info.Type == 12 && info.StateMedia.Count == 0) return null;`. -- Add to the switch: -```csharp - 12 => BuildText(info, resolve), // UIElement_Text (reg :115655) -``` -- Add the builder: -```csharp - /// Type-12 UIElement_Text: a scrollable colored-line text view. The - /// element's own Direct/Normal media (if any) becomes the background sprite, drawn - /// under the text — so a Type-12 element that previously rendered via UiDatElement - /// keeps its sprite. Lines are bound later by the controller (LinesProvider). - private static UiText BuildText(ElementInfo info, Func resolve) - { - uint bg = info.StateMedia.TryGetValue( - !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName - : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) - ? m.File : 0u; - return new UiText { BackgroundSprite = bg, SpriteResolve = resolve }; - } -``` -> Update the `Create` summary/`` doc that referenced Type-12 returning null. - -- [ ] **Step 7: Verify factory + vitals fixture still green (vitals frozen).** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DatWidgetFactoryTests|FullyQualifiedName~LayoutConformanceTests|FullyQualifiedName~VitalsBindingTests"` -Expected: PASS. The vitals number text elements are meter-children (consumed, never built — `LayoutImporter.cs:113`), and any other vitals Type-12 element now builds as an unbound, transparent `UiText` (draws only its own sprite, if it had one — same as before). **Spec verification #2:** if a vitals conformance test fails, a standalone Type-12 element changed class — inspect it; its sprite must still draw via `BackgroundSprite`. - -- [ ] **Step 8: Controller binds the factory-built transcript (instead of constructing it).** - -In `ChatWindowController.cs`, the factory now builds the Type-12 transcript element `0x10000011` as a `UiText`. Replace the "Transcript" block (which read `tInfo` and `new UiChatView { … }; transcriptPanel.AddChild(...)`) with find-and-bind: -```csharp - // The factory built the Type-12 transcript as a UiText; find + bind it. - c.Transcript = layout.FindElement(TranscriptId) as UiText - ?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText"); - c.Transcript.DatFont = datFont; - c.Transcript.Font = debugFont; - c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript - c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont); -``` -Change the `Transcript` property type to `public UiText Transcript { get; private set; } = null!;`. Remove the now-unused `tInfo` lookup + the `transcriptPanel.AddChild` (the transcript is already in the tree at its dat position). Keep the `transcriptPanel.Top/Height` resize-bar reclaim. - -Also in `ChatWindowController.cs`, replace **every** `UiChatView.Line` with `UiText.Line` — this hits `BuildLines` (its `UiText view` parameter, its `IReadOnlyList` return type, the `Array.Empty()`, and the `new UiText.Line(frag, color)` inside the wrap loop). `WrapText`/`RetailChatColor` are unaffected (they return `string`/`Vector4`). - -Finally, repoint the `Bind` early-guard: it currently does `var tInfo = FindInfo(rootInfo, TranscriptId);` and checks `tInfo is null`. The transcript is now found via `layout.FindElement(TranscriptId)`; change the guard to null-check the factory-built widgets it needs (`layout.FindElement(TranscriptPanelId)` for the panel, plus the transcript/input found in their Steps). The `iInfo` lookup stays only for Task 6 Variant B. (Full guard tidy lands in Task 7.) - -- [ ] **Step 9: GameWindow follow-through.** - -`GameWindow.cs:1860` (`chatController.Transcript.Keyboard = …`) still compiles (`UiText.Keyboard` exists). Build to confirm. - -- [ ] **Step 10: Build + full UI suite.** - -Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` -Expected: PASS. - -- [ ] **Step 11: Amend AP-37 (Type-0 text skip retired).** - -In `docs/architecture/retail-divergence-register.md`, edit AP-37: remove the "Standalone Type-0 text elements are also skipped (… a dedicated dat-text widget is Plan 2)" clause (now shipped as `UiText`). Keep the meter-collapse clause and the vitals-numbers-via-`UiMeter.Label` clause (retired in Task 8). - -- [ ] **Step 12: Commit.** -```bash -git add -A -git commit -m "feat(D.2b): UiText (Type 12) — generic text + Type-12 flip; transcript factory-built (widget-generalization Task 5)" -``` - ---- - -## Task 6: `UiField` (Type 3) — editable input - -Rename `UiChatInput` → `UiField`, register Type 3, and wire the input. **Input handling depends on Task 1 Step 5's recorded resolved Type** for `0x10000016`: -- **If it resolved to Type 3:** the factory builds `UiField` directly; the controller finds + binds it. -- **If it resolved to Type 12** (per the Map trace): the factory built it as a `UiText`; the controller *replaces* it with a `UiField` at the same rect (the existing replace pattern). - -**Files:** -- Rename: `src/AcDream.App/UI/UiChatInput.cs` → `src/AcDream.App/UI/UiField.cs`; `UiChatInputTests.cs` → `UiFieldTests.cs` -- Modify: `DatWidgetFactory.cs`, `ChatWindowController.cs`, `DatWidgetFactoryTests.cs`, `GameWindow.cs` - -- [ ] **Step 1: Confirm the input's resolved Type from Task 1, choose the path.** - -Re-read `ChatLayoutConformanceTests.ChatFixture_ResolvedTypes_MatchRetailRegistry` (Task 1) for `0x10000016`. Note "Type 3 → direct build" or "Type 12 → controller-place". Proceed with the matching variant in Step 6. - -- [ ] **Step 2: Rename file + class + tests.** -```bash -git mv src/AcDream.App/UI/UiChatInput.cs src/AcDream.App/UI/UiField.cs -git mv tests/AcDream.App.Tests/UI/UiChatInputTests.cs tests/AcDream.App.Tests/UI/UiFieldTests.cs -``` -In `UiField.cs`: rename `class UiChatInput` → `class UiField`; body unchanged. Update doc to cite `UIElement_Field (RegisterElementClass(3) @ :126190)` + the drag-drop hooks (`CatchDroppedItem`/`MouseOverTop`) it will host for future item windows. In `UiFieldTests.cs`: rename class, replace `UiChatInput` → `UiField`. - -- [ ] **Step 3: Default the background to transparent (consistency with UiText).** - -Change `UiField.BackgroundColor` default to `new(0f, 0f, 0f, 0f)`. The controller sets the translucent value (Step 6). - -- [ ] **Step 4: Failing factory test + register Type 3.** - -In `DatWidgetFactoryTests.cs`: -```csharp - [Fact] - public void Type3_Field_MakesUiField() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null); - Assert.IsType(e); - } -``` -In `DatWidgetFactory.Create` switch: -```csharp - 3 => new UiField(), // UIElement_Field (reg :126190) -``` - -- [ ] **Step 5: Run — verify pass.** - -Run: `dotnet test … --filter "FullyQualifiedName~DatWidgetFactoryTests.Type3_Field_MakesUiField|FullyQualifiedName~UiFieldTests"` -Expected: PASS. - -- [ ] **Step 6: Wire the input in the controller (variant per Step 1).** - -Replace the "Input" block (`new UiChatInput { … }; inputBar.AddChild(c.Input); c.Input.OnSubmit = …`). - -**Variant A — input resolved to Type 3 (factory-built):** -```csharp - c.Input = layout.FindElement(InputId) as UiField - ?? throw new InvalidOperationException("chat input 0x10000016 not built as UiField"); - c.Input.DatFont = datFont; c.Input.Font = debugFont; - c.Input.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); - c.Input.SpriteResolve = resolve; c.Input.FocusFieldSprite = InputFocusField; - c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); -``` - -**Variant B — input resolved to Type 12 (controller-placed UiField over the UiText):** -```csharp - // 0x10000016 resolves to Type-12 Text in this layout; the editable entry is a - // controller-placed UiField at the dat element's rect (retail authors a separate Field). - var iInfo = FindInfo(rootInfo, InputId) - ?? throw new InvalidOperationException("chat input info 0x10000016 missing"); - if (layout.FindElement(InputId) is { Parent: { } iparent } placeholder) - iparent.RemoveChild(placeholder); // drop the read-only Text placeholder - c.Input = new UiField - { - Left = iInfo.X, Top = iInfo.Y, Width = iInfo.Width, Height = iInfo.Height, - Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom), - DatFont = datFont, Font = debugFont, - BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f), - SpriteResolve = resolve, FocusFieldSprite = InputFocusField, - }; - (inputBar).AddChild(c.Input); - c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); -``` -Change the `Input` property type to `public UiField Input { get; private set; } = null!;` (Keep `FindInfo` for Variant B; it may become unused in Variant A — remove it then.) - -- [ ] **Step 7: GameWindow follow-through.** - -`GameWindow.cs:1861` (`chatController.Input.Keyboard = …`) still compiles (`UiField.Keyboard` exists). Build to confirm. - -- [ ] **Step 8: Build + full UI suite.** - -Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` -Expected: PASS. - -- [ ] **Step 9: Commit.** -```bash -git add -A -git commit -m "feat(D.2b): UiField (Type 3) — editable input as a generic field (widget-generalization Task 6)" -``` - ---- - -## Task 7: Thin + verify the controller; remove dead construction - -After Tasks 2–6, `ChatWindowController.Bind` should construct no widgets (except the Variant-B input). Audit and tidy. - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/ChatWindowController.cs` - -- [ ] **Step 1: Remove dead helpers + confirm find-by-id shape.** - -In `ChatWindowController.cs`: confirm every widget is obtained via `layout.FindElement(id) as UiX` and only data/callbacks are bound. Remove any now-unused locals (`transcriptPanel`/`inputBar` are still used for the resize-bar reclaim / Variant-B parent — keep those; remove `tInfo`/`FindInfo` if Variant A). Confirm the class doc reads as the `gmMainChatUI::PostInit @0x4ce130` analogue (find child by id → bind). - -- [ ] **Step 2: Update `ChatWindowControllerTests` for the new types.** - -In `tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs`, update any references to `UiChatView`/`UiChatInput`/`UiChatScrollbar`/`UiChannelMenu` to `UiText`/`UiField`/`UiScrollbar`/`UiMenu`, and any assertions on `.Selected`/`OnChannelChanged` to the generic `OnSelect`/payload surface. Run them to confirm the binding still wires the right elements. - -- [ ] **Step 3: Build + full UI suite.** - -Run: `dotnet build` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` -Expected: PASS. - -- [ ] **Step 4: Visual gate (user) — chat unchanged.** - -Launch the client (`ACDREAM_RETAIL_UI=1`, per CLAUDE.md launch recipe) and confirm the chat window looks + behaves identically to before this pass: transcript scroll/select/copy, input write-mode/history/clipboard, channel dropdown, send, max/min, scrollbar drag. **Stop for user confirmation.** - -- [ ] **Step 5: Commit.** -```bash -git add -A -git commit -m "refactor(D.2b): ChatWindowController is now a thin find-by-id binder (widget-generalization Task 7)" -``` - ---- - -## Task 8 (GATED): vitals numbers as `UiText` - -Rewire the vitals number text from `UiMeter.Label` to factory-built `UiText` (retail-faithful: vitals numbers are `UIElement_Text`). **This is a stop-and-confirm gate** — vitals shipped pixel-identical and is fixture-locked. If it risks the pixel-identical result, **stop and keep `UiMeter.Label`** (narrow AP-37 instead). - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/VitalsController.cs`, `LayoutImporter.cs` (meter child handling), `GameWindow.cs` (Bind call), `tests/.../VitalsBindingTests.cs`, `fixtures/vitals_2100006C.json` - -- [ ] **Step 1: Decide the number element's path.** - -The vitals number text is a **meter child** (consumed; `LayoutImporter.cs:113` does not recurse meter children). To render it as a real `UiText`, either (a) have `VitalsController` construct a `UiText` at the number element's rect (read from the meter's children — mirrors the chat Variant-B pattern), or (b) stop consuming the meter's text child so the factory builds it. **Prefer (a)** — it is local to `VitalsController` and does not disturb the meter slice extraction. Read the number element's rect from `DatWidgetFactory.BuildMeter`'s skipped text child (expose it, or re-read via the layout's `ElementInfo`). - -- [ ] **Step 2: Write a failing binding test.** - -In `VitalsBindingTests.cs`, add a test that, after `VitalsController.Bind`, a `UiText` exists for each vital and its `LinesProvider` returns the cur/max string. (Use the vitals fixture; assert the text node is present + bound.) - -- [ ] **Step 3: Implement the `UiText` number binding in `VitalsController`.** - -Add a `UiText` per meter (constructed at the number rect, single centered line). Keep `UiMeter.Label` unset for vitals. Bind `LinesProvider = () => new[] { new UiText.Line(text(), color) }` (centered — add a `UiText.CenterSingleLine` option or a thin overload if needed for horizontal centering). -> If centering a single line requires new `UiText` layout support, add a minimal `public bool CenterHorizontally` flag to `UiText` with a unit test, rather than overloading the chat path. - -- [ ] **Step 4: Build + run vitals tests.** - -Run: `dotnet test … --filter "FullyQualifiedName~VitalsBindingTests|FullyQualifiedName~LayoutConformanceTests"` -Expected: PASS. Update `vitals_2100006C.json` only if the resolved tree legitimately changed (it should not — the change is in binding, not the tree). - -- [ ] **Step 5: Visual gate (user) — vitals pixel-identical.** - -Launch (`ACDREAM_RETAIL_UI=1`); confirm the vitals numbers render identically (font, position, centering, color) to the shipped `UiMeter.Label` version. **Stop for user confirmation. If not identical → revert this task and narrow AP-37 instead.** - -- [ ] **Step 6: Retire/narrow AP-37 + update memory.** - -If the rewire lands: in `docs/architecture/retail-divergence-register.md`, retire the AP-37 vitals-numbers clause (now real `UiText`). Update `claude-memory/project_d2b_retail_ui.md` (the generalization pass shipped) + the roadmap. - -- [ ] **Step 7: Commit.** -```bash -git add -A -git commit -m "feat(D.2b): vitals numbers as UiText (widget-generalization Task 8, gated)" -``` - ---- - -## Done criteria (from spec §8) - -- [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets; `_` still → `UiDatElement`. -- [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built (fixtures green). -- [ ] No `ChatChannelKind`/chat-color/command-routing knowledge inside any widget; `ChatWindowController` only finds-by-id and binds. -- [ ] Chat window visually + behaviorally identical through Tasks 2–7 (user-confirmed, Task 7 Step 4). -- [ ] `chat_21000006.json` golden fixture + renamed generic-widget tests all green. -- [ ] Vitals window unchanged after Task 8 (user-confirmed), or Task 8 deferred with AP-37 narrowed. -- [ ] Every generic widget cites its retail `UIElement_X` class + reg. line. -- [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same commits. -- [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands. diff --git a/docs/superpowers/plans/2026-06-17-d2b-stateful-icon.md b/docs/superpowers/plans/2026-06-17-d2b-stateful-icon.md deleted file mode 100644 index 63b76929..00000000 --- a/docs/superpowers/plans/2026-06-17-d2b-stateful-icon.md +++ /dev/null @@ -1,973 +0,0 @@ -# Stateful item-icon system (D.5.2) — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the item icon a live function of the item's state — capture the discarded `UiEffects` bitfield, build retail's faithful effect-recolor in the icon compositor, and wire the live `PublicUpdatePropertyInt(0x02CE)` update so the icon re-composites in real time. - -**Architecture:** `UiEffects` flows `CreateObject → EntitySpawn → ItemInstance.Effects` and, live, `PublicUpdatePropertyInt(0x02CE) → ItemRepository.UpdateIntProperty → ItemInstance.Effects`. Any change fires `ItemPropertiesUpdated`, which the bound `UiItemSlot` already re-resolves via `IconComposer.GetIcon(…, effects)`. The compositor mirrors retail `IconData::RenderIcons`: a 2-stage composite where the effect tile (`enum 0x10000005`) supplies a `ReplaceColor(white → effectColor)` tint, never a blit layer. - -**Tech Stack:** C# .NET 10, xUnit, `DatReaderWriter` (EnumIDMap/RenderSurface), Silk.NET (GL via `TextureCache`). - -**Spec:** [`docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md`](../specs/2026-06-17-d2b-stateful-icon-design.md). **Research:** [`docs/research/2026-06-17-stateful-icon-RESOLVED.md`](../../research/2026-06-17-stateful-icon-RESOLVED.md). - -**Conventions:** Every commit appends the CLAUDE.md co-author trailer: -`Co-Authored-By: Claude Opus 4.8 (1M context) `. Build with `dotnet build`; the tree must be green after every task. - ---- - -### Task 1: Core data model — `ItemInstance.Effects` + `ItemRepository` hooks - -**Files:** -- Modify: `src/AcDream.Core/Items/ItemInstance.cs` (add `Effects` field, ~line 138) -- Modify: `src/AcDream.Core/Items/ItemRepository.cs` (`EnrichItem` +param; add `UpdateIntProperty`) -- Test: `tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Add to `ItemRepositoryTests.cs`: - -```csharp -[Fact] -public void EnrichItem_carriesEffects() -{ - var repo = new ItemRepository(); - repo.AddOrUpdate(new ItemInstance { ObjectId = 0x500000AAu }); - bool ok = repo.EnrichItem(0x500000AAu, iconId: 0x06001234u, name: "Wand", - type: ItemType.Caster, iconOverlayId: 0, iconUnderlayId: 0, effects: 0x1u); - Assert.True(ok); - Assert.Equal(0x1u, repo.GetItem(0x500000AAu)!.Effects); -} - -[Fact] -public void UpdateIntProperty_uiEffects_setsEffectsAndFires() -{ - var repo = new ItemRepository(); - repo.AddOrUpdate(new ItemInstance { ObjectId = 0x500000ABu }); - ItemInstance? fired = null; - repo.ItemPropertiesUpdated += i => fired = i; - bool ok = repo.UpdateIntProperty(0x500000ABu, 18u, value: 0x9); // 18 = UiEffects - Assert.True(ok); - Assert.Equal(0x9u, repo.GetItem(0x500000ABu)!.Effects); - Assert.Equal(0x9, repo.GetItem(0x500000ABu)!.Properties.Ints[18u]); - Assert.NotNull(fired); -} - -[Fact] -public void UpdateIntProperty_unknownItem_returnsFalse() -{ - var repo = new ItemRepository(); - Assert.False(repo.UpdateIntProperty(0xDEADBEEFu, 18u, 1)); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ItemRepositoryTests"` -Expected: FAIL — `EnrichItem` has no `effects` param; `UpdateIntProperty`/`Effects` don't exist. - -- [ ] **Step 3: Add the `Effects` field** - -In `ItemInstance.cs`, after the `IconOverlayId` property (~line 138): - -```csharp - /// - /// UiEffects bitfield (retail PublicWeenieDesc._effects, acclient.h:37183). - /// Drives the icon's effect-overlay recolor (Magical=0x1 … Nether=0x1000). - /// CreateObject-only (weenieFlags 0x80) + live PublicUpdatePropertyInt(0x02CE); - /// appraise never carries it. 0 = no effect. - /// - public uint Effects { get; set; } -``` - -- [ ] **Step 4: Add the `effects` param to `EnrichItem`** - -In `ItemRepository.cs`, change the `EnrichItem` signature + body: - -```csharp - public bool EnrichItem(uint objectId, uint iconId, string name, ItemType type, - uint iconOverlayId = 0, uint iconUnderlayId = 0, uint effects = 0) - { - if (!_items.TryGetValue(objectId, out var item)) return false; - if (iconId != 0) item.IconId = iconId; - if (!string.IsNullOrEmpty(name)) item.Name = name; - if (type != default) item.Type = type; - if (iconOverlayId != 0) item.IconOverlayId = iconOverlayId; - if (iconUnderlayId != 0) item.IconUnderlayId = iconUnderlayId; - // D.5.2: 0 is a meaningful "no effect" state (e.g. a caster out of mana), - // so assign unconditionally — re-composition reflects the CURRENT state. - item.Effects = effects; - ItemPropertiesUpdated?.Invoke(item); - return true; - } -``` - -- [ ] **Step 5: Add `UpdateIntProperty`** - -In `ItemRepository.cs`, add after `UpdateProperties`: - -```csharp - /// PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield. - public const uint UiEffectsPropertyId = 18u; - - /// - /// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an - /// item: store it in the bundle and, for known typed ints, mirror to the typed - /// field. Today: UiEffects (18) → . Fires - /// ItemPropertiesUpdated so bound widgets re-composite. Extensible hook for future - /// typed PropertyInts (StackSize, Structure, …). False if the item is unknown. - /// - public bool UpdateIntProperty(uint itemId, uint propertyId, int value) - { - if (!_items.TryGetValue(itemId, out var item)) return false; - item.Properties.Ints[propertyId] = value; - if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value; - ItemPropertiesUpdated?.Invoke(item); - return true; - } -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ItemRepositoryTests"` -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.Core/Items/ItemInstance.cs src/AcDream.Core/Items/ItemRepository.cs tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs -git commit -m "feat(D.5.2): ItemInstance.Effects + ItemRepository.UpdateIntProperty" -``` - ---- - -### Task 2: Capture `UiEffects` from `CreateObject` - -**Files:** -- Modify: `src/AcDream.Core.Net/Messages/CreateObject.cs` (record field, capture site, ctor call) -- Test: `tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs` - -- [ ] **Step 1: Write the failing tests** - -In `CreateObjectTests.cs`, add a `uiEffects` parameter to the builder and write it for the -UiEffects field. Change the builder signature (add `uint uiEffects = 0,` next to `iconId`) -and the UiEffects write line (currently `WriteU32(bytes, 0); // UiEffects u32`): - -```csharp - if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32 -``` - -Then add the tests: - -```csharp -[Fact] -public void TryParse_UiEffects_Captured() -{ - // weenieFlags 0x80 = UiEffects; value 0x1 = Magical. - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000010u, name: "MagicWand", itemType: (uint)ItemType.Caster, - weenieFlags: 0x80u, uiEffects: 0x1u); - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal(0x1u, parsed!.Value.UiEffects); -} - -[Fact] -public void TryParse_UiEffectsThenIconOverlay_BothCaptured() -{ - // Verifies the cursor still reaches IconOverlay after reading (not skipping) UiEffects. - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000011u, name: "GlowSword", itemType: (uint)ItemType.MeleeWeapon, - weenieFlags: 0x80u | 0x40000000u, uiEffects: 0x4u, iconOverlayId: 0x1ABCu); - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal(0x4u, parsed!.Value.UiEffects); - Assert.Equal(0x06001ABCu, parsed.Value.IconOverlayId); -} - -[Fact] -public void TryParse_NoUiEffectsBit_LeavesUiEffectsZero() -{ - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000012u, name: "PlainRock", itemType: (uint)ItemType.Misc, weenieFlags: 0u); - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal(0u, parsed!.Value.UiEffects); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests"` -Expected: FAIL — `Parsed` has no `UiEffects` member. - -- [ ] **Step 3: Add the `UiEffects` record field** - -In `CreateObject.cs`, in the `Parsed` record, after `uint IconUnderlayId = 0`: - -```csharp - uint IconUnderlayId = 0, - // D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's - // effect recolor (Magical=0x1 … Nether=0x1000). The ONLY wire path for the effect - // state (PropertyInt.UiEffects=18 has no [AssessmentProperty] → not in appraise). - // Previously read + discarded at the UiEffects skip. 0 = no effect. - uint UiEffects = 0); -``` - -- [ ] **Step 4: Capture at the UiEffects site** - -In `CreateObject.cs`, declare the local next to `iconOverlayId`/`iconUnderlayId`: - -```csharp - uint iconOverlayId = 0; - uint iconUnderlayId = 0; - uint uiEffects = 0; - uint weenieFlags2 = 0; -``` - -Change the UiEffects skip to a capture: - -```csharp - if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE - { - if (body.Length - pos < 4) throw new FormatException("trunc UiEffects"); - uiEffects = ReadU32(body, ref pos); - } -``` - -- [ ] **Step 5: Pass it to the `Parsed` constructor** - -In the success-path `return new Parsed(...)`, change the tail: - -```csharp - IconId: iconId, - Useability: useability, UseRadius: useRadius, - IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId, - UiEffects: uiEffects); -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests"` -Expected: PASS (all existing CreateObject tests still pass — the builder change is additive). - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.Core.Net/Messages/CreateObject.cs tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs -git commit -m "feat(D.5.2): capture UiEffects from CreateObject weenie header" -``` - ---- - -### Task 3: `PublicUpdatePropertyInt (0x02CE)` parser - -**Files:** -- Create: `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` -- Test: `tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs`: - -```csharp -using System.Buffers.Binary; -using AcDream.Core.Net.Messages; - -namespace AcDream.Core.Net.Tests.Messages; - -public sealed class PublicUpdatePropertyIntTests -{ - private static byte[] Build(uint guid, uint property, int value, byte seq = 1, uint opcode = 0x02CEu) - { - var b = new byte[17]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); - b[4] = seq; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid); - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(9), property); - BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value); - return b; - } - - [Fact] - public void TryParse_uiEffectsUpdate_returnsGuidPropValue() - { - var p = PublicUpdatePropertyInt.TryParse(Build(0x50000001u, property: 18u, value: 0x9)); - Assert.NotNull(p); - Assert.Equal(0x50000001u, p!.Value.Guid); - Assert.Equal(18u, p.Value.Property); - Assert.Equal(0x9, p.Value.Value); - } - - [Fact] - public void TryParse_wrongOpcode_returnsNull() - => Assert.Null(PublicUpdatePropertyInt.TryParse(Build(1, 18, 1, opcode: 0x02CDu))); - - [Fact] - public void TryParse_truncated_returnsNull() - => Assert.Null(PublicUpdatePropertyInt.TryParse(new byte[16])); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PublicUpdatePropertyIntTests"` -Expected: FAIL — `PublicUpdatePropertyInt` does not exist. - -- [ ] **Step 3: Create the parser** - -Create `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`: - -```csharp -using System; -using System.Buffers.Binary; - -namespace AcDream.Core.Net.Messages; - -/// -/// Inbound PublicUpdatePropertyInt (0x02CE) — the server updates one -/// PropertyInt on a visible object (carries the object guid). Standalone -/// GameMessage, dispatched like / CreateObject. -/// -/// -/// The companion PrivateUpdatePropertyInt (0x02CD) targets the player's OWN -/// object (no guid) and is not parsed here — it has no item-icon impact. -/// -/// -/// Wire layout (ACE GameMessagePublicUpdatePropertyInt, size hint 17): -/// -/// u32 opcode = 0x02CE -/// u8 sequence // single byte (ByteSequence.NextBytes) — see PrivateUpdateVital -/// u32 guid -/// u32 property // PropertyInt enum; UiEffects = 18 -/// i32 value -/// -/// The sequence is parsed-past but not honored (latest-wins; divergence DR-4). -/// -public static class PublicUpdatePropertyInt -{ - public const uint Opcode = 0x02CEu; - - public readonly record struct Parsed(uint Guid, uint Property, int Value); - - /// Parse a raw 0x02CE body. Returns null on opcode mismatch / truncation. - public static Parsed? TryParse(ReadOnlySpan body) - { - if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4 - if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; - int pos = 4; - pos += 1; // sequence byte (not honored) - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); - return new Parsed(guid, prop, value); - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PublicUpdatePropertyIntTests"` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs -git commit -m "feat(D.5.2): PublicUpdatePropertyInt (0x02CE) parser" -``` - ---- - -### Task 4: Thread `UiEffects` through `WorldSession` + route `0x02CE` - -**Files:** -- Modify: `src/AcDream.Core.Net/WorldSession.cs` (EntitySpawn field + ctor thread; new event; message-loop branch) - -> No unit test: the private message loop needs a live session. The parser is covered by -> Task 3; the event consumption by Tasks 1+8; the end-to-end path by visual verification. -> This matches the existing `PrivateUpdateVital` routing (parser tested, loop not). - -- [ ] **Step 1: Add `UiEffects` to the `EntitySpawn` record** - -In `WorldSession.cs`, in the `EntitySpawn` record, after `uint IconUnderlayId = 0`: - -```csharp - uint IconOverlayId = 0, - uint IconUnderlayId = 0, - // D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's - // effect recolor. CreateObject-only; 0 = no effect. - uint UiEffects = 0); -``` - -- [ ] **Step 2: Thread it at the `EntitySpawn` construction site** - -Find the `new EntitySpawn(... parsed.Value.IconUnderlayId)` construction (the spawn fired from -the CreateObject branch). Change its tail: - -```csharp - parsed.Value.IconId, - parsed.Value.IconOverlayId, - parsed.Value.IconUnderlayId, - parsed.Value.UiEffects)); -``` - -- [ ] **Step 3: Declare the live-update event + payload** - -In `WorldSession.cs`, near the other event declarations (e.g. after the `StateUpdated` -event ~line 162), add: - -```csharp - /// - /// Payload for : a single PropertyInt change on - /// a visible object (from PublicUpdatePropertyInt 0x02CE). Subscribers map the - /// property to typed state (e.g. UiEffects → the item's icon effect). - /// - public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value); - - /// - /// Fires when the session parses a PublicUpdatePropertyInt (0x02CE) — one - /// PropertyInt updated on a visible object. D.5.2 routes UiEffects (18) to the - /// item repository so the icon re-composites live. - /// - public event Action? ObjectIntPropertyUpdated; -``` - -- [ ] **Step 4: Add the message-loop branch** - -In the top-level message dispatch (where `op` is the opcode and `body` the message bytes), -add after the `PrivateUpdateVital.CurrentOpcode` branch (~line 905): - -```csharp - else if (op == PublicUpdatePropertyInt.Opcode) - { - var p = PublicUpdatePropertyInt.TryParse(body); - if (p is not null) - ObjectIntPropertyUpdated?.Invoke( - new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value)); - } -``` - -- [ ] **Step 5: Build to verify it compiles** - -Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj` -Expected: Build succeeded. - -- [ ] **Step 6: Run the Net test suite (regression)** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj` -Expected: PASS. - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.Core.Net/WorldSession.cs -git commit -m "feat(D.5.2): thread UiEffects through EntitySpawn + route 0x02CE PublicUpdatePropertyInt" -``` - ---- - -### Task 5: `IconComposer.ResolveEffectDid` (effect submap resolve) - -**Files:** -- Modify: `src/AcDream.App/UI/IconComposer.cs` (effect-submap fields + `ResolveEffectDid` + `EnsureEffectSubMap`) -- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs` - -- [ ] **Step 1: Write the failing golden test** - -In `IconComposerTests.cs`, add (dat-gated, mirroring `ResolveUnderlayDid_goldenValues_matchDat`): - -```csharp -[Fact] -public void ResolveEffectDid_goldenValues_matchDat() -{ - var datDir = ResolveDatDir(); - if (datDir is null) return; // dats absent (CI) — skip cleanly - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var composer = new IconComposer(dats, null!); - - // Golden values (live dat, MasterMap 0x25000000 → effect submap 0x25000009; - // index = LowestSetBit(UiEffects)+1, fallback 0x21): - // Magical (0x0001) → idx 1 → 0x060011CA - // Poisoned (0x0002) → idx 2 → 0x060011C6 - // BoostHealth (0x0004) → idx 3 → 0x06001B05 - // BoostStamina (0x0010) → idx 5 → 0x06001B06 - // Nether (0x1000) → idx 13 (absent) → fallback 0x21 → 0x060011C5 - // none (0x0000) → idx 0 (zero) → fallback 0x21 → 0x060011C5 - Assert.Equal(0x060011CAu, composer.ResolveEffectDid(0x0001u)); - Assert.Equal(0x060011C6u, composer.ResolveEffectDid(0x0002u)); - Assert.Equal(0x06001B05u, composer.ResolveEffectDid(0x0004u)); - Assert.Equal(0x06001B06u, composer.ResolveEffectDid(0x0010u)); - Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x1000u)); - Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x0000u)); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ResolveEffectDid"` -Expected: FAIL — `ResolveEffectDid` does not exist. - -- [ ] **Step 3: Add the effect-submap fields** - -In `IconComposer.cs`, after the underlay fields (`_underlayDidByIndex`): - -```csharp - // ── effect overlay resolve (EnumIDMap 0x10000005) ──────────────────────── - // Portal MasterMap (0x25000000) maps enum 0x10000005 → submap DID (0x25000009). - // Submap maps index → 0x06 RenderSurface DID. index = LSB(effects)+1, fallback 0x21. - // Refs: IconData::RenderIcons 0x0058d180 (effect path); the effect tile is a - // ReplaceColor tint SOURCE, not a blit layer (see RESOLVED doc, divergence DR-1). - private EnumIDMap? _effectSubMap; - private bool _effectResolveTried; - private readonly Dictionary _effectDidByIndex = new(); -``` - -- [ ] **Step 4: Add `ResolveEffectDid` + `EnsureEffectSubMap`** - -In `IconComposer.cs`, after `EnsureUnderlaySubMap`: - -```csharp - /// - /// Resolve the effect-overlay DID for via the EnumIDMap - /// 0x10000005 chain. index = LowestSetBit(effects)+1; if the entry is missing/zero, - /// retail falls back to index 0x21 (the solid-black tile). NOTE: the effect path has - /// NO lsb==-1 pre-check (unlike the type underlay), so effects==0 → index 0 → miss → - /// fallback. (Retail IconData::RenderIcons 0x0058d180.) - /// - internal uint ResolveEffectDid(uint effects) - { - int lsb = effects == 0 ? -1 : BitOperations.TrailingZeroCount(effects); - uint index = (uint)(lsb + 1); - if (_effectDidByIndex.TryGetValue(index, out var cached)) return cached; - EnsureEffectSubMap(); - uint did = 0; - if (_effectSubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d; - if (did == 0 && _effectSubMap is { } sub2 && sub2.ClientEnumToID.TryGetValue(0x21u, out var fb)) - did = fb; - _effectDidByIndex[index] = did; - return did; - } - - private void EnsureEffectSubMap() - { - if (_effectResolveTried) return; - _effectResolveTried = true; - uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000 - if (masterDid == 0) return; - if (!_dats.Portal.TryGet(masterDid, out var master)) return; - if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009 - if (_dats.Portal.TryGet(subDid, out var sub)) _effectSubMap = sub; - } -``` - -- [ ] **Step 5: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ResolveEffectDid"` -Expected: PASS. (If it skips, the dats aren't at `%USERPROFILE%\Documents\Asheron's Call` — set `ACDREAM_DAT_DIR` and re-run.) - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/IconComposer.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs -git commit -m "feat(D.5.2): IconComposer.ResolveEffectDid (effect submap 0x10000005)" -``` - ---- - -### Task 6: `IconComposer` recolor helpers (`ReplaceColorWhite` + effect color) - -**Files:** -- Modify: `src/AcDream.App/UI/IconComposer.cs` (`ReplaceColorWhite`, `TryGetEffectColor`, `TryDecode`) -- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs` - -- [ ] **Step 1: Write the failing dat-free recolor test** - -In `IconComposerTests.cs`, add: - -```csharp -[Fact] -public void ReplaceColorWhite_replacesOnlyPureWhiteOpaque() -{ - // 2x2: [white-opaque, red-opaque, white-transparent, white-opaque] - var px = new byte[] - { - 255,255,255,255, // pure white opaque → replaced - 255, 0, 0,255, // red → untouched - 255,255,255, 0, // white but alpha 0 → untouched (not 0xFFFFFFFF) - 255,255,255,255, // pure white opaque → replaced - }; - IconComposer.ReplaceColorWhite(px, 2, 2, (10, 20, 30, 255)); - Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[0..4]); // replaced - Assert.Equal(new byte[] { 255, 0, 0, 255 }, px[4..8]); // untouched - Assert.Equal(new byte[] { 255, 255, 255, 0 }, px[8..12]); // untouched - Assert.Equal(new byte[] { 10, 20, 30, 255 }, px[12..16]); // replaced -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ReplaceColorWhite"` -Expected: FAIL — `ReplaceColorWhite` does not exist. - -- [ ] **Step 3: Add `ReplaceColorWhite`** - -In `IconComposer.cs`, add (near `Compose`): - -```csharp - /// - /// Retail SurfaceWindow::ReplaceColor (0x00441530) with the icon-composite's - /// fixed source color: replace pixels exactly equal to pure-white-opaque - /// (RGBAColor(1,1,1,1) → 0xFFFFFFFF) with . Mutates in place. - /// - internal static void ReplaceColorWhite(byte[] rgba, int w, int h, (byte r, byte g, byte b, byte a) dest) - { - for (int i = 0; i < w * h; i++) - { - if (rgba[i * 4] == 255 && rgba[i * 4 + 1] == 255 && - rgba[i * 4 + 2] == 255 && rgba[i * 4 + 3] == 255) - { - rgba[i * 4] = dest.r; rgba[i * 4 + 1] = dest.g; - rgba[i * 4 + 2] = dest.b; rgba[i * 4 + 3] = dest.a; - } - } - } -``` - -- [ ] **Step 4: Add `TryGetEffectColor` + `TryDecode`** - -In `IconComposer.cs`, add the color cache field next to `_effectDidByIndex`: - -```csharp - private readonly Dictionary _effectColorByDid = new(); -``` - -And the methods (after `ResolveEffectDid`): - -```csharp - /// - /// The effect tint color for : the effect tile's mean-opaque - /// color (blue=Magical, green=Poisoned, …). The exact retail color byte is a - /// decompiler-ambiguous SurfaceWindow-header read; the tile IS the per-effect color, so - /// its representative color is the faithful equivalent (divergence DR-2). Cached per DID. - /// - private bool TryGetEffectColor(uint effects, out (byte r, byte g, byte b, byte a) color) - { - color = default; - uint did = ResolveEffectDid(effects); - if (did == 0) return false; - if (_effectColorByDid.TryGetValue(did, out var cached)) { color = cached; return true; } - if (!TryDecode(did, out var d)) return false; - long sr = 0, sg = 0, sb = 0; int n = 0; - for (int i = 0; i < d.Width * d.Height; i++) - { - if (d.Rgba8[i * 4 + 3] == 0) continue; - sr += d.Rgba8[i * 4]; sg += d.Rgba8[i * 4 + 1]; sb += d.Rgba8[i * 4 + 2]; n++; - } - if (n == 0) return false; - var rep = ((byte)(sr / n), (byte)(sg / n), (byte)(sb / n), (byte)255); - _effectColorByDid[did] = rep; - color = rep; - return true; - } - - private bool TryDecode(uint renderSurfaceId, out DecodedTexture decoded) - { - decoded = null!; - if (renderSurfaceId == 0) return false; - if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) && - !_dats.HighRes.TryGet(renderSurfaceId, out rs)) - return false; - decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); - return true; - } -``` - -> `DecodedTexture` is in `AcDream.Core.Textures` — already imported by `IconComposer.cs`. - -- [ ] **Step 5: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ReplaceColorWhite"` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/IconComposer.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs -git commit -m "feat(D.5.2): IconComposer effect-color + ReplaceColorWhite helpers" -``` - ---- - -### Task 7: `IconComposer.GetIcon` 5-arg 2-stage composite + update callers - -**Files:** -- Modify: `src/AcDream.App/UI/IconComposer.cs` (`_byTuple` key + `GetIcon` rewrite + class doc) -- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs` (`_iconIds` Func type + `Populate`) -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (`iconIds` closure + `OnLiveEntitySpawned` effects) -- Test: `tests/AcDream.App.Tests/UI/IconComposerTests.cs` - -> This task changes `GetIcon`'s signature, which breaks both callers; all three files are -> edited together so the tree compiles. - -- [ ] **Step 1: Write the failing dat-free composite test** - -In `IconComposerTests.cs`, add (exercises the 2-stage compose + recolor without GL/dat via -the static `Compose`/`ReplaceColorWhite` — the GL upload in `GetIcon` needs a real cache): - -```csharp -[Fact] -public void TwoStageWithEffect_recolorsWhiteBeforeUnderlay() -{ - // drag = base (white pixel) over overlay (none); recolor white→blue; then over - // an opaque tawny underlay. The white pixel must become blue in the final. - var baseIcon = (new byte[] { 255,255,255,255 }, 1, 1); // 1x1 white opaque - var drag = IconComposer.Compose(new[] { baseIcon }); - IconComposer.ReplaceColorWhite(drag.rgba, drag.w, drag.h, (0, 0, 255, 255)); // blue - var underlay = (new byte[] { 105, 70, 50, 255 }, 1, 1); // tawny opaque - var final = IconComposer.Compose(new[] { underlay, (drag.rgba, drag.w, drag.h) }); - Assert.Equal(new byte[] { 0, 0, 255, 255 }, final.rgba); // blue on top -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TwoStageWithEffect"` -Expected: FAIL — won't compile yet only if `Compose`/`ReplaceColorWhite` aren't both public/internal; they are (`Compose` public, `ReplaceColorWhite` internal from Task 6), so this test should actually PASS once Task 6 is in. If it passes immediately, that's fine — it locks the recolor-before-underlay ordering. Proceed to Step 3 regardless (the GetIcon rewrite is the real change). - -- [ ] **Step 3: Widen the cache key** - -In `IconComposer.cs`, change the dictionary field: - -```csharp - private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new(); -``` - -- [ ] **Step 4: Rewrite `GetIcon` to 5-arg 2-stage** - -Replace the whole `GetIcon` method with: - -```csharp - /// - /// Resolve (and cache) the composited GL texture for an item's icon state. - /// Returns 0 if no base icon. Mirrors retail IconData::RenderIcons (0x0058d180): - /// a DRAG composite (base + custom overlay + effect recolor) blitted over the - /// type-default underlay + custom underlay. The effect tile (enum 0x10000005) is a - /// ReplaceColor tint SOURCE, not a blit layer (DR-1). The 2-stage form is - /// associative-equivalent to a single Compose when effects==0, so D.5.1 visuals are - /// unchanged for non-effect items. - /// - public uint GetIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId, uint effects) - { - if (iconId == 0) return 0; - uint typeUnderlayDid = ResolveUnderlayDid(itemType); - var key = (typeUnderlayDid, iconId, underlayId, overlayId, effects); - if (_byTuple.TryGetValue(key, out var tex)) return tex; - - // Stage 1 — retail m_pDragIcon: base + custom overlay, then the effect recolor. - var dragLayers = new List<(byte[] rgba, int w, int h)>(); - AddLayer(dragLayers, iconId); - AddLayer(dragLayers, overlayId); - (byte[] rgba, int w, int h)? drag = null; - if (dragLayers.Count > 0) - { - var composed = Compose(dragLayers); - // Effect recolor only when an effect bit is set. Retail nominally also runs the - // effects==0 black-fallback recolor; we skip it (DR-3: white→black on every item - // is a likely no-op but a regression risk, pending visual/cdb confirmation). - if (effects != 0 && TryGetEffectColor(effects, out var ec)) - ReplaceColorWhite(composed.rgba, composed.w, composed.h, ec); - drag = composed; - } - - // Stage 2 — retail m_pIcon: type-default underlay (opaque) + custom underlay + drag. - var layers = new List<(byte[] rgba, int w, int h)>(); - AddLayer(layers, typeUnderlayDid); - AddLayer(layers, underlayId); - if (drag is { } d) layers.Add(d); - if (layers.Count == 0) return 0; - - var (rgba, w, h) = Compose(layers); - uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true); - _byTuple[key] = handle; - return handle; - } -``` - -- [ ] **Step 5: Update `ToolbarController` for the new delegate arity** - -In `ToolbarController.cs`: -- Change the field type (~line 54): - -```csharp - private readonly Func _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex -``` - -- Change the constructor parameter type (the `Func iconIds` param): - -```csharp - Func iconIds, -``` - -- Change the `Bind` parameter type to match (same `Func iconIds`). -- In `Populate`, pass `item.Effects`: - -```csharp - uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); -``` - -- [ ] **Step 6: Update `GameWindow` — closure + spawn enrich** - -In `GameWindow.cs`: -- Widen the `iconIds` closure (~line 2005): - -```csharp - iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), -``` - -- Pass `spawn.UiEffects` in `OnLiveEntitySpawned`'s `EnrichItem` call (~line 2647): - -```csharp - Items.EnrichItem(spawn.Guid, spawn.IconId, spawn.Name ?? string.Empty, - (AcDream.Core.Items.ItemType)(spawn.ItemType ?? 0), - spawn.IconOverlayId, spawn.IconUnderlayId, spawn.UiEffects); -``` - -- [ ] **Step 7: Build + run the App test suite** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj` -Expected: Build succeeded. -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~IconComposer"` -Expected: PASS. - -- [ ] **Step 8: Commit** - -```bash -git add src/AcDream.App/UI/IconComposer.cs src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/UI/IconComposerTests.cs -git commit -m "feat(D.5.2): IconComposer 2-stage effect composite + 5-arg GetIcon" -``` - ---- - -### Task 8: Wire the live `0x02CE` update into the item repository - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (subscribe `ObjectIntPropertyUpdated`, next to `VitalUpdated`) - -> No unit test: this is a one-line session-event binding (the same shape as the existing -> `VitalUpdated` binding). `UpdateIntProperty` is unit-tested in Task 1; the end-to-end path -> is the visual-verification acceptance test. - -- [ ] **Step 1: Subscribe the event** - -In `GameWindow.cs`, next to the `VitalUpdated`/`VitalCurrentUpdated` subscriptions (~line 2630), -add: - -```csharp - // D.5.2: live PublicUpdatePropertyInt(0x02CE). Route UiEffects (18) to the item - // repository so a draining/charging item re-composites its icon in real time. - _liveSession.ObjectIntPropertyUpdated += u => - { - if (u.Property == AcDream.Core.Items.ItemRepository.UiEffectsPropertyId) - Items.UpdateIntProperty(u.Guid, u.Property, u.Value); - }; -``` - -- [ ] **Step 2: Build to verify it compiles** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj` -Expected: Build succeeded. - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(D.5.2): route live UiEffects updates (0x02CE) to the item icon" -``` - ---- - -### Task 9: Bookkeeping — divergence register, roadmap, memory - -**Files:** -- Modify: `docs/architecture/retail-divergence-register.md` (retire `IA-16`; add `DR-1..DR-4`) -- Modify: `docs/plans/2026-04-11-roadmap.md` (mark D.5.2 shipped) -- Modify: `claude-memory/project_d2b_retail_ui.md` (D.5.2 entry) - -- [ ] **Step 1: Update the divergence register** - -In `docs/architecture/retail-divergence-register.md`: -- **Delete the `IA-16` row** (item-icon composite PARTIAL — now complete). -- **Add four rows** (use the table's existing column shape; anchor file:line): - - `DR-1` — effect overlay (enum 0x10000005) is a `ReplaceColor` tint SOURCE, not a blit - layer; this IS faithful retail behavior — do not "fix" it back to a blit. Anchor: - `IconData::RenderIcons` 0x0058d180, `ReplaceColor` 0x00441530; code - `src/AcDream.App/UI/IconComposer.cs` (`GetIcon`). - - `DR-2` — effect tint color = the effect tile's mean-opaque color; the exact retail color - byte (`effectTile + 0xac` reinterpreted as RGBAColor) is decompiler-ambiguous. - Approximation; visual/cdb confirmation pending. Code `IconComposer.TryGetEffectColor`. - - `DR-3` — the `effects==0` black-fallback recolor that retail nominally runs is skipped - (white→black on every item — likely no-op, real regression risk). Code - `IconComposer.GetIcon` (`effects != 0` gate). - - `DR-4` — `PublicUpdatePropertyInt(0x02CE)` sequence not honored (latest-wins). Code - `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`. - -- [ ] **Step 2: Update the roadmap shipped table** - -In `docs/plans/2026-04-11-roadmap.md`, move D.5.2 (stateful item-icon system) into the shipped -section with the commit range and a one-line summary (appraise dropped as no-op; effect recolor -+ live 0x02CE wire-up). - -- [ ] **Step 3: Update the D.2b memory digest** - -In `claude-memory/project_d2b_retail_ui.md`, append a D.5.2 entry: UiEffects captured from -CreateObject (was discarded) → ItemInstance.Effects → IconComposer 2-stage recolor (effect -tile = ReplaceColor SOURCE, golden submap 0x10000005); live via PublicUpdatePropertyInt 0x02CE; -appraise carries NO icon data (dropped). Link `[[stateful-icon-system-handoff]]` superseded by -the RESOLVED doc. - -- [ ] **Step 4: Full build + test sweep** - -Run: `dotnet build` -Expected: Build succeeded (no warnings introduced). -Run: `dotnet test` -Expected: All green. - -- [ ] **Step 5: Commit** - -```bash -git add docs/architecture/retail-divergence-register.md docs/plans/2026-04-11-roadmap.md claude-memory/project_d2b_retail_ui.md -git commit -m "docs(D.5.2): retire IA-16, add DR-1..4, roadmap + memory" -``` - ---- - -## Visual verification (acceptance — after all tasks) - -Launch against live ACE (per CLAUDE.md "Running the client" recipe), then confirm with the user: -1. A **magical item** pinned to the toolbar shows the effect tint (white highlights take the - effect hue). -2. An item whose **mana drains** updates its icon live (the server's `0x02CE` UiEffects change - re-composites without a relog). - -If the tint is wrong/too subtle vs retail, the open lever is `DR-2` (effect color source) — a -cdb trace of `RenderIcons`/`ReplaceColor` on a live retail client resolves the exact byte. - ---- - -## Self-review - -- **Spec coverage:** §5.1→T1, §5.2→T2, §5.4→T3, §5.3→T4, §5.5→T1, §5.6→T5+T6+T7, - §5.7→T7, §5.8→T8, §6→T9, §7 tests→T1/T2/T3/T5/T6/T7 + visual. All covered. -- **Placeholders:** none — every code step shows full code; every command shows expected output. -- **Type consistency:** `Func` used identically in - `IconComposer.GetIcon`, `ToolbarController` field/ctor/Bind, and the `GameWindow` closure; - `UiEffectsPropertyId` (18) defined in T1 and referenced in T8; `ObjectIntPropertyUpdate` - record defined in T4 and consumed in T8; `ReplaceColorWhite`/`ResolveEffectDid`/`Compose` - signatures match between definition (T5/T6/T7) and tests. diff --git a/docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md b/docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md deleted file mode 100644 index 6110f3e3..00000000 --- a/docs/superpowers/plans/2026-06-18-a7-fixd-torch-overbright.md +++ /dev/null @@ -1,603 +0,0 @@ -# A7 Fix D — torch over-brightness on indoor walls — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make outdoor objects and indoor cell walls near torches render warm-but-bounded like retail, instead of blowing out warm-white. - -**Architecture:** Two orthogonal fixes. **D-1**: in `mesh_modern.vert`, accumulate point/spot lights into their own sum and clamp it to `[0,1]` BEFORE adding ambient+sun (mirrors retail `SetStaticLightingVertexColors`). **D-2**: `EnvCellRenderer` binds its OWN per-cell point-light set (SSBO 4+5) instead of reading the light set `WbDrawDispatcher` last left bound. A shared `GlobalLightPacker` (Core, pure) packs the global-light SSBO so the two renderers can't drift. `LightBake.cs` is the C# conformance oracle. - -**Tech Stack:** C# .NET 10, Silk.NET OpenGL (bindless + MDI SSBOs), GLSL 460. Tests: xUnit in `tests/AcDream.Core.Tests`. - -**Spec:** [`docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md`](../specs/2026-06-18-a7-fixd-torch-overbright-design.md) - -**Ground-truth golden (live cdb, Holtburg):** wall torches are `LightKind.Point`, `Intensity=100`, `Range = falloff×1.3` (falloff 3–5 → Range 3.9–6.5 m), warm colours `(1.0, 0.588, 0.314)` orange and `(0.980, 0.843, 0.612)` cream. The per-channel cap pins each torch to its colour ⇒ warm, never white. - -**Pre-flight (every task):** worktree is `C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b` (cwd). Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`. The retail client locks the DLLs — it must be closed before a build. - ---- - -## Task 1: Extract `GlobalLightPacker` (shared, pure) + refactor `WbDrawDispatcher` - -Pull the global-light SSBO float packing out of `WbDrawDispatcher.UploadGlobalLights` into a pure Core helper so `EnvCellRenderer` (Task 4) reuses the exact same layout. No behaviour change. - -**Files:** -- Create: `src/AcDream.Core/Lighting/GlobalLightPacker.cs` -- Create: `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs` -- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:1813-1848` (`UploadGlobalLights`) - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs`: - -```csharp -using System.Numerics; -using AcDream.Core.Lighting; -using Xunit; - -namespace AcDream.Core.Tests.Lighting; - -public class GlobalLightPackerTests -{ - [Fact] - public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout() - { - var light = new LightSource - { - Kind = LightKind.Point, - WorldPosition = new Vector3(10f, 20f, 30f), - WorldForward = new Vector3(0f, 0f, 1f), - ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), - Intensity = 100f, - Range = 5.2f, - ConeAngle = 0f, - }; - float[] buffer = System.Array.Empty(); - - int count = GlobalLightPacker.Pack(new[] { light }, ref buffer); - - Assert.Equal(1, count); - Assert.True(buffer.Length >= 16); - // posAndKind - Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]); - Assert.Equal((float)(int)LightKind.Point, buffer[3]); - // dirAndRange - Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]); - Assert.Equal(5.2f, buffer[7]); - // colorAndIntensity - Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]); - Assert.Equal(100f, buffer[11]); - // coneAngleEtc - Assert.Equal(0f, buffer[12]); - } - - [Fact] - public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot() - { - float[] buffer = System.Array.Empty(); - int count = GlobalLightPacker.Pack(null, ref buffer); - Assert.Equal(0, count); - Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight); - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests` -Expected: FAIL — `GlobalLightPacker` does not exist (compile error). - -- [ ] **Step 3: Implement `GlobalLightPacker`** - -Create `src/AcDream.Core/Lighting/GlobalLightPacker.cs`: - -```csharp -using System; -using System.Collections.Generic; - -namespace AcDream.Core.Lighting; - -/// -/// Packs a point-light snapshot into the flat float layout the bindless mesh -/// shader reads at SSBO binding=4 (mesh_modern.vert GlobalLight gLights[]): -/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity, -/// coneAngleEtc. Pure (no GL), so both WbDrawDispatcher and -/// EnvCellRenderer share ONE layout and cannot drift. -/// -public static class GlobalLightPacker -{ - public const int FloatsPerLight = 16; - - /// - /// Fill (grown + zero-cleared as needed) with the - /// packed snapshot; returns the light count n. The buffer always has at - /// least floats (so a zero-light frame still - /// uploads a non-empty SSBO). Callers upload max(n,1) * FloatsPerLight floats. - /// - public static int Pack(IReadOnlyList? snapshot, ref float[] buffer) - { - int n = snapshot?.Count ?? 0; - int floatsNeeded = Math.Max(n, 1) * FloatsPerLight; - if (buffer.Length < floatsNeeded) - buffer = new float[floatsNeeded + FloatsPerLight * 16]; - Array.Clear(buffer, 0, floatsNeeded); - - for (int i = 0; i < n; i++) - { - var L = snapshot![i]; - int o = i * FloatsPerLight; - buffer[o + 0] = L.WorldPosition.X; - buffer[o + 1] = L.WorldPosition.Y; - buffer[o + 2] = L.WorldPosition.Z; - buffer[o + 3] = (int)L.Kind; - buffer[o + 4] = L.WorldForward.X; - buffer[o + 5] = L.WorldForward.Y; - buffer[o + 6] = L.WorldForward.Z; - buffer[o + 7] = L.Range; - buffer[o + 8] = L.ColorLinear.X; - buffer[o + 9] = L.ColorLinear.Y; - buffer[o + 10] = L.ColorLinear.Z; - buffer[o + 11] = L.Intensity; - buffer[o + 12] = L.ConeAngle; - } - return n; - } -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter GlobalLightPackerTests` -Expected: PASS (2 tests). - -- [ ] **Step 5: Refactor `WbDrawDispatcher.UploadGlobalLights` to use the packer** - -In `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`, replace the body of `UploadGlobalLights` (1813-1848) with: - -```csharp - private unsafe void UploadGlobalLights() - { - int n = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); - int count = n > 0 ? n : 1; // never zero-size - fixed (float* gp = _globalLightData) - UploadSsbo(_globalLightsSsbo, 4, gp, - count * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)); - } -``` - -Leave the `_globalLightData` field declaration (line 145) as-is; the packer grows it. - -- [ ] **Step 6: Build and run the full Core test suite** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Then: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` -Expected: build green; all tests pass (no regression — the packing is byte-identical). - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.Core/Lighting/GlobalLightPacker.cs tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs -git commit -m "refactor(lighting): extract GlobalLightPacker (shared binding=4 layout) — A7 Fix D prep - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: Lock the bake contract — `LightBake` conformance test on golden torches - -`LightBake.cs` already implements the correct retail math (per-light cap + sum + `[0,1]` clamp, skip directional). This test pins the contract the D-1 shader change must mirror, using the captured golden torch values. It PASSES against the existing `LightBake` (this is a characterization/lock test — there is no failing-first step because the C# oracle is already correct; the bug lives in GLSL, which is verified by review in Task 3 + the user's visual check). - -**Files:** -- Create: `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs` - -- [ ] **Step 1: Write the conformance test** - -Create `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`: - -```csharp -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Lighting; -using Xunit; - -namespace AcDream.Core.Tests.Lighting; - -/// -/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp), -/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that -/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1) -/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md. -/// -public class LightBakeConformanceTests -{ - private static LightSource OrangeTorch(Vector3 pos) => new() - { - Kind = LightKind.Point, - WorldPosition = pos, - ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange - Intensity = 100f, - Range = 4f * 1.3f, // falloff 4 × static_light_factor - IsLit = true, - }; - - [Theory] - [InlineData(1f)] - [InlineData(2f)] - [InlineData(3f)] - [InlineData(4f)] - [InlineData(5f)] - public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist) - { - // Wall vertex at the origin, normal facing the torch (+X). Torch out along +X. - var vtx = Vector3.Zero; - var normal = Vector3.UnitX; - var torch = OrangeTorch(new Vector3(dist, 0f, 0f)); - - var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch }); - - // Every channel bounded to [0,1] — intensity=100 must NOT blow to white. - Assert.InRange(c.X, 0f, 1f); - Assert.InRange(c.Y, 0f, 1f); - Assert.InRange(c.Z, 0f, 1f); - // Warm hue preserved while lit (R ≥ G ≥ B), matching the torch colour ordering. - if (c.X > 0f) - { - Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}"); - Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}"); - } - } - - [Fact] - public void BeyondRange_ContributesNothing() - { - var torch = OrangeTorch(new Vector3(100f, 0f, 0f)); // far past Range - var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch }); - Assert.Equal(Vector3.Zero, c); - } - - [Fact] - public void ManyOverlappingIntenseTorches_StillClampToOne() - { - // Eight near-white intensity-100 torches all 1.5 m from the vertex: the - // [0,1] saturate must hold (no overflow past 1.0 per channel). - var lights = new List(); - for (int i = 0; i < 8; i++) - lights.Add(new LightSource - { - Kind = LightKind.Point, - WorldPosition = new Vector3(1.5f, 0.1f * i, 0f), - ColorLinear = new Vector3(0.98f, 0.95f, 0.9f), - Intensity = 100f, - Range = 5.2f, - IsLit = true, - }); - - var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights); - Assert.InRange(c.X, 0f, 1f); - Assert.InRange(c.Y, 0f, 1f); - Assert.InRange(c.Z, 0f, 1f); - } -} -``` - -- [ ] **Step 2: Run the test — verify it PASSES on existing LightBake** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter LightBakeConformanceTests` -Expected: PASS (7 cases). If any case FAILS, stop — `LightBake` (the oracle) diverges from the expected bake contract and that must be understood before changing the shader. (This is the lock; it should be green.) - -- [ ] **Step 3: Commit** - -```bash -git add tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs -git commit -m "test(lighting): lock the bake contract on golden torches (A7 Fix D oracle) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: D-1 — clamp the torch sum on its own in `mesh_modern.vert` - -Give point/spot lights their own accumulator and saturate it to `[0,1]` before it joins ambient+sun. Mirrors `LightBake.ComputeVertexColor` (Task 2) and retail `SetStaticLightingVertexColors`. The per-light cap and `pointContribution` are untouched. GLSL is not unit-testable in-process — correctness is the line-for-line match to `LightBake` (cite it) plus the user's visual check. - -**Files:** -- Modify: `src/AcDream.App/Rendering/Shaders/mesh_modern.vert:183-209` (`accumulateLights`) - -- [ ] **Step 1: Apply the clamp split** - -Replace the body of `accumulateLights` (183-209) with the following. The ambient base and sun loop are byte-identical; only the point loop changes (own accumulator + `min(pointAcc, 1.0)`): - -```glsl -vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { - vec3 lit = uCellAmbient.xyz; - - // SUN / directional — material-lit term (added with ambient, NOT into the - // torch sum), unchanged from before. - int activeLights = int(uCellAmbient.w); - for (int i = 0; i < 8; ++i) { - if (i >= activeLights) break; - if (int(uLights[i].posAndKind.w) != 0) continue; // directional only - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; - } - - // POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's - // SetStaticLightingVertexColors sums the static point lights from BLACK and - // clamps the SUM to [0,1] before anything else (it is a baked emissive term), - // so a few warm intensity-100 torches can't push the whole pixel to white the - // way folding them into ambient+sun did. Matches LightBake.ComputeVertexColor - // (tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests). Per-light cap - // inside pointContribution is unchanged. - vec3 pointAcc = vec3(0.0); - int base = instanceIndex * 8; - for (int k = 0; k < 8; ++k) { - int gi = instanceLightIdx[base + k]; - if (gi < 0) continue; - pointAcc += pointContribution(N, worldPos, gLights[gi]); - } - lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive) - - return lit; // frag still does the final min(lit, 1.0) -} -``` - -(`mesh_modern.frag:92`'s `lit = min(lit, vec3(1.0))` and the lightning bump at `:89` are unchanged — they remain the final pixel clamp.) - -- [ ] **Step 2: Build** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Expected: green. (Shaders are loaded at runtime from disk; the build only confirms nothing else broke.) - -- [ ] **Step 3: Review the math against the oracle** - -Confirm by reading both side-by-side that the shader's point path now matches `LightBake`: -- `mesh_modern.vert` `pointContribution` ↔ `LightBake.PointContribution` (range gate, wrap, norm, per-channel `min(scale·col, col)`) — already equal. -- new `min(pointAcc, vec3(1.0))` ↔ `LightBake.ComputeVertexColor`'s final `Clamp(·,0,1)` over the point sum. -No code change expected here — this is the verification step the commit message cites. - -- [ ] **Step 4: Commit** - -```bash -git add src/AcDream.App/Rendering/Shaders/mesh_modern.vert -git commit -m "fix(render): A7 Fix D D-1 — clamp the point-light sum on its own (#140) - -accumulateLights folded ambient+sun+torches into one accumulator clamped only -in the frag, so a few warm intensity-100 torches blew walls/objects to white. -Mirror retail SetStaticLightingVertexColors: sum point/spot into pointAcc, clamp -to [0,1] (the baked emissive), THEN add ambient+sun, frag final-clamps. Matches -LightBake.ComputeVertexColor (LightBakeConformanceTests). Per-light cap unchanged. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: D-2 — `EnvCellRenderer` binds its OWN per-cell light set (SSBO 4+5) - -Stop the cell shell from reading the leaked `WbDrawDispatcher` light set. EnvCellRenderer uploads its own binding-4 global lights (from the frame's `PointSnapshot`, via `GlobalLightPacker`) and a binding-5 per-instance light-set buffer, computing each cell's set with `LightManager.SelectForObject` over the cell's world bounds — mirroring the existing `_cellIdToSlot` per-instance pattern. - -**Files:** -- Modify: `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (fields ~70-110; `AllocateMdiBuffers` 207-236; new setter near 262; `RenderModernMDIInternal` 1007-~1234) -- Modify: `src/AcDream.App/Rendering/GameWindow.cs:~7777` (wire the snapshot) - -- [ ] **Step 1: Add fields + the per-frame snapshot setter** - -In `EnvCellRenderer.cs`, near the other scratch-buffer fields (after `_clipSlotBuffer`/`_clipSlotData`, ~line 110), add: - -```csharp - // A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state, - // like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last - // left bound. binding=4 = global point-light snapshot (same data/indices as the - // dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance. - private uint _globalLightsSsbo; // binding=4 - private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16]; - private uint _instLightSetSsbo; // binding=5 - private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; - private System.Collections.Generic.IReadOnlyList? _pointSnapshot; - private readonly System.Collections.Generic.Dictionary _cellLightSetCache = new(); -``` - -Near `SetClipRouting` (~262) add the per-frame setter: - -```csharp - /// - /// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot - /// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside - /// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets - /// reference this snapshot, which is also uploaded to binding=4 here, so the - /// pass is self-contained. Null/empty ⇒ shells receive no point lights. - /// - public void SetPointSnapshot( - System.Collections.Generic.IReadOnlyList? snapshot) - => _pointSnapshot = snapshot; -``` - -- [ ] **Step 2: Generate the two SSBOs in `AllocateMdiBuffers`** - -In `AllocateMdiBuffers` (207-236), before the final `_gl.BindBuffer(... 0)` calls (line 234), add: - -```csharp - // A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set. - _gl.GenBuffers(1, out _globalLightsSsbo); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw); - - _gl.GenBuffers(1, out _instLightSetSsbo); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)), - null, GLEnum.DynamicDraw); -``` - -- [ ] **Step 3: Add the per-cell light-set helper** - -Add this private method to `EnvCellRenderer` (e.g. just below `RenderModernMDIInternal`). It returns the cached 8-int set for a cell, computing it once per frame from the cell's world bounds + the snapshot via the static `SelectForObject`: - -```csharp - // A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world - // bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet). - // Cached per frame; unused slots are -1 (shader adds no point light there). - private int[] GetCellLightSet(uint cellId) - { - if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached; - - var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; - System.Array.Fill(set, -1); - - var snap = _pointSnapshot; - if (snap is { Count: > 0 } && - _landblocks.TryGetValue(cellId & 0xFFFF0000u, out var lb) && - lb.EnvCellBounds.TryGetValue(cellId, out var b)) - { - Vector3 center = (b.Min + b.Max) * 0.5f; - float radius = (b.Max - b.Min).Length() * 0.5f; - AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set); - } - _cellLightSetCache[cellId] = set; - return set; - } -``` - -(`WbBoundingBox` has public `Vector3 Min` / `Vector3 Max` — confirmed at `WbFrustum.cs:15-16`.) - -- [ ] **Step 4: Upload binding 4, fill + upload binding 5, and bind both in `RenderModernMDIInternal`** - -(a) At the TOP of `RenderModernMDIInternal` (after the `if (drawCalls.Count == 0 ...) return;` guard, ~1014), clear the per-frame cache: - -```csharp - _cellLightSetCache.Clear(); -``` - -(b) Where `_clipSlotData` is filled per instance (1195-1206), add a parallel fill of `_lightSetData` right after it: - -```csharp - // A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms, - // keyed on the cell each shell instance belongs to (mirrors _clipSlotData). - int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject; - if (_lightSetData.Length < uniqueInstanceCount * lightStride) - _lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)]; - for (int i = 0; i < uniqueInstanceCount; i++) - { - int[] cellSet = GetCellLightSet(allInstances[i].CellId); - System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride); - } -``` - -(c) Where the four buffers are uploaded (the `_clipSlotData` upload ends ~1209-1214), add the binding-4 + binding-5 uploads: - -```csharp - // A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set). - int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); - int glUploadCount = lightCount > 0 ? lightCount : 1; - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), - null, GLEnum.DynamicDraw); - fixed (float* gp = _globalLightData) - _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, - (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp); - - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw); - fixed (int* lp = _lightSetData) - _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, - (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp); -``` - -(d) In the bind block (1225-1230, after `BindClipRegionBinding2();`), add: - -```csharp - _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); - _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); -``` - -- [ ] **Step 5: Wire the snapshot from GameWindow** - -In `GameWindow.cs`, immediately after the existing `_wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot);` (line ~7777), add: - -```csharp - _envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2) -``` - -- [ ] **Step 6: Dispose the new buffers** - -In `EnvCellRenderer.Dispose` (search for the existing `_gl.DeleteBuffer(...)` cleanup), add: - -```csharp - if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); - if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); -``` - -- [ ] **Step 7: Build** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Expected: green. Fix any `WbBoundingBox` field-name or namespace mismatches surfaced by the compiler. - -- [ ] **Step 8: Commit** - -```bash -git add src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs src/AcDream.App/Rendering/GameWindow.cs -git commit -m "fix(render): A7 Fix D D-2 — EnvCell shell binds its own per-cell light set (#140) - -The cell shell read whatever light set (SSBO 4/5) WbDrawDispatcher last left -bound, lighting walls with a leaked set. EnvCellRenderer now uploads its own -binding=4 global lights (frame PointSnapshot via GlobalLightPacker) + a binding=5 -per-instance set, computed per cell by LightManager.SelectForObject over the -cell's world bounds (mirrors _cellIdToSlot + WbDrawDispatcher.ComputeEntityLightSet). - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 5: Divergence register — correct AP-35, reconcile the Fix B row - -**Files:** -- Modify: `docs/architecture/retail-divergence-register.md` (AP-35 row, line ~134; the Fix B per-object-light-selection row) - -- [ ] **Step 1: Correct AP-35** - -Find the `AP-35` row. It currently describes the point-light path as per-pixel -`mesh_modern.frag:52` with the half-Lambert wrap "neither ported". Rewrite the row to -reflect reality after Fix A + Fix D D-1: -- Path is per-vertex Gouraud in `mesh_modern.vert` (`pointContribution` ~:153, wrap ~:163), not per-pixel `frag`. -- The half-Lambert wrap + the `norm` (`distsq·d`) attenuation ARE ported (vert + `LightBake.cs`). -- The point-light sum is now clamped to `[0,1]` on its own (D-1), matching `SetStaticLightingVertexColors`. -- Update the `file:line` to `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` and cite `LightBake.cs` as the conformance oracle. - -- [ ] **Step 2: Reconcile the Fix B per-object-light-selection row** - -Find the row describing Fix B (per-object 8-light selection by sphere overlap vs -retail's per-vertex sum over the full static list — `minimize_object_lighting` -0x0054d480). Confirm its wording now covers EnvCell **shells** too (D-2 selects per -cell-sphere via the same `SelectForObject`). If it only mentions GfxObjs, extend the -"file:line" / description to include `EnvCellRenderer.GetCellLightSet`. Do NOT add a -new contradicting row. - -- [ ] **Step 3: Commit** - -```bash -git add docs/architecture/retail-divergence-register.md -git commit -m "docs(register): correct AP-35 (per-vertex+wrap ported, point sum clamped) — A7 Fix D - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Final verification (after all tasks) - -- [ ] `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` green. -- [ ] `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` green (GlobalLightPacker + LightBakeConformance + no regressions). -- [ ] **Visual (user, acceptance gate):** launch the client against live ACE, go to Holtburg. Confirm (a) outdoor objects near torches no longer blow out warm-white, and (b) the meeting-hall walls render warm-but-dim like retail. This is the sign-off the spec requires. -- [ ] Update `docs/ISSUES.md` / roadmap if #140 is tracked there (move to Recently closed with the commit SHAs once the user signs off visually). - -## Notes for the implementer - -- **No D3D-FF port.** Do not touch `config_hardware_light`-style `color×intensity / 1/d / Range×1.5` math — it is the wrong oracle for the baked walls (handoff warning). -- **No CPU bake.** `LightBake.cs` stays the test oracle only; the runtime path is the in-shader clamp (chosen approach). -- **Self-contained GL state.** EnvCellRenderer must bind binding 4 + 5 ITSELF every draw (per `feedback_render_self_contained_gl_state`); do not assume WbDrawDispatcher left them bound — that leak is the bug. -- **Don't touch the purple portal** — confirmed correct. diff --git a/docs/superpowers/plans/2026-06-18-d53a-selected-object-meter-plan.md b/docs/superpowers/plans/2026-06-18-d53a-selected-object-meter-plan.md deleted file mode 100644 index 61af1469..00000000 --- a/docs/superpowers/plans/2026-06-18-d53a-selected-object-meter-plan.md +++ /dev/null @@ -1,46 +0,0 @@ -# D.5.3a — Selected-object meter — implementation plan - -Spec: `docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md`. -Pre-approved by user 2026-06-18; subagent-driven, sequential (build-safe in one worktree). - -Mandatory per task: cite named-retail anchors in comments; `dotnet build` + the relevant -`dotnet test` green; match surrounding code style. No commits by subagents — the lead commits the -coherent set after the full build+test passes. - -## Task order (each builds on the accumulated working tree) - -### T1 — `WorldSession.SendQueryHealth` (+ net test) · project: `AcDream.Core.Net` -- Add `SendQueryHealth(uint targetGuid)` mirroring `SendChangeCombatMode` (`WorldSession.cs:1134`): - `NextGameActionSequence()` → `SocialActions.BuildQueryHealth(seq, guid)` → `SendGameAction(body)`. -- Test in `tests/AcDream.Core.Net.Tests/`: drive it through the existing send-capture seam used by the - other `WorldSession.Send*` tests; assert captured bytes == `BuildQueryHealth(seq, guid)`. -- Accept: `dotnet test` for `AcDream.Core.Net.Tests` green. - -### T2 — `DatWidgetFactory.BuildMeter` single-image shape (+ test) · project: `AcDream.App` -- Handle `containers.Count == 1`: `BackLeft = info.StateMedia[""].File`, - `FrontLeft = containers[0].StateMedia[""].File`, tile/right = 0. Keep `>= 2` (vitals) path unchanged. - Warn only on `Count == 0` / `Count > 2`. -- Extend `tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs`: 1-container synthetic meter - asserts Back/Front populated + others 0; 2-container case asserts vitals path unchanged. -- Accept: `dotnet test` for `AcDream.App.Tests` green. - -### T3 — `SelectedObjectController` (+ test) · project: `AcDream.App` -- New `src/AcDream.App/UI/Layout/SelectedObjectController.cs` per spec §3 (Bind signature, bind-time - setup, `OnSelectionChanged` clear-then-populate). Cite `HandleSelectionChanged:198635`. -- New `tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs` per spec §Testing item 2 - (mirror `ToolbarControllerTests` for building a minimal `ImportedLayout` + recording delegates). -- Accept: `dotnet test` for `AcDream.App.Tests` green. - -### T4 — GameWindow integration + register rows · project: `AcDream.App` (depends on T1, T3) -- Convert `_selectedGuid` field → `SelectedGuid` property + `SelectionChanged` event (spec §1); replace - the 3 write sites; leave read sites on the field. -- Remove `0x100001A1` + `0x100001A2` from `ToolbarController.HiddenIds` (keep `0x100001A4`). -- Wire `SelectedObjectController.Bind(...)` after `ToolbarController.Bind` (spec §5). -- Add the 2 divergence rows (spec §Divergence) to - `docs/architecture/retail-divergence-register.md`. -- Accept: full `dotnet build` + `dotnet test` green. - -## Then (lead) -- Adversarial Opus review of the full diff vs spec + decomp. -- Commit the coherent set to the branch; update roadmap/ISSUES if applicable; memory if a durable lesson. -- Stop for the user's visual gate (the acceptance test for this stream). diff --git a/docs/superpowers/plans/2026-06-18-d54-object-item-model.md b/docs/superpowers/plans/2026-06-18-d54-object-item-model.md deleted file mode 100644 index 080f8988..00000000 --- a/docs/superpowers/plans/2026-06-18-d54-object-item-model.md +++ /dev/null @@ -1,1344 +0,0 @@ -# D.5.4 Client Object/Item Data Model — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `CreateObject (0xF745)` the canonical create-or-update for every server object, holding the data side of all objects in one guid-keyed table (retail's `weenie_object_table` shape), so the UI resolves items by guid and the Coldeve blank-icon bug is fixed at the root. - -**Architecture:** Two guid-keyed tables (render/physics `WorldEntity` unchanged; data/UI `ClientObjectTable` broadened to all objects). `CreateObject` field-level **merge upsert** into `ClientObjectTable`; `DeleteObject` evicts; `PlayerDescription`/shortcuts are references; a live container-membership index; ingestion wired in `AcDream.Core.Net` (off `GameWindow`); `_liveEntityInfoByGuid` retired. - -**Tech Stack:** C# / .NET 10, xUnit (hand-built byte fixtures, no Moq), `AcDream.slnx` solution. Build `dotnet build`; test `dotnet test`. - -**Spec:** [`docs/superpowers/specs/2026-06-18-d54-object-item-model-design.md`](../specs/2026-06-18-d54-object-item-model-design.md) - -**Canonical name map (used throughout this plan):** - -| Old | New | -|---|---| -| `ItemInstance` (type) | `ClientObject` | -| `ItemRepository` (type) | `ClientObjectTable` | -| `ItemRepository.GetItem` | `ClientObjectTable.Get` | -| `ItemRepository.ItemCount` | `ClientObjectTable.ObjectCount` | -| `ItemRepository.Items` (IEnumerable) | `ClientObjectTable.Objects` | -| event `ItemAdded` | `ObjectAdded` | -| event `ItemMoved` | `ObjectMoved` | -| event `ItemRemoved` | `ObjectRemoved` | -| event `ItemPropertiesUpdated` | `ObjectUpdated` | -| `GameWindow.Items` (field) | `GameWindow.Objects` | - -Unchanged member names (object-agnostic / container-specific): `AddOrUpdate`, `MoveItem`, `Remove`, `UpdateProperties`, `UpdateIntProperty`, `Clear`, `AddContainer`, `GetContainer`, `Containers`, `ContainerCount`, `UiEffectsPropertyId`. (`EnrichItem` is kept temporarily and deleted in Task 9.) - ---- - -## Task 1: Mechanical rename — `ItemInstance`→`ClientObject`, `ItemRepository`→`ClientObjectTable` - -Pure refactor, no behavior change. Do this first so every later task uses the new names. - -**Files:** -- Rename: `src/AcDream.Core/Items/ItemInstance.cs` → `ClientObject.cs` -- Rename: `src/AcDream.Core/Items/ItemRepository.cs` → `ClientObjectTable.cs` -- Rename: `tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs` → `ClientObjectTableTests.cs` -- Modify (consumers): `src/AcDream.Core.Net/GameEventWiring.cs`, `src/AcDream.App/Rendering/GameWindow.cs`, `src/AcDream.App/UI/Layout/ToolbarController.cs`, plus anything the grep in Step 1 surfaces. - -- [ ] **Step 1: Enumerate every reference (bound the rename)** - -Run (Grep tool or shell): -```bash -grep -rn -E "ItemInstance|ItemRepository|\.GetItem\(|\.ItemAdded|\.ItemMoved|\.ItemRemoved|\.ItemPropertiesUpdated|\.ItemCount\b" src tests -``` -Expected: hits in the files listed above (ItemInstance.cs, ItemRepository.cs, GameEventWiring.cs, GameWindow.cs, ToolbarController.cs, ItemRepositoryTests.cs). Record any *additional* files (e.g. plugin abstractions) and include them in the edits below. `CreateObjectTests.cs` references only `ItemType` (not renamed) — leave it. - -- [ ] **Step 2: git mv the three files** - -```bash -git mv src/AcDream.Core/Items/ItemInstance.cs src/AcDream.Core/Items/ClientObject.cs -git mv src/AcDream.Core/Items/ItemRepository.cs src/AcDream.Core/Items/ClientObjectTable.cs -git mv tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs -``` - -- [ ] **Step 3: Rename the types + members in `ClientObject.cs`** - -In `src/AcDream.Core/Items/ClientObject.cs`: `public sealed class ItemInstance` → `public sealed class ClientObject`. (The `ItemType`, `EquipMask`, `PropertyBundle`, `Container`, `BurdenMath` types in this file keep their names.) Update the XML-doc summary on the class from "Per-item live state" to "Per-object live state (the data side of every server object — items and creatures alike). Retail `ACCWeenieObject`." - -- [ ] **Step 4: Rename the type + members in `ClientObjectTable.cs`** - -In `src/AcDream.Core/Items/ClientObjectTable.cs`, apply (replace_all per token): -- `public sealed class ItemRepository` → `public sealed class ClientObjectTable` -- every `ItemInstance` → `ClientObject` (field types, event generic args, params) -- `event Action? ItemAdded` → `event Action? ObjectAdded` -- `event Action? ItemMoved` → `event Action? ObjectMoved` -- `event Action? ItemRemoved` → `event Action? ObjectRemoved` -- `event Action? ItemPropertiesUpdated` → `event Action? ObjectUpdated` -- `public int ItemCount` → `public int ObjectCount` -- `public IEnumerable Items` → `public IEnumerable Objects` -- `public ItemInstance? GetItem(uint objectId)` → `public ClientObject? Get(uint objectId)` -- update every internal `ItemAdded?.Invoke`/`ItemPropertiesUpdated?.Invoke`/`ItemMoved?.Invoke`/`ItemRemoved?.Invoke` to the new event names. -- Update the class XML-doc summary to "the client's table of every server object (retail `weenie_object_table` / `CObjectMaint`)." - -- [ ] **Step 5: Fix consumers** - -In `src/AcDream.Core.Net/GameEventWiring.cs`: `ItemRepository items` → `ClientObjectTable items`; `new ItemInstance` → `new ClientObject`; `items.GetItem` → `items.Get`. (Leave the PD seeding body as-is for now — Task 8 rewrites it.) - -In `src/AcDream.App/Rendering/GameWindow.cs`: -- `public readonly AcDream.Core.Items.ItemRepository Items = new();` → `public readonly AcDream.Core.Items.ClientObjectTable Objects = new();` -- every other `Items.` in this file → `Objects.` (e.g. `Items.EnrichItem`, `Items.UpdateIntProperty`); every `ItemRepository.UiEffectsPropertyId` → `ClientObjectTable.UiEffectsPropertyId`. -- the `WireAll(_liveSession.GameEvents, Items, ...)` arg → `Objects`. - -In `src/AcDream.App/UI/Layout/ToolbarController.cs`: `ItemRepository` → `ClientObjectTable` (field `_repo`, ctor param); `repo.ItemAdded` → `repo.ObjectAdded`; `repo.ItemPropertiesUpdated` → `repo.ObjectUpdated`; `_repo.GetItem` → `_repo.Get`. - -In `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs`: `ItemRepository` → `ClientObjectTable`; `ItemInstance` → `ClientObject`; `repo.GetItem` → `repo.Get`; event names; `ItemCount` → `ObjectCount`. (The `MakeItem` helper keeps its name; it returns a `ClientObject`.) - -Apply the same renames in any extra files Step 1 surfaced. - -- [ ] **Step 6: Build + test green (no behavior change)** - -```bash -dotnet build -dotnet test -``` -Expected: build succeeds; full suite PASS (same count as before, just renamed). - -- [ ] **Step 7: Commit** - -```bash -git add -A -git commit -m "refactor(D.5.4): rename ItemRepository->ClientObjectTable, ItemInstance->ClientObject - -Broaden naming to the data side of every server object (retail weenie_object_table -shape). Pure rename; no behavior change. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: Capture the full item field set in the `CreateObject` parser - -The wire-cursor walk already exists (`CreateObject.cs:558-806`); turn the `pos += N` skips into reads, capture `WeenieClassId` from the fixed prefix, and surface all fields on `Parsed`. - -**Files:** -- Modify: `src/AcDream.Core.Net/Messages/CreateObject.cs` -- Test: `tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs` - -- [ ] **Step 1: Write the failing test (full-field capture + cursor integrity)** - -Add to `CreateObjectTests.cs`. First extend the builder so the new fields are parameterizable — add these parameters to `BuildMinimalCreateObjectWithWeenieHeader` and write them in their correct slots (insert next to the existing matching `if ((weenieFlags & ...))` lines): - -```csharp -// add to the BuildMinimalCreateObjectWithWeenieHeader parameter list: - uint weenieClassId = 0x1234, - uint? maxStackSize = null, - byte? itemsCapacity = null, - byte? containersCapacity = null, - uint? container = null, - uint? wielder = null, - uint? validLocations = null, - uint? currentWieldedLocation = null, - uint? priority = null, - float? workmanship = null, -``` - -Replace the corresponding writer lines in the builder body with value-carrying versions: -```csharp - WritePackedDword(bytes, weenieClassId); // WeenieClassId (was hardcoded 0x1234) - // ... - if ((weenieFlags & 0x00000002u) != 0) bytes.Add(itemsCapacity ?? 0); // ItemsCapacity u8 - if ((weenieFlags & 0x00000004u) != 0) bytes.Add(containersCapacity ?? 0); // ContainersCapacity u8 - if ((weenieFlags & 0x00002000u) != 0) WriteU16(bytes, (ushort)(maxStackSize ?? 0)); // MaxStackSize u16 - if ((weenieFlags & 0x00004000u) != 0) WriteU32(bytes, container ?? 0); // Container u32 - if ((weenieFlags & 0x00008000u) != 0) WriteU32(bytes, wielder ?? 0); // Wielder u32 - if ((weenieFlags & 0x00010000u) != 0) WriteU32(bytes, validLocations ?? 0); // ValidLocations - if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation - if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority - if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32 - { - Span tmp = stackalloc byte[4]; - BinaryPrimitives.WriteSingleLittleEndian(tmp, workmanship ?? 0f); - bytes.AddRange(tmp.ToArray()); - } -``` -(Leave `WritePackedDword(bytes, 0x1234)` → now `weenieClassId`; keep the `value`/`structure`/`maxStructure`/`stackSize`/`burden` lines already parameterized.) - -Then add the tests: -```csharp -[Fact] -public void TryParse_WeenieClassId_Surfaced() -{ - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000020u, name: "Sword", itemType: (uint)ItemType.MeleeWeapon, - weenieClassId: 0xABCDu); - var parsed = CreateObject.TryParse(body); - Assert.NotNull(parsed); - Assert.Equal(0xABCDu, parsed!.Value.WeenieClassId); -} - -[Fact] -public void TryParse_FullItemFields_Captured() -{ - // Set every capture flag and assert every value round-trips. - uint flags = - 0x00000008u | // Value - 0x00001000u | // StackSize - 0x00002000u | // MaxStackSize - 0x00200000u | // Burden - 0x00000002u | // ItemsCapacity - 0x00000004u | // ContainersCapacity - 0x00004000u | // Container - 0x00008000u | // Wielder - 0x00010000u | // ValidLocations - 0x00020000u | // CurrentlyWieldedLocation - 0x00040000u | // Priority - 0x00000400u | // Structure - 0x00000800u | // MaxStructure - 0x01000000u; // Workmanship - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000021u, name: "Pack", itemType: (uint)ItemType.Container, - weenieFlags: flags, - value: 250u, stackSize: 7, maxStackSize: 100u, burden: 42, - itemsCapacity: 24, containersCapacity: 7, - container: 0x50000099u, wielder: 0x5000009Au, - validLocations: 0x02000000u, currentWieldedLocation: 0x02000000u, - priority: 8u, structure: 5, maxStructure: 10, workmanship: 7.5f); - var parsed = CreateObject.TryParse(body); - Assert.NotNull(parsed); - var p = parsed!.Value; - Assert.Equal(250, p.Value); - Assert.Equal(7, p.StackSize); - Assert.Equal(100, p.StackSizeMax); - Assert.Equal(42, p.Burden); - Assert.Equal(24, p.ItemsCapacity); - Assert.Equal(7, p.ContainersCapacity); - Assert.Equal(0x50000099u, p.ContainerId); - Assert.Equal(0x5000009Au, p.WielderId); - Assert.Equal(0x02000000u, p.ValidLocations); - Assert.Equal(0x02000000u, p.CurrentWieldedLocation); - Assert.Equal(8u, p.Priority); - Assert.Equal(5, p.Structure); - Assert.Equal(10, p.MaxStructure); - Assert.Equal(7.5f, p.Workmanship); -} - -[Fact] -public void TryParse_MidTailFieldsSet_StillReachesIconOverlay() -{ - // Cursor-integrity guard: setting fields BEFORE IconOverlay must not - // desync the IconOverlay read. - uint flags = - 0x00001000u | // StackSize (mid-tail) - 0x00004000u | // Container - 0x40000000u; // IconOverlay - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000022u, name: "Ring", itemType: (uint)ItemType.Jewelry, - weenieFlags: flags, stackSize: 1, container: 0x500000F0u, - iconOverlayId: 0x4321u); - var parsed = CreateObject.TryParse(body); - Assert.NotNull(parsed); - Assert.Equal(0x06004321u, parsed!.Value.IconOverlayId); - Assert.Equal(0x500000F0u, parsed.Value.ContainerId); -} -``` - -- [ ] **Step 2: Run to verify it fails** - -```bash -dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests" -``` -Expected: FAIL — `Parsed` has no `WeenieClassId`/`Value`/`StackSize`/… members (compile error). - -- [ ] **Step 3: Extend the `Parsed` record** - -In `CreateObject.cs`, append these parameters to the `Parsed` record (after `UiEffects = 0`, before the closing `)`; bump the `UiEffects = 0` to `UiEffects = 0,`): -```csharp - // D.5.4 (2026-06-18): full item field set from the WeenieHeader tail — - // previously walked-past. Nullable = the gated flag was absent (don't - // clobber on merge); WeenieClassId is the fixed-prefix class id (was - // discarded at cs:538). Wire bits per r06 §4 / PublicWeenieDesc. - uint WeenieClassId = 0, - int? Value = null, - int? StackSize = null, - int? StackSizeMax = null, - int? Burden = null, - int? ItemsCapacity = null, - int? ContainersCapacity = null, - uint? ContainerId = null, - uint? WielderId = null, - uint? ValidLocations = null, - uint? CurrentWieldedLocation = null, - uint? Priority = null, - int? Structure = null, - int? MaxStructure = null, - float? Workmanship = null); -``` - -- [ ] **Step 4: Capture the values in `TryParse`** - -In `CreateObject.cs`, declare the new locals beside `iconId` (before the fixed-prefix `try`): -```csharp - uint weenieClassId = 0; - int? wValue = null; int? wStackSize = null; uint? wMaxStackSize = null; - int? wBurden = null; int? wItemsCapacity = null; int? wContainersCapacity = null; - uint? wContainerId = null; uint? wWielderId = null; - uint? wValidLocations = null; uint? wCurrentWieldedLocation = null; - uint? wPriority = null; int? wStructure = null; int? wMaxStructure = null; - float? wWorkmanship = null; -``` -Change the fixed-prefix WeenieClassId read: -```csharp - weenieClassId = ReadPackedDword(body, ref pos); // WeenieClassId (D.5.4: was discarded) -``` -In the optional-tail `try`, change these skips to reads (keep the bounds-check throw on each): -```csharp - if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity u8 - { - if (body.Length - pos < 1) throw new FormatException("trunc ItemCap"); - wItemsCapacity = body[pos]; pos += 1; - } - if ((weenieFlags & 0x00000004u) != 0) // ContainersCapacity u8 - { - if (body.Length - pos < 1) throw new FormatException("trunc ContCap"); - wContainersCapacity = body[pos]; pos += 1; - } - if ((weenieFlags & 0x00000008u) != 0) // Value u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Value"); - wValue = (int)ReadU32(body, ref pos); - } - // ... (Usable/UseRadius/TargetType/UiEffects/CombatUse unchanged) ... - if ((weenieFlags & 0x00000400u) != 0) // Structure u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc Structure"); - wStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; - } - if ((weenieFlags & 0x00000800u) != 0) // MaxStructure u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc MaxStructure"); - wMaxStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; - } - if ((weenieFlags & 0x00001000u) != 0) // StackSize u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc StackSize"); - wStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; - } - if ((weenieFlags & 0x00002000u) != 0) // MaxStackSize u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc MaxStackSize"); - wMaxStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; - } - if ((weenieFlags & 0x00004000u) != 0) // Container u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Container"); - wContainerId = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00008000u) != 0) // Wielder u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Wielder"); - wWielderId = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00010000u) != 0) // ValidLocations u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc ValidLocations"); - wValidLocations = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00020000u) != 0) // CurrentlyWieldedLocation u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc CurrentlyWieldedLocation"); - wCurrentWieldedLocation = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00040000u) != 0) // Priority u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Priority"); - wPriority = ReadU32(body, ref pos); - } - // ... (RadarBlipColor/RadarBehavior/PScript unchanged) ... - if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Workmanship"); - wWorkmanship = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4; - } - if ((weenieFlags & 0x00200000u) != 0) // Burden u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc Burden"); - wBurden = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; - } -``` -(Leave every other field — Usable, UseRadius, UiEffects, CombatUse, RadarBlipColor, RadarBehavior, PScript, Spell, HouseOwner, HouseRestrictions, HookItemTypes, Monarch, HookType, IconOverlay, IconUnderlay — exactly as-is.) - -- [ ] **Step 5: Pass the new fields to both `Parsed` construction sites** - -Append to the final `return new Parsed(...)` (after `UiEffects: uiEffects`): -```csharp - UiEffects: uiEffects, - WeenieClassId: weenieClassId, - Value: wValue, StackSize: wStackSize, StackSizeMax: wMaxStackSize, - Burden: wBurden, ItemsCapacity: wItemsCapacity, ContainersCapacity: wContainersCapacity, - ContainerId: wContainerId, WielderId: wWielderId, - ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation, - Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure, - Workmanship: wWorkmanship); -``` -`PartialResult()` does not reach the weenie tail, so it needs no change (its new fields default to null/0). - -- [ ] **Step 6: Run to verify pass** - -```bash -dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~CreateObjectTests" -``` -Expected: PASS (all existing + 3 new tests). - -- [ ] **Step 7: Commit** - -```bash -git add -A -git commit -m "feat(D.5.4): capture full item field set in CreateObject parser - -WeenieClassId + Value/StackSize/MaxStackSize/Burden/capacities/Container/Wielder/ -ValidLocations/CurrentWieldedLocation/Priority/Structure/Workmanship. Nullable = -flag absent (don't clobber on merge). Cursor walk unchanged; +cursor-integrity test. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: Plumb the new fields through `WorldSession.EntitySpawn` - -Pure plumbing (record + the single `EntitySpawned.Invoke`). Verified by build + existing tests (no easy unit test for an event record). - -**Files:** -- Modify: `src/AcDream.Core.Net/WorldSession.cs` - -- [ ] **Step 1: Extend the `EntitySpawn` record** - -Append to the `EntitySpawn` record (after `uint UiEffects = 0`, change it to `,`): -```csharp - uint UiEffects = 0, - // D.5.4 (2026-06-18): full item field set, forwarded to the object table. - uint WeenieClassId = 0, - int? Value = null, - int? StackSize = null, - int? StackSizeMax = null, - int? Burden = null, - int? ItemsCapacity = null, - int? ContainersCapacity = null, - uint? ContainerId = null, - uint? WielderId = null, - uint? ValidLocations = null, - uint? CurrentWieldedLocation = null, - uint? Priority = null, - int? Structure = null, - int? MaxStructure = null, - float? Workmanship = null); -``` - -- [ ] **Step 2: Forward the fields at the `EntitySpawned.Invoke` site** - -In the `0xF745` dispatch (after `parsed.Value.UiEffects` in the `new EntitySpawn(...)` call): -```csharp - parsed.Value.UiEffects, - parsed.Value.WeenieClassId, - parsed.Value.Value, - parsed.Value.StackSize, - parsed.Value.StackSizeMax, - parsed.Value.Burden, - parsed.Value.ItemsCapacity, - parsed.Value.ContainersCapacity, - parsed.Value.ContainerId, - parsed.Value.WielderId, - parsed.Value.ValidLocations, - parsed.Value.CurrentWieldedLocation, - parsed.Value.Priority, - parsed.Value.Structure, - parsed.Value.MaxStructure, - parsed.Value.Workmanship)); -``` -(Replace the existing closing `parsed.Value.UiEffects));` with the block above.) - -- [ ] **Step 3: Build + test green** - -```bash -dotnet build -dotnet test -``` -Expected: build succeeds; full suite PASS. - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "feat(D.5.4): forward full item field set through WorldSession.EntitySpawn - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: Add the new fields to `ClientObject` + define the `WeenieData` ingest DTO - -**Files:** -- Modify: `src/AcDream.Core/Items/ClientObject.cs` -- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` - -- [ ] **Step 1: Write the failing test** - -Add to `ClientObjectTableTests.cs`: -```csharp - [Fact] - public void ClientObject_NewFields_DefaultAndSettable() - { - var o = new ClientObject - { - ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7, - Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f, - }; - o.WeenieClassId = 0xABCDu; // now settable - Assert.Equal(0x42u, o.WielderId); - Assert.Equal(24, o.ItemsCapacity); - Assert.Equal(7, o.ContainersCapacity); - Assert.Equal(8u, o.Priority); - Assert.Equal(5, o.Structure); - Assert.Equal(10, o.MaxStructure); - Assert.Equal(7.5f, o.Workmanship); - Assert.Equal(0xABCDu, o.WeenieClassId); - } - - [Fact] - public void WeenieData_Construct() - { - var d = new WeenieData(Guid: 1, Name: "x", Type: ItemType.Misc, WeenieClassId: 2, - IconId: 0x06001234u, IconOverlayId: 0, IconUnderlayId: 0, Effects: 0, - Value: 5, StackSize: 1, StackSizeMax: 1, Burden: 10, - ContainerId: 0x99u, WielderId: null, ValidLocations: null, - CurrentWieldedLocation: null, Priority: null, - ItemsCapacity: null, ContainersCapacity: null, - Structure: null, MaxStructure: null, Workmanship: null); - Assert.Equal(0x99u, d.ContainerId); - } -``` - -- [ ] **Step 2: Run to verify it fails** - -```bash -dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" -``` -Expected: FAIL — `WielderId`/`Priority`/… and `WeenieData` don't exist; `WeenieClassId` is init-only. - -- [ ] **Step 3: Add fields to `ClientObject`** - -In `ClientObject.cs`, change `public uint WeenieClassId { get; init; }` → `public uint WeenieClassId { get; set; }`. After the `Bonded` property (before `Properties`), add: -```csharp - public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded - public int ItemsCapacity { get; set; } // main-pack slots (containers) - public int ContainersCapacity{ get; set; } // side-pack slots (containers) - public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order - public int Structure { get; set; } // charges/uses remaining - public int MaxStructure{ get; set; } - public float Workmanship{ get; set; } // 0..10 (fractional on the wire) -``` - -- [ ] **Step 4: Add the `WeenieData` DTO** - -Append to `ClientObject.cs` (same namespace), after the `ClientObject` class: -```csharp -/// -/// The wire-delivered patch from a CreateObject (0xF745). Nullable fields -/// were gated by a WeenieHeader flag that was ABSENT — the merge upsert -/// () leaves the existing value untouched -/// for those, matching retail's SetWeenieDesc (patches only present fields). -/// Non-nullable id/effect fields use 0 = "not sent". Effects is assigned -/// unconditionally (0 clears) — the D.5.2 icon contract. -/// -public readonly record struct WeenieData( - uint Guid, - string? Name, - ItemType? Type, - uint WeenieClassId, - uint IconId, - uint IconOverlayId, - uint IconUnderlayId, - uint Effects, - int? Value, - int? StackSize, - int? StackSizeMax, - int? Burden, - uint? ContainerId, - uint? WielderId, - uint? ValidLocations, - uint? CurrentWieldedLocation, - uint? Priority, - int? ItemsCapacity, - int? ContainersCapacity, - int? Structure, - int? MaxStructure, - float? Workmanship); -``` - -- [ ] **Step 5: Run to verify pass** - -```bash -dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" -``` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "feat(D.5.4): add item fields to ClientObject + WeenieData ingest DTO - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 5: `Ingest` merge-upsert + `RecordMembership` (with the D.5.2 effects contract) - -**Files:** -- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` -- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Add to `ClientObjectTableTests.cs` (these port the D.5.2 effects contract onto `Ingest` + lock the merge + the Coldeve fix): -```csharp - private static WeenieData FullWeenie(uint guid, uint icon = 0x06001234u, - string name = "Sword", ItemType type = ItemType.MeleeWeapon, uint effects = 0, - int? value = 100, int? stack = 1, uint? container = null, uint wcid = 0xABCDu) => - new WeenieData(guid, name, type, wcid, icon, 0, 0, effects, - value, stack, StackSizeMax: 1, Burden: 10, ContainerId: container, - WielderId: null, ValidLocations: null, CurrentWieldedLocation: null, - Priority: null, ItemsCapacity: null, ContainersCapacity: null, - Structure: null, MaxStructure: null, Workmanship: null); - - [Fact] - public void Ingest_NewItemWithNoPriorStub_Creates_AndFiresAdded() // the Coldeve bug - { - var table = new ClientObjectTable(); - ClientObject? added = null; - table.ObjectAdded += o => added = o; - var obj = table.Ingest(FullWeenie(0x500000B0u)); - Assert.NotNull(added); - Assert.Equal(0x06001234u, table.Get(0x500000B0u)!.IconId); - Assert.Equal(0xABCDu, obj.WeenieClassId); - } - - [Fact] - public void Ingest_Existing_PatchesInPlace_PreservesPropertyBundle() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x500000B1u)); - // Simulate an appraise having populated Properties. - table.Get(0x500000B1u)!.Properties.Ints[999u] = 7; - ClientObject? updated = null; - table.ObjectUpdated += o => updated = o; - table.Ingest(FullWeenie(0x500000B1u, name: "Renamed")); - Assert.NotNull(updated); - Assert.Equal("Renamed", table.Get(0x500000B1u)!.Name); - Assert.Equal(7, table.Get(0x500000B1u)!.Properties.Ints[999u]); // NOT clobbered - } - - [Fact] - public void Ingest_AbsentNullableField_DoesNotClobber() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x500000B2u, value: 100)); - // Re-send with Value absent (null) — prior 100 must stay. - var noValue = FullWeenie(0x500000B2u) with { Value = null }; - table.Ingest(noValue); - Assert.Equal(100, table.Get(0x500000B2u)!.Value); - } - - [Fact] - public void Ingest_Effects_AssignedUnconditionally_ClearsToZero() // D.5.2 contract - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x500000B3u, effects: 0x1u)); - Assert.Equal(0x1u, table.Get(0x500000B3u)!.Effects); - table.Ingest(FullWeenie(0x500000B3u, effects: 0u)); // now inert - Assert.Equal(0u, table.Get(0x500000B3u)!.Effects); - } - - [Fact] - public void RecordMembership_CreatesEntry_AndSetsEquip() - { - var table = new ClientObjectTable(); - table.RecordMembership(0x500000B4u, equip: EquipMask.MeleeWeapon); - var o = table.Get(0x500000B4u); - Assert.NotNull(o); - Assert.Equal(EquipMask.MeleeWeapon, o!.CurrentlyEquippedLocation); - Assert.Equal(0u, o.IconId); // data not set — CreateObject fills it - } - - [Fact] - public void Ingest_AfterMembership_FillsData_NoDuplicate() // out-of-order: PD then CreateObject - { - var table = new ClientObjectTable(); - table.RecordMembership(0x500000B5u); - table.Ingest(FullWeenie(0x500000B5u)); - Assert.Equal(1, table.ObjectCount); - Assert.Equal(0x06001234u, table.Get(0x500000B5u)!.IconId); - } -``` - -- [ ] **Step 2: Run to verify it fails** - -```bash -dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" -``` -Expected: FAIL — `Ingest`/`RecordMembership` don't exist. - -- [ ] **Step 3: Implement `Ingest` + `RecordMembership`** - -In `ClientObjectTable.cs`, rename the backing field `_items` → `_objects` (and update existing references in the file), then add (the `Reindex` call is a no-op stub here; Task 6 fills it): -```csharp - /// - /// Canonical CreateObject ingestion: create-if-absent, else patch the - /// wire-carried fields in place (retail SetWeenieDesc). Preserves the - /// PropertyBundle (appraise) and any field the wire didn't carry. - /// Effects is assigned unconditionally (0 clears) — the D.5.2 icon contract. - /// - public ClientObject Ingest(WeenieData d) - { - bool existed = _objects.TryGetValue(d.Guid, out var obj); - if (!existed || obj is null) - { - obj = new ClientObject { ObjectId = d.Guid }; - _objects[d.Guid] = obj; - } - uint oldContainer = obj.ContainerId; - - if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!; - if (d.Type is { } t) obj.Type = t; - if (d.WeenieClassId != 0) obj.WeenieClassId = d.WeenieClassId; - if (d.IconId != 0) obj.IconId = d.IconId; - if (d.IconOverlayId != 0) obj.IconOverlayId = d.IconOverlayId; - if (d.IconUnderlayId != 0) obj.IconUnderlayId = d.IconUnderlayId; - obj.Effects = d.Effects; // D.5.2 contract - if (d.Value is { } v) obj.Value = v; - if (d.StackSize is { } s) obj.StackSize = s; - if (d.StackSizeMax is { } sm) obj.StackSizeMax = sm; - if (d.Burden is { } b) obj.Burden = b; - if (d.ContainerId is { } c) obj.ContainerId = c; - if (d.WielderId is { } w) obj.WielderId = w; - if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl; - if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl; - if (d.Priority is { } pr) obj.Priority = pr; - if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic; - if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc; - if (d.Structure is { } st) obj.Structure = st; - if (d.MaxStructure is { } ms) obj.MaxStructure = ms; - if (d.Workmanship is { } wm) obj.Workmanship = wm; - - Reindex(obj, oldContainer); - if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); - return obj; - } - - /// - /// PlayerDescription manifest: record that this guid is the player's - /// (in inventory or equipped at ), creating an - /// empty entry if CreateObject hasn't arrived yet. Never touches - /// icon/name/type/effects — that data comes from CreateObject. - /// - public ClientObject RecordMembership(uint guid, uint containerId = 0, - EquipMask equip = EquipMask.None) - { - bool existed = _objects.TryGetValue(guid, out var obj); - if (!existed || obj is null) - { - obj = new ClientObject { ObjectId = guid }; - _objects[guid] = obj; - } - uint oldContainer = obj.ContainerId; - if (containerId != 0) obj.ContainerId = containerId; - if (equip != EquipMask.None) obj.CurrentlyEquippedLocation = equip; - Reindex(obj, oldContainer); - if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); - return obj; - } - - // Filled in Task 6 (container index). No-op until then. - private void Reindex(ClientObject obj, uint oldContainerId) { } -``` - -- [ ] **Step 4: Run to verify pass** - -```bash -dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" -``` -Expected: PASS (existing + 6 new). `dotnet build` still green (EnrichItem unchanged, still called by GameWindow). - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(D.5.4): ClientObjectTable.Ingest merge-upsert + RecordMembership - -Field-level merge (retail SetWeenieDesc): create-if-absent else patch present -fields, preserve PropertyBundle. Effects unconditional (D.5.2 contract). -RecordMembership = PD manifest. Locks the Coldeve no-prior-stub fix + out-of-order. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 6: Container membership index - -**Files:** -- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` -- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` - -- [ ] **Step 1: Write the failing tests** - -```csharp - [Fact] - public void ContainerIndex_IngestThenContents_OrderedBySlot() - { - var table = new ClientObjectTable(); - // two items into container 0xC0, slots set via MoveItem after ingest - table.Ingest(FullWeenie(0x510u, container: 0xC0u)); - table.Ingest(FullWeenie(0x511u, container: 0xC0u)); - table.MoveItem(0x510u, 0xC0u, newSlot: 1); - table.MoveItem(0x511u, 0xC0u, newSlot: 0); - Assert.Equal(new[] { 0x511u, 0x510u }, table.GetContents(0xC0u)); - } - - [Fact] - public void ContainerIndex_Move_ReparentsBetweenContainers() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x520u, container: 0xC1u)); - table.MoveItem(0x520u, 0xC2u, newSlot: 0); - Assert.Empty(table.GetContents(0xC1u)); - Assert.Equal(new[] { 0x520u }, table.GetContents(0xC2u)); - } - - [Fact] - public void ContainerIndex_Remove_DropsFromContents() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x530u, container: 0xC3u)); - table.Remove(0x530u); - Assert.Empty(table.GetContents(0xC3u)); - } - - [Fact] - public void GetContents_UnknownContainer_Empty() - { - var table = new ClientObjectTable(); - Assert.Empty(table.GetContents(0xDEADu)); - } -``` - -- [ ] **Step 2: Run to verify it fails** - -```bash -dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" -``` -Expected: FAIL — `GetContents` doesn't exist; `Reindex` is a no-op. - -- [ ] **Step 3: Implement the index** - -In `ClientObjectTable.cs`, add the field beside `_objects`: -```csharp - private readonly Dictionary> _containerIndex = new(); -``` -Replace the `Reindex` no-op stub with: -```csharp - private void Reindex(ClientObject obj, uint oldContainerId) - { - if (oldContainerId != obj.ContainerId && oldContainerId != 0 - && _containerIndex.TryGetValue(oldContainerId, out var oldList)) - oldList.Remove(obj.ObjectId); - - if (obj.ContainerId != 0) - { - if (!_containerIndex.TryGetValue(obj.ContainerId, out var list)) - _containerIndex[obj.ContainerId] = list = new List(); - if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId); - list.Sort((a, b) => SlotOf(a).CompareTo(SlotOf(b))); - } - } - - private int SlotOf(uint guid) => - _objects.TryGetValue(guid, out var o) ? o.ContainerSlot : int.MaxValue; - - /// Ordered item guids in a container (retail object_inventory_table). - public IReadOnlyList GetContents(uint containerId) => - _containerIndex.TryGetValue(containerId, out var l) - ? l : (IReadOnlyList)System.Array.Empty(); -``` -In `MoveItem`, add a `Reindex` call before firing the event (and rename the event to `ObjectMoved`): -```csharp - uint oldContainer = item.ContainerId; - item.ContainerId = newContainerId; - item.ContainerSlot = newSlot; - item.CurrentlyEquippedLocation = newEquipLocation; - Reindex(item, oldContainer); - ObjectMoved?.Invoke(item, oldContainer, newContainerId); -``` -In `Remove`, drop from the index before firing `ObjectRemoved`: -```csharp - if (!_objects.TryRemove(itemId, out var item)) return false; - if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l)) - l.Remove(itemId); - ObjectRemoved?.Invoke(item); - return true; -``` -In `Clear`, also clear the index: add `_containerIndex.Clear();`. - -- [ ] **Step 4: Run to verify pass** - -```bash -dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" -``` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(D.5.4): live container membership index (object_inventory_table) - -Reindex on Ingest/MoveItem/Remove; GetContents(containerId) ordered by slot. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 7: `ObjectTableWiring` + rewire `GameWindow` ingestion off `EnrichItem` - -Move CreateObject/DeleteObject/0x02CE ingestion into `AcDream.Core.Net`; `GameWindow` stops calling `EnrichItem`. - -**Files:** -- Create: `src/AcDream.Core.Net/ObjectTableWiring.cs` -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` -- Test: `tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs`. Since `EntitySpawned`/`EntityDeleted` are `WorldSession` events, test the mapping function directly by exposing it as a static. Test the translation `EntitySpawn → WeenieData`: -```csharp -using AcDream.Core.Items; -using AcDream.Core.Net; -using Xunit; - -namespace AcDream.Core.Net.Tests; - -public sealed class ObjectTableWiringTests -{ - [Fact] - public void ToWeenieData_CopiesFieldsFromSpawn() - { - var spawn = new WorldSession.EntitySpawn( - Guid: 0x600u, Position: null, SetupTableId: null, - AnimPartChanges: System.Array.Empty(), - TextureChanges: System.Array.Empty(), - SubPalettes: System.Array.Empty(), - BasePaletteId: null, ObjScale: null, Name: "Gem", ItemType: (uint)Items.ItemType.Gem, - MotionState: null, MotionTableId: null) - { - // positional record — use 'with' for the optional tail - } with { IconId = 0x06001111u, UiEffects = 0x2u, WeenieClassId = 0x10u, - Value = 50, StackSize = 3, ContainerId = 0xC9u }; - - var d = ObjectTableWiring.ToWeenieData(spawn); - Assert.Equal(0x600u, d.Guid); - Assert.Equal(0x06001111u, d.IconId); - Assert.Equal(0x2u, d.Effects); - Assert.Equal(0x10u, d.WeenieClassId); - Assert.Equal(50, d.Value); - Assert.Equal(3, d.StackSize); - Assert.Equal(0xC9u, d.ContainerId); - Assert.Equal(Items.ItemType.Gem, d.Type); - } - - [Fact] - public void Wire_CreateObject_Ingests() - { - var table = new ClientObjectTable(); - var session = WorldSessionTestFactory.Create(); // see note below - ObjectTableWiring.Wire(session, table); - session.RaiseEntitySpawnedForTest(new WorldSession.EntitySpawn( - 0x601u, null, null, - System.Array.Empty(), - System.Array.Empty(), - System.Array.Empty(), - null, null, "Coin", (uint)Items.ItemType.Money, null, null) - { } with { IconId = 0x06002222u }); - Assert.Equal(0x06002222u, table.Get(0x601u)!.IconId); - } -} -``` -> NOTE: if `WorldSession` cannot be constructed/raised directly in a test, drop `Wire_CreateObject_Ingests` and keep only `ToWeenieData_CopiesFieldsFromSpawn` (the pure mapping is the load-bearing logic; the `Wire` subscription is verified by build + the live run). Do NOT invent a `WorldSessionTestFactory`/`RaiseEntitySpawnedForTest` if no equivalent test seam exists — check `tests/AcDream.Core.Net.Tests` for how `WorldSession` is exercised first. - -- [ ] **Step 2: Run to verify it fails** - -```bash -dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~ObjectTableWiringTests" -``` -Expected: FAIL — `ObjectTableWiring` doesn't exist. - -- [ ] **Step 3: Create `ObjectTableWiring`** - -`src/AcDream.Core.Net/ObjectTableWiring.cs`: -```csharp -using AcDream.Core.Items; - -namespace AcDream.Core.Net; - -/// -/// Wires WorldSession GameMessage-level object events into the client object -/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747) -/// = evict, PublicUpdatePropertyInt (0x02CE) UiEffects = live icon re-composite. -/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow. -/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side). -/// -public static class ObjectTableWiring -{ - public static void Wire(WorldSession session, ClientObjectTable table) - { - System.ArgumentNullException.ThrowIfNull(session); - System.ArgumentNullException.ThrowIfNull(table); - - session.EntitySpawned += s => table.Ingest(ToWeenieData(s)); - session.EntityDeleted += d => table.Remove(d.Guid); - session.ObjectIntPropertyUpdated += u => - { - if (u.Property == ClientObjectTable.UiEffectsPropertyId) - table.UpdateIntProperty(u.Guid, u.Property, u.Value); - }; - } - - /// Translate the wire spawn into the table's merge patch. - public static WeenieData ToWeenieData(WorldSession.EntitySpawn s) => new( - Guid: s.Guid, - Name: s.Name, - Type: s.ItemType is { } it ? (ItemType)it : (ItemType?)null, - WeenieClassId: s.WeenieClassId, - IconId: s.IconId, - IconOverlayId: s.IconOverlayId, - IconUnderlayId: s.IconUnderlayId, - Effects: s.UiEffects, - Value: s.Value, - StackSize: s.StackSize, - StackSizeMax: s.StackSizeMax, - Burden: s.Burden, - ContainerId: s.ContainerId, - WielderId: s.WielderId, - ValidLocations: s.ValidLocations, - CurrentWieldedLocation: s.CurrentWieldedLocation, - Priority: s.Priority, - ItemsCapacity: s.ItemsCapacity, - ContainersCapacity: s.ContainersCapacity, - Structure: s.Structure, - MaxStructure: s.MaxStructure, - Workmanship: s.Workmanship); -} -``` - -- [ ] **Step 4: Rewire `GameWindow`** - -In `GameWindow.cs`: -- In `WireLiveSessionEvents` (the `_liveSession.EntitySpawned += OnLiveEntitySpawned;` block), add right after assigning `_liveSession`: -```csharp - AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects); -``` -- In `OnLiveEntitySpawned`, **delete** the `Objects.EnrichItem(...)` call (the whole 4-line `D.5.1: enrich...` block) — ingestion now happens in `ObjectTableWiring`. Leave the `lock (_datLock) { OnLiveEntitySpawnedLocked(spawn); }` render path. -- Delete the inline `_liveSession.ObjectIntPropertyUpdated += u => { ... Objects.UpdateIntProperty ... };` block (now in `ObjectTableWiring`). - -- [ ] **Step 5: Run to verify pass + build** - -```bash -dotnet build -dotnet test -``` -Expected: build succeeds; full suite PASS (the new `ObjectTableWiringTests` + all existing). The toolbar now gets its icons via `Ingest` → `ObjectAdded`/`ObjectUpdated`. - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "feat(D.5.4): ObjectTableWiring (CreateObject=upsert, Delete=evict, 0x02CE) off GameWindow - -CreateObject ingestion moves to Core.Net; GameWindow drops the EnrichItem call + -inline 0x02CE handler. Fixes the Coldeve blank-icon root cause: items with no PD -stub are now created, not dropped. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 8: `PlayerDescription` → membership manifest (fix the `WeenieClassId` misuse) - -**Files:** -- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` -- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` - -- [ ] **Step 1: Write the failing test** - -Add to `GameEventWiringTests.cs` (match the file's existing dispatch-test style; adapt names to its helpers): -```csharp - [Fact] - public void PlayerDescription_SeedsMembership_NotWeenieClassIdMisuse() - { - var table = new ClientObjectTable(); - // ... build + dispatch a PlayerDescription with one inventory guid 0x700 - // (ContainerType=1) and one equipped guid 0x701 (EquipLocation=MeleeWeapon), - // using the same harness the existing PlayerDescription test uses ... - - Assert.NotNull(table.Get(0x700u)); - Assert.Equal(0u, table.Get(0x700u)!.WeenieClassId); // NOT the ContainerType (1) - Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x701u)!.CurrentlyEquippedLocation); - } -``` -> If the existing PlayerDescription test already builds a parser fixture, reuse its builder; otherwise model this test on the existing `PlayerDescription` registration test in the file. - -- [ ] **Step 2: Run to verify it fails** - -```bash -dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests" -``` -Expected: FAIL — current code sets `WeenieClassId = inv.ContainerType` (1, not 0). - -- [ ] **Step 3: Replace the PD seeding block** - -In `GameEventWiring.cs`, replace the inventory/equipped seeding loops (the `foreach (var inv ...)` and `foreach (var eq ...)` blocks) with: -```csharp - // D.5.4: PlayerDescription is a membership MANIFEST, not the data - // source. Record existence (+ equip slot); CreateObject fills the - // actual weenie data via ObjectTableWiring. (Previously this seeded - // stubs with WeenieClassId = ContainerType, a misuse.) - foreach (var inv in p.Value.Inventory) - items.RecordMembership(inv.Guid); - foreach (var eq in p.Value.Equipped) - items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation); -``` -(`items` is now a `ClientObjectTable` after Task 1; `RecordMembership` from Task 5.) - -- [ ] **Step 4: Run to verify pass + build** - -```bash -dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests" -dotnet build -``` -Expected: PASS; build green. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "feat(D.5.4): PlayerDescription = membership manifest; drop WeenieClassId=ContainerType misuse - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 9: Delete `EnrichItem` + migrate its tests to `Ingest` - -`EnrichItem` is now unused (Task 7 removed its only caller). Remove it and port its remaining contract tests. - -**Files:** -- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` -- Modify: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` - -- [ ] **Step 1: Confirm no callers** - -```bash -grep -rn "EnrichItem" src tests -``` -Expected: hits only in `ClientObjectTable.cs` (definition) and `ClientObjectTableTests.cs` (the old tests). If any *production* caller remains, stop and rewire it to `Ingest` first. - -- [ ] **Step 2: Delete `EnrichItem`** - -Remove the entire `EnrichItem` method from `ClientObjectTable.cs` (the `public bool EnrichItem(...)` block). - -- [ ] **Step 3: Delete/port the EnrichItem tests** - -In `ClientObjectTableTests.cs`, delete `EnrichItem_updatesIconOnExistingStub_andRaisesUpdated`, `EnrichItem_returnsFalse_whenItemUnknown`, `EnrichItem_carriesEffects`, and `EnrichItem_effectsZero_clearsPriorEffects`. Their contracts are already covered by Task 5's `Ingest_*` tests (`Ingest_NewItemWithNoPriorStub_*`, `Ingest_Effects_AssignedUnconditionally_ClearsToZero`, `Ingest_Existing_PatchesInPlace_*`). Keep `UpdateIntProperty_*` tests (the 0x02CE path is unchanged). - -- [ ] **Step 4: Build + test green** - -```bash -dotnet build -dotnet test -``` -Expected: build succeeds (no `EnrichItem` references); full suite PASS. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "refactor(D.5.4): delete EnrichItem (superseded by Ingest merge-upsert) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 10: Retire `_liveEntityInfoByGuid` → resolve from `ClientObjectTable` - -All objects are now in the table, so the redundant Name+ItemType dictionary can go. - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` - -- [ ] **Step 1: Add resolve helpers** - -In `GameWindow.cs`, add two private helpers (near `DescribeLiveEntity`): -```csharp - private AcDream.Core.Items.ItemType LiveItemType(uint guid) => - Objects.Get(guid)?.Type ?? AcDream.Core.Items.ItemType.None; - - private string? LiveName(uint guid) => Objects.Get(guid)?.Name; -``` - -- [ ] **Step 2: Migrate every `_liveEntityInfoByGuid` read** - -Replace each read site (verified locations) with the table lookup: -- target-indicator `entityResolver` (~line 1308-1316): `if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)) rawItemType = (uint)info.ItemType;` → `rawItemType = (uint)LiveItemType(guid);` -- door-cycle diagnostic (~3821): `_liveEntityInfoByGuid.TryGetValue(update.Guid, out var doorInfo) && IsDoorName(doorInfo.Name)` → `IsDoorName(LiveName(update.Guid))` -- picker diagnostic (~11604): same pattern → `rawItemType = (uint)LiveItemType(guid);` -- `isCreature` for SendUse (~11663): `_liveEntityInfoByGuid.TryGetValue(sel, out var info) && (info.ItemType & ...Creature) != 0` → `(LiveItemType(sel) & AcDream.Core.Items.ItemType.Creature) != 0` -- use-radius heuristics #1/#2 (~11905, ~11933): same Creature-bit check → `(LiveItemType(targetGuid) & ...Creature) != 0` -- `IsLiveCreatureTarget` (~12009): keep the `_entitiesByServerGuid.ContainsKey` guard; replace the info lookup with `return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0;` -- useability creature fallback (~12185): `(LiveItemType(guid) & ...Creature) != 0` -- `DescribeLiveEntity` (~12294): `var name = LiveName(guid); if (!string.IsNullOrWhiteSpace(name)) return name!;` - -Ensure `IsDoorName` tolerates a null arg (it takes `string?`; if it doesn't, guard: `LiveName(...) is { } dn && IsDoorName(dn)`). - -- [ ] **Step 3: Delete the dictionary, its record, and its write/remove** - -- Delete `private readonly Dictionary _liveEntityInfoByGuid = new();` (~840). -- Delete the `LiveEntityInfo` record (~857-859). -- Delete the write in `OnLiveEntitySpawnedLocked` (~2720-2724, the `_liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo(...)` block). -- Delete `_liveEntityInfoByGuid.Remove(serverGuid);` in `RemoveLiveEntityByServerGuid` (~3731). - -- [ ] **Step 4: Build + test green** - -```bash -dotnet build -dotnet test -``` -Expected: build succeeds (no `_liveEntityInfoByGuid`/`LiveEntityInfo` references remain — grep to confirm: `grep -rn "_liveEntityInfoByGuid\|LiveEntityInfo" src` returns nothing); full suite PASS. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "refactor(D.5.4): retire _liveEntityInfoByGuid; selection resolves from ClientObjectTable - -The one weenie table now holds every object's name+type, so the redundant -Name+ItemType dictionary is gone (retail: one weenie_object_table). - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 11: `ToolbarController` — guid-filtered re-bind + `ObjectRemoved` - -With all objects ingested, every creature spawn fires `ObjectAdded`. Filter so only shortcut-guid changes re-`Populate`, and clear a slot when its item is removed. - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs` -- Test: `tests/AcDream.App.Tests/...` if an App test project exists; otherwise verify by build + the live run (note in commit). - -- [ ] **Step 1: Add a shortcut-guid filter + replace the subscriptions** - -In `ToolbarController.cs`, replace: -```csharp - repo.ItemAdded += _ => Populate(); // (already renamed to ObjectAdded in Task 1) - repo.ItemPropertiesUpdated += _ => Populate(); -``` -with: -```csharp - // D.5.4: the table now holds ALL objects, so filter to our shortcut guids - // (else every creature spawn re-populates the bar). - repo.ObjectAdded += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); }; - repo.ObjectUpdated += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); }; - repo.ObjectRemoved += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); }; -``` -Add the helper: -```csharp - private bool IsShortcutGuid(uint guid) - { - foreach (var sc in _shortcuts()) - if (sc.ObjectGuid == guid) return true; - return false; - } -``` - -- [ ] **Step 2: Build + test green** - -```bash -dotnet build -dotnet test -``` -Expected: build succeeds; full suite PASS. - -- [ ] **Step 3: Commit** - -```bash -git add -A -git commit -m "perf(D.5.4): toolbar re-binds only on shortcut-guid object changes; clear on remove - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 12: Bookkeeping + final verification + live run - -**Files:** -- Modify: `docs/plans/2026-04-11-roadmap.md`, `docs/architecture/retail-divergence-register.md`, `claude-memory/` (+ `MEMORY.md` index). - -- [ ] **Step 1: Roadmap — mark D.5.4 shipped** - -In `docs/plans/2026-04-11-roadmap.md`, change the `☐ D.5.4` ledger line to `✓ SHIPPED — D.5.4` with a one-paragraph summary (CreateObject canonical merge-upsert, all-objects table, container index, `_liveEntityInfoByGuid` retired, Coldeve blank-icon root fix) and the commit range. - -- [ ] **Step 2: Divergence register** - -In `docs/architecture/retail-divergence-register.md`: delete the enrich-only stopgap row(s) (the behavior is gone). Add a row for the global-event-with-guid-filter consumer model vs. retail's per-object `NoticeRegistrar`, and a row noting the deferred `null_object_table` parent/child pre-queue. - -- [ ] **Step 3: Memory digest** - -If there's a durable lesson (e.g. "retail is two tables, not one — keep render/data split"), add/update a `claude-memory/` note + a one-line `MEMORY.md` index entry. Keep the index line under ~200 chars. - -- [ ] **Step 4: Full build + test** - -```bash -dotnet build -dotnet test -``` -Expected: build succeeds; entire suite PASS. - -- [ ] **Step 5: Live run (visual gate — user confirms)** - -Launch against the local ACE server (per CLAUDE.md "Running the client"): -```powershell -$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" -$env:ACDREAM_LIVE = "1"; $env:ACDREAM_TEST_HOST = "127.0.0.1"; $env:ACDREAM_TEST_PORT = "9000" -$env:ACDREAM_TEST_USER = "testaccount"; $env:ACDREAM_TEST_PASS = "testpassword"; $env:ACDREAM_RETAIL_UI = "1" -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "d54.log" -``` -Acceptance: the toolbar/hotbar now renders icons for items that were NOT in the login inventory snapshot (the Coldeve repro — previously 4/6 blank). The user confirms visually. - -- [ ] **Step 6: Commit bookkeeping** - -```bash -git add -A -git commit -m "docs(D.5.4): roadmap shipped + divergence register + memory - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Self-review notes (author) - -- **Spec coverage:** §4 in-scope items each map to a task — rename (T1), field capture (T2/T3/T4), merge-upsert + RecordMembership (T5), container index (T6), wiring off GameWindow + DeleteObject evict (T7), PD manifest + WeenieClassId fix (T8), EnrichItem delete (T9), `_liveEntityInfoByGuid` retire (T10), toolbar guid-filter (T11), bookkeeping (T12). Out-of-scope items (panels, ViewContents, drag-drop wire, ShortCutManager, null_object_table) are untouched. -- **Type consistency:** `ClientObject`/`ClientObjectTable`/`WeenieData`/`Ingest`/`RecordMembership`/`GetContents`/`ObjectAdded`/`ObjectUpdated`/`ObjectMoved`/`ObjectRemoved`/`Objects`/`Get` are used identically across tasks. -- **Known soft spots flagged inline:** the `ObjectTableWiring` `Wire` test depends on a `WorldSession` test seam that may not exist (Task 7 Step 1 note — fall back to the pure `ToWeenieData` test); `GameEventWiringTests` PD fixture should reuse the file's existing harness (Task 8 Step 1 note). The executor verifies these against the real test files before writing. diff --git a/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md b/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md deleted file mode 100644 index 71a6ac50..00000000 --- a/docs/superpowers/plans/2026-06-20-d2b-drag-drop-spine.md +++ /dev/null @@ -1,752 +0,0 @@ -# Drag-drop spine (Stream B.1) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Complete the widget-level drag-drop machine so an item icon can be picked up, dragged with a cursor-following ghost, hovered slots flip accept/reject, and the drop is dispatched to the owning panel's handler — with a visible toolbar stub proving the chain this session. - -**Architecture:** `UiRoot` already holds the device-level drag state machine. We add (1) a typed `ItemDragPayload` + `IItemListDragHandler`, (2) two `UiElement` virtuals (`GetDragPayload`/`GetDragGhost`) so `UiRoot` stays item-agnostic, (3) drag/drop event handling + an accept/reject overlay on the cell (`UiItemSlot`), which delegates the accept decision + dispatch UP to its parent `UiItemList`'s registered handler, and (4) a logging toolbar stub handler (wire deferred to B.2). Spec: `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md`. - -**Tech Stack:** C# / .NET 10, xUnit (`[Fact]`, `Assert.*`), the `src/AcDream.App/UI/` retained-mode toolkit. `InternalsVisibleTo("AcDream.App.Tests")` is set — internal members are test-visible. Tests are pure-logic (no GL); the cursor ghost render is visual-only (no unit test), confirmed by the user at the end. - ---- - -## File Structure - -**Create:** -- `src/AcDream.App/UI/ItemDragPayload.cs` — `ItemDragSource` enum + `ItemDragPayload` record (the drag snapshot). -- `src/AcDream.App/UI/IItemListDragHandler.cs` — the panel-handler interface. -- `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` — toolkit-level conformance. - -**Modify:** -- `src/AcDream.App/UI/UiItemList.cs` — `DragHandler` property + `RegisterDragHandler`. -- `src/AcDream.App/UI/UiElement.cs` — `GetDragPayload()` + `GetDragGhost()` virtuals. -- `src/AcDream.App/UI/UiItemSlot.cs` — `SlotIndex`, `SourceKind`, accept/reject overlay, the two overrides, drag/drop `OnEvent` cases, `FindList`, MouseDown→Click use move. -- `src/AcDream.App/UI/UiRoot.cs` — `BeginDrag` payload pull + cancel; cursor ghost in `Draw`. -- `src/AcDream.App/UI/Layout/ToolbarController.cs` — implement `IItemListDragHandler`, register on each slot list, set `SlotIndex`/`SourceKind`, stub `HandleDropRelease`. -- `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` — fix `Click_emitsUseForBoundItem` (send Click not MouseDown), add registration tests. -- `docs/architecture/retail-divergence-register.md` — add **AP-47** (Task 3) + **TS-33** (Task 4). - -**Build/test commands** (run from repo root): -- Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -- Test a class: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` -- Full app tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` - -**Every commit ends with the trailer** `Co-Authored-By: Claude Opus 4.8 (1M context) ` (per CLAUDE.md). - ---- - -## Task 1: Payload types + handler interface + UiItemList registration - -**Files:** -- Create: `src/AcDream.App/UI/ItemDragPayload.cs` -- Create: `src/AcDream.App/UI/IItemListDragHandler.cs` -- Modify: `src/AcDream.App/UI/UiItemList.cs` -- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs`: - -```csharp -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class DragDropSpineTests -{ - // A spy handler used across the spine tests. - private sealed class SpyHandler : IItemListDragHandler - { - public bool AcceptResult = true; - public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver; - public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop; - - public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p) - { LastOver = (list, cell, p); return AcceptResult; } - - public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p) - { LastDrop = (list, cell, p); } - } - - [Fact] - public void Payload_holdsAllFields() - { - var src = new UiItemSlot(); - var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src); - Assert.Equal(0x5001u, p.ObjId); - Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind); - Assert.Equal(3, p.SourceSlot); - Assert.Same(src, p.SourceCell); - } - - [Fact] - public void UiItemList_registerDragHandler_roundtrips() - { - var list = new UiItemList(_ => (0u, 0, 0)); - Assert.Null(list.DragHandler); - var h = new SpyHandler(); - list.RegisterDragHandler(h); - Assert.Same(h, list.DragHandler); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` -Expected: FAIL to compile — `ItemDragPayload`, `ItemDragSource`, `IItemListDragHandler`, `UiItemList.DragHandler`, `RegisterDragHandler` not defined. - -- [ ] **Step 3: Create the payload + enum** - -Create `src/AcDream.App/UI/ItemDragPayload.cs`: - -```csharp -namespace AcDream.App.UI; - -/// -/// Where a dragged item came from — the retail InqDropIconInfo flag -/// distinction (flags & 0xE == 0 fresh-from-inventory vs -/// flags & 4 within-list reorder) expressed as a typed enum. The drop -/// handler maps SourceKind + target back to the fresh-vs-reorder decision. -/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533. -/// -public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground } - -/// -/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving -/// mid-drag can't mutate it under us). Port of retail's m_dragElement + -/// InqDropIconInfo out-params (objId/container/flags, decomp 230533). -/// SourceContainer is intentionally NOT stored: the handler resolves the -/// LIVE container via ClientObjectTable.Get(ObjId).ContainerId at drop — the -/// same container id retail reads off the dragged element, single source of truth. -/// -public sealed record ItemDragPayload( - uint ObjId, // dragged weenie guid (retail itemID, +0x5FC) - ItemDragSource SourceKind, // what kind of slot it left - int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged) - UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost -``` - -- [ ] **Step 4: Create the handler interface** - -Create `src/AcDream.App/UI/IItemListDragHandler.cs`: - -```csharp -namespace AcDream.App.UI; - -/// -/// A panel controller implements this and registers itself on each of its -/// s. Port of retail's m_dragHandler vtable -/// (RegisterItemListDragHandler, decomp 230461; confirmed acclient -/// 0x004a539e + the gmToolbarUI block 0x004bdd89). -/// decides the accept/reject OVERLAY only (advisory). -/// is authoritative — it performs the action, or -/// no-ops to reject. -/// -public interface IItemListDragHandler -{ - /// True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red). - bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); - - /// Perform the drop (issue the per-panel wire action), or no-op to reject. - void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); -} -``` - -- [ ] **Step 5: Add registration to UiItemList** - -In `src/AcDream.App/UI/UiItemList.cs`, add inside the class (e.g. just after the `SpriteResolve` property at ~line 25): - -```csharp - /// The drag handler this list routes drops to (a panel controller). - /// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler. - public IItemListDragHandler? DragHandler { get; private set; } - - /// Register the panel's drag handler on this list. Retail: - /// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461). - public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler; -``` - -- [ ] **Step 6: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` -Expected: PASS (2 tests). - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.App/UI/ItemDragPayload.cs src/AcDream.App/UI/IItemListDragHandler.cs src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs -git commit -m "feat(ui): D.5.3/B.1 — drag payload + handler interface + UiItemList registration" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: UiElement drag hooks + UiItemSlot drag/drop + overlay - -**Files:** -- Modify: `src/AcDream.App/UI/UiElement.cs` -- Modify: `src/AcDream.App/UI/UiItemSlot.cs` -- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` (fix the one MouseDown→Click test) -- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add cases) - -- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class) - -```csharp - // ── UiItemSlot drag-source payload/ghost ──────────────────────────────── - [Fact] - public void GetDragPayload_emptyCell_isNull() - => Assert.Null(new UiItemSlot().GetDragPayload()); - - [Fact] - public void GetDragPayload_boundCell_snapshotsFields() - { - var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar }; - cell.SetItem(0x5001u, 0x99u); - var p = Assert.IsType(cell.GetDragPayload()); - Assert.Equal(0x5001u, p.ObjId); - Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind); - Assert.Equal(4, p.SourceSlot); - Assert.Same(cell, p.SourceCell); - } - - [Fact] - public void GetDragGhost_emptyCell_isNull() - => Assert.Null(new UiItemSlot().GetDragGhost()); - - [Fact] - public void GetDragGhost_boundCell_returnsIconTuple() - { - var cell = new UiItemSlot { Width = 32, Height = 32 }; - cell.SetItem(0x5001u, 0x99u); - var g = cell.GetDragGhost(); - Assert.NotNull(g); - Assert.Equal(0x99u, g!.Value.tex); - Assert.Equal(32, g.Value.w); - Assert.Equal(32, g.Value.h); - } - - // ── cell drop-target: DragEnter overlay + DropReleased dispatch ────────── - private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler() - { - var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless - var h = new SpyHandler(); - list.RegisterDragHandler(h); - return (list, list.Cell, h); - } - - private static ItemDragPayload SomePayload() - => new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot()); - - [Fact] - public void DragEnter_setsAcceptOverlay_whenHandlerAccepts() - { - var (_, cell, h) = ListWithHandler(); - h.AcceptResult = true; - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); - Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual); - } - - [Fact] - public void DragEnter_setsRejectOverlay_whenHandlerRejects() - { - var (_, cell, h) = ListWithHandler(); - h.AcceptResult = false; - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); - Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual); - } - - [Fact] - public void DragOver_resetsOverlayToNeutral() - { - var (_, cell, h) = ListWithHandler(); - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload())); - Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual); - } - - [Fact] - public void DropReleased_accepted_dispatchesToHandler() - { - var (list, cell, h) = ListWithHandler(); - var p = SomePayload(); - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p)); - Assert.NotNull(h.LastDrop); - Assert.Same(list, h.LastDrop!.Value.list); - Assert.Same(cell, h.LastDrop.Value.cell); - Assert.Same(p, h.LastDrop.Value.payload); - } - - [Fact] - public void DropReleased_notAccepted_skipsDispatch() - { - var (_, cell, h) = ListWithHandler(); - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload())); - Assert.Null(h.LastDrop); - } -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` -Expected: FAIL to compile — `GetDragPayload`/`GetDragGhost` not on `UiItemSlot`, `SlotIndex`/`SourceKind`/`DragAcceptVisual`/`DragAcceptState` not defined. - -- [ ] **Step 3: Add the two virtuals to UiElement** - -In `src/AcDream.App/UI/UiElement.cs`, add to the "Virtual overrides" region (e.g. after `OnEvent` at ~line 204): - -```csharp - /// The data this element carries when a drag begins. - /// pulls this on drag-promote; a NULL return CANCELS the drag (retail: - /// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable. - public virtual object? GetDragPayload() => null; - - /// The texture paints at the cursor while this element - /// is the drag source: (GL handle, width, height). Null = no ghost. Keeps - /// item-agnostic. Retail analog: m_dragIcon (decomp 229738). - public virtual (uint tex, int w, int h)? GetDragGhost() => null; -``` - -- [ ] **Step 4: Add drag state + overrides + event handling to UiItemSlot** - -In `src/AcDream.App/UI/UiItemSlot.cs`: - -(a) Add fields/props after `SourceKind` area — insert after the `IconTexture` property (~line 22): - -```csharp - /// This cell's own index within its panel (0..17 toolbar; container slot - /// for inventory). Distinct from (the 1–9 label, -1 on the - /// bottom row). Set by the controller; used as the drag payload's SourceSlot and to - /// identify the drop TARGET slot. - public int SlotIndex { get; set; } = -1; - - /// What kind of slot this is, for the drag payload (retail InqDropIconInfo - /// flags). Controller overrides; default Inventory. - public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory; - - /// Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9, - /// state id 0x10000041). Configurable; guard id != 0 before resolving. - public uint DragAcceptSprite { get; set; } = 0x060011F9u; - /// Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8, - /// state id 0x10000040). - public uint DragRejectSprite { get; set; } = 0x060011F8u; - - /// Accept/reject overlay state while a drag hovers this cell. - public enum DragAcceptState { None, Accept, Reject } - private DragAcceptState _dragAccept = DragAcceptState.None; - /// Current overlay state — internal so unit tests can assert it (InternalsVisibleTo). - internal DragAcceptState DragAcceptVisual => _dragAccept; -``` - -(b) Add the two overrides (anywhere in the class, e.g. after `Clear()`): - -```csharp - /// - public override object? GetDragPayload() - => ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null; - - /// - public override (uint tex, int w, int h)? GetDragGhost() - => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null; - - /// Walk up to the containing (the drop handler owner). - private UiItemList? FindList() - { - UiElement? e = Parent; - while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; } - return null; - } -``` - -(c) Replace the `OnEvent` method (currently MouseDown→Clicked) with: - -```csharp - /// - public override bool OnEvent(in UiEvent e) - { - switch (e.Type) - { - // Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a - // drag (press + >3px move) does NOT also use the item. UiRoot suppresses the - // post-drag Click (UiRoot.cs FinishDrag returns before the Click emit). - case UiEventType.MouseDown: - return true; // consume the press; no use here - case UiEventType.Click: - Clicked?.Invoke(); - return true; - - case UiEventType.DragBegin: - return true; // we're the source; payload already pulled by UiRoot - - case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler - _dragAccept = (FindList() is { DragHandler: { } h } list - && e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p)) - ? DragAcceptState.Accept : DragAcceptState.Reject; - return true; - - case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral - _dragAccept = DragAcceptState.None; - return true; - - case UiEventType.DropReleased: - _dragAccept = DragAcceptState.None; - if (e.Data0 == 1 // accepted = dropped on a DIFFERENT element (skips self/empty) - && FindList() is { DragHandler: { } dh } dl - && e.Payload is ItemDragPayload dp) - dh.HandleDropRelease(dl, this, dp); - return true; - } - return false; - } -``` - -(d) In `OnDraw`, add the accept/reject overlay AFTER the digit-overlay block (at the very end of `OnDraw`): - -```csharp - // Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40). - // Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder - // with a non-zero GL handle (feedback_ui_resolve_zero_magenta). - if (_dragAccept != DragAcceptState.None && SpriteResolve is not null) - { - uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite; - if (id != 0) - { - var (tex, _, _) = SpriteResolve(id); - if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - } -``` - -- [ ] **Step 5: Fix the existing controller test that sent MouseDown** - -In `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs`, in `Click_emitsUseForBoundItem`, change the event from `MouseDown` to `Click` (the test name already says "Click"): - -```csharp - // Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation. - slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click)); -``` - -- [ ] **Step 6: Run to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine|FullyQualifiedName~ToolbarController|FullyQualifiedName~UiItemSlot"` -Expected: PASS (the new DragDropSpine cases, the fixed ToolbarController test, and all UiItemSlot tests). - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.App/UI/UiElement.cs src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs -git commit -m "feat(ui): D.5.3/B.1 — UiItemSlot drag source + drop target + accept/reject overlay" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: UiRoot payload injection + cursor ghost (+ AP-47) - -**Files:** -- Modify: `src/AcDream.App/UI/UiRoot.cs` -- Modify: `docs/architecture/retail-divergence-register.md` -- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` (add full-chain cases) - -- [ ] **Step 1: Write the failing tests** (append to `DragDropSpineTests.cs`, inside the class) - -```csharp - // ── Full UiRoot chain: arming + use-vs-drag ───────────────────────────── - // A bound, hit-testable slot inside a list, sized for the hit-test. - private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId) - { - var root = new UiRoot { Width = 800, Height = 600 }; - var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; - // Tests don't run OnDraw (which sizes the cell), so size the cell explicitly. - list.Cell.Width = 32; list.Cell.Height = 32; - if (itemId != 0) list.Cell.SetItem(itemId, 0x99u); - root.AddChild(list); - return (root, list, list.Cell); - } - - [Fact] - public void BeginDrag_arms_whenPayloadNonNull() - { - var (root, _, cell) = RootWithBoundSlot(0x5001u); - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseMove(20, 10); // >3px → promote to drag - Assert.Same(cell, root.DragSource); - Assert.IsType(root.DragPayload); - } - - [Fact] - public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot() - { - var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseMove(20, 10); - Assert.Null(root.DragSource); // never armed - } - - [Fact] - public void Click_withoutDrag_firesUse() - { - var (root, _, cell) = RootWithBoundSlot(0x5001u); - bool used = false; - cell.Clicked = () => used = true; - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted - Assert.True(used); - } - - [Fact] - public void CompletedDrag_doesNotFireUse() - { - var (root, _, cell) = RootWithBoundSlot(0x5001u); - bool used = false; - cell.Clicked = () => used = true; - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseMove(20, 10); // promote to drag - root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click - Assert.False(used); - } -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` -Expected: FAIL — `BeginDrag_doesNotArm_whenPayloadNull_emptySlot` fails (current `BeginDrag` arms unconditionally with `payload: null`, so `DragSource` is non-null), and `CompletedDrag_doesNotFireUse` may behave wrong because a null-payload drag would still arm. - -- [ ] **Step 3: Change BeginDrag to pull the payload + cancel when null** - -In `src/AcDream.App/UI/UiRoot.cs`, replace the `BeginDrag` method (~line 450): - -```csharp - private void BeginDrag(UiElement source) - { - // Pull the payload from the source; a null payload (e.g. an empty item cell) - // CANCELS the drag — retail's ItemList_BeginDrag only arms an occupied cell. - var payload = source.GetDragPayload(); - if (payload is null) { _dragCandidate = false; return; } - - DragSource = source; - DragPayload = payload; - var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload); - source.OnEvent(in e); - } -``` - -Update the single call site (~line 188 in `OnMouseMove`) from `BeginDrag(Captured, payload: null);` to: - -```csharp - BeginDrag(Captured); -``` - -- [ ] **Step 4: Add the cursor ghost to Draw** - -In `src/AcDream.App/UI/UiRoot.cs`, replace the `Draw` method (~line 133) so it paints the ghost in the overlay layer, and add the helper + constant: - -```csharp - public void Draw(UiRenderContext ctx) - { - DrawSelfAndChildren(ctx); - // Open popups/menus + the drag ghost draw ON TOP of the whole tree (overlay layer). - ctx.BeginOverlayLayer(); - DrawOverlays(ctx); - DrawDragGhost(ctx); - ctx.EndOverlayLayer(); - } - - /// Translucency of the cursor-following drag ghost. AP-47: we reuse the - /// item's full composited icon at this alpha rather than retail's dedicated - /// underlay-less m_pDragIcon. - private const float GhostAlpha = 0.6f; - - /// Paint the drag ghost at the cursor. The texture comes from the source - /// element's so UiRoot stays item-agnostic; the - /// ghost is NOT a tree element, so it never intercepts hit-tests. - private void DrawDragGhost(UiRenderContext ctx) - { - if (DragSource?.GetDragGhost() is not { } g || g.tex == 0) return; - ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h, - 0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha)); - } -``` - -- [ ] **Step 5: Run to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` -Expected: PASS (all DragDropSpine tests). - -- [ ] **Step 6: Add the AP-47 divergence row** - -In `docs/architecture/retail-divergence-register.md`: bump the AP section header count `## 3. Documented approximation (AP) — 42 rows` → `— 43 rows`, and append this row at the END of the AP table (after the `AP-46` row, before the section's `---`): - -```markdown -| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay) | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost shows the opaque type-default underlay backing rather than retail's underlay-less translucent copy — a subtle look difference while dragging, no functional effect | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 | -``` - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.App/UI/UiRoot.cs docs/architecture/retail-divergence-register.md tests/AcDream.App.Tests/UI/DragDropSpineTests.cs -git commit -m "feat(ui): D.5.3/B.1 — UiRoot payload injection + cursor drag ghost (AP-47)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: Toolbar stub handler + wiring (+ TS-33 + registration tests) - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs` -- Modify: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` -- Modify: `docs/architecture/retail-divergence-register.md` - -- [ ] **Step 1: Write the failing tests** (append to `ToolbarControllerTests.cs`, inside the class) - -```csharp - // ── B.1: drag-drop spine wiring ────────────────────────────────────────── - - /// Bind registers the controller as each slot list's drag handler and - /// stamps every cell's SlotIndex + SourceKind=ShortcutBar. - [Fact] - public void Bind_registersDragHandler_andStampsSlots() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - - var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); - - for (int i = 0; i < Row1.Length; i++) - { - Assert.Same(ctrl, slots[Row1[i]].DragHandler); - Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex); - Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind); - } - // Bottom row slots are indices 9..17. - for (int j = 0; j < Row2.Length; j++) - Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex); - } - - /// OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible) - /// is Stream B.2; the stub accepts any non-empty payload. - [Fact] - public void OnDragOver_acceptsRealItem() - { - var (layout, slots, _) = FakeToolbar(); - var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); - - var list = slots[Row1[0]]; - var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot()); - Assert.True(ctrl.OnDragOver(list, list.Cell, payload)); - } - - /// HandleDropRelease is a logging stub (TS-33): it must not throw and must not - /// mutate the target slot (no wire / no store yet). - [Fact] - public void HandleDropRelease_isInertStub() - { - var (layout, slots, _) = FakeToolbar(); - var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); - - var list = slots[Row1[2]]; - var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot()); - var ex = Record.Exception(() => ctrl.HandleDropRelease(list, list.Cell, payload)); - Assert.Null(ex); - Assert.Equal(0u, list.Cell.ItemId); // unchanged — inert stub - } -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` -Expected: FAIL to compile — `ToolbarController` doesn't implement `IItemListDragHandler` (`OnDragOver`/`HandleDropRelease`), `DragHandler` not registered. - -- [ ] **Step 3: Make ToolbarController implement the handler + wire registration** - -In `src/AcDream.App/UI/Layout/ToolbarController.cs`: - -(a) Change the class declaration: - -```csharp -public sealed class ToolbarController : IItemListDragHandler -``` - -(b) In the constructor, extend the slot loop (currently `_slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; if (_slots[i] is { } list) WireClick(list);`) to also register the handler + stamp the cell: - -```csharp - for (int i = 0; i < SlotIds.Length; i++) - { - _slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; - if (_slots[i] is { } list) - { - WireClick(list); - // B.1 drag-drop spine: this controller is the drop handler for every - // toolbar slot list; each cell knows its slot index + that it's a - // shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler). - list.RegisterDragHandler(this); - list.Cell.SlotIndex = i; - list.Cell.SourceKind = AcDream.App.UI.ItemDragSource.ShortcutBar; - } - } -``` - -(c) Add the interface implementation (e.g. at the end of the class): - -```csharp - // ── IItemListDragHandler (B.1 spine) ───────────────────────────────────── - // Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list. - // The accept/reject gate (IsShortcutEligible) and the AddShortcut/RemoveShortcut - // wire are Stream B.2; this stub accepts any real item and LOGS the drop (TS-33). - - /// - public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - => payload.ObjId != 0; - - /// - public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - { - // TS-33: no wire yet. Stream B.2 replaces this with the ShortcutStore mutation + - // AddShortcut 0x019C / RemoveShortcut 0x019D sends (gmToolbarUI::HandleDropRelease :197971). - Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} from {payload.SourceKind} " + - $"slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}"); - } -``` - -(d) Confirm `using AcDream.App.UI;` is present at the top (it is). The `ItemDragSource`/`ItemDragPayload`/`UiItemSlot`/`UiItemList`/`IItemListDragHandler` types are all in `AcDream.App.UI`. - -- [ ] **Step 4: Run to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` -Expected: PASS (the 3 new B.1 tests + all existing ToolbarController tests, including the fixed Click test). - -- [ ] **Step 5: Add the TS-33 divergence row** - -In `docs/architecture/retail-divergence-register.md`: bump the TS section header count `## 4. Temporary stopgap (TS) — 31 rows` → `— 32 rows`, and append this row at the END of the TS table (after the `TS-32` row, before the section's `---`): - -```markdown -| TS-33 | Toolbar `IItemListDragHandler.HandleDropRelease` is a logging stub — no `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire, no mutable `ShortcutStore`; a drop onto the bar logs and does nothing | `src/AcDream.App/UI/Layout/ToolbarController.cs` (`HandleDropRelease`) | The B.1 spine ships the drag machine (payload/ghost/overlay/dispatch) independent of the wire; the shortcut store + add/remove/reorder sends are Stream B.2, which needs the inventory window as a drag source too | A player dragging onto/within the hotbar sees the ghost + accept overlay but the drop is inert until B.2 — add/remove/reorder don't persist | `gmToolbarUI::HandleDropRelease` acclient_2013_pseudo_c.txt:197971; `AddShortcut`/`RemoveShortcut` 0x019C/0x019D | -``` - -- [ ] **Step 6: Run the FULL app test suite (no regressions)** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` -Expected: build green; all tests pass. - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.App/UI/Layout/ToolbarController.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs docs/architecture/retail-divergence-register.md -git commit -m "feat(ui): D.5.3/B.1 — toolbar drag handler stub + spine wiring (TS-33)" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Final verification - -- [ ] **Full solution build + test:** `dotnet build AcDream.slnx -c Debug` and `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` — both green. -- [ ] **Self-check the acceptance criteria** in the spec §9 (UiRoot item-agnostic; AP-47 + TS-33 added; counts bumped). -- [ ] **Hand back for visual verification** (the user runs the client): with `ACDREAM_RETAIL_UI=1`, in-world — grab a hotbar item and drag: a translucent ghost follows the cursor; the hovered slot shows the accept (green) frame; on release over another slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item stays put; a plain click still USES the item. (Launch per CLAUDE.md "Running the client" — build green first.) -- [ ] **Update memory** if a durable lesson emerged; note in `docs/ISSUES.md` / roadmap only if scope shifted. -``` diff --git a/docs/superpowers/plans/2026-06-20-d2b-inventory-grid-mount.md b/docs/superpowers/plans/2026-06-20-d2b-inventory-grid-mount.md deleted file mode 100644 index 69fbefb9..00000000 --- a/docs/superpowers/plans/2026-06-20-d2b-inventory-grid-mount.md +++ /dev/null @@ -1,390 +0,0 @@ -# D.2b B-Grid Implementation Plan (inventory sub-window mount + UiItemList grid) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make `LayoutImporter.Import(0x21000023)` produce the full nested inventory frame and give `UiItemList` an N-cell grid, so F12 shows the real inventory window. - -**Architecture:** A ~4-line mount in `LayoutImporter.Resolve` attaches a base element's resolved children to a childless, media-less inheritor (the three gmInventoryUI panels nest via the existing `BaseElement`+`BaseLayoutId` path). `UiItemList` gains a column-count + cell-pitch layout, with single-cell (toolbar) behavior preserved by defaults. `GameWindow` swaps the Sub-phase A placeholder for the real import. - -**Tech Stack:** C# .NET 10, xUnit, the `AcDream.App.UI` toolkit + `LayoutImporter`, `DatCollection`. - -**Spec:** `docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md` - ---- - -## File Structure - -- **Modify** `src/AcDream.App/UI/UiItemList.cs` — add `Columns`/`CellWidth`/`CellHeight` + grid layout (`CellOffset` helper, `LayoutCells`). -- **Modify** `src/AcDream.App/UI/Layout/LayoutImporter.cs` — `ShouldMountBaseChildren` predicate + the `Resolve` mount. -- **Modify** `src/AcDream.App/Rendering/GameWindow.cs` — swap the placeholder for `Import(0x21000023)`. -- **Create** `tests/AcDream.App.Tests/UI/UiItemListGridTests.cs` — grid layout tests. -- **Create** `tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs` — mount predicate tests. - -(`InternalsVisibleTo` from `AcDream.App` → `AcDream.App.Tests` is already configured, so `internal static` helpers are testable — see `UiItemSlot.DragAcceptVisual`.) - ---- - -## Task 1: `UiItemList` grid mode - -**Files:** -- Modify: `src/AcDream.App/UI/UiItemList.cs` -- Test: `tests/AcDream.App.Tests/UI/UiItemListGridTests.cs` (create) - -- [ ] **Step 1: Write the failing tests** - -Create `tests/AcDream.App.Tests/UI/UiItemListGridTests.cs`: - -```csharp -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class UiItemListGridTests -{ - [Fact] - public void CellOffset_RowMajor() - { - Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 36, 36)); - Assert.Equal((72f, 0f), UiItemList.CellOffset(2, 3, 36, 36)); - Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1 - } - - [Fact] - public void GridMode_PositionsCellsInColumns() - { - var list = new UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 }; - list.Flush(); // drop the ctor's default cell - for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot()); - - var c4 = list.GetItem(4)!; - Assert.Equal(36f, c4.Left); - Assert.Equal(36f, c4.Top); - Assert.Equal(36f, c4.Width); - Assert.Equal(36f, c4.Height); - } - - [Fact] - public void FillMode_SizesSingleCellToList() - { - // CellWidth defaults to 0 = "fill the list" (single-cell toolbar legacy). - var list = new UiItemList { Width = 36, Height = 36 }; - list.Flush(); - list.AddItem(new UiItemSlot()); - - var c = list.Cell; - Assert.Equal(0f, c.Left); - Assert.Equal(0f, c.Top); - Assert.Equal(36f, c.Width); - Assert.Equal(36f, c.Height); - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListGridTests"` -Expected: FAIL — compile error, `'UiItemList' does not contain a definition for 'Columns'` / `'CellOffset'`. - -- [ ] **Step 3: Implement grid mode** - -In `src/AcDream.App/UI/UiItemList.cs`, replace the `AddItem` method and the `OnDraw` method: - -```csharp - public void AddItem(UiItemSlot cell) - { - cell.SpriteResolve ??= SpriteResolve; - _cells.Add(cell); - AddChild(cell); - LayoutCells(); - } -``` - -```csharp - protected override void OnDraw(UiRenderContext ctx) - { - // The factory sets Width/Height AFTER construction, so re-layout each frame: - // fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells. - LayoutCells(); - } -``` - -Then add these members to the class (e.g. after the `Cell` property): - -```csharp - /// Grid columns (row-major). 1 = single column. Ignored in fill mode. - public int Columns { get; set; } = 1; - - /// Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes - /// to the whole list (the toolbar single-slot legacy). Set >0 (with CellHeight) for a grid. - public float CellWidth { get; set; } - /// Fixed cell height in grid mode (pairs with CellWidth). - public float CellHeight { get; set; } - - /// Row-major pixel offset of cell in a grid of - /// columns at the given cell pitch. - internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH) - { - int col = index % columns, row = index / columns; - return (col * cellW, row * cellH); - } - - /// Position every cell per the current mode: fill (CellWidth<=0) sizes the single - /// cell to the list; grid (CellWidth>0) tiles cells row-major at the cell pitch. - private void LayoutCells() - { - if (CellWidth <= 0f) - { - if (_cells.Count > 0) - { - var c = _cells[0]; - c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height; - } - return; - } - int cols = Columns < 1 ? 1 : Columns; - for (int i = 0; i < _cells.Count; i++) - { - var (x, y) = CellOffset(i, cols, CellWidth, CellHeight); - var cell = _cells[i]; - cell.Left = x; cell.Top = y; cell.Width = CellWidth; cell.Height = CellHeight; - } - } -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListGridTests"` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListGridTests.cs -git commit -m "feat(ui): D.2b-B — UiItemList N-cell grid mode - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: Sub-window mount in `LayoutImporter.Resolve` - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/LayoutImporter.cs` (add `ShouldMountBaseChildren`; restructure `Resolve`) -- Test: `tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs` (create) - -- [ ] **Step 1: Write the failing tests** - -Create `tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs`: - -```csharp -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI; - -public class LayoutImporterMountTests -{ - [Fact] - public void Mounts_ChildlessMediaLessInheritor_WithContentfulBase() - => Assert.True(LayoutImporter.ShouldMountBaseChildren(derivedChildCount: 0, derivedMediaCount: 0, baseChildCount: 5)); - - [Fact] - public void DoesNotMount_WhenDerivedHasOwnMedia() // close button / title - => Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 1, 5)); - - [Fact] - public void DoesNotMount_WhenDerivedHasOwnChildren() - => Assert.False(LayoutImporter.ShouldMountBaseChildren(2, 0, 5)); - - [Fact] - public void DoesNotMount_WhenBaseIsChildless() // vitals/chat/toolbar style prototypes - => Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 0, 0)); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~LayoutImporterMountTests"` -Expected: FAIL — compile error, `'LayoutImporter' does not contain a definition for 'ShouldMountBaseChildren'`. - -- [ ] **Step 3: Add the predicate** - -In `src/AcDream.App/UI/Layout/LayoutImporter.cs`, add this method just above the private `Resolve` method (after the `Import` method, in the "Inheritance resolution" region): - -```csharp - /// True when a pure-container leaf should inherit its base's subtree (the - /// gmInventoryUI sub-window mount): the derived element has no own children, no own - /// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors - /// (close button / title), elements with their own children, and childless style - /// prototypes (vitals/chat/toolbar text). - internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount) - => derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0; -``` - -- [ ] **Step 4: Wire the mount into `Resolve`** - -In `src/AcDream.App/UI/Layout/LayoutImporter.cs`, replace the entire body of the private `Resolve` method: - -```csharp - private static ElementInfo Resolve( - DatCollection dats, - ElementDesc d, - HashSet<(uint layoutId, uint elementId)> baseChain) - { - // Read this element's own fields + media (no inheritance, no children yet). - var self = ToInfo(d); - var result = self; - List? baseChildren = null; - - // Apply BaseElement / BaseLayoutId inheritance if present. - if (d.BaseElement != 0 && d.BaseLayoutId != 0 - && baseChain.Add((d.BaseLayoutId, d.BaseElement))) - { - var baseLd = dats.Get(d.BaseLayoutId); - var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement); - if (baseDesc is not null) - { - // Recurse the base chain (already guarded by the HashSet add above). - var baseInfo = Resolve(dats, baseDesc, baseChain); - // Derived fields override the base; children are attached below. - result = ElementReader.Merge(baseInfo, self); - baseChildren = baseInfo.Children; // capture for the sub-window mount - } - } - - // Resolve + attach children. Each child gets a FRESH base-chain set: - // the cycle guard is per-element, not shared across siblings. - foreach (var kv in d.Children) - result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>())); - - // Sub-window mount: a pure-container leaf (no own children, no own media) that inherits - // from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels - // (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window); - // inert for media-bearing inheritors (close button/title) and childless style prototypes - // (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec. - if (baseChildren is not null - && ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count)) - result.Children.AddRange(baseChildren); - - return result; - } -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~LayoutImporterMountTests"` -Expected: PASS (4 tests). - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/Layout/LayoutImporter.cs tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs -git commit -m "feat(ui): D.2b-B — sub-window mount (inheritor attaches base subtree) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: Swap the placeholder for the real import (`GameWindow`) - -No unit tests — GL-coupled, runs only with `ACDREAM_RETAIL_UI=1`; verified by build + the visual check in Task 4. - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the Sub-phase A placeholder block in `_options.RetailUi`) - -- [ ] **Step 1: Replace the placeholder mount** - -In `src/AcDream.App/Rendering/GameWindow.cs`, find the Sub-phase A placeholder block: - -```csharp - // Phase D.2b-A — placeholder inventory window. Starts HIDDEN; F12 - // (InputAction.ToggleInventoryPanel) reveals it via the UiRoot window - // manager. Throwaway scaffolding: Sub-phase B replaces the body with the - // real gmInventoryUI (0x21000023) nested layout, keeping the same name. - var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) - { - Left = 220, Top = 120, Width = 320, Height = 400, - Visible = false, - }; - _uiHost.Root.AddChild(inventoryWindow); - _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow); - Console.WriteLine("[D.2b-A] placeholder inventory window registered (F12 toggles)."); -``` - -Replace it with the real import: - -```csharp - // Phase D.2b-B — the real inventory window from LayoutDesc 0x21000023 (gmInventoryUI), - // via the LayoutImporter. The sub-window mount pulls in the nested paperdoll/backpack/ - // 3D-items panels (Type-0 leaves inheriting BaseLayoutId 0x21000024/22/21). Starts - // HIDDEN; F12 toggles it via the window manager. Position is the dat's own (X=500,Y=138). - AcDream.App.UI.Layout.ImportedLayout? invLayout; - lock (_datLock) - invLayout = AcDream.App.UI.Layout.LayoutImporter.Import( - _dats!, 0x21000023u, ResolveChrome, vitalsDatFont); - if (invLayout is not null) - { - var inventoryWindow = invLayout.Root; - inventoryWindow.Visible = false; - inventoryWindow.Anchors = AcDream.App.UI.AnchorEdges.None; // user-positioned - inventoryWindow.Draggable = true; - _uiHost.Root.AddChild(inventoryWindow); - _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow); - Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); - } - else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found."); -``` - -(`_dats`, `_datLock`, `ResolveChrome`, and `vitalsDatFont` are all in scope in this block — the vitals/chat imports above use the same.) - -- [ ] **Step 2: Build to verify it compiles** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Expected: Build succeeded, 0 errors. - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(ui): D.2b-B — F12 shows the real gmInventoryUI (0x21000023) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: Regression guard + full verification - -- [ ] **Step 1: Run the full test suite (regression guard)** - -Run: `dotnet test` -Expected: PASS — full suite green (2757 baseline + 3 grid + 4 mount-predicate = ~2764). The existing `LayoutImporter` / vitals / chat / toolbar tests passing confirms the mount is inert for those windows (their bases are childless prototypes). - -- [ ] **Step 2: Hand off for visual verification** - -Launch with `ACDREAM_RETAIL_UI=1` (standard launch in CLAUDE.md) and confirm with the user: -- F12 now shows the **real nested inventory frame** (outer chrome + backpack strip on the right + a 3D-items area + an empty paperdoll panel), not the blank placeholder. -- F12 again hides it; clicking it raises it; it doesn't toggle while chat is focused (Sub-phase A behavior intact). -- **Vitals, chat, and toolbar look unchanged** (the mount regression guard, visually). - -Watch the launch log for `[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).` and the absence of importer errors. - -Do NOT mark B-Grid done until the user confirms the visual. - ---- - -## Self-Review - -**Spec coverage:** -- Sub-window mount (spec §4) → Task 2 (`ShouldMountBaseChildren` + `Resolve`). ✓ -- `UiItemList` grid mode (spec §5) → Task 1. ✓ -- Placeholder → real import swap (spec §9) → Task 3. ✓ -- Grid + mount-predicate tests (spec §6) → Tasks 1–2. ✓ -- Regression guard, vitals/chat/toolbar unchanged (spec §6/§8) → Task 4 Step 1 (suite) + Step 2 (visual). ✓ -- No divergence row (spec §7) → nothing to add. ✓ -- Visual acceptance (spec §8) → Task 4 Step 2. ✓ - -**Placeholder scan:** No TBD/TODO/"handle edge cases"; every code step shows full code. ✓ - -**Type consistency:** `Columns`/`CellWidth`/`CellHeight`/`CellOffset`/`LayoutCells` (Task 1), `ShouldMountBaseChildren(int,int,int)` (Task 2 — same signature in predicate, test, and `Resolve` call site), `LayoutImporter.Import(_dats, 0x21000023u, ResolveChrome, vitalsDatFont)` (Task 3, matches the vitals/chat call shape). ✓ diff --git a/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md b/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md deleted file mode 100644 index f0054329..00000000 --- a/docs/superpowers/plans/2026-06-20-d2b-toolbar-shortcut-drag.md +++ /dev/null @@ -1,699 +0,0 @@ -# Toolbar shortcut drag interactivity (Stream B.2) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make toolbar shortcut drag functional + retail-faithful — lift empties the source slot, the full-opacity icon follows the cursor with a green-cross indicator, dropping on another slot reorders (bumping the occupant to the source slot), dropping off-bar removes it, and all changes go to ACE via `AddShortcut 0x019C`/`RemoveShortcut 0x019D`. - -**Architecture:** Retail's **remove-on-lift / place-on-drop / no-restore** model. Three slices: (1) Core `ShortcutStore` + the wire builders/senders; (2) small spine extensions (a lift hook, a ghost snapshot, drop-on-hit-only); (3) `ToolbarController` as the live drag handler driving the store + wire. Spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md`. - -**Tech Stack:** C# / .NET 10, xUnit. Core logic in `AcDream.Core`, wire in `AcDream.Core.Net`, UI toolkit in `AcDream.App/UI`. `InternalsVisibleTo("AcDream.App.Tests")` + `("AcDream.Core.Tests")` are set. - ---- - -## File Structure - -**Create:** -- `src/AcDream.Core/Items/ShortcutStore.cs` — mutable 18-slot shortcut model. -- `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs` - -**Modify:** -- `src/AcDream.Core.Net/Messages/InventoryActions.cs` — `BuildAddShortcut` signature/field fix. -- `src/AcDream.Core.Net/WorldSession.cs` — `SendAddShortcut`/`SendRemoveShortcut`. -- `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` — update `BuildAddShortcut_ThreeFields`. -- `src/AcDream.App/UI/IItemListDragHandler.cs` — add `OnDragLift`. -- `src/AcDream.App/UI/UiItemSlot.cs` — `DragBegin`→lift; `DropReleased` ungate. -- `src/AcDream.App/UI/UiRoot.cs` — `_dragGhost` snapshot; `GhostAlpha=1`; `FinishDrag` deliver-on-hit-only; test accessor. -- `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` — update B.1 tests + add lift/ghost/off-bar. -- `src/AcDream.App/UI/Layout/ToolbarController.cs` — store + handler + green cross + wire actions. -- `src/AcDream.App/Rendering/GameWindow.cs` — inject the session sends at the toolbar Bind. -- `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` — lift/drop/store tests. -- `docs/architecture/retail-divergence-register.md` — reword AP-47, delete TS-33. - -**Commands** (from repo root): -- Build all: `dotnet build AcDream.slnx -c Debug` -- Core tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` -- Net tests: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj` -- App tests: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` - -**Every commit ends with** `Co-Authored-By: Claude Opus 4.8 (1M context) `. - ---- - -## Task 1: Core — ShortcutStore + the AddShortcut/RemoveShortcut wire - -**Files:** -- Create: `src/AcDream.Core/Items/ShortcutStore.cs`, `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs` -- Modify: `src/AcDream.Core.Net/Messages/InventoryActions.cs`, `src/AcDream.Core.Net/WorldSession.cs`, `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` - -- [ ] **Step 1: Write the failing ShortcutStore test** - -Create `tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs`: - -```csharp -using System.Collections.Generic; -using AcDream.Core.Items; -using AcDream.Core.Net.Messages; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -public class ShortcutStoreTests -{ - [Fact] - public void Load_mapsIndexToObjId_skipsEmptyAndOutOfRange() - { - var store = new ShortcutStore(); - var entries = new List - { - new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), - new(Index: 3, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0), - new(Index: 5, ObjectGuid: 0u, SpellId: 7, Layer: 1), // spell-only → skipped (item store) - new(Index: 99, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0), // out of range → skipped - }; - store.Load(entries); - Assert.Equal(0x5001u, store.Get(0)); - Assert.Equal(0x5002u, store.Get(3)); - Assert.Equal(0u, store.Get(5)); - Assert.True(store.IsEmpty(1)); - } - - [Fact] - public void SetRemoveGet_roundtrip_andBoundsSafe() - { - var store = new ShortcutStore(); - store.Set(4, 0xABCDu); - Assert.Equal(0xABCDu, store.Get(4)); - Assert.False(store.IsEmpty(4)); - store.Remove(4); - Assert.True(store.IsEmpty(4)); - // bounds-safe: out-of-range Get/Set/Remove never throw - Assert.Equal(0u, store.Get(-1)); - Assert.Equal(0u, store.Get(18)); - store.Set(18, 1u); // no-op, no throw - store.Remove(-1); // no-op, no throw - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShortcutStore"` -Expected: FAIL to compile — `ShortcutStore` not defined. - -- [ ] **Step 3: Create ShortcutStore** - -Create `src/AcDream.Core/Items/ShortcutStore.cs`: - -```csharp -using System.Collections.Generic; -using AcDream.Core.Net.Messages; - -namespace AcDream.Core.Items; - -/// -/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail -/// ShortCutManager::shortCuts_[18] (acclient.h:36492). Holds the bound object guid per -/// slot (0 = empty). Loaded from the login PlayerDescription, then mutated by drag-drop -/// (lift removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the -/// store stays the client's source of truth within a session (retail: client owns the array). -/// Item shortcuts only — spell shortcuts (ObjectGuid 0) are skipped on Load. -/// -public sealed class ShortcutStore -{ - public const int SlotCount = 18; - private readonly uint[] _objIds = new uint[SlotCount]; - - /// Replace all slots from the login PlayerDescription shortcut list (item entries only). - public void Load(IReadOnlyList entries) - { - System.Array.Clear(_objIds); - foreach (var e in entries) - if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[(int)e.Index] = e.ObjectGuid; - } - - /// Bound object guid at , or 0 (empty / out of range). - public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u; - public bool IsEmpty(int slot) => Get(slot) == 0u; - public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; } - public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; } -} -``` - -(`PlayerDescriptionParser.ShortcutEntry` has `uint Index, uint ObjectGuid, ushort SpellId, ushort Layer`. Verify the field types when you open the file; if `Index` is `uint`, the `e.Index < SlotCount` compare and the `(int)e.Index` cast as written are correct.) - -- [ ] **Step 4: Run to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShortcutStore"` -Expected: PASS (2 tests). - -- [ ] **Step 5: Write the failing wire test (new BuildAddShortcut signature)** - -Replace the existing `BuildAddShortcut_ThreeFields` test in `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` with: - -```csharp - [Fact] - public void BuildAddShortcut_ItemShortcut_FieldLayout() - { - // ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0. - byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0); - Assert.Equal(24, body.Length); - Assert.Equal(InventoryActions.AddShortcutOpcode, - System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); - Assert.Equal(0u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index - Assert.Equal(0x3E1u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid - Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId - Assert.Equal((ushort)0, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer - } - - [Fact] - public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s() - { - byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3); - Assert.Equal(0x1234, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); - Assert.Equal(3, System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); - } -``` - -(The file already has `using System.Buffers.Binary;` per the other tests — if so, use the short `BinaryPrimitives.` form to match; the fully-qualified form above is safe regardless.) - -- [ ] **Step 6: Run to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~BuildAddShortcut"` -Expected: FAIL to compile — `BuildAddShortcut` has no `(seq, index, objectGuid, spellId, layer)` overload (old signature is `(seq, slotIndex, objectType, targetId)`). - -- [ ] **Step 7: Fix BuildAddShortcut signature + field packing** - -In `src/AcDream.Core.Net/Messages/InventoryActions.cs`, replace the `BuildAddShortcut` method (the `(uint seq, uint slotIndex, uint objectType, uint targetId)` one) with: - -```csharp - /// Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32), - /// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive - /// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0. - public static byte[] BuildAddShortcut( - uint seq, uint index, uint objectGuid, ushort spellId, ushort layer) - { - byte[] body = new byte[24]; - BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid); - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId); - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer); - return body; - } -``` - -- [ ] **Step 8: Add the WorldSession send wrappers** - -In `src/AcDream.Core.Net/WorldSession.cs`, after `SendChangeCombatMode` (~line 1141), add: - -```csharp - /// Send AddShortcut (0x019C) — pin an item to toolbar slot . - /// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern. - public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0) - { - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer)); - } - - /// Send RemoveShortcut (0x019D) — clear toolbar slot . - /// Retail: CM_Character::Event_RemoveShortCut. - public void SendRemoveShortcut(uint index) - { - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index)); - } -``` - -(Confirm `InventoryActions` is in scope — `WorldSession` already calls other `*Actions.Build*` builders; add a `using AcDream.Core.Net.Messages;` only if not already present.) - -- [ ] **Step 9: Run to verify all Core/Net tests pass** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryActions"` then `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj -c Debug` -Expected: PASS; build green (no stale callers of the old 4-arg `BuildAddShortcut` — there were none besides the test). - -- [ ] **Step 10: Commit** - -```bash -git add src/AcDream.Core/Items/ShortcutStore.cs tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs src/AcDream.Core.Net/Messages/InventoryActions.cs src/AcDream.Core.Net/WorldSession.cs tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs -git commit -m "feat(core): D.5.3/B.2 — ShortcutStore + AddShortcut/RemoveShortcut wire + senders" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: Spine extensions — lift hook + ghost snapshot + drop-on-hit-only - -**Files:** -- Modify: `src/AcDream.App/UI/IItemListDragHandler.cs`, `src/AcDream.App/UI/UiItemSlot.cs`, `src/AcDream.App/UI/UiRoot.cs` -- Test: `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` - -- [ ] **Step 1: Write/adjust the failing tests** in `tests/AcDream.App.Tests/UI/DragDropSpineTests.cs` - -First, the `SpyHandler` (nested in `DragDropSpineTests`) needs to record `OnDragLift`. UPDATE the `SpyHandler` class to add: - -```csharp - public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift; - public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p) - { LastLift = (list, cell, p); } -``` - -Then REPLACE the existing `DropReleased_notAccepted_skipsDispatch` test (its premise — Data0==0 skips — is gone under the retail model) with: - -```csharp - [Fact] - public void DropReleased_dispatchesToHandler_regardlessOfData0() - { - // Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a - // hit), so the handler is authoritative — it dispatches whether or not Data0 is set. - var (list, cell, h) = ListWithHandler(); - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload())); - Assert.NotNull(h.LastDrop); - } -``` - -Then ADD these new tests inside the class: - -```csharp - [Fact] - public void DragBegin_callsHandlerOnDragLift() - { - var (list, cell, h) = ListWithHandler(); - var p = SomePayload(); - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p)); - Assert.NotNull(h.LastLift); - Assert.Same(list, h.LastLift!.Value.list); - Assert.Same(cell, h.LastLift.Value.cell); - Assert.Same(p, h.LastLift.Value.payload); - } - - [Fact] - public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing() - { - // The lift empties the source cell; the ghost must persist (snapshot at BeginDrag), not - // re-read the now-empty cell. Drive the full UiRoot chain, then clear the source cell. - var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99 - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost - cell.Clear(); // simulate the lift emptying the source - Assert.Equal((0x99u, 32, 32), root.DragGhostForTest); - } - - [Fact] - public void FinishDrag_overNothing_deliversNoDrop_butLiftStands() - { - // Drag from a slot with a handler, release over empty space → no HandleDropRelease (off-bar), - // but OnDragLift already fired at begin (the lift). Requires the slot inside a list w/ handler. - var root = new UiRoot { Width = 800, Height = 600 }; - var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; - list.Cell.Width = 32; list.Cell.Height = 32; - list.Cell.SetItem(0x5001u, 0x99u); - var h = new SpyHandler(); - list.RegisterDragHandler(h); - root.AddChild(list); - - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseMove(20, 10); // BeginDrag → OnDragLift - root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space - Assert.NotNull(h.LastLift); // lift happened - Assert.Null(h.LastDrop); // no drop dispatched (off-bar) - Assert.Null(root.DragSource); // cleaned up - } -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` -Expected: FAIL to compile — `IItemListDragHandler.OnDragLift` not defined (SpyHandler doesn't satisfy the interface), `root.DragGhostForTest` not defined. - -- [ ] **Step 3: Add OnDragLift to the interface** - -In `src/AcDream.App/UI/IItemListDragHandler.cs`, add as the FIRST member of the interface: - -```csharp - /// The drag STARTED from a cell in this list — retail's RecvNotice_ItemListBeginDrag - /// → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler removes the lifted item from its - /// model + wire so the source slot empties immediately. The item is "in hand" until - /// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel. - void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload); -``` - -- [ ] **Step 4: UiItemSlot — DragBegin fires the lift; DropReleased ungated** - -In `src/AcDream.App/UI/UiItemSlot.cs` `OnEvent`, change the `DragBegin` case from `return true;` to: - -```csharp - case UiEventType.DragBegin: - // Notify the source list's handler so it can lift (remove + wire) — retail - // RecvNotice_ItemListBeginDrag → RemoveShortcut. UiRoot snapshotted the ghost first. - if (FindList() is { DragHandler: { } lh } liftList && e.Payload is ItemDragPayload lp) - lh.OnDragLift(liftList, this, lp); - return true; -``` - -And change the `DropReleased` case — drop the `e.Data0 == 1` gate (reaching the cell = a real slot was hit; the handler is authoritative): - -```csharp - case UiEventType.DropReleased: - _dragAccept = DragAcceptState.None; - if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp) - dh.HandleDropRelease(dl, this, dp); - return true; -``` - -- [ ] **Step 5: UiRoot — snapshot the ghost, full opacity, deliver-on-hit-only** - -In `src/AcDream.App/UI/UiRoot.cs`: - -(a) Add the snapshot field near `DragPayload` (~line 73): `private (uint tex, int w, int h)? _dragGhost;` and a test accessor near it: `internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost;` - -(b) In `BeginDrag`, snapshot the ghost BEFORE firing `DragBegin`: - -```csharp - private void BeginDrag(UiElement source) - { - var payload = source.GetDragPayload(); - if (payload is null) { _dragCandidate = false; return; } - DragSource = source; - DragPayload = payload; - _dragGhost = source.GetDragGhost(); // snapshot NOW — the DragBegin handler may empty the source cell - var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload); - source.OnEvent(in e); - } -``` - -(c) Change `GhostAlpha` to `1.0f` and make `DrawDragGhost` use the snapshot: - -```csharp - private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade - - private void DrawDragGhost(UiRenderContext ctx) - { - if (_dragGhost is not { } g || g.tex == 0) return; - ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h, - 0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha)); - } -``` - -(d) Replace `FinishDrag` to deliver only on a real hit + clear the ghost: - -```csharp - private void FinishDrag(int x, int y) - { - var (t, lx, ly) = HitTestTopDown(x, y); - if (t is not null) - { - // Dropped on a real element — deliver DropReleased; the hit cell's handler places. - // A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal. - var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased, - Data1: (int)lx, Data2: (int)ly, Payload: DragPayload); - t.OnEvent(in e); - } - // else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail). - DragSource = null; - DragPayload = null; - _dragGhost = null; - _lastDragHoverTarget = null; - } -``` - -- [ ] **Step 6: Run to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~DragDropSpine"` -Expected: PASS (the updated + new spine tests; the existing arming, use-vs-drag, overlay, and window-topology tests still pass — `CompletedDrag_doesNotFireUse` still holds because a completed drag takes the `FinishDrag` path before any Click). - -- [ ] **Step 7: Run the full app suite (no regressions)** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` -Expected: all pass (the ToolbarController tests still pass — `ToolbarController` already implements the interface from B.1; you'll add the `OnDragLift` body in Task 3, but a default-throwing stub isn't present, so confirm: if the build fails because `ToolbarController` doesn't implement the new `OnDragLift` member, add a temporary `public void OnDragLift(...) {}` no-op in `ToolbarController` now and flesh it out in Task 3. Prefer to just do Task 3's `OnDragLift` body — but to keep Task 2 self-contained + green, a one-line empty `OnDragLift` is acceptable here and gets its real body in Task 3.) - -- [ ] **Step 8: Commit** - -```bash -git add src/AcDream.App/UI/IItemListDragHandler.cs src/AcDream.App/UI/UiItemSlot.cs src/AcDream.App/UI/UiRoot.cs tests/AcDream.App.Tests/UI/DragDropSpineTests.cs src/AcDream.App/UI/Layout/ToolbarController.cs -git commit -m "feat(ui): D.5.3/B.2 — spine: drag-lift hook + ghost snapshot (full opacity) + drop-on-hit-only" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: ToolbarController — live handler (store + reorder/remove + wire + green cross) - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/ToolbarController.cs`, `src/AcDream.App/Rendering/GameWindow.cs`, `docs/architecture/retail-divergence-register.md` -- Test: `tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs` - -- [ ] **Step 1: Write the failing tests** — append inside `ToolbarControllerTests` class - -```csharp - // ── B.2: live drag handler (store + reorder/remove + wire) ─────────────── - private static (System.Collections.Generic.List<(uint i,uint g)> adds, - System.Collections.Generic.List removes) NewSpies(out System.Action add, out System.Action rem) - { - var adds = new System.Collections.Generic.List<(uint,uint)>(); - var removes = new System.Collections.Generic.List(); - add = (i, g) => adds.Add((i, g)); - rem = i => removes.Add(i); - return (adds, removes); - } - - [Fact] - public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new System.Collections.Generic.List - { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; - var (adds, removes) = NewSpies(out var add, out var rem); - - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, - sendAddShortcut: add, sendRemoveShortcut: rem); - Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // bound at slot 3 - - var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); - ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); - - Assert.Contains(3u, removes); // RemoveShortcut(3) sent - Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); // source slot now empty - } - - [Fact] - public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B - var shortcuts = new System.Collections.Generic.List - { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), - new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; - var (adds, removes) = NewSpies(out var add, out var rem); - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, - sendAddShortcut: add, sendRemoveShortcut: rem); - - // Lift A from slot 3, then drop on occupied slot 5 (holds B). - var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); - ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); - ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload); - - // End state: A at slot 5, B bumped to the vacated source slot 3. - Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId); - Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId); - // Wire (retail sequence): RemoveShortcut(3)[lift], RemoveShortcut(5)[evict B], AddShortcut(5,A), AddShortcut(3,B) - Assert.Equal(new[] { 3u, 5u }, removes.ToArray()); - Assert.Contains((5u, 0x5001u), adds); - Assert.Contains((3u, 0x5002u), adds); - } - - [Fact] - public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new System.Collections.Generic.List - { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; - var (adds, removes) = NewSpies(out var add, out var rem); - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, - sendAddShortcut: add, sendRemoveShortcut: rem); - - var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); - ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); - ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload); // slot 7 empty - - Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId); // A placed at 7 - Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); // source 3 stays empty - Assert.Contains((7u, 0x5001u), adds); - Assert.DoesNotContain(adds, a => a.i == 3u); // no bump (target was empty) - } - - [Fact] - public void ToolbarSlots_useGreenCrossAcceptSprite() - { - var (layout, slots, _) = FakeToolbar(); - ToolbarController.Bind(layout, new ClientObjectTable(), - () => System.Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); - Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 - } -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` -Expected: FAIL to compile — `ToolbarController.Bind` has no `sendAddShortcut`/`sendRemoveShortcut` params; `OnDragLift` is a no-op stub (from Task 2); `DragAcceptSprite` not set to FA. - -- [ ] **Step 3: Add the store + wire fields + Bind params** - -In `src/AcDream.App/UI/Layout/ToolbarController.cs`: - -(a) Add fields near the other private fields: - -```csharp - private readonly AcDream.Core.Items.ShortcutStore _store = new(); - private bool _storeLoaded; - private readonly Action? _sendAddShortcut; // (index, objectGuid) - private readonly Action? _sendRemoveShortcut; // (index) -``` - -(b) Add the two params to the private ctor signature and the `Bind` factory (both, at the end, optional), and assign them in the ctor: - -```csharp - // ctor params (add after emptyDigits): - Action? sendAddShortcut = null, - Action? sendRemoveShortcut = null, - // ctor body (with the other assignments): - _sendAddShortcut = sendAddShortcut; - _sendRemoveShortcut = sendRemoveShortcut; -``` - -(Mirror the same two params on the `public static ToolbarController Bind(...)` signature and forward them into the `new ToolbarController(...)` call.) - -(c) In the ctor's slot loop (where B.1 already does `RegisterDragHandler(this)` + `SlotIndex` + `SourceKind`), add the green-cross sprite: - -```csharp - list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory) -``` - -- [ ] **Step 4: Switch Populate + IsShortcutGuid to the store; add the handler bodies** - -(a) Replace `Populate()` with the store-driven version (lazy-load once): - -```csharp - public void Populate() - { - // Lazy-load the store from the login shortcut list the first time it's present (PD arrives - // after Bind); thereafter the store is authoritative and drag ops mutate it directly. - if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; } - - foreach (var list in _slots) list?.Cell.Clear(); - - for (int slot = 0; slot < _slots.Length; slot++) - { - uint guid = _store.Get(slot); - if (guid == 0) continue; - var list = _slots[slot]; - if (list is null) continue; - var item = _repo.Get(guid); - if (item is null) continue; // deferred: ObjectAdded re-calls Populate - uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); - list.Cell.SetItem(guid, tex); - } - RestampShortcutNumbers(); - } -``` - -(b) Replace `IsShortcutGuid` to read the store: - -```csharp - private bool IsShortcutGuid(uint guid) - { - for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++) - if (_store.Get(s) == guid) return true; - return false; - } -``` - -(c) Replace the Task-2 stub `OnDragLift` (and the B.1 `HandleDropRelease` logging stub) with the real bodies: - -```csharp - /// - public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) - { - // Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted - // shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a - // drop onto a slot. Off-bar release leaves it removed. - _store.Remove(payload.SourceSlot); - _sendRemoveShortcut?.Invoke((uint)payload.SourceSlot); - Populate(); - } - - /// - public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - { - // Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5): - // evict the target's occupant, place the dragged item there, and bump the evicted item into - // the (now-vacated) source slot if it's free. - int target = targetCell.SlotIndex; - uint evicted = _store.Get(target); // RemoveShortcutInSlotNum(target) - if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); } - _store.Set(target, payload.ObjId); // AddShortcut(dragged, target) - _sendAddShortcut?.Invoke((uint)target, payload.ObjId); - if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot)) - { // displaced → vacated source slot - _store.Set(payload.SourceSlot, evicted); - _sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted); - } - Populate(); - } -``` - -(Delete the old logging `Console.WriteLine` `HandleDropRelease` body and the `OnDragOver` stays as-is.) - -- [ ] **Step 5: Run to verify the controller tests pass** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ToolbarController"` -Expected: PASS (new B.2 tests + the existing B.1 controller tests — `Bind_registersDragHandler_andStampsSlots`, `Populate_bindsShortcutToCorrectSlot` etc. still hold; note `Populate_bindsShortcutToCorrectSlot` exercises the lazy-load path now and should still bind 0x5001 to slot 0). - -- [ ] **Step 6: Inject the session sends at the GameWindow toolbar Bind** - -In `src/AcDream.App/Rendering/GameWindow.cs`, at the `ToolbarController.Bind(...)` call (~line 2013), add the two send actions (mirror the `sendQueryHealth: g => _liveSession?.SendQueryHealth(g)` style used by `SelectedObjectController.Bind` just below it): - -```csharp - sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g), - sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i), -``` - -(Add them as the last arguments to the `ToolbarController.Bind(...)` call, after `emptyDigits:`.) - -- [ ] **Step 7: Update the divergence register** - -In `docs/architecture/retail-divergence-register.md`: -- **Reword AP-47** (the ghost row) — remove the reduced-alpha claim (the ghost is now full opacity, matching retail). New text for the Divergence cell: - `Cursor drag ghost reuses the full composited m_pIcon (incl. type-default underlay) instead of retail's dedicated underlay-less m_pDragIcon. Opacity now matches retail (full).` - and the Risk cell: `The ghost carries the opaque type-default underlay backing rather than retail's underlay-less copy — a subtle look difference while dragging, no functional effect.` (Where/oracle columns unchanged.) -- **Delete the TS-33 row** entirely (the logging stub is replaced by the real store + wire) and change the TS section header count `## 4. Temporary stopgap (TS) — 32 rows` → `— 31 rows`. - -- [ ] **Step 8: Full build + test (no regressions)** - -Run: `dotnet build AcDream.slnx -c Debug` then `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` and `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` -Expected: build green; all pass. - -- [ ] **Step 9: Commit** - -```bash -git add src/AcDream.App/UI/Layout/ToolbarController.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs docs/architecture/retail-divergence-register.md -git commit -m "feat(ui): D.5.3/B.2 — toolbar reorder/remove via ShortcutStore + wire + green-cross overlay" -m "Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Final verification - -- [ ] **Full solution:** `dotnet build AcDream.slnx -c Debug`; `dotnet test tests/AcDream.Core.Tests/...`, `tests/AcDream.Core.Net.Tests/...`, `tests/AcDream.App.Tests/...` — all green. -- [ ] **Spec acceptance §9 self-check:** UiRoot/UiItemSlot stay item-agnostic; AP-47 reworded; TS-33 deleted; wire builder renamed with no stale callers. -- [ ] **Hand to visual verification** (user, `ACDREAM_RETAIL_UI=1`, in-world): lift a hotbar item → source slot empties immediately; full-opacity icon follows the cursor; hovered slots show a green CROSS; drop on another slot → moves there (occupant bumps to source); drop off-bar → removed; single click still uses; changes persist after relog. -- [ ] **Update memory** (`project_d2b_retail_ui.md`) with the B.2 landing + the retail remove-on-lift model; refresh #141 / roadmap as needed. diff --git a/docs/superpowers/plans/2026-06-20-d2b-window-manager.md b/docs/superpowers/plans/2026-06-20-d2b-window-manager.md deleted file mode 100644 index 0bd23d3a..00000000 --- a/docs/superpowers/plans/2026-06-20-d2b-window-manager.md +++ /dev/null @@ -1,472 +0,0 @@ -# D.2b Window Manager Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add open/close/raise for top-level retail-UI windows and toggle a (placeholder) inventory window with F12. - -**Architecture:** A named-window registry on `UiRoot` (`RegisterWindow` / `ShowWindow` / `HideWindow` / `ToggleWindow` / `BringToFront`) — `UiRoot` already owns the top-level children, `ZOrder`, hit-test, and focus, so window show/hide/raise is its job. `Show`/`Hide` flip `UiElement.Visible` (which already gates Draw/Tick/HitTest); `BringToFront` bumps `ZOrder` above peers. The existing F12-bound `InputAction.ToggleInventoryPanel` is wired through `OnInputAction` to toggle a throwaway placeholder window that Sub-phase B replaces with the real `gmInventoryUI`. - -**Tech Stack:** C# .NET 10, xUnit, the in-tree `AcDream.App.UI` retained-mode toolkit (`UiRoot`/`UiElement`/`UiHost`/`UiNineSlicePanel`), the `InputDispatcher` input pipeline. - -**Spec:** `docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md` - ---- - -## File Structure - -- **Create** `src/AcDream.App/UI/WindowNames.cs` — canonical registry-name constants (one literal shared by mount, registry, toggle). -- **Modify** `src/AcDream.App/UI/UiRoot.cs` — add the window registry + `BringToFront`; add raise-on-click in `OnMouseDown`. -- **Modify** `src/AcDream.App/UI/UiHost.cs` — two forwarders (`RegisterWindow`, `ToggleWindow`) delegating to `Root`. -- **Modify** `src/AcDream.App/Rendering/GameWindow.cs` — mount the placeholder inventory window (default-hidden) in the `_options.RetailUi` block; add the `ToggleInventoryPanel` case in `OnInputAction`. -- **Modify** `tests/AcDream.App.Tests/UI/UiRootInputTests.cs` — registry + z-order + raise-on-click unit tests. -- **Modify** `docs/architecture/retail-divergence-register.md` — extend IA-12 "Where" to cite the window manager. - ---- - -## Task 1: `WindowNames` + `UiRoot` window registry (visibility) - -**Files:** -- Create: `src/AcDream.App/UI/WindowNames.cs` -- Modify: `src/AcDream.App/UI/UiRoot.cs` (insert after `ReleaseCapture`, ~line 475) -- Test: `tests/AcDream.App.Tests/UI/UiRootInputTests.cs` - -- [ ] **Step 1: Create the `WindowNames` constant holder** - -Create `src/AcDream.App/UI/WindowNames.cs`: - -```csharp -namespace AcDream.App.UI; - -/// Canonical registry names for top-level retail-UI windows, so the -/// mount, the window registry, and the toggle keybind all agree on one literal. -public static class WindowNames -{ - public const string Inventory = "inventory"; -} -``` - -- [ ] **Step 2: Write the failing tests** - -Append to `tests/AcDream.App.Tests/UI/UiRootInputTests.cs`, inside the class (before the final closing `}`): - -```csharp - [Fact] - public void ToggleWindow_FlipsVisible_AndReturnsNewState() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var win = new UiPanel { Width = 100, Height = 100, Visible = false }; - root.AddChild(win); - root.RegisterWindow("inventory", win); - - Assert.True(root.ToggleWindow("inventory")); // hidden -> shown - Assert.True(win.Visible); - Assert.False(root.ToggleWindow("inventory")); // shown -> hidden - Assert.False(win.Visible); - } - - [Fact] - public void ShowHideWindow_SetVisibility_UnknownNameIsNoOp() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var win = new UiPanel { Width = 100, Height = 100, Visible = false }; - root.AddChild(win); - root.RegisterWindow("inventory", win); - - Assert.True(root.ShowWindow("inventory")); - Assert.True(win.Visible); - Assert.True(root.HideWindow("inventory")); - Assert.False(win.Visible); - - Assert.False(root.ShowWindow("nope")); - Assert.False(root.HideWindow("nope")); - Assert.False(root.ToggleWindow("nope")); - } -``` - -- [ ] **Step 3: Run the tests to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"` -Expected: FAIL — compile error, `'UiRoot' does not contain a definition for 'RegisterWindow'`. - -- [ ] **Step 4: Implement the registry** - -In `src/AcDream.App/UI/UiRoot.cs`, find: - -```csharp - public void SetCapture(UiElement e) => Captured = e; - public void ReleaseCapture() => Captured = null; -``` - -Insert immediately after it: - -```csharp - - // ── Window manager (named top-level windows: Show / Hide / Toggle) ─── - - private readonly Dictionary _windows = new(); - - /// Register a top-level window under a name for Show/Hide/Toggle. - /// Does NOT add it to the tree — the caller mounts via AddChild and controls - /// initial Visible. Idempotent (re-register replaces). The dict is the source - /// of truth — independent of UiElement.Name (init-only, not set here). - public void RegisterWindow(string name, UiElement window) => _windows[name] = window; - - /// Make the named window visible. No-op (returns false) if unknown. - public bool ShowWindow(string name) - { - if (!_windows.TryGetValue(name, out var w)) return false; - w.Visible = true; - return true; - } - - /// Hide the named window. No-op (returns false) if unknown. - public bool HideWindow(string name) - { - if (!_windows.TryGetValue(name, out var w)) return false; - w.Visible = false; - return true; - } - - /// Flip the named window's visibility (Show if hidden, Hide if shown). - /// Returns the new IsVisible state (false for an unknown name). - public bool ToggleWindow(string name) - { - if (!_windows.TryGetValue(name, out var w)) return false; - if (w.Visible) { HideWindow(name); return false; } - ShowWindow(name); - return true; - } -``` - -(`using System.Collections.Generic;` is already present at the top of `UiRoot.cs`.) - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"` -Expected: PASS (all `UiRootInputTests`, including the two new ones). - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/WindowNames.cs src/AcDream.App/UI/UiRoot.cs tests/AcDream.App.Tests/UI/UiRootInputTests.cs -git commit -m "feat(ui): D.2b-A — UiRoot named-window registry (Show/Hide/Toggle) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: `BringToFront` + raise-on-show + raise-on-click - -**Files:** -- Modify: `src/AcDream.App/UI/UiRoot.cs` (add `BringToFront`; edit `ShowWindow` + `OnMouseDown`) -- Test: `tests/AcDream.App.Tests/UI/UiRootInputTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Append to `tests/AcDream.App.Tests/UI/UiRootInputTests.cs`, inside the class: - -```csharp - [Fact] - public void ShowWindow_RaisesAbovePeers() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var a = new UiPanel { Width = 100, Height = 100 }; - var b = new UiPanel { Width = 100, Height = 100 }; - var win = new UiPanel { Width = 100, Height = 100, Visible = false }; - root.AddChild(a); root.AddChild(b); root.AddChild(win); - root.RegisterWindow("inventory", win); - - root.ShowWindow("inventory"); - Assert.True(win.ZOrder > a.ZOrder); - Assert.True(win.ZOrder > b.ZOrder); - } - - [Fact] - public void BringToFront_SetsStrictlyGreatestZOrder() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var a = new UiPanel { Width = 100, Height = 100, ZOrder = 5 }; - var b = new UiPanel { Width = 100, Height = 100, ZOrder = 9 }; - root.AddChild(a); root.AddChild(b); - - root.BringToFront(a); - Assert.True(a.ZOrder > b.ZOrder); // 10 > 9 - } - - [Fact] - public void MouseDown_OnWindow_RaisesItAbovePeers() - { - var root = new UiRoot { Width = 800, Height = 600 }; - // 'peer' has a higher ZOrder but does NOT cover (50,50); 'clicked' does. - var peer = new UiPanel { Left = 300, Top = 0, Width = 100, Height = 100, ZOrder = 9 }; - var clicked = new UiPanel { Left = 0, Top = 0, Width = 100, Height = 100, ZOrder = 0, Draggable = true }; - root.AddChild(peer); root.AddChild(clicked); - - root.OnMouseDown(UiMouseButton.Left, 50, 50); // only 'clicked' occupies (50,50) - Assert.True(clicked.ZOrder > peer.ZOrder); - root.OnMouseUp(UiMouseButton.Left, 50, 50); - } -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"` -Expected: FAIL — compile error, `'UiRoot' does not contain a definition for 'BringToFront'`. - -- [ ] **Step 3: Add `BringToFront`** - -In `src/AcDream.App/UI/UiRoot.cs`, find the end of `ToggleWindow` (added in Task 1): - -```csharp - if (w.Visible) { HideWindow(name); return false; } - ShowWindow(name); - return true; - } -``` - -Insert immediately after it: - -```csharp - - /// Raise a top-level window above its siblings by setting its ZOrder - /// one past the current max among the OTHER top-level children. Used on Show - /// and on click. Leaves ZOrder unchanged if it is the only / already-topmost child. - public void BringToFront(UiElement window) - { - int top = window.ZOrder; - foreach (var c in Children) - if (!ReferenceEquals(c, window)) - top = System.Math.Max(top, c.ZOrder + 1); - window.ZOrder = top; - } -``` - -- [ ] **Step 4: Make `ShowWindow` raise** - -In `src/AcDream.App/UI/UiRoot.cs`, find (inside `ShowWindow`): - -```csharp - if (!_windows.TryGetValue(name, out var w)) return false; - w.Visible = true; - return true; -``` - -Replace with: - -```csharp - if (!_windows.TryGetValue(name, out var w)) return false; - w.Visible = true; - BringToFront(w); - return true; -``` - -- [ ] **Step 5: Add raise-on-click in `OnMouseDown`** - -In `src/AcDream.App/UI/UiRoot.cs`, find (in `OnMouseDown`): - -```csharp - var window = FindWindow(target); - if (btn == UiMouseButton.Left && window is not null) -``` - -Replace with: - -```csharp - var window = FindWindow(target); - // Retail-faithful: pressing on a window raises it above its peers. - if (window is not null) BringToFront(window); - if (btn == UiMouseButton.Left && window is not null) -``` - -- [ ] **Step 6: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiRootInputTests"` -Expected: PASS — all `UiRootInputTests` (the new three plus the existing window-drag/resize tests, which still pass: raise-on-click only changes `ZOrder`, not geometry). - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.App/UI/UiRoot.cs tests/AcDream.App.Tests/UI/UiRootInputTests.cs -git commit -m "feat(ui): D.2b-A — BringToFront + raise on show/click - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: `UiHost` forwarders + `GameWindow` wiring (placeholder + F12) - -No unit tests — these sites are GL-coupled (`UiHost` needs a `GL`) and only run with `ACDREAM_RETAIL_UI=1`; they are verified by build + the visual check in Task 4. - -**Files:** -- Modify: `src/AcDream.App/UI/UiHost.cs` (add forwarders before `Dispose`) -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (placeholder mount in the `_options.RetailUi` block; `OnInputAction` case) - -- [ ] **Step 1: Add `UiHost` forwarders** - -In `src/AcDream.App/UI/UiHost.cs`, find: - -```csharp - public void Dispose() - { - TextRenderer.Dispose(); - } -``` - -Insert immediately before it: - -```csharp - // ── Window manager forwarders (delegate to UiRoot) ───────────────── - - /// Register a top-level window for Show/Hide/Toggle. See . - public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window); - - /// Toggle a registered window's visibility; returns the new IsVisible. - public bool ToggleWindow(string name) => Root.ToggleWindow(name); - -``` - -- [ ] **Step 2: Mount the placeholder inventory window** - -In `src/AcDream.App/Rendering/GameWindow.cs`, find the END of the plugin-panel drain, which is the last block inside `if (_options.RetailUi)`: - -```csharp - catch (Exception ex) - { - Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}"); - } - } - } - } -``` - -Insert the placeholder mount between the inner `}` (closing `if (_uiRegistry is not null)`) and the outer `}` (closing `if (_options.RetailUi)`) — i.e. make it the last statement inside the `_options.RetailUi` block: - -```csharp - catch (Exception ex) - { - Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}"); - } - } - } - - // Phase D.2b-A — placeholder inventory window. Starts HIDDEN; F12 - // (InputAction.ToggleInventoryPanel) reveals it via the UiRoot window - // manager. Throwaway scaffolding: Sub-phase B replaces the body with the - // real gmInventoryUI (0x21000023) nested layout, keeping the same name. - var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) - { - Left = 220, Top = 120, Width = 320, Height = 400, - Visible = false, - }; - _uiHost.Root.AddChild(inventoryWindow); - _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow); - Console.WriteLine("[D.2b-A] placeholder inventory window registered (F12 toggles)."); - } -``` - -(`ResolveChrome` and `_uiHost` are both in scope here — `ResolveChrome` is the local function defined earlier in the same block; `_uiHost` is the field assigned at the top of the block.) - -- [ ] **Step 3: Wire F12 → toggle in `OnInputAction`** - -In `src/AcDream.App/Rendering/GameWindow.cs`, find the first arm of the `switch (action)` in `OnInputAction`: - -```csharp - case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel: -``` - -Insert this case immediately before it: - -```csharp - case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel: - // Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it - // does not fire while the chat input holds focus. Null _uiHost = - // retail UI off (ACDREAM_RETAIL_UI unset) → no-op. - _uiHost?.ToggleWindow(AcDream.App.UI.WindowNames.Inventory); - break; - -``` - -- [ ] **Step 4: Build to verify it compiles** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Expected: Build succeeded, 0 errors. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/UiHost.cs src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(ui): D.2b-A — F12 toggles placeholder inventory window - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: Divergence register + full verification - -**Files:** -- Modify: `docs/architecture/retail-divergence-register.md` (extend IA-12 "Where") - -- [ ] **Step 1: Extend IA-12 to cite the window manager** - -In `docs/architecture/retail-divergence-register.md`, find the IA-12 row's "Where" cell: - -``` -| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md | -``` - -Replace the "Where" cell `` `src/AcDream.App/UI/README.md:3` `` with: - -``` -`src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) -``` - -So the full row becomes: - -``` -| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` (window mgr: `UiRoot.cs` RegisterWindow/Show/Hide/Toggle/BringToFront — F12 inventory toggle IS retail-faithful) | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md | -``` - -- [ ] **Step 2: Run the full test suite** - -Run: `dotnet test` -Expected: PASS — full suite green (2752+ tests; the window-manager facts add ~5). - -- [ ] **Step 3: Commit** - -```bash -git add docs/architecture/retail-divergence-register.md -git commit -m "docs(register): D.2b-A — extend IA-12 to cite the window manager - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - -- [ ] **Step 4: Hand off for visual verification** - -Stop and ask the user to launch the client (`ACDREAM_RETAIL_UI=1`, the standard launch in CLAUDE.md) and confirm: -- F12 shows the placeholder inventory window above vitals/chat/toolbar; F12 again hides it. -- Clicking any window (vitals/chat/toolbar/inventory) raises it above the others. -- F12 does nothing while the chat input is focused (write mode). - -Do NOT mark Sub-phase A done until the user confirms the visual behavior. - ---- - -## Self-Review - -**Spec coverage:** -- Registry on `UiRoot` (§4.1) → Task 1. ✓ -- `BringToFront` + raise-on-click (§4.2) → Task 2. ✓ -- `UiHost` forwarders (§4.3) → Task 3 Step 1. ✓ -- F12 wiring in `OnInputAction` (§4.4) → Task 3 Step 3. ✓ -- Placeholder inventory window (§4.5) → Task 3 Step 2. ✓ -- In-memory persistence (§5) → no code needed (Visible + hardcoded positions); covered by construction. ✓ -- Tests (§6) → Tasks 1–2. ✓ -- Divergence register (§7) → Task 4 Step 1 (extend IA-12, dedup convention). ✓ -- Acceptance (§8): build+tests green → Tasks 1–4; visual → Task 4 Step 4. ✓ - -**Placeholder scan:** No TBD/TODO/"handle edge cases"/"similar to" — every code step shows full code. ✓ - -**Type consistency:** `RegisterWindow(string, UiElement)`, `ShowWindow/HideWindow/ToggleWindow(string)→bool`, `BringToFront(UiElement)`, `WindowNames.Inventory`, `InputAction.ToggleInventoryPanel`, `_uiHost.ToggleWindow` — names identical across Tasks 1–3 and the spec. ✓ diff --git a/docs/superpowers/plans/2026-06-21-145-cell-relative-physics-frame.md b/docs/superpowers/plans/2026-06-21-145-cell-relative-physics-frame.md deleted file mode 100644 index 09b2b401..00000000 --- a/docs/superpowers/plans/2026-06-21-145-cell-relative-physics-frame.md +++ /dev/null @@ -1,577 +0,0 @@ -# Cell-Relative Physics Frame (#145 Option B) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make acdream's physics position cell-relative like retail (`Position{ ObjCellId, Frame{ Origin∈[0,192), Quaternion } }`), retire the streaming center `_liveCenterX/Y` from the physics path (render-only), so the far-town teleport cell-label cascade (#145) becomes structurally impossible. - -**Architecture:** Parallel-frame incremental migration. Introduce the cell-relative `Position`/`Frame` types and the one missing primitive (`GetBlockOffset`) with zero behavior change, then migrate the physics body, membership pick, inter-tick collision state, and the wire onto the carried `Position` slice-by-slice — holding every existing conformance fixture green via transform-on-read — and finally delete `_liveCenter`/`WorldOffset` from the physics reads. Each slice ends green and commits. - -**Tech Stack:** C# .NET 10, `System.Numerics`, xUnit. Decomp oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` (grep by `class::method`); Ghidra patchmem :8081 for single-function lookups. - -**Design spec (read first):** `docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md` -**Handoff (verified mechanism + apparatus):** `docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md` -**Physics digest (SSOT + DO-NOT-RETRY):** `claude-memory/project_physics_collision_digest.md` - ---- - -## Methodology note for every task - -This is a faithful decomp port, not greenfield code. **Each task that ports retail behavior MUST follow the grep-named-first workflow:** read the cited decomp address(es) AND the existing acdream code listed under **Read first** BEFORE writing, then port line-by-line with the same variable names and boundary conditions. Do NOT "improve" the algorithm. Every ported function carries a comment citing its decomp address. Each deviation introduced/retired updates `docs/architecture/retail-divergence-register.md` in the same commit. - -**Green bar per slice** = `dotnet build` clean + these suites pass: -``` -dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -dotnet test tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj -dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj -``` - -## File structure (what gets created / modified across all slices) - -| File | Responsibility | Slices | -|---|---|---| -| `src/AcDream.Core/Physics/Position.cs` (create) | `CellFrame` + `Position` value types (`CellFrame` = retail `Frame`, renamed to dodge `DatReaderWriter.Types.Frame`) | 1 | -| `src/AcDream.Core/Physics/LandDefs.cs` (modify) | add `GetBlockOffset` | 1 | -| `src/AcDream.Core/Physics/PhysicsBody.cs` (modify) | carry `Position`; computed `WorldPosition`; `AdjustToOutside` on placement; integrate-then-rewrap | 2,4 | -| `src/AcDream.Core/Physics/ResolveResult.cs` (modify) | carry the canonicalized `Position` | 2,3 | -| `src/AcDream.Core/Physics/CellTransit.cs` (modify) | membership pick via carried `Position` + `AdjustToOutside`; delete `(0,0)` fallback | 3 | -| `src/AcDream.Core/Physics/PhysicsEngine.cs` (modify) | resolve loop / terrain sampling off the cell frame | 3,6 | -| `src/AcDream.Core/Physics/TransitionTypes.cs` (modify) | `curr_pos`/`check_pos` as `Position`; contact-plane translate; `validate_transition` lockstep | 4 | -| `src/AcDream.App/Rendering/GameWindow.cs` (modify) | wire off `Position`; `_liveCenter` render-only | 5,6 | -| `src/AcDream.Core/Physics/RemoteMoveToDriver.cs` (modify) | inbound builds `Position` | 5 | -| `src/AcDream.App/Input/PlayerMovementController.cs` (modify) | teleport velocity-idle | 7 | -| `tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs` (create) | `GetBlockOffset` conformance | 1 | -| `tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs` (create) | the #145 regression pin | 3 | -| Existing replay/conformance tests (modify setup only) | transform-on-read seam | 2–4 | - ---- - -## Task 1: Cell-relative types + `GetBlockOffset` (zero behavior change) - -**Files:** -- Create: `src/AcDream.Core/Physics/Position.cs` -- Modify: `src/AcDream.Core/Physics/LandDefs.cs` (add method after `AdjustToOutside`, before the private `CellLowInRange`) -- Test: `tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs` (create) - -**Read first:** `src/AcDream.Core/Physics/LandDefs.cs` (whole file — note `CellLength=24f`, `BlockLength=192f`); decomp `LandDefs::get_block_offset` @0x0043e630 (pc:69189). - -- [ ] **Step 1: Create the value types** - -`src/AcDream.Core/Physics/Position.cs` (the value type is named `CellFrame`, NOT `Frame` — `Frame` -collides with `DatReaderWriter.Types.Frame` used in physics-adjacent code; the `Position.Frame` *member* -keeps retail's name): -```csharp -using System.Numerics; - -namespace AcDream.Core.Physics; - -/// -/// Retail Frame (acclient.h:30647) — named CellFrame here to avoid -/// colliding with DatReaderWriter.Types.Frame. is LOCAL -/// to the owning cell: outdoor → X/Y ∈ [0,192), Z = height; indoor → EnvCell-relative. -/// replaces retail's m_fl2gv 3×3 matrix. -/// -public readonly record struct CellFrame(Vector3 Origin, Quaternion Orientation); - -/// -/// Retail Position (acclient.h:30658): the (which-cell, where-inside) pair. -/// Full 32-bit cell id + the cell-local . Never reconstructed -/// from a streaming center — see #145 design spec. The Frame member keeps -/// retail's name. -/// -public readonly record struct Position(uint ObjCellId, CellFrame Frame) -{ - public Position(uint objCellId, Vector3 origin, Quaternion orientation) - : this(objCellId, new CellFrame(origin, orientation)) { } -} -``` - -- [ ] **Step 2: Write the failing `GetBlockOffset` test** - -`tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs`: -```csharp -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -public class LandDefsBlockOffsetTests -{ - // get_block_offset(src, dst) returns the world-meter offset from src's - // landblock origin to dst's landblock origin. block_byte<<3 = lcoord, *24 m/cell - // → nets to (Δlandblock)·192 m. Same landblock → Zero. Decomp @0x0043e630. - - [Fact] - public void SameLandblock_ReturnsZero() - { - // both 0xC95B - Assert.Equal(Vector3.Zero, LandDefs.GetBlockOffset(0xC95B0001u, 0xC95B0008u)); - } - - [Fact] - public void SouthNeighbour_IsMinus192Y() - { - // 0xC95B (lbY=0x5B) → 0xC95A (lbY=0x5A): one landblock south = (0,-192,0). - // This is the exact #145 case: correct origin for 0xC95A relative to 0xC95B. - Assert.Equal(new Vector3(0f, -192f, 0f), LandDefs.GetBlockOffset(0xC95B0001u, 0xC95A0001u)); - } - - [Fact] - public void EastNeighbour_IsPlus192X() - { - // 0xC95B (lbX=0xC9) → 0xCA5B (lbX=0xCA): one landblock east = (+192,0,0). - Assert.Equal(new Vector3(192f, 0f, 0f), LandDefs.GetBlockOffset(0xC95B0001u, 0xCA5B0001u)); - } - - [Fact] - public void DiagonalNeighbour_CombinesBothAxes() - { - // 0xC95B → 0xCA5C: lbX +1 (+192), lbY +1 (+192). - Assert.Equal(new Vector3(192f, 192f, 0f), LandDefs.GetBlockOffset(0xC95B0001u, 0xCA5C0001u)); - } -} -``` - -- [ ] **Step 3: Run it — expect compile failure (method missing)** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter LandDefsBlockOffsetTests` -Expected: build error — `'LandDefs' does not contain a definition for 'GetBlockOffset'`. - -- [ ] **Step 4: Port `GetBlockOffset` faithfully** - -In `src/AcDream.Core/Physics/LandDefs.cs`, add after `AdjustToOutside` (after line 142): -```csharp - /// - /// LandDefs::get_block_offset (pc:69189, @0x0043e630): world-meter offset - /// from 's landblock origin to 's. - /// ZeroVector when both ids share a landblock (high words equal). The decomp's - /// block_byte<<3 converts to lcoord (cell) units and the literal - /// ·24 m/cell then nets to (Δlandblock)·192 m per axis. The - /// degenerate arg==0 fallbacks are ported verbatim (dest==0 → raw source - /// id; source==0 → 0 lcoord). The ONLY cross-cell translation in retail physics: - /// a delta of two NAMED cell ids, never an accumulation against a moving center. - /// - public static Vector3 GetBlockOffset(uint source, uint dest) - { - uint srcBlock = source >> 16; - uint dstBlock = dest >> 16; - if (srcBlock == dstBlock) - return Vector3.Zero; - - int srcLx, srcLy; - if (source == 0u) { srcLx = 0; srcLy = 0; } - else { srcLx = (int)((source >> 21) & 0x7f8u); srcLy = (int)((srcBlock & 0xFFu) << 3); } - - int dstLx, dstLy; - if (dest == 0u) { dstLx = (int)source; dstLy = (int)source; } // retail degenerate guard - else { dstLx = (int)((dest >> 21) & 0x7f8u); dstLy = (int)((dstBlock & 0xFFu) << 3); } - - return new Vector3((dstLx - srcLx) * CellLength, (dstLy - srcLy) * CellLength, 0f); - } -``` - -- [ ] **Step 5: Run the test — expect PASS** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter LandDefsBlockOffsetTests` -Expected: 4 passed. - -- [ ] **Step 6: Full Core suite green (types added, nothing consumes them yet)** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` -Expected: all pass (no regressions; `Position`/`Frame` are unused). - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.Core/Physics/Position.cs src/AcDream.Core/Physics/LandDefs.cs tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs -git commit -m "feat(physics #145): Slice 1 — Position/Frame types + LandDefs.GetBlockOffset (0x0043e630) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -> **Execution note (2026-06-21):** Slice 2 split into **2a** (Core: `PhysicsBody.CellPosition` + `SnapToCell` + setter delta-sync, SHIPPED `afe495b`+`7928b44`) and **2b** (App: seed `SnapToCell` at the placement sites). **2b folds into Task 3** — the seed is only verifiable once membership consumes it, so they ship together under the runaway test. The confirmed mechanism is wire-seed + mirrored deltas (no `_liveCenter` in Core); the `WorldPosition`/`IWorldFrame` bridge described below was superseded by the setter-delta-sync design (no separate bridge type needed). - -## Task 2: `PhysicsBody` carries `Position`; canonicalize on placement (behavior unchanged) - -**Goal:** Make the body's source of truth a cell-relative `Position`, with a computed `WorldPosition` so render + wire keep working unchanged. Run `AdjustToOutside` wherever a placement/teleport sets position, so the (cell, local) pair is always canonical. No membership/sweep behavior changes yet. - -**Files:** -- Modify: `src/AcDream.Core/Physics/PhysicsBody.cs:95` (the `Position` field) -- Modify: `src/AcDream.Core/Physics/ResolveResult.cs:21-37` -- Test: `tests/AcDream.Core.Tests/Physics/PhysicsBodyPositionFrameTests.cs` (create) - -**Read first:** `PhysicsBody.cs` (whole — note `Position` used by `UpdatePhysicsInternal:381`); `ResolveResult.cs`; every caller of `PhysicsBody.Position` (grep `\.Position` in `src/AcDream.Core/Physics` and `src/AcDream.App`); `LandDefs.AdjustToOutside`; decomp `Position::adjust_to_outside` @0x00504A40. - -**Design decision for this slice (keep the migration safe):** Add `Position CellPosition { get; private set; }` as the new source of truth, and KEEP `Vector3 Position` as a computed accessor over `CellPosition.Frame.Origin` expressed in the body's *current-landblock-anchored* world frame. Because the anchored frame for the body's OWN landblock places the body at `WorldFromAnchor(cell, origin)`, and existing callers feed/read the streaming-world frame, introduce a single conversion owned by the seam (see Step 1). The goal is: existing tests stay green because `Position` round-trips through `CellPosition` with no numeric change for the player's own landblock. - -- [ ] **Step 1: Write the failing round-trip + canonicalization test** - -`tests/AcDream.Core.Tests/Physics/PhysicsBodyPositionFrameTests.cs`: -```csharp -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -public class PhysicsBodyPositionFrameTests -{ - [Fact] - public void SetCellPosition_RoundTrips_Local() - { - var body = new PhysicsBody(); - body.SetCellPosition(new Position(0xC95B0001u, new Vector3(14.8f, 0.3f, 12f), Quaternion.Identity)); - - Assert.Equal(0xC95B0001u, body.CellPosition.ObjCellId); - Assert.Equal(new Vector3(14.8f, 0.3f, 12f), body.CellPosition.Frame.Origin); - } - - [Fact] - public void SetCellPosition_OutOfBoundsLocal_CanonicalizesCellAndOrigin() - { - // Local Y just below 0 in 0xC95B must rebase one landblock south to 0xC95A - // with origin wrapped into [0,192) — NOT a (0,0)-anchored march. - var body = new PhysicsBody(); - body.SetCellPosition(new Position(0xC95B0001u, new Vector3(14.8f, -0.1f, 12f), Quaternion.Identity)); - - // 0xC95B lbY=0x5B → south neighbour 0xC95A lbY=0x5A; low word recomputed. - Assert.Equal(0xC95Au, body.CellPosition.ObjCellId >> 16 << 8 >> 8 | (body.CellPosition.ObjCellId & 0xFF00u)); // block byte check - Assert.InRange(body.CellPosition.Frame.Origin.Y, 0f, 192f); - } -} -``` -(Adjust the block-byte assertion to the project's preferred form after reading `LcoordToGid`; the load-bearing assertion is `Origin.Y ∈ [0,192)` and the landblock id moved to the south neighbour, never `(0,0)`.) - -- [ ] **Step 2: Run — expect failure (`SetCellPosition`/`CellPosition` missing)** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter PhysicsBodyPositionFrameTests` -Expected: build error. - -- [ ] **Step 3: Add `CellPosition` + `SetCellPosition` to `PhysicsBody`** - -In `PhysicsBody.cs`, replace the `Position` auto-property (line 95) with a `CellPosition`-backed model: -```csharp - /// Cell-relative source of truth (retail Position). #145 Slice 2. - public Position CellPosition { get; private set; } - - /// - /// Place the body at a cell-relative position, canonicalizing via - /// AdjustToOutside (Position::adjust_to_outside @0x00504A40) so the (cell, local) - /// pair is always valid — outdoor origin wrapped into [0,192), cell id rebuilt - /// from the absolute lcoord grid. An inconsistent pair cannot persist. - /// - public void SetCellPosition(Position p) - { - uint cellId = p.ObjCellId; - Vector3 origin = p.Frame.Origin; - // Outdoor canonicalization only (low word < 0x100). Indoor frames are - // EnvCell-relative and already validated by the BSP path. - if ((cellId & 0xFFFFu) is >= 1u and < 0x100u) - LandDefs.AdjustToOutside(ref cellId, ref origin); - CellPosition = new Position(cellId, new Frame(origin, p.Frame.Orientation)); - } -``` -Keep a `Vector3 Position` accessor for back-compat during migration. Its getter/setter convert through the body's anchored world frame. **The exact conversion to use is the inverse pair already in production** — see the render seam at `GameWindow.cs:4991-4995` (`worldPos = local + (lb − _liveCenter)·192`). For Slice 2, keep `Position` numerically identical to today by routing through a `IWorldFrame` seam supplied by the owner (default: identity for unit tests). Document that `Position` is deprecated and physics reads migrate to `CellPosition` in Slices 3–6. - -> **Orchestrator note:** This back-compat `Position` accessor is the one place a deviation row is warranted (a temporary dual-frame bridge). Add a register row "temporary PhysicsBody.Position world-frame bridge (#145 migration)"; retire it in Slice 6. - -- [ ] **Step 4: Update `ResolveResult` to carry the `Position`** - -In `ResolveResult.cs`, add a `Position ResolvedPosition` field alongside the existing `Vector3 Position` + `uint CellId` (keep both during migration; the new field defaults so existing constructors compile): -```csharp - /// #145: the canonicalized cell-relative result. Source of truth from - /// Slice 3 onward; Position/CellId remain as the world-frame bridge until Slice 6. - Position ResolvedPosition = default -``` -(Append as the last optional parameter so the 3 existing call sites — `PhysicsEngine.cs:858-861`, `:1155-1166`, and any others grep finds — still compile.) - -- [ ] **Step 5: Run — expect PASS + no regressions** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` -Expected: new tests pass; all existing physics replay/conformance tests still green (behavior unchanged — `Position` bridge round-trips). - -- [ ] **Step 6: App/UI/Net suites green** - -Run the App/UI/Net suites (see Methodology). Expected: green. - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.Core/Physics/PhysicsBody.cs src/AcDream.Core/Physics/ResolveResult.cs tests/AcDream.Core.Tests/Physics/PhysicsBodyPositionFrameTests.cs docs/architecture/retail-divergence-register.md -git commit -m "feat(physics #145): Slice 2 — PhysicsBody carries cell-relative Position; canonicalize on placement - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: Membership pick through the carried `Position` — delete the `(0,0)` fallback (closes the cascade) - -**Goal:** The outdoor membership pick stops deriving the landblock from `TryGetTerrainOrigin` (which falls back to `(0,0)` for unstreamed neighbours). It runs `AdjustToOutside` directly on the carried `(cell, local)` pair. This is the fix — the far-town cascade becomes impossible. - -**THE MECHANISM (designed + verified 2026-06-21 — the carried-anchor):** the landblock world origin acdream needs is exactly `blockOrigin = body.Position − body.CellPosition.Frame.Origin` (world pos minus the carried cell-local). This is the *true* landblock origin derived from the carried frame — correct even for an UNSTREAMED neighbour, where `TryGetTerrainOrigin` returns `(0,0)`. Verified against the runaway: at the first far-town tick `body.Position≈(14.8,−0.088,12)` (streaming-rel) and `CellPosition.Frame.Origin≈(14.8,191.9,12)` (local in `0xC95A`), so `blockOrigin ≈ (0,−192)` — exactly the value the handoff confirmed correct for `0xC95A`. Feeding this `blockOrigin` into the EXISTING pick (`pickPos = worldSphereCenter − blockOrigin` at `CellTransit.cs:845`) and `AddAllOutsideCells` makes the whole downstream membership cascade-proof with no rewrite of the pick logic. - -**Implementation steps:** -1. **Seed** `_body.SnapToCell(cellId, worldPos, wireLocal)` at the 3 player-placement sites — login spawn (`GameWindow.cs:~2930`, wire local `(p.PositionX,Y,Z)`, cell `p.LandblockId`), teleport arrival (`~:5474/:5537` — seed the RESOLVED cell + its local), inbound player update. Without a seed, `CellPosition.ObjCellId==0` and the fix no-ops (legacy fallback). Behavior-neutral. -2. **Thread** `Vector3? carriedBlockOrigin` into `CellTransit.FindCellSet`/`FindCellList`/`BuildCellSetAndPickContaining` (default `null` → legacy `TryGetTerrainOrigin`). When supplied, it REPLACES the `:736` derivation. Compute it in `PhysicsEngine.ResolveWithTransition` from the passed `body` (`body.CellPosition.ObjCellId` outdoor & seeded → `body.Position − body.CellPosition.Frame.Origin`, else `null`), thread through the sweep (`RunCheckOtherCellsAndAdvance` → `FindCellSet`) in `TransitionTypes.cs`. ⚠ The sweep threading is the delicate part — `RunCheckOtherCellsAndAdvance` is in the ~2,300-line `TransitionTypes`; the resolve `body` must reach the `FindCellSet` call (add a field on the transition/sphere-path state set at resolve entry). Read that path fully before editing. -3. **Make membership authoritative**: the controller commits `_body.CellPosition.ObjCellId` (or the now-correct `resolveResult.CellId`) — they agree once `blockOrigin` is the carried anchor. Delete the legacy-anchor comment at `:732-735`. -4. **Test** `TeleportFarTownRunawayTests` from `desync-capture.jsonl` (south + east edge, unregistered neighbour, assert no march). Hold every existing membership/replay test green. - -Note: the OTHER `TryGetTerrainOrigin` site (`CellTransit.cs:484`, `BuildShadowCellSet`) is registration-side collision (Slice 4 concern), NOT the player pick — leave it for Slice 4. - -**⚠ Edge cases surfaced during design (2026-06-21) — handle these or regress #98–#112:** -- **cellId ↔ anchor must share a landblock.** The carried anchor `body.Position − CellPosition.Frame.Origin` is the origin of `CellPosition`'s landblock; the pick's `AdjustToOutside` starts from the passed `cellId`. If `cellId` is the *lagging/marched* controller `CellId` while `CellPosition` is correct, the pick mixes a marched landblock with a correct local → garbage. **Fix: the controller passes `_body.CellPosition.ObjCellId` as the resolve cell for the OUTDOOR case** (so `cellId == CellPosition.ObjCellId`, consistent with the anchor); indoor keeps the BSP-tracked `CellId`. Selection: `(_body.CellPosition.ObjCellId outdoor & seeded && current is outdoor) ? CellPosition.ObjCellId : CellId`. -- **CellPosition goes stale indoors.** The Slice-2a setter only maintains `CellPosition` for outdoor cells (low word 1..0x40); while the player is inside a building (`CellId` indoor) it is NOT updated. On building EXIT (indoor→outdoor) the carried anchor would be stale. **Fix: re-seed `SnapToCell` from the resolve's outdoor result when membership transitions indoor→outdoor** (the resolve already returns the correct outdoor cell + the world position gives the local via the just-computed anchor). Verify with an indoor↔outdoor round-trip replay (CellarUp / cottage harness) in addition to the far-town test. -- **Sweep threading vehicle:** add `Vector3? CarriedBlockOrigin` to `SpherePath` (alongside `CheckCellId`, ~`:336`); set it in `PhysicsEngine.ResolveWithTransition` right after `InitPath` (`:975`) from `body`; read it at the `FindCellSet` call (`TransitionTypes.cs:2291`) and pass through `FindCellSet`→`FindCellList`→`BuildCellSetAndPickContaining` (default `null` = legacy `TryGetTerrainOrigin`, so every existing caller/test is unaffected). - -**Files:** -- Modify: `src/AcDream.Core/Physics/CellTransit.cs:732-736` (the discarded-bool fallback), `:242` (`AddAllOutsideCells` block-origin subtraction), `:845-849` (containing pick), `:484` (the other seed) -- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs:603-625` (the resolve loop feeds the pick) -- Test: `tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs` (create) - -**Read first (REQUIRED — this is the fix site):** `CellTransit.cs` in full, focusing on `BuildCellSetAndPickContaining`, `AddAllOutsideCells`, the legacy-anchor comment `:732-735`; `CellGraph.cs:47-56` (`TryGetTerrainOrigin`); `LandDefs.GetOutsideLcoord`/`AdjustToOutside`; the design spec §3.3–§4; decomp `LandDefs::adjust_to_outside` @0x005A9BC0, `get_outside_lcoord` @0x005A9B00. The apparatus capture `desync-capture.jsonl` (worktree root) is the regression source. - -- [ ] **Step 1: Write the failing regression test from the capture** - -`tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs` — load the far-town arrival record(s) from `desync-capture.jsonl` (cellId `0xC95B0001`, first far-town velocity `(3.637,−11.276,0)`), drive the membership pick with the SOUTH neighbour `0xC95A` UNREGISTERED, and assert the cell id stays consistent with the body (no per-quantum march toward `0x00`). Mirror the existing `CellarUpTrajectoryReplayTests` `LoadCapturedRecord`/`SimulateTicks` apparatus (read it first). Include a second case where the unregistered neighbour is to the EAST (the cascade is direction-agnostic — assert no march in X either). - -Concrete assertion shape: -```csharp -[Fact] -public void FarTownArrival_SouthNeighbourUnregistered_CellDoesNotMarch() -{ - var (body, startCell) = LoadFarTownArrival("desync-capture.jsonl"); // 0xC95B0001 - // 0xC95A NOT registered in the CellGraph/landblock set. - for (int tick = 0; tick < 40; tick++) - Step(body); // one physics quantum each - - // Body barely moved (|Δ| small); the landblock id must NOT have walked - // south by tens of blocks. Allow at most one legitimate crossing. - int lbY = (int)((body.CellPosition.ObjCellId >> 16) & 0xFFu); - Assert.InRange(lbY, 0x5A, 0x5B); // pre-fix this marched 0x5B→0x00 -} -``` - -- [ ] **Step 2: Run — expect FAIL (the march reproduces)** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter TeleportFarTownRunawayTests` -Expected: FAIL — `lbY` marches below `0x5A` (reproduces #145). If it does not reproduce, the harness isn't faithfully feeding the unregistered-neighbour path — fix the harness before proceeding (do NOT weaken the assertion). - -- [ ] **Step 3: Route the pick through `AdjustToOutside`; delete the `(0,0)` fallback** - -In `CellTransit.cs`, replace the `world XY − TryGetTerrainOrigin(→Zero fallback)` derivation at the pick (`:732-736`, `:845`) and seeds (`:242`, `:484`) with: take the carried `(cellId, localOrigin∈[0,192))` and call `LandDefs.AdjustToOutside(ref cellId, ref localOrigin)` — which crosses landblocks via the absolute lcoord grid, needing NO terrain registration. Delete the discarded-bool line and the legacy-anchor comment. Remove the physics use of `CellGraph.TryGetTerrainOrigin` from these paths (leave it for any render-only caller). Port faithfully against `adjust_to_outside` @0x005A9BC0; keep the existing straddle-gate / `outdoorPickAllowed` logic intact (that's a separate, correct mechanism — #112). - -> **Orchestrator note:** This is the slice the CLAUDE.md warning targets ("don't integrate via a subagent without full context"). The implementer must have read all of `CellTransit.cs`. Review the diff against the existing pick logic before accepting — the straddle gate (`:863`) and indoor-primary branch must be untouched. - -- [ ] **Step 4: Run the new test — expect PASS** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter TeleportFarTownRunawayTests` -Expected: PASS — no march under the unregistered neighbour, both south and east cases. - -- [ ] **Step 5: Full physics conformance green (no regression at registered-neighbour seams)** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` -Expected: all green — especially `CellarUp*`, `DoorBug*`, `DoorwayMembership*`, `ThresholdPortalCrossing*`, `Issue106*`/`Issue112*` membership tests. These prove normal landblock crossings (registered neighbours) still cross correctly. - -- [ ] **Step 6: App/UI/Net green; update ISSUES + register** - -Move #145 toward closed in `docs/ISSUES.md` (note "membership cascade fixed; full close after Slices 5–7 land the wire + velocity-idle"). The `(0,0)` legacy-anchor fallback deletion may retire/rewrite a register row — check `docs/architecture/retail-divergence-register.md` for the streaming-anchor row and annotate. - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.Core/Physics/CellTransit.cs src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs docs/ISSUES.md docs/architecture/retail-divergence-register.md -git commit -m "fix(physics #145): Slice 3 — membership pick via AdjustToOutside on carried Position; delete (0,0) fallback — closes the cascade - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: Inter-tick collision state cell-relative + `validate_transition` lockstep (the B-specific work) - -**Goal:** Store the contact plane + walkable polygon in their owning cell's frame + cell id, translating into the per-tick working frame via `GetBlockOffset` exactly like retail (`0x0050A592`). Carry `curr_pos`/`check_pos` as `Position` in the sweep. `validate_transition` adopts cell id AND frame in lockstep. - -**Files:** -- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` (the SPHEREPATH/CTransition sweep: `curr_pos`/`check_pos`, `SetCheckPos ~:398-401, ~:2315`, contact-plane usage, `validate_transition`) -- Modify: `src/AcDream.Core/Physics/PhysicsBody.cs` (the contact-plane/walkable fields `:128-151` become cell-relative + their `…CellId`; `UpdatePhysicsInternal:381` integrate-then-`AdjustToOutside`) - -**Read first (REQUIRED):** `TransitionTypes.cs` in full; `PhysicsBody.cs:115-151` (contact-plane fields); decomp — `cache_global_curr_center` @0x0050C740, `validate_transition` @0x0050AA70, the contact-plane translate at @0x0050A592, `SPHEREPATH::get_curr_pos_check_pos_block_offset` @0x00509FD0. **Verify the matrix/quaternion order and the translate sign against the decomp (oracle flagged a transposition risk).** - -- [ ] **Step 1: Write a failing seam test** - -Build a cross-landblock contact case: the body in landblock A standing on a contact plane owned by neighbour landblock B (a seam). Assert the working-frame contact plane = stored-plane translated by `GetBlockOffset(workingCell, contactPlaneCellId)`, and that walking across the seam keeps the plane geometrically stationary (the player doesn't pop off the ground at the seam). Use the `CameraCornerSealReplayTests`/`CellarUp` apparatus form. (If no existing seam fixture fits, construct a synthetic two-landblock body state — document the construction.) - -- [ ] **Step 2: Run — expect FAIL (plane not translated, or world-frame stored)** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter ` -Expected: FAIL. - -- [ ] **Step 3: Port the per-cell contact-plane model + lockstep adoption** - -- Store `ContactPlane`/`WalkablePlane`/`WalkableVertices` in their owning cell's frame; keep `ContactPlaneCellId` as the anchor. In the sweep, before using the contact plane, translate it into `check_pos`'s working frame via `GetBlockOffset(check_pos.ObjCellId, ContactPlaneCellId)` iff `ContactPlaneCellId != 0` (faithful to @0x0050A592). -- `curr_pos`/`check_pos` become `Position`. `SetCheckPos` updates `check_pos.Frame` together with its cell id (never the id alone). -- `validate_transition`: adopt `curr_pos.ObjCellId = check_pos.ObjCellId` AND `curr_pos.Frame = check_pos.Frame` together; then rebuild the working center (`cache_global_curr_center`: `Orientation·local + Origin`, verified order). -- In `UpdatePhysicsInternal`, after the `Position += Velocity*dt …` integration, run `AdjustToOutside` on the resulting `(cell, local)` so a tick that crosses a landblock re-wraps immediately. - -> **Orchestrator note:** Largest, most delicate slice. Review the diff against `TransitionTypes.cs` carefully; the contact-plane retention semantics (digest: "seed CollisionInfo.ContactPlane from these fields") must be preserved — only the FRAME they live in changes. Hold every physics replay green before accepting. - -- [ ] **Step 4: Run the seam test — expect PASS** - -Run as Step 2. Expected: PASS. - -- [ ] **Step 5: Full physics conformance green (transform-on-read fixtures)** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` -Expected: all green. The committed world-space fixtures stay byte-for-byte; the harness converts captured `(world plane + cell id) → cell-frame` on read. If a fixture goes red, the conversion seam is wrong — fix the seam, NEVER re-capture. - -- [ ] **Step 6: App/UI/Net green; register row** - -Add/update the register row for the per-cell contact-plane frame (retail-faithful port — if it retires the world-frame contact-plane divergence row, delete it). - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.Core/Physics/TransitionTypes.cs src/AcDream.Core/Physics/PhysicsBody.cs tests/AcDream.Core.Tests/Physics/ docs/architecture/retail-divergence-register.md -git commit -m "feat(physics #145): Slice 4 — inter-tick collision state cell-relative + validate_transition lockstep (0x0050aa70/0x0050a592) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 5: Wire derives from the carried `Position` (kills the 17410 leak at the source) - -**Goal:** Outbound + inbound motion `(cell, local)` derive directly from the carried `Position`. The `localY = Position.Y − (lbY − _liveCenterY)·192` subtraction and its `_liveCenter` fallback are deleted, so the bogus `17410` can no longer be synthesized. - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs:7753-7765` (outbound), `:4989-4995` (inbound) -- Modify: `src/AcDream.Core/Physics/RemoteMoveToDriver.cs:271-276` (remote inbound) -- Test: `tests/AcDream.App.Tests/...` (create a wire round-trip test if an App-layer harness exists; otherwise a Core-level `Position`→wire→`Position` round-trip) - -**Read first:** `GameWindow.cs:7740-7840` (the outbound builder + the `:7755-7761` fallback), `:4971-4995` (inbound); `RemoteMoveToDriver.cs:260-280`; holtburger `client/movement/actions.rs` (MoveToState/AutonomousPosition wire field order) + `references/ACE` movement validation; design spec §4. - -- [ ] **Step 1: Write the failing round-trip test** - -Assert: given a carried `Position{ ObjCellId=0xC95A0001, Origin=(14.8, 191.9, 12) }`, the outbound wire `(cellId, localX, localY, localZ)` equals `(0xC95A0001, 14.8, 191.9, 12)` exactly — NOT `191.9 + 91·192 = 17463`. And inbound `(cellId, local)` rebuilds the same `Position`. (Pick values near a landblock edge so a `_liveCenter`-based formula would visibly diverge.) - -- [ ] **Step 2: Run — expect FAIL (old formula injects the offset)** - -Expected: FAIL — outbound localY shows the `(lbY − _liveCenterY)·192` addition. - -- [ ] **Step 3: Derive the wire from `Position`** - -Replace `:7753-7765` with: `wireCellId = body.CellPosition.ObjCellId; wirePos = body.CellPosition.Frame.Origin;` — no `lbX/lbY`/`_liveCenter` math, delete the `:7755-7761` fallback entirely. Inbound (`:4989-4995`, `RemoteMoveToDriver:271-276`): build `new Position(p.LandblockId/cellId, new Vector3(p.PositionX, p.PositionY, p.PositionZ), orientation)` directly; the render path converts to world for meshes via the seam (`WorldFromPosition`). - -- [ ] **Step 4: Run — expect PASS** - -Expected: PASS — wire local matches the carried origin exactly; round-trip identity. - -- [ ] **Step 5: Suites green; live cross-check note** - -Run all four suites. Expected: green. (Live ACE verification of an actual far-town portal is a user gate, recorded in Slice 7's handoff — the wire format is cross-checked vs holtburger here.) - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.Core/Physics/RemoteMoveToDriver.cs tests/ -git commit -m "fix(physics #145): Slice 5 — outbound/inbound wire derives from carried Position; delete _liveCenter wire math (17410 leak) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 6: `_liveCenter` + baked `WorldOffset` made render-only - -**Goal:** Delete every PHYSICS read of `_liveCenter`/`WorldOffset`. Physics is frame-free below the render seam. Retire the temporary `PhysicsBody.Position` world-frame bridge from Slice 2. - -**Files:** -- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs` (terrain sampling `:113-235`, `:341-342`, `:513-514`, the `Resolve` loop `:606-625`, `:858-861`) -- Modify: `src/AcDream.Core/Physics/PhysicsBody.cs` (remove the deprecated `Position` bridge accessor; physics reads `CellPosition`) -- Modify: `src/AcDream.App/Rendering/GameWindow.cs:5415-5448` (the teleport-recenter PHYSICS reads → render-only) - -**Read first:** the `_liveCenter`/`WorldOffset` classification table in the frame-map output (the ~20 render sites to LEAVE vs the physics sites to delete); `PhysicsEngine.cs` (`WorldOffsetX/Y` record `:59-60` + every consumer); design spec §4 "render reads (keep)". - -- [ ] **Step 1: Write the failing assertion that physics no longer reads `_liveCenter`/`WorldOffset`** - -A guard test: terrain sampling + `Resolve` produce identical results regardless of the streaming center value (drive the same cell-relative input with two different `_liveCenter` values; assert identical physics output). Pre-fix this fails (output depends on `WorldOffset`). - -- [ ] **Step 2: Run — expect FAIL** - -- [ ] **Step 3: Switch physics reads to the cell frame** - -Replace `world − WorldOffset` conversions in `PhysicsEngine` terrain sampling + `Resolve` with cell-frame access derived from the cell id via `GetBlockOffset` / the carried `Position`. Remove `WorldOffsetX/Y` from the physics read path (keep it only if a render caller still needs the baked value — verify none do). Delete the `PhysicsBody.Position` bridge. Make `GameWindow:5415-5448` teleport-recenter touch `_liveCenter` as a RENDER decision only (physics arrival uses the cell-relative `Position`). - -- [ ] **Step 4: Run — expect PASS (streaming-center-invariant)** - -- [ ] **Step 5: Full suites green** - -Run all four. Expected: green — the whole physics conformance set passes with `_liveCenter` invisible to physics. - -- [ ] **Step 6: Retire register rows** - -Delete AP-36 (streaming-center physics gate) and the temporary Slice-2 bridge row + the wire-leak row in `docs/architecture/retail-divergence-register.md`. Update the physics digest banner (the #145 entry → RESOLVED/cell-relative shipped). - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.Core/Physics/PhysicsEngine.cs src/AcDream.Core/Physics/PhysicsBody.cs src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md -git commit -m "refactor(physics #145): Slice 6 — _liveCenter/WorldOffset render-only; physics frame-free; retire bridge - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 7: Teleport velocity-idle (separable — the last user-visible bit of #145) - -**Goal:** On teleport arrival, idle the motion state so the player doesn't resume the pre-teleport run vector. Not a cascade fix (the cascade is gone after Slice 3) — just correctness. - -**Files:** -- Modify: `src/AcDream.App/Input/PlayerMovementController.cs` (after `PlaceTeleportArrival`'s `SetPosition`, around `:803`; the motion-idle call exists at `:718` `DoMotion(MotionCommand.Ready,1.0f)` and `MotionInterpreter.StopCompletely():510-535`) -- Test: `tests/AcDream.App.Tests/...` motion-state test - -**Read first:** `PlayerMovementController.cs:794-808` (`SetPosition`), `:840-854` (portal-space guard), `:943-977` (velocity re-apply from `get_state_velocity`); `MotionInterpreter.cs:510-535` (`StopCompletely`), `:587-636` (`get_state_velocity`). - -- [ ] **Step 1: Write the failing test** - -Simulate: player running (ForwardCommand=RunForward) → teleport arrival → first `Update()`. Assert resulting velocity is ~zero (motion state idled), not the stale run vector. - -- [ ] **Step 2: Run — expect FAIL (stale run re-applied)** - -- [ ] **Step 3: Idle the motion state on arrival** - -After `SetPosition` in the arrival path, call the motion-idle (`StopCompletely()` / `DoMotion(MotionCommand.Ready, 1.0f)`) so `InterpretedState` resets before the next `Update()` reads `get_state_velocity()`. Faithful to the existing `:718` pattern. - -- [ ] **Step 4: Run — expect PASS** - -- [ ] **Step 5: Suites green; close #145** - -Run all four. Move #145 to **Recently closed** in `docs/ISSUES.md` with the slice SHAs. Update the physics digest + milestones doc (M1.5 far-town round-trip unblocked). Note the live ACE far-town portal round-trip remains the user visual gate. - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/Input/PlayerMovementController.cs tests/ docs/ISSUES.md -git commit -m "fix(physics #145): Slice 7 — idle motion state on teleport arrival; close #145 - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Self-review (run before execution) - -**Spec coverage:** §2 retail port table → Task 1 (`GetBlockOffset`) + Tasks 3–4 (`adjust_to_outside`, `validate_transition`, `cache_global_curr_center`, contact-plane translate). §3 types → Task 1. §3.2 seam → Tasks 5–6. §3.3 working frame → Task 4. §4 divergence-site table → mapped row-by-row across Tasks 2–7. §5 conformance/transform-on-read → Tasks 2–4 fixture handling + Task 3 `TeleportFarTownRunawayTests`. §6 slices → Tasks 1–7 (1:1). §7 verification items → Task 4 "Read first" + the orchestrator notes. §8 register → per-task register steps. ✅ no gaps. - -**Placeholders:** Task 1 is fully literal. Tasks 2–7 are decomp-port tasks: they name the exact files, the decomp addresses, the test to write, the integration points, and acceptance — the "code" for a faithful port is derived from the cited decomp + existing source by design (the methodology note governs this), reviewed by the orchestrator between slices. No "TBD/handle edge cases" hand-waves. - -**Type consistency:** `Position`/`Frame` (Task 1) ↔ `CellPosition`/`SetCellPosition` (Task 2) ↔ `ResolvedPosition` (Task 2) ↔ membership/wire consumers (Tasks 3,5) ↔ contact-plane `…CellId` translate (Task 4) — names consistent throughout. `GetBlockOffset(source, dest)` signature stable across Tasks 1/4/6. - -## Execution handoff - -Subagent-driven (user-directed): fresh subagent per task, orchestrator reviews each diff against the existing code BEFORE accepting (the CLAUDE.md "subagent needs full context" mitigation), holds the green bar per slice. Slices 1→7 are strictly ordered (each builds on the prior). Live ACE far-town round-trip is the user visual gate after Slice 7. diff --git a/docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md b/docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md deleted file mode 100644 index 0194deee..00000000 --- a/docs/superpowers/plans/2026-06-21-d2b-inventory-controller.md +++ /dev/null @@ -1,976 +0,0 @@ -# D.2b B-Controller — Inventory Population Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the F12 inventory window show the player's live contents — populate the "Contents of Backpack" grid + the right-strip pack-selector from `ClientObjectTable`, drive the vertical burden meter, and render the Type-0 captions. - -**Architecture:** A new `InventoryController` (the `gm*UI::PostInit` analogue) binds the imported `gmInventoryUI` (`LayoutDesc 0x21000023`) tree by element id, partitions `GetContents(player)` into loose items + side bags, fills the `UiItemList` grids (reusing B-Grid's grid mode), drives a faithful burden meter (one new vertical-fill path on `UiMeter`), and attaches caption `UiText` overlays. Pure AC math (encumbrance) lives in `Core`; the controller + widget live in `App`. Bound inline in GameWindow's existing inventory-init block. - -**Tech Stack:** C# .NET 10, xUnit, the shipped D.2b toolkit (`UiItemList`/`UiItemSlot`/`UiMeter`/`UiText`/`ImportedLayout`/`IconComposer`). - -**Spec:** `docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md` - ---- - -## File Structure - -| File | Responsibility | Create/Modify | -|---|---|---| -| `src/AcDream.Core/Items/ClientObject.cs` (`BurdenMath`) | Encumbrance math (capacity, load ratio, fill, percent) — ports of `EncumbranceSystem` + `SetLoadLevel` | Modify | -| `src/AcDream.Core/Items/ClientObjectTable.cs` | `SumCarriedBurden(owner)` — client-side carried-Burden fallback | Modify | -| `src/AcDream.App/UI/UiMeter.cs` | Vertical fill path (the burden bar is vertical) | Modify | -| `src/AcDream.App/UI/Layout/InventoryController.cs` | The controller: bind by id, populate grids, drive burden, captions | Create | -| `src/AcDream.App/Rendering/GameWindow.cs` | Wire `InventoryController.Bind` into the inventory-init block | Modify (~2149) | -| `tests/AcDream.Core.Tests/Items/BurdenMathTests.cs` | Encumbrance golden values | Create | -| `tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs` | `SumCarriedBurden` | Create | -| `tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs` | Vertical fill rect | Create | -| `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` | Bind + populate + partition + burden + captions | Create | -| `docs/architecture/retail-divergence-register.md` | New AP rows | Modify | - -**Reference (read before coding):** spec §1 (element id table), §4 (burden formula + decomp anchors), and the live code: `ToolbarController.cs` (controller pattern), `SelectedObjectController.cs` (UiText-attach pattern), `UiItemList.cs` (grid mode), `DatWidgetFactory.BuildMeter` (meter sprite assignment — already correct for the burden meter). - ---- - -## Task 1: Encumbrance math (`BurdenMath`) - -Port `EncumbranceSystem::EncumbranceCapacity` (decomp 256393, `0x004fcc00`), `EncumbranceSystem::Load` (256413, `0x004fcc40`), and the `gmBackpackUI::SetLoadLevel` fill/percent transform (176536, `0x004a6ea0`). `BurdenMath` already has `BurdenPerStrength = 150` and `ComputeMax`. - -**Files:** -- Modify: `src/AcDream.Core/Items/ClientObject.cs` (the `BurdenMath` static class, ~line 215) -- Test: `tests/AcDream.Core.Tests/Items/BurdenMathTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.Core.Tests/Items/BurdenMathTests.cs`: - -```csharp -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -public class BurdenMathTests -{ - [Theory] - [InlineData(100, 0, 15000)] // base: str*150 - [InlineData(100, 3, 24000)] // +clamp(3*30,0,150)=90 -> +90*100 - [InlineData(100, 10, 30000)] // 10*30=300 clamped to 150 -> +150*100 - [InlineData(0, 5, 0)] // str<=0 -> 0 - public void EncumbranceCapacity_matches_retail(int str, int aug, int expected) - => Assert.Equal(expected, BurdenMath.EncumbranceCapacity(str, aug)); - - [Theory] - [InlineData(15000, 7500, 0.5f)] - [InlineData(15000, 0, 0f)] - [InlineData(0, 100, 0f)] // cap<=0 guard - public void LoadRatio_is_burden_over_capacity(int cap, int burden, float expected) - => Assert.Equal(expected, BurdenMath.LoadRatio(cap, burden), 4); - - [Theory] - [InlineData(0f, 0f)] - [InlineData(0.5f, 0.16667f)] - [InlineData(1.0f, 0.33333f)] - [InlineData(3.0f, 1.0f)] // full at 300% - [InlineData(4.0f, 1.0f)] // clamped - public void LoadToFill_is_third_clamped(float load, float expected) - => Assert.Equal(expected, BurdenMath.LoadToFill(load), 4); - - [Theory] - [InlineData(0f, 0)] - [InlineData(0.5f, 50)] - [InlineData(1.0f, 100)] - [InlineData(3.0f, 300)] - [InlineData(4.0f, 400)] // percent NOT clamped - public void LoadToPercent_is_floor_load_times_100(float load, int expected) - => Assert.Equal(expected, BurdenMath.LoadToPercent(load)); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~BurdenMathTests"` -Expected: FAIL — `BurdenMath` has no `EncumbranceCapacity`/`LoadRatio`/`LoadToFill`/`LoadToPercent` (compile error). - -- [ ] **Step 3: Add the methods to `BurdenMath`** - -In `src/AcDream.Core/Items/ClientObject.cs`, inside `public static class BurdenMath`, add (keep existing members): - -```csharp - /// Augmentation bonus-burden per aug rank (retail EncumbranceCapacity, 0x1e). - public const int AugBurdenPerRank = 30; - /// Max augmentation bonus-burden contribution per Strength (retail clamp 0x96). - public const int AugBurdenCap = 150; - - /// - /// Retail EncumbranceSystem::EncumbranceCapacity(strength, aug) - /// (named-retail decomp 256393, 0x004fcc00): - /// strength <= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength. - /// - public static int EncumbranceCapacity(int strength, int aug) - { - if (strength <= 0) return 0; - int bonus = aug * AugBurdenPerRank; - if (bonus < 0) bonus = 0; // decomp: eax_2 < 0 -> base only - if (bonus > AugBurdenCap) bonus = AugBurdenCap; - return strength * BurdenPerStrength + bonus * strength; - } - - /// - /// Retail EncumbranceSystem::Load(capacity, burden) (decomp 256413, - /// 0x004fcc40): the encumbrance ratio burden / capacity - /// (1.0 = at capacity, up to ~3.0). The decompiler mangled the FP divide to - /// return burden; the cap <= 0 guard + single divide is unambiguous. - /// Returns 0 when capacity <= 0 (no-data). - /// - public static float LoadRatio(int capacity, int burden) - => capacity <= 0 ? 0f : (float)burden / capacity; - - /// - /// Retail gmBackpackUI::SetLoadLevel bar fill (decomp 176542, - /// 0x004a6ea6): clamp(load * 0.3333…, 0, 1) — the bar is 1/3 full at - /// 100% capacity, full at 300%. - /// - public static float LoadToFill(float load) - { - float fill = load / 3f; - if (fill < 0f) return 0f; - return fill > 1f ? 1f : fill; - } - - /// - /// Retail gmBackpackUI::SetLoadLevel percent text (decomp 176576): after - /// arg2 = load/3, the text is floor(arg2 * 300) = floor(load * 100), - /// NOT clamped (reads up to 300%+). - /// - public static int LoadToPercent(float load) - => (int)System.MathF.Floor(load * 100f); -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~BurdenMathTests"` -Expected: PASS (12 cases). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core/Items/ClientObject.cs tests/AcDream.Core.Tests/Items/BurdenMathTests.cs -git commit -m "feat(core): D.2b-B — port EncumbranceSystem capacity/load + SetLoadLevel fill/percent - -EncumbranceCapacity (decomp 0x004fcc00), Load (0x004fcc40), SetLoadLevel -fill=load/3 clamped + percent=floor(load*100) (0x004a6ea0). Golden tests." -``` - ---- - -## Task 2: Carried-burden fallback (`ClientObjectTable.SumCarriedBurden`) - -acdream does not track the player's wire `EncumbranceVal` yet, so the controller falls back to summing carried `Burden`. (Divergence row in Task 7.) - -**Files:** -- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` -- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs`: - -```csharp -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -public class ClientObjectTableBurdenTests -{ - private const uint Player = 0x50000001u; - - [Fact] - public void SumCarriedBurden_sums_pack_sidebag_and_wielded_but_not_unrelated() - { - var t = new ClientObjectTable(); - // loose pack item - t.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 100 }); - // side bag in pack - t.AddOrUpdate(new ClientObject { ObjectId = 0xB, ContainerId = Player, Burden = 50, - Type = ItemType.Container }); - // item inside the side bag (2-deep) - t.AddOrUpdate(new ClientObject { ObjectId = 0xC, ContainerId = 0xB, Burden = 30 }); - // wielded (ContainerId 0, WielderId = player) - t.AddOrUpdate(new ClientObject { ObjectId = 0xD, WielderId = Player, Burden = 200 }); - // unrelated object in the world - t.AddOrUpdate(new ClientObject { ObjectId = 0xE, ContainerId = 0, Burden = 999 }); - - Assert.Equal(380, t.SumCarriedBurden(Player)); // 100+50+30+200 - } - - [Fact] - public void SumCarriedBurden_unknown_owner_is_zero() - => Assert.Equal(0, new ClientObjectTable().SumCarriedBurden(Player)); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableBurdenTests"` -Expected: FAIL — no `SumCarriedBurden`. - -- [ ] **Step 3: Add `SumCarriedBurden` to `ClientObjectTable`** - -In `src/AcDream.Core/Items/ClientObjectTable.cs`, add (near `GetContents`): - -```csharp - /// - /// Σ Burden over every object carried by : items - /// whose container chain roots at the owner (pack + side-bag contents, retail - /// hierarchy is 2-deep) plus items wielded by the owner. The client-side - /// equivalent of the server's EncumbranceVal (PropertyInt 5) — used by - /// the inventory burden bar until the wire value is parsed (B-Wire). The hop - /// cap (8) is a cycle guard; real chains are ≤2. - /// - public int SumCarriedBurden(uint ownerGuid) - { - int total = 0; - foreach (var o in _objects.Values) - if (IsCarriedBy(o, ownerGuid)) - total += o.Burden; - return total; - } - - private bool IsCarriedBy(ClientObject o, uint ownerGuid) - { - if (o.WielderId == ownerGuid) return true; - uint c = o.ContainerId; - for (int hops = 0; c != 0 && hops < 8; hops++) - { - if (c == ownerGuid) return true; - c = _objects.TryGetValue(c, out var parent) ? parent.ContainerId : 0u; - } - return false; - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableBurdenTests"` -Expected: PASS (2 cases). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs -git commit -m "feat(core): D.2b-B — ClientObjectTable.SumCarriedBurden (carried-Burden fallback)" -``` - ---- - -## Task 3: `UiMeter` vertical fill - -The burden bar (`0x100001D9`, 11×58) is vertical; `UiMeter` is horizontal-only. Per `UIElement_Meter::DrawChildren` (decomp 123574) the meter draws its own DirectState (`BackTile`) as the track + the child (`FrontTile`) clipped to the fill fraction along `m_eDirection`. `BuildMeter`'s single-image assignment is already correct (meter-own→`BackTile`/track, child→`FrontTile`/fill); this task only adds the vertical clip. - -**Files:** -- Modify: `src/AcDream.App/UI/UiMeter.cs` -- Test: `tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs`: - -```csharp -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class UiMeterVerticalTests -{ - [Fact] - public void Vertical_fill_from_bottom_occupies_lower_fraction() - { - // 11x58 bar, 50% → fill rect is the bottom 29px. - var (x, y, w, h) = UiMeter.ComputeVFillRect(0.5f, 11f, 58f, fromBottom: true); - Assert.Equal(0f, x); - Assert.Equal(29f, y, 3); // h - h*p = 58 - 29 - Assert.Equal(11f, w); - Assert.Equal(29f, h, 3); - } - - [Fact] - public void Vertical_fill_from_top_starts_at_zero() - { - var (_, y, _, h) = UiMeter.ComputeVFillRect(0.25f, 11f, 58f, fromBottom: false); - Assert.Equal(0f, y); - Assert.Equal(14.5f, h, 3); - } - - [Theory] - [InlineData(-1f, 0f)] - [InlineData(2f, 58f)] - public void Vertical_fill_clamps_fraction(float pct, float expectedH) - { - var (_, _, _, h) = UiMeter.ComputeVFillRect(pct, 11f, 58f, fromBottom: true); - Assert.Equal(expectedH, h, 3); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiMeterVerticalTests"` -Expected: FAIL — no `ComputeVFillRect`. - -- [ ] **Step 3: Add the vertical path to `UiMeter`** - -In `src/AcDream.App/UI/UiMeter.cs`: - -(a) Add properties after the sprite-id block (after `FrontRight`): - -```csharp - /// Vertical orientation (retail m_eDirection 2/4). Default false = - /// horizontal (direction 1). The burden bar is the only vertical meter today. - public bool Vertical { get; set; } - - /// For a vertical meter, fill grows from the bottom up (retail direction 4) - /// when true, top-down (direction 2) when false. Default bottom-up. Visual-confirmed. - public bool FillFromBottom { get; set; } = true; -``` - -(b) Add the testable rect helper next to `ComputeFillRect`: - -```csharp - /// Clamp to [0,1] and return the vertical fill rect - /// (local px). true → the fill occupies the bottom - /// h*pct px (retail direction 4); false → the top (direction 2). - public static (float x, float y, float w, float h) ComputeVFillRect( - float pct, float w, float h, bool fromBottom) - { - if (pct < 0f) pct = 0f; - if (pct > 1f) pct = 1f; - float fh = h * pct; - return (0f, fromBottom ? h - fh : 0f, w, fh); - } -``` - -(c) In `OnDraw`, replace the sprite branch (the `if (SpriteResolve is { } resolve && (...))` block) so it routes vertical meters to a vertical draw. Change: - -```csharp - if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0)) - { - DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width); - if (pct is not null && p > 0f) - DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p); - } -``` - -to: - -```csharp - if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0)) - { - if (Vertical) - { - // Track full height, fill clipped to the fraction along the fill direction. - // The burden meter is a single-tile bar (BackTile/FrontTile, no caps). - DrawVBar(ctx, resolve, BackTile, Height, fromBottom: FillFromBottom, isFill: false); - if (pct is not null && p > 0f) - DrawVBar(ctx, resolve, FrontTile, Height * p, fromBottom: FillFromBottom, isFill: true); - } - else - { - DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width); - if (pct is not null && p > 0f) - DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p); - } - } -``` - -(d) Add `DrawVBar` after `DrawHBar`: - -```csharp - /// Draws a single-tile vertical bar slice. The track ( - /// false) spans the full height; the fill spans px from the - /// bottom () or top, UV-cropped to that fraction of the - /// sprite so the fill reveals the matching part of the art (retail - /// UIElement_Meter::DrawChildren Box2D clip, direction 2/4). A 0 id is a no-op. - private void DrawVBar(UiRenderContext ctx, Func resolve, - uint tileId, float visibleH, bool fromBottom, bool isFill) - { - if (tileId == 0 || visibleH <= 0f) return; - var (tex, _, _) = resolve(tileId); - if (tex == 0) return; - float w = Width, h = Height; - if (visibleH > h) visibleH = h; - float frac = h > 0f ? visibleH / h : 0f; - // Bottom-up fill: bottom of the rect AND bottom of the sprite (v in [1-frac, 1]). - // Top-down / track: top of the rect AND top of the sprite (v in [0, frac]). - float y = isFill && fromBottom ? h - visibleH : 0f; - float v0 = isFill && fromBottom ? 1f - frac : 0f; - float v1 = isFill && fromBottom ? 1f : frac; - ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One); - } -``` - -(Note: track always draws full height → `frac=1`, `v0=0`, `v1=1`. Good.) - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiMeterVerticalTests"` -Expected: PASS (4 cases). Also confirm the existing meter tests still pass: -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiMeter"` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/UiMeter.cs tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs -git commit -m "feat(ui): D.2b-B — UiMeter vertical fill (burden bar, retail m_eDirection 2/4)" -``` - ---- - -## Task 4: `InventoryController` — bind + grid population - -The controller skeleton: find-by-id bind, partition `GetContents(player)`, populate the 3D-items grid + side-bag list + main-pack cell. (Burden + captions in Task 5.) - -**Files:** -- Create: `src/AcDream.App/UI/Layout/InventoryController.cs` -- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs`. The helper builds a dat-free `ImportedLayout` from constructed widgets (constructor is public): - -```csharp -using System.Collections.Generic; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.App.Tests.UI.Layout; - -public class InventoryControllerTests -{ - private const uint Player = 0x50000001u; - - // UiElement is abstract — a concrete, parameterless stand-in for the root + caption hosts - // (the real captions are Type-0 → UiDatElement; in tests we only need a host that holds children). - private sealed class TestElement : UiElement { } - - // Element ids (spec §1). - private const uint ContentsGrid = 0x100001C6u; - private const uint ContainerList = 0x100001CAu; - private const uint TopContainer = 0x100001C9u; - private const uint BurdenMeter = 0x100001D9u; - private const uint BurdenText = 0x100001D8u; - private const uint BurdenCaption = 0x100001D7u; - private const uint ContentsCaption = 0x100001C5u; - - private static (ImportedLayout layout, UiItemList grid, UiItemList containers, - UiItemList top, UiMeter meter, UiElement burdenText, - UiElement burdenCap, UiElement contentsCap) BuildLayout() - { - var grid = new UiItemList { Width = 192, Height = 96 }; - var containers = new UiItemList { Width = 36, Height = 252 }; - var top = new UiItemList { Width = 36, Height = 36 }; - var meter = new UiMeter { Width = 11, Height = 58 }; - var burdenText = new TestElement { Width = 36, Height = 15 }; - var burdenCap = new TestElement { Width = 36, Height = 15 }; - var contentsCap= new TestElement { Width = 192, Height = 15 }; - var root = new TestElement { Width = 300, Height = 362 }; - root.AddChild(grid); root.AddChild(containers); root.AddChild(top); - root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap); - root.AddChild(contentsCap); - var byId = new Dictionary - { - [ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top, - [BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap, - [ContentsCaption] = contentsCap, - }; - return (new ImportedLayout(root, byId), grid, containers, top, meter, - burdenText, burdenCap, contentsCap); - } - - private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, - int? strength = 100) - => InventoryController.Bind(layout, objects, () => Player, - iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests - strength: () => strength, datFont: null); - - [Fact] - public void Populate_fills_contents_grid_with_loose_items_only() - { - var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, - ContainerSlot = 0, Burden = 10 }); // loose - objects.AddOrUpdate(new ClientObject { ObjectId = 0xB, ContainerId = Player, - ContainerSlot = 1, Burden = 20 }); // loose - objects.AddOrUpdate(new ClientObject { ObjectId = 0xC, ContainerId = Player, - ContainerSlot = 2, Type = ItemType.Container }); // side bag - - Bind(layout, objects); - - Assert.Equal(2, grid.GetNumUIItems()); // 2 loose - Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); - Assert.Equal(0xBu, grid.GetItem(1)!.ItemId); - Assert.Equal(1, containers.GetNumUIItems()); // 1 side bag - Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); - } - - [Fact] - public void Grid_is_six_columns_thirtytwo_px() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - Bind(layout, new ClientObjectTable()); - Assert.Equal(6, grid.Columns); - Assert.Equal(32f, grid.CellWidth); - Assert.Equal(32f, grid.CellHeight); - } - - [Fact] - public void ObjectAdded_for_player_item_rebuilds_grid() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - Bind(layout, objects); - Assert.Equal(0, grid.GetNumUIItems()); - - objects.Ingest(new WeenieData(0xA, "Sword", ItemType.MeleeWeapon, 1, 0, 0, 0, 0, - null, null, null, 5, Player, null, null, null, null, null, null, null, null, null)); - - Assert.Equal(1, grid.GetNumUIItems()); - Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` -Expected: FAIL — no `InventoryController`. - -- [ ] **Step 3: Create `InventoryController` (bind + populate)** - -Create `src/AcDream.App/UI/Layout/InventoryController.cs`: - -```csharp -using System; -using AcDream.Core.Items; - -namespace AcDream.App.UI.Layout; - -/// -/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from -/// . The acdream analogue of retail -/// gmInventoryUI/gmBackpackUI/gm3DItemsUI ::PostInit (named-retail decomp 176236/176596/176728). -/// Read-only: no container switching / drag / wield-drop wire (later sub-phases). -/// -public sealed class InventoryController -{ - // Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds). - public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack") - public const uint ContainerListId = 0x100001CAu; // gmBackpackUI m_containerList (side-bag selector) - public const uint TopContainerId = 0x100001C9u; // gmBackpackUI m_topContainer (main-pack cell) - public const uint BurdenMeterId = 0x100001D9u; // gmBackpackUI m_burdenMeter (vertical) - public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%") - public const uint BurdenCaptionId = 0x100001D7u; // "Burden" - public const uint ContentsCaptionId= 0x100001C5u; // "Contents of Backpack" - - // 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037). - private const int ContentsColumns = 6; - private const float CellPx = 32f; - - private readonly ClientObjectTable _objects; - private readonly Func _playerGuid; - private readonly Func _iconIds; - - private readonly UiItemList? _contentsGrid; - private readonly UiItemList? _containerList; - private readonly UiItemList? _topContainer; - - private InventoryController( - ImportedLayout layout, - ClientObjectTable objects, - Func playerGuid, - Func iconIds, - Func strength, - UiDatFont? datFont) - { - _objects = objects; - _playerGuid = playerGuid; - _iconIds = iconIds; - - _contentsGrid = layout.FindElement(ContentsGridId) as UiItemList; - _containerList = layout.FindElement(ContainerListId) as UiItemList; - _topContainer = layout.FindElement(TopContainerId) as UiItemList; - - if (_contentsGrid is not null) - { - _contentsGrid.Columns = ContentsColumns; - _contentsGrid.CellWidth = CellPx; - _contentsGrid.CellHeight = CellPx; - } - if (_containerList is not null) - { - _containerList.Columns = 1; - _containerList.CellWidth = CellPx; - _containerList.CellHeight = CellPx; - } - - // Burden + captions are bound in Task 5 (BindBurden / BindCaptions called here). - - // Rebuild on any change to the player's possessions. - _objects.ObjectAdded += OnObjectChanged; - _objects.ObjectMoved += OnObjectMoved; - _objects.ObjectRemoved += OnObjectChanged; - _objects.ObjectUpdated += OnObjectChanged; - - Populate(); - } - - public static InventoryController Bind( - ImportedLayout layout, - ClientObjectTable objects, - Func playerGuid, - Func iconIds, - Func strength, - UiDatFont? datFont) - => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont); - - private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } - private void OnObjectMoved(ClientObject o, uint from, uint to) - { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } - - /// True if the object is in (or wielded by) the player — i.e. a rebuild is warranted. - private bool Concerns(ClientObject o) - { - uint p = _playerGuid(); - return o.ContainerId == p || o.WielderId == p - || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep - } - - /// Retail gm*UI display refresh: partition the player's direct contents into loose - /// items (the "Contents of Backpack" grid) and side bags (the pack-selector list). - public void Populate() - { - uint p = _playerGuid(); - var contents = _objects.GetContents(p); - - _contentsGrid?.Flush(); - _containerList?.Flush(); - - foreach (var guid in contents) - { - var item = _objects.Get(guid); - if (item is null) continue; - bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; - var list = isBag ? _containerList : _contentsGrid; - if (list is null) continue; - uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, - item.IconOverlayId, item.Effects); - var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; - cell.SetItem(guid, tex); - list.AddItem(cell); - } - - // Main-pack cell: the player's own container. Icon = placeholder until a backpack - // RenderSurface DID is pinned (spec §3 / divergence row). Bind the guid so a future - // click can select it. - if (_topContainer is not null) - { - _topContainer.Flush(); - var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; - main.SetItem(p, 0u); - _topContainer.AddItem(main); - } - } - - /// Detach event handlers (idempotent). - public void Dispose() - { - _objects.ObjectAdded -= OnObjectChanged; - _objects.ObjectMoved -= OnObjectMoved; - _objects.ObjectRemoved -= OnObjectChanged; - _objects.ObjectUpdated -= OnObjectChanged; - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` -Expected: PASS (3 cases). (Note: `UiItemList` is constructed with one default cell; `Flush()` then `AddItem` per item gives exact counts — the default cell is flushed away.) - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs -git commit -m "feat(ui): D.2b-B — InventoryController bind + grid population (loose/side-bag partition)" -``` - ---- - -## Task 5: `InventoryController` — burden meter + captions - -Drive the vertical burden meter + `%` text + the two captions. - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` -- Modify: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` - -- [ ] **Step 1: Write the failing test** - -Append to `InventoryControllerTests.cs`: - -```csharp - [Fact] - public void Burden_meter_fill_and_percent_from_load() - { - var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - // Str 100 → capacity 15000. Carried 7500 → load 0.5 → fill 0.1667, "50%". - objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 7500 }); - Bind(layout, objects, strength: 100); - - Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3); - Assert.True(meter.Vertical); - // The % text caption child reads "50%". - Assert.Contains("50%", CaptionText(burdenText)); - } - - [Fact] - public void Captions_render_known_strings() - { - var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout(); - Bind(layout, new ClientObjectTable()); - Assert.Contains("Burden", CaptionText(burdenCap)); - Assert.Contains("Contents of Backpack", CaptionText(contentsCap)); - } - - // Reads the text of the UiText caption child attached by the controller. - private static string CaptionText(UiElement host) - { - foreach (var c in host.Children) - if (c is UiText t) - { - var lines = t.LinesProvider(); - if (lines.Count > 0) return lines[0].Text; - } - return ""; - } -``` - -> If `UiElement.Children` is not publicly enumerable, expose a read-only view or use the existing accessor the other controllers' tests use (check `SelectedObjectController` tests). The `host.Children` enumeration mirrors `UiElement.AddChild` usage in `SelectedObjectController`. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` -Expected: FAIL — meter `Fill` not driven; no caption children. - -- [ ] **Step 3: Add burden + captions to `InventoryController`** - -Add fields + wire into the constructor (after the grid setup, before `Populate()`), and the helper methods. Add `using System.Numerics;` and `using System.Collections.Generic;`. - -Fields: -```csharp - private readonly Func _strength; - private readonly UiMeter? _burdenMeter; - private float _burdenFill; - private int _burdenPercent; - private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f); -``` - -In the constructor, set `_strength = strength;`, then after the list setup: -```csharp - _burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter; - if (_burdenMeter is not null) - { - _burdenMeter.Vertical = true; // 11x58 vertical bar - _burdenMeter.FillFromBottom = true; // retail direction 4 (visual-confirmed) - _burdenMeter.Fill = () => _burdenFill; - } - - // Captions: attach a centered UiText child carrying the known string. "Contents of - // Backpack" + "%d%%" are procedural in retail (gm3DItemsUI/gmBackpackUI PostInit/ - // SetLoadLevel); "Burden" is the dat label. (Caption pass, spec §5.) - AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont); - AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of Backpack", datFont); - AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont); - - RefreshBurden(); -``` - -Also subscribe to Strength changes — but `LocalPlayerState` lives in Core and the controller only gets `Func strength`; recompute burden on item events (already wired) is sufficient for the live path. (Strength rarely changes mid-session; the next item event refreshes it. A dedicated attribute subscription is wired at the GameWindow call site in Task 6 if needed.) - -Add helpers: -```csharp - private void AttachCaption(UiElement? host, Func text, UiDatFont? datFont) - { - if (host is null) return; - var label = new UiText - { - Left = 0f, Top = 0f, Width = host.Width, Height = host.Height, - Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, - Centered = true, DatFont = datFont, ClickThrough = true, - AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false, - LinesProvider = () => - { - var s = text(); - return string.IsNullOrEmpty(s) - ? System.Array.Empty() - : new[] { new UiText.Line(s, CaptionColor) }; - }, - }; - host.AddChild(label); - } - - /// Recompute the burden fill + percent. Port of CACQualities::InqLoad - /// (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel (0x004a6ea0). currentBurden: - /// player wire EncumbranceVal (PropertyInt 5) if present, else the carried-Burden sum. - private void RefreshBurden() - { - uint p = _playerGuid(); - int str = _strength() ?? 10; // InqAttribute default 0xa - int aug = _objects.Get(p)?.Properties.GetInt(EncumbranceAugProperty) ?? 0; - int capacity = BurdenMath.EncumbranceCapacity(str, aug); - - int? wire = _objects.Get(p)?.Properties.Ints.TryGetValue(EncumbranceValProperty, out var ev) == true - ? ev : (int?)null; - int burden = wire ?? _objects.SumCarriedBurden(p); - - float load = BurdenMath.LoadRatio(capacity, burden); - _burdenFill = BurdenMath.LoadToFill(load); - _burdenPercent = BurdenMath.LoadToPercent(load); - } - - // ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)). - private const uint EncumbranceValProperty = 5u; // total carried burden - private const uint EncumbranceAugProperty = 0xE6u; // carry-capacity augmentation -``` - -Finally, call `RefreshBurden()` from `Populate()` (so item changes refresh the bar). Add at the end of `Populate()`: -```csharp - RefreshBurden(); -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` -Expected: PASS (5 cases). Run the whole App suite to confirm no regression: -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs -git commit -m "feat(ui): D.2b-B — InventoryController burden meter (InqLoad port) + captions" -``` - ---- - -## Task 6: Wire `InventoryController` into GameWindow - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (field ~607; inventory-init block ~2149-2158) - -- [ ] **Step 1: Add the controller field** - -Near the other UI controller fields (e.g. after `_selectedObjectController`; grep `private … _selectedObjectController` for the exact spot), add: - -```csharp - private AcDream.App.UI.Layout.InventoryController? _inventoryController; -``` - -- [ ] **Step 2: Bind it in the inventory-init block** - -In `GameWindow.cs`, the block at ~2149 currently ends with `RegisterWindow(...)` + a `Console.WriteLine`. Insert the bind BEFORE the `Console.WriteLine("[D.2b-B] retail inventory window …")`, using the same `iconComposer` / `vitalsDatFont` / `Objects` already in scope (mirroring the toolbar bind at 2013): - -```csharp - _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow); - - // Phase D.2b-B — populate the inventory from ClientObjectTable: the - // "Contents of Backpack" grid + the pack-selector strip + the burden meter + - // captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit. - _inventoryController = AcDream.App.UI.Layout.InventoryController.Bind( - invLayout, Objects, - playerGuid: () => _playerServerGuid, - iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), - strength: () => (int?)LocalPlayer.GetAttribute( - AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)?.Current, - datFont: vitalsDatFont); - - Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); -``` - -- [ ] **Step 3: Build** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Expected: `Build succeeded. 0 Error(s)`. - -- [ ] **Step 4: Full build + test** - -Run: `dotnet build AcDream.slnx -c Debug` then `dotnet test AcDream.slnx -c Debug` -Expected: all green. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(ui): D.2b-B — wire InventoryController into the inventory-init block" -``` - ---- - -## Task 7: Divergence register + roadmap/issues + memory - -**Files:** -- Modify: `docs/architecture/retail-divergence-register.md` -- Modify: `docs/ISSUES.md` and/or `docs/plans/2026-04-11-roadmap.md` (if a row tracks B-Controller) - -- [ ] **Step 1: Add divergence rows** - -Append rows to `docs/architecture/retail-divergence-register.md` (match the table's existing column format; use the next `AP-NN` ids): - -- **AP** — inventory burden `currentBurden` summed client-side (`ClientObjectTable.SumCarriedBurden`) when the player's wire `EncumbranceVal` (PropertyInt 5) is absent; retail reads the server value via `CACQualities::InqLoad`. Risk: drift from coins/untracked items. Retire when B-Wire parses `EncumbranceVal`. (`InventoryController.RefreshBurden`) -- **AP** — capacity augmentation (PropertyInt `0xE6`) not tracked → `bonus=0` → un-augmented `Str×150`. Risk: augmented chars read slightly low. (`BurdenMath.EncumbranceCapacity` caller) -- **AP** — burden meter orientation/direction set from geometry + default bottom-up (`UiMeter.FillFromBottom=true`) rather than retail's `m_eDirection` from LayoutDesc property `0x6f`. Risk: wrong fill direction (visual-confirmed). (`InventoryController` / `UiMeter.Vertical`) -- **AP** — main-pack `m_topContainer` cell uses placeholder/empty icon, not a weenie-driven icon (the main pack ≡ the player, no item IconId). Risk: blank main-pack cell. (`InventoryController.Populate`) - -- [ ] **Step 2: Update ISSUES/roadmap** - -If `docs/ISSUES.md` has a B-Controller line, move it to "Recently closed" with the final commit SHA; note B-Wire / B-Drag / Sub-phase C remain open. Otherwise add a one-line "Recently closed" entry. - -- [ ] **Step 3: Commit** - -```bash -git add docs/architecture/retail-divergence-register.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md -git commit -m "docs(D.2b-B): divergence rows + issues/roadmap for inventory population" -``` - ---- - -## Task 8: Visual verification (acceptance gate — user) - -**Not a code task.** Build green, launch with `ACDREAM_RETAIL_UI=1`, press F12, and confirm with the user: - -- [ ] Pack items show as icons in the "Contents of Backpack" grid (6 cols). -- [ ] Side bags (if any) appear in the right strip; the main-pack cell is present. -- [ ] The burden bar fills **vertically** with the right sprites; the fill **direction** (bottom-up) looks right; the `%` reads sanely. -- [ ] The "Burden" + "Contents of Backpack" captions render. -- [ ] **No #145 z-order regression** on chat + toolbar (the deferred eyeball — chat is provably safe; the toolbar now honors ZLevel). - -Two visual-confirm unknowns to settle here (not guessed in code): the **cell pitch** (32px → 6 cols is the clean fit) and the **fill direction** (bottom-up = retail dir 4). If either is wrong, the fix is a one-line change (`ContentsColumns`/`CellPx` or `FillFromBottom`). - -After the user confirms, update `memory/project_d2b_retail_ui.md` (or the `claude-memory` mirror) with a B-Controller "SHIPPED" note + the key facts (burden formula port; `BuildMeter` already-correct finding; the four AP rows). - ---- - -## Self-Review - -**Spec coverage:** §1 element map → Task 4/5 ids. §2 architecture (controller, inline bind) → Task 4/6. §3 population (3D grid 6-col, side bags, top cell) → Task 4. §4 burden (formula + vertical + data source) → Task 1/3/5. §5 captions → Task 5. §6 divergence rows → Task 7. §7 testing (bind/populate/partition/burden golden/vertical/caption/rebuild) → Tasks 1-5. §8 visual → Task 8. §9 acceptance → all. ✓ No gaps. - -**Placeholder scan:** All steps have concrete code/commands. The two deferred values (main-pack icon DID, property-`0x6f` read) are explicit, register-documented fallbacks, not placeholders. The one soft spot — `UiElement.Children` public enumeration in the Task 5 test — has an inline note to verify against the existing controller-test pattern. ✓ - -**Type consistency:** `BurdenMath.EncumbranceCapacity/LoadRatio/LoadToFill/LoadToPercent` (Task 1) used in Task 5 `RefreshBurden`. `ClientObjectTable.SumCarriedBurden` (Task 2) used in Task 5. `UiMeter.Vertical/FillFromBottom/ComputeVFillRect` (Task 3) used in Task 5 + Task 3 tests. `InventoryController.Bind(layout, objects, playerGuid, iconIds, strength, datFont)` signature identical in Task 4 def, Task 4/5 tests, Task 6 call site. `UiItemSlot.SetItem(guid, tex)` + `UiItemList.Flush/AddItem/Columns/CellWidth/CellHeight/GetNumUIItems/GetItem` match the shipped `UiItemList.cs`. ✓ diff --git a/docs/superpowers/plans/2026-06-21-d2b-inventory-window-finish.md b/docs/superpowers/plans/2026-06-21-d2b-inventory-window-finish.md deleted file mode 100644 index 1640f928..00000000 --- a/docs/superpowers/plans/2026-06-21-d2b-inventory-window-finish.md +++ /dev/null @@ -1,535 +0,0 @@ -# D.2b inventory window finish (Stage 1) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the inventory window match retail's 2D presentation — the contents grid clips + scrolls, the backdrop covers the whole window, and the side-bag column shows proper slots. - -**Architecture:** Reuse the existing `UiScrollable` scroll model (the chat transcript + `UiScrollbar` already use it) by giving `UiItemList` a clip+scroll capability (cells positioned offset by `-ScrollY`, `cell.Visible` toggled by whole-row clip — the same whole-row approach `UiText` uses, since there is no GL scissor). `InventoryController` binds the dat gutter scrollbar `0x100001C7` to the grid's model exactly like `ChatWindowController` does, splits the cell pitch (contents 32px / backpack 36px), and pads the side-bag column with empty slots. The backdrop is mostly fixed for free by clipping the grid; any residual is a screenshot-gated render fix. - -**Tech Stack:** C# / .NET 10, xUnit. `src/AcDream.App/UI/`. Spec: `docs/superpowers/specs/2026-06-21-d2b-inventory-window-finish-design.md`. Grounding (dat geometry): contents grid `0x100001C6` = 192×96 (6×3, 32px cells); gutter scrollbar `0x100001C7` = 16×96; side-bag column `0x100001CA` = 36×252 (7 slots of 36px); backdrop `0x100001D0` = 300×362 full-window. - -**Reuse references (read before coding):** -- `src/AcDream.App/UI/UiScrollable.cs` — the scroll model (ContentHeight/ViewHeight/ScrollY/ScrollByLines/etc.). Already unit-tested; do NOT re-test it. -- `src/AcDream.App/UI/UiText.cs:117-247` — the whole-row clip (`y < top || y+lh > bottom → skip`, line 198) + the wheel handler (`OnEvent` Scroll case, line 239) to mirror. -- `src/AcDream.App/UI/Layout/ChatWindowController.cs:44-49` (scrollbar sprite-id constants) + `:237-255` (the `FindElement → bar.Model = …Scroll + sprite ids` binding pattern). -- `src/AcDream.App/UI/UiItemList.cs` + `src/AcDream.App/UI/Layout/InventoryController.cs` — the files being changed. -- `tests/AcDream.App.Tests/UI/UiItemListTests.cs` + `.../UI/Layout/InventoryControllerTests.cs` — test style + the `BuildLayout`/`Bind`/`SeedContained` harness. - -Every commit message MUST end with: -`Co-Authored-By: Claude Opus 4.8 (1M context) ` - ---- - -## Task 1: `UiItemList` — scroll model + scroll-aware clip layout - -Give the grid a `UiScrollable` and make `LayoutCells` clip whole rows + offset by the scroll position. Fill mode (the toolbar single-cell, `CellWidth <= 0`) is unchanged. - -**Files:** -- Modify: `src/AcDream.App/UI/UiItemList.cs` -- Test: `tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs` (create) - -- [ ] **Step 1: Write the failing tests** - -Create `tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs`: - -```csharp -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public sealed class UiItemListScrollTests -{ - private static UiItemList Grid(int items) - { - var list = new UiItemList { Columns = 6, CellWidth = 32, CellHeight = 32, Width = 192, Height = 96 }; - list.Flush(); // drop the default single cell - for (int i = 0; i < items; i++) list.AddItem(new UiItemSlot()); - list.LayoutCells(); // drive the scroll model + position cells - return list; - } - - [Fact] - public void RowCount_ceils_to_whole_rows() - { - Assert.Equal(0, UiItemList.RowCount(0, 6)); - Assert.Equal(1, UiItemList.RowCount(1, 6)); - Assert.Equal(7, UiItemList.RowCount(41, 6)); - } - - [Fact] - public void Grid_drives_scroll_model_from_content() - { - var list = Grid(30); // 5 rows × 32 = 160 content, 96 view - Assert.Equal(160, list.Scroll.ContentHeight); - Assert.Equal(96, list.Scroll.ViewHeight); - Assert.Equal(64, list.Scroll.MaxScroll); - Assert.True(list.Scroll.HasOverflow); - } - - [Fact] - public void At_top_first_three_rows_visible_rest_clipped() - { - var list = Grid(30); - Assert.True(list.GetItem(0)!.Visible); // row 0, top 0 - Assert.True(list.GetItem(12)!.Visible); // row 2, top 64 (+32 == 96) - Assert.False(list.GetItem(18)!.Visible); // row 3, top 96 (off the bottom) - } - - [Fact] - public void Scrolled_one_row_shifts_window_and_clips_top_row() - { - var list = Grid(30); - list.Scroll.SetScrollY(32); - list.LayoutCells(); - Assert.False(list.GetItem(0)!.Visible); // row 0 scrolled above - Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 now at the view top - Assert.True(list.GetItem(18)!.Visible); // row 3 now visible - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests"` -Expected: FAIL — `UiItemList` has no `Scroll`, no `RowCount`, `LayoutCells` is private. - -- [ ] **Step 3: Implement** - -In `src/AcDream.App/UI/UiItemList.cs`: - -(a) add `using System;` if missing, and the scroll model field after `_cells`: - -```csharp - /// Vertical scroll model for grid mode (clip+scroll). Bound to the gutter - /// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell). - public UiScrollable Scroll { get; } = new(); -``` - -(b) add the row-count helper (next to `CellOffset`): - -```csharp - /// Whole rows needed for cells in a grid of - /// columns. - public static int RowCount(int cellCount, int columns) - { - int cols = columns < 1 ? 1 : columns; - return (cellCount + cols - 1) / cols; - } -``` - -(c) replace the whole `private void LayoutCells()` body with this (and change `private` → `internal` so tests can drive it): - -```csharp - internal void LayoutCells() - { - if (CellWidth <= 0f) - { - // Fill mode (the toolbar single cell): size the one cell to the list. - if (_cells.Count > 0) - { - var c = _cells[0]; - c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height; c.Visible = true; - } - return; - } - - int cols = Columns < 1 ? 1 : Columns; - int cellH = (int)MathF.Round(CellHeight); - - // Drive the shared scroll model from the current geometry, then re-clamp the - // offset to the (possibly changed) max. ContentHeight/ViewHeight must be set - // BEFORE reading ScrollY so the clamp uses the right max. - Scroll.LineHeight = cellH > 0 ? cellH : 1; - Scroll.ContentHeight = RowCount(_cells.Count, cols) * cellH; - Scroll.ViewHeight = (int)MathF.Floor(Height); - Scroll.SetScrollY(Scroll.ScrollY); // re-clamp to new max - float scrollY = Scroll.ScrollY; - - for (int i = 0; i < _cells.Count; i++) - { - int col = i % cols, row = i / cols; - float top = row * CellHeight - scrollY; - var cell = _cells[i]; - cell.Left = col * CellWidth; - cell.Top = top; - cell.Width = CellWidth; - cell.Height = CellHeight; - // Whole-row vertical clip (no scissor — mirrors UiText.cs:198). A row fully - // inside [0, Height] draws; a partially-scrolled row is hidden. - cell.Visible = top >= -0.5f && top + CellHeight <= Height + 0.5f; - } - } -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests"` -Expected: PASS (4 tests). - -- [ ] **Step 5: Run the existing UiItemList tests (no regression)** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemList"` -Expected: PASS (the scroll tests + the pre-existing `UiItemListTests`/`UiItemListGridTests`; fill mode + grid offsets unchanged for non-scrolled grids). - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs -git commit -F - <<'EOF' -feat(ui): D.2b inventory finish — UiItemList clip+scroll via UiScrollable - -Grid mode now drives a shared UiScrollable from its content + clips whole rows -to the panel (cells offset by -ScrollY, Visible toggled by whole-row clip, -mirroring UiText). Fill mode (toolbar single cell) unchanged. - -Co-Authored-By: Claude Opus 4.8 (1M context) -EOF -``` - ---- - -## Task 2: `UiItemList` — mouse-wheel scroll - -Wheel over the grid scrolls it, mirroring `UiText`'s Scroll handler. - -**Files:** -- Modify: `src/AcDream.App/UI/UiItemList.cs` -- Test: `tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs` (append) - -- [ ] **Step 1: Write the failing test** - -First open `src/AcDream.App/UI/UiText.cs` around line 239 to confirm the `UiEvent` Scroll shape (`e.Data0` = wheel delta). Then append to `UiItemListScrollTests.cs`: - -```csharp - [Fact] - public void Wheel_down_scrolls_toward_the_bottom() - { - var list = Grid(30); // max scroll 64, LineHeight 32 - // Silk wheel +Y = up/older; UiText negates Data0. Wheel DOWN (Data0 < 0) → +ScrollY. - var e = new UiEvent { Type = UiEventType.Scroll, Data0 = -1f }; - bool handled = list.OnEvent(e); - Assert.True(handled); - Assert.Equal(32, list.Scroll.ScrollY); // one line (32px) down - } -``` - -(If `UiEvent`'s field types differ — e.g. `Data0` is `int` — match them; the construction style is the only thing to align, the assertion stands.) - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests.Wheel"` -Expected: FAIL — `UiItemList` doesn't override `OnEvent` (wheel ignored, ScrollY stays 0). - -- [ ] **Step 3: Implement** - -In `src/AcDream.App/UI/UiItemList.cs` add an `OnEvent` override (mirror `UiText.cs:239`): - -```csharp - public override bool OnEvent(in UiEvent e) - { - if (e.Type == UiEventType.Scroll && CellWidth > 0f) - { - // Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0. - Scroll.ScrollByLines(-(int)e.Data0); - return true; - } - return base.OnEvent(e); - } -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListScrollTests"` -Expected: PASS (5 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs -git commit -F - <<'EOF' -feat(ui): D.2b inventory finish — UiItemList mouse-wheel scroll - -OnEvent handles the Scroll wheel in grid mode (mirrors UiText's sign), driving -the shared UiScrollable. The bound gutter scrollbar (Task 3) is the primary -scroll affordance; the wheel is the convenience path. - -Co-Authored-By: Claude Opus 4.8 (1M context) -EOF -``` - -> NOTE for the implementer: the wheel only fires if a Scroll event reaches the list (directly hovered, or bubbled from a cell that doesn't consume Scroll). `UiItemSlot` does not handle Scroll, so it should bubble — but confirm at the visual gate (Task 5). If the wheel doesn't reach the list, the scrollbar (Task 3) still works; file a follow-up rather than reworking event dispatch here. - ---- - -## Task 3: `InventoryController` — bind the contents-grid gutter scrollbar - -Bind `0x100001C7` (a factory-built Type-11 `UiScrollbar`) to the contents grid's scroll model, with the same sprite ids the chat scrollbar uses (both inherit base layout `0x2100003E`). - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` -- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` (extend `BuildLayout` + add a test) - -- [ ] **Step 1: Write the failing test** - -In `InventoryControllerTests.cs`: (a) add the scrollbar id constant near the others (`private const uint ContentsScrollbar = 0x100001C7u;`); (b) extend `BuildLayout` to create + register + return a `UiScrollbar`. Change its signature/return tuple to append `UiScrollbar scrollbar`: - -```csharp - var scrollbar = new UiScrollbar { Width = 16, Height = 96 }; - // ... after the existing root.AddChild(...) calls: - root.AddChild(scrollbar); - // ... in the byId dictionary initializer add: - // [ContentsScrollbar] = scrollbar, - // ... and append scrollbar to the returned tuple. -``` - -Then add the test: - -```csharp - [Fact] - public void Contents_grid_scrollbar_binds_to_the_grid_scroll_model() - { - var (layout, grid, _, _, _, _, _, _, scrollbar) = BuildLayout(); - Bind(layout, new ClientObjectTable()); - Assert.Same(grid.Scroll, scrollbar.Model); // the bar drives the grid's scroll - } -``` - -(Update the other `BuildLayout()` call sites' tuple deconstruction to add a trailing `_` for the new element.) - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests.Contents_grid_scrollbar"` -Expected: FAIL — `scrollbar.Model` is null (controller doesn't bind it). - -- [ ] **Step 3: Implement** - -In `src/AcDream.App/UI/Layout/InventoryController.cs`: - -(a) add the element-id + sprite-id constants (sprite ids copied from `ChatWindowController.cs:44-49` — both scrollbars inherit `0x2100003E`): - -```csharp - public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar - // Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar). - private const uint ScrollTrackSprite = 0x06004C5Fu; - private const uint ScrollThumbSprite = 0x06004C63u; - private const uint ScrollThumbTop = 0x06004C60u; - private const uint ScrollThumbBot = 0x06004C66u; - private const uint ScrollUpSprite = 0x06004C6Cu; - private const uint ScrollDownSprite = 0x06004C69u; -``` - -(b) in the constructor, after the `_contentsGrid` setup block (the `if (_contentsGrid is not null) { … }` around line 63-68), bind the scrollbar: - -```csharp - // Bind the gutter scrollbar to the contents grid's scroll model (the factory built - // 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does). - if (_contentsGrid is not null - && layout.FindElement(ContentsScrollbarId) is UiScrollbar bar) - { - bar.Model = _contentsGrid.Scroll; - bar.SpriteResolve = _contentsGrid.SpriteResolve; // chrome resolve from the factory ctor - bar.TrackSprite = ScrollTrackSprite; - bar.ThumbSprite = ScrollThumbSprite; - bar.ThumbTopSprite = ScrollThumbTop; - bar.ThumbBotSprite = ScrollThumbBot; - bar.UpSprite = ScrollUpSprite; - bar.DownSprite = ScrollDownSprite; - } -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` -Expected: PASS (the new test + all pre-existing InventoryController tests, incl. the B-Wire burden tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs -git commit -F - <<'EOF' -feat(ui): D.2b inventory finish — bind contents-grid gutter scrollbar - -InventoryController binds 0x100001C7 (factory Type-11 UiScrollbar) to the -contents grid's UiScrollable + the shared 0x2100003E scrollbar sprites, -mirroring ChatWindowController. The grid now scrolls instead of overflowing. - -Co-Authored-By: Claude Opus 4.8 (1M context) -EOF -``` - ---- - -## Task 4: `InventoryController` — side-bag column (36px pitch + empty-slot padding) - -The side-bag column should show the player's bags + empty slot frames up to capacity, at the correct 36px pitch. - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` -- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` (append) - -- [ ] **Step 1: Write the failing test** - -Append to `InventoryControllerTests.cs`: - -```csharp - [Fact] - public void Side_bag_column_pads_empty_slots_up_to_capacity() - { - var (layout, _, containers, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - objects.AddOrUpdate(new ClientObject { ObjectId = Player, ContainersCapacity = 3 }); - SeedContained(objects, 0xC, Player, slot: 0, type: ItemType.Container); // one side bag - - Bind(layout, objects); - - Assert.Equal(3, containers.GetNumUIItems()); // 1 bag + 2 empty = capacity 3 - Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); // the bag - Assert.Equal(0u, containers.GetItem(1)!.ItemId); // empty frame - Assert.Equal(0u, containers.GetItem(2)!.ItemId); // empty frame - } -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests.Side_bag_column"` -Expected: FAIL — only 1 cell (the bag); no empty padding. - -- [ ] **Step 3: Implement** - -In `src/AcDream.App/UI/Layout/InventoryController.cs`: - -(a) split the cell pitch — replace the single `private const float CellPx = 32f;` with: - -```csharp - private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px) - private const float BackpackCellPx = 36f; // gmBackpackUI column cells (0x100001C9/CA = 36px) - private const int SideBagSlots = 7; // 0x100001CA is 36x252 = 7 slots -``` - -Update the constructor's three cell-pitch assignments: `_contentsGrid` uses `ContentsCellPx` (was `CellPx`); `_containerList` and `_topContainer` use `BackpackCellPx`. - -(b) in `Populate()`, after the `foreach (var guid in contents)` loop that adds side bags to `_containerList`, pad it with empty slots. Find where the loop ends (before the `_topContainer` block) and add: - -```csharp - // Side-bag column: pad with empty slot frames up to the player's container - // capacity (clamped to the 7-slot column), so the column reads like retail - // (bags on top, empty frames below) rather than one lone cell. Divergence AP-XX - // if capacity is absent → default to the full 7-slot column. - if (_containerList is not null) - { - int capacity = _objects.Get(p)?.ContainersCapacity ?? 0; - int slots = capacity > 0 ? capacity : SideBagSlots; - slots = Math.Clamp(slots, _containerList.GetNumUIItems(), SideBagSlots); - while (_containerList.GetNumUIItems() < slots) - _containerList.AddItem(new UiItemSlot { SpriteResolve = _containerList.SpriteResolve }); - } -``` - -(Place this AFTER the `foreach` that adds bags and BEFORE the `if (_topContainer is not null)` block. The `_contentsGrid`/`_containerList` were already `Flush()`ed at the top of `Populate`, so the count here is exactly the bags added this pass.) - -- [ ] **Step 4: Run to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` -Expected: PASS (all InventoryController tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs -git commit -F - <<'EOF' -feat(ui): D.2b inventory finish — side-bag column slots (36px pitch + empties) - -The side-bag column (0x100001CA, 36x252 = 7 slots) pads empty slot frames up to -the player's ContainersCapacity (clamped to 7), at the correct 36px pitch -(split from the contents grid's 32px). Reads like retail's bag column. - -Co-Authored-By: Claude Opus 4.8 (1M context) -EOF -``` - ---- - -## Task 5: Backdrop coverage — visual gate + conditional residual fix - -The backdrop `0x100001D0` is already full-window (300×362); the tear is the unclipped grid overflowing below it, which Tasks 1+3 fix. This task verifies that at the running client and fixes any residual. - -**Files:** -- Modify (only if residual gap found): `src/AcDream.App/UI/UiDatElement.cs` (Alphablend draw) — exact change determined by the root cause. - -- [ ] **Step 1: Build + launch** - -Run: `dotnet build` -Expected: Build succeeded. -Then launch the client against ACE with `ACDREAM_RETAIL_UI=1` (per CLAUDE.md "Running the client"), get in-world with a full pack, press F12. - -- [ ] **Step 2: Observe the backdrop** - -With the grid now clipped to 3 rows + scrollable, check the dark backdrop covers the whole inventory window (no grass/world showing through anywhere) — compare to retail screenshot 2. - -- [ ] **Step 3: If fully covered — done.** Record the observation; no code change. Skip to Task 6. - -- [ ] **Step 4: If a residual gap remains — root-cause then fix.** Most likely candidate: `UiDatElement` draws the Alphablend backdrop sprite (`0x06004D0A`) at native size instead of stretched/tiled to the element's 300×362 bounds. Open `src/AcDream.App/UI/UiDatElement.cs`, find the Alphablend `DrawMode` draw path, and confirm whether it fills the element rect. If it draws at native sprite size, change it to fill the element bounds (stretch or tile, matching how the vitals chrome center fill tiles — see `UiNineSlicePanel`). Add a divergence row if the fill differs from retail's exact draw. Re-launch + re-verify. (Do not change the draw speculatively — only if Step 2 shows a gap.) - -- [ ] **Step 5: Commit (only if a fix was made)** - -```bash -git add src/AcDream.App/UI/UiDatElement.cs -git commit -F - <<'EOF' -fix(ui): D.2b inventory finish — backdrop fills the full window - - - -Co-Authored-By: Claude Opus 4.8 (1M context) -EOF -``` - ---- - -## Task 6: Full verification + bookkeeping - -**Files:** -- Modify: `docs/ISSUES.md` (close the contents-grid-overflow issue), `docs/architecture/retail-divergence-register.md` (add the side-bag-capacity-fallback row + any backdrop row), `claude-memory/project_d2b_retail_ui.md` + `MEMORY.md` (Stage 1 shipped entry). - -- [ ] **Step 1: Full build + test** - -Run: `dotnet build` then `dotnet test` -Expected: all green (App count = the B-Wire baseline 534 + the new Stage-1 tests; Core/Core.Net/UI unchanged). - -- [ ] **Step 2: Close the overflow issue + add divergence rows** - -In `docs/ISSUES.md`: move the "Inventory 'Contents of Backpack' grid overflows (no scroll)" issue to DONE with the commit SHAs (Tasks 1-3 fixed it). -In `docs/architecture/retail-divergence-register.md`: add a row for the side-bag slot count using the 7-slot fallback when `ContainersCapacity` is absent (replace the `AP-XX` placeholder in the Task-4 comment with the real id). Add a backdrop row only if Task 5 made a fill change that differs from retail. - -- [ ] **Step 3: Update memory** - -In `claude-memory/project_d2b_retail_ui.md`: add a Stage-1 SHIPPED entry (UiItemList clip+scroll via UiScrollable; gutter scrollbar 0x100001C7 bound like chat; side-bag column 36px + empty padding; backdrop coverage outcome). Update the `MEMORY.md` index line. Note Stage 2 (paperdoll) is next. - -- [ ] **Step 4: Commit** - -```bash -git add docs/ISSUES.md docs/architecture/retail-divergence-register.md -git commit -F - <<'EOF' -docs(D.2b): inventory window finish (Stage 1) shipped — close overflow issue - -Co-Authored-By: Claude Opus 4.8 (1M context) -EOF -``` - ---- - -## Post-implementation: visual gate - -After Task 6, Stage 1 is code-complete. The acceptance is visual (project model): open F12 with a full pack → scrollable 3-row grid with a working gutter scrollbar, solid backdrop edge-to-edge, side-bag column with bag(s) + empty frames — matching retail screenshot 2 minus the doll. Report the observation. Then Stage 2 (paperdoll) gets its own brainstorm → spec → plan. - ---- - -## Self-review notes (coverage vs. spec) - -- **A (contents-grid clip+scroll)** → Tasks 1 (clip layout) + 2 (wheel) + 3 (scrollbar binding). Reuses `UiScrollable` per decision §4. -- **B (backdrop coverage)** → Task 5 (screenshot-gated; primary fix is A's clip per spec §B / decision §4). -- **C (side-bag column)** → Task 4 (36px pitch + empty padding up to capacity per decision §4). -- **Testing** → per-task unit tests (UiItemList scroll/wheel pure-ish via internal `LayoutCells`; controller scrollbar-bound + side-bag count via the `BuildLayout` harness); backdrop is visual-gate only per spec §5. -- **Divergence** → side-bag capacity fallback row in Task 6; backdrop row only if Task 5 changes the draw. -- **Out of scope** (paperdoll, B-Drag, side-bag scrollbar `0x100001CB`, stack overlays) — untouched. -- Type consistency: `UiItemList.Scroll` (UiScrollable) / `RowCount` / internal `LayoutCells` defined in Task 1 and consumed in Tasks 2-3; `ContentsCellPx`/`BackpackCellPx`/`SideBagSlots` defined + used in Task 4; scrollbar `Model`/sprite setters match `UiScrollbar`'s public API. diff --git a/docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md b/docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md deleted file mode 100644 index c8d4aeb1..00000000 --- a/docs/superpowers/plans/2026-06-21-d2b-inventory-wire.md +++ /dev/null @@ -1,1432 +0,0 @@ -# D.2b-B B-Wire — inventory wire layer Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Deliver the player's server-authoritative properties (so the burden bar reads the wire `EncumbranceVal`), fix two latent inbound-parse bugs, and add the missing inventory builders/parsers — completing the inventory wire layer so B-Drag and container-open are unblocked. - -**Architecture:** All wire code is in `AcDream.Core.Net` (pure data, no GL). New pure parsers/builders/table-methods are TDD'd directly. The glue (WorldSession's GameMessage switch, ObjectTableWiring subscriptions, the GameEventWiring PD handler, GameWindow call sites) follows the codebase's existing convention: dispatcher-routed GameEvents are unit-tested through the `GameEventWiringTests` harness; top-level GameMessages (no test seam on `WorldSession`) are wired and verified by build + the parser tests + the live run. - -**Tech Stack:** C# / .NET 10, xUnit, `System.Buffers.Binary.BinaryPrimitives`. Spec: `docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md`. Oracle field orders: `docs/research/2026-06-16-inventory-deep-dive.md` §4 (ACE `file:line` + holtburger fixtures cited per format). - -**Mandatory per-format workflow:** before porting each wire format, grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` for the client symbol + confirm the field order against the cited ACE writer. The golden bytes in each test below are derived from those confirmed orders; if grep-named reveals a discrepancy, fix the one test + impl and note it. - -**Opcodes (pinned against `references/ACE/.../GameMessageOpcode.cs` + `GameActionType.cs`):** -- `PrivateUpdatePropertyInt = 0x02CD` (top-level GameMessage, **no guid**) -- `SetStackSize = 0x0197` (top-level GameMessage, UIQueue) -- `InventoryRemoveObject = 0x0024` (top-level GameMessage, UIQueue) -- `ViewContents = 0x0196` (GameEvent, inside `0xF7B0`) -- C→S GameActions: `GetAndWieldItem = 0x001A`, `DropItem = 0x001B`, `NoLongerViewingContents = 0x0195` - -> **Namespace caution:** `SetStackSize 0x0197` / `InventoryRemoveObject 0x0024` are **GameMessage** opcodes (WorldSession top-level switch, like `0x02CE`). `ViewContents 0x0196` is a **GameEvent** eventType (dispatcher path). Adjacent numbers, different dispatchers — don't cross them. - ---- - -## Task 1: `ClientObjectTable.UpsertProperties` (Core) - -The PD handler needs to apply the player's property bundle even if the player's `ClientObject` doesn't exist yet (PD may arrive before the player's `CreateObject`). `UpdateProperties` no-ops on an unknown object; add a create-if-absent variant. - -**Files:** -- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` (add method after `UpdateProperties`, ~line 164) -- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs` (create) - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs`: - -```csharp -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -public sealed class ClientObjectTableUpdateTests -{ - [Fact] - public void UpsertProperties_unknownObject_createsThenMerges() - { - var t = new ClientObjectTable(); - bool added = false; - t.ObjectAdded += _ => added = true; - - var bundle = new PropertyBundle(); - bundle.Ints[5] = 1234; // EncumbranceVal - - t.UpsertProperties(0x50000001u, bundle); - - var o = t.Get(0x50000001u); - Assert.NotNull(o); - Assert.Equal(1234, o!.Properties.Ints[5]); - Assert.True(added); - } - - [Fact] - public void UpsertProperties_existingObject_mergesAndFiresUpdated() - { - var t = new ClientObjectTable(); - t.AddOrUpdate(new ClientObject { ObjectId = 0x50000001u }); - bool updated = false; - t.ObjectUpdated += _ => updated = true; - - var bundle = new PropertyBundle(); - bundle.Ints[5] = 99; - t.UpsertProperties(0x50000001u, bundle); - - Assert.Equal(99, t.Get(0x50000001u)!.Properties.Ints[5]); - Assert.True(updated); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests"` -Expected: FAIL — compile error, `ClientObjectTable` has no `UpsertProperties`. - -- [ ] **Step 3: Write minimal implementation** - -In `src/AcDream.Core/Items/ClientObjectTable.cs`, immediately after the `UpdateProperties` method (the one ending at ~line 164), add: - -```csharp - /// - /// Apply a patch, creating the object if it does not - /// exist yet. Used for the local player's own properties from PlayerDescription - /// (0x0013), which may arrive BEFORE the player's CreateObject — unlike - /// , which no-ops on an unknown object. Fires - /// ObjectAdded on create, else ObjectUpdated. - /// - public void UpsertProperties(uint guid, PropertyBundle incoming) - { - ArgumentNullException.ThrowIfNull(incoming); - bool existed = _objects.TryGetValue(guid, out var item); - if (!existed || item is null) - { - item = new ClientObject { ObjectId = guid }; - _objects[guid] = item; - } - foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; - foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; - foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value; - foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value; - foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; - foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; - foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; - if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item); - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests"` -Expected: PASS (2 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs -git commit -m "feat(core): D.2b-B B-Wire — ClientObjectTable.UpsertProperties (create-if-absent)" -``` - ---- - -## Task 2: `ClientObjectTable.UpdateStackSize` (Core) - -`SetStackSize (0x0197)` updates a stack's count + value. Add the typed apply method (the parser + glue come later). - -**Files:** -- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` (add after `UpdateIntProperty`, ~line 180) -- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs` (append) - -- [ ] **Step 1: Write the failing test** - -Append to `ClientObjectTableUpdateTests.cs` (inside the class): - -```csharp - [Fact] - public void UpdateStackSize_knownObject_setsFieldsAndFiresUpdated() - { - var t = new ClientObjectTable(); - t.AddOrUpdate(new ClientObject { ObjectId = 0x600u, StackSize = 1, Value = 5 }); - bool updated = false; - t.ObjectUpdated += _ => updated = true; - - bool ok = t.UpdateStackSize(0x600u, stackSize: 25, value: 125); - - Assert.True(ok); - Assert.Equal(25, t.Get(0x600u)!.StackSize); - Assert.Equal(125, t.Get(0x600u)!.Value); - Assert.True(updated); - } - - [Fact] - public void UpdateStackSize_unknownObject_returnsFalse() - => Assert.False(new ClientObjectTable().UpdateStackSize(0xDEADu, 1, 1)); -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests"` -Expected: FAIL — no `UpdateStackSize`. - -- [ ] **Step 3: Write minimal implementation** - -In `src/AcDream.Core/Items/ClientObjectTable.cs`, after `UpdateIntProperty` (~line 180), add: - -```csharp - /// - /// Apply a SetStackSize (0x0197) update: set the object's StackSize + Value and - /// fire ObjectUpdated so bound widgets refresh the quantity overlay. False if the - /// object is unknown. Retail: ACCWeenieObject::ServerSaysSetStackSize (0x0058...). - /// - public bool UpdateStackSize(uint guid, int stackSize, int value) - { - if (!_objects.TryGetValue(guid, out var item)) return false; - item.StackSize = stackSize; - item.Value = value; - ObjectUpdated?.Invoke(item); - return true; - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableUpdateTests"` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs -git commit -m "feat(core): D.2b-B B-Wire — ClientObjectTable.UpdateStackSize" -``` - ---- - -## Task 3: `PrivateUpdatePropertyInt (0x02CD)` parser (Core.Net) - -Live burden updates ride `0x02CD` — the player's own object, **no guid**. Mirror `PublicUpdatePropertyInt.cs`. - -**Files:** -- Create: `src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs` -- Test: `tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs`: - -```csharp -using System.Buffers.Binary; -using AcDream.Core.Net.Messages; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -public sealed class PrivateUpdatePropertyIntTests -{ - // 0x02CD body: opcode(4) + seq(1) + property(4) + value(4) = 13 bytes. NO guid. - private static byte[] Build(uint property, int value, byte seq = 1, uint opcode = 0x02CDu) - { - var b = new byte[13]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); - b[4] = seq; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), property); - BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), value); - return b; - } - - [Fact] - public void TryParse_encumbranceVal_returnsPropValue() - { - var p = PrivateUpdatePropertyInt.TryParse(Build(property: 5u, value: 1500)); - Assert.NotNull(p); - Assert.Equal(5u, p!.Value.Property); - Assert.Equal(1500, p.Value.Value); - } - - [Fact] - public void TryParse_wrongOpcode_returnsNull() - => Assert.Null(PrivateUpdatePropertyInt.TryParse(Build(5, 1, opcode: 0x02CEu))); - - [Fact] - public void TryParse_truncated_returnsNull() - => Assert.Null(PrivateUpdatePropertyInt.TryParse(new byte[12])); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PrivateUpdatePropertyIntTests"` -Expected: FAIL — `PrivateUpdatePropertyInt` does not exist. - -- [ ] **Step 3: Write minimal implementation** - -Create `src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs`: - -```csharp -using System; -using System.Buffers.Binary; - -namespace AcDream.Core.Net.Messages; - -/// -/// Inbound PrivateUpdatePropertyInt (0x02CD) — the server updates one -/// PropertyInt on the player's OWN object. Unlike the sibling -/// (0x02CE), this carries NO guid (it -/// implicitly targets the local player). Burden (EncumbranceVal, PropertyInt 5) -/// changes ride this opcode on every pick-up / drop. -/// -/// Wire layout (ACE GameMessagePrivateUpdatePropertyInt): -/// -/// u32 opcode = 0x02CD -/// u8 sequence // single byte (ByteSequence) — not honored, latest-wins (DR-4) -/// u32 property // PropertyInt enum -/// i32 value -/// -/// -public static class PrivateUpdatePropertyInt -{ - public const uint Opcode = 0x02CDu; - - public readonly record struct Parsed(uint Property, int Value); - - /// Parse a raw 0x02CD body. Returns null on opcode mismatch / truncation. - public static Parsed? TryParse(ReadOnlySpan body) - { - if (body.Length < 13) return null; // 4 + 1 + 4 + 4 - if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; - int pos = 4; - pos += 1; // sequence byte (not honored) - uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); - return new Parsed(prop, value); - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~PrivateUpdatePropertyIntTests"` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs -git commit -m "feat(net): D.2b-B B-Wire — PrivateUpdatePropertyInt (0x02CD) parser" -``` - ---- - -## Task 4: `SetStackSize (0x0197)` parser (Core.Net) - -Top-level GameMessage. Wire layout (ACE `GameMessageSetStackSize.cs`, size hint 17 ⇒ byte seq): `opcode(4) + u8 seq + guid(4) + stackSize(4) + value(4)`. - -**Files:** -- Create: `src/AcDream.Core.Net/Messages/SetStackSize.cs` -- Test: `tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs`: - -```csharp -using System.Buffers.Binary; -using AcDream.Core.Net.Messages; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -public sealed class SetStackSizeTests -{ - private static byte[] Build(uint guid, int stackSize, int value, byte seq = 1, uint opcode = 0x0197u) - { - var b = new byte[17]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); - b[4] = seq; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid); - BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), stackSize); - BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value); - return b; - } - - [Fact] - public void TryParse_validStack_returnsGuidStackValue() - { - var p = SetStackSize.TryParse(Build(0x50000A01u, stackSize: 25, value: 125)); - Assert.NotNull(p); - Assert.Equal(0x50000A01u, p!.Value.Guid); - Assert.Equal(25, p.Value.StackSize); - Assert.Equal(125, p.Value.Value); - } - - [Fact] - public void TryParse_wrongOpcode_returnsNull() - => Assert.Null(SetStackSize.TryParse(Build(1, 1, 1, opcode: 0x0196u))); - - [Fact] - public void TryParse_truncated_returnsNull() - => Assert.Null(SetStackSize.TryParse(new byte[16])); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStackSizeTests"` -Expected: FAIL — `SetStackSize` does not exist. - -- [ ] **Step 3: Write minimal implementation** - -Create `src/AcDream.Core.Net/Messages/SetStackSize.cs`: - -```csharp -using System; -using System.Buffers.Binary; - -namespace AcDream.Core.Net.Messages; - -/// -/// Inbound SetStackSize (0x0197) — a top-level GameMessage (UIQueue group), -/// NOT a GameEvent. The server updates a stack's count + value after a merge / split. -/// Client consumer: ACCWeenieObject::ServerSaysSetStackSize. -/// -/// Wire layout (ACE GameMessageSetStackSize.cs, size hint 17): -/// -/// u32 opcode = 0x0197 -/// u8 sequence // ByteSequence (UpdatePropertyInt) — not honored -/// u32 guid -/// i32 stackSize -/// i32 value -/// -/// -public static class SetStackSize -{ - public const uint Opcode = 0x0197u; - - public readonly record struct Parsed(uint Guid, int StackSize, int Value); - - /// Parse a raw 0x0197 body. Returns null on opcode mismatch / truncation. - public static Parsed? TryParse(ReadOnlySpan body) - { - if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4 - if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; - int pos = 4; - pos += 1; // sequence byte (not honored) - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - int stack = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); pos += 4; - int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); - return new Parsed(guid, stack, value); - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~SetStackSizeTests"` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/Messages/SetStackSize.cs tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs -git commit -m "feat(net): D.2b-B B-Wire — SetStackSize (0x0197) parser" -``` - ---- - -## Task 5: `InventoryRemoveObject (0x0024)` parser (Core.Net) - -Top-level GameMessage. Wire layout (ACE `GameMessageInventoryRemoveObject.cs`, size hint 8): `opcode(4) + guid(4)`. - -**Files:** -- Create: `src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs` -- Test: `tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs` - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs`: - -```csharp -using System.Buffers.Binary; -using AcDream.Core.Net.Messages; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -public sealed class InventoryRemoveObjectTests -{ - private static byte[] Build(uint guid, uint opcode = 0x0024u) - { - var b = new byte[8]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), guid); - return b; - } - - [Fact] - public void TryParse_valid_returnsGuid() - { - var p = InventoryRemoveObject.TryParse(Build(0x50000A07u)); - Assert.NotNull(p); - Assert.Equal(0x50000A07u, p!.Value.Guid); - } - - [Fact] - public void TryParse_wrongOpcode_returnsNull() - => Assert.Null(InventoryRemoveObject.TryParse(Build(1, opcode: 0x0023u))); - - [Fact] - public void TryParse_truncated_returnsNull() - => Assert.Null(InventoryRemoveObject.TryParse(new byte[7])); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryRemoveObjectTests"` -Expected: FAIL — `InventoryRemoveObject` does not exist. - -- [ ] **Step 3: Write minimal implementation** - -Create `src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs`: - -```csharp -using System; -using System.Buffers.Binary; - -namespace AcDream.Core.Net.Messages; - -/// -/// Inbound InventoryRemoveObject (0x0024) — a top-level GameMessage (UIQueue), -/// NOT a GameEvent. The server tells the client an object left its inventory view -/// (given away / sold / destroyed). The client drops it from object maintenance. -/// -/// Wire layout (ACE GameMessageInventoryRemoveObject.cs, size hint 8): -/// -/// u32 opcode = 0x0024 -/// u32 guid -/// -/// -public static class InventoryRemoveObject -{ - public const uint Opcode = 0x0024u; - - public readonly record struct Parsed(uint Guid); - - /// Parse a raw 0x0024 body. Returns null on opcode mismatch / truncation. - public static Parsed? TryParse(ReadOnlySpan body) - { - if (body.Length < 8) return null; // 4 + 4 - if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[4..]); - return new Parsed(guid); - } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryRemoveObjectTests"` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs -git commit -m "feat(net): D.2b-B B-Wire — InventoryRemoveObject (0x0024) parser" -``` - ---- - -## Task 6: `GameEvents.ParseViewContents (0x0196)` (Core.Net) - -GameEvent (payload = envelope stripped). Layout (ACE `GameEventViewContents.cs`): `containerGuid(4), count(4), [guid(4), containerType(4)] × count`. - -**Files:** -- Modify: `src/AcDream.Core.Net/Messages/GameEvents.cs` (add near `ParsePutObjInContainer`, ~line 359) -- Test: `tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs` (create) - -- [ ] **Step 1: Write the failing test** - -Create `tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs`: - -```csharp -using System.Buffers.Binary; -using AcDream.Core.Net.Messages; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -public sealed class GameEventsInventoryTests -{ - [Fact] - public void ParseViewContents_twoEntries_returnsContainerAndItems() - { - // containerGuid + count(2) + 2×{guid, containerType} - var b = new byte[4 + 4 + 2 * 8]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); // containerGuid - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 2u); // count - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 0x50000A01u); // guid 1 - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 0u); // type 1 (item) - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(16), 0x50000A02u);// guid 2 - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(20), 1u); // type 2 (container) - - var p = GameEvents.ParseViewContents(b); - Assert.NotNull(p); - Assert.Equal(0x500000C9u, p!.Value.ContainerGuid); - Assert.Equal(2, p.Value.Items.Count); - Assert.Equal(0x50000A01u, p.Value.Items[0].Guid); - Assert.Equal(0u, p.Value.Items[0].ContainerType); - Assert.Equal(0x50000A02u, p.Value.Items[1].Guid); - Assert.Equal(1u, p.Value.Items[1].ContainerType); - } - - [Fact] - public void ParseViewContents_zeroCount_returnsEmptyList() - { - var b = new byte[8]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0u); - var p = GameEvents.ParseViewContents(b); - Assert.NotNull(p); - Assert.Empty(p!.Value.Items); - } - - [Fact] - public void ParseViewContents_truncated_returnsNull() - => Assert.Null(GameEvents.ParseViewContents(new byte[4])); -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseViewContents"` -Expected: FAIL — no `ParseViewContents` / no `ViewContents` record. - -- [ ] **Step 3: Write minimal implementation** - -In `src/AcDream.Core.Net/Messages/GameEvents.cs`, after `ParsePutObjInContainer` (~line 359), add: - -```csharp - /// 0x0196 ViewContents: full contents list of a container you opened. - /// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count. - /// Client consumer: ClientUISystem::OnViewContents (PackableList<ContentProfile>). - public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType); - public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList Items); - - public static ViewContents? ParseViewContents(ReadOnlySpan payload) - { - if (payload.Length < 8) return null; - uint containerGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload); - uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)); - int pos = 8; - if ((long)payload.Length - pos < (long)count * 8) return null; - var items = new ViewContentsEntry[count]; - for (int i = 0; i < count; i++) - { - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - items[i] = new ViewContentsEntry(guid, type); - } - return new ViewContents(containerGuid, items); - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseViewContents"` -Expected: PASS (3 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/Messages/GameEvents.cs tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs -git commit -m "feat(net): D.2b-B B-Wire — ParseViewContents (0x0196)" -``` - ---- - -## Task 7: Fix `ParsePutObjInContainer` 4th field (Core.Net) - -`0x0022` carries a 4th `containerType` u32 that the current parser drops. - -**Files:** -- Modify: `src/AcDream.Core.Net/Messages/GameEvents.cs` (the `InventoryPutObjInContainer` record + `ParsePutObjInContainer`, ~lines 346-359) -- Test: `tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs` (append) - -- [ ] **Step 1: Write the failing test** - -Append to `GameEventsInventoryTests.cs` (inside the class): - -```csharp - [Fact] - public void ParsePutObjInContainer_readsAllFourFields() - { - var b = new byte[16]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x500000C9u); // containerGuid - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 3u); // placement - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 1u); // containerType - - var p = GameEvents.ParsePutObjInContainer(b); - Assert.NotNull(p); - Assert.Equal(0x50000A01u, p!.Value.ItemGuid); - Assert.Equal(0x500000C9u, p.Value.ContainerGuid); - Assert.Equal(3u, p.Value.Placement); - Assert.Equal(1u, p.Value.ContainerType); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParsePutObjInContainer"` -Expected: FAIL — `InventoryPutObjInContainer` record has no `ContainerType`. - -- [ ] **Step 3: Write minimal implementation** - -In `src/AcDream.Core.Net/Messages/GameEvents.cs`, replace the `InventoryPutObjInContainer` record + parser (~lines 346-359) with: - -```csharp - /// 0x0022 InventoryPutObjInContainer: server puts item into container slot. - /// 4 fields (ACE GameEventItemServerSaysContainId.cs): itemGuid, containerGuid, - /// placement, containerType. ContainerType (0=item,1=container,2=foci) confirmed - /// vs holtburger events.rs fixture (slot=3 type=1). - public readonly record struct InventoryPutObjInContainer( - uint ItemGuid, - uint ContainerGuid, - uint Placement, - uint ContainerType); - - public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan payload) - { - if (payload.Length < 16) return null; - return new InventoryPutObjInContainer( - BinaryPrimitives.ReadUInt32LittleEndian(payload), - BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)), - BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)), - BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12))); - } -``` - -(The `GameEventWiring` caller at ~line 246 uses `.ItemGuid`/`.ContainerGuid`/`.Placement` — unaffected by the added field.) - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParsePutObjInContainer"` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/Messages/GameEvents.cs tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs -git commit -m "fix(net): D.2b-B B-Wire — ParsePutObjInContainer reads containerType (4th field)" -``` - ---- - -## Task 8: Fix `ParseInventoryServerSaveFailed` to read the error (Core.Net) - -`0x00A0` carries `itemGuid, weenieError`; the current parser returns only the guid. Change to a record. (Parser is currently unwired — no caller breaks.) - -**Files:** -- Modify: `src/AcDream.Core.Net/Messages/GameEvents.cs` (`ParseInventoryServerSaveFailed`, ~line 377) -- Test: `tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs` (append) - -- [ ] **Step 1: Write the failing test** - -Append to `GameEventsInventoryTests.cs`: - -```csharp - [Fact] - public void ParseInventoryServerSaveFailed_readsGuidAndError() - { - var b = new byte[8]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x0Au); // weenieError - - var p = GameEvents.ParseInventoryServerSaveFailed(b); - Assert.NotNull(p); - Assert.Equal(0x50000A01u, p!.Value.ItemGuid); - Assert.Equal(0x0Au, p.Value.WeenieError); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseInventoryServerSaveFailed"` -Expected: FAIL — current return type is `uint?`, has no `.ItemGuid`/`.WeenieError`. - -- [ ] **Step 3: Write minimal implementation** - -In `src/AcDream.Core.Net/Messages/GameEvents.cs`, replace `ParseInventoryServerSaveFailed` (~line 377) with: - -```csharp - /// 0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op. - /// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger - /// events.rs:147 reads both fields. - public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError); - - public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan payload) - { - if (payload.Length < 8) return null; - return new InventoryServerSaveFailed( - BinaryPrimitives.ReadUInt32LittleEndian(payload), - BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventsInventoryTests.ParseInventoryServerSaveFailed"` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/Messages/GameEvents.cs tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs -git commit -m "fix(net): D.2b-B B-Wire — ParseInventoryServerSaveFailed reads weenieError" -``` - ---- - -## Task 9: New C→S builders + `WorldSession.Send*` wrappers (Core.Net) - -`DropItem 0x001B` (`itemGuid`), `GetAndWieldItem 0x001A` (`itemGuid, equipMask`), `NoLongerViewingContents 0x0195` (`containerGuid`). All ride the `0xF7B1` GameAction envelope. - -**Files:** -- Modify: `src/AcDream.Core.Net/Messages/InventoryActions.cs` (add opcodes + 3 builders) -- Modify: `src/AcDream.Core.Net/WorldSession.cs` (add 3 `Send*` wrappers after `SendRemoveShortcut`, ~line 1157) -- Test: `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` (append) - -- [ ] **Step 1: Write the failing test** - -Append to `tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs` (inside the existing class): - -```csharp - [Fact] - public void BuildDropItem_layout() - { - byte[] b = InventoryActions.BuildDropItem(seq: 7, itemGuid: 0x50000A01u); - Assert.Equal(16, b.Length); - Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); - Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); - Assert.Equal(0x001Bu, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); - Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); - } - - [Fact] - public void BuildGetAndWieldItem_layout() - { - byte[] b = InventoryActions.BuildGetAndWieldItem(seq: 7, itemGuid: 0x50000A01u, equipMask: 0x02u); - Assert.Equal(20, b.Length); - Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); - Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); - Assert.Equal(0x001Au, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); - Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); - Assert.Equal(0x02u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(16))); - } - - [Fact] - public void BuildNoLongerViewingContents_layout() - { - byte[] b = InventoryActions.BuildNoLongerViewingContents(seq: 7, containerGuid: 0x500000C9u); - Assert.Equal(16, b.Length); - Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); - Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); - Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); - Assert.Equal(0x500000C9u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); - } -``` - -(If `InventoryActionsTests.cs` lacks `using System.Buffers.Binary;`, add it.) - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryActionsTests.Build"` -Expected: FAIL — builders don't exist. - -- [ ] **Step 3: Write minimal implementation** - -In `src/AcDream.Core.Net/Messages/InventoryActions.cs`, add the opcode constants (after `TeleToPoiOpcode`, ~line 24): - -```csharp - public const uint GetAndWieldItemOpcode = 0x001Au; - public const uint DropItemOpcode = 0x001Bu; - public const uint NoLongerViewingContentsOpcode = 0x0195u; -``` - -And add the three builders (before the closing brace of the class): - -```csharp - /// Drop an item on the ground. ACE GameActionDropItem.Handle reads 1 u32. - public static byte[] BuildDropItem(uint seq, uint itemGuid) - { - byte[] body = new byte[16]; - BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), DropItemOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid); - return body; - } - - /// Equip an item from inventory onto the doll. holtburger actions.rs - /// GetAndWieldItemActionData = (itemGuid, equipMask). - public static byte[] BuildGetAndWieldItem(uint seq, uint itemGuid, uint equipMask) - { - byte[] body = new byte[20]; - BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), GetAndWieldItemOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), equipMask); - return body; - } - - /// Close a side-pack / ground-container view. holtburger actions.rs = (containerGuid). - public static byte[] BuildNoLongerViewingContents(uint seq, uint containerGuid) - { - byte[] body = new byte[16]; - BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), NoLongerViewingContentsOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), containerGuid); - return body; - } -``` - -In `src/AcDream.Core.Net/WorldSession.cs`, after `SendRemoveShortcut` (~line 1157), add: - -```csharp - /// Send DropItem (0x001B) — drop an item on the ground. - public void SendDropItem(uint itemGuid) - { - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid)); - } - - /// Send GetAndWieldItem (0x001A) — equip an item to an equip slot. - public void SendGetAndWieldItem(uint itemGuid, uint equipMask) - { - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildGetAndWieldItem(seq, itemGuid, equipMask)); - } - - /// Send NoLongerViewingContents (0x0195) — close a container view. - public void SendNoLongerViewingContents(uint containerGuid) - { - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildNoLongerViewingContents(seq, containerGuid)); - } -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~InventoryActionsTests.Build"` -Expected: PASS (3 new tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/Messages/InventoryActions.cs src/AcDream.Core.Net/WorldSession.cs tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs -git commit -m "feat(net): D.2b-B B-Wire — DropItem/GetAndWieldItem/NoLongerViewingContents builders + Send wrappers" -``` - ---- - -## Task 10: Login PD player-property delivery (Core.Net) - -`GameEventWiring.WireAll` gains an optional `Func? playerGuid`; the PD handler upserts the player's parsed `PropertyBundle` into the player `ClientObject`. TDD via the dispatcher harness. - -**Files:** -- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (add param ~line 68; add upsert in the PD handler ~line 408) -- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` (append) - -- [ ] **Step 1: Write the failing test** - -Append to `GameEventWiringTests.cs` (inside the class): - -```csharp - [Fact] - public void WireAll_PlayerDescription_UpsertsPlayerPropertiesIntoClientObject() - { - // PD with a PropertyInt32 table carrying EncumbranceVal(5)=1500. The handler - // must land it in the player ClientObject so the burden bar reads the wire value. - const uint playerGuid = 0x50000001u; - var dispatcher = new GameEventDispatcher(); - var items = new ClientObjectTable(); - GameEventWiring.WireAll(dispatcher, items, new CombatState(), new Spellbook(), - new ChatLog(), playerGuid: () => playerGuid); - - var sb = new MemoryStream(); - using var w = new BinaryWriter(sb); - w.Write(0x00000001u); // propertyFlags = PropertyInt32 - w.Write(0x52u); // weenieType - // int table: u16 count, u16 buckets, then key/val pairs - w.Write((ushort)1); // count - w.Write((ushort)8); // buckets (ignored) - w.Write(5u); // key = EncumbranceVal - w.Write(1500u); // val - // vector + has_health (no vector blocks) - w.Write(0u); // vectorFlags = None - w.Write(0u); // has_health - // strict trailer - w.Write(0u); // option_flags = None - w.Write(0u); // options1 - w.Write(0u); // legacy hotbar count - w.Write(0u); // spellbook_filters - w.Write(0u); // inventory count - w.Write(0u); // equipped count - - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); - dispatcher.Dispatch(env!.Value); - - var player = items.Get(playerGuid); - Assert.NotNull(player); - Assert.Equal(1500, player!.Properties.Ints[5]); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests.WireAll_PlayerDescription_UpsertsPlayerProperties"` -Expected: FAIL — `WireAll` has no `playerGuid` parameter. - -- [ ] **Step 3: Write minimal implementation** - -In `src/AcDream.Core.Net/GameEventWiring.cs`, add a new optional parameter at the END of the `WireAll` parameter list (after `onShortcuts`, ~line 68): - -```csharp - Action>? onShortcuts = null, - // B-Wire: the local player's server guid. When provided, the PD handler upserts - // the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject. - Func? playerGuid = null) -``` - -In the PD handler, immediately after the `if (p is null) return;` guard (~line 290), add: - -```csharp - // B-Wire: deliver the player's OWN properties to the player ClientObject. - // (PD's "membership manifest" rule is about ITEMS, whose data comes from - // CreateObject; the player's own stats legitimately come from PD.) Upsert - // because PD can arrive before the player's CreateObject. Retires AP-48/AP-49. - if (playerGuid is not null) - items.UpsertProperties(playerGuid(), p.Value.Properties); -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests"` -Expected: PASS (all GameEventWiring tests, including the new one). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs -git commit -m "feat(net): D.2b-B B-Wire — PlayerDescription delivers player properties to ClientObject" -``` - ---- - -## Task 11: Register ViewContents (membership) + unwired inventory GameEvents (Core.Net) - -Register `ViewContents` (apply membership — testable), and the existing-but-unwired `InventoryPutObjectIn3D (0x019A)` (unparent to world), `InventoryServerSaveFailed (0x00A0)` (log), `CloseGroundContainer (0x0052)` (log). - -**Files:** -- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (add registrations after the `InventoryPutObjInContainer` registration, ~line 248) -- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` (append) - -- [ ] **Step 1: Write the failing test** - -Append to `GameEventWiringTests.cs`: - -```csharp - [Fact] - public void WireAll_ViewContents_RecordsMembershipInClientObjectTable() - { - var (d, items, _, _, _) = MakeAll(); - - // containerGuid 0xC9 + 2 entries - byte[] payload = new byte[8 + 2 * 8]; - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x500000C9u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x50000A01u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 0x50000A02u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 1u); - - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload)); - d.Dispatch(env!.Value); - - Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId); - Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId); - } - - [Fact] - public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer() - { - var (d, items, _, _, _) = MakeAll(); - items.RecordMembership(0x50000A01u, containerId: 0x500000C9u); - - byte[] payload = new byte[4]; - BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u); - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload)); - d.Dispatch(env!.Value); - - Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); - } -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests.WireAll_ViewContents|FullyQualifiedName~GameEventWiringTests.WireAll_InventoryPutObjectIn3D"` -Expected: FAIL — these events aren't registered, so nothing changes (the `Get` returns null / ContainerId unchanged). - -- [ ] **Step 3: Write minimal implementation** - -In `src/AcDream.Core.Net/GameEventWiring.cs`, after the `InventoryPutObjInContainer` registration block (the one ending ~line 248), add: - -```csharp - // B-Wire: ViewContents (0x0196) — the server's full contents list for a - // container you opened. Record membership for each entry so the table is - // correct before the container-open UI mounts the second item list. - dispatcher.Register(GameEventType.ViewContents, e => - { - var p = GameEvents.ParseViewContents(e.Payload.Span); - if (p is null) return; - foreach (var entry in p.Value.Items) - items.RecordMembership(entry.Guid, containerId: p.Value.ContainerGuid); - }); - - // B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped - // to the world. Unparent it from its container (it's now a ground object) so - // the inventory grid drops the cell; the object itself survives. - dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e => - { - var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span); - if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u); - }); - - // B-Wire: InventoryServerSaveFailed (0x00A0) — rollback of a speculative move. - // acdream does not do speculative moves yet (read-only inventory); parse + log - // so the wire is correct. B-Drag wires the rollback behavior. (Divergence note.) - dispatcher.Register(GameEventType.InventoryServerSaveFailed, e => - { - var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span); - if (p is not null) - Console.WriteLine($"[B-Wire] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X}"); - }); - - // B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container - // view. No table change (the view is UI-only, wired in container-open); log. - dispatcher.Register(GameEventType.CloseGroundContainer, e => - { - var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span); - if (guid is not null) - Console.WriteLine($"[B-Wire] CloseGroundContainer guid=0x{guid.Value:X8}"); - }); -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~GameEventWiringTests"` -Expected: PASS (all GameEventWiring tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs -git commit -m "feat(net): D.2b-B B-Wire — register ViewContents + InventoryPutObjectIn3D/SaveFailed/CloseGroundContainer" -``` - ---- - -## Task 12: WorldSession dispatch + events for the 3 top-level GameMessages (Core.Net) - -Add events + switch cases for `PrivateUpdatePropertyInt (0x02CD)`, `SetStackSize (0x0197)`, `InventoryRemoveObject (0x0024)`. Glue — no test seam on the switch; verified by build + the parser tests + the live run (per the codebase convention documented in `ObjectTableWiringTests`). - -**Files:** -- Modify: `src/AcDream.Core.Net/WorldSession.cs` (add events near `ObjectIntPropertyUpdated` ~line 196; add switch cases after the `PublicUpdatePropertyInt` branch ~line 960) - -- [ ] **Step 1: Add the events** - -In `src/AcDream.Core.Net/WorldSession.cs`, after the `ObjectIntPropertyUpdated` event (~line 196), add: - -```csharp - /// Payload for : a PropertyInt change on - /// the player's OWN object (from PrivateUpdatePropertyInt 0x02CD — no guid on the wire). - public readonly record struct PlayerIntPropertyUpdate(uint Property, int Value); - - /// Fires when the session parses a PrivateUpdatePropertyInt (0x02CD) — one - /// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar. - public event Action? PlayerIntPropertyUpdated; - - /// Payload for : SetStackSize (0x0197) — a stack's - /// count + value after a merge / split. - public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value); - - /// Fires when the session parses a SetStackSize (0x0197) top-level GameMessage. - public event Action? StackSizeUpdated; - - /// Fires when the session parses an InventoryRemoveObject (0x0024) — the guid left - /// the player's inventory view. - public event Action? InventoryObjectRemoved; -``` - -- [ ] **Step 2: Add the switch cases** - -In `src/AcDream.Core.Net/WorldSession.cs`, immediately after the `else if (op == PublicUpdatePropertyInt.Opcode)` branch (the one ending ~line 960), add: - -```csharp - else if (op == PrivateUpdatePropertyInt.Opcode) - { - var p = PrivateUpdatePropertyInt.TryParse(body); - if (p is not null) - PlayerIntPropertyUpdated?.Invoke( - new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value)); - } - else if (op == SetStackSize.Opcode) - { - var p = SetStackSize.TryParse(body); - if (p is not null) - StackSizeUpdated?.Invoke( - new StackSizeUpdate(p.Value.Guid, p.Value.StackSize, p.Value.Value)); - } - else if (op == InventoryRemoveObject.Opcode) - { - var p = InventoryRemoveObject.TryParse(body); - if (p is not null) InventoryObjectRemoved?.Invoke(p.Value.Guid); - } -``` - -- [ ] **Step 3: Build to verify it compiles** - -Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj` -Expected: Build succeeded. - -- [ ] **Step 4: Run the full Core.Net test suite (no regressions)** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj` -Expected: PASS (all green). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/WorldSession.cs -git commit -m "feat(net): D.2b-B B-Wire — WorldSession dispatch for 0x02CD/SetStackSize/InventoryRemoveObject" -``` - ---- - -## Task 13: ObjectTableWiring — apply all ints + player-int route + stack/remove (Core.Net) - -Loosen the int gate (apply all ints, not just UiEffects), route the player int to the player object, and apply SetStackSize / InventoryRemoveObject. Glue — verified by build + the underlying table-method tests (Tasks 1/2) + run. - -**Files:** -- Modify: `src/AcDream.Core.Net/ObjectTableWiring.cs` (the `Wire` method) - -- [ ] **Step 1: Replace the `Wire` method body** - -In `src/AcDream.Core.Net/ObjectTableWiring.cs`, change the `Wire` signature + subscriptions. Replace the whole method (lines ~19-31) with: - -```csharp - public static void Wire(WorldSession session, ClientObjectTable table, Func? playerGuid = null) - { - ArgumentNullException.ThrowIfNull(session); - ArgumentNullException.ThrowIfNull(table); - - session.EntitySpawned += s => table.Ingest(ToWeenieData(s)); - session.EntityDeleted += d => table.Remove(d.Guid); - - // B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just - // UiEffects — the server is the authority on object properties. UpdateIntProperty - // stores it in the bundle and still mirrors UiEffects → the typed Effects field. - session.ObjectIntPropertyUpdated += u => - table.UpdateIntProperty(u.Guid, u.Property, u.Value); - - // B-Wire: PrivateUpdatePropertyInt (0x02CD) carries no guid — it targets the - // local player. Route it to the player object so live EncumbranceVal updates the - // burden bar. (No-op if the player guid isn't wired yet.) - session.PlayerIntPropertyUpdated += u => - { - if (playerGuid is not null) - table.UpdateIntProperty(playerGuid(), u.Property, u.Value); - }; - - // B-Wire: SetStackSize (0x0197) — update the object's stack count + value. - session.StackSizeUpdated += u => table.UpdateStackSize(u.Guid, u.StackSize, u.Value); - - // B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory - // view; drop it from the table (retail ClientUISystem removes it from maintenance). - session.InventoryObjectRemoved += guid => table.Remove(guid); - } -``` - -Also update the class doc-comment (line ~8) to mention the new routes: - -```csharp -/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt -/// (0x02CD) = player int (burden), SetStackSize (0x0197) = stack count, InventoryRemoveObject -/// (0x0024) = inventory-view removal. -``` - -- [ ] **Step 2: Build to verify it compiles** - -Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj` -Expected: Build succeeded. - -- [ ] **Step 3: Run the full Core.Net test suite** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj` -Expected: PASS (`ObjectTableWiringTests.ToWeenieData_CopiesFieldsFromSpawn` still green — the signature change is source-compatible since `playerGuid` is optional). - -- [ ] **Step 4: Commit** - -```bash -git add src/AcDream.Core.Net/ObjectTableWiring.cs -git commit -m "feat(net): D.2b-B B-Wire — ObjectTableWiring applies all ints + player int + stack/remove" -``` - ---- - -## Task 14: GameWindow — pass the player guid into both wirings (App) - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (line ~2471 `ObjectTableWiring.Wire`; line ~2569 the `WireAll` `onShortcuts` arg) - -- [ ] **Step 1: Pass playerGuid to ObjectTableWiring.Wire** - -In `src/AcDream.App/Rendering/GameWindow.cs` at ~line 2471, change: - -```csharp - AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects); -``` - -to: - -```csharp - AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid); -``` - -- [ ] **Step 2: Pass playerGuid to GameEventWiring.WireAll** - -In `src/AcDream.App/Rendering/GameWindow.cs`, in the `WireAll(...)` call (~line 2525-2569), change the final argument line: - -```csharp - onShortcuts: list => Shortcuts = list); -``` - -to: - -```csharp - onShortcuts: list => Shortcuts = list, - playerGuid: () => _playerServerGuid); -``` - -- [ ] **Step 3: Build to verify it compiles** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj` -Expected: Build succeeded. - -- [ ] **Step 4: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(app): D.2b-B B-Wire — wire player guid into ObjectTableWiring + GameEventWiring" -``` - ---- - -## Task 15: InventoryController refreshes burden on player-object update (App) - -`Concerns()` excludes the player's own object, so a live `EncumbranceVal` update wouldn't repaint the burden bar. The player object IS the burden source — include it. - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` (`Concerns`, ~line 115) - -- [ ] **Step 1: Make the change** - -In `src/AcDream.App/UI/Layout/InventoryController.cs`, change `Concerns` (~line 115-120): - -```csharp - private bool Concerns(ClientObject o) - { - uint p = _playerGuid(); - return o.ContainerId == p || o.WielderId == p - || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep - } -``` - -to: - -```csharp - private bool Concerns(ClientObject o) - { - uint p = _playerGuid(); - return o.ObjectId == p // B-Wire: the player object IS the burden source - || o.ContainerId == p || o.WielderId == p - || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep - } -``` - -- [ ] **Step 2: Build to verify it compiles** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj` -Expected: Build succeeded. - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.App/UI/Layout/InventoryController.cs -git commit -m "fix(app): D.2b-B B-Wire — burden bar refreshes on player-object property update" -``` - ---- - -## Task 16: Full verification + divergence register + ISSUES/roadmap + memory - -**Files:** -- Modify: `docs/architecture/retail-divergence-register.md` (retire AP-48, AP-49) -- Modify: `docs/ISSUES.md` (B-Wire SHIPPED note) -- Modify: `docs/plans/2026-04-11-roadmap.md` (B-Wire shipped row) -- Modify: `claude-memory/project_d2b_retail_ui.md` (B-Wire SHIPPED entry + the 0x02CD/no-guid + apply-gate DO-NOT-RETRY) - -- [ ] **Step 1: Full build + test (the phase gate)** - -Run: `dotnet build` then `dotnet test` -Expected: Build succeeded; all tests green (App + Core + Core.Net). Note the new counts vs the B-Controller baseline (App 532 / Core 1526). - -- [ ] **Step 2: Retire the divergence rows** - -In `docs/architecture/retail-divergence-register.md`, find rows **AP-48** (client-side `SumCarriedBurden`) and **AP-49** (carry-aug unwired). Delete them (the wire mechanism is now ported), OR if `SumCarriedBurden` remains reachable as a fallback when the wire value is genuinely absent, reword AP-48 to "fallback only when EncumbranceVal absent" rather than deleting. State which you did in the commit. Confirm no other row references them. - -- [ ] **Step 3: Update ISSUES + roadmap** - -In `docs/ISSUES.md`: add a "Recently closed / shipped" note for D.2b-B B-Wire referencing the commits, and note that the contents-grid overflow + B-Drag remain open. - -In `docs/plans/2026-04-11-roadmap.md`: move B-Wire to the shipped column for the D.2b inventory arc. - -- [ ] **Step 4: Update the memory digest** - -In `claude-memory/project_d2b_retail_ui.md`, add a B-Wire SHIPPED entry (after the B-Controller entry) capturing: EncumbranceVal delivery (login PD upsert + live 0x02CD, no-guid) retires AP-48/AP-49; the apply-gate now passes all ints; SetStackSize 0x0197 / InventoryRemoveObject 0x0024 are top-level GameMessages (not GameEvents); ViewContents 0x0196 is a GameEvent applying membership. Update the MEMORY.md index line if the one-liner changed. - -- [ ] **Step 5: Commit** - -```bash -git add docs/architecture/retail-divergence-register.md docs/ISSUES.md docs/plans/2026-04-11-roadmap.md claude-memory/ -git commit -m "docs(D.2b-B): B-Wire shipped — retire AP-48/AP-49 + ISSUES/roadmap/memory" -``` - ---- - -## Post-implementation: visual gate - -After Task 16, B-Wire is code-complete and test-green, but the **burden-bar-reads-server-value** behavior is best confirmed live (the project's acceptance model). Launch the client against ACE (per CLAUDE.md "Running the client"), open the inventory (F12, `ACDREAM_RETAIL_UI=1`), and confirm: the burden % matches what a retail client / ACE shows for `+Acdream`, and it updates when you pick up / drop an item (live 0x02CD). Report the observation; do not claim the behavior verified without it. - ---- - -## Self-review notes (coverage vs. spec) - -- **C1a** login delivery → Task 10 (+ Task 1 UpsertProperties). **C1b** live 0x02CD → Tasks 3, 12, 13. **C1c** gate → Task 13. **C1d** consumer refresh → Task 15. -- **C2a** 0x0022 4th field → Task 7. **C2b** 0x00A0 error → Task 8. -- **C3** builders → Task 9. **C4a** ViewContents → Tasks 6, 11. **C4b** SetStackSize → Tasks 4, 12, 13 (+ Task 2 UpdateStackSize). **C4c** InventoryRemoveObject → Tasks 5, 12, 13. -- **C5** registration → Tasks 11 (GameEvents) + 12 (WorldSession switch). -- Divergence/AP-48/AP-49 + bookkeeping → Task 16. -- **CreateObject extension** (spec §7): already done (`ToWeenieData`); if Step-1 grep-named of `CreateObject.TryParse` finds a still-discarded inventory field, file it as a follow-up issue — out of scope here. diff --git a/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md b/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md deleted file mode 100644 index 23784ea3..00000000 --- a/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md +++ /dev/null @@ -1,2315 +0,0 @@ -# Retail Teleport Flow Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build the retail-faithful unified teleport flow — a `TeleportAnimState` (TAS) animation state machine + arrival hold that covers **login, logout, death, and portal** with a fade-to-black cover, **holds until the destination landblock is actually loaded** (folding in the #145 unstreamed-arrival cascade fix), and **locks input + camera for the whole transition**. - -**Architecture:** A pure Core state machine (`TeleportAnimSequencer`, the 7-state TAS machine) + a thin App orchestrator (`TeleportFlowController`) that wraps it and drives all side effects through injected delegates + a `TeleportFadeOverlay` `UiElement` for the black fade. The readiness gate (`PhysicsEngine.IsLandblockLoaded`) makes outdoor arrivals hold until the collision landblock is resident. Slices 1–2 land the sequencer + readiness gate (the **#145 fix ships independently of the visuals**); slices 3–5 route portal/login/death/logout through one controller and de-dup the duplicated readiness/recenter logic; **slice 6 (the literal 3D portal swirl) is a follow-up plan** gated on a cdb asset trace (see "Slice 6" at the end). - -**Tech Stack:** C# .NET 10, xunit (`dotnet test`), Silk.NET (GL / OpenAL / input), DatReaderWriter (dat access), the existing `AcDream.Core` / `AcDream.App` / `AcDream.UI.Abstractions` layers. Decomp oracle: `docs/research/named-retail/`. Design doc this plan implements: [`docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md`](../specs/2026-06-21-retail-teleport-flow-design.md). - ---- - -## File structure - -| Path | Responsibility | Slice | -|---|---|---| -| `src/AcDream.Core/World/TeleportAnimSequencer.cs` (new) | Pure 7-state TAS machine: enums, `TeleportAnimSnapshot`, the sequencer. No GL/dat/net. | 1 | -| `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs` (new) | Golden-timing unit tests for the sequencer. | 1 | -| `src/AcDream.Core/Physics/PhysicsEngine.cs` (modify) | Add `IsLandblockLoaded(uint)`. | 2 | -| `src/AcDream.App/World/TeleportArrivalController.cs` (modify) | Extend `TeleportArrivalRules.Decide` with the `outdoorReady` axis. | 2 | -| `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` (modify) | `ProbeArrivalGateEnabled` flag for the apparatus probe. | 2 | -| `src/AcDream.App/World/TeleportFlowController.cs` (new) | The TAS orchestrator: wraps the sequencer, drives side effects via delegates. | 3 | -| `src/AcDream.App/UI/TeleportFadeOverlay.cs` (new) | Full-screen black alpha-quad `UiElement`, driven by `Snapshot.FadeAlpha`. | 3 | -| `tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs` (new) | Delegate-dispatch unit tests (spy delegates + stub readiness). | 3 | -| `tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs` (new) | Alpha→Visible + fill-color unit tests. | 3 | -| `src/AcDream.App/Rendering/GameWindow.cs` (modify) | Wiring only (god object — no feature bodies): construct + tick the controller, split `PlaceTeleportArrival`, the four entry points, the yaw-freeze, the portal sounds, the login de-dup, the logout command. | 3,4,5 | -| `docs/architecture/retail-divergence-register.md` (modify) | The four deviation rows (§5 of the design). | 1,3,5 | - -## Architecture — the pinned controller design (read before slices 3–5) - -The relationship between the pieces is **load-bearing**; the slices assume exactly this shape: - -- **`TeleportAnimSequencer` (pure, Slice 1)** is the 7-state machine. It holds in `Tunnel` while `!worldReady` (no internal timeout), emits `Place` when it leaves `Tunnel`, and `FireLoginComplete` when it reaches `Off`. `FadeAlpha` is 0 inside the tunnel states and smoothsteps on the fade states. -- **`TeleportFlowController` (Slice 3)** owns the sequencer and **all timing/lock/placement decisions**. Each `Tick(dt)` it computes `worldReady` from the injected readiness probe (`Ready` ∨ `Impossible` ∨ frame-timeout, else hold; **Logout has no destination so it is intrinsically ready**), ticks the sequencer, and dispatches the sequencer's events to injected delegates: `onPlace` (place the player), `onLoginComplete` (flip `PortalSpace→InWorld` + send GameAction `0xA1`), `onLogoutDisconnect`, `onPlayEnterSound`, `onPlayExitSound`. -- **The input lock (`PlayerState.PortalSpace`) persists the WHOLE animation.** It is set at the entry point and cleared **only in `onLoginComplete`** (sequencer reached `Off`) — *not* at placement. This is why `PlaceTeleportArrival` is **split** in Slice 3: the placement half (Resolve + SetPosition + camera) runs on the `Place` event; the `State=InWorld` + `LoginComplete` half runs at `Off`. -- **`Begin(kind, destPos, destCell)` is idempotent on the sequencer.** Portal triggers it twice — once with no coords (`OnTeleportStarted`) and once with coords (`OnLivePositionUpdated`); the second call fills the destination without restarting the animation. -- **Entry points:** Portal/Death enter at `Tunnel` (await coords → `onPlace`); Login enters at `Tunnel` already-placed (readiness returns `Ready` immediately → fast exit; `onPlace` re-resolves harmlessly); Logout enters at `WorldFadeOut` and ends in `onLogoutDisconnect`. - -**Cross-slice conventions (so the slices compose):** -1. The 4th `Decide` arg is named **`outdoorReady`** everywhere (terrain-ready for login, landblock-ready for teleport). -2. The **yaw-freeze lives only in Slice 4** (one early-return covering both MMB look + RMB orbit); it is *not* repeated in slices 3 or 5. -3. **`PlaceTeleportArrival` is split exactly once** (Slice 3) into `PlaceTeleportArrivalCore` + `OnTeleportLoginComplete`; slices 4/5 consume those. -4. The portal sound enum is **`DatReaderWriter.Enums.Sound.EnterPortal`/`ExitPortal`** (verified — *not* `UI_EnterPortal=0x6A`); the Core `SoundId` enum is untouched. -5. The old `TeleportArrivalController` (the bare hold) becomes unused once the portal path routes through `_teleportFlow`; its field/plumbing removal is deferred to the Slice 5 de-dup commit to avoid a half-removed field mid-plan. - ---- - -### Slice 1: TeleportAnimSequencer — pure 7-state machine + unit tests - -**Placement decision:** `src/AcDream.Core/World/TeleportAnimSequencer.cs` in `namespace AcDream.Core.World`. Core already owns `src/AcDream.Core/World/` (DerethDateTime, WorldEntity, etc.) and carries no GL/net dependency. The enums and record from the shared-type contract land here alongside the sequencer. Tests in `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`. - ---- - -### Task 1.1: Create the types file (enums + record) - -**Files:** -- Create: `src/AcDream.Core/World/TeleportAnimSequencer.cs` -- Test: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs` - -- [ ] **Step 1: Write the failing test** — the test file references `TeleportAnimState`, `TeleportEntryKind`, `TeleportAnimEvent`, and `TeleportAnimSnapshot` in `AcDream.Core.World`; the test project already references `AcDream.Core` (`tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj:31`). - -```csharp -// tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs -using AcDream.Core.World; - -namespace AcDream.Core.Tests.World; - -public sealed class TeleportAnimSequencerTests -{ - // --- Task 1.1: type presence --- - - [Fact] - public void Enums_HaveExpectedValues() - { - Assert.Equal(0, (int)TeleportAnimState.Off); - Assert.Equal(1, (int)TeleportAnimState.WorldFadeOut); - Assert.Equal(2, (int)TeleportAnimState.TunnelFadeIn); - Assert.Equal(3, (int)TeleportAnimState.Tunnel); - Assert.Equal(4, (int)TeleportAnimState.TunnelContinue); - Assert.Equal(5, (int)TeleportAnimState.TunnelFadeOut); - Assert.Equal(6, (int)TeleportAnimState.WorldFadeIn); - - _ = TeleportEntryKind.Portal; - _ = TeleportEntryKind.Login; - _ = TeleportEntryKind.Death; - _ = TeleportEntryKind.Logout; - - _ = TeleportAnimEvent.PlayEnterSound; - _ = TeleportAnimEvent.PlayExitSound; - _ = TeleportAnimEvent.Place; - _ = TeleportAnimEvent.FireLoginComplete; - _ = TeleportAnimEvent.EnterTunnel; - } - - [Fact] - public void Snapshot_DefaultsAreOff_ClearAlpha() - { - var snap = new TeleportAnimSnapshot( - TeleportAnimState.Off, FadeAlpha: 0f, ShowTunnel: false, ShowPleaseWait: false); - Assert.Equal(TeleportAnimState.Off, snap.State); - Assert.Equal(0f, snap.FadeAlpha); - Assert.False(snap.ShowTunnel); - Assert.False(snap.ShowPleaseWait); - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Enums_HaveExpectedValues" - ``` - Expected: FAIL — `TeleportAnimState` does not exist yet. - -- [ ] **Step 3: Implement** — create the types (no sequencer class body yet, that comes in Task 1.2): - -```csharp -// src/AcDream.Core/World/TeleportAnimSequencer.cs -namespace AcDream.Core.World; - -// acclient.h:6871 — TeleportAnimState enum, verbatim order. -public enum TeleportAnimState -{ - Off = 0, - WorldFadeOut = 1, - TunnelFadeIn = 2, - Tunnel = 3, - TunnelContinue = 4, - TunnelFadeOut = 5, - WorldFadeIn = 6, -} - -/// Why the teleport was triggered — drives per-entry start state (spec §2.5). -public enum TeleportEntryKind { Portal, Login, Death, Logout } - -/// -/// Edge-triggered events the sequencer emits on the tick they first occur. -/// Consumers drive audio / placement / LoginComplete from these; the sequencer -/// has no dependency on any of those systems. -/// -public enum TeleportAnimEvent -{ - PlayEnterSound, // Begin(): sound_ui_enter_portal - EnterTunnel, // Off/WorldFade* -> Tunnel: world is now hidden - Place, // Tunnel -> TunnelContinue: world loaded; place the player - PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal - FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1 -} - -/// Immutable per-frame snapshot from the sequencer. -public readonly record struct TeleportAnimSnapshot( - TeleportAnimState State, - float FadeAlpha, // 0 = clear world, 1 = full black - bool ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut - bool ShowPleaseWait); // true during TunnelContinue only -``` - -- [ ] **Step 4: Run to verify it passes** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests" - ``` - Expected: PASS. - -- [ ] **Step 5: Commit** - ``` - git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs - git commit -m "feat(core/slice-1): TeleportAnimSequencer — define enums, record, event types" - ``` - ---- - -### Task 1.2: Sequencer skeleton + Begin(), IsActive, initial state - -**Files:** -- Modify: `src/AcDream.Core/World/TeleportAnimSequencer.cs` -- Modify: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs` - -- [ ] **Step 1: Write the failing test** - -```csharp -// Add to TeleportAnimSequencerTests: - -[Theory] -[InlineData(TeleportEntryKind.Portal, TeleportAnimState.Tunnel)] -[InlineData(TeleportEntryKind.Login, TeleportAnimState.Tunnel)] -[InlineData(TeleportEntryKind.Death, TeleportAnimState.Tunnel)] -[InlineData(TeleportEntryKind.Logout, TeleportAnimState.WorldFadeOut)] -public void Begin_SetsCorrectStartState(TeleportEntryKind kind, TeleportAnimState expectedStart) -{ - var seq = new TeleportAnimSequencer(); - Assert.False(seq.IsActive); - - seq.Begin(kind); - - Assert.True(seq.IsActive); - Assert.Equal(expectedStart, seq.State); -} - -[Fact] -public void Begin_EmitsPlayEnterSoundOnFirstTick() -{ - // PlayEnterSound is edge-triggered on the first Tick after Begin. - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - var (_, events) = seq.Tick(dt: 0f, worldReady: false); - Assert.Contains(TeleportAnimEvent.PlayEnterSound, events); -} -``` - -- [ ] **Step 2: Run to verify it fails** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Begin_SetsCorrectStartState" - ``` - Expected: FAIL — `TeleportAnimSequencer` class missing. - -- [ ] **Step 3: Implement** — add class body to the same file (golden constants + Begin + Tick stub): - -```csharp -// Append to src/AcDream.Core/World/TeleportAnimSequencer.cs - -/// -/// Pure 7-state teleport animation machine (spec §2.1, acclient gmSmartBoxUI::UseTime 0x004d6e30). -/// No GL / dat / network dependency — fully unit-testable. -/// -public sealed class TeleportAnimSequencer -{ - // Golden constants (spec §2.1, verified VAs): - // TELEPORT_ANIM_FADE_TIME 0x007BD278 = 1.0s - // TELEPORT_ANIM_MIN_CONTINUE_TIME 0x007BD268 = 2.0s - // TELEPORT_ANIM_MAX_CONTINUE_TIME 0x007BD270 = 5.0s - public const float FadeTime = 1.0f; - public const float MinContinue = 2.0f; - public const float MaxContinue = 5.0f; - - private TeleportAnimState _state = TeleportAnimState.Off; - private float _elapsed = 0f; // time in current state - private bool _enterSoundPending = false; // edge: fire PlayEnterSound on next Tick - - public bool IsActive => _state != TeleportAnimState.Off; - public TeleportAnimState State => _state; - - /// - /// Start the animation. Portal/Login/Death enter at Tunnel (skipping world-fade-out); - /// Logout enters at WorldFadeOut (spec §2.5). - /// - public void Begin(TeleportEntryKind kind) - { - _state = kind == TeleportEntryKind.Logout - ? TeleportAnimState.WorldFadeOut - : TeleportAnimState.Tunnel; - _elapsed = 0f; - _enterSoundPending = true; - } - - /// - /// Advance the machine by seconds. - /// = destination collision landblock is resident. - /// Returns the current snapshot + edge-triggered events fired THIS tick. - /// - public (TeleportAnimSnapshot snapshot, IReadOnlyList events) - Tick(float dt, bool worldReady) - { - // Full implementation in Task 1.3 – stub returns current state with no transitions. - var evts = new List(); - if (_enterSoundPending) - { - evts.Add(TeleportAnimEvent.PlayEnterSound); - _enterSoundPending = false; - } - _elapsed += dt; - var snap = BuildSnapshot(); - return (snap, evts); - } - - private TeleportAnimSnapshot BuildSnapshot() - { - float alpha = ComputeFadeAlpha(_state, _elapsed); - bool showTunnel = _state is TeleportAnimState.TunnelFadeIn - or TeleportAnimState.Tunnel - or TeleportAnimState.TunnelContinue - or TeleportAnimState.TunnelFadeOut; - bool pleaseWait = _state == TeleportAnimState.TunnelContinue; - return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait); - } - - private static float Smoothstep(float t) - { - t = Math.Clamp(t, 0f, 1f); - return t * t * (3f - 2f * t); - } - - private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed) - { - float t = Math.Clamp(elapsed / FadeTime, 0f, 1f); - return state switch - { - // Fading TO black (alpha 0→1): - TeleportAnimState.WorldFadeOut => Smoothstep(t), - TeleportAnimState.TunnelFadeIn => 1f - Smoothstep(t), // tunnel fades IN: overlay goes clear - // Full black / fully clear inside tunnel states: - TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed - TeleportAnimState.TunnelContinue => 0f, - // Fading back out of tunnel: - TeleportAnimState.TunnelFadeOut => Smoothstep(t), // tunnel fades out: overlay goes black - TeleportAnimState.WorldFadeIn => 1f - Smoothstep(t), // world fades in: overlay clears - _ => 0f, - }; - } -} -``` - -- [ ] **Step 4: Run to verify it passes** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests" - ``` - Expected: PASS (all tests so far). - -- [ ] **Step 5: Commit** - ``` - git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs - git commit -m "feat(core/slice-1): TeleportAnimSequencer — Begin(), IsActive, enter-sound edge event" - ``` - ---- - -### Task 1.3: State transitions — timing-correct Tick() implementation - -**Files:** -- Modify: `src/AcDream.Core/World/TeleportAnimSequencer.cs` -- Modify: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs` - -- [ ] **Step 1: Write the failing tests** — drive the machine through each transition with exact elapsed times: - -```csharp -// Add to TeleportAnimSequencerTests: - -// Helper: drive the sequencer forward by total elapsed time using small fixed steps. -private static (TeleportAnimSnapshot snap, List allEvents) - DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f) -{ - var allEvts = new List(); - float remaining = seconds; - TeleportAnimSnapshot last = default; - while (remaining > 0f) - { - float dt = Math.Min(step, remaining); - var (snap, evts) = seq.Tick(dt, worldReady); - last = snap; - allEvts.AddRange(evts); - remaining -= dt; - } - return (last, allEvts); -} - -// --- Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel --- - -[Fact] -public void Logout_AfterFadeTime_TransitionsToTunnelFadeIn() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - - // Consume the enter-sound tick at dt=0 - seq.Tick(0f, worldReady: false); - - // Drive just past FadeTime (1.0s) - DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false); - - Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State); -} - -[Fact] -public void Logout_After2xFadeTime_TransitionsToTunnel() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - seq.Tick(0f, worldReady: false); // consume enter-sound - - DriveSeconds(seq, 2f * TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false); - - Assert.Equal(TeleportAnimState.Tunnel, seq.State); -} - -// --- Portal/Login/Death path: enters at Tunnel --- - -[Fact] -public void Portal_StartsInTunnel_HoldsWhileNotReady() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); // consume enter-sound - - // Drive 10s — should not advance past Tunnel while !worldReady - DriveSeconds(seq, 10f, worldReady: false); - - Assert.Equal(TeleportAnimState.Tunnel, seq.State); -} - -[Fact] -public void Portal_WhenWorldReady_TransitionsToTunnelContinue_EmitsEnterTunnelAndPlace() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); // consume enter-sound - - // The very first tick with worldReady=true should advance Tunnel→TunnelContinue and emit Place. - var (_, evts) = seq.Tick(0.016f, worldReady: true); - - Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); - Assert.Contains(TeleportAnimEvent.Place, evts); -} - -// --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut --- - -[Fact] -public void TunnelContinue_DoesNotAdvance_BeforeMinContinue() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - // Enter TunnelContinue - seq.Tick(0.016f, worldReady: true); - Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); - - // Drive MinContinue - ε — should still be TunnelContinue - DriveSeconds(seq, TeleportAnimSequencer.MinContinue - 0.1f, worldReady: true); - - Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); -} - -[Fact] -public void TunnelContinue_AdvancesToTunnelFadeOut_AfterMinContinue() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - seq.Tick(0.016f, worldReady: true); // -> TunnelContinue - - DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true); - - Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State); -} - -// --- MAX_CONTINUE forces progress even when !worldReady --- - -[Fact] -public void TunnelContinue_ForcesAdvance_AtMaxContinue_EvenIfNotReady() -{ - // Simulate: world never becomes fully ready but MAX_CONTINUE forces fade-out - // This path exercises the safety-net from spec §3.4. - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - // Manually push the state to TunnelContinue by passing worldReady=true for one tick, - // then simulate worldReady toggling back to false. - seq.Tick(0.016f, worldReady: true); // -> TunnelContinue - - // Drive MaxContinue + ε with worldReady=false (simulating "world never loaded") - DriveSeconds(seq, TeleportAnimSequencer.MaxContinue + 0.05f, worldReady: false); - - Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State); -} - -// --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event --- - -[Fact] -public void TunnelFadeOut_EmitsPlayExitSound_OnTransitionToWorldFadeIn() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - seq.Tick(0.016f, worldReady: true); // -> TunnelContinue - - // Drive through MinContinue -> TunnelFadeOut - DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true); - Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State); - - // Drive through FadeTime -> WorldFadeIn; PlayExitSound should fire on that edge - var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); - Assert.Equal(TeleportAnimState.WorldFadeIn, seq.State); - Assert.Contains(TeleportAnimEvent.PlayExitSound, evts); -} - -// --- WorldFadeIn -> Off: FireLoginComplete edge event --- - -[Fact] -public void WorldFadeIn_EmitsFireLoginComplete_OnTransitionToOff() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - seq.Tick(0.016f, worldReady: true); // -> TunnelContinue - DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true); - DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); // -> WorldFadeIn - - var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); - - Assert.Equal(TeleportAnimState.Off, seq.State); - Assert.False(seq.IsActive); - Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts); -} -``` - -- [ ] **Step 2: Run to verify it fails** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_WhenWorldReady_TransitionsToTunnelContinue" - ``` - Expected: FAIL — Tick() stub never advances state. - -- [ ] **Step 3: Implement** — replace the `Tick()` stub in `TeleportAnimSequencer` with the full transition machine. Also tracks elapsed-in-state and `_continueElapsed` for the MIN/MAX gates: - -```csharp -// Replace the existing Tick() and add _continueElapsed field in TeleportAnimSequencer: - -private float _continueElapsed = 0f; // tracks time inside TunnelContinue -private bool _exitSoundPending = false; -private bool _loginCompletePending = false; -private bool _enterTunnelPending = false; - -public (TeleportAnimSnapshot snapshot, IReadOnlyList events) - Tick(float dt, bool worldReady) -{ - var evts = new List(); - - // Edge events queued by Begin or a prior transition: - if (_enterSoundPending) { evts.Add(TeleportAnimEvent.PlayEnterSound); _enterSoundPending = false; } - if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; } - if (_exitSoundPending) { evts.Add(TeleportAnimEvent.PlayExitSound); _exitSoundPending = false; } - if (_loginCompletePending){ evts.Add(TeleportAnimEvent.FireLoginComplete); _loginCompletePending = false; } - - _elapsed += dt; - - // State-machine transitions: - switch (_state) - { - case TeleportAnimState.WorldFadeOut: - if (_elapsed >= FadeTime) - Advance(TeleportAnimState.TunnelFadeIn, evts, enterTunnel: false); - break; - - case TeleportAnimState.TunnelFadeIn: - if (_elapsed >= FadeTime) - Advance(TeleportAnimState.Tunnel, evts, enterTunnel: true); - break; - - case TeleportAnimState.Tunnel: - // Hold here until worldReady (EndTeleportAnimation analogue). - if (worldReady) - { - evts.Add(TeleportAnimEvent.Place); - Advance(TeleportAnimState.TunnelContinue, evts, enterTunnel: false); - _continueElapsed = 0f; - } - break; - - case TeleportAnimState.TunnelContinue: - _continueElapsed += dt; - bool minMet = _continueElapsed >= MinContinue; - bool maxForce = _continueElapsed >= MaxContinue; - if (minMet || maxForce) - Advance(TeleportAnimState.TunnelFadeOut, evts, enterTunnel: false); - break; - - case TeleportAnimState.TunnelFadeOut: - if (_elapsed >= FadeTime) - { - _exitSoundPending = true; - Advance(TeleportAnimState.WorldFadeIn, evts, enterTunnel: false); - // Flush the exit sound in the same tick: - evts.Add(TeleportAnimEvent.PlayExitSound); - _exitSoundPending = false; - } - break; - - case TeleportAnimState.WorldFadeIn: - if (_elapsed >= FadeTime) - { - _loginCompletePending = true; - Advance(TeleportAnimState.Off, evts, enterTunnel: false); - evts.Add(TeleportAnimEvent.FireLoginComplete); - _loginCompletePending = false; - } - break; - - case TeleportAnimState.Off: - default: - break; - } - - return (BuildSnapshot(), evts); -} - -private void Advance(TeleportAnimState next, List evts, bool enterTunnel) -{ - _state = next; - _elapsed = 0f; - if (enterTunnel) _enterTunnelPending = true; -} -``` - -- [ ] **Step 4: Run to verify it passes** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests" - ``` - Expected: PASS (all transition tests). - -- [ ] **Step 5: Commit** - ``` - git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs - git commit -m "feat(core/slice-1): TeleportAnimSequencer — full 7-state Tick() with timed transitions and edge events" - ``` - ---- - -### Task 1.4: FadeAlpha endpoints and monotonicity - -**Files:** -- Modify: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs` - -- [ ] **Step 1: Write the failing tests** - -```csharp -// Add to TeleportAnimSequencerTests: - -[Fact] -public void FadeAlpha_IsZeroAtStart_OfWorldFadeOut() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - var (snap, _) = seq.Tick(dt: 0f, worldReady: false); - // At elapsed=0 in WorldFadeOut: smoothstep(0)=0 => alpha=0 (world fully visible). - Assert.Equal(0f, snap.FadeAlpha, precision: 4); -} - -[Fact] -public void FadeAlpha_IsOneAtEnd_OfWorldFadeOut() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0 - - // Drive to just BEFORE the transition (so we're still in WorldFadeOut) - DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.02f, worldReady: false); - Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State); - - var (snap, _) = seq.Tick(0f, worldReady: false); - // smoothstep(clamp((1.0-0.02)/1.0,0,1)) should be close to 1 - Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}"); -} - -[Fact] -public void FadeAlpha_IsMonotonicallyIncreasing_DuringWorldFadeOut() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - seq.Tick(0f, worldReady: false); - - float prev = -1f; - // Sample 50 steps within FadeTime - float step = TeleportAnimSequencer.FadeTime / 50f; - for (int i = 0; i < 48; i++) // stop before transition - { - var (snap, _) = seq.Tick(step, worldReady: false); - if (seq.State != TeleportAnimState.WorldFadeOut) break; - Assert.True(snap.FadeAlpha >= prev - 0.001f, - $"Alpha decreased: {prev} -> {snap.FadeAlpha} at step {i}"); - prev = snap.FadeAlpha; - } -} - -[Fact] -public void FadeAlpha_IsZero_DuringTunnelStates() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - - // In Tunnel state, overlay alpha should be 0 (the tunnel viewport is shown; no alpha quad needed) - var (snap, _) = seq.Tick(0.016f, worldReady: false); - Assert.Equal(TeleportAnimState.Tunnel, seq.State); - Assert.Equal(0f, snap.FadeAlpha); -} - -[Fact] -public void ShowTunnel_TrueInTunnelFamilyStates_FalseOtherwise() -{ - // Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel -> TunnelContinue -> TunnelFadeOut -> WorldFadeIn -> Off - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - seq.Tick(0f, worldReady: false); - - // WorldFadeOut: ShowTunnel = false - var (snap, _) = seq.Tick(0.016f, worldReady: false); - Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State); - Assert.False(snap.ShowTunnel); - - // Advance to TunnelFadeIn - DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false); - Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State); - var (snap2, _) = seq.Tick(0f, worldReady: false); - Assert.True(snap2.ShowTunnel); - - // Advance to Tunnel - DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false); - Assert.Equal(TeleportAnimState.Tunnel, seq.State); - var (snap3, _) = seq.Tick(0f, worldReady: false); - Assert.True(snap3.ShowTunnel); -} - -[Fact] -public void ShowPleaseWait_TrueOnlyInTunnelContinue() -{ - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - - // Tunnel: ShowPleaseWait false - var (snap1, _) = seq.Tick(0.016f, worldReady: false); - Assert.Equal(TeleportAnimState.Tunnel, seq.State); - Assert.False(snap1.ShowPleaseWait); - - // Advance to TunnelContinue - var (snap2, _) = seq.Tick(0.016f, worldReady: true); - Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); - Assert.True(snap2.ShowPleaseWait); -} -``` - -- [ ] **Step 2: Run to verify it fails** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.FadeAlpha_IsZeroAtStart_OfWorldFadeOut" - ``` - Expected: FAIL — `ComputeFadeAlpha` result not yet connected through `Tick` (the stub returned 0 unconditionally, Task 1.2 hadn't wired `BuildSnapshot` into the real state in `Tick`). After Task 1.3 landed, the alpha logic is correct — this test validates it runs through the right smoothstep path. (If Task 1.3 is already green, these may pass immediately; the step 2 confirms they exercise the code path.) - -- [ ] **Step 3: Implement** — `ComputeFadeAlpha` is already implemented in Task 1.2; no new implementation needed. The only refinement: ensure `_elapsed` is sampled correctly relative to the current state's entry time (already reset to 0f in `Advance`). No code changes expected if Task 1.3 is green. - -- [ ] **Step 4: Run to verify it passes** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests" - ``` - Expected: PASS. - -- [ ] **Step 5: Commit** - ``` - git add tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs - git commit -m "test(core/slice-1): FadeAlpha endpoint + monotonicity + ShowTunnel/ShowPleaseWait coverage" - ``` - ---- - -### Task 1.5: Full portal event sequence and logout event sequence - -**Files:** -- Modify: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs` - -- [ ] **Step 1: Write the failing tests** — exercise the complete ordered event stream for each major path: - -```csharp -// Add to TeleportAnimSequencerTests: - -// Helper: run the sequencer to completion collecting ordered events. -private static List RunToOff( - TeleportEntryKind kind, bool worldReadyAfterFirstTunnel = true, float step = 0.05f) -{ - var seq = new TeleportAnimSequencer(); - var allEvts = new List(); - seq.Begin(kind); - - int safetyNet = 5000; - while (seq.IsActive && --safetyNet > 0) - { - // Simulate world becoming ready on first Tick inside Tunnel - bool ready = worldReadyAfterFirstTunnel && seq.State == TeleportAnimState.Tunnel; - var (_, evts) = seq.Tick(step, worldReady: ready); - allEvts.AddRange(evts); - } - return allEvts; -} - -[Fact] -public void Portal_FullSequence_EventsInOrder() -{ - var evts = RunToOff(TeleportEntryKind.Portal); - - // Required events in order: PlayEnterSound, Place, PlayExitSound, FireLoginComplete. - // EnterTunnel is optional but must come before Place if present. - int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound); - int placeIdx = evts.IndexOf(TeleportAnimEvent.Place); - int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound); - int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete); - - Assert.True(enterIdx >= 0, "PlayEnterSound must fire"); - Assert.True(placeIdx >= 0, "Place must fire"); - Assert.True(exitIdx >= 0, "PlayExitSound must fire"); - Assert.True(loginIdx >= 0, "FireLoginComplete must fire"); - - Assert.True(enterIdx < placeIdx, "PlayEnterSound must precede Place"); - Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound"); - Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete"); -} - -[Fact] -public void Logout_FullSequence_EventsInOrder_WithEnterTunnelAfterFades() -{ - var evts = RunToOff(TeleportEntryKind.Logout); - - int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound); - int tunnelIdx = evts.IndexOf(TeleportAnimEvent.EnterTunnel); - int placeIdx = evts.IndexOf(TeleportAnimEvent.Place); - int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound); - int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete); - - Assert.True(enterIdx >= 0, "PlayEnterSound must fire"); - Assert.True(tunnelIdx >= 0, "EnterTunnel must fire (Logout path goes through WorldFadeOut->TunnelFadeIn->Tunnel)"); - Assert.True(placeIdx >= 0, "Place must fire"); - Assert.True(exitIdx >= 0, "PlayExitSound must fire"); - Assert.True(loginIdx >= 0, "FireLoginComplete must fire"); - - Assert.True(enterIdx < tunnelIdx, "PlayEnterSound must precede EnterTunnel"); - Assert.True(tunnelIdx < placeIdx, "EnterTunnel must precede Place"); - Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound"); - Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete"); -} - -[Fact] -public void Portal_NoEventsFiredTwice() -{ - var evts = RunToOff(TeleportEntryKind.Portal); - Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayEnterSound)); - Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.Place)); - Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayExitSound)); - Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.FireLoginComplete)); -} - -[Fact] -public void Death_BehavesIdenticallyToPortal_FullSequence() -{ - var portalEvts = RunToOff(TeleportEntryKind.Portal); - var deathEvts = RunToOff(TeleportEntryKind.Death); - // Same event sequence (both enter at Tunnel) - Assert.Equal(portalEvts, deathEvts); -} - -[Fact] -public void Login_BehavesIdenticallyToPortal_FullSequence() -{ - var portalEvts = RunToOff(TeleportEntryKind.Portal); - var loginEvts = RunToOff(TeleportEntryKind.Login); - Assert.Equal(portalEvts, loginEvts); -} - -[Fact] -public void AfterOff_IsActiveIsFalse_AndStateIsOff() -{ - RunToOff(TeleportEntryKind.Portal); - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - - // Drain to Off - for (int i = 0; i < 5000 && seq.IsActive; i++) - { - bool ready = seq.State == TeleportAnimState.Tunnel; - seq.Tick(0.05f, worldReady: ready); - } - - Assert.False(seq.IsActive); - Assert.Equal(TeleportAnimState.Off, seq.State); -} -``` - -- [ ] **Step 2: Run to verify it fails** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_FullSequence_EventsInOrder" - ``` - Expected: FAIL — `EnterTunnel` event not fired correctly through Logout's TunnelFadeIn→Tunnel transition in current stub (Task 1.3's `_enterTunnelPending` wiring needs verification). - -- [ ] **Step 3: Implement** — verify `_enterTunnelPending` is flushed at the START of the NEXT Tick, not deferred further. The current `Tick()` already has: - ```csharp - if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; } - ``` - which runs before the state-machine switch. The `Advance(TunnelFadeIn→Tunnel, enterTunnel: true)` sets `_enterTunnelPending = true`, which flushes on the next Tick. This is already correct. No code change needed — the `RunToOff` helper exercises the Logout path which was not hit before. If EnterTunnel fires on the same tick as the transition, it is counted in `allEvts` correctly because `DriveSeconds` accumulates all per-tick events. - -- [ ] **Step 4: Run to verify it passes** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests" - ``` - Expected: PASS for all sequencer tests. - -- [ ] **Step 5: Commit** - ``` - git add tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs - git commit -m "test(core/slice-1): full portal+logout event-sequence ordering + no-duplicate-fire coverage" - ``` - ---- - -### Task 1.6: EnterTunnel fired for Portal path via Logout-to-Tunnel transition correctness + Build green - -**Files:** -- Modify: `src/AcDream.Core/World/TeleportAnimSequencer.cs` (if any gap surfaces) -- Run: `dotnet build` and full test suite - -- [ ] **Step 1: Write the failing test** — assert EnterTunnel fires on the Portal path (Portal enters Tunnel directly; the Begin sets state to Tunnel but EnterTunnel should still fire since the tunnel is newly entered): - -```csharp -// Add to TeleportAnimSequencerTests: - -[Fact] -public void Portal_EmitsEnterTunnel_OnFirstTick() -{ - // Portal begins directly in Tunnel; EnterTunnel signals "world is now hidden". - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - - // First tick: should emit PlayEnterSound AND EnterTunnel (both on entry) - var (_, evts) = seq.Tick(0f, worldReady: false); - Assert.Contains(TeleportAnimEvent.PlayEnterSound, evts); - Assert.Contains(TeleportAnimEvent.EnterTunnel, evts); -} -``` - -- [ ] **Step 2: Run to verify it fails** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_EmitsEnterTunnel_OnFirstTick" - ``` - Expected: FAIL — `Begin` with Portal kind doesn't set `_enterTunnelPending`. - -- [ ] **Step 3: Implement** — update `Begin()` to also set `_enterTunnelPending` when entering at Tunnel: - -```csharp -public void Begin(TeleportEntryKind kind) -{ - _state = kind == TeleportEntryKind.Logout - ? TeleportAnimState.WorldFadeOut - : TeleportAnimState.Tunnel; - _elapsed = 0f; - _continueElapsed = 0f; - _enterSoundPending = true; - _enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death - _exitSoundPending = false; - _loginCompletePending = false; -} -``` - -- [ ] **Step 4: Run all sequencer tests + build** - ``` - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests" - dotnet build src/AcDream.Core/AcDream.Core.csproj - ``` - Expected: all PASS, build green. - -- [ ] **Step 5: Commit** - ``` - git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs - git commit -m "feat(core/slice-1): Begin() sets EnterTunnel pending for portal/login/death entry; all sequencer tests pass" - ``` - ---- - -### Task 1.7: Full build + full test suite green; register divergence row - -**Files:** -- Modify: `docs/architecture/retail-divergence-register.md` (add smoothstep row per spec §5 / §3.2) - -- [ ] **Step 1:** No test to write — this is the completion gate. - -- [ ] **Step 2:** Run full suite: - ``` - dotnet build - dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj - ``` - Expected: build green; all tests pass (including pre-existing suite). - -- [ ] **Step 3: Implement** — add the divergence-register row. Read the existing register to find the insertion point: - Open `docs/architecture/retail-divergence-register.md`, locate the table body, and add one row: - - ``` - | TAS-smoothstep | TeleportAnimSequencer.ComputeFadeAlpha | GetAnimLevel 1024-entry table approximated as smoothstep | approximation | src/AcDream.Core/World/TeleportAnimSequencer.cs | retire if GetAnimLevel table resolved via cdb read (spec §8) | - ``` - - (Exact column order matches the existing register rows — read the file header before inserting.) - -- [ ] **Step 4:** Build + test green (same commands as Step 2). - -- [ ] **Step 5: Commit** - ``` - git add docs/architecture/retail-divergence-register.md - git commit -m "docs(slice-1): retail-divergence-register — TAS smoothstep approximation row (spec §5 row 2)" - ``` - ---- - -### Slice 2 Task List — Readiness Gate (#145 Residual / Work-Item-A Fix) - ---- - -### Task 2.1: Add `PhysicsEngine.IsLandblockLoaded` with unit test - -**Files:** -- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs` (after `LandblockCount` property, line 32) -- Test: `tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs` (new file) - -- [ ] **Step 1: Write the failing test** - -```csharp -// tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -public class PhysicsEngineIsLandblockLoadedTests -{ - private static TerrainSurface MakeFlatTerrain() - { - var heights = new byte[81]; - Array.Fill(heights, (byte)50); - var heightTable = new float[256]; - for (int i = 0; i < 256; i++) heightTable[i] = i * 1f; - return new TerrainSurface(heights, heightTable); - } - - [Fact] - public void IsLandblockLoaded_AbsentId_ReturnsFalse() - { - var engine = new PhysicsEngine(); - Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu)); - } - - [Fact] - public void IsLandblockLoaded_AddedId_ReturnsTrue() - { - var engine = new PhysicsEngine(); - engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(), - Array.Empty(), Array.Empty(), - worldOffsetX: 0f, worldOffsetY: 0f); - - Assert.True(engine.IsLandblockLoaded(0xA9B4FFFFu)); - } - - [Fact] - public void IsLandblockLoaded_AnotherIdAbsent_ReturnsFalse() - { - var engine = new PhysicsEngine(); - engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(), - Array.Empty(), Array.Empty(), - worldOffsetX: 0f, worldOffsetY: 0f); - - Assert.False(engine.IsLandblockLoaded(0xDEADBEEFu)); - } - - [Fact] - public void IsLandblockLoaded_AfterRemove_ReturnsFalse() - { - var engine = new PhysicsEngine(); - engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(), - Array.Empty(), Array.Empty(), - worldOffsetX: 0f, worldOffsetY: 0f); - engine.RemoveLandblock(0xA9B4FFFFu); - - Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu)); - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - - Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineIsLandblockLoadedTests"` - - Expected: FAIL — `CS1061: 'PhysicsEngine' does not contain a definition for 'IsLandblockLoaded'`. - -- [ ] **Step 3: Implement** - - In `src/AcDream.Core/Physics/PhysicsEngine.cs`, after `LandblockCount` (line 32): - - ```csharp - /// - /// Returns true iff a landblock with this id has been registered via - /// . Because registers - /// terrain + collision cells + portals atomically, ContainsKey implies the - /// collision mesh is present and resolvable. - /// - public bool IsLandblockLoaded(uint landblockId) => - _landblocks.ContainsKey(landblockId); - ``` - -- [ ] **Step 4: Run to verify it passes** - - Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineIsLandblockLoadedTests"` - - Expected: PASS (4 tests, all green). - -- [ ] **Step 5: Commit** - - ``` - git add src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs && git commit -m "feat(slice2): add PhysicsEngine.IsLandblockLoaded — readiness gate §3.4" - ``` - ---- - -### Task 2.2: Extend `TeleportArrivalRules.Decide` with `outdoorReady` and update truth-table tests - -**Files:** -- Modify: `src/AcDream.App/World/TeleportArrivalController.cs` (`TeleportArrivalRules.Decide`, line 119) -- Modify: `tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs` (extend existing tests + add new ones) - -- [ ] **Step 1: Write the failing test** - - Replace the content of `tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs`: - - ```csharp - using AcDream.App.World; - using Xunit; - - namespace AcDream.App.Tests.World; - - /// - /// Full truth-table for TeleportArrivalRules.Decide after the §3.4 readiness-gate change. - /// - public class TeleportArrivalRulesTests - { - [Fact] - public void Unhydratable_IsImpossible_Outdoor() - { - Assert.Equal(ArrivalReadiness.Impossible, - TeleportArrivalRules.Decide( - claimUnhydratable: true, indoor: false, - indoorCellReady: false, outdoorReady: false)); - } - - [Fact] - public void Unhydratable_IsImpossible_Indoor() - { - Assert.Equal(ArrivalReadiness.Impossible, - TeleportArrivalRules.Decide( - claimUnhydratable: true, indoor: true, - indoorCellReady: true, outdoorReady: false)); - } - - [Fact] - public void Indoor_CellReady_IsReady() - { - Assert.Equal(ArrivalReadiness.Ready, - TeleportArrivalRules.Decide( - claimUnhydratable: false, indoor: true, - indoorCellReady: true, outdoorReady: false)); - } - - [Fact] - public void Indoor_CellNotReady_IsNotReady() - { - Assert.Equal(ArrivalReadiness.NotReady, - TeleportArrivalRules.Decide( - claimUnhydratable: false, indoor: true, - indoorCellReady: false, outdoorReady: false)); - } - - [Fact] - public void Outdoor_LandblockLoaded_IsReady() - { - Assert.Equal(ArrivalReadiness.Ready, - TeleportArrivalRules.Decide( - claimUnhydratable: false, indoor: false, - indoorCellReady: false, outdoorReady: true)); - } - - [Fact] - public void Outdoor_LandblockNotLoaded_IsNotReady() - { - // §3.4: the #145 residual — outdoor arrival on a not-yet-streamed landblock - // must hold. Previously returned Ready immediately. - Assert.Equal(ArrivalReadiness.NotReady, - TeleportArrivalRules.Decide( - claimUnhydratable: false, indoor: false, - indoorCellReady: false, outdoorReady: false)); - } - } - ``` - -- [ ] **Step 2: Run to verify it fails** - - Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"` - - Expected: FAIL — `Outdoor_LandblockNotLoaded_IsNotReady` fails (current code returns `Ready` for all outdoor), and `Outdoor_LandblockLoaded_IsReady` fails with a compile error because the new parameter doesn't exist yet. - -- [ ] **Step 3: Implement** - - In `src/AcDream.App/World/TeleportArrivalController.cs`, replace `TeleportArrivalRules.Decide` (lines 119–143): - - ```csharp - /// - /// Decide whether a held teleport arrival can place now. - /// - /// The destination cell can never hydrate. - /// Destination is an indoor cell (cell index ≥ 0x0100). - /// For an indoor destination, the EnvCell floor has hydrated. - /// For an outdoor destination, the physics landblock is loaded - /// ( returned true for the destination landblock id). - /// Ignored for indoor destinations. - public static ArrivalReadiness Decide( - bool claimUnhydratable, - bool indoor, - bool indoorCellReady, - bool outdoorReady) - { - if (claimUnhydratable) - return ArrivalReadiness.Impossible; - - // Indoor (sealed dungeon / building interior): gate on the EnvCell floor hydrating, - // exactly as #135. Unaffected by the outdoor-landblock gate. - if (indoor) - return indoorCellReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady; - - // Outdoor (#145 §3.4): hold until the physics landblock is registered. - // AddLandblock is atomic (terrain + cells + portals in one write — PhysicsEngine.cs:70), - // so ContainsKey implies the collision mesh is present and resolvable. The hold is - // self-resolving: streaming progresses unconditionally during the hold because the - // streaming controller runs before TeleportArrivalController.Tick (GameWindow.cs:7420-7551). - // The 10 s timeout safety-net (_maxHoldFrames) remains active. - return outdoorReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady; - } - ``` - -- [ ] **Step 4: Run to verify it passes** - - Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"` - - Expected: PASS (6 tests, all green). - -- [ ] **Step 5: Commit** - - ``` - git add src/AcDream.App/World/TeleportArrivalController.cs tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs && git commit -m "feat(slice2): TeleportArrivalRules.Decide — outdoor holds until landblock loaded (#145 §3.4)" - ``` - ---- - -### Task 2.3: Fix the compile break — update the one `Decide` call-site in `GameWindow.cs` - -The signature change in Task 2.2 breaks the call at `GameWindow.cs:5528`. This task wires `outdoorReady` and verifies the build is green. There is no unit test for GameWindow wiring (god object); verification is build-green + a described manual run. - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (method `TeleportArrivalReadiness`, around line 5513) - -- [ ] **Step 1: Locate the anchor** — `GameWindow.cs:5528`: - - ```csharp - // existing line 5528: - return AcDream.App.World.TeleportArrivalRules.Decide( - claimUnhydratable, indoor, indoorCellReady); - ``` - -- [ ] **Step 2: Apply the edit** — replace the three-argument call with a four-argument call: - - ```csharp - // After the existing: - // bool indoor = (destCell & 0xFFFFu) >= 0x0100u; - // bool indoorCellReady = indoor && !claimUnhydratable - // && _physicsEngine.IsSpawnCellReady(destCell); - // Add: - bool outdoorReady = !indoor - && _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u); - - return AcDream.App.World.TeleportArrivalRules.Decide( - claimUnhydratable, indoor, indoorCellReady, outdoorReady); - ``` - - The full method body after the edit: - - ```csharp - private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness( - System.Numerics.Vector3 destPos, uint destCell) - { - bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell); - - bool indoor = (destCell & 0xFFFFu) >= 0x0100u; - bool indoorCellReady = indoor && !claimUnhydratable - && _physicsEngine.IsSpawnCellReady(destCell); - bool outdoorReady = !indoor - && _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u); - - return AcDream.App.World.TeleportArrivalRules.Decide( - claimUnhydratable, indoor, indoorCellReady, outdoorReady); - } - ``` - -- [ ] **Step 3: Build green** - - Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` - - Expected: zero errors. - -- [ ] **Step 4: Commit** - - ``` - git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(slice2): wire outdoorReady into TeleportArrivalReadiness (GameWindow)" - ``` - ---- - -### Task 2.4: Add `ProbeArrivalGate` flag to `PhysicsDiagnostics` and log the `NotReady → Ready` transition - -The existing `PhysicsDiagnostics` pattern (read `PhysicsDiagnostics.cs:31–32` for the template): a `static bool` property seeded from an env var, listed in `ResetForTest()`. The probe log lives in `TeleportArrivalReadiness` in `GameWindow.cs`. - -**Files:** -- Modify: `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` (add property + `ResetForTest` entry) -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add `_lastArrivalVerdict` field + probe emission in `TeleportArrivalReadiness`) - -There is no unit test for a diagnostic emission; verification is the described manual run in Task 2.5. - -- [ ] **Step 1: Add the probe flag to `PhysicsDiagnostics`** - - In `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`, after `ProbeResolveEnabled` (after line 32): - - ```csharp - /// - /// Retail teleport readiness gate (§3.4, 2026-06-21). When true, every transition of - /// the TeleportArrivalReadiness verdict (NotReady → Ready / Impossible) emits one - /// [arrival-gate] line: the destination cell id, landblock id, verdict, - /// PhysicsEngine.LandblockCount at the moment it flips, and whether the - /// flip was forced (Impossible) or natural (Ready). - /// - /// Initial state from ACDREAM_PROBE_RESOLVE=1 — shares the existing probe - /// flag because this is a readiness-path extension; no new env var needed. - /// - public static bool ProbeArrivalGateEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1"; - ``` - - In `PhysicsDiagnostics.ResetForTest()` (line 536 area), add after `ProbeResolveEnabled = false;`: - - ```csharp - ProbeArrivalGateEnabled = false; - ``` - -- [ ] **Step 2: Add the transition-tracking field and probe emission to GameWindow** - - In `src/AcDream.App/Rendering/GameWindow.cs`, near the `TeleportArrivalController` field (grep for `_teleportArrival` to find the declaration block), add: - - ```csharp - private AcDream.App.World.ArrivalReadiness _lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady; - ``` - - Then in `TeleportArrivalReadiness` (after computing the verdict and before returning), add the probe emission: - - ```csharp - private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness( - System.Numerics.Vector3 destPos, uint destCell) - { - bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell); - - bool indoor = (destCell & 0xFFFFu) >= 0x0100u; - bool indoorCellReady = indoor && !claimUnhydratable - && _physicsEngine.IsSpawnCellReady(destCell); - bool outdoorReady = !indoor - && _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u); - - var verdict = AcDream.App.World.TeleportArrivalRules.Decide( - claimUnhydratable, indoor, indoorCellReady, outdoorReady); - - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeArrivalGateEnabled - && verdict != _lastArrivalVerdict - && verdict != AcDream.App.World.ArrivalReadiness.NotReady) - { - Console.WriteLine(System.FormattableString.Invariant( - $"[arrival-gate] destCell=0x{destCell:X8} " + - $"landblock=0x{(destCell & 0xFFFF0000u):X8} " + - $"verdict={verdict} indoor={indoor} " + - $"landblockCount={_physicsEngine.LandblockCount}")); - } - _lastArrivalVerdict = verdict; - - return verdict; - } - ``` - - Reset `_lastArrivalVerdict` to `NotReady` in `BeginArrival` (i.e. wherever `_teleportArrival.BeginArrival` is called) so each new hold starts fresh. Find the `BeginArrival` call site by searching for `_teleportArrival.BeginArrival` in `GameWindow.cs`; immediately before or after that call add: - - ```csharp - _lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady; - ``` - -- [ ] **Step 3: Build green** - - Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` - - Expected: zero errors, zero warnings on the touched files. - -- [ ] **Step 4: Commit** - - ``` - git add src/AcDream.Core/Physics/PhysicsDiagnostics.cs src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(slice2): arrival-gate probe — log NotReady→Ready flip with landblock count" - ``` - ---- - -### Task 2.5: Apparatus re-test — verify the gate on the `0xC98C` edge-arrival scenario - -This task has no automated xunit test — it is a described manual run against a live session using the probe. - -**What the test proves:** The readiness gate correctly holds an outdoor arrival until `AddLandblock` fires for the destination, then flips `Ready` in the same streaming frame — no cell-march, no Z free-fall. - -- [ ] **Step 1: Launch with the probe enabled** - - In PowerShell: - - ```powershell - $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" - $env:ACDREAM_LIVE = "1" - $env:ACDREAM_TEST_HOST = "127.0.0.1" - $env:ACDREAM_TEST_PORT = "9000" - $env:ACDREAM_TEST_USER = "testaccount" - $env:ACDREAM_TEST_PASS = "testpassword" - $env:ACDREAM_PROBE_RESOLVE = "1" # gates both ProbeResolveEnabled and ProbeArrivalGateEnabled - dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-gate-retest.log" - ``` - -- [ ] **Step 2: Reproduce the `0xC98C` scenario** - - Use the `/teleport` GM command to portal to a landblock near a streaming edge — ideally the same destination (`0xC98C…`) documented in `launch5.log`. Alternatively, use `/tp 0xC98C0200` or portal via a gate that forces a cross-landblock hop. - -- [ ] **Step 3: Inspect the log** - - Look for `[arrival-gate]` lines in `launch-gate-retest.log`. The expected pattern: - - ``` - [arrival-gate] destCell=0xC98C0200 landblock=0xC98C0000 verdict=Ready indoor=False landblockCount=N - live: teleport complete — snapped to (...) cell=0xC98C... - ``` - - The `[arrival-gate]` line MUST appear before the `live: teleport complete` line. `landblockCount` should be ≥ 1 at the moment the gate flips. - - The probe MUST NOT be followed by any sequence of `[resolve]` lines that show `branch=NO-LANDBLOCK` after the placement — that is the Z-free-fall symptom. If NO-LANDBLOCK lines appear after `live: teleport complete`, the fix has a gap and must be investigated before closing. - -- [ ] **Step 4: Confirm no regression on indoor arrival** - - Enter a dungeon (walk into a portal in Holtburg). The log should show `[arrival-gate] indoor=True` lines while `NotReady`, then a flip to `Ready` once the EnvCell floor hydrates — same behavior as before. The change must not regress the indoor path. - -- [ ] **Step 5: Commit result note** - - If the run passes, record a one-line note in the commit that closes this apparatus task: - - ``` - git commit --allow-empty -m "test(slice2): apparatus re-test passed — arrival gate flips Ready before placement on 0xC98C edge arrival; no NO-LANDBLOCK march observed" - ``` - - If the run reveals a gap, file it as a new issue before continuing. - ---- - -### Task 2.6: Full test suite green + run-all confirmation - -- [ ] **Step 1: Run the full suite** - - ``` - dotnet test AcDream.slnx -c Debug - ``` - - Expected: all tests pass. No new test failures introduced by the `Decide` signature change. (The only existing caller was `TeleportArrivalRulesTests.cs`, which was updated in Task 2.2. `TeleportArrivalControllerTests.cs` exercises the controller via the delegate — not `Decide` directly — so it is unaffected.) - -- [ ] **Step 2: Confirm `TeleportArrivalControllerTests` still passes** - - Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"` - - Expected: PASS (the controller's injected `_readiness` delegate passes `ArrivalReadiness` values directly; it does not call `Decide` internally, so the signature change is invisible to it). - -- [ ] **Step 3: Commit (if not already committed per-task above)** - - No additional commit if each task was committed individually. Otherwise: - - ``` - git add -u && git commit -m "feat(slice2): readiness gate complete — IsLandblockLoaded + Decide(outdoorReady) + probe (#145 §3.4)" - ``` - ---- - -### Slice 3: TeleportFlowController + TeleportFadeOverlay (portal through the sequencer + fade) - -#### Task 3.1 — `TeleportFlowController` (delegate-injected, unit-tested) - -**Files:** -- Create `src/AcDream.App/World/TeleportFlowController.cs` (namespace `AcDream.App.World`). -- Test: `tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs` (match style of `tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs:1-147` — `record`-based call spies, static `Make` helper, `[Fact]`). -- Consumes (do NOT redefine): `AcDream.Core.World.TeleportAnimSequencer` / `TeleportAnimState` / `TeleportEntryKind` / `TeleportAnimEvent` / `TeleportAnimSnapshot` (slice 1); `AcDream.App.World.ArrivalReadiness` (`src/AcDream.App/World/TeleportArrivalController.cs:7-19`). - -- [ ] **Step 1: Write the failing test.** Real xunit, mirroring `TeleportArrivalControllerTests` spy style: - -```csharp -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.World; -using AcDream.Core.World; -using Xunit; - -namespace AcDream.App.Tests.World; - -public class TeleportFlowControllerTests -{ - private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced); - - private sealed class Spies - { - public readonly List Placed = new(); - public int LoginComplete; - public int LogoutDisconnect; - public int EnterSound; - public int ExitSound; - } - - // readinessQueue: one verdict per Tick (last value repeats once exhausted). - private static TeleportFlowController Make( - Spies s, Queue readinessQueue, - int maxHoldFrames = TeleportFlowController.DefaultMaxHoldFrames) - { - ArrivalReadiness last = ArrivalReadiness.NotReady; - return new TeleportFlowController( - readiness: (_, _) => { if (readinessQueue.Count > 0) last = readinessQueue.Dequeue(); return last; }, - onPlace: (pos, cell, forced) => s.Placed.Add(new PlaceCall(pos, cell, forced)), - onLoginComplete: () => s.LoginComplete++, - onLogoutDisconnect: () => s.LogoutDisconnect++, - onPlayEnterSound: () => s.EnterSound++, - onPlayExitSound: () => s.ExitSound++, - maxHoldFrames: maxHoldFrames); - } - - // Drive enough fixed-dt ticks (1/60s) to walk a portal run all the way to OFF. - private static void RunToOff(TeleportFlowController c, int maxTicks = 2000) - { - for (int i = 0; i < maxTicks && c.IsActive; i++) c.Tick(1f / 60f); - } - - [Fact] - public void Tick_BeforeBegin_IsNoOp() - { - var s = new Spies(); - var c = Make(s, new Queue()); - c.Tick(1f / 60f); - Assert.False(c.IsActive); - Assert.Empty(s.Placed); - } - - [Fact] - public void Portal_FullRun_FiresEnterPlaceExitLoginComplete_InOrder() - { - var s = new Spies(); - // readiness Ready from the first probe so the tunnel exits as soon as it can hold. - var q = new Queue(); - q.Enqueue(ArrivalReadiness.Ready); - var c = Make(s, q); - - c.Begin(TeleportEntryKind.Portal, new Vector3(10, 20, 6), 0x01250126u); - RunToOff(c); - - Assert.False(c.IsActive); - Assert.Equal(1, s.EnterSound); - var place = Assert.Single(s.Placed); - Assert.False(place.Forced); - Assert.Equal(0x01250126u, place.Cell); - Assert.Equal(new Vector3(10, 20, 6), place.Pos); - Assert.Equal(1, s.ExitSound); - Assert.Equal(1, s.LoginComplete); - Assert.Equal(0, s.LogoutDisconnect); - } - - [Fact] - public void Begin_Idempotent_SecondBeginWithCoords_DoesNotRestartSequencer() - { - var s = new Spies(); - var q = new Queue(); - q.Enqueue(ArrivalReadiness.NotReady); // hold in TUNNEL after first probe - var c = Make(s, q); - - c.Begin(TeleportEntryKind.Portal); // no coords (OnTeleportStarted) - var stateAfterFirst = c.Snapshot.State; // sequencer began (Tunnel) - c.Tick(1f / 60f); // holding; no coords -> worldReady false for Portal - c.Begin(TeleportEntryKind.Portal, new Vector3(1, 2, 3), 0x01250126u); // coords arrive - - // Still active, still no premature place: the second Begin must NOT re-call _seq.Begin. - Assert.True(c.IsActive); - Assert.Empty(s.Placed); - Assert.Equal(TeleportAnimState.Tunnel, c.Snapshot.State); - } - - [Fact] - public void Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete() - { - var s = new Spies(); - var c = Make(s, new Queue()); // no dest -> Logout worldReady=true intrinsically - c.Begin(TeleportEntryKind.Logout); - RunToOff(c); - - Assert.False(c.IsActive); - Assert.Equal(1, s.LogoutDisconnect); - Assert.Equal(0, s.LoginComplete); - Assert.Empty(s.Placed); // _haveDest false -> Place event is a no-op - Assert.Equal(1, s.ExitSound); - } - - [Fact] - public void Impossible_ForcesPlace_WithForcedTrue() - { - var s = new Spies(); - var q = new Queue(); - q.Enqueue(ArrivalReadiness.Impossible); - var c = Make(s, q); - c.Begin(TeleportEntryKind.Portal, new Vector3(4, 5, 6), 0x0125FF00u); - RunToOff(c); - var place = Assert.Single(s.Placed); - Assert.True(place.Forced); - } -} -``` - -- [ ] **Step 2: Run to verify it fails** — `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFlowControllerTests"`. Expected: FAIL — `TeleportFlowController` does not exist (compile error). - -- [ ] **Step 3: Implement** the pinned class verbatim: - -```csharp -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.World; - -namespace AcDream.App.World; - -/// -/// Phase B (retail teleport flow): the visual/timing/lock state machine that wraps -/// the pure (the 7-state TAS machine) around the -/// existing arrival-hold readiness probe. Single owner of "a teleport is happening". -/// -/// All side effects are injected delegates (same pure-seam style as -/// ): readiness probe, placement, login-complete, -/// logout teardown, and the two portal sounds. Carries no GL / dat / net dependency -/// directly, so it is fully unit-testable. -/// -/// Drives: the sequencer per frame with a worldReady gate computed from the -/// readiness probe; on the sequencer's it runs the -/// placement (Resolve + SetPosition + camera); at -/// (sequencer reached ) it either disconnects (Logout) -/// or sends LoginComplete + flips PortalSpace->InWorld (all other kinds). The input lock -/// therefore persists the WHOLE animation, not just until placement. -/// -public sealed class TeleportFlowController -{ - /// ~10 s at 60 fps. Coarse safety net for a destination that never streams. - public const int DefaultMaxHoldFrames = 600; - - private readonly Func _readiness; - private readonly Action _onPlace; // (destPos, destCell, forced) - private readonly Action _onLoginComplete; - private readonly Action _onLogoutDisconnect; - private readonly Action _onPlayEnterSound; - private readonly Action _onPlayExitSound; - private readonly int _maxHoldFrames; - - private readonly TeleportAnimSequencer _seq = new(); - - private TeleportEntryKind _kind; - private Vector3 _destPos; - private uint _destCell; - private bool _haveDest; - private int _heldFrames; - private bool _forced; - - public TeleportFlowController( - Func readiness, - Action onPlace, - Action onLoginComplete, - Action onLogoutDisconnect, - Action onPlayEnterSound, - Action onPlayExitSound, - int maxHoldFrames = DefaultMaxHoldFrames) - { - _readiness = readiness ?? throw new ArgumentNullException(nameof(readiness)); - _onPlace = onPlace ?? throw new ArgumentNullException(nameof(onPlace)); - _onLoginComplete = onLoginComplete ?? throw new ArgumentNullException(nameof(onLoginComplete)); - _onLogoutDisconnect = onLogoutDisconnect ?? throw new ArgumentNullException(nameof(onLogoutDisconnect)); - _onPlayEnterSound = onPlayEnterSound ?? throw new ArgumentNullException(nameof(onPlayEnterSound)); - _onPlayExitSound = onPlayExitSound ?? throw new ArgumentNullException(nameof(onPlayExitSound)); - _maxHoldFrames = maxHoldFrames; - } - - /// Last snapshot from the sequencer — drives the fade overlay alpha + tunnel/please-wait flags. - public TeleportAnimSnapshot Snapshot { get; private set; } - - public bool IsActive => _seq.IsActive; - - /// Start (or supply coords to) a teleport flow. Idempotent on the sequencer: - /// the first call (OnTeleportStarted, no coords) begins it; a later call with coords - /// (OnLivePositionUpdated) fills the destination WITHOUT restarting the animation. - public void Begin(TeleportEntryKind kind, Vector3 destPos = default, uint destCell = 0) - { - _kind = kind; - if (destCell != 0) - { - _destPos = destPos; - _destCell = destCell; - _haveDest = true; - _heldFrames = 0; - } - if (!_seq.IsActive) - _seq.Begin(kind); - } - - public void Tick(float dt) - { - if (!_seq.IsActive) return; - - bool worldReady; - if (_haveDest) - { - ArrivalReadiness v = _readiness(_destPos, _destCell); - _heldFrames++; - bool forced = v == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames; - worldReady = v == ArrivalReadiness.Ready || forced; - _forced = forced && v != ArrivalReadiness.Ready; - } - else - { - // Logout has no destination world to load; Portal/Death/Login wait for coords. - worldReady = _kind == TeleportEntryKind.Logout; - } - - var (snap, events) = _seq.Tick(dt, worldReady); - Snapshot = snap; - - foreach (var e in events) - { - switch (e) - { - case TeleportAnimEvent.PlayEnterSound: - _onPlayEnterSound(); - break; - case TeleportAnimEvent.PlayExitSound: - _onPlayExitSound(); - break; - case TeleportAnimEvent.Place: - if (_haveDest) _onPlace(_destPos, _destCell, _forced); - break; - case TeleportAnimEvent.FireLoginComplete: - if (_kind == TeleportEntryKind.Logout) _onLogoutDisconnect(); - else _onLoginComplete(); - break; - case TeleportAnimEvent.EnterTunnel: - // no-op — the sequencer drives the tunnel visual state itself. - break; - } - } - } -} -``` - -- [ ] **Step 4: Run to verify it passes** — same `--filter`. Expected: PASS. -- [ ] **Step 5: Commit** — `git add src/AcDream.App/World/TeleportFlowController.cs tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs && git commit -m "feat(teleport): Slice 3 — TeleportFlowController wrapping the TAS sequencer with delegate side-effects"` - ---- - -#### Task 3.2 — `TeleportFadeOverlay` (full-screen alpha-black UiElement) - -**Files:** -- Create `src/AcDream.App/UI/TeleportFadeOverlay.cs` (namespace `AcDream.App.UI`). -- Test: `tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs` (match a UiElement test, e.g. `tests/AcDream.App.Tests/UI/UiMeterTests.cs` style — these tests assert geometry/state without a live GL context). -- Depends on: `UiElement` (`src/AcDream.App/UI/UiElement.cs:32`, virtual `OnDraw(UiRenderContext)` at :168, `Visible` at :70, `Width`/`Height` at :50-51); `UiRenderContext.DrawRect(float,float,float,float,Vector4)` (`src/AcDream.App/UI/UiRenderContext.cs:78`); `UiRenderContext.ScreenSize` (:19); `UiElement.ClickThrough` (:78); `UiElement.ZOrder` (:94). - -The overlay holds a settable `Alpha` (0..1). When `Alpha <= 0` it sets `Visible = false` (so it never participates in hit-test or draw). Because the overlay has no public render-surface seam that's GL-free, the *testable logic* is "Alpha→Visible gating + the draw color it would emit". Extract that into a pure method `FillColor()` so the test asserts it without a `UiRenderContext`. - -- [ ] **Step 1: Write the failing test:** - -```csharp -using System.Numerics; -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class TeleportFadeOverlayTests -{ - [Fact] - public void Alpha_Zero_IsInvisible() - { - var o = new TeleportFadeOverlay { Alpha = 0f }; - Assert.False(o.Visible); - } - - [Fact] - public void Alpha_Positive_IsVisible_AndBlackWithThatAlpha() - { - var o = new TeleportFadeOverlay { Alpha = 0.5f }; - Assert.True(o.Visible); - Assert.Equal(new Vector4(0f, 0f, 0f, 0.5f), o.FillColor()); - } - - [Fact] - public void Alpha_Clamps_To_Unit_Range() - { - var o = new TeleportFadeOverlay { Alpha = 5f }; - Assert.Equal(1f, o.FillColor().W); - o.Alpha = -3f; - Assert.False(o.Visible); - Assert.Equal(0f, o.FillColor().W); - } -} -``` - -- [ ] **Step 2: Run to verify it fails** — `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFadeOverlayTests"`. Expected: FAIL — `TeleportFadeOverlay` does not exist. - -- [ ] **Step 3: Implement:** - -```csharp -using System; -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// Phase B (retail teleport flow): a top-most that fills the -/// whole screen with (0,0,0,Alpha). The drives -/// from Snapshot.FadeAlpha each frame (0 = clear world, 1 = full -/// black). Added once to the (highest ZOrder so it sits over panels). -/// -/// Divergence-register row: black fade via an alpha overlay quad, NOT retail's -/// camera near-plane collapse (Render::set_vdst to 0.001). Visually identical, no -/// projection surgery — see the design doc §3.3. -/// -public sealed class TeleportFadeOverlay : UiElement -{ - private float _alpha; - - /// 0..1 black-fade alpha. Setting it <= 0 hides the overlay entirely so it - /// never draws and never participates in hit-testing. - public float Alpha - { - get => _alpha; - set - { - _alpha = Math.Clamp(value, 0f, 1f); - Visible = _alpha > 0f; - } - } - - public TeleportFadeOverlay() - { - // Decoration: never claim the pointer (the world / panels stay interactive when clear, - // and during a full-black hold the input lock is enforced separately by PortalSpace). - ClickThrough = true; - Visible = false; - ZOrder = int.MaxValue; // over every retail-UI panel - } - - /// The fill color this overlay emits — pure black at the current alpha. - /// Pure (no GL) so it can be asserted in a unit test. - public Vector4 FillColor() => new(0f, 0f, 0f, _alpha); - - protected override void OnDraw(UiRenderContext ctx) - { - if (_alpha <= 0f) return; - // Cover the entire screen regardless of this element's Left/Top (Root sits at origin, - // so local == screen). DrawRect composites after sprites — the fade must cover them. - var size = ctx.ScreenSize; - ctx.DrawRect(0f, 0f, size.X, size.Y, FillColor()); - } -} -``` - -- [ ] **Step 4: Run to verify it passes** — same `--filter`. Expected: PASS. -- [ ] **Step: Add divergence-register row TF-FADE** — the alpha-overlay deviation is introduced here, so its row lands in this commit (CLAUDE.md register rule). In `docs/architecture/retail-divergence-register.md`, matching the existing column order, add (design §5 row 1): - -``` -| TF-FADE | Adaptation | TeleportFadeOverlay fills the screen with a black alpha quad instead of retail's camera near-plane collapse (Render::set_vdst -> 0.001, TAS §2.2). Visually identical. | src/AcDream.App/UI/TeleportFadeOverlay.cs | Edge-pixel depth-precision differences only; tolerable. | -``` - -- [ ] **Step 5: Commit** — `git add src/AcDream.App/UI/TeleportFadeOverlay.cs tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs docs/architecture/retail-divergence-register.md && git commit -m "feat(teleport): Slice 3 — TeleportFadeOverlay full-screen alpha-black fade quad + TF-FADE register row"` - ---- - -#### Task 3.3 — GameWindow: wire the flow controller + overlay for the PORTAL path (integration, build-green + manual) - -**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (god object — no unit test; per the structure rules new LOGIC lives in 3.1/3.2, this task is wiring only). - -Anchors (all line numbers from the current tree): -- `OnTeleportStarted` at `:5579`. -- `OnLivePositionUpdated` portal-space branch / `BeginArrival` call at `:5472-5474`. -- `PlaceTeleportArrival` body at `:5534-5570` (the SPLIT target). -- `EnsureTeleportArrivalController` at `:5501`. -- Per-frame `_teleportArrival?.Tick();` at `:7555`. -- `_uiHost.Draw(...)` at `:8994`. -- `_audioEngine`/`_soundCache`/`_dats` fields at `:250-251`/`:28`. -- `GameActionLoginComplete.Build()` (`src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs`). - -- [ ] **Step 1 (edit): Add fields + a lazy ensure** near `_teleportArrival` (`:5482`): - -```csharp -// Phase B (retail teleport flow): the TAS state machine + its full-screen fade overlay. -// Replaces the bare _teleportArrival hold for the player. Lazily constructed on the -// first teleport (all runtime deps wired by then), same as _teleportArrival. -private AcDream.App.World.TeleportFlowController? _teleportFlow; -private AcDream.App.UI.TeleportFadeOverlay? _teleportFade; - -private void EnsureTeleportFlowController() -{ - if (_teleportFlow is not null) return; - _teleportFlow = new AcDream.App.World.TeleportFlowController( - readiness: TeleportArrivalReadiness, - onPlace: PlaceTeleportArrivalCore, - onLoginComplete: OnTeleportLoginComplete, - onLogoutDisconnect: () => { _liveSessionController?.Dispose(); _liveSessionController = null; }, - onPlayEnterSound: () => PlayPortalSound(PortalSoundKind.Enter), // Slice 4 supplies PlayPortalSound - onPlayExitSound: () => PlayPortalSound(PortalSoundKind.Exit)); - _teleportFade = new AcDream.App.UI.TeleportFadeOverlay(); - _uiHost?.Root.AddChild(_teleportFade); -} -``` - -(Slice 4 adds `PlayPortalSound` + `PortalSoundKind`. For Slice 3 to build green on its own, temporarily pass `onPlayEnterSound: () => { }` / `onPlayExitSound: () => { }` and replace them in Task 4.2 — note this in the commit message.) - -- [ ] **Step 2 (edit): SPLIT `PlaceTeleportArrival`** (`:5534-5570`). Rename the existing body to `PlaceTeleportArrivalCore` and REMOVE its last two statements (`_playerController.State = InWorld;` at `:5563` and the `_liveSession?.SendGameAction(...LoginComplete...)` at `:5568-5569`). Add the new login-complete delegate that owns exactly those two lines: - -```csharp -// The placement half (Resolve + SetPosition + camera). Runs on the sequencer's Place -// event. Does NOT flip PortalSpace->InWorld and does NOT send LoginComplete — those move -// to OnTeleportLoginComplete (fired at sequencer OFF) so the input lock persists the -// WHOLE animation, not just until placement. -private void PlaceTeleportArrivalCore( - System.Numerics.Vector3 destPos, uint destCell, bool forced) -{ - // ... existing body verbatim through the camera Update calls (:5537-5561) ... - if (forced) - Console.WriteLine($"live: teleport HOLD gave up (impossible/timeout) — force-snapping ..."); - // (entity SetPosition + ParentCellId, _playerController.SetPosition, chase-camera Update) - Console.WriteLine($"live: teleport placed — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); - // NOTE: no State=InWorld, no LoginComplete here anymore. -} - -// Fired at sequencer OFF (TeleportAnimEvent.FireLoginComplete) for every non-logout kind. -// Flips the input lock back on AND tells the server the client finished loading. -private void OnTeleportLoginComplete() -{ - if (_playerController is not null) - _playerController.State = AcDream.App.Input.PlayerState.InWorld; - _liveSession?.SendGameAction( - AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); -} -``` - -- [ ] **Step 3 (edit): Portal trigger.** In `OnTeleportStarted` (`:5583`), after `EnsureTeleportArrivalController();` add: - -```csharp -EnsureTeleportFlowController(); -_teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Portal); // no coords yet -``` - -In `OnLivePositionUpdated` portal-space branch, REPLACE the `_teleportArrival!.BeginArrival(newWorldPos, p.LandblockId);` at `:5474` with (keeping the recenter / pre-collapse above it untouched): - -```csharp -EnsureTeleportFlowController(); -_pendingTeleportRot = rot; -_teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Portal, newWorldPos, p.LandblockId); -``` - -- [ ] **Step 4 (edit): per-frame tick + overlay drive.** REPLACE `_teleportArrival?.Tick();` at `:7555` with: - -```csharp -// Phase B: advance the teleport flow (TAS animation + arrival hold). Runs AFTER streaming -// + the live-session drain, same ordering invariant the old _teleportArrival.Tick had. -_teleportFlow?.Tick(dt); -if (_teleportFade is not null && _teleportFlow is not null) - _teleportFade.Alpha = _teleportFlow.Snapshot.FadeAlpha; -``` - -(`dt` is in scope at this call site — confirm by reading the enclosing method; if the local is named differently, use that name. Add a "read GameWindow.cs:7551 enclosing-method dt local to confirm name" check.) The fade overlay draws inside the existing `_uiHost.Draw(...)` pass at `:8994` because it was added as a `UiRoot` child in `EnsureTeleportFlowController` — no new draw site. - -- [ ] **Step: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. -- [ ] **Step: MANUAL/VISUAL verification.** Launch against ACE (CLAUDE.md launch block), walk the player into a portal. Expect: screen fades to black, holds while the destination streams, fades back in; the camera no longer "floats" to the destination through an unstreamed world; input stays frozen until the fade-in completes (try pressing W during the black hold — no movement until the world is back). The tunnel states currently render as a black hold (the swirl is Slice 6). -- [ ] **Step 5: Commit** — `git add -A && git commit -m "feat(teleport): Slice 3 — route portal teleports through TeleportFlowController + fade overlay; split placement from login-complete"` - -> Note: `_teleportArrival` becomes dead once the portal path routes through `_teleportFlow`. Leave it in place for Slice 3 (login/death/logout still need the unified path in Slice 5); Slice 5's de-dup commit removes the now-unused `_teleportArrival` field + `EnsureTeleportArrivalController` + `BeginArrival` plumbing. - ---- - -### Slice 4: Yaw-freeze in PortalSpace + portal sounds - -#### Task 4.1 — Yaw freeze during PortalSpace (build-green + manual; NO fabricated-signature unit test) - -**Files:** `src/AcDream.App/Rendering/GameWindow.cs` — the chase-camera mouse-move block. - -Anchor: the single `if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)` block at `:1095`, which contains BOTH the MMB mouse-look sub-branch (`:1098`) and the RMB orbit sub-branch (`:1120`). One early-return at the TOP of that block covers both (per the prompt — do NOT add it in slice 3 or 5). - -- [ ] **Step 1 (edit):** Insert as the first statement inside the `:1095` block, before `float sens = _sensChase;` (`:1097`): - -```csharp -// Phase B (retail teleport flow): freeze look/orbit yaw while the teleport animation -// holds the player in PortalSpace. The keyboard-turn freeze already happens (the -// PlayerMovementController.Update early-return precedes the yaw block), but the Silk -// mouse-move handler bypasses Update — so MMB mouse-look (below) and RMB orbit both -// leak yaw without this gate. Covers BOTH sub-branches since they share this block. -if (_playerController is not null - && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace) -{ - _lastMouseX = pos.X; - _lastMouseY = pos.Y; - return; -} -``` - -- [ ] **Step: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. -- [ ] **Step: MANUAL/VISUAL verification.** During a portal (while the fade holds), hold MMB and move the mouse, then hold RMB and move the mouse. Expect: neither rotates the character/camera while in PortalSpace; both resume normally after the fade-in completes (State back to InWorld). (Mouse input can't be unit-tested through Silk — this is build-green + manual per the prompt; do NOT fabricate a `PlayerMovementController.Update` signature test.) -- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "fix(teleport): Slice 4 — freeze MMB look + RMB orbit yaw while held in PortalSpace"` - ---- - -#### Task 4.2 — Portal Enter/Exit sounds (clean EnumIDMap chain, no stopgap) - -**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (`PlayPortalSound` + `PortalSoundKind`). - -> **VERIFIED CORRECTION — enum member names.** The design doc / handoff says `DRWSound.UI_EnterPortal=0x6A` / `UI_ExitPortal=0x6B`. That is **wrong** for our dat library. The real members are **`DRWSound.EnterPortal` and `DRWSound.ExitPortal`** (no `UI_` prefix). Therefore: -> - Do **NOT** add `UI_EnterPortal=0x6A`/`UI_ExitPortal=0x6B` to the Core `SoundId` enum (`src/AcDream.Core/Audio/AudioModel.cs:16-42`). The portal-sound chain uses `DatReaderWriter.Enums.Sound` (aliased `DRWSound`), NOT the Core `SoundId` — exactly as `SoundCookbook.Roll(SoundTable, DRWSound, Random)` is typed (`src/AcDream.Core/Audio/SoundCookbook.cs:74-82`, alias at :4). The Core `SoundId` enum needs **no change**. -> - **Add a confirm step** below to pin the exact member spelling before use. - -Pinned chain elements (all read from source): -- `_dats.Portal.Header.MasterMapId` + `_dats.Portal.TryGet(...)` — the EnumIDMap pattern (`src/AcDream.App/UI/IconComposer.cs:86-95` for the master→sub two-level lookup). -- `_soundCache.GetSoundTable(uint)` (`src/AcDream.Core/Audio/DatSoundCache.cs:59-66`) → `SoundTable?`. -- `SoundCookbook.Roll(SoundTable, DRWSound, Random)` → `SoundEntry?` (:74); winning `entry.Id` is the Wave id (used exactly so at `src/AcDream.App/Audio/AudioHookSink.cs:113`). -- `_soundCache.GetWave(uint)` → `WaveData?` (`DatSoundCache.cs:39`). -- `_audioEngine.PlayUiWave(uint waveId, WaveData wave, float volume=1, float pitch=1)` (`src/AcDream.App/Audio/OpenAlAudioEngine.cs:278`). -- Fields `_dats` (`:28`), `_audioEngine` (`:250`), `_soundCache` (`:251`). - -The UI SoundTable id resolves via `MasterMapId → ClientEnumToID[0x10000003] → ClientEnumToID[7]` per the design's §8 chain. This chain is **medium-confidence** (not yet runtime-confirmed), so the implementation must fail gracefully (no sound + one warning log) if any link misses — that is the design's required fallback, not a workaround. - -- [ ] **Step 0 (confirm the enum spelling — required before writing code):** read the member names by greping the dat lib so the case `DRWSound.EnterPortal`/`DRWSound.ExitPortal` compiles. The build error pins exact casing in YOUR build; if casing differs, use the build error to correct it — do not guess a third spelling. - -- [ ] **Step 1 (no separate unit test):** This is GameWindow integration (god object) and the resolution depends on live dats — it's build-green + manual, like 4.1. (The reusable picker logic — `SoundCookbook.Roll` — is already unit-covered.) Skip Steps 1-2 of the TDD template; go straight to implement + build + manual-verify. - -- [ ] **Step 3: Implement** in GameWindow: - -```csharp -private enum PortalSoundKind { Enter, Exit } - -// Phase B (retail teleport flow): play the EnterPortal / ExitPortal UI sound. -// Resolves the UI SoundTable via the master EnumIDMap chain (design §8): -// Portal.Header.MasterMapId -> EnumIDMap.ClientEnumToID[0x10000003] -> [7] -> SoundTable id -// then rolls the EnterPortal/ExitPortal entry and plays its Wave on the UI pool. -// Medium-confidence chain: if any link misses, log once + play nothing (the design's -// required graceful fallback, not a workaround — the visual flow proceeds regardless). -private uint _uiSoundTableId; // 0 = unresolved/unknown; resolved lazily on first portal sound -private bool _uiSoundTableTried; - -private void PlayPortalSound(PortalSoundKind kind) -{ - if (_audioEngine is null || !_audioEngine.IsAvailable || _soundCache is null || _dats is null) - return; - - uint tableId = ResolveUiSoundTableId(); - if (tableId == 0) return; - - var table = _soundCache.GetSoundTable(tableId); - if (table is null) return; - - var sound = kind == PortalSoundKind.Enter - ? DatReaderWriter.Enums.Sound.EnterPortal - : DatReaderWriter.Enums.Sound.ExitPortal; - - var entry = AcDream.Core.Audio.SoundCookbook.Roll(table, sound, Random.Shared); - if (entry is null) return; - - var wave = _soundCache.GetWave((uint)entry.Id); - if (wave is null) return; - - _audioEngine.PlayUiWave((uint)entry.Id, wave, - volume: System.Math.Clamp(entry.Volume > 0 ? entry.Volume : 1f, 0f, 1f)); -} - -private uint ResolveUiSoundTableId() -{ - if (_uiSoundTableTried) return _uiSoundTableId; - _uiSoundTableTried = true; - if (_dats is null) return 0; - - uint masterDid = (uint)_dats.Portal.Header.MasterMapId; - if (masterDid == 0 - || !_dats.Portal.TryGet(masterDid, out var master) - || !master.ClientEnumToID.TryGetValue(0x10000003u, out var subDid) - || !_dats.Portal.TryGet(subDid, out var sub) - || !sub.ClientEnumToID.TryGetValue(7u, out var tableId)) - { - Console.WriteLine("[teleport] UI SoundTable EnumIDMap chain did not resolve — portal sounds disabled (no-op)."); - return 0; - } - _uiSoundTableId = tableId; - return tableId; -} -``` - -Then in `EnsureTeleportFlowController` (Task 3.3), replace the temporary empty sound delegates with `() => PlayPortalSound(PortalSoundKind.Enter)` / `() => PlayPortalSound(PortalSoundKind.Exit)`. - -- [ ] **Step: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. (If `DatReaderWriter.Enums.Sound.EnterPortal` / `.ExitPortal` fails to resolve, the build error pins the real casing — fix it from the error, do not guess.) -- [ ] **Step: MANUAL/VISUAL (audio) verification.** Portal in: expect the retail EnterPortal whoosh at the start of the fade; expect the ExitPortal sound at the tunnel→world fade-in edge. If the log prints "UI SoundTable EnumIDMap chain did not resolve", the §8 index guess (`0x10000003`→`[7]`) is wrong — capture the real chain via a cdb trace / dat dump and correct the two constants (do NOT add a hardcoded table id workaround). -- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 4 — EnterPortal/ExitPortal UI sounds via master EnumIDMap chain, graceful no-sound fallback"` - -> Divergence-register: no new row for sounds (faithful port). If the §8 chain stays unresolved and you ship with sounds disabled, add a stopgap row noting "portal sounds pending EnumIDMap-chain runtime confirmation" and delete it when resolved. - ---- - -### Slice 5: Unify login + death + logout (de-dup readiness; logout command) - -#### Task 5.1 — De-dup the login readiness predicate onto `TeleportArrivalRules.Decide` - -**Files:** -- `src/AcDream.App/Rendering/GameWindow.cs` — the `isSpawnGroundReady` login predicate (`:1036-1065`). -- Test: extend `tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs` (the slice-2 `Decide(claimUnhydratable, indoor, indoorCellReady, outdoorReady)` 4-arg form). - -The login predicate (`:1036-1064`) and `TeleportArrivalReadiness` (`:5513-5530`) were verbatim duplicates of the readiness logic. Slice 2 already rewrote `TeleportArrivalReadiness` to call `TeleportArrivalRules.Decide(..., outdoorReady)` with `outdoorReady` from `IsLandblockLoaded`. This task collapses the LOGIN predicate to call the SAME `Decide`, computing its `outdoorReady` arg from `SampleTerrainZ(...) != null` (login's existing outdoor gate). Do NOT create a separate `ComputeArrivalReadiness` method (the prompt forbids it) — both sites call `Decide` directly. - -- [ ] **Step 1: Write the failing test** — add the outdoor-axis case proving `Decide` honors `outdoorReady` (this guards the shared method the login predicate will now depend on): - -```csharp -[Fact] -public void Outdoor_NotLoaded_HoldsNotReady() -{ - // Slice 2 axis: outdoor now HOLDS until the landblock is loaded (folds in #145 residual). - Assert.Equal(ArrivalReadiness.NotReady, - TeleportArrivalRules.Decide( - claimUnhydratable: false, indoor: false, indoorCellReady: false, outdoorReady: false)); -} - -[Fact] -public void Outdoor_Loaded_IsReady() -{ - Assert.Equal(ArrivalReadiness.Ready, - TeleportArrivalRules.Decide( - claimUnhydratable: false, indoor: false, indoorCellReady: false, outdoorReady: true)); -} -``` - -(If slice 2 already added these exact cases, instead add a login-shaped case asserting the indoor branch ignores `outdoorReady`: `Decide(false, true, true, false) == Ready`. Read the current `TeleportArrivalRulesTests.cs` first and only add what's missing — do not duplicate.) - -- [ ] **Step 2: Run to verify it fails** — `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"`. Expected: FAIL if the 4-arg `Decide` cases aren't present yet (compile/assert), or PASS-already if slice 2 covered them (then this task is the GameWindow rewrite only — note it). - -- [ ] **Step 3: Implement** — rewrite the login predicate body (`:1036-1064`) to delegate to `Decide`: - -```csharp -isSpawnGroundReady: () => -{ - if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false; - - // #135/#145 (de-dup §3.5): the login spawn-readiness verdict is the SAME decision as a - // teleport arrival — route both through TeleportArrivalRules.Decide. Indoor gates on the - // EnvCell floor; outdoor gates on the terrain under the spawn having streamed in - // (SampleTerrainZ != null — login's existing outdoor gate, the async equivalent of - // retail's synchronous cell load). - uint claim = (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp) - && sp.Position is { } spawnClaim && spawnClaim.LandblockId != 0) - ? spawnClaim.LandblockId : 0u; - - bool claimUnhydratable = claim != 0 && IsSpawnClaimUnhydratable(claim); - bool indoor = claim != 0 && (claim & 0xFFFFu) >= 0x0100u; - bool indoorCellReady = indoor && !claimUnhydratable && _physicsEngine.IsSpawnCellReady(claim); - bool outdoorReady = _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null; - - return AcDream.App.World.TeleportArrivalRules.Decide( - claimUnhydratable, indoor, indoorCellReady, outdoorReady) == AcDream.App.World.ArrivalReadiness.Ready; -}, -``` - -(Read `:1036-1064` first to preserve the exact field names — `_lastSpawnByGuid`, `sp.Position`, `IsSpawnClaimUnhydratable`, `IsSpawnCellReady`, `SampleTerrainZ` are all confirmed in the current body. The original returned `bool`; `Decide(...) == Ready` preserves that — login holds on `NotReady`/`Impossible`, places on `Ready`. **Preserve the old un-hydratable-indoor truth table:** if the old code demoted an un-hydratable indoor claim to the outdoor terrain gate (placed on `SampleTerrainZ != null`) rather than holding, treat `claimUnhydratable && indoor` as "use outdoorReady" — read the old branch and reproduce its exact behavior; do not silently change login placement timing.) - -- [ ] **Step 4: Run to verify it passes** — Rules `--filter` PASS; then `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` green. -- [ ] **Step: MANUAL/VISUAL verification.** Log in fresh (outdoor spawn at Holtburg) and into a dungeon: both must reach in-world FPS-steady with no free-fall (the #106/#135 invariant the predicate guards). Regression-gate: login stays FPS-steady (the design's explicit login regression gate). -- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs && git commit -m "refactor(teleport): Slice 5 — de-dup login spawn-readiness onto TeleportArrivalRules.Decide (§3.5)"` - ---- - -#### Task 5.2 — Route LOGIN + DEATH through the flow controller - -**Files:** `src/AcDream.App/Rendering/GameWindow.cs` — `EnterPlayerModeFromAutoEntry` success branch (`:2610-2624`); `OnTeleportStarted` (`:5579`, death confirmation comment). - -Anchors: -- `EnterPlayerModeFromAutoEntry` at `:2610`; its success branch logs "auto-entered player mode" at `:2622`. -- `EnterPlayerModeNow` returns true after setting up `_playerController` with `Position` (`PlayerMovementController.Position` => `_body.Position`, `src/AcDream.App/Input/PlayerMovementController.cs:131`) and `CellId` (`:133`). -- Death: server `0xF751` teleport-to-lifestone already routes through `OnTeleportStarted` → `Begin(Portal)` (Task 3.3). NO new death code. - -- [ ] **Step 1 (LOGIN edit):** In `EnterPlayerModeFromAutoEntry` success branch (`:2620-2623`), after the `Console.WriteLine($"live: auto-entered player mode ...")`: - -```csharp -// Phase B (retail teleport flow) §4: route LOGIN through the same TAS flow. The world is -// already loaded (auto-entry only fires once isSpawnGroundReady passed), so the readiness -// probe returns Ready immediately -> the tunnel exits fast -> continue + fade-in plays. -// onPlace re-resolves the player position harmlessly (idempotent). This gives login the -// retail fade-in instead of a hard pop-in. -EnsureTeleportFlowController(); -_teleportFlow!.Begin( - AcDream.Core.World.TeleportEntryKind.Login, - _playerController!.Position, - _playerController.CellId); -``` - -- [ ] **Step 2 (DEATH comment):** Above `OnTeleportStarted` (`:5579`), add a confirming comment (no code): - -```csharp -// Phase B §4: DEATH needs NO dedicated code. ACE teleports the corpse owner to the -// lifestone after the death anim (Player_Death.cs:247 / Player_Location.cs:686); the client -// observes it as an ordinary 0xF751 teleport, which already enters here -> Begin(Portal) -> -// TUNNEL. The large-position-jump branch of OnLivePositionUpdated is a second safety net. -``` - -- [ ] **Step: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. -- [ ] **Step: MANUAL/VISUAL verification.** (1) Login: expect a short black hold → fade-in (NOT an instant pop) and FPS-steady. (2) Death (let the character die against a creature, or use a GM kill): expect death anim → fade → respawn at the lifestone with the same fade-in. Confirm LoginComplete is sent after login fade-in (the server accepts subsequent movement). **If death shows NO fade** (the teleport arrived without a preceding `0xF751`, so `OnTeleportStarted` never fired): apply the open-question #1 contingency — add a large-position-jump → `PortalSpace` + `Begin(Death)` trigger in `OnLivePositionUpdated`. File it as a follow-up task; do not block the slice. -- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 5 — route login + death through TeleportFlowController (Login enters at TUNNEL, exits fast)"` - ---- - -#### Task 5.3 — LOGOUT command (animation + 0xF653 + clean disconnect) - -**Files:** -- `src/AcDream.App/Rendering/GameWindow.cs` — `OnInputAction` switch (`:11442` method; add a `case` near `:11646`). -- Test: extend `tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs` (the `Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete` case in Task 3.1 already covers the controller side — this task is the input wiring + the disconnect delegate, which is integration). - -Confirmed: -- `InputAction.LOGOUT` exists (`src/AcDream.UI.Abstractions/Input/InputAction.cs:100`) with a retail default keybind **Shift+Escape** (`src/AcDream.UI.Abstractions/Input/KeyBindings.cs:212`) — so NO new action and NO new keybind needed. -- `OnInputAction` filters to Press/DoubleClick before the switch (`:11501`), so a `case` fires on Press. -- Logout teardown = `_liveSessionController?.Dispose()` → `LiveSessionController.Dispose()` (`src/AcDream.App/Net/LiveSessionController.cs:87`) → `WorldSession.Dispose()` (`src/AcDream.Core.Net/WorldSession.cs:1319`) which sends `0xF653` CharacterLogOff (`:1328`) then DISCONNECT. The flow controller's `onLogoutDisconnect` delegate (wired in Task 3.3) already calls exactly this. - -- [ ] **Step 1 (no new unit test):** the controller's Logout behavior is unit-covered by `Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete` (Task 3.1). The input-case wiring is GameWindow integration (build-green + manual). Confirm the existing 3.1 test passes: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFlowControllerTests.Logout"`. Expected: PASS (already implemented in Slice 3). - -- [ ] **Step 2 (edit):** Add a `case` to the `OnInputAction` switch, before the closing brace at `:11647` (after the `EscapeKey` case at `:11632-11646`): - -```csharp -case AcDream.UI.Abstractions.Input.InputAction.LOGOUT: - // Phase B §4: retail logout = the TAS animation starting at WORLD_FADE_OUT, then 0xF653 - // CharacterLogOff + clean disconnect (via WorldSession.Dispose, fired by the controller's - // onLogoutDisconnect delegate at sequencer OFF). Char-select-return UI is deferred (§9 / - // register row 4). Idempotent: ignore if a flow is already running or we're offline. - if (_liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld - && (_teleportFlow is null || !_teleportFlow.IsActive)) - { - if (_playerController is not null) - _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; // lock input for the whole fade - EnsureTeleportFlowController(); - _teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Logout); - } - break; -``` - -- [ ] **Step: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. -- [ ] **Step: MANUAL/VISUAL verification.** In-world, press Shift+Esc. Expect: world fades to black (WORLD_FADE_OUT → TUNNEL_FADE_IN front segments — Logout's ~6s budget) → at sequencer OFF the client sends `0xF653` and disconnects cleanly (the window closes / session ends; verify via WireMCP capture on loopback that a `0xF653` CharacterLogOff frame goes out before the DISCONNECT, and that ACE clears the session promptly — graceful close, not a hard kill). No char-select screen (deferred, §9). -- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 5 — LOGOUT (Shift+Esc) plays the TAS fade then sends 0xF653 + disconnects"` - ---- - -#### Task 5.4 — Remove the now-dead `TeleportArrivalController` plumbing + add divergence-register row 4 - -**Files:** -- `src/AcDream.App/Rendering/GameWindow.cs` — remove the unused `_teleportArrival` field, `EnsureTeleportArrivalController`, and the `BeginArrival` call path (all superseded by `_teleportFlow` after slices 3/5). -- `docs/architecture/retail-divergence-register.md` — add row 4 (logout → disconnect, not char-select). - -- [ ] **Step 1: Confirm `_teleportArrival` is unreferenced except its own decls** — `rg -n "_teleportArrival|EnsureTeleportArrivalController" src/AcDream.App/Rendering/GameWindow.cs`. Expected: only the field declaration, the (now-shimmed) ensure, and no live `.Tick()` / `.BeginArrival()` callers (slices 3/5 replaced them with `_teleportFlow`). If a live caller remains, route it through `_teleportFlow` first. - -- [ ] **Step 2: Remove the dead plumbing** — delete the `_teleportArrival` field decl, the `EnsureTeleportArrivalController` method, and any `EnsureTeleportArrivalController();` calls left in `OnTeleportStarted` (the `EnsureTeleportFlowController()` call from Task 3.3 supersedes it). Keep `TeleportArrivalController.cs` (the class + `TeleportArrivalRules`) — `TeleportArrivalRules.Decide` is still used by `TeleportArrivalReadiness`; only the unused instance plumbing in GameWindow goes. - -- [ ] **Step 3: dotnet build green** — `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Then full suite: `dotnet test AcDream.slnx -c Debug`. - -- [ ] **Step 4: Add divergence-register row 4** — in `docs/architecture/retail-divergence-register.md`, matching the existing column order: - -``` -| Logout returns to disconnect/window-close, not a char-select screen | Stopgap | Retail shows char-select after logout; acdream closes the session. Retire when the char-select / re-login UI is built (design §9). | src/AcDream.App/World/TeleportFlowController.cs (onLogoutDisconnect) | No char-select after logout; user must relaunch to reconnect. | -``` - -- [ ] **Step 5: Commit** — `git add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md && git commit -m "refactor(teleport): Slice 5 — remove dead _teleportArrival plumbing; divergence row 4 (logout→disconnect)"` - ---- - -**Cross-slice consistency notes (the parallel-draft fixes):** -1. **One sound-enum decision, stated once:** `DRWSound.EnterPortal`/`ExitPortal` (NOT `UI_*=0x6A/0x6B`); Core `SoundId` enum untouched. Owned by Task 4.2; referenced nowhere else. -2. **One yaw-freeze, in Slice 4 only** (Task 4.1) — a single early-return at `GameWindow.cs:1095` covering both MMB + RMB sub-branches; not duplicated in 3 or 5. -3. **PlaceTeleportArrival split happens once** (Task 3.3): `PlaceTeleportArrivalCore` (placement only) + `OnTeleportLoginComplete` (State=InWorld + LoginComplete). Slices 4/5 consume these, never re-split. -4. **`_teleportArrival` removal is the explicit Task 5.4** once login no longer uses the old `isSpawnGroundReady`→`BeginArrival` path — avoids a half-removed field across slices. -5. **`Begin` idempotency** (sequencer not restarted by the second coords-carrying `Begin`) is the contract that makes Portal's two-call trigger (OnTeleportStarted no-coords + OnLivePositionUpdated coords) safe — unit-pinned in Task 3.1. - ---- - -## Slice 6 (FOLLOW-UP PLAN — not in this plan): the literal 3D portal swirl - -The retail tunnel is a 3D sub-viewport (`UIElement_Viewport` Type 0xD) rendering a portal `CPhysicsObj` (dat `GetDIDByEnum(0x10000001,7)` setup + `(0x10000002,7)` animation, 40 fps, randomized camera spin) shown during the `Tunnel*` states. acdream has **no** offscreen-3D / sub-viewport infrastructure, and the asset ids only resolve via a runtime trace. This slice is therefore its **own** spec→plan, executed in the same pass **after** its prerequisite: - -- **Prerequisite (cdb trace):** attach cdb to a live retail client (see CLAUDE.md "Retail debugger toolchain"), `bp acclient!CPhysicsObj::makeObject` during a portal, and capture the resolved `GetDIDByEnum(0x10000001,7)` / `(0x10000002,7)` dat ids. Also capture `UIGlobals::GetAnimLevel`'s 1024-entry table if the fade ease needs to be exact (else smoothstep stands — register row TF-EASE). -- **Then:** build a `PortalTunnelView` (offscreen 3D render of the swirl `CPhysicsObj` at 40 fps with the randomized camera rotation) and blit it during `Snapshot.ShowTunnel`; the sequencer/overlay seams already exist (`ShowTunnel`/`ShowPleaseWait` flags are wired in slices 1/3). Until then the tunnel states render as the black fade-hold — a coherent, retail-shaped flow. - -This staging is the design's §7 slice 6 + §8 prerequisites. It does **not** block slices 1–5, which ship the complete behavioral feature (float gone, input locked, hold-until-loaded, sounds, the four entry points, the #145 fix). - -## Open questions / risks (resolve during execution; none block plan start) - -1. **Death trigger (verify in Slice 5 Task 5.2).** Death is assumed to arrive as a server `0xF751` teleport → `OnTeleportStarted` → `Begin(Portal)`. The research could not confirm from acdream code alone that ACE always sends `0xF751` before the lifestone `UpdatePosition`. **Gate:** the Task 5.2 manual death test. **Contingency if it fails (no fade on death):** add a large-position-jump detector in `OnLivePositionUpdated` that sets `PortalSpace` + calls `_teleportFlow.Begin(TeleportEntryKind.Death, …)` when the player jumps landblocks with no preceding `0xF751`. File it as a follow-up task only if the manual test shows death bypassing the flow. -2. **Portal sound enum casing + the EnumIDMap chain (Slice 4 Task 4.2).** The member is `DatReaderWriter.Enums.Sound.EnterPortal`/`ExitPortal` (verified against the dat lib; the design doc's `UI_EnterPortal=0x6A` is wrong). If the build still can't resolve it, the compile error pins the exact casing — fix from the error, don't guess. The UI-SoundTable EnumIDMap index chain (`0x10000003`→`[7]`) is medium-confidence; the implementation logs once and plays no sound if it misses (graceful, per design) — capture the real chain via cdb/dat-dump and correct the two constants rather than hardcoding a table id. -3. **Login `Impossible` truth-table (Slice 5 Task 5.1).** Preserve the old login predicate's exact behavior for an un-hydratable indoor claim (it demoted to the outdoor terrain gate). Read `GameWindow.cs:1036-1064` before rewriting and confirm `Decide(...) == Ready` reproduces it; if the old code placed (not held) on an un-hydratable indoor claim, keep that. **Regression gate:** login stays FPS-steady (the #106/#135 invariant). -4. **Apparatus gate for the #145 fix (Slice 2 Task 2.5).** Before relying on the readiness-gate change, run the `ACDREAM_PROBE_RESOLVE` apparatus on a `0xC98C`-style edge arrival and confirm the gate flips `Ready` *before* placement with no `NO-LANDBLOCK` march. This is the "no guess-patch" gate (the original #145 burned 5 attempts). Tracked as task #4 in the session. -5. **`DatReaderWriter` source-of-truth.** Confirm whether acdream references the vendored `references/DatReaderWriter` or the `chorizite.datreaderwriter` NuGet DLL — the two disagreed on the Sound enum member names during research. The build resolves this at Slice 4. - -## Phase completion checklist (per CLAUDE.md) - -- [ ] Every AC-specific algorithm cites its decomp reference (the sequencer constants cite `acclient.h:6871` + the VAs from the design §2.1). -- [ ] Every deviation this work introduces has a `retail-divergence-register.md` row (TF-FADE alpha overlay, TF-EASE smoothstep, logout→disconnect; tasks 1.7 / 3.x / 5.3 add them). -- [ ] Conformance tests exist for the critical paths (sequencer golden timings; `Decide` truth table; controller event dispatch). -- [ ] `dotnet build` green, `dotnet test` green. -- [ ] Visual verification by the user (the mandatory stop): portal feel, login fade-in, death→lifestone, logout fade. -- [ ] Roadmap / milestones updated when the feature lands; ISSUES #145 residual closed if the apparatus re-test passes. - ---- - -## Execution - -**Plan complete.** Two execution options: - -1. **Subagent-Driven (recommended)** — dispatch a fresh subagent per task, review between tasks, fast iteration (REQUIRED SUB-SKILL: `superpowers:subagent-driven-development`). Best for a plan this size (it isolates the god-object wiring tasks from the pure-logic tasks). -2. **Inline Execution** — execute tasks in this session with batched checkpoints (REQUIRED SUB-SKILL: `superpowers:executing-plans`). - -**Recommended order:** Slice 1 → Slice 2 (lands the #145 fix; run the apparatus gate) → Slice 3 → Slice 4 → Slice 5. Each slice ends build+test green; slices 3/5 end with a visual stop. diff --git a/docs/superpowers/plans/2026-06-22-d2b-container-switching.md b/docs/superpowers/plans/2026-06-22-d2b-container-switching.md deleted file mode 100644 index a959700c..00000000 --- a/docs/superpowers/plans/2026-06-22-d2b-container-switching.md +++ /dev/null @@ -1,819 +0,0 @@ -# D.2b inventory container-switching — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Click a side bag in the inventory pack-selector → the grid shows that bag's contents (opened via a `Use 0x0036` → `ViewContents 0x0196` full-replace round-trip), the open bag shows the retail triangle, the selected cell shows the retail green/yellow square; click the main-pack cell → back. - -**Architecture:** Add an authoritative full-replace path to the Core object table (`ReplaceContents`), reroute the `ViewContents` wiring through it, add a thin `WorldSession.SendUse` wire wrapper, give `UiItemSlot` two procedural indicator overlays (modeled on the existing DragAccept overlay), and give `InventoryController` `_openContainer`/`_selectedItem` state with per-cell click roles (grid→select, container→open+select). `GameWindow` injects the two send callbacks. - -**Tech Stack:** C# .NET 10, xUnit. Layers: `AcDream.Core` (object table), `AcDream.Core.Net` (wire + wiring), `AcDream.App` (UI widgets + controller + window). - -**Spec:** `docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md`. Decomp anchors: `UpdateOpenContainerIndicator 0x004e3070`/`SetOpenContainerState 0x004e1200` (triangle `0x06005D9C`); `ItemList_SetSelectedItem 0x004e2fe0`/`SetSelectedState 0x004e1240` (square `0x06004D21`). Wire: ACE `GameEventViewContents.cs` (entries `OrderBy(PlacementPosition)`, no slot field → `ContainerSlot = index`). - -**Build/test commands** (run from repo root `C:\Users\erikn\source\repos\acdream\.claude\worktrees\hopeful-maxwell-214a12`): -- One project's tests: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` -- Full suite at the phase boundary: `dotnet test` - ---- - -## Task 1: `ClientObjectTable.ReplaceContents` (full-replace membership) - -**Files:** -- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` (add a method after `GetContents`, ~line 316) -- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` (add after `GetContents_UnknownContainer_Empty`) - -- [ ] **Step 1: Write the failing tests** - -Add to `ClientObjectTableTests.cs`: - -```csharp -[Fact] -public void ReplaceContents_RecordsAllInListOrder() -{ - var table = new ClientObjectTable(); - table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); - Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u)); - Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId); -} - -[Fact] -public void ReplaceContents_SecondSmallerList_DetachesDropped() // the full-REPLACE semantics (vs the old additive merge) -{ - var table = new ClientObjectTable(); - table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); - table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side - Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u)); - Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); // detached, not lingering - Assert.Equal(0u, table.Get(0xA03u)!.ContainerId); - Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId); -} - -[Fact] -public void ReplaceContents_ReordersToNewListOrder() -{ - var table = new ClientObjectTable(); - table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u }); - table.ReplaceContents(0xC9u, new uint[] { 0xA02u, 0xA01u }); - Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u)); -} - -[Fact] -public void ReplaceContents_ZeroContainer_NoOp() -{ - var table = new ClientObjectTable(); - table.ReplaceContents(0u, new uint[] { 0xA01u }); - Assert.Empty(table.GetContents(0u)); - Assert.Null(table.Get(0xA01u)); -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ReplaceContents"` -Expected: FAIL — `'ClientObjectTable' does not contain a definition for 'ReplaceContents'` (compile error). - -- [ ] **Step 3: Implement `ReplaceContents`** - -In `ClientObjectTable.cs`, immediately after the `GetContents` method (~line 316), add: - -```csharp - /// - /// Replace a container's entire membership with (in order) — the - /// authoritative full snapshot the server sends in ViewContents (0x0196) when you open a - /// container. Members no longer present are detached (ContainerId → 0); new members are - /// recorded with ContainerSlot = list index. ACE writes ViewContents entries - /// OrderBy(PlacementPosition) with NO explicit slot field (GameEventViewContents.cs), so the - /// list order IS the slot order. Fires ObjectAdded/ObjectMoved so bound UI repaints. - /// Retail consumer: ClientUISystem::OnViewContents. - /// - public void ReplaceContents(uint containerId, IReadOnlyList guids) - { - ArgumentNullException.ThrowIfNull(guids); - if (containerId == 0) return; - - var keep = new HashSet(guids); - - // Detach prior members no longer present (they left the container server-side). GetContents - // returns a snapshot, so mutating the index inside the loop is safe. - foreach (var old in GetContents(containerId)) - { - if (keep.Contains(old)) continue; - if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue; - o.ContainerId = 0; - Reindex(o, containerId); - ObjectMoved?.Invoke(o, containerId, 0); - } - - // Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition. - for (int i = 0; i < guids.Count; i++) - { - uint g = guids[i]; - bool existed = _objects.TryGetValue(g, out var obj); - if (!existed || obj is null) - { - obj = new ClientObject { ObjectId = g }; - _objects[g] = obj; - } - uint oldContainer = obj.ContainerId; - obj.ContainerId = containerId; - obj.ContainerSlot = i; - Reindex(obj, oldContainer); - if (!existed) ObjectAdded?.Invoke(obj); - else ObjectMoved?.Invoke(obj, oldContainer, containerId); - } - } -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ReplaceContents"` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs -git commit -m "feat(D.2b): ClientObjectTable.ReplaceContents — authoritative full-replace for ViewContents - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: route `ViewContents` wiring through `ReplaceContents` - -**Files:** -- Modify: `src/AcDream.Core.Net/GameEventWiring.cs:253-266` (the ViewContents handler) -- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` (add after `WireAll_ViewContents_RecordsMembershipInClientObjectTable`) - -- [ ] **Step 1: Write the failing test** - -Add to `GameEventWiringTests.cs` (the existing `WireAll_ViewContents_RecordsMembershipInClientObjectTable` stays — it still passes under replace): - -```csharp -[Fact] -public void WireAll_ViewContents_IsFullReplace_DropsRemovedItems() -{ - var (d, items, _, _, _) = MakeAll(); - - // First view of container 0xC9: two items. - DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A01u, 0x50000A02u }); - Assert.Equal(2, items.GetContents(0x500000C9u).Count); - - // Second view: only one item — the other was removed server-side. Additive merge would keep both. - DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A02u }); - Assert.Equal(new[] { 0x50000A02u }, items.GetContents(0x500000C9u)); - Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); -} - -private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids) -{ - byte[] payload = new byte[8 + itemGuids.Length * 8]; - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), containerGuid); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)itemGuids.Length); - for (int i = 0; i < itemGuids.Length; i++) - { - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8 + i * 8), itemGuids[i]); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12 + i * 8), 0u); // containerType - } - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload)); - d.Dispatch(env!.Value); -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~ViewContents"` -Expected: FAIL — `WireAll_ViewContents_IsFullReplace_DropsRemovedItems` asserts `GetContents` has 1 entry, but the current additive `RecordMembership` loop leaves both (count 2) and `0x50000A01` keeps `ContainerId == 0xC9`. - -- [ ] **Step 3: Replace the handler body** - -In `GameEventWiring.cs`, replace the block at `:253-266` (the comment + handler) with: - -```csharp - // ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you - // opened (Use 0x0036). Treat it as a full REPLACE: flush the container's prior membership and - // record the new entries in order (ContainerSlot = index — ACE writes entries - // OrderBy(PlacementPosition) with no slot field). The container-open UI (InventoryController) - // repaints the grid off the resulting ObjectAdded/Moved events. Retail: ClientUISystem::OnViewContents. - dispatcher.Register(GameEventType.ViewContents, e => - { - var p = GameEvents.ParseViewContents(e.Payload.Span); - if (p is null) return; - var guids = new uint[p.Value.Items.Count]; - for (int i = 0; i < guids.Length; i++) guids[i] = p.Value.Items[i].Guid; - items.ReplaceContents(p.Value.ContainerGuid, guids); - }); -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~ViewContents"` -Expected: PASS (both the existing membership test and the new full-replace test). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs -git commit -m "feat(D.2b): ViewContents wiring is a full REPLACE (consumes the GameEventWiring TODO) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: `WorldSession.SendUse` (thin wire wrapper) - -**Files:** -- Modify: `src/AcDream.Core.Net/WorldSession.cs` (add after `SendNoLongerViewingContents`, ~line 1216) - -**Test note:** no new unit test. The wire contract (`Use 0x0036` + target guid) is already locked by `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs::BuildUse_WritesOpcode0x0036AndTarget`. `SendUse` is a trivial pass-through that mirrors the adjacent, test-free wrappers (`SendNoLongerViewingContents`, `SendDropItem`, `SendAddShortcut`) — there is no send-capture seam on `WorldSession` to assert against, and the round-trip is confirmed at the WireMCP visual gate (Task 8). - -- [ ] **Step 1: Add the wrapper** - -In `WorldSession.cs`, directly after `SendNoLongerViewingContents` (~line 1216), add: - -```csharp - /// Send Use (0x0036) — open/use an object by guid (e.g. open a container in your - /// inventory). A direct wire send: opening a pack you already hold needs no autowalk, unlike - /// GameWindow.SendUse (the world-interaction path). Retail: CM_Physics::Event_Use. - public void SendUse(uint targetGuid) - { - uint seq = NextGameActionSequence(); - SendGameAction(InteractRequests.BuildUse(seq, targetGuid)); - } -``` - -(`InteractRequests` is in `AcDream.Core.Net.Messages`; confirm the `using AcDream.Core.Net.Messages;` is present at the top of `WorldSession.cs` — it is, used by the other Send wrappers.) - -- [ ] **Step 2: Verify it compiles** - -Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj` -Expected: Build succeeded. - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.Core.Net/WorldSession.cs -git commit -m "feat(D.2b): WorldSession.SendUse — thin Use 0x0036 wire wrapper for container-open - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: `UiItemSlot` indicator overlays (triangle + square) - -**Files:** -- Modify: `src/AcDream.App/UI/UiItemSlot.cs` (add properties near `DragAcceptSprite` ~line 36; add draw in `OnDraw` ~line 226) -- Test: `tests/AcDream.App.Tests/UI/UiItemSlotTests.cs` (add after the DefaultEmptySprite test) - -- [ ] **Step 1: Write the failing tests** - -Add to `UiItemSlotTests.cs`: - -```csharp -// ── Container-switching indicator overlays (decomp SetOpenContainerState 0x004e1200 / -// SetSelectedState 0x004e1240) ─────────────────────────────────────────────────────── -[Fact] -public void Selected_defaultsFalse() => Assert.False(new UiItemSlot().Selected); - -[Fact] -public void IsOpenContainer_defaultsFalse() => Assert.False(new UiItemSlot().IsOpenContainer); - -[Fact] -public void SelectedSprite_default_isGreenYellowSquare() - => Assert.Equal(0x06004D21u, new UiItemSlot().SelectedSprite); - -[Fact] -public void OpenContainerSprite_default_isTriangle() - => Assert.Equal(0x06005D9Cu, new UiItemSlot().OpenContainerSprite); -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemSlotTests"` -Expected: FAIL — `'UiItemSlot' does not contain a definition for 'Selected'` (compile error). - -- [ ] **Step 3: Add the properties** - -In `UiItemSlot.cs`, after the `DragRejectSprite` property (~line 39), add: - -```csharp - /// True when this cell is the OPEN container (its contents fill the grid). Draws the - /// open-container triangle. Port of UIElement_ItemList::UpdateOpenContainerIndicator - /// (0x004e3070) → SetOpenContainerState (0x004e1200): shown when item.itemID == openContainerId. - public bool IsOpenContainer { get; set; } - /// Open-container triangle sprite (element 0x10000450 on the container prototype - /// 0x1000033F). Configurable; guard id != 0 before resolving. - public uint OpenContainerSprite { get; set; } = 0x06005D9Cu; - - /// True when this cell is the SELECTED item. Draws the green/yellow selection square. - /// Port of UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0) → SetSelectedState - /// (0x004e1240): shown when item.itemID == selectedItemId. Uniform across item + container cells. - public bool Selected { get; set; } - /// Selected-item square sprite (element 0x10000342 on the 32×32 item prototype - /// 0x10000341; pixel-confirmed green/yellow frame). Drawn as a procedural overlay so it renders - /// on the 36×36 container cell too (whose prototype lacks the square child). - public uint SelectedSprite { get; set; } = 0x06004D21u; -``` - -- [ ] **Step 4: Add the draw calls** - -In `UiItemSlot.cs` `OnDraw`, immediately BEFORE the drag-rollover block (the `if (_dragAccept != DragAcceptState.None …`, ~line 226), add: - -```csharp - // Open-container triangle (retail SetOpenContainerState 0x004e1200) + selected-item square - // (retail SetSelectedState 0x004e1240). Drawn procedurally like the digit/drag overlays; - // guard id != 0 BEFORE resolving (feedback_ui_resolve_zero_magenta). Triangle under the - // square; both under the transient drag overlay below. - if (IsOpenContainer && SpriteResolve is not null && OpenContainerSprite != 0) - { - var (tex, _, _) = SpriteResolve(OpenContainerSprite); - if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - if (Selected && SpriteResolve is not null && SelectedSprite != 0) - { - var (tex, _, _) = SpriteResolve(SelectedSprite); - if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemSlotTests"` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/UiItemSlot.cs tests/AcDream.App.Tests/UI/UiItemSlotTests.cs -git commit -m "feat(D.2b): UiItemSlot open-container triangle + selected-item square overlays - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 5: `InventoryController` container-switching + indicators - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` (state, `Bind` signature, `Populate`, click handlers, `ApplyIndicators`, `Concerns`, caption) -- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` - -- [ ] **Step 1: Extend the test `Bind` helper + write the failing tests** - -In `InventoryControllerTests.cs`, replace the private `Bind` helper (lines 53-57) with: - -```csharp - private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, - int? strength = 100, List? uses = null, List? closes = null) - => InventoryController.Bind(layout, objects, () => Player, - iconIds: (_, _, _, _, _) => 0u, // no real GL upload in tests - strength: () => strength, datFont: null, - sendUse: uses is null ? null : g => uses.Add(g), - sendNoLongerViewing: closes is null ? null : g => closes.Add(g)); - - // Seed a side bag (a container) in the player's pack, plus optionally its own contents. - private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24) - { - t.AddOrUpdate(new ClientObject { ObjectId = bag, Type = ItemType.Container, ItemsCapacity = itemsCapacity }); - t.MoveItem(bag, Player, slot); - } -``` - -Then add these tests at the end of the class (before the closing brace): - -```csharp - [Fact] - public void ClickSideBag_sendsUse_andSwapsGridToBagContents() - { - var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); // a loose main-pack item - SeedBag(objects, 0xC, slot: 1); // side bag (player slot 1) - SeedContained(objects, 0xB1, 0xC, slot: 0); // a thing already known to be in the bag - var uses = new List(); - Bind(layout, objects, uses: uses); - - // The side-bag column's first cell holds the bag guid; click it. - containers.GetItem(0)!.Clicked!(); - - Assert.Contains(0xCu, uses); // Use(bag) sent - Assert.Equal(0xB1u, grid.GetItem(0)!.ItemId); // grid swapped to the bag's contents - Assert.Equal(24, grid.GetNumUIItems()); // padded to the bag's ItemsCapacity - } - - [Fact] - public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack() - { - var (layout, grid, containers, top, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); // loose main-pack item - SeedBag(objects, 0xC, slot: 1); - SeedContained(objects, 0xB1, 0xC, slot: 0); - var uses = new List(); - var closes = new List(); - var ctrl = Bind(layout, objects, uses: uses, closes: closes); - - containers.GetItem(0)!.Clicked!(); // open the bag - top.GetItem(0)!.Clicked!(); // click the main-pack cell - - Assert.Contains(0xCu, closes); // NoLongerViewingContents(bag) sent - Assert.DoesNotContain(Player, uses); // no Use for the main pack - Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack - Assert.Equal(102, grid.GetNumUIItems()); - } - - [Fact] - public void SwitchBetweenTwoBags_closesPrevious_opensNext() - { - var (layout, _, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedBag(objects, 0xC1, slot: 0); - SeedBag(objects, 0xC2, slot: 1); - var uses = new List(); - var closes = new List(); - Bind(layout, objects, uses: uses, closes: closes); - - containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1) - containers.GetItem(1)!.Clicked!(); // open bag B (column index 1 → 0xC2) - - Assert.Equal(new[] { 0xC1u, 0xC2u }, uses.ToArray()); // Use(A) then Use(B) - Assert.Equal(new[] { 0xC1u }, closes.ToArray()); // close A when switching to B (not the main pack) - } - - [Fact] - public void OpenBag_marksTriangleAndSquare_onTheBagCell() - { - var (layout, _, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedBag(objects, 0xC, slot: 0); - Bind(layout, objects, uses: new List()); - - containers.GetItem(0)!.Clicked!(); - - Assert.True(containers.GetItem(0)!.IsOpenContainer); // triangle on the open bag - Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item - } - - [Fact] - public void ClickGridItem_movesSquareOnly_noWire_keepsOpenContainer() - { - var (layout, grid, _, top, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); - var uses = new List(); - var closes = new List(); - Bind(layout, objects, uses: uses, closes: closes); - - grid.GetItem(0)!.Clicked!(); // select the loose item - - Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item - Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged) - Assert.Empty(uses); // selection sends no wire - Assert.Empty(closes); - } - - [Fact] - public void Default_mainPackCell_isOpenContainer() - { - var (layout, _, _, top, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - Bind(layout, objects); - Assert.True(top.GetItem(0)!.IsOpenContainer); // default open container = main pack - } -``` - -Note: `BuildLayout()` returns `top` as the 4th tuple element (the `_topContainer` UiItemList); the existing tests discard it with `_`. The new tests above bind it where needed. - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` -Expected: FAIL — `InventoryController.Bind` has no `sendUse`/`sendNoLongerViewing` parameters (compile error). - -- [ ] **Step 3: Add controller state + new ctor/Bind parameters** - -In `InventoryController.cs`: - -(a) Add constant near `MainPackSlots` (~line 39): -```csharp - private const int SidePackSlots = 24; // standard side-pack capacity (reference_retail_inventory_paperdoll) -``` - -(b) Add fields near `_burdenPercent` (~line 52): -```csharp - private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid - private uint _selectedItem; // 0 = none; the panel-wide selected item (green square) - private readonly Action? _sendUse; - private readonly Action? _sendNoLongerViewing; -``` - -(c) Add ctor parameters (append to the existing private ctor parameter list, after `mainPackEmptySprite`): -```csharp - uint mainPackEmptySprite, - Action? sendUse, - Action? sendNoLongerViewing) -``` -and assign them at the top of the ctor body (after `_strength = strength;`): -```csharp - _sendUse = sendUse; - _sendNoLongerViewing = sendNoLongerViewing; -``` - -(d) Add the same two parameters to the public `Bind` (after `mainPackEmptySprite = 0u`), and pass them through: -```csharp - uint mainPackEmptySprite = 0u, - Action? sendUse = null, - Action? sendNoLongerViewing = null) - => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, - contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, - sendUse, sendNoLongerViewing); -``` - -- [ ] **Step 4: Replace `Populate` + add the new private methods** - -Replace the entire `Populate` method body with: - -```csharp - public void Populate() - { - uint p = _playerGuid(); - uint open = EffectiveOpen(); - - _contentsGrid?.Flush(); - _containerList?.Flush(); - - // Side-bag column: ALWAYS the player's bags (constant across container switches; only the - // open/selected indicators move). Equipped items never appear here. - foreach (var guid in _objects.GetContents(p)) - { - var item = _objects.Get(guid); - if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; - bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; - if (isBag) AddCell(_containerList, guid, isContainer: true); - } - - // Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has - // no sub-bags, so the isBag skip is a no-op when a bag is open.) - foreach (var guid in _objects.GetContents(open)) - { - var item = _objects.Get(guid); - if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; - bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; - if (!isBag) AddCell(_contentsGrid, guid, isContainer: false); - } - - // Pad the grid to the OPEN container's capacity (main pack 102 / side bag 24). - if (_contentsGrid is not null) - { - int cap = _objects.Get(open)?.ItemsCapacity ?? 0; - int slots = cap > 0 ? cap : (open == p ? MainPackSlots : SidePackSlots); - while (_contentsGrid.GetNumUIItems() < slots) AddEmptyCell(_contentsGrid); - } - - // Pad the side-bag column to capacity, clamped to the 7-slot column (AP-52). - if (_containerList is not null) - { - int capacity = _objects.Get(p)?.ContainersCapacity ?? 0; - int bags = _containerList.GetNumUIItems(); - int slots = capacity > 0 ? capacity : SideBagSlots; - slots = Math.Max(slots, bags); - slots = Math.Min(slots, SideBagSlots); - while (_containerList.GetNumUIItems() < slots) AddEmptyCell(_containerList); - } - - // Main-pack cell: the player's own container — clicking it opens/selects the main pack. - if (_topContainer is not null) - { - _topContainer.Flush(); - var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; - main.SetItem(p, 0u); - main.Clicked = () => OpenContainer(p); - _topContainer.AddItem(main); - } - - ApplyIndicators(); - RefreshBurden(); - } - - /// The effective open container — the explicit one, or the player (main pack) by default. - /// Resolved live (not cached at ctor) so a late-arriving player guid is handled. - private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); - - /// Add a populated cell wired to its click role: container cell → open+select, - /// item cell → select-only. - private void AddCell(UiItemList? list, uint guid, bool isContainer) - { - if (list is null) return; - var item = _objects.Get(guid); - uint tex = item is null ? 0u - : _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); - var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; - cell.SetItem(guid, tex); - if (isContainer) cell.Clicked = () => OpenContainer(guid); - else cell.Clicked = () => SelectItem(guid); - list.AddItem(cell); - } - - private static void AddEmptyCell(UiItemList list) - => list.AddItem(new UiItemSlot { SpriteResolve = list.SpriteResolve }); - - /// Select an item (panel-wide green square) without changing the open container or - /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0). - private void SelectItem(uint guid) - { - if (guid == 0) return; - _selectedItem = guid; - ApplyIndicators(); - } - - /// Open a container (side bag or main pack): it becomes both the selected item and the - /// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and - /// NoLongerViewingContents when switching away from a previously-open side bag. Retail: - /// gmBackpackUI container-selection + CM_Physics::Event_Use. - private void OpenContainer(uint guid) - { - if (guid == 0) return; - _selectedItem = guid; // the container cell is also the selected item - uint open = EffectiveOpen(); - if (guid == open) { ApplyIndicators(); return; } // already open — just move the square - - uint p = _playerGuid(); - if (open != p && open != 0) - _sendNoLongerViewing?.Invoke(open); // close the previously-open side bag - _openContainer = guid; - if (guid != p) - _sendUse?.Invoke(guid); // open the side bag (ViewContents will land) - Populate(); // repaint the grid for the new open container - } - - /// Stamp the open-container triangle + selected-item square onto every cell per the - /// retail keying (item.itemID == openContainerId / selectedItemId). - private void ApplyIndicators() - { - SetIndicators(_contentsGrid); - SetIndicators(_containerList); - SetIndicators(_topContainer); - } - - private void SetIndicators(UiItemList? list) - { - if (list is null) return; - uint open = EffectiveOpen(); - for (int i = 0; i < list.GetNumUIItems(); i++) - { - var cell = list.GetItem(i); - if (cell is null) continue; - cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem; - cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open; - } - } -``` - -- [ ] **Step 5: Extend `Concerns` + the caption** - -(a) In `Concerns`, add the open-container clause. Replace the return with: -```csharp - uint p = _playerGuid(); - return o.ObjectId == p // the player object IS the burden source - || o.ContainerId == p || o.WielderId == p - || o.ContainerId == EffectiveOpen() // the OPEN container's contents drive the grid - || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep -``` - -(b) In the ctor, change the contents-caption attach (currently `() => "Contents of Backpack"`) to follow the open container: -```csharp - AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont); -``` -and add the helper near `EffectiveOpen`: -```csharp - /// The open container's display name for the contents caption. - private string OpenContainerName() - { - uint open = EffectiveOpen(); - return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack"); - } -``` - -- [ ] **Step 6: Run the tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` -Expected: PASS (the 6 new tests + all existing InventoryController tests). - -- [ ] **Step 7: Commit** - -```bash -git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs -git commit -m "feat(D.2b): InventoryController container-switching + open/selected indicators - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 6: wire the send callbacks in `GameWindow` - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs:2231-2241` (the `InventoryController.Bind` call) - -**Test note:** `GameWindow` is integration-wired, not unit-tested; verified by build + the visual gate (Task 8). - -- [ ] **Step 1: Add the two callbacks to the Bind call** - -In `GameWindow.cs`, in the `InventoryController.Bind(...)` call (~line 2231), append two arguments after `mainPackEmptySprite: mainPackEmpty`: - -```csharp - contentsEmptySprite: contentsEmpty, - sideBagEmptySprite: sideBagEmpty, - mainPackEmptySprite: mainPackEmpty, - sendUse: g => _liveSession?.SendUse(g), - sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g)); -``` - -- [ ] **Step 2: Verify it compiles** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj` -Expected: Build succeeded. - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(D.2b): wire InventoryController container-open callbacks to the live session - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 7: divergence register + docs - -**Files:** -- Modify: `docs/architecture/retail-divergence-register.md` (retire AP-56; reword AP-53; add new rows) - -- [ ] **Step 1: Retire AP-56** - -Delete the AP-56 row (line ~158) — both indicators are now drawn. - -- [ ] **Step 2: Reword AP-53** - -Change AP-53's text from "the grid always shows the main pack (container-switch to a side pack's 24 not wired)" / "selecting a side pack doesn't show its 24-slot contents yet" to note that container-switching is now wired and the row covers only the capacity *default* (102/24 when `ItemsCapacity`/`ContainersCapacity` is absent). - -- [ ] **Step 3: Add the new rows** (use the next free AP-numbers after the current max; e.g. AP-57/AP-58): - -- A row: the open-container triangle (`0x06005D9C`) + selected-item square (`0x06004D21`) are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail keying (`item.itemID == openContainerId / selectedItemId` per `UpdateOpenContainerIndicator 0x004e3070` / `ItemList_SetSelectedItem 0x004e2fe0`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. File: `src/AcDream.App/UI/UiItemSlot.cs`, `src/AcDream.App/UI/Layout/InventoryController.cs`. Risk: a cell that should show an indicator via a dat-state path retail uses but we don't would be missed; outcome currently matches retail (the 36×36 container prototype lacks the square child, so the procedural overlay is the only way to show the square on a selected bag). -- A row: inventory item **selection is panel-local + visual-only** — not wired to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. File: `src/AcDream.App/UI/Layout/InventoryController.cs`. Risk: selecting an inventory item doesn't populate the selected-object strip as retail does; deferred to the selection follow-up. - -- [ ] **Step 4: Commit** - -```bash -git add docs/architecture/retail-divergence-register.md -git commit -m "docs(D.2b): divergence register — retire AP-56, reword AP-53, add overlay/selection rows - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 8: full-suite gate + visual verification - -**Files:** none (verification only). - -- [ ] **Step 1: Full build + full test suite** - -Run: `dotnet build` then `dotnet test` -Expected: Build succeeded; ALL tests pass (run the FULL suite, not a filtered subset — the B-Wire process lesson: a filtered batch hid a cross-file regression). - -- [ ] **Step 2: Launch the client (plain `dotnet run`, DO NOT pre-kill any running client)** - -If a rebuild is locked by a running client, ASK the user to close it — do not Stop-Process. Launch (PowerShell, background, per CLAUDE.md): - -```powershell -$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"; $env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"; $env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"; $env:ACDREAM_RETAIL_UI="1" -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log" -``` - -- [ ] **Step 3: Capture the Use → ViewContents round-trip** (settles lazy-load-on-open) - -While the client is in-world with F12 open, capture loopback with WireMCP (`capture_packets` on the `127.0.0.1:9000` conversation) and/or grep `launch.log` for the ViewContents handler, while the user clicks a side bag. Confirm a `Use 0x0036` goes out and a `ViewContents 0x0196` comes back, and note whether the bag's contents arrived only on open. - -- [ ] **Step 4: Visual gate (the oracle — user confirms)** - -The user, with F12 open: clicks a side bag → grid swaps to its contents; the open bag shows the triangle; the clicked cell shows the green/yellow square; clicks another bag → grid + indicators follow + the previous bag is closed; clicks the main-pack cell → grid returns to the main pack. Iterate on any sprite/positioning mismatch (the visual gate is the oracle for WHICH sprite, per the empty-slot-art lesson). - -- [ ] **Step 5: Update SSOT + roadmap after the gate passes** - -Append a "container-switching SHIPPED" entry to `claude-memory/project_d2b_retail_ui.md` (key facts + any DO-NOT-RETRY from the gate), update the roadmap/milestones if a milestone marker moved, and record the lazy-load finding. Commit. - ---- - -## Self-review - -**Spec coverage:** ReplaceContents (T1) ↔ spec §Components.1; ViewContents full-replace (T2) ↔ §Components.2; SendUse (T3) ↔ §Components.3; UiItemSlot overlays (T4) ↔ §Components.4; InventoryController state/Populate/clicks/indicators/Concerns/caption (T5) ↔ §Components.5; GameWindow wiring (T6) ↔ §Components.6; divergence rows (T7) ↔ §Divergence register; full suite + visual gate + lazy-load capture (T8) ↔ §Acceptance + §Open verification. All spec sections covered. - -**Placeholder scan:** every code step shows complete code; no TBD/TODO; the only "as appropriate" is T7's next-free-AP-number, which is mechanical (read the current max in the register). - -**Type consistency:** `ReplaceContents(uint, IReadOnlyList)` consistent T1↔T2. `SendUse(uint)` T3↔T6. `IsOpenContainer`/`OpenContainerSprite`/`Selected`/`SelectedSprite` consistent T4↔T5. `Bind(..., Action? sendUse, Action? sendNoLongerViewing)` consistent T5↔T6 (test helper)↔T6 (GameWindow). `EffectiveOpen()`/`OpenContainer()`/`SelectItem()`/`ApplyIndicators()`/`SetIndicators()`/`AddCell()`/`AddEmptyCell()`/`OpenContainerName()` all defined in T5. `SidePackSlots` const defined T5 step 3a. diff --git a/docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md b/docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md deleted file mode 100644 index 37c13797..00000000 --- a/docs/superpowers/plans/2026-06-22-d2b-empty-slot-art.md +++ /dev/null @@ -1,556 +0,0 @@ -# D.2b Inventory empty-slot art — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make the inventory contents grid, side-bag column, and main-pack cell render their correct per-list empty-slot background — resolved from the dat exactly as retail does — instead of the hardcoded generic toolbar square. - -**Architecture:** Port retail's `UIElement_ItemList::InternalCreateItem` resolver into a small static helper (`ItemListCellTemplate.ResolveEmptySprite`): read attribute `0x1000000e` off the list's ElementDesc → a cell-template element id → look it up in catalog LayoutDesc `0x21000037` → take that prototype's empty-slot media. A new `UiItemList.CellEmptySprite` carries the resolved sprite to the cells; `InventoryController` sets it for the three lists; `GameWindow` resolves and passes the three values (mirroring the existing `0x21000037` digit-array read + `ToolbarController.Bind`). `UiItemSlot` and the toolbar are untouched. - -**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter` (dat reader), the existing `AcDream.App.UI.Layout` importer. - -**Spec:** `docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md`. - ---- - -## File Structure - -| File | Create/Modify | Responsibility | -|---|---|---| -| `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` | **Create** | The pure dat→sprite resolver (port of `InternalCreateItem`'s template lookup). | -| `tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs` | **Create** | Real-dat smoke + the "pin the exact asset" printout (skips without the dat). | -| `src/AcDream.App/UI/UiItemList.cs` | Modify | Add `CellEmptySprite` (stamps cells). | -| `tests/AcDream.App.Tests/UI/UiItemListTests.cs` | Modify | Unit test for `CellEmptySprite` (no dat). | -| `src/AcDream.App/UI/Layout/InventoryController.cs` | Modify | `Bind`/ctor gain the three sprites; apply to the three lists. | -| `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` | Modify | Test the three lists receive the sprites. | -| `src/AcDream.App/Rendering/GameWindow.cs` | Modify (near `:2217`) | Resolve the three sprites; pass into `InventoryController.Bind`. | -| `docs/architecture/retail-divergence-register.md` | Modify | Two AP rows (toolbar hardcode; flat-cell approximation). | -| `docs/ISSUES.md` | Modify | Move the empty-slot-art issue to *Recently closed* (inventory portion). | - -`UiItemSlot.cs` is **not** modified — its `0x060074CF` default stays (correct for the toolbar); cells receive the per-list value via `UiItemList.CellEmptySprite`. - ---- - -### Task 1: The resolver + real-dat pinning test - -**Files:** -- Create: `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` -- Test: `tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs` - -- [ ] **Step 1: Write the failing real-dat test** - -Create `tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs`: - -```csharp -using System; -using System.IO; -using AcDream.App.UI.Layout; -using DatReaderWriter; -using DatReaderWriter.Options; -using Xunit; -using Xunit.Abstractions; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Real-dat test for : the inventory -/// item-lists must resolve their empty-slot sprite from the dat cell template (attribute -/// 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty), NOT the generic toolbar -/// square 0x060074CF. Also PRINTS the resolved ids — the "pin the exact asset" record. -/// Skips when the live dat directory is absent (CI). -/// -public class ItemListCellTemplateTests -{ - private const uint Generic = 0x060074CFu; // generic toolbar-shortcut empty square (the bug) - - private const uint ContentsLayout = 0x21000021u; private const uint ContentsGrid = 0x100001C6u; - private const uint BackpackLayout = 0x21000022u; private const uint SideBag = 0x100001CAu; - private const uint MainPack = 0x100001C9u; - - private readonly ITestOutputHelper _out; - public ItemListCellTemplateTests(ITestOutputHelper o) => _out = o; - - private static string? DatDir() - { - var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - return Directory.Exists(d) ? d : null; - } - - [Fact] - public void Inventory_lists_resolve_a_real_nongeneric_empty_sprite() - { - var datDir = DatDir(); - if (datDir is null) return; // CI: no live dat — skip (smoke test) - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - foreach (var (name, layout, elem) in new[] - { - ("contents", ContentsLayout, ContentsGrid), - ("side-bag", BackpackLayout, SideBag), - ("main-pack", BackpackLayout, MainPack), - }) - { - uint sprite = ItemListCellTemplate.ResolveEmptySprite(dats, layout, elem); - _out.WriteLine($"{name}: list 0x{elem:X8} (layout 0x{layout:X8}) -> empty sprite 0x{sprite:X8}"); - Assert.True(sprite != 0u, $"{name}: resolved 0 (no 0x1000000e attr / prototype / media)"); - Assert.True(sprite != Generic, $"{name}: resolved the generic 0x060074CF — the bug, not the fix"); - Assert.True((sprite & 0xFF000000u) == 0x06000000u, $"{name}: 0x{sprite:X8} is not a 0x06 RenderSurface id"); - } - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ItemListCellTemplateTests"` -Expected: **compile error** — `ItemListCellTemplate` does not exist. - -- [ ] **Step 3: Implement the resolver** - -Create `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs`: - -```csharp -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -namespace AcDream.App.UI.Layout; - -/// -/// Resolves the empty-slot background sprite for a UIElement_ItemList's cells the way retail -/// does. Retail ref: UIElement_ItemList::InternalCreateItem (acclient_2013_pseudo_c.txt:231486 / -/// 0x004e3570): GetAttribute_Enum(this, 0x1000000e, &id) -> GetByEnum(0x10000038,5,0x23) [the -/// shared UIItem catalog LayoutDesc, = 0x21000037] -> CreateChildElement(catalog, id); the cloned -/// prototype's ItemSlot_Empty (state 0x1000001c) media is the empty-cell background. -/// -public static class ItemListCellTemplate -{ - /// The shared UIItem cell-template catalog. Hardcoded: retail resolves it via - /// GetByEnum(0x10000038,5,0x23) through a master enum-map DAT object (no code literal); - /// 0x21000037 is the only LayoutDesc that is a catalog of standalone UIItem (0x10000032) - /// prototypes. Validated by the real-dat test (the resolved sprite must be a real 0x06 surface). - public const uint CatalogLayoutId = 0x21000037u; - - private const uint CellTemplateAttr = 0x1000000eu; // UIElement attribute: cell-template element id - private const uint IconChildId = 0x1000033Bu; // m_elem_Icon sub-element (carries ItemSlot_Empty) - private const string ItemSlotEmpty = "ItemSlot_Empty"; // UIStateId.ToString() for state 0x1000001c - - /// - /// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list - /// element in LayoutDesc . - /// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps - /// the default). - /// - public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId) - { - var listLd = dats.Get(listLayoutId); - if (listLd is null) return 0; - var listElem = FindDesc(listLd, listElementId); - if (listElem is null) return 0; - - uint protoId = ReadCellTemplateId(listElem); // attr 0x1000000e - if (protoId == 0) return 0; - - var catalog = dats.Get(CatalogLayoutId); - if (catalog is null) return 0; - var proto = FindDesc(catalog, protoId); - if (proto is null) return 0; - - // Priority: a child's DirectState background frame (the 36x36 backpack cell's recessed - // border, e.g. 0x06005D9C); else the m_elem_Icon child's ItemSlot_Empty media (the 32x32 - // contents cell, e.g. 0x06004D20). See the spec's explicit single-sprite contract. - uint frame = FirstChildDirectStateImage(proto); - return frame != 0 ? frame : IconChildEmptyImage(proto); - } - - // ── attribute 0x1000000e (read like the font DID 0x1A: bare DataIdBaseProperty or an array) ── - private static uint ReadCellTemplateId(ElementDesc elem) - { - uint id = ReadIdFromState(elem.StateDesc); - if (id != 0) return id; - foreach (var s in elem.States) - { - id = ReadIdFromState(s.Value); - if (id != 0) return id; - } - return 0; - } - - private static uint ReadIdFromState(StateDesc? sd) - { - if (sd?.Properties is null) return 0; - return sd.Properties.TryGetValue(CellTemplateAttr, out var raw) ? ReadId(raw) : 0; - } - - private static uint ReadId(BaseProperty raw) - { - if (raw is ArrayBaseProperty arr && arr.Value.Count > 0) return ReadId(arr.Value[0]); - if (raw is DataIdBaseProperty did) return did.Value; - return 0; - } - - // ── prototype media ── - private static uint FirstChildDirectStateImage(ElementDesc proto) - { - foreach (var kv in proto.Children) - { - uint f = DirectStateImage(kv.Value); - if (f != 0) return f; - } - return 0; - } - - private static uint IconChildEmptyImage(ElementDesc proto) - { - var icon = FindDescIn(proto, IconChildId); // recursive: handles inner wrappers (e.g. 0x10000340) - if (icon is null) return 0; - foreach (var s in icon.States) - if (s.Key.ToString() == ItemSlotEmpty) - { - uint f = StateImage(s.Value); - if (f != 0) return f; - } - return DirectStateImage(icon); // some prototypes carry empty media on the DirectState - } - - private static uint DirectStateImage(ElementDesc d) - => d.StateDesc is null ? 0u : StateImage(d.StateDesc); - - private static uint StateImage(StateDesc sd) - { - foreach (var m in sd.Media) - if (m is MediaDescImage img && img.File != 0) return img.File; - return 0; - } - - // ── depth-first element search by id (LayoutImporter.FindDesc is private there) ── - private static ElementDesc? FindDesc(LayoutDesc ld, uint id) - { - foreach (var kv in ld.Elements) - { - var f = FindDescIn(kv.Value, id); - if (f is not null) return f; - } - return null; - } - - private static ElementDesc? FindDescIn(ElementDesc d, uint id) - { - if (d.ElementId == id) return d; - foreach (var kv in d.Children) - { - var f = FindDescIn(kv.Value, id); - if (f is not null) return f; - } - return null; - } -} -``` - -- [ ] **Step 4: Run the test to verify it passes (and PIN the values)** - -Run (this machine has the live dat at `~/Documents/Asheron's Call`): -`dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~ItemListCellTemplateTests" -v n` -Expected: **PASS**. In the test output, **record the three printed sprite ids** (the "pin the exact asset" deliverable), e.g. `contents: ... -> empty sprite 0x06004D20`. If any line prints `0x00000000` or fails the `!= Generic` assert, the `0x1000000e` attribute was not found where expected — fall back to the cdb route in the spec §6 (break at `InternalCreateItem` `004e3616`, read arg4 + the catalog DID) and extend `ReadId`/the priority before continuing. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/Layout/ItemListCellTemplate.cs tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs -git commit -m "feat(ui): D.2b empty-slot art — ItemListCellTemplate resolver (0x1000000e -> 0x21000037) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -### Task 2: `UiItemList.CellEmptySprite` - -**Files:** -- Modify: `src/AcDream.App/UI/UiItemList.cs` -- Test: `tests/AcDream.App.Tests/UI/UiItemListTests.cs` - -- [ ] **Step 1: Write the failing unit test** - -Add to `tests/AcDream.App.Tests/UI/UiItemListTests.cs` (inside the existing test class): - -```csharp - [Fact] - public void CellEmptySprite_stamps_existing_and_future_cells() - { - var list = new UiItemList(); // ctor adds one default cell - Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // UiItemSlot default before set - - list.CellEmptySprite = 0x06004D20u; // a pack-slot sprite - Assert.Equal(0x06004D20u, list.GetItem(0)!.EmptySprite); // existing cell re-stamped - - list.AddItem(new UiItemSlot()); - Assert.Equal(0x06004D20u, list.GetItem(1)!.EmptySprite); // new cell stamped - } - - [Fact] - public void CellEmptySprite_zero_leaves_the_UiItemSlot_default() - { - var list = new UiItemList(); - list.CellEmptySprite = 0u; - Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // unchanged default - } -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListTests.CellEmptySprite"` -Expected: **compile error** — `UiItemList` has no `CellEmptySprite`. - -- [ ] **Step 3: Implement `CellEmptySprite`** - -In `src/AcDream.App/UI/UiItemList.cs`, add the property after the `SpriteResolve` property (around line 29): - -```csharp - private uint _cellEmptySprite; - /// Empty-slot sprite for THIS list's cells, resolved from the dat cell template - /// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see - /// ). 0 = leave the - /// default (0x060074CF). Applied to every existing - /// AND future cell. - public uint CellEmptySprite - { - get => _cellEmptySprite; - set - { - _cellEmptySprite = value; - if (value != 0) - foreach (var c in _cells) c.EmptySprite = value; - } - } -``` - -In `AddItem`, after the existing `cell.SpriteResolve ??= SpriteResolve;` line, add: - -```csharp - if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite; -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~UiItemListTests.CellEmptySprite"` -Expected: **PASS** (both tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/UiItemList.cs tests/AcDream.App.Tests/UI/UiItemListTests.cs -git commit -m "feat(ui): D.2b empty-slot art — UiItemList.CellEmptySprite stamps cells - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -### Task 3: `InventoryController` applies the three sprites - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` -- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` - -- [ ] **Step 1: Write the failing test** - -Add to `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs`: - -```csharp - [Fact] - public void Empty_sprites_are_applied_to_the_three_lists() - { - var (layout, grid, containers, top, _, _, _, _) = BuildLayout(); - InventoryController.Bind(layout, new ClientObjectTable(), () => Player, - iconIds: (_, _, _, _, _) => 0u, strength: () => 100, datFont: null, - contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu); - - Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell - Assert.Equal(0x06005D9Cu, containers.GetItem(0)!.EmptySprite); // a padded empty side-bag cell - Assert.Equal(0x06005D9Cu, top.GetItem(0)!.EmptySprite); // the main-pack cell - } -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests.Empty_sprites"` -Expected: **compile error** — `Bind` has no `contentsEmptySprite`/`sideBagEmptySprite`/`mainPackEmptySprite` parameters. - -- [ ] **Step 3: Add the parameters and apply them** - -In `src/AcDream.App/UI/Layout/InventoryController.cs`: - -(a) Add three parameters to the **private constructor** signature (after `UiDatFont? datFont`): - -```csharp - UiDatFont? datFont, - uint contentsEmptySprite, - uint sideBagEmptySprite, - uint mainPackEmptySprite) -``` - -(b) Inside the constructor, after the existing `if (_containerList is not null) { ... }` block that sets `Columns`/`CellWidth`/`CellHeight` (and before the burden-meter block), add: - -```csharp - // Per-list empty-slot art, resolved from the dat cell template (retail attr 0x1000000e - // -> catalog 0x21000037 prototype's ItemSlot_Empty). 0 = leave the UiItemSlot default. - if (_contentsGrid is not null) _contentsGrid.CellEmptySprite = contentsEmptySprite; - if (_containerList is not null) _containerList.CellEmptySprite = sideBagEmptySprite; - if (_topContainer is not null) _topContainer.CellEmptySprite = mainPackEmptySprite; -``` - -(c) Update the public **`Bind`** factory to take + forward the three values (defaulted to 0 so every existing caller and test still compiles): - -```csharp - public static InventoryController Bind( - ImportedLayout layout, - ClientObjectTable objects, - Func playerGuid, - Func iconIds, - Func strength, - UiDatFont? datFont, - uint contentsEmptySprite = 0u, - uint sideBagEmptySprite = 0u, - uint mainPackEmptySprite = 0u) - => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, - contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite); -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` -Expected: **PASS** (the new test + all existing `InventoryControllerTests` — the defaults keep them green). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs -git commit -m "feat(ui): D.2b empty-slot art — InventoryController applies per-list empty sprites - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -### Task 4: GameWindow wiring + divergence rows - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (around the `InventoryController.Bind` call, `:2217`) -- Modify: `docs/architecture/retail-divergence-register.md` - -- [ ] **Step 1: Resolve the three sprites and pass them into `Bind`** - -In `src/AcDream.App/Rendering/GameWindow.cs`, immediately **before** the `_inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(` call (currently at ~line 2217), insert: - -```csharp - // Resolve each inventory list's empty-slot art from the dat cell template - // (retail UIElement_ItemList::InternalCreateItem 0x004e3570: attr 0x1000000e -> - // catalog 0x21000037 prototype's ItemSlot_Empty). The contents grid lives in - // gm3DItemsUI (0x21000021); the side-bag + main-pack in gmBackpackUI (0x21000022). - uint contentsEmpty, sideBagEmpty, mainPackEmpty; - lock (_datLock) - { - contentsEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000021u, 0x100001C6u); - sideBagEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001CAu); - mainPackEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001C9u); - } - Console.WriteLine($"[D.2b empty-slot] contents=0x{contentsEmpty:X8} sideBag=0x{sideBagEmpty:X8} mainPack=0x{mainPackEmpty:X8}"); -``` - -Then extend the existing `Bind(...)` call's argument list — after `datFont: vitalsDatFont` (the current last argument, line ~2224) change the trailing `);` so the call ends: - -```csharp - datFont: vitalsDatFont, - contentsEmptySprite: contentsEmpty, - sideBagEmptySprite: sideBagEmpty, - mainPackEmptySprite: mainPackEmpty); -``` - -- [ ] **Step 2: Add the two divergence-register rows** - -In `docs/architecture/retail-divergence-register.md`, in the AP (Approximation/Adaptation) table (after the `AP-54` row, line ~156), add two rows (renumber if `AP-55`/`AP-56` are taken — use the next free ids): - -```markdown -| AP-55 | The toolbar's item slots keep the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via attribute `0x1000000e`. Correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype whose empty media is `0x060074CF`) but not dat-resolved. | `src/AcDream.App/UI/UiItemSlot.cs` (`EmptySprite` default); `src/AcDream.App/UI/Layout/ToolbarController.cs` | The inventory lists now port the retail resolver (`ItemListCellTemplate`); the toolbar was left on its hardcoded default to avoid regressing frozen, working art. Retire when the toolbar is routed through `ItemListCellTemplate`. | If a future toolbar layout's `0x1000000e` points at a non-generic prototype, the toolbar would show stale art. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; catalog `0x21000037` | -| AP-56 | `UiItemSlot` is a flat single-sprite cell; for a prototype with both a frame child and an inner icon (the 36×36 backpack prototype `0x1000033F`: frame `0x06005D9C` + inner `0x06000F6E`), only the dominant background (the frame) is drawn. Retail clones the full prototype subtree (frame + inner layered). | `src/AcDream.App/UI/UiItemSlot.cs`; `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` (`ResolveEmptySprite` single-sprite contract) | acdream's cell draws one empty sprite; replicating retail's multi-layer prototype needs a layered cell or a subtree clone. Frame-first chosen as the dominant visible art. Revisit at the visual gate / build a layered cell if the backpack slots read wrong. | The 36×36 backpack cells may lack the inner placeholder layer vs retail. | `UIElement_ItemList::InternalCreateItem` 0x004e3570; prototype `0x1000033F` | -``` - -- [ ] **Step 3: Build and run the full inventory test slice** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug` -Expected: **build succeeds**. - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests|FullyQualifiedName~ItemListCellTemplateTests|FullyQualifiedName~UiItemListTests"` -Expected: **PASS**. - -- [ ] **Step 4: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md -git commit -m "feat(ui): D.2b empty-slot art — GameWindow resolves + wires per-list empty sprites - -Inventory contents grid / side-bag / main-pack cells now render the retail -pack-slot empty art (dat cell template via ItemListCellTemplate) instead of the -generic toolbar square. Divergence rows AP-55 (toolbar still hardcoded) + AP-56 -(flat single-sprite cell vs retail's layered prototype). - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -### Task 5: Full verification, ISSUES, and the visual gate - -**Files:** -- Modify: `docs/ISSUES.md` - -- [ ] **Step 1: Run the FULL test suite (all four projects)** - -Run: `dotnet test` -Expected: **0 failures** across Core.Net / App / UI / Core. (Run the full suite — a filtered batch hid a cross-file regression during B-Wire.) - -- [ ] **Step 2: Move the ISSUES entry to *Recently closed* (inventory portion)** - -In `docs/ISSUES.md`, find the OPEN issue *"Inventory + equipment slots show the wrong empty-slot background art"*. Edit it so the inventory portion is closed and only the paperdoll equip-slot silhouettes remain (Sub-phase C): - -- Change its status line to: `**Status:** PARTIALLY CLOSED — inventory contents grid + side-bag + main-pack empty art now resolved from the dat cell template (`ItemListCellTemplate`), commit . REMAINING: paperdoll equip-slot silhouettes (Sub-phase C — needs the 0x10000032 UiItemSlot registration + UiViewport).` -- If the repo convention is to fully close + open a follow-up, instead move the whole entry to *Recently closed* with the SHA and add a one-line OPEN issue *"Paperdoll equip slots show a generic blue border, not per-slot silhouettes (Sub-phase C)"*. Match whichever pattern the surrounding ISSUES entries use. - -- [ ] **Step 3: Commit the ISSUES update** - -```bash -git add docs/ISSUES.md -git commit -m "docs: D.2b empty-slot art — close inventory portion of the empty-slot-art issue - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - -- [ ] **Step 4: STOP — visual gate (user)** - -This is the acceptance test and requires the user's eyes. Launch the client (plain `dotnet run --no-build`, **do not auto-kill** a running client — ask the user to close it if a rebuild is locked), open the inventory (F12, `ACDREAM_RETAIL_UI=1`), and have the user confirm: -- The contents-grid empty cells show the retail pack-slot background (not the generic toolbar square). -- The side-bag column empty cells show the correct backpack-slot art. - -If the 36×36 backpack cells read wrong (frame-only vs retail's frame+inner), that is AP-56 — escalate to a layered `UiItemSlot` empty (out of this plan's scope) or flip the `ResolveEmptySprite` priority to icon-first; confirm with the user before changing. - ---- - -## Self-Review - -**Spec coverage:** -- §2 retail mechanism → Task 1 resolver (ports `InternalCreateItem`'s `0x1000000e` → catalog → `ItemSlot_Empty`). ✓ -- §4.1 components (helper, `CellEmptySprite`, controller, GameWindow) → Tasks 1–4. ✓ -- §4.2 `CellEmptySprite` (stamps existing + future cells) → Task 2. ✓ -- §4.3 resolver contract (frame-first → icon-empty) → Task 1 `ResolveEmptySprite`. ✓ -- §4.4 data flow (per-list resolution from `0x21000021`/`0x21000022` → `Bind` → cells) → Tasks 3–4. ✓ -- §5 divergence rows (toolbar hardcode + flat-cell) → Task 4 Step 2. ✓ -- §6 step-1 validation (pin `0x1000000e` values) → Task 1 Step 4 (the test prints them). ✓ -- §7 testing (resolver smoke non-zero/≠generic/0x06; `CellEmptySprite` unit; controller applies; `DefaultEmptySprite_isToolbarBorder` untouched) → Tasks 1–3 + Task 5 full suite. ✓ -- §8 acceptance (build+test green; rows + ISSUES; visual gate) → Tasks 4–5. ✓ - -**Placeholder scan:** No "TBD"/"TODO"/"implement later". The recorded `0x1000000e` values (Task 1 Step 4) are an output to capture at run time, not a code placeholder — the resolver is value-agnostic; the test asserts structural properties (non-zero, ≠ generic, 0x06-prefixed), so no magic literal is required for the suite to pass. The `0x06004D20`/`0x06005D9C` literals in the Task 2/3 *unit* tests are illustrative pack-slot ids (any non-zero values exercise the stamping logic); they are not asserted against the dat. - -**Type consistency:** `ResolveEmptySprite(DatCollection, uint, uint) → uint` is used identically in Task 1 (test), Task 4 (GameWindow). `UiItemList.CellEmptySprite` (uint) set in Task 3, defined in Task 2. `InventoryController.Bind(..., uint contentsEmptySprite=0, uint sideBagEmptySprite=0, uint mainPackEmptySprite=0)` defined in Task 3, called with named args in Task 4. `UiItemSlot.EmptySprite` (existing, uint) read in Tasks 2–3 tests. Consistent throughout. diff --git a/docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md b/docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md deleted file mode 100644 index 365fca0a..00000000 --- a/docs/superpowers/plans/2026-06-22-d2b-inventory-drag-drop.md +++ /dev/null @@ -1,577 +0,0 @@ -# D.2b inventory drag-drop (item moving) — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Drag an inventory item and drop it to move it instantly — empty grid slot → first empty; on an item → insert before; on a side-bag cell → into that container — with a green insert-arrow (valid) / red circle (full) overlay and a server-rollback if the move bounces. - -**Architecture:** `InventoryController` implements `IItemListDragHandler` (like `ToolbarController`) on the contents grid + side-bag list + main-pack cell. Drop does an **optimistic** local `MoveItem` (instant repaint) + `PutItemInContainer 0x0019` wire; the server's `0x0022` echo confirms and `0x00A0` rolls back. Pending-move tracking lives in `ClientObjectTable` so both layers reach it. - -**Tech Stack:** C# .NET 10, xUnit. Layers: `AcDream.Core` (table + pending moves), `AcDream.Core.Net` (wire + wiring), `AcDream.App` (the drag handler). - -**Spec:** `docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md`. Sprites (export-confirmed): green insert-arrow `0x060011F7`, red circle `0x060011F8`. Retail: `InqDropIconInfo 0x004e26f0`, `ItemList_InsertItem`, `HandleDropRelease`, `ServerSaysMoveItem`; wire `PutItemInContainer 0x0019` (`item, container, placement`). - -**Build/test:** from repo root. Per-project e.g. `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj`. Full (phase boundary): `dotnet test` (use `--no-build` if a client is running, to avoid the App.dll lock). - ---- - -## Task 1: `ClientObjectTable` optimistic move + rollback - -**Files:** -- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` (add a pending-move map field near the other fields ~line 45, and three methods after `MoveItem` ~line 131) -- Test: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` - -- [ ] **Step 1: Write the failing tests** - -```csharp -[Fact] -public void MoveItemOptimistic_movesAndRemembersPreMove() -{ - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x700u, container: 0xC0u)); table.MoveItem(0x700u, 0xC0u, newSlot: 3); - table.MoveItemOptimistic(0x700u, 0xC1u, 0); - Assert.Equal(0xC1u, table.Get(0x700u)!.ContainerId); // moved now (instant) - Assert.True(table.RollbackMove(0x700u)); // pre-move was remembered - Assert.Equal(0xC0u, table.Get(0x700u)!.ContainerId); // snapped back to the original container - Assert.Equal(3, table.Get(0x700u)!.ContainerSlot); // and slot -} - -[Fact] -public void ConfirmMove_clearsPending_soRollbackIsNoOp() -{ - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x701u, container: 0xC0u)); - table.MoveItemOptimistic(0x701u, 0xC1u, 0); - table.ConfirmMove(0x701u); - Assert.False(table.RollbackMove(0x701u)); // nothing pending → no rollback - Assert.Equal(0xC1u, table.Get(0x701u)!.ContainerId); // stays at the confirmed container -} - -[Fact] -public void MoveItemOptimistic_twice_keepsTheOriginalPreMove() -{ - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x702u, container: 0xC0u)); - table.MoveItemOptimistic(0x702u, 0xC1u, 0); - table.MoveItemOptimistic(0x702u, 0xC2u, 0); // a second move before any confirm - table.RollbackMove(0x702u); - Assert.Equal(0xC0u, table.Get(0x702u)!.ContainerId); // rolls back to the FIRST pre-move -} - -[Fact] -public void RollbackMove_unknownItem_false() - => Assert.False(new ClientObjectTable().RollbackMove(0xDEADu)); -``` - -- [ ] **Step 2: Run to verify they fail** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Optimistic|FullyQualifiedName~ConfirmMove|FullyQualifiedName~RollbackMove"` -Expected: FAIL — `'ClientObjectTable' does not contain a definition for 'MoveItemOptimistic'`. - -- [ ] **Step 3: Add the field + methods** - -Add the field near the other private fields (after `_containerIndex`, ~line 45): -```csharp - // B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot) BEFORE - // the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0), - // cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo. - private readonly Dictionary _pendingMoves = new(); -``` - -Add the methods after `MoveItem` (~line 131): -```csharp - /// - /// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST - /// time it moves, then (immediate repaint via ObjectMoved). The wire - /// PutItemInContainer is sent by the caller; / - /// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile. - /// - public bool MoveItemOptimistic(uint itemId, uint newContainerId, int newSlot) - { - if (!_objects.TryGetValue(itemId, out var item)) return false; - if (!_pendingMoves.ContainsKey(itemId)) - _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot); // snapshot the ORIGINAL once - return MoveItem(itemId, newContainerId, newSlot); - } - - /// The server confirmed the move (InventoryPutObjInContainer 0x0022 echo) — drop the - /// pending snapshot. No-op for a server-initiated move we never tracked. - public void ConfirmMove(uint itemId) => _pendingMoves.Remove(itemId); - - /// The server rejected the move (InventoryServerSaveFailed 0x00A0) — restore the item to - /// its pre-move (container, slot) and drop the snapshot. False if nothing was pending. - public bool RollbackMove(uint itemId) - { - if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; - _pendingMoves.Remove(itemId); - return MoveItem(itemId, pre.container, pre.slot); - } -``` - -- [ ] **Step 4: Run to verify they pass** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Optimistic|FullyQualifiedName~ConfirmMove|FullyQualifiedName~RollbackMove"` -Expected: PASS (4 tests). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs -git commit -m "feat(D.2b): ClientObjectTable optimistic move + confirm/rollback for inventory drag - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: `WorldSession.SendPutItemInContainer` - -**Files:** -- Modify: `src/AcDream.Core.Net/WorldSession.cs` (add after `SendUse`, ~line 1226) - -**Test note:** no new unit test — the wire is locked by `tests/AcDream.Core.Net.Tests/Messages/InteractRequestsTests.cs` (`BuildPickUp` opcode `0x0019` + fields); the wrapper mirrors the test-free `SendUse`/`SendNoLongerViewingContents`. Round-trip confirmed at the WireMCP gate (Task 7). - -- [ ] **Step 1: Add the wrapper** - -After `SendUse` (~line 1226) add: -```csharp - /// Send PutItemInContainer (0x0019) — move an item into a container at a slot. placement - /// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail: - /// CM_Inventory::Event_PutItemInContainer → ACE Player.HandleActionPutItemInContainer. - public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement) - { - uint seq = NextGameActionSequence(); - SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)); - } -``` - -- [ ] **Step 2: Verify it builds** - -Run: `dotnet build src/AcDream.Core.Net/AcDream.Core.Net.csproj` -Expected: Build succeeded. - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.Core.Net/WorldSession.cs -git commit -m "feat(D.2b): WorldSession.SendPutItemInContainer (0x0019) for inventory moves - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: wire the confirm/rollback handlers - -**Files:** -- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (the `InventoryPutObjInContainer` handler ~line 246-251 and the `InventoryServerSaveFailed` handler ~line 280-285) -- Test: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` - -- [ ] **Step 1: Write the failing test** - -```csharp -[Fact] -public void WireAll_InventoryServerSaveFailed_RollsBackOptimisticMove() -{ - var (d, items, _, _, _) = MakeAll(); - // An item known to be in container 0xC0 at slot 2, then optimistically moved to 0xC1. - items.Ingest(FullWeenieAt(0x50000B01u, 0x500000C0u, slot: 2)); - items.MoveItemOptimistic(0x50000B01u, 0x500000C1u, 0); - Assert.Equal(0x500000C1u, items.Get(0x50000B01u)!.ContainerId); // moved (optimistic) - - // Server bounces it: InventoryServerSaveFailed (itemGuid, weenieError). - byte[] payload = new byte[8]; - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B01u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x1Au); // some WeenieError - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryServerSaveFailed, payload)); - d.Dispatch(env!.Value); - - Assert.Equal(0x500000C0u, items.Get(0x50000B01u)!.ContainerId); // snapped back - Assert.Equal(2, items.Get(0x50000B01u)!.ContainerSlot); -} -``` - -Add this helper to the test class if `FullWeenieAt` isn't present (place near the other helpers): -```csharp -private static WeenieData FullWeenieAt(uint guid, uint container, int slot) -{ - var d = new WeenieData { Guid = guid, ContainerId = container }; - // Ingest sets ContainerId from the wire; the slot is set by the follow-up MoveItem. - return d; -} -``` -(If the test file already constructs `WeenieData` inline elsewhere, match that style instead and set `ContainerId`. After `Ingest`, call `items.MoveItem(guid, container, slot)` to pin the slot — adjust the test's first two lines to: `items.Ingest(new WeenieData{Guid=0x50000B01u, ContainerId=0x500000C0u}); items.MoveItem(0x50000B01u, 0x500000C0u, 2);`.) - -- [ ] **Step 2: Run to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~RollsBackOptimisticMove"` -Expected: FAIL — the item stays at `0x500000C1` (the handler only logs today; no rollback). - -- [ ] **Step 3: Wire confirm + rollback** - -In `GameEventWiring.cs`, the `InventoryPutObjInContainer` handler — add `ConfirmMove` after the existing `MoveItem`: -```csharp - dispatcher.Register(GameEventType.InventoryPutObjInContainer, e => - { - var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); - if (p is null) return; - items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement); - items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move - }); -``` - -Replace the `InventoryServerSaveFailed` handler body (the `Console.WriteLine` log) with the rollback: -```csharp - dispatcher.Register(GameEventType.InventoryServerSaveFailed, e => - { - var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span); - if (p is null) return; - // B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot. - if (!items.RollbackMove(p.Value.ItemGuid)) - Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} (no pending move)"); - }); -``` - -- [ ] **Step 4: Run to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~RollsBackOptimisticMove"` -Expected: PASS. Also run the existing inventory wiring tests to confirm no regression: `--filter "FullyQualifiedName~Inventory"`. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs -git commit -m "feat(D.2b): wire ConfirmMove (0x0022 echo) + RollbackMove (0x00A0) for optimistic inventory moves - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: `InventoryController` drag-drop handler - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/InventoryController.cs` (implement `IItemListDragHandler`; register on the 3 lists; set drag sprites + `SlotIndex` on cells; add `sendPutItemInContainer` ctor/Bind param) -- Test: `tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs` - -- [ ] **Step 1: Extend the test harness + write the failing tests** - -In `InventoryControllerTests.cs`, replace the `Bind` helper with one that captures the new callback: -```csharp - private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, - int? strength = 100, List? uses = null, List? closes = null, - List<(uint item, uint container, int placement)>? puts = null) - => InventoryController.Bind(layout, objects, () => Player, - iconIds: (_, _, _, _, _) => 0u, - strength: () => strength, datFont: null, - sendUse: uses is null ? null : g => uses.Add(g), - sendNoLongerViewing: closes is null ? null : g => closes.Add(g), - sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p))); -``` - -Add these tests at the end of the class (before the `CaptionText` helper): -```csharp - private static ItemDragPayload Payload(uint obj) => new(obj, ItemDragSource.Inventory, 0, new UiItemSlot()); - - [Fact] - public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); - SeedContained(objects, 0xB, Player, slot: 1); - var puts = new List<(uint, uint, int)>(); - var ctrl = Bind(layout, objects, puts: puts); - - // Drag a fresh item 0xZ from elsewhere; drop on the grid cell holding 0xB (slot 1). - objects.AddOrUpdate(new ClientObject { ObjectId = 0xZ }); - var bCell = grid.GetItem(1)!; // ItemId == 0xB, SlotIndex 1 - ((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xZ)); - - Assert.Contains((0xZu, Player, 1), puts); // insert-before slot 1, into the open container - Assert.Equal(Player, objects.Get(0xZ)!.ContainerId); // moved locally (instant) - } - - [Fact] - public void Drop_onEmptyGridCell_appendsToFirstEmpty() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); // 1 loose item → first empty = slot 1 - var puts = new List<(uint, uint, int)>(); - var ctrl = Bind(layout, objects, puts: puts); - - objects.AddOrUpdate(new ClientObject { ObjectId = 0xZ }); - var emptyCell = grid.GetItem(5)!; // an empty padded cell (ItemId 0) - ((IItemListDragHandler)ctrl).HandleDropRelease(grid, emptyCell, Payload(0xZ)); - - Assert.Contains((0xZu, Player, 1), puts); // placement = occupied count (1) = first empty - } - - [Fact] - public void Drop_onSideBagCell_movesIntoThatBag() - { - var (layout, _, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); - var puts = new List<(uint, uint, int)>(); - var ctrl = Bind(layout, objects, puts: puts); - - objects.AddOrUpdate(new ClientObject { ObjectId = 0xZ }); - var bagCell = containers.GetItem(0)!; // ItemId == 0xC (the bag) - ((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xZ)); - - Assert.Contains((0xZu, 0xCu, 0), puts); // into the bag, append (placement 0) - Assert.Equal(0xCu, objects.Get(0xZ)!.ContainerId); - } - - [Fact] - public void OnDragOver_fullSideBag_rejects_butGridAccepts() - { - var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedBag(objects, 0xC, slot: 0, itemsCapacity: 1); // capacity 1... - SeedContained(objects, 0xB0, 0xC, slot: 0); // ...and already full - objects.AddOrUpdate(new ClientObject { ObjectId = 0xZ }); - var ctrl = (IItemListDragHandler)Bind(layout, objects); - - Assert.False(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xZ))); // full bag → red - Assert.True(ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xZ))); // grid → green - } - - [Fact] - public void OnDragLift_isNoOp_itemStaysUntilServerConfirms() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); - var ctrl = (IItemListDragHandler)Bind(layout, objects); - ((IItemListDragHandler)ctrl).OnDragLift(grid, grid.GetItem(0)!, Payload(0xA)); - Assert.Equal(Player, objects.Get(0xA)!.ContainerId); // NOT removed on lift (unlike the toolbar) - } -``` - -- [ ] **Step 2: Run to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` -Expected: FAIL — `Bind` has no `sendPutItemInContainer`; `InventoryController` doesn't implement `IItemListDragHandler` (compile errors). - -- [ ] **Step 3: Make `InventoryController` the drag handler** - -(a) Class declaration — add the interface: -```csharp -public sealed class InventoryController : IItemListDragHandler -``` - -(b) Add the field + ctor/Bind param. Add field near `_sendNoLongerViewing`: -```csharp - private readonly Action? _sendPutItemInContainer; // (item, container, placement) -``` -Append to the private ctor params (after `sendNoLongerViewing`): -```csharp - Action? sendNoLongerViewing, - Action? sendPutItemInContainer) -``` -Assign in the ctor body (after `_sendNoLongerViewing = ...`): -```csharp - _sendPutItemInContainer = sendPutItemInContainer; -``` -Register the handler on the three lists in the ctor (after the lists are resolved, near the other `_contentsGrid` setup): -```csharp - _contentsGrid?.RegisterDragHandler(this); - _containerList?.RegisterDragHandler(this); - _topContainer?.RegisterDragHandler(this); -``` -Append to the public `Bind` params (after `sendNoLongerViewing = null`) and thread it through: -```csharp - Action? sendNoLongerViewing = null, - Action? sendPutItemInContainer = null) - => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, - contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, - sendUse, sendNoLongerViewing, sendPutItemInContainer); -``` - -(c) Set drag sprites + `SlotIndex` on cells. In `AddCell`, after `cell.SetItem(...)`: -```csharp - cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list) - cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed) - cell.DragRejectSprite = 0x060011F8u; // red circle -``` -In `AddEmptyCell`, change it to set the same so empty grid cells are valid drop targets: -```csharp - private static void AddEmptyCell(UiItemList list) - { - var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() }; - cell.DragAcceptSprite = 0x060011F7u; - cell.DragRejectSprite = 0x060011F8u; - list.AddItem(cell); - } -``` -In the main-pack cell block (`Populate`), after `main.SetItem(...)`: -```csharp - main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; -``` - -(d) Add the handler methods (place near the other private helpers, e.g. after `SetCapacityBar`): -```csharp - // ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ────────────── - /// Inventory items do NOT lift-remove (unlike the toolbar): the item stays in its slot - /// until the server confirms the move. Retail dims the source; we leave it + the floating ghost. - public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } - - /// Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always - /// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full. - public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - { - if (payload.ObjId == 0) return false; - if (targetList == _contentsGrid) return true; - if (targetList == _containerList || targetList == _topContainer) - { - if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false; - return !IsContainerFull(targetCell.ItemId); - } - return false; - } - - /// Perform the move: resolve (container, placement) from the target, optimistically move - /// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem. - public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - { - uint item = payload.ObjId; - if (item == 0) return; - - uint container; int placement; - if (targetList == _contentsGrid) - { - container = EffectiveOpen(); - placement = targetCell.ItemId != 0 - ? (_objects.Get(targetCell.ItemId)?.ContainerSlot ?? targetCell.SlotIndex) // insert-before - : _objects.GetContents(container).Count; // first empty = append - } - else if (targetList == _containerList || targetList == _topContainer) - { - if (targetCell.ItemId == 0 || targetCell.ItemId == item) return; - container = targetCell.ItemId; // the bag / main pack - if (IsContainerFull(container)) return; // red already shown - placement = _objects.GetContents(container).Count; // append into it - } - else return; - - if (container == item) return; // never into itself - _objects.MoveItemOptimistic(item, container, placement); // instant local move - _sendPutItemInContainer?.Invoke(item, container, placement); - } - - /// True only when we KNOW the container is full (capacity known + contents indexed). A - /// closed bag (unknown count) returns false → advisory accept; the server is authoritative. - private bool IsContainerFull(uint container) - { - int cap = _objects.Get(container)?.ItemsCapacity ?? 0; - if (cap <= 0) return false; - return _objects.GetContents(container).Count >= cap; - } -``` - -(e) Add the `using` if missing at the top of the file: `using AcDream.App.UI;` (for `IItemListDragHandler`/`ItemDragPayload`/`UiItemSlot` — likely already present since the file uses `UiItemSlot`). Confirm the file compiles. - -- [ ] **Step 4: Run to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~InventoryControllerTests"` -Expected: PASS (5 new + all existing). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/UI/Layout/InventoryController.cs tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs -git commit -m "feat(D.2b): InventoryController drag-drop handler (optimistic move + green-arrow/red-circle) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 5: wire the send callback in `GameWindow` - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the `InventoryController.Bind(...)` call ~line 2231-2242) - -**Test note:** integration-wired; verified by build + the visual gate (Task 7). - -- [ ] **Step 1: Add the callback** - -Append after `sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g)`: -```csharp - sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g), - sendPutItemInContainer: (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement)); -``` - -- [ ] **Step 2: Verify it builds** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj` -Expected: Build succeeded. - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(D.2b): wire InventoryController drag-drop to the live session - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 6: divergence register - -**Files:** -- Modify: `docs/architecture/retail-divergence-register.md` (add rows after AP-59; use the next free numbers AP-60/AP-61) - -- [ ] **Step 1: Add the rows** - -- **AP-60** — Inventory drag `OnDragLift` is a no-op + the source cell is not dimmed: retail dims the lifted item's source cell; acdream leaves the item in place during the drag + the floating ghost. File `src/AcDream.App/UI/Layout/InventoryController.cs`. Risk: a drag in progress doesn't visually mark its origin (cosmetic). Anchor: `RecvNotice_ItemListBeginDrag`. -- **AP-61** — Drop on a CLOSED side bag is advisory-accept: the client can't know a closed bag's item count (contents aren't indexed until opened), so `OnDragOver` shows green + relies on the server's `InventoryServerSaveFailed` reject + the rollback; retail knows the count when loaded. File `src/AcDream.App/UI/Layout/InventoryController.cs` (`IsContainerFull`). Risk: a drop into a closed-but-full bag shows green then snaps back (a flicker) instead of pre-showing red. Anchor: `UIElement_ItemList::InqDropIconInfo 0x004e26f0`. - -- [ ] **Step 2: Commit** - -```bash -git add docs/architecture/retail-divergence-register.md -git commit -m "docs(D.2b): divergence rows AP-60 (lift no-op) + AP-61 (closed-bag advisory accept) - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 7: full-suite gate + visual verification - -**Files:** none. - -- [ ] **Step 1: Full build + full test** - -Run: `dotnet build` then `dotnet test` (use `dotnet test --no-build` if a client is running — avoids the App.dll lock; the implementer built above). Expected: all green, 0 failures (run the FULL suite — the B-Wire cross-file-regression lesson). - -- [ ] **Step 2: Launch (plain `dotnet run`, DO NOT pre-kill a running client)** - -If a rebuild is locked by a running client, ASK the user to close it. Launch (PowerShell, background): -```powershell -$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1"; $env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000"; $env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword"; $env:ACDREAM_RETAIL_UI="1" -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log" -``` - -- [ ] **Step 3: Visual gate (the oracle — user confirms)** - -With F12 open, the user: drags an item onto an **empty slot** → it lands at the first empty slot the instant they release; onto an **item** → inserts before it; onto a **side bag** → goes in; hovering a **full bag** shows the **red circle** and the drop bounces (snaps back); a valid target shows the **green insert-arrow**. WireMCP (`127.0.0.1:9000`) confirms `PutItemInContainer 0x0019` out + the `0x0022` echo (and `0x00A0` on a full-bag bounce). Note any sprite/placement mismatch and iterate (the gate is the oracle). - -- [ ] **Step 4: Update SSOT + roadmap** - -Append a "B-Drag SHIPPED" entry to `claude-memory/project_d2b_retail_ui.md` (key facts + any DO-NOT-RETRY) + update the NEXT pointer. Commit. - ---- - -## Self-review - -**Spec coverage:** optimistic move + rollback (T1) ↔ §Components.3; SendPutItemInContainer (T2) ↔ §Components.1; confirm/rollback wiring (T3) ↔ §Components.3; the drag handler — OnDragLift/OnDragOver/HandleDropRelease + the 3 placements + overlays (T4) ↔ §Components.2 + §Scope goals 1–3; GameWindow wiring (T5) ↔ §Components; divergence (T6) ↔ §Divergence register; full suite + visual gate (T7) ↔ §Acceptance. All covered. - -**Placeholder scan:** every code step is complete; no TBD. The overlay ids are export-confirmed literals (`0x060011F7`/`0x060011F8`); the AP-60/61 numbers are mechanical (next free in the register). - -**Type consistency:** `MoveItemOptimistic(uint,uint,int)`/`ConfirmMove(uint)`/`RollbackMove(uint)` consistent T1↔T3↔T4. `SendPutItemInContainer(uint,uint,int)` consistent T2↔T5↔T4 (capture). `Action? sendPutItemInContainer` consistent T4 (ctor/Bind/test harness)↔T5. `OnDragLift`/`OnDragOver`/`HandleDropRelease`/`IsContainerFull` all defined in T4; `EffectiveOpen()` pre-exists. Drag sprites `0x060011F7`/`0x060011F8` consistent across T4. diff --git a/docs/superpowers/plans/2026-06-22-d2b-paperdoll-slice1-equip-slots.md b/docs/superpowers/plans/2026-06-22-d2b-paperdoll-slice1-equip-slots.md deleted file mode 100644 index 1a4d769e..00000000 --- a/docs/superpowers/plans/2026-06-22-d2b-paperdoll-slice1-equip-slots.md +++ /dev/null @@ -1,862 +0,0 @@ -# Paperdoll Slice 1 (Equip Slots) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Bind the ~21 mounted paperdoll equip slots to live equipped-item data and make them drag-drop wield/unwield targets (no 3D doll — that's Slice 2). - -**Architecture:** A new `PaperdollController` (mirrors the shipped `InventoryController`) maps each equip-slot element-id → `EquipMask`, populates each single-cell `UiItemList` from the item whose `CurrentlyEquippedLocation` intersects the slot mask, and is the slots' `IItemListDragHandler` (drop = wield via the existing `GetAndWieldItem 0x001A` wire + a new optimistic-equip Core method). Two Core prerequisites: correct the misaligned `EquipMask` enum to canonical ACE/retail values, and add `WieldItemOptimistic` + an equip-aware rollback snapshot. Unwield is already handled by `InventoryController` (dragging an equipped item onto the grid). - -**Tech Stack:** C# / .NET 10, xUnit, the acdream `UiHost` retained-mode toolkit, the `ClientObjectTable` data model. - -**Spec:** `docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md` - ---- - -## Task 1: De-risk probe — equip slots resolve to `UiItemList` - -The whole plan assumes the imported paperdoll subtree materializes each equip slot as a `UiItemList` -(factory `0x10000031`). Verify against the live dat FIRST. If it fails, STOP and report — the importer -would need to factory-build the slots (out of this plan's scope). - -**Files:** -- Modify: `tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs` - -- [ ] **Step 1: Add the using + the probe test** - -At the top of the file, ensure `using AcDream.App.UI;` is present (add it after `using AcDream.App.UI.Layout;`). Then add this method inside the `InventoryFrameImportProbe` class: - -```csharp -[Fact] -public void Paperdoll_equip_slots_resolve_to_item_lists() -{ - var datDir = DatDir(); - if (datDir is null) return; // CI: no live dat — skip - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null); - Assert.NotNull(layout); - - // A representative spread across the slot grid (head, shield, the weapon composite, cloak, - // trinket, a finger). All inherit base 0x100001E4 → 0x2100003D (Type 0x10000031 = UIElement_ItemList). - foreach (uint id in new[] { 0x100005ABu, 0x100001E1u, 0x100001DFu, 0x100005E9u, 0x1000058Eu, 0x100001DCu }) - { - var el = layout!.FindElement(id); - Assert.True(el is UiItemList, - $"equip slot 0x{id:X8} resolved to {el?.GetType().Name ?? "null"}, expected UiItemList"); - } -} -``` - -- [ ] **Step 2: Run the probe against the live dat** - -Run (PowerShell, the dat dir is the user's): - -``` -$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" -dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~Paperdoll_equip_slots_resolve_to_item_lists" -``` - -Expected: **PASS** (1 test). If it reports 0 run, the env var didn't take — confirm the dat dir exists. -**If it FAILS** (a slot resolved to `UiDatElement`/null), STOP: the importer does not factory-build the -paperdoll slots. Report this — the plan needs an importer fix prepended before continuing. - -- [ ] **Step 3: Commit** - -```bash -git add tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs -git commit -m "test(D.2b): probe — paperdoll equip slots resolve to UiItemList" -``` - ---- - -## Task 2: Correct the `EquipMask` enum (Core) + numeric-pin test - -acdream's `EquipMask` diverges from canonical AC (`acclient.h:3193` `INVENTORY_LOC`) from bit `0x2000` up -(phantom `HandArmor`/`FootArmor`). Replace it with the verbatim retail values and lock them with a test. - -**Files:** -- Create: `tests/AcDream.Core.Tests/Items/EquipMaskTests.cs` -- Modify: `src/AcDream.Core/Items/ClientObject.cs:60-100` (the `EquipMask` enum) - -- [ ] **Step 1: Write the failing numeric-pin test** - -Create `tests/AcDream.Core.Tests/Items/EquipMaskTests.cs`: - -```csharp -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -/// -/// Pins every EquipMask member to the verbatim retail INVENTORY_LOC value -/// (docs/research/named-retail/acclient.h:3193). The wire delivers these exact bits -/// (ACE EquipMask == retail INVENTORY_LOC); any drift desyncs the paperdoll element-id→mask -/// map and the GetAndWieldItem wire. This test is the anti-regression lock. -/// -public sealed class EquipMaskTests -{ - [Theory] - [InlineData(0x00000000u, EquipMask.None)] - [InlineData(0x00000001u, EquipMask.HeadWear)] - [InlineData(0x00000002u, EquipMask.ChestWear)] - [InlineData(0x00000004u, EquipMask.AbdomenWear)] - [InlineData(0x00000008u, EquipMask.UpperArmWear)] - [InlineData(0x00000010u, EquipMask.LowerArmWear)] - [InlineData(0x00000020u, EquipMask.HandWear)] - [InlineData(0x00000040u, EquipMask.UpperLegWear)] - [InlineData(0x00000080u, EquipMask.LowerLegWear)] - [InlineData(0x00000100u, EquipMask.FootWear)] - [InlineData(0x00000200u, EquipMask.ChestArmor)] - [InlineData(0x00000400u, EquipMask.AbdomenArmor)] - [InlineData(0x00000800u, EquipMask.UpperArmArmor)] - [InlineData(0x00001000u, EquipMask.LowerArmArmor)] - [InlineData(0x00002000u, EquipMask.UpperLegArmor)] - [InlineData(0x00004000u, EquipMask.LowerLegArmor)] - [InlineData(0x00008000u, EquipMask.NeckWear)] - [InlineData(0x00010000u, EquipMask.WristWearLeft)] - [InlineData(0x00020000u, EquipMask.WristWearRight)] - [InlineData(0x00040000u, EquipMask.FingerWearLeft)] - [InlineData(0x00080000u, EquipMask.FingerWearRight)] - [InlineData(0x00100000u, EquipMask.MeleeWeapon)] - [InlineData(0x00200000u, EquipMask.Shield)] - [InlineData(0x00400000u, EquipMask.MissileWeapon)] - [InlineData(0x00800000u, EquipMask.MissileAmmo)] - [InlineData(0x01000000u, EquipMask.Held)] - [InlineData(0x02000000u, EquipMask.TwoHanded)] - [InlineData(0x04000000u, EquipMask.TrinketOne)] - [InlineData(0x08000000u, EquipMask.Cloak)] - [InlineData(0x10000000u, EquipMask.SigilOne)] - [InlineData(0x20000000u, EquipMask.SigilTwo)] - [InlineData(0x40000000u, EquipMask.SigilThree)] - public void Member_has_canonical_retail_value(uint expected, EquipMask member) - => Assert.Equal(expected, (uint)member); - - [Fact] - public void Weapon_ready_slot_composite_is_0x3500000() // acclient.h:3235 WEAPON_READY_SLOT_LOC - => Assert.Equal(0x3500000u, - (uint)(EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded)); -} -``` - -- [ ] **Step 2: Run it to verify it fails** - -``` -dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EquipMaskTests" -``` - -Expected: **FAIL** — e.g. `EquipMask.UpperLegArmor` is `0x4000` but the test expects `0x2000`; `Shield` is `0x800000` but expected `0x200000`. (Several `Member_has_canonical_retail_value` cases fail; some won't even compile if a member name is gone — proceed to Step 3.) - -- [ ] **Step 3: Replace the enum body with the canonical values** - -In `src/AcDream.Core/Items/ClientObject.cs`, replace the entire `EquipMask` enum (the `[Flags] public enum EquipMask : uint { … }` block, currently lines ~64-100) and its doc comment with: - -```csharp -/// -/// Equipment slot bitmask — the verbatim retail INVENTORY_LOC enum -/// (docs/research/named-retail/acclient.h:3193; identical to ACE's EquipMask). -/// The wire (ValidLocations / CurrentWieldedLocation / WieldObject EquipLoc) delivers -/// these exact bits. Pinned by EquipMaskTests — do NOT renumber. -/// -[Flags] -public enum EquipMask : uint -{ - None = 0x00000000, - HeadWear = 0x00000001, - ChestWear = 0x00000002, - AbdomenWear = 0x00000004, - UpperArmWear = 0x00000008, - LowerArmWear = 0x00000010, - HandWear = 0x00000020, - UpperLegWear = 0x00000040, - LowerLegWear = 0x00000080, - FootWear = 0x00000100, - ChestArmor = 0x00000200, - AbdomenArmor = 0x00000400, - UpperArmArmor = 0x00000800, - LowerArmArmor = 0x00001000, - UpperLegArmor = 0x00002000, - LowerLegArmor = 0x00004000, - NeckWear = 0x00008000, - WristWearLeft = 0x00010000, - WristWearRight = 0x00020000, - FingerWearLeft = 0x00040000, - FingerWearRight= 0x00080000, - MeleeWeapon = 0x00100000, - Shield = 0x00200000, - MissileWeapon = 0x00400000, - MissileAmmo = 0x00800000, - Held = 0x01000000, - TwoHanded = 0x02000000, - TrinketOne = 0x04000000, - Cloak = 0x08000000, - SigilOne = 0x10000000, - SigilTwo = 0x20000000, - SigilThree = 0x40000000, -} -``` - -- [ ] **Step 4: Run the pin test + the full Core suite** - -``` -dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -``` - -Expected: **PASS** (EquipMaskTests green; the existing `ClientObjectTableTests` round-trip tests using `EquipMask.MeleeWeapon` still pass — they're value-agnostic). If a NON-test file fails to compile because it used a removed member (`HandArmor`/`Necklace`/`LeftRing`/`AetheriaRed`/etc.), that is a real consumer the §3 blast-radius scan missed — STOP and report it (do not invent a replacement). - -- [ ] **Step 5: Run the Core.Net suite too** (the wire round-trip lives there) - -``` -dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj -``` - -Expected: **PASS** (`GameEventWiringTests` WieldObject round-trips are value-agnostic). - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.Core/Items/ClientObject.cs tests/AcDream.Core.Tests/Items/EquipMaskTests.cs -git commit -m "fix(D.2b): correct EquipMask to canonical retail INVENTORY_LOC + numeric-pin test" -``` - ---- - -## Task 3: `WieldItemOptimistic` + equip-aware rollback snapshot (Core) - -Add the wield sibling to `MoveItemOptimistic` and extend the pending-move snapshot to remember the -pre-move equip location (so rollback is faithful in both directions). - -**Files:** -- Modify: `src/AcDream.Core/Items/ClientObjectTable.cs` -- Modify: `tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs` - -- [ ] **Step 1: Write the failing tests** - -Add to `ClientObjectTableTests.cs` (the class already has a `FullWeenie` helper; these tests use the simpler `AddOrUpdate`+`MoveItem` seed path like the existing optimistic tests): - -```csharp -[Fact] -public void WieldItemOptimistic_equipsInstantly_andSetsWielderAndContainer() -{ - var table = new ClientObjectTable(); - const uint player = 0x50000001u, pack = 0x40000005u; - table.AddOrUpdate(new ClientObject { ObjectId = 0x940u }); - table.MoveItem(0x940u, pack, newSlot: 2); // a pack item - table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear); - var o = table.Get(0x940u)!; - Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant) - Assert.Equal(player, o.WielderId); - Assert.Equal(player, o.ContainerId); -} - -[Fact] -public void WieldItemOptimistic_rollback_unequips_andReturnsToPack() -{ - var table = new ClientObjectTable(); - const uint player = 0x50000001u, pack = 0x40000005u; - table.AddOrUpdate(new ClientObject { ObjectId = 0x941u }); - table.MoveItem(0x941u, pack, newSlot: 2); - table.WieldItemOptimistic(0x941u, player, EquipMask.HeadWear); - Assert.True(table.RollbackMove(0x941u)); // server rejected the wield - var o = table.Get(0x941u)!; - Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // un-equipped - Assert.Equal(pack, o.ContainerId); // back in the pack - Assert.Equal(2, o.ContainerSlot); // at its original slot -} - -[Fact] -public void RollbackMove_restoresPreMoveEquipLocation() // unwield-reject must snap back to EQUIPPED -{ - var table = new ClientObjectTable(); - const uint player = 0x50000001u; - table.AddOrUpdate(new ClientObject { ObjectId = 0x942u }); - table.MoveItem(0x942u, player, newSlot: -1, newEquipLocation: EquipMask.Shield); // equipped - table.MoveItemOptimistic(0x942u, player, 0); // optimistic UNWIELD into the pack (clears equip) - Assert.Equal(EquipMask.None, table.Get(0x942u)!.CurrentlyEquippedLocation); - Assert.True(table.RollbackMove(0x942u)); // server rejected the unwield - Assert.Equal(EquipMask.Shield, table.Get(0x942u)!.CurrentlyEquippedLocation); // restored to equipped -} - -[Fact] -public void WieldItemOptimistic_unknownItem_false() - => Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear)); -``` - -- [ ] **Step 2: Run to verify they fail** - -``` -dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ClientObjectTableTests" -``` - -Expected: **FAIL to compile** (`WieldItemOptimistic` does not exist) — that counts as red; proceed. - -- [ ] **Step 3: Extend the snapshot tuple + add the helper + `WieldItemOptimistic`** - -In `src/AcDream.Core/Items/ClientObjectTable.cs`: - -(a) Change the `_pendingMoves` field (line ~50) to carry the equip location: - -```csharp -private readonly Dictionary _pendingMoves = new(); -``` - -(b) In `MoveItemOptimistic`, replace the inline snapshot block (the `if (_pendingMoves.TryGetValue(itemId, out var p)) … else …` that currently records `(item.ContainerId, item.ContainerSlot, 1)`) with a single call: - -```csharp - RecordPending(itemId, item); -``` - -(c) Add the shared helper (place it just above `MoveItemOptimistic`): - -```csharp -/// Snapshot the item's pre-move (container, slot, equip) the FIRST time it moves, and -/// bump the OUTSTANDING count on subsequent in-flight moves of the same item (so an early confirm -/// can't clear a still-pending later move — the I1 hardening). Shared by MoveItemOptimistic (unwield/ -/// move) and WieldItemOptimistic (wield). -private void RecordPending(uint itemId, ClientObject item) -{ - if (_pendingMoves.TryGetValue(itemId, out var p)) - _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1); - else - _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, - item.CurrentlyEquippedLocation, 1); -} -``` - -(d) In `ConfirmMove`, fix the decrement line to carry `equip`: - -```csharp - else _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1); -``` - -(e) In `RollbackMove`, restore the equip location too: - -```csharp - return MoveItem(itemId, pre.container, pre.slot, pre.equip); -``` - -(f) Add `WieldItemOptimistic` (place it just after `MoveItemOptimistic`): - -```csharp -/// Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set -/// ContainerId = WielderId = wielderGuid and CurrentlyEquippedLocation = equipMask (matching the -/// server's WieldObject 0x0023 confirm), firing ObjectMoved for an immediate repaint. The caller -/// sends GetAndWieldItem; ConfirmMove (on the 0x0023 echo) / RollbackMove (on 0x00A0) reconcile. -public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask) -{ - if (!_objects.TryGetValue(itemId, out var item)) return false; - RecordPending(itemId, item); - item.WielderId = wielderGuid; // unambiguously the player's gear during the optimistic window - return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask); -} -``` - -- [ ] **Step 4: Run the full Core suite** - -``` -dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -``` - -Expected: **PASS** — the 4 new tests + all existing optimistic-move tests (`MoveItemOptimistic_*`, `ConfirmMove_*`, the I1/I2 tests) stay green (the tuple gained a field but their behavior is unchanged). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core/Items/ClientObjectTable.cs tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs -git commit -m "feat(D.2b): WieldItemOptimistic + equip-aware rollback snapshot (Core)" -``` - ---- - -## Task 4: Confirm an optimistic wield on the `WieldObject 0x0023` echo (Core.Net) - -The `WieldObject` handler updates state but never calls `ConfirmMove`, so an optimistic wield's snapshot -would linger. Add the confirm (mirrors the `InventoryPutObjInContainer` handler). - -**Files:** -- Modify: `src/AcDream.Core.Net/GameEventWiring.cs` (the `WieldObject` handler, ~line 238) -- Modify: `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` - -- [ ] **Step 1: Write the failing test** - -Add to `GameEventWiringTests.cs` (mirror the existing `WireAll_WieldObject_RoutesToClientObjectTable` payload format — itemGuid, EquipLoc, WielderGuid): - -```csharp -[Fact] -public void WireAll_WieldObject_ConfirmsOptimisticWield() -{ - var (d, items, _, _, _) = MakeAll(); - const uint player = 0x2000u; - items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 }); - items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack - items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending - - byte[] payload = new byte[12]; - BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), player); - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload)); - d.Dispatch(env!.Value); // server confirms the wield - - Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove -} -``` - -- [ ] **Step 2: Run to verify it fails** - -``` -dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj --filter "FullyQualifiedName~WireAll_WieldObject_ConfirmsOptimisticWield" -``` - -Expected: **FAIL** — `RollbackMove` returns `true` (the snapshot was never confirmed), so the assertion fails. - -- [ ] **Step 3: Add the `ConfirmMove` call** - -In `src/AcDream.Core.Net/GameEventWiring.cs`, the `WieldObject` handler currently reads: - -```csharp - dispatcher.Register(GameEventType.WieldObject, e => - { - var p = GameEvents.ParseWieldObject(e.Payload.Span); - if (p is not null) items.MoveItem( - p.Value.ItemGuid, - newContainerId: p.Value.WielderGuid, - newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc); - }); -``` - -Change the body so it confirms an optimistic wield after applying the move: - -```csharp - dispatcher.Register(GameEventType.WieldObject, e => - { - var p = GameEvents.ParseWieldObject(e.Payload.Span); - if (p is null) return; - items.MoveItem( - p.Value.ItemGuid, - newContainerId: p.Value.WielderGuid, - newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc); - items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler) - }); -``` - -- [ ] **Step 4: Run the full Core.Net suite** - -``` -dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj -``` - -Expected: **PASS** (the new test + the existing `WireAll_WieldObject_RoutesToClientObjectTable` both green). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core.Net/GameEventWiring.cs tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs -git commit -m "fix(D.2b): ConfirmMove on the WieldObject 0x0023 echo (clears optimistic wield)" -``` - ---- - -## Task 5: `PaperdollController` + tests + divergence rows (App) - -The controller: element-id→mask map, bind each slot, populate icons, be the wield drag handler. - -**Files:** -- Create: `src/AcDream.App/UI/Layout/PaperdollController.cs` -- Create: `tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs` -- Modify: `docs/architecture/retail-divergence-register.md` - -- [ ] **Step 1: Write the failing tests** - -Create `tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs`: - -```csharp -using System.Collections.Generic; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.App.Tests.UI.Layout; - -public class PaperdollControllerTests -{ - private const uint Player = 0x50000001u; - private const uint Pack = 0x40000005u; - private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1 - private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000 - private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000 - private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000 - - private sealed class RootElement : UiElement { } - - private static (ImportedLayout layout, Dictionary lists) BuildLayout() - { - var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot }; - var lists = new Dictionary(); - var byId = new Dictionary(); - var root = new RootElement { Width = 224, Height = 214 }; - foreach (var id in ids) - { - var list = new UiItemList { Width = 32, Height = 32 }; - lists[id] = list; byId[id] = list; root.AddChild(list); - } - return (new ImportedLayout(root, byId), lists); - } - - private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects, - List<(uint item, uint mask)>? wields = null) - => PaperdollController.Bind(layout, objects, () => Player, - iconIds: (_, _, _, _, _) => 0x1234u, - sendWield: wields is null ? null : (i, m) => wields.Add((i, m))); - - private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc) - { - t.AddOrUpdate(new ClientObject { ObjectId = guid, WielderId = Player }); - t.MoveItem(guid, Player, newSlot: -1, newEquipLocation: loc); - } - - private static void SeedPackItem(ClientObjectTable t, uint guid, EquipMask validLocations) - { - t.AddOrUpdate(new ClientObject { ObjectId = guid, ValidLocations = validLocations }); - t.MoveItem(guid, Pack, newSlot: 0); - } - - [Fact] - public void Populate_shows_equipped_item_in_its_slot() - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedEquipped(objects, 0xA01u, EquipMask.HeadWear); - Bind(layout, objects); - Assert.Equal(0xA01u, lists[HeadSlot].Cell.ItemId); // helm in the head slot - Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty - } - - [Fact] - public void Populate_matches_a_weapon_into_the_composite_slot() - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedEquipped(objects, 0xB01u, EquipMask.MeleeWeapon); // a sword's actual equip loc - Bind(layout, objects); - Assert.Equal(0xB01u, lists[WeaponSlot].Cell.ItemId); // matched via (loc & composite) != 0 - } - - [Fact] - public void OnDragOver_accepts_only_valid_locations() - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedPackItem(objects, 0xC01u, EquipMask.HeadWear); // a helm in the pack - var ctrl = Bind(layout, objects); - var payload = new ItemDragPayload(0xC01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell); - Assert.True(ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); // helm → head OK - Assert.False(ctrl.OnDragOver(lists[ShieldSlot], lists[ShieldSlot].Cell, payload)); // helm → shield NO - } - - [Fact] - public void HandleDropRelease_wields_optimistically_and_sends_wire() - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedPackItem(objects, 0xD01u, EquipMask.HeadWear); - var wields = new List<(uint item, uint mask)>(); - var ctrl = Bind(layout, objects, wields); - var payload = new ItemDragPayload(0xD01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell); - ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload); - Assert.Equal(EquipMask.HeadWear, objects.Get(0xD01u)!.CurrentlyEquippedLocation); // equipped instantly - Assert.Equal(Player, objects.Get(0xD01u)!.ContainerId); // contained-by-wielder (the optimistic wield is ContainerId-based; it does NOT write WielderId) - Assert.Single(wields); - Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire - } - - [Fact] - public void HandleDropRelease_resolves_the_finger_bit_for_a_dual_finger_ring() - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedPackItem(objects, 0xE01u, EquipMask.FingerWearLeft | EquipMask.FingerWearRight); - var wields = new List<(uint item, uint mask)>(); - var ctrl = Bind(layout, objects, wields); - var payload = new ItemDragPayload(0xE01u, ItemDragSource.Inventory, 0, lists[FingerLSlot].Cell); - ctrl.HandleDropRelease(lists[FingerLSlot], lists[FingerLSlot].Cell, payload); - Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only - } - - [Fact] - public void Empty_equip_slot_is_transparent() // EmptySprite=0 ⇒ nothing drawn (no doll behind it in Slice 1) - { - var (layout, lists) = BuildLayout(); - Bind(layout, new ClientObjectTable()); - Assert.Equal(0u, lists[HeadSlot].Cell.EmptySprite); - } -} -``` - -- [ ] **Step 2: Run to verify they fail** - -``` -dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~PaperdollControllerTests" -``` - -Expected: **FAIL to compile** (`PaperdollController` does not exist) — counts as red; proceed. - -- [ ] **Step 3: Implement `PaperdollController`** - -Create `src/AcDream.App/UI/Layout/PaperdollController.cs`: - -```csharp -using System; -using System.Collections.Generic; -using AcDream.App.UI; -using AcDream.Core.Items; - -namespace AcDream.App.UI.Layout; - -/// -/// Binds the ~21 equip slots mounted under the paperdoll (gmPaperDollUI 0x21000024, nested in the -/// inventory frame 0x21000023) to live equipped-item data and makes them drag-drop WIELD targets. -/// The acdream analogue of gmPaperDollUI::PostInit + GetLocationInfoFromElementID (named-retail decomp -/// 175480 / 173620). Slice 1: equip slots only — no 3D doll viewport (that's Slice 2). -/// Unwield is handled by InventoryController (dragging an equipped item onto the pack grid). -/// -public sealed class PaperdollController : IItemListDragHandler -{ - // WEAPON_READY_SLOT_LOC (acclient.h:3235): the weapon-hand doll slot accepts any wieldable weapon. - private const EquipMask WeaponSlotMask = - EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded; - - // element-id → EquipMask (verified: dump paperdoll-0x21000024.txt ↔ deep-dive §3a ↔ acclient.h:3193). - // The 3 Aetheria sigil slots (0x10000595/96/97) are SetVisible(0) in retail — skipped (Slice 1 scope). - private static readonly (uint Element, EquipMask Mask)[] SlotMap = - { - (0x100005ABu, EquipMask.HeadWear), // 0x1 - (0x100001E2u, EquipMask.ChestWear), // 0x2 - (0x100001E3u, EquipMask.UpperLegWear), // 0x40 - (0x100005B0u, EquipMask.HandWear), // 0x20 - (0x100005B3u, EquipMask.FootWear), // 0x100 - (0x100005ACu, EquipMask.ChestArmor), // 0x200 - (0x100005ADu, EquipMask.AbdomenArmor), // 0x400 - (0x100005AEu, EquipMask.UpperArmArmor), // 0x800 - (0x100005AFu, EquipMask.LowerArmArmor), // 0x1000 - (0x100005B1u, EquipMask.UpperLegArmor), // 0x2000 - (0x100005B2u, EquipMask.LowerLegArmor), // 0x4000 - (0x100001DAu, EquipMask.NeckWear), // 0x8000 - (0x100001DBu, EquipMask.WristWearLeft), // 0x10000 - (0x100001DDu, EquipMask.WristWearRight), // 0x20000 - (0x100001DCu, EquipMask.FingerWearLeft), // 0x40000 - (0x100001DEu, EquipMask.FingerWearRight), // 0x80000 - (0x100001E1u, EquipMask.Shield), // 0x200000 - (0x100001E0u, EquipMask.MissileAmmo), // 0x800000 (LIKELY — gate-verify, AP row) - (0x100001DFu, WeaponSlotMask), // 0x3500000 composite - (0x1000058Eu, EquipMask.TrinketOne), // 0x4000000 - (0x100005E9u, EquipMask.Cloak), // 0x8000000 - }; - - private readonly ClientObjectTable _objects; - private readonly Func _playerGuid; - private readonly Func _iconIds; - private readonly Action? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A - private readonly List<(EquipMask Mask, UiItemList List)> _slots = new(); - - private PaperdollController( - ImportedLayout layout, ClientObjectTable objects, Func playerGuid, - Func iconIds, Action? sendWield) - { - _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield; - - int index = 0; - foreach (var (element, mask) in SlotMap) - { - if (layout.FindElement(element) is not UiItemList list) { index++; continue; } - list.RegisterDragHandler(this); - list.Cell.SourceKind = ItemDragSource.Equipment; - list.Cell.SlotIndex = index++; // a stable per-slot index (routing uses the list, not this) - list.Cell.EmptySprite = 0u; // TRANSPARENT empty slot (brainstorm decision; no doll behind it) - _slots.Add((mask, list)); - } - - _objects.ObjectAdded += OnObjectChanged; - _objects.ObjectMoved += OnObjectMoved; - _objects.ObjectRemoved += OnObjectChanged; - _objects.ObjectUpdated += OnObjectChanged; - - Populate(); - } - - public static PaperdollController Bind( - ImportedLayout layout, ClientObjectTable objects, Func playerGuid, - Func iconIds, Action? sendWield = null) - => new PaperdollController(layout, objects, playerGuid, iconIds, sendWield); - - private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } - private void OnObjectMoved(ClientObject o, uint from, uint to) - { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } - - /// The object is (or just became / ceased to be) the player's equipped gear. - private bool Concerns(ClientObject o) - { - uint p = _playerGuid(); - return o.CurrentlyEquippedLocation != EquipMask.None || o.WielderId == p || o.ContainerId == p; - } - - public void Populate() - { - uint p = _playerGuid(); - - // The player's equipped gear (one pass). Scoped to the player so a vendor's/NPC's wielded item - // (which can also carry CurrentWieldedLocation) can't leak into the doll. - var equipped = new List(); - foreach (var o in _objects.Objects) - if (o.CurrentlyEquippedLocation != EquipMask.None && (o.WielderId == p || o.ContainerId == p)) - equipped.Add(o); - - foreach (var (mask, list) in _slots) - { - ClientObject? worn = null; - foreach (var o in equipped) - if ((o.CurrentlyEquippedLocation & mask) != EquipMask.None) { worn = o; break; } - - if (worn is null) { list.Cell.Clear(); continue; } - uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects); - list.Cell.SetItem(worn.ObjectId, tex); - } - } - - private EquipMask MaskFor(UiItemList list) - { - foreach (var (mask, l) in _slots) if (ReferenceEquals(l, list)) return mask; - return EquipMask.None; - } - - // ── IItemListDragHandler ────────────────────────────────────────────────────────────────────── - /// No-op: a wielded item stays put until the server confirms (same as the inventory grid, - /// unlike the toolbar's remove-on-lift). Unwield happens on DROP onto the pack grid — InventoryController. - public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } - - /// Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask). - /// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302). - public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - { - var item = _objects.Get(payload.ObjId); - if (item is null) return false; - return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None; - } - - /// Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A. - /// wieldMask = ValidLocations & slotMask resolves the specific bit (the composite weapon slot, the - /// chosen finger). Retail: gmPaperDollUI HandleDropRelease → GetAndWieldItem. - public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - { - var item = _objects.Get(payload.ObjId); - if (item is null) return; - EquipMask wieldMask = item.ValidLocations & MaskFor(targetList); - if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected) - _objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask); - _sendWield?.Invoke(payload.ObjId, (uint)wieldMask); - } - - /// Detach event handlers (idempotent). - public void Dispose() - { - _objects.ObjectAdded -= OnObjectChanged; - _objects.ObjectMoved -= OnObjectMoved; - _objects.ObjectRemoved -= OnObjectChanged; - _objects.ObjectUpdated -= OnObjectChanged; - } -} -``` - -- [ ] **Step 4: Run the controller tests + the full App suite** - -``` -dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj -``` - -Expected: **PASS** (the 6 `PaperdollControllerTests` + the whole App suite green; the EquipMask correction is value-agnostic for existing App tests). - -- [ ] **Step 5: Add the divergence-register rows** - -Read `docs/architecture/retail-divergence-register.md`, find the highest existing `AP-NN`, and append three rows using the next sequential numbers (the B-Drag rows AP-60/AP-61 are the current tail, so expect AP-62..AP-64 — but use whatever the file's actual max+1 is). One-line rows in the file's existing format: - -1. **MissileAmmo slot mask LIKELY** — `0x100001E0 → MissileAmmo 0x800000` is inferred (the decomp immediate at `GetLocationInfoFromElementID` 173676 is corrupted to a string pointer); risk if wrong: dropping ammo onto that slot wields to the wrong location. Source: `PaperdollController.SlotMap`. Gate-verify. -2. **Dual-wield-into-shield-slot special not implemented** — retail `OnItemListDragOver` (decomp 174302) lets a melee-capable item also drop on the Shield slot; acdream's `wieldMask = ValidLocations & slotMask` rejects it. Risk: a dual-wielder can't off-hand a melee weapon via the doll. Source: `PaperdollController.HandleDropRelease`. -3. **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise it's corrected by the next authoritative message. Source: `GameEventWiring` 0x00A0 handler. Gate-verify via WireMCP. - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/UI/Layout/PaperdollController.cs tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs docs/architecture/retail-divergence-register.md -git commit -m "feat(D.2b): PaperdollController — equip slots bind + wield drag handler (Slice 1)" -``` - ---- - -## Task 6: Wire `PaperdollController` into GameWindow (App) - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (the field declaration near `_inventoryController`; the bind site at ~line 2245, right after the `InventoryController.Bind(...)` call) - -- [ ] **Step 1: Add the field** - -Find the `_inventoryController` field declaration (grep `_inventoryController` in `GameWindow.cs` — it's a `private … InventoryController? _inventoryController;`). Add directly beneath it: - -```csharp - private AcDream.App.UI.Layout.PaperdollController? _paperdollController; -``` - -- [ ] **Step 2: Add the Bind call** - -In the inventory-frame block, immediately after the `_inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(...);` statement ends (the `sendPutItemInContainer:` line that closes with `));`, ~line 2245) and before the following `Console.WriteLine("[D.2b-B] retail inventory window …");`, insert: - -```csharp - // Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear + - // wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid). - _paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind( - invLayout, Objects, - playerGuid: () => _playerServerGuid, - iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), - sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask)); -``` - -- [ ] **Step 3: Build the whole solution** - -``` -dotnet build -``` - -Expected: **build succeeds** (0 errors). - -- [ ] **Step 4: Run the full test suite (all projects)** - -``` -dotnet test -``` - -Expected: **all green** across Core / Core.Net / App / UI.Abstractions. This is the phase-boundary full-suite gate (the B-Wire process lesson — don't trust filtered subsets). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(D.2b): wire PaperdollController into the inventory frame (Slice 1)" -``` - ---- - -## Visual gate (user) - -Launch the client (plain `dotnet run` — do NOT auto-kill a running client; if a rebuild is locked, ASK the user to close it), log in, press **F12**: - -- Equipped gear shows as icons in the correct doll slots; empty slots are transparent. -- Drag a wieldable item from the pack onto a matching slot → it wields instantly (icon appears) and survives the server echo; an invalid slot shows red and rejects. -- Drag an equipped item from a slot to the pack → it unwields. - -While testing, run WireMCP to confirm `GetAndWieldItem 0x001A` goes out on a wield and whether `0x00A0` comes back on a rejected wield (divergence row 3). - -## Post-gate (after visual confirmation) - -- Update `claude-memory/project_d2b_retail_ui.md` with the Slice 1 SHIPPED entry (key facts + DO-NOT-RETRY). -- Update `docs/plans/2026-04-11-roadmap.md` (Sub-phase C Slice 1 shipped). -- Note in `docs/ISSUES.md` any gate-verify follow-ups (the AP rows). diff --git a/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md b/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md deleted file mode 100644 index b06406d7..00000000 --- a/docs/superpowers/plans/2026-06-22-retail-teleport-residency-tunnel.md +++ /dev/null @@ -1,615 +0,0 @@ -# Retail Teleport — Priority Residency + Fade Cover — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make a teleport materialize the player onto a resident, grounded destination within ~1 s (was 10–14 s) with no movement desync, covered by a retail fade — fixing "long transition," "dropped at the wrong position," and "stops at the portal" in one coherent change. - -**Architecture:** The dormant `TeleportAnimSequencer` (TAS) becomes the transit *driver*: it holds at `Tunnel` until `worldReady` (the destination landblock is resident), fires `Place` (materialize) and `FireLoginComplete`, and outputs a `FadeAlpha` for a fullscreen cover. `worldReady` flips fast because `StreamingController` **priority-applies** the player's destination landblock ahead of the 4/frame budget. `PortalSpace` stays set for the whole transit, so no resolve runs against the empty world; a cell-march hardening guarantees the outbound cell frame can't corrupt even if one does. - -**Tech Stack:** C# .NET 10, Silk.NET OpenGL, xUnit. Spec: `docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md`. - ---- - -## Design decisions locked in (read before starting) - -1. **TAS is the single transit driver.** The existing `TeleportArrivalController` machine (BeginArrival/Tick/place/600-frame timeout) is **retired as the driver**; its readiness *rules* (`TeleportArrivalRules.Decide` + the `TeleportArrivalReadiness` triplet) are **kept** and reused to compute `worldReady`. The TAS's `Place` event replaces `PlaceTeleportArrival`-on-readiness, and `MaxContinue` (5 s) replaces the 600-frame timeout safety net. -2. **`PortalSpace` is the transit suppression and is owned by the TAS lifecycle** — set when the TAS begins, cleared at `FireLoginComplete` (end of `WorldFadeIn`). This is what stops the empty-world resolve: input + outbound movement are suppressed until the world is resident and the cover lifts. -3. **`worldReady` = the player's destination landblock terrain is registered in physics** (outdoor) **+ the EnvCell struct is cached** (indoor). Use the **canonical `EncodeLandblockId` (low 16 = 0xFFFF) key** — never the `& 0xFFFF0000` raw key (the `c880973` bug). -4. **Priority-apply buffers the overflow.** `DrainAndApply`, when a priority id is set, drains enough of the outbox to find it, applies the priority completion **first**, applies up to `MaxCompletionsPerFrame` others, and **holds the remainder in a local buffer** for subsequent frames (no GPU spike, no lost completions). - ---- - -## File structure - -- **Modify** `src/AcDream.App/Streaming/StreamingController.cs` — priority-apply + overflow buffer (Task 1). -- **Modify** `src/AcDream.Core/Physics/PhysicsEngine.cs` — add `IsLandblockTerrainResident(uint)` query (Task 2). -- **Modify** `src/AcDream.App/Rendering/GameWindow.cs` — TAS wiring: `worldReady` computation, drive `TeleportAnimSequencer`, route `Place`/`FireLoginComplete`, set priority id, own `PortalSpace` via TAS, draw fade (Tasks 2–4). -- **Create** `src/AcDream.App/Rendering/FadeOverlay.cs` — fullscreen alpha-quad renderer (Task 4). -- **Modify** the swept-transition cell-march in `src/AcDream.Core/Physics/` (`Transition`/`CellTransit`) — preserve landblock id when no resident landblock (Task 5). -- **Modify** `docs/architecture/retail-divergence-register.md` — retire/add rows (Tasks 3, 4). -- **Tests:** `tests/AcDream.Core.Tests/Streaming/`, `tests/AcDream.Core.Tests/Physics/`, `tests/AcDream.App.Tests/World/`. - ---- - -## Task 0: Baseline green - -- [ ] **Step 1: Confirm build + tests green at the current tree** (the `tp-probe` is present and harmless). - -Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo` -Expected: `Build succeeded. 0 Error(s)`. - -Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --nologo` -Expected: all pass. - ---- - -## Task 1: Priority-apply in StreamingController - -**Files:** -- Modify: `src/AcDream.App/Streaming/StreamingController.cs` -- Test: `tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs` (create) - -The contract: when `PriorityLandblockId` is set, `DrainAndApply` applies that landblock's `Loaded`/`Promoted` completion **this frame** even if it sits past the `MaxCompletionsPerFrame` position in the outbox, applying up to `MaxCompletionsPerFrame` other completions and buffering any remainder for later frames. - -- [ ] **Step 1: Write the failing test** - -```csharp -using System.Collections.Generic; -using AcDream.App.Streaming; -using AcDream.Core.Terrain; -using AcDream.Core.World; -using Xunit; - -namespace AcDream.Core.Tests.Streaming; - -public class StreamingControllerPriorityApplyTests -{ - private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId) - => new(canonicalId, LandblockStreamTier.Near, - new LoadedLandblock(canonicalId, default!, System.Array.Empty()), - new LandblockMeshData()); - - [Fact] - public void PriorityLandblock_isApplied_eventWhenBeyondPerFrameCap() - { - // Outbox order: 4 fillers, then the priority LB at position 5. - uint priority = StreamingRegion.EncodeLandblockId(169, 180); // 0xA9B4FFFF - var outbox = new Queue(new LandblockStreamResult[] - { - LoadedOf(StreamingRegion.EncodeLandblockId(0, 0)), - LoadedOf(StreamingRegion.EncodeLandblockId(0, 1)), - LoadedOf(StreamingRegion.EncodeLandblockId(0, 2)), - LoadedOf(StreamingRegion.EncodeLandblockId(0, 3)), - LoadedOf(priority), - }); - - var applied = new List(); - var state = new GpuWorldState(); - var ctrl = new StreamingController( - enqueueLoad: (_, _) => { }, - enqueueUnload: _ => { }, - drainCompletions: max => - { - var batch = new List(); - while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); - return batch; - }, - applyTerrain: (lb, _) => applied.Add(lb.LandblockId), - state: state, nearRadius: 4, farRadius: 12) - { MaxCompletionsPerFrame = 4 }; - - ctrl.PriorityLandblockId = priority; - - // One observer tick at the destination center. - ctrl.Tick(169, 180); - - Assert.Contains(priority, applied); // priority applied THIS tick - Assert.True(applied.Count <= 5); // did not flush the whole outbox blindly - } -} -``` - -- [ ] **Step 2: Run it; verify it fails** - -Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter PriorityLandblock_isApplied_eventWhenBeyondPerFrameCap` -Expected: FAIL — `StreamingController` has no `PriorityLandblockId`. - -- [ ] **Step 3: Implement `PriorityLandblockId` + buffered priority drain** - -In `StreamingController.cs`, add a field + property and a local overflow buffer, and rewrite `DrainAndApply`: - -```csharp - /// - /// When set (the active teleport destination, canonical 0xFFFF key), DrainAndApply - /// applies this landblock's completion ahead of the per-frame cap so the player - /// materializes as soon as the worker finishes it — independent of the post-arrival - /// CreateObject flood. Cleared by the caller once the player has materialized. - /// - public uint PriorityLandblockId { get; set; } - - // Completions drained while hunting for the priority id but not yet applied - // (held to honor the per-frame GPU budget instead of flushing all at once). - private readonly List _deferredApply = new(); - - private void DrainAndApply() - { - // Apply any completions deferred from a prior priority-hunt first. - int budget = MaxCompletionsPerFrame; - int i = 0; - for (; i < _deferredApply.Count && budget > 0; i++, budget--) - ApplyResult(_deferredApply[i]); - if (i > 0) _deferredApply.RemoveRange(0, i); - - if (PriorityLandblockId != 0u) - { - // Hunt the outbox for the priority completion. Drain in chunks; apply the - // priority immediately when found; hold non-priority drained items in the - // deferred buffer (applied above, over subsequent frames). - bool found = false; - for (int guard = 0; guard < 64 && !found; guard++) - { - var chunk = _drainCompletions(MaxCompletionsPerFrame); - if (chunk.Count == 0) break; - foreach (var r in chunk) - { - if (!found && ResultLandblockId(r) == PriorityLandblockId) - { - ApplyResult(r); - found = true; - } - else - { - _deferredApply.Add(r); - } - } - } - if (found) return; // priority handled; deferred items drain next frames - // not found this frame — fall through and apply the deferred budget normally - } - - var drained = _drainCompletions(budget); - foreach (var result in drained) - ApplyResult(result); - } - - private static uint ResultLandblockId(LandblockStreamResult r) => r switch - { - LandblockStreamResult.Loaded l => l.Landblock.LandblockId, - LandblockStreamResult.Promoted p => p.LandblockId, - LandblockStreamResult.Unloaded u => u.LandblockId, - LandblockStreamResult.Failed f => f.LandblockId, - _ => 0u, - }; - - private void ApplyResult(LandblockStreamResult result) - { - switch (result) - { - case LandblockStreamResult.Loaded loaded: - _applyTerrain(loaded.Landblock, loaded.MeshData); - _state.AddLandblock(loaded.Landblock); - _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); - break; - case LandblockStreamResult.Promoted promoted: - _applyTerrain(promoted.Landblock, promoted.MeshData); - _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); - _onLandblockLoaded?.Invoke(promoted.LandblockId); - break; - case LandblockStreamResult.Unloaded unloaded: - _state.RemoveLandblock(unloaded.LandblockId); - _removeTerrain?.Invoke(unloaded.LandblockId); - break; - case LandblockStreamResult.Failed failed: - Console.WriteLine($"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}"); - break; - case LandblockStreamResult.WorkerCrashed crashed: - Console.WriteLine($"streaming: worker CRASHED: {crashed.Error}"); - break; - } - } -``` - -(Delete the old inline `switch` body of `DrainAndApply`; it is now `ApplyResult`.) - -- [ ] **Step 4: Run the test; verify it passes** - -Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter StreamingControllerPriorityApply` -Expected: PASS. Also run the existing `StreamingController*` tests — all green. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/Streaming/StreamingController.cs tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs -git commit -m "feat(teleport A): StreamingController priority-apply for the teleport destination LB" -``` - ---- - -## Task 2: Residency query + worldReady - -**Files:** -- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs` (add `IsLandblockTerrainResident`) -- Test: `tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs` (create) - -- [ ] **Step 1: Write the failing test** - -```csharp -using System; -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -public class PhysicsEngineResidencyTests -{ - [Fact] - public void IsLandblockTerrainResident_trueOnlyAfterAddLandblock() - { - var eng = new PhysicsEngine(); - // canonical id with the 0xFFFF terminator, matching the streaming key. - uint canonical = 0xA9B4FFFFu; - uint lbId = canonical & 0xFFFF0000u; // physics stores by the AddLandblock id - - Assert.False(eng.IsLandblockTerrainResident(canonical)); - - eng.AddLandblock(lbId, terrain: null!, cells: Array.Empty(), - portals: Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); - - Assert.True(eng.IsLandblockTerrainResident(canonical)); - } -} -``` - -- [ ] **Step 2: Run it; verify it fails** (no such method). - -Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter IsLandblockTerrainResident_trueOnlyAfterAddLandblock` -Expected: FAIL. - -- [ ] **Step 3: Implement the query** (in `PhysicsEngine.cs`, next to `LandblockCount`) - -`AddLandblock` keys `_landblocks` by the id it is given. Streaming applies terrain with `loaded.Landblock.LandblockId`, which is the canonical `0xFFFF` form; component A's `_state.AddLandblock` stores the same. Physics is populated from `ApplyLoadedTerrainLocked` with `lb.LandblockId` (canonical). So accept either form and compare on the high 16 bits: - -```csharp - /// - /// True once the landblock covering has had its - /// terrain + cells registered via . Accepts a canonical - /// (0xFFFF) id, a cell-resolved id, or a bare landblock id — compares on the high 16 - /// bits. This is the teleport "worldReady" gate (the destination is grounded). - /// - public bool IsLandblockTerrainResident(uint cellOrLandblockId) - { - uint prefix = cellOrLandblockId & 0xFFFF0000u; - foreach (var key in _landblocks.Keys) - if ((key & 0xFFFF0000u) == prefix) return true; - return false; - } -``` - -- [ ] **Step 4: Run the test; verify it passes.** - -Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter IsLandblockTerrainResident` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs -git commit -m "feat(teleport B): PhysicsEngine.IsLandblockTerrainResident worldReady query" -``` - ---- - -## Task 3: Drive the TAS as the transit driver (wiring) - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` -- Modify: `docs/architecture/retail-divergence-register.md` -- Test: `tests/AcDream.App.Tests/World/TeleportWorldReadyTests.cs` (create) — unit-test the pure `worldReady` decision; the GameWindow wiring is verified by the live probe in Task 6. - -This task replaces the `TeleportArrivalController`-driven flow with the TAS. It is integration-heavy; keep edits surgical and reuse the existing readiness triplet. - -- [ ] **Step 1: Add fields next to the existing `_teleportArrival`** - -```csharp - private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new(); - private System.Numerics.Vector3 _pendingTeleportPos; - private uint _pendingTeleportCell; - private bool _teleportInProgress; -``` - -- [ ] **Step 2: `OnTeleportStarted` begins the TAS instead of only freezing input** - -Replace the body of `OnTeleportStarted` (`GameWindow.cs:5579-5585`): - -```csharp - private void OnTeleportStarted(uint sequence) - { - if (_playerController is not null) - _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; - _teleportInProgress = true; - _teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal); - Console.WriteLine($"live: teleport started (seq={sequence})"); - } -``` - -- [ ] **Step 3: In the teleport branch of `OnLivePositionUpdated`, record the pending placement instead of calling `BeginArrival`** - -Replace the `EnsureTeleportArrivalController(); _pendingTeleportRot = rot; _teleportArrival!.BeginArrival(...)` block (`GameWindow.cs:5472-5474`, plus the `tp-probe AIM` line stays) with: - -```csharp - _pendingTeleportRot = rot; - _pendingTeleportPos = newWorldPos; - _pendingTeleportCell = p.LandblockId; - if (_streamingController is not null) - _streamingController.PriorityLandblockId = - AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY); - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( - "AIM", p.LandblockId, - $"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}"); -``` - -- [ ] **Step 4: Tick the TAS each frame and route its events** — in the per-frame update, right after `_streamingController.Tick(...)` (around `GameWindow.cs:7531`): - -```csharp - if (_teleportInProgress) - { - bool worldReady = TeleportWorldReady(_pendingTeleportCell); - var (snap, evts) = _teleportAnim.Tick((float)dt, worldReady); - _teleportFadeAlpha = snap.FadeAlpha; // consumed by the fade overlay (Task 4) - foreach (var e in evts) - { - switch (e) - { - case AcDream.Core.World.TeleportAnimEvent.Place: - PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, forced: false); - if (_streamingController is not null) - _streamingController.PriorityLandblockId = 0u; - break; - case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete: - if (_playerController is not null) - _playerController.State = AcDream.App.Input.PlayerState.InWorld; - _liveSession?.SendGameAction( - AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); - _teleportInProgress = false; - break; - case AcDream.Core.World.TeleportAnimEvent.PlayEnterSound: - case AcDream.Core.World.TeleportAnimEvent.EnterTunnel: - case AcDream.Core.World.TeleportAnimEvent.PlayExitSound: - break; // audio hooks deferred (sounds are polish) - } - } - } -``` - -Add the field `private float _teleportFadeAlpha;` near `_teleportAnim`. - -- [ ] **Step 5: Modify `PlaceTeleportArrival` to NOT flip InWorld or send LoginComplete** (the TAS now owns those at `FireLoginComplete`). Remove the `_playerController.State = InWorld;`, the `PLACED` is fine to keep, and remove the `SendGameAction(GameActionLoginComplete...)` line (`GameWindow.cs:5563-5569`). It keeps only: resolve, set `pe`/controller position, update cameras, log `PLACED` + `teleport complete`. - -- [ ] **Step 6: Add the pure `TeleportWorldReady` helper** (reuses the existing readiness triplet but gates outdoor on terrain residency) - -```csharp - /// worldReady for the TAS: the destination's collision landblock is resident. - /// Indoor → the EnvCell struct is cached (#135); outdoor → the terrain landblock is - /// registered (priority-applied). An impossible claim is treated as ready so the TAS - /// stops holding and the forced placement surfaces the failure. - private bool TeleportWorldReady(uint destCell) - { - if (IsSpawnClaimUnhydratable(destCell)) return true; - bool indoor = (destCell & 0xFFFFu) >= 0x0100u; - return indoor - ? _physicsEngine.IsSpawnCellReady(destCell) - : _physicsEngine.IsLandblockTerrainResident(destCell); - } -``` - -- [ ] **Step 7: Unit-test the readiness decision** (`tests/AcDream.App.Tests/World/TeleportWorldReadyTests.cs`) - -```csharp -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.App.Tests.World; - -public class TeleportWorldReadyTests -{ - [Fact] - public void Outdoor_notReady_untilTerrainResident() - { - var eng = new PhysicsEngine(); - uint outdoorCell = 0xA9B40019u; // low word < 0x0100 → outdoor - Assert.False(eng.IsLandblockTerrainResident(outdoorCell)); - eng.AddLandblock(outdoorCell & 0xFFFF0000u, null!, - System.Array.Empty(), System.Array.Empty(), 0, 0); - Assert.True(eng.IsLandblockTerrainResident(outdoorCell)); - } -} -``` - -(The indoor path is already covered by the existing `IsSpawnCellReady`/dungeon tests; this task only changes outdoor from place-immediately to hold-until-resident.) - -- [ ] **Step 8: Retire the `TeleportArrivalController` driver** — delete the `_teleportArrival` field, `EnsureTeleportArrivalController`, `TeleportArrivalReadiness`, and the now-unused `using`s/refs. Keep `TeleportArrivalRules`/`ArrivalReadiness` only if still referenced; otherwise delete `src/AcDream.App/World/TeleportArrivalController.cs` and its test `tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs`. Verify nothing else references them (`grep -rn TeleportArrivalController src tests`). - -- [ ] **Step 9: Divergence register — retire the place-immediately row.** In `docs/architecture/retail-divergence-register.md`, find the row describing "outdoor teleport places immediately because streaming doesn't progress during a hold" and delete it (the premise was measured false; the new transit-until-resident model is retail-faithful). If no explicit row exists, add a one-line note under the teleport section that the place-immediately divergence was retired by this change. - -- [ ] **Step 10: Build + test green** - -Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Debug --nologo` → `0 Error(s)`. -Run: `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj --nologo` and `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --nologo` → all pass. - -- [ ] **Step 11: Commit** - -```bash -git add -A -git commit -m "feat(teleport B/C): TAS drives transit; outdoor holds until terrain resident; retire TeleportArrivalController driver" -``` - ---- - -## Task 4: Fade overlay render - -**Files:** -- Create: `src/AcDream.App/Rendering/FadeOverlay.cs` -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (draw it in the 2D pass) -- Modify: `docs/architecture/retail-divergence-register.md` - -The 2D self-contained pass lives near `GameWindow.cs:9013` ("Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush"). The fade is a fullscreen black quad whose alpha is `_teleportFadeAlpha`, drawn AFTER the world and UI so it covers everything. - -- [ ] **Step 1: Implement `FadeOverlay`** — a minimal self-contained GL object: a unit-quad VAO + a trivial shader that outputs `vec4(0,0,0, uAlpha)` in clip space (no projection needed; positions are full-screen NDC `[-1,1]`). Blend `SRC_ALPHA, ONE_MINUS_SRC_ALPHA`, depth test off. Follow the existing self-contained-GL-state pattern (set every state it uses; see `memory/feedback_render_self_contained_gl_state.md`). Expose `Draw(float alpha)` that early-returns when `alpha <= 0`. - -```csharp -// src/AcDream.App/Rendering/FadeOverlay.cs -using System; -using Silk.NET.OpenGL; - -namespace AcDream.App.Rendering; - -/// Fullscreen black quad at a given alpha — the teleport fade cover (spec C). -/// Self-contained GL state; draws in NDC so it needs no view/projection. -public sealed class FadeOverlay : IDisposable -{ - private readonly GL _gl; - private readonly uint _vao, _vbo, _prog, _uAlphaLoc; - - public FadeOverlay(GL gl) - { - _gl = gl; - // 1) compile the trivial shader (vert: gl_Position = vec4(aPos,0,1); frag: FragColor = vec4(0,0,0,uAlpha)) - // 2) upload a 2-triangle fullscreen quad in NDC to _vbo/_vao - // 3) cache _uAlphaLoc = glGetUniformLocation(_prog, "uAlpha") - // (full GL boilerplate — mirror an existing minimal renderer, e.g. the terrain-atlas/test quads) - throw new NotImplementedException("fill GL boilerplate during impl"); - } - - public void Draw(float alpha) - { - if (alpha <= 0f) return; - _gl.Disable(EnableCap.DepthTest); - _gl.Enable(EnableCap.Blend); - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - _gl.UseProgram(_prog); - _gl.Uniform1(_uAlphaLoc, Math.Clamp(alpha, 0f, 1f)); - _gl.BindVertexArray(_vao); - _gl.DrawArrays(PrimitiveType.Triangles, 0, 6); - _gl.BindVertexArray(0); - } - - public void Dispose() - { - _gl.DeleteVertexArray(_vao); - _gl.DeleteBuffer(_vbo); - _gl.DeleteProgram(_prog); - } -} -``` - -(The ctor GL boilerplate — shader compile + quad upload — is the only `NotImplementedException` to resolve; copy the shape from the smallest existing self-contained renderer in `src/AcDream.App/Rendering/`. There is no logic to design, only GL plumbing.) - -- [ ] **Step 2: Construct it once** where other render objects are built (near the `TerrainAtlas.Build` / shader init, ~`GameWindow.cs:1719`): `_fadeOverlay = new FadeOverlay(_gl);` + the field `private FadeOverlay? _fadeOverlay;` and dispose it in `OnClosing`. - -- [ ] **Step 3: Draw it last in the 2D pass** — at the end of the self-contained 2D pass (after `UiHost.Draw`/`TextRenderer.Flush`, ~`GameWindow.cs:9013+`): - -```csharp - _fadeOverlay?.Draw(_teleportFadeAlpha); -``` - -- [ ] **Step 4: Divergence register — add the fade-vs-swirl row.** Add a row: acdream covers the teleport transit with a black fade (`FadeOverlay` driven by `TeleportAnimSequencer.ComputeFadeAlpha`) instead of retail's 3D portal-tunnel swirl (`gmSmartBoxUI` tunnel render). Risk: visual-only (no swirl); retire by porting the tunnel. Sibling to AP-49. - -- [ ] **Step 5: Build green + visual smoke** — `dotnet build ... -c Debug`. (Visual confirmation is the user's; see Task 6.) - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "feat(teleport C): fullscreen fade cover driven by TeleportAnimSequencer" -``` - ---- - -## Task 5: Cell-march landblock-id hardening - -**Files:** -- Modify: the swept-transition cell-march in `src/AcDream.Core/Physics/` (the path that finalizes `result.CellId` for a moving outdoor sphere — `Transition`/`CellTransit.AddAllOutsideCells` / `LandDefs.adjust_to_outside`) -- Test: `tests/AcDream.Core.Tests/Physics/CellMarchLandblockPreservationTests.cs` (create) - -The bug: when the player moves and the swept position is over **no resident landblock**, the cell-march emits a cell id with the landblock-X byte zeroed (`0x00B4…` from a `0xA9B4…` seed — observed live, ACE `MOVEMENT SPEED`). With the transit model the player never moves against an empty world on the normal path, but this is the defense-in-depth fix (also clips the #145 edge residual). - -- [ ] **Step 1: Write the failing test** — drive a swept resolve from a seeded outdoor cell where NO landblock is registered, asserting the returned cell preserves the seed's `lbX`/`lbY` (never `lbX == 0`). - -```csharp -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -public class CellMarchLandblockPreservationTests -{ - [Fact] - public void SweptResolve_overNoResidentLandblock_preservesSeedLandblock() - { - var eng = new PhysicsEngine(); // no AddLandblock → empty world - uint seedCell = 0xA9B40019u; // outdoor cell in landblock 0xA9B4 - var fromLocal = new Vector3(84f, 7.1f, 94.005f); - var delta = new Vector3(2f, 0f, 0f); // a small step east - - var result = eng.Resolve(fromLocal, seedCell, delta, stepUpHeight: 1.0f); - - Assert.Equal(seedCell & 0xFFFF0000u, result.CellId & 0xFFFF0000u); // lbX/lbY preserved - Assert.NotEqual(0u, (result.CellId >> 24) & 0xFFu); // lbX never zeroed - } -} -``` - -- [ ] **Step 2: Run it; confirm it fails** (reproduces `lbX == 0`). - -Run: `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --filter SweptResolve_overNoResidentLandblock_preservesSeedLandblock` -Expected: FAIL — `result.CellId` high byte is 0 (or the prefix differs). - -- [ ] **Step 3: Locate the zeroing site.** It is NOT `ResolveCellId` (it returns the fallback unchanged, `PhysicsEngine.cs:330-351`). Trace the swept path from `PhysicsEngine.Resolve` → `Transition`/`SpherePath` → the outdoor cell update (`CellTransit.AddAllOutsideCells` / `LandDefs.adjust_to_outside`, which works in the GLOBAL landcell grid and converts back to a landblock-keyed id). The zeroing is in that global→landblock id reconstruction when the grid lookup finds no resident block. Add a guard: when the swept position resolves to no resident landblock, **return the entering sphere's current cell id** (preserve `lbX`/`lbY`) rather than a reconstructed `lbX=0` id — mirroring `ResolveCellId`'s fallback-preserve behavior and retail's `GotoLostCell`-keeps-last-cell shape. - -- [ ] **Step 4: Run the test; verify it passes.** Then run the full physics suite — `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj --nologo` — to confirm no regression in the swept-transition / cellar / doorway tests. - -- [ ] **Step 5: Divergence register** — if the fix introduces any deviation from retail's lost-cell machinery (we have no `GotoLostCell`), add/keep the existing note; otherwise no row needed (this restores correctness). - -- [ ] **Step 6: Commit** - -```bash -git add -A -git commit -m "fix(teleport D): cell-march preserves seed landblock id when no resident LB (no more lbX=0 outbound)" -``` - ---- - -## Task 6: Verify, then strip/promote the probe - -**Files:** `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`, `src/AcDream.App/Rendering/GameWindow.cs`, `src/AcDream.App/Streaming/LandblockStreamer.cs`, `docs/ISSUES.md`. - -- [ ] **Step 1: Full build + test green** — `dotnet build` + `dotnet test` for all test projects. - -- [ ] **Step 2: Live acceptance (user-driven launch, probe ON).** Launch with `ACDREAM_PROBE_TELEPORT=1` and teleport into a town and a dungeon. Confirm in the log: - - the destination-LB `APPLY` lands within ~1–2 frames of its `BUILD` (was +10–14 s); - - NO `00B4…` / `MOVEMENT SPEED` / `failed transition` lines in the ACE log after a teleport; - - visually: a faded transition that pops out onto real, grounded terrain — no run-in-place, no desync; - - the death→lifestone building keeps its collision. - - **This is the visual gate — stop and get the user's confirmation before Step 3.** - -- [ ] **Step 3: Strip (or promote) the `tp-probe`.** If not promoted: remove `ProbeTeleportEnabled` + `LogTeleport` from `PhysicsDiagnostics.cs` (and `ResetForTest`), the `AIM`/`PLACED`/`BUILD`/`APPLY`/`ENQ` call sites in `GameWindow.cs` + `LandblockStreamer.cs`, and the `waited/held` branch in `BuildLandblockForStreaming`. Build + test green. - -- [ ] **Step 4: Update ISSUES + roadmap.** Move #138 (teleport-OUT gap) to Recently closed with the commit SHAs; add the deferred follow-ups: (a) CreateObject-flood timeslicing for post-materialization FPS sag (measure in Release first), (b) authentic 3D portal-swirl tunnel. Update the milestones "currently working toward" line if M1.5 advances. - -- [ ] **Step 5: Commit** - -```bash -git add -A -git commit -m "chore(teleport): strip tp-probe; close #138; file flood-timeslice + swirl follow-ups" -``` - ---- - -## Self-review notes - -- **Spec coverage:** A→Task 1; B→Tasks 2–3; C→Tasks 3–4; D→Task 5; deferrals + register + probe→Tasks 3/4/6. All §1–§9 spec items map to a task. -- **Type consistency:** `IsLandblockTerrainResident` (Tasks 2,3,5-test), `PriorityLandblockId` (Tasks 1,3), `TeleportWorldReady` (Task 3), `_teleportFadeAlpha`/`FadeOverlay.Draw` (Tasks 3,4), `TeleportAnimEvent.Place`/`FireLoginComplete` (Task 3, from the existing TAS) are used consistently. -- **Two impl-time discoveries (not placeholders — test-defined):** the `FadeOverlay` ctor GL boilerplate (Task 4 Step 1) and the swept cell-march zeroing site (Task 5 Step 3). Each has a failing test that defines done. -- **Risk:** Task 3 (retiring `TeleportArrivalController`) is the largest edit; the `grep -rn` in Step 8 guards against dangling refs. Indoor dungeon login (#135) shares `IsSpawnCellReady` and is unchanged by the worldReady mapping — re-verify a dungeon login in Task 6. diff --git a/docs/superpowers/plans/2026-06-23-d2b-paperdoll-slice2.md b/docs/superpowers/plans/2026-06-23-d2b-paperdoll-slice2.md deleted file mode 100644 index 12c04dd0..00000000 --- a/docs/superpowers/plans/2026-06-23-d2b-paperdoll-slice2.md +++ /dev/null @@ -1,671 +0,0 @@ -# D.2b Sub-phase C Slice 2 — Paperdoll Doll + Slots Toggle — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -> -> **⚠ Execution split (project rule — CLAUDE.md "Do not integrate via subagent unless the subagent has full context"):** Tasks 1-4 are isolated/testable → safe for fresh subagents. **Tasks 5-7 are GL/animation/GameWindow integration → do INLINE** (the session that has the render-pipeline context), build **static-doll-first then animate**, verify at build + screenshot/visual-gate. Task 8 is wrap-up. - -**Goal:** Render the local player as a live 3-D "doll" inside the inventory paperdoll panel and wire the "Slots" button to toggle between doll-view (doll + 12 non-armor slots) and slot-view (9 armor slots). - -**Architecture:** Render-to-texture — a `PaperdollViewportRenderer` draws a re-dressed clone of the player (a dedicated `WorldEntity`) through the existing `WbDrawDispatcher` into an off-screen `ManagedGLFramebuffer` with a fixed doll camera + one distant light, inside a `GLStateScope`; the new `UiViewport` widget (dat Type `0xD`) blits that texture as a normal 2-D sprite so it lands in correct painter order. The shipped `PaperdollController` is extended (not rewritten) with the armor/non-armor partition + the toggle. - -**Tech Stack:** C# .NET 10, Silk.NET OpenGL, the acdream `AcDream.App.UI` retained-mode toolkit + `AcDream.App.Rendering.Wb` mesh pipeline. - -**Spec:** `docs/superpowers/specs/2026-06-23-d2b-paperdoll-slice2-design.md`. Read it first. - -**Reference call sites (verified this session — line numbers drift, grep the symbol):** -- World single-entity draw to mirror: `GameWindow.cs:8756` `_wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum, neverCullLandblockId: playerLb, visibleCellIds: null, animatedEntityIds: animatedIds)`. -- `animatedIds` built from `_animatedEntities.Keys`: `GameWindow.cs:8369-8375`. -- Spawn animation idle setup (`SetCycle`): `GameWindow.cs:3514-3588`. `MotionCommand.Ready = 0x41000003u` (`AcDream.Core.Physics.MotionInterpreter`). -- `WorldEntity` build template (palette/part overrides): `GameWindow.cs:3390-3431`. -- Player data: `_entitiesByServerGuid[_playerServerGuid]` (live entity), `_lastSpawnByGuid[_playerServerGuid]` (spawn). Player appearance update hook: `OnLiveAppearanceUpdated` `GameWindow.cs:3733`. -- Lighting UBO: `AcDream.Core.Lighting.SceneLightingUbo` + `AcDream.App.Rendering.SceneLightingUboBinding.Upload` (binding=1); world build at `GameWindow.cs:8306`. -- FBO: `AcDream.App.Rendering.Wb.ManagedGLFramebuffer` (ctor `(OpenGLGraphicsDevice, ITexture, w, h, hasDepthStencil)`). GL save/restore: `AcDream.App.Rendering.Wb.GLStateScope` (RAII). -- `WbDrawDispatcher.Draw` signature: `WbDrawDispatcher.cs:881`. `ICamera`: `ICamera.cs:5` (`View`, `Projection`, `Aspect`). -- `EntitySpawnAdapter.OnCreate(WorldEntity)`: `EntitySpawnAdapter.cs:100` (requires `ServerGuid != 0`). -- `UiButton.OnClick` (Action): `UiButton.cs:39`; wiring pattern `ChatWindowController.cs:283-289,322`. -- Window register: `_uiHost.RegisterWindow(WindowNames.Inventory, inventoryFrame)` `GameWindow.cs:2214`; `WindowNames.Inventory = "inventory"`. -- `DatWidgetFactory` Type switch: `DatWidgetFactory.cs:~63`. -- `PaperdollController.Bind` call site in GameWindow: grep `PaperdollController.Bind`. -- Camera convention to mirror: `src/AcDream.App/Rendering/ChaseCamera.cs` (`View`/`Projection` construction). - -**Decomp anchors:** spec §2. The 9 armor element-ids (the toggle set): `0x100005ab, 0x100005ac, 0x100005ad, 0x100005ae, 0x100005af, 0x100005b0, 0x100005b1, 0x100005b2, 0x100005b3`. Camera eye `(0.12, −2.4, 0.88)` → origin. Light `DISTANT_LIGHT` dir `(0.3, 1.9, 0.65)` intensity `2.0`. Heading `191.367905°`. - ---- - -## Task 1: Armor/non-armor partition + Slots toggle (`PaperdollController`) - -**Files:** -- Modify: `src/AcDream.App/UI/Layout/PaperdollController.cs` -- Test: `tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs` (create) - -This task is pure logic. The doll viewport is injected as a plain `UiElement?` (the concrete `UiViewport` arrives in Task 3/6); the toggle just flips `Visible`. - -- [ ] **Step 1: Write the failing test** - -```csharp -// tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using Xunit; - -public class PaperdollToggleTests -{ - // The 9 armor element-ids that gmPaperDollUI::ListenToElementMessage flips - // (decomp 175674-175706). Doll-view hides these; slot-view shows them. - private static readonly uint[] ArmorIds = - { - 0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu, - 0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u, - }; - - [Fact] - public void ArmorSlotIds_match_the_decomp_nine() - { - Assert.Equal(ArmorIds, PaperdollController.ArmorSlotElementIds); - } - - [Fact] - public void DollView_default_shows_doll_hides_armor() - { - var v = new PaperdollViewState(); - // default = doll-view (checkbox attr 0xe = 0) - Assert.False(v.SlotView); - Assert.True(v.DollVisible); - Assert.False(v.ArmorSlotsVisible); - } - - [Fact] - public void Toggle_to_slotview_hides_doll_shows_armor_and_back() - { - var v = new PaperdollViewState(); - v.Toggle(); - Assert.True(v.SlotView); - Assert.False(v.DollVisible); - Assert.True(v.ArmorSlotsVisible); - v.Toggle(); - Assert.False(v.SlotView); - Assert.True(v.DollVisible); - Assert.False(v.ArmorSlotsVisible); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter PaperdollToggleTests` -Expected: FAIL — `PaperdollController.ArmorSlotElementIds` and `PaperdollViewState` don't exist. - -- [ ] **Step 3: Add the partition + a testable view-state struct to `PaperdollController.cs`** - -Add the armor-id set as a public static (the test pins it). Add a small `PaperdollViewState` class that holds the boolean state + `Toggle()` (testable without UI). The controller will own a `PaperdollViewState` and project it onto the real widgets in `ApplyView`. - -```csharp -// In PaperdollController.cs, inside the AcDream.App.UI.Layout namespace. - -/// The 9 equip slots the Slots button toggles — the body-covering set that -/// gmPaperDollUI::PostInit starts hidden and ListenToElementMessage (idMessage==1, -/// 0x100005be) flips (decomp 175412-175508 / 175674-175706). NOTE: this is the exact -/// element-id set, NOT an EquipMask heuristic — it includes HeadWear/HandWear/FootWear -/// and excludes the shirt/pants clothing slots. -public static readonly uint[] ArmorSlotElementIds = -{ - 0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu, - 0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u, -}; - -/// Pure boolean view-state for the doll/slot toggle (testable without widgets). -/// Default = doll-view, mirroring retail SetAttribute_Bool(m_SlotCheckbox, 0xe, 0). -public sealed class PaperdollViewState -{ - public bool SlotView { get; private set; } // false = doll-view (default) - public bool DollVisible => !SlotView; // doll shown only in doll-view - public bool ArmorSlotsVisible => SlotView; // 9 armor slots shown only in slot-view - public void Toggle() => SlotView = !SlotView; -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter PaperdollToggleTests` -Expected: PASS. - -- [ ] **Step 5: Wire the state onto the real widgets (find armor slots + button + viewport, apply on click)** - -In `PaperdollController`, after the existing slot-binding loop in the constructor: collect the 9 armor-slot `UiItemList`s by element-id; find the Slots button (`0x100005BE`) and the doll viewport element (`0x100001D5`); store a `PaperdollViewState`; on button click, `Toggle()` then `ApplyView()`. `ApplyView()` sets `viewport.Visible = state.DollVisible` and each armor slot `.Visible = state.ArmorSlotsVisible`. Apply once at the end of construction so the initial state matches default (doll-view). - -```csharp -// Fields: -private readonly PaperdollViewState _viewState = new(); -private readonly List _armorSlots = new(); -private AcDream.App.UI.UiElement? _dollViewport; // set in ctor; the UiViewport (Task 3/6) - -// In the constructor, after the SlotMap binding loop: -foreach (var id in ArmorSlotElementIds) - if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor); - -_dollViewport = layout.FindElement(0x100001D5u); // the UiViewport widget (may be null pre-Task 6) - -if (layout.FindElement(0x100005BEu) is AcDream.App.UI.UiButton slotsBtn) - slotsBtn.OnClick = () => { _viewState.Toggle(); ApplyView(); }; - -ApplyView(); // initial: doll-view - -// Method: -private void ApplyView() -{ - if (_dollViewport is not null) _dollViewport.Visible = _viewState.DollVisible; - foreach (var a in _armorSlots) a.Visible = _viewState.ArmorSlotsVisible; -} -``` - -- [ ] **Step 6: Build + commit** - -```bash -dotnet build src/AcDream.App/AcDream.App.csproj -git add src/AcDream.App/UI/Layout/PaperdollController.cs tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs -git commit -m "feat(D.2b): Slice 2 — paperdoll armor/non-armor partition + Slots toggle state" -``` - ---- - -## Task 2: `DollCamera` — the fixed `ICamera` - -**Files:** -- Create: `src/AcDream.App/Rendering/DollCamera.cs` -- Test: `tests/AcDream.App.Tests/Rendering/DollCameraTests.cs` (create) - -Mirror `ChaseCamera`'s `View`/`Projection` construction convention (read `src/AcDream.App/Rendering/ChaseCamera.cs` first) so winding/culling match the world. Fixed eye/target from the decomp; up = AC `+Z`. - -- [ ] **Step 1: Write the failing test** (convention-independent: recover the eye from the inverse view; check aspect) - -```csharp -// tests/AcDream.App.Tests/Rendering/DollCameraTests.cs -using System.Numerics; -using AcDream.App.Rendering; -using Xunit; - -public class DollCameraTests -{ - [Fact] - public void Eye_position_is_the_retail_camera_offset() - { - var cam = new DollCamera { Aspect = 100f / 214f }; - Assert.True(Matrix4x4.Invert(cam.View, out var inv)); - var eye = inv.Translation; - Assert.Equal(0.12f, eye.X, 3); - Assert.Equal(-2.4f, eye.Y, 3); - Assert.Equal(0.88f, eye.Z, 3); - } - - [Fact] - public void Projection_is_finite_and_uses_aspect() - { - var cam = new DollCamera { Aspect = 1.5f }; - // A perspective projection has a non-zero [3][2] (w = -z) term and finite entries. - Assert.True(float.IsFinite(cam.Projection.M11)); - Assert.NotEqual(0f, cam.Projection.M34); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollCameraTests` -Expected: FAIL — `DollCamera` does not exist. - -- [ ] **Step 3: Implement `DollCamera`** (mirror ChaseCamera's matrix calls; substitute the fixed eye/target/up) - -```csharp -// src/AcDream.App/Rendering/DollCamera.cs -using System.Numerics; - -namespace AcDream.App.Rendering; - -/// Fixed camera for the paperdoll mini-scene. Eye/target from -/// gmPaperDollUI::PostInit (decomp 175521-175527): eye (0.12,-2.4,0.88) looking at the -/// origin, AC up = +Z. Uses the same View/Projection convention as ChaseCamera so the -/// doll's triangle winding + back-face culling match the world pass. Per-race camera -/// distance (UpdateForRace) is deferred polish. -public sealed class DollCamera : ICamera -{ - private static readonly Vector3 Eye = new(0.12f, -2.4f, 0.88f); - private static readonly Vector3 Target = Vector3.Zero; - private static readonly Vector3 Up = new(0f, 0f, 1f); - - // Match ChaseCamera: . - // Default FOV/near/far below are starting values — tune at the visual gate. - public float FovRadians { get; set; } = 0.6f; // ~34°; refine vs CreatureMode::SetFOVRad - public float Near { get; set; } = 0.1f; - public float Far { get; set; } = 50f; - public float Aspect { get; set; } = 1f; - - public Matrix4x4 View => Matrix4x4.CreateLookAt(Eye, Target, Up); - public Matrix4x4 Projection => - Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0 ? 1f : Aspect, Near, Far); -} -``` - -> **Note:** if `ChaseCamera` uses a left-handed / custom matrix (not `CreateLookAt`/`CreatePerspectiveFieldOfView`), copy ITS exact calls instead — the doll must share the world's winding so culling isn't inverted. The test only pins the eye + aspect, which both conventions satisfy. - -- [ ] **Step 4: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollCameraTests` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/Rendering/DollCamera.cs tests/AcDream.App.Tests/Rendering/DollCameraTests.cs -git commit -m "feat(D.2b): Slice 2 — DollCamera (fixed paperdoll ICamera)" -``` - ---- - -## Task 3: `UiViewport` widget + Type `0xD` registration - -**Files:** -- Create: `src/AcDream.App/UI/UiViewport.cs` -- Create: `src/AcDream.App/UI/IUiViewportRenderer.cs` -- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (the Type switch) -- Test: `tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs` (create) - -- [ ] **Step 1: Write the failing test** - -```csharp -// tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using Xunit; - -public class UiViewportFactoryTests -{ - [Fact] - public void Factory_builds_UiViewport_for_dat_type_0xD() - { - var info = TestElementInfo.WithType(0xD); // helper: minimal ElementInfo, Type=0xD - var widget = DatWidgetFactory.Create(info, _ => 0u); // resolve stub - Assert.IsType(widget); - Assert.True(widget.ConsumesDatChildren); - } -} -``` - -> If a `TestElementInfo` helper / the exact `DatWidgetFactory.Create` signature differs, read `DatWidgetFactory.cs` + an existing factory test (e.g. the UiMeter/UiScrollbar tests under `tests/AcDream.App.Tests/UI/Layout/`) and match their construction exactly. - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiViewportFactoryTests` -Expected: FAIL — no `0xD` case / no `UiViewport`. - -- [ ] **Step 3: Define the seam interface** - -```csharp -// src/AcDream.App/UI/IUiViewportRenderer.cs -namespace AcDream.App.UI; - -/// Renders a 3-D mini-scene into an off-screen buffer and returns the GL color- -/// texture handle. Called by the per-frame pre-UI hook (GameWindow), NOT from -/// UiViewport.OnDraw. Implemented by PaperdollViewportRenderer in AcDream.App.Rendering. -/// Intra-App decoupling so the UI widget doesn't depend on WbDrawDispatcher/GameWindow. -public interface IUiViewportRenderer -{ - /// Render at (width,height); return the color-texture GL handle, or 0 if nothing rendered. - uint Render(int width, int height); -} -``` - -- [ ] **Step 4: Implement the widget** - -```csharp -// src/AcDream.App/UI/UiViewport.cs -namespace AcDream.App.UI; - -/// Leaf widget for dat Type 0xD (UIElement_Viewport). Blits the texture produced -/// by its IUiViewportRenderer (run in the pre-UI hook) as a single sprite at its own rect. -/// The 3-D render does NOT happen here (OnDraw only has a 2-D context). -public sealed class UiViewport : UiElement -{ - public override bool ConsumesDatChildren => true; - - /// Set by GameWindow wiring (Task 6). When null, the widget draws nothing. - public IUiViewportRenderer? Renderer { get; set; } - - /// Last color-texture handle the pre-UI hook produced (0 = none). - public uint TextureHandle { get; set; } - - protected override void OnDraw(UiRenderContext ctx) - { - if (!Visible || TextureHandle == 0) return; - var p = ScreenPosition; - ctx.DrawSprite(TextureHandle, p.X, p.Y, Width, Height); - } -} -``` - -> Confirm the exact `UiElement` virtual + `ConsumesDatChildren`/`OnDraw`/`ScreenPosition`/`DrawSprite` signatures against `UiElement.cs`/`UiRenderContext.cs` and an existing leaf widget (`UiItemSlot`/`UiMeter`); match them. The RTT texture is a normal GL texture id — `DrawSprite` already takes a `uint texture` (see SSOT chrome-sprite path). - -- [ ] **Step 5: Register Type `0xD` in `DatWidgetFactory`** - -In the Type switch (`DatWidgetFactory.cs:~63`), add: `0xD => new UiViewport(),`. Match the surrounding arm style (some arms pass `info`/`resolve`; `UiViewport` needs neither for construction — it's wired post-build). - -- [ ] **Step 6: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiViewportFactoryTests` -Expected: PASS. - -- [ ] **Step 7: Build + commit** - -```bash -dotnet build src/AcDream.App/AcDream.App.csproj -git add src/AcDream.App/UI/UiViewport.cs src/AcDream.App/UI/IUiViewportRenderer.cs src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs -git commit -m "feat(D.2b): Slice 2 — UiViewport widget (dat Type 0xD) + IUiViewportRenderer seam" -``` - ---- - -## Task 4: Doll-entity builder (player Setup + ObjDesc → a `WorldEntity`) - -**Files:** -- Create: `src/AcDream.App/Rendering/DollEntityBuilder.cs` -- Test: `tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs` (create) - -Extract the palette/part-override mapping from `GameWindow.cs:3390-3431` into a reusable pure helper that turns a player's spawn-derived inputs into the doll `WorldEntity` (synthetic guid, scene-origin position, heading 191.37°). Tests feed plain inputs (no dats). - -- [ ] **Step 1: Read** `GameWindow.cs:3390-3431` (the `PaletteOverride` + `PartOverride[]` construction) and `WorldEntity.cs` (the record's settable members) so the builder's output matches what the dispatcher consumes. - -- [ ] **Step 2: Write the failing test** - -```csharp -// tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.Rendering; -using AcDream.Core.World; -using Xunit; - -public class DollEntityBuilderTests -{ - [Fact] - public void Builds_doll_entity_with_synthetic_guid_and_player_setup() - { - var meshRefs = new List(); // empty is fine for this mapping test - var doll = DollEntityBuilder.Build( - setupId: 0x0200_0001u, - meshRefs: meshRefs, - basePaletteId: 0x04000ABCu, - subPalettes: new (uint Id, uint Off, uint Len)[] { (0x0F00_0001u, 0, 8) }, - partOverrides: new (byte Part, uint GfxObj)[] { (2, 0x0100_0042u) }); - - Assert.Equal(0x0200_0001u, doll.SourceGfxObjOrSetupId); - Assert.NotEqual(0u, doll.ServerGuid); // synthetic non-zero (EntitySpawnAdapter requires it) - Assert.Single(doll.PartOverrides); - Assert.Equal((byte)2, doll.PartOverrides[0].PartIndex); - Assert.Equal(0x0100_0042u, doll.PartOverrides[0].GfxObjId); - Assert.NotNull(doll.PaletteOverride); - Assert.Equal(0x04000ABCu, doll.PaletteOverride!.BasePaletteId); - } - - [Fact] - public void Heading_faces_the_viewer() - { - var doll = DollEntityBuilder.Build(0x0200_0001u, new List(), null, null, null); - // 191.367905° about +Z → quaternion; just assert it's set (non-identity) + normalized. - Assert.True(System.MathF.Abs(doll.Rotation.LengthSquared() - 1f) < 1e-3f); - } -} -``` - -> Match `MeshRef`, `PaletteOverride` (ctor `(BasePaletteId, SubPaletteRange[])`), `PartOverride` (`(byte PartIndex, uint GfxObjId)`), and `WorldEntity`'s members to their real definitions (read `WorldEntity.cs`, `PaletteOverride.cs`, `PartOverride.cs`). Adjust the test's tuple shapes to whatever signature you give `Build`. - -- [ ] **Step 3: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollEntityBuilderTests` -Expected: FAIL — `DollEntityBuilder` does not exist. - -- [ ] **Step 4: Implement `DollEntityBuilder`** (mirror GameWindow.cs:3390-3431; add the fixed pose + synthetic guid) - -```csharp -// src/AcDream.App/Rendering/DollEntityBuilder.cs -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.World; - -namespace AcDream.App.Rendering; - -/// Builds the dedicated paperdoll WorldEntity (retail makeObject(player) clone): -/// the player's Setup + current ObjDesc, posed at the scene origin facing the viewer. -/// Mirrors the palette/part-override mapping at GameWindow.cs:3390-3431. The synthetic -/// guid keeps it distinct from the live player entity and lets EntitySpawnAdapter.OnCreate -/// accept it (ServerGuid != 0). -public static class DollEntityBuilder -{ - /// Reserved synthetic guid for the paperdoll clone (high, collision-free). - public const uint DollServerGuid = 0xDA11_D011u; - - // 191.367905° about +Z (retail set_heading), in radians. - private static readonly float HeadingRad = 191.367905f * (MathF.PI / 180f); - - public static WorldEntity Build( - uint setupId, - IReadOnlyList meshRefs, - uint? basePaletteId = null, - IReadOnlyList<(uint Id, uint Off, uint Len)>? subPalettes = null, - IReadOnlyList<(byte Part, uint GfxObj)>? partOverrides = null) - { - PaletteOverride? pal = null; - if (subPalettes is { Count: > 0 }) - { - var ranges = new PaletteOverride.SubPaletteRange[subPalettes.Count]; - for (int i = 0; i < subPalettes.Count; i++) - ranges[i] = new PaletteOverride.SubPaletteRange( - subPalettes[i].Id, subPalettes[i].Off, subPalettes[i].Len); - pal = new PaletteOverride(basePaletteId ?? 0, ranges); - } - - PartOverride[] parts = Array.Empty(); - if (partOverrides is { Count: > 0 }) - { - parts = new PartOverride[partOverrides.Count]; - for (int i = 0; i < partOverrides.Count; i++) - parts[i] = new PartOverride(partOverrides[i].Part, partOverrides[i].GfxObj); - } - - return new WorldEntity - { - Id = 0, // assigned by the caller if it needs a render id - ServerGuid = DollServerGuid, - SourceGfxObjOrSetupId = setupId, - Position = Vector3.Zero, // scene origin - Rotation = Quaternion.CreateFromAxisAngle(new Vector3(0, 0, 1), HeadingRad), - MeshRefs = meshRefs, - PaletteOverride = pal, - PartOverrides = parts, - ParentCellId = 0, - }; - } -} -``` - -> Adjust property names/types to `WorldEntity`'s real definition (it's `required`-init in GameWindow's usage — match exactly). If `MeshRefs` must be a concrete `List`/array, convert. - -- [ ] **Step 5: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollEntityBuilderTests` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/Rendering/DollEntityBuilder.cs tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs -git commit -m "feat(D.2b): Slice 2 — DollEntityBuilder (player Setup+ObjDesc -> doll WorldEntity)" -``` - ---- - -## Task 5: `PaperdollViewportRenderer` — the RTT pass (INLINE; static doll first) - -**Files:** -- Create: `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs` - -This is GL integration — **do inline**, build a **static doll first** (no live animation), verify with a screenshot, THEN Task 7 adds idle motion. Implements `IUiViewportRenderer`. - -**Responsibilities:** -1. Own a `ManagedGLFramebuffer` (color `ITexture` + depth) sized to the widget rect; lazily (re)create when `(w,h)` changes. Read how `ManagedGLFramebuffer` is constructed elsewhere (grep `new ManagedGLFramebuffer`) for the `OpenGLGraphicsDevice` + `ITexture` creation pattern; reuse it. -2. Hold the doll `WorldEntity` (from `DollEntityBuilder`, refreshed by Task 6) + its mesh-resolved `MeshRefs`, a `DollCamera`, and refs to `WbDrawDispatcher` + `SceneLightingUboBinding` (injected). -3. `Render(w,h)`: - -```csharp -public uint Render(int width, int height) -{ - if (_dollEntity is null || width <= 0 || height <= 0) return 0; - EnsureFramebuffer(width, height); // (re)create FBO on size change - _camera.Aspect = width / (float)height; - - using var _ = new GLStateScope(_gl); // RAII: restores ALL gl state on dispose - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, (uint)_fbo.NativeHandle.ToInt32()); - _gl.Viewport(0, 0, (uint)width, (uint)height); - _gl.Disable(EnableCap.ScissorTest); - _gl.ClearColor(0f, 0f, 0f, 0f); // transparent — window backdrop shows through - _gl.ClearDepth(1f); - _gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); - _gl.Enable(EnableCap.DepthTest); - _gl.DepthMask(true); - // cull/front-face: match the world object pass (read GameWindow.cs ~8098). The doll - // shares mesh winding, so use the SAME CullFace + FrontFace the world sets. - - UploadDollLight(); // overwrite binding=1 contents (safe — rebuilt next frame) - - _wbDrawDispatcher.Draw( - _camera, - new[] { (_dollLandblockId, _aabbMin, _aabbMax, - (IReadOnlyList)new[] { _dollEntity }, - (IReadOnlyDictionary?)_animatedById) }, - frustum: null, - neverCullLandblockId: _dollLandblockId, - visibleCellIds: null, - animatedEntityIds: _animatedEntityIds); // empty for static doll; {dollId} after Task 7 - - return _fboColorTextureHandle; -} -``` - -```csharp -private void UploadDollLight() -{ - var ubo = new AcDream.Core.Lighting.SceneLightingUbo(); - var dir = System.Numerics.Vector3.Normalize(new System.Numerics.Vector3(0.3f, 1.9f, 0.65f)); - ubo.Light0 = new AcDream.Core.Lighting.UboLight - { - PosAndKind = new System.Numerics.Vector4(0, 0, 0, 0), // kind 0 = directional - DirAndRange = new System.Numerics.Vector4(dir, 1e9f), // huge range = no cutoff - ColorAndIntensity = new System.Numerics.Vector4(1f, 1f, 1f, 2.0f), // white @ retail intensity 2.0 - ConeAngleEtc = System.Numerics.Vector4.Zero, - }; - ubo.CellAmbient = new System.Numerics.Vector4(0.35f, 0.35f, 0.35f, 1f); // 1 active light; ambient tunable - _sceneLightingUbo.Upload(ubo); -} -``` - -**Steps:** - -- [ ] **Step 1:** Read `new ManagedGLFramebuffer` usage + `OpenGLGraphicsDevice`/`ITexture` creation; the world object-pass GL state at `GameWindow.cs:~8098` (cull/front-face); confirm `WbDrawDispatcher.Draw`'s tuple element types. Confirm whether a Setup-backed entity renders WITHOUT an `animatedEntityIds` entry (static pose) — if the dispatcher needs an animation entry to pose parts, note it for Task 7 and use a minimal pose; otherwise static is fine. - - **⚠ Known interaction — the doll pass is a SECOND `Draw()` per frame.** `WbDrawDispatcher.cs:951-959` explicitly warns that its indoor-probe `IndoorProbeState` construction assumes "Draw() is called exactly once per frame" and "a second pass within the same frame needs revisiting." This only bites when an `ACDREAM_PROBE_*`/indoor diagnostic is active (not normal play), but verify: (a) the Tier-1 cache (#53) is keyed by entity+owning-landblock, so the doll's synthetic guid/landblock must NOT collide with any world entity/landblock (use a reserved `_dollLandblockId` unused by streaming, e.g. `0`); (b) the world Draw runs BEFORE the doll pass each frame and `_groups` is cleared at the top of every `Draw()` (`:926`), so the doll pass doesn't corrupt the world's next frame. If probes are on and you see doubled probe lines, that's expected/benign for this slice — note it, don't chase it. -- [ ] **Step 2:** Implement `PaperdollViewportRenderer` (ctor injects `GL`, `OpenGLGraphicsDevice`, `WbDrawDispatcher`, `SceneLightingUboBinding`, `DollCamera`; `EnsureFramebuffer`, `UploadDollLight`, `Render`, `SetDoll(WorldEntity, meshRefs)`, `Dispose`). `_dollLandblockId` = a synthetic constant (e.g. `0`); `_aabbMin/Max` = the doll entity's local bounds (or a generous fixed box). -- [ ] **Step 3:** Build: `dotnet build src/AcDream.App/AcDream.App.csproj` — green. -- [ ] **Step 4:** Commit (renderer compiles; wiring is Task 6): - -```bash -git add src/AcDream.App/Rendering/PaperdollViewportRenderer.cs -git commit -m "feat(D.2b): Slice 2 — PaperdollViewportRenderer RTT pass (static doll)" -``` - ---- - -## Task 6: GameWindow wiring + pre-UI hook + re-dress (INLINE) - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` - -**Steps:** - -- [ ] **Step 1 — construct the renderer + give the controller the viewport.** At the `PaperdollController.Bind` call site (grep `PaperdollController.Bind`): after binding, `FindElement(0x100001D5)` for the `UiViewport`; construct `PaperdollViewportRenderer` (inject `_gl`, the `OpenGLGraphicsDevice`, `_wbDrawDispatcher`, `_sceneLightingUbo`, a `new DollCamera()`); set `uiViewport.Renderer = renderer`; store the renderer in a field `_paperdollViewport`. (The controller already finds `0x100001D5` for its toggle in Task 1 — the same element; ensure both see it.) - -- [ ] **Step 2 — build the doll entity from the player + resolve its meshes.** Add `RefreshPaperdollDoll()`: read `_lastSpawnByGuid[_playerServerGuid]` (+ `_entitiesByServerGuid[_playerServerGuid]` for already-resolved `MeshRefs`); call `DollEntityBuilder.Build(...)`; resolve `MeshRefs` the same way the spawn path does (reuse the player entity's `MeshRefs` if present — cheapest); route the doll through `EntitySpawnAdapter.OnCreate(doll)` (so its meshes background-load + a per-instance `AnimatedEntityState` exists); `renderer.SetDoll(doll, meshRefs)`. Call it once when the inventory opens and on re-dress. - -- [ ] **Step 3 — the pre-UI hook.** Immediately before `_uiHost.Tick/Draw` (`GameWindow.cs:9009`), add: - -```csharp -// Paperdoll doll RTT pass — only when the inventory window is open AND in doll-view. -if (_options.RetailUi && _paperdollViewport is not null && _inventoryFrame is { Visible: true } - && _paperdollDollViewport is { Visible: true } uvp) // the UiViewport; Visible == doll-view -{ - if (_lastSpawnByGuid.ContainsKey(_playerServerGuid)) - { - if (_paperdollDollDirty) { RefreshPaperdollDoll(); _paperdollDollDirty = false; } - uvp.TextureHandle = _paperdollViewport.Render((int)uvp.Width, (int)uvp.Height); - } -} -``` - -> Capture `_inventoryFrame` (the registered window root) + `_paperdollDollViewport` (the `UiViewport`) as fields at the Bind site. `Visible` on the `UiViewport` is set by the controller's toggle (Task 1) — so the gate "doll-view" == `uvp.Visible`. - -- [ ] **Step 4 — re-dress hook.** In `OnLiveAppearanceUpdated` (`GameWindow.cs:3733`), when `update.Guid == _playerServerGuid`, set `_paperdollDollDirty = true` (rebuild lazily next doll pass). Also set it true on inventory-open. - -- [ ] **Step 5 — teardown.** In the logout/teleport `Clear()` path, dispose `_paperdollViewport` (FBO) + drop the doll's `EntitySpawnAdapter` state; null the fields so they rebuild on next login. - -- [ ] **Step 6 — build + launch + screenshot (visual checkpoint).** Build green. Launch (`dotnet run`, plain — do NOT kill a running client; if the rebuild is locked, ASK the user to close it). Open the inventory; confirm a (static) re-dressed doll renders in the paperdoll panel and the Slots button toggles doll ↔ armor-slots. - -- [ ] **Step 7 — commit.** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(D.2b): Slice 2 — wire paperdoll doll RTT pass + re-dress into GameWindow" -``` - ---- - -## Task 7: Idle animation (INLINE refinement) - -**Files:** -- Modify: `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`, `src/AcDream.App/Rendering/GameWindow.cs` - -Make the doll idle-animate, mirroring the spawn path. - -- [ ] **Step 1:** Read `GameWindow.cs:3514-3588` (the `SetCycle` + `_animatedEntities[entity.Id]` setup) and how `TickAnimations` (`GameWindow.cs:8114`) advances them. Decide the doll's tick owner: either (a) add the doll to `_animatedEntities` (free ticking, its Id flows into the world `animatedIds` harmlessly since it's not in `_worldState.LandblockEntries`), or (b) tick the doll sequencer in the doll pass. Prefer (a) — least new machinery. -- [ ] **Step 2:** In `RefreshPaperdollDoll`, after `OnCreate`, give the doll an `_animatedEntities[doll.Id]` entry with `sequencer.SetCycle(style, MotionCommand.Ready)` (style = the player's MotionTable `DefaultStyle`, or `0x8000003D` NonCombat — match the spawn path). Pass the doll's `Id` in `renderer`'s `_animatedEntityIds` set + the `_animatedById` dict so `WbDrawDispatcher.Draw` poses it. -- [ ] **Step 3:** Build + launch + screenshot: the doll idles (subtle breathing/sway). -- [ ] **Step 4:** Commit. - -```bash -git add src/AcDream.App/Rendering/PaperdollViewportRenderer.cs src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(D.2b): Slice 2 — paperdoll doll idle animation" -``` - ---- - -## Task 8: Wrap-up — divergence rows, SSOT, full suite, visual gate - -**Files:** -- Modify: `docs/architecture/retail-divergence-register.md` -- Modify: `claude-memory/project_d2b_retail_ui.md` (the D.2b SSOT) + `MEMORY.md` if needed -- Modify: `docs/plans/2026-04-11-roadmap.md` (mark Slice 2) - -- [ ] **Step 1:** Reword **AP-66** (empty-slot frame stays in both views — slots ring the doll, no overlay/transparent). Add rows: per-race camera deferred; idle-DID per-race deferred; `IUiViewportRenderer` seam in App.UI not Core (intentional adaptation, rationale = spec §4C); doll lighting = one hand-built distant-light UBO (approximation iff it differs from retail `CreatureMode` lighting). Gate-verify AP-62 (MissileAmmo) opportunistically. -- [ ] **Step 2:** Run the FULL suite (not filtered): `dotnet test` across Core / Core.Net / App / UI. All green. (PROCESS lesson: a filtered subset can hide a cross-file regression.) -- [ ] **Step 3:** Update the D.2b SSOT with a "SUB-PHASE C SLICE 2 SHIPPED" entry (the doll + toggle + RTT seam + the key DO-NOT-RETRY facts) once the visual gate passes. -- [ ] **Step 4 — VISUAL GATE (stop for the user):** the user compares to retail — doll renders the re-dressed local player (race/gender/gear; naked if bare), faces the viewer, idles; Slots toggles doll-view ↔ armor-slots; live re-dress on wield/unwield. **This is the acceptance test; do not declare done before it passes.** -- [ ] **Step 5:** Commit the docs. - -```bash -git add docs/architecture/retail-divergence-register.md claude-memory/project_d2b_retail_ui.md docs/plans/2026-04-11-roadmap.md -git commit -m "docs(D.2b): Slice 2 shipped — paperdoll doll + Slots toggle; divergence + SSOT" -``` - ---- - -## Self-review notes (author) - -- **Spec coverage:** §4A→T1, §4B→T3, §4C→T3+T5, §4D→T4+T6+T7, §4E→T6, §4F→T1-4 tests + T8 gate. §7 pin-items resolved: UiButton (T1), inventory-open (T6 `_inventoryFrame.Visible`), animation contract (T5 step1 + T7), lighting UBO (T5 `UploadDollLight`), camera (T2 mirrors ChaseCamera), idle motion (T7 `MotionCommand.Ready`). All covered. -- **Type consistency:** `PaperdollViewState`, `ArmorSlotElementIds`, `DollCamera`, `UiViewport`, `IUiViewportRenderer.Render(w,h)→uint`, `DollEntityBuilder.Build`, `PaperdollViewportRenderer.Render/SetDoll`, `DollServerGuid` used consistently across tasks. -- **Known soft spots (resolved inline, by design):** the exact `WbDrawDispatcher.Draw` tuple types, `WorldEntity` `required` members, `MeshRef`/`PaletteOverride` ctors, and `ChaseCamera`'s matrix convention are confirmed against source DURING Tasks 4-5 (each task's step 1 says "read X first") — they're "mirror this exact existing code" directives, not open requirements. GL output is verified at the visual gate (Tasks 6-7), which is the acceptance test per the spec. diff --git a/docs/superpowers/plans/2026-06-23-datlock-contention-fix.md b/docs/superpowers/plans/2026-06-23-datlock-contention-fix.md deleted file mode 100644 index 396107b7..00000000 --- a/docs/superpowers/plans/2026-06-23-datlock-contention-fix.md +++ /dev/null @@ -1,445 +0,0 @@ -# `_datLock` Contention Fix — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Remove the `_datLock` the update thread waits on during a landblock apply, by having the streaming worker pre-read the dats the apply needs into a bundle that rides on `LoadedLandblock`. - -**Architecture:** The worker already holds `_datLock` for its whole per-landblock build and already reads most of these dats. We package the parsed dat objects the apply consumes onto `LoadedLandblock` (which already carries dat objects). `ApplyLoadedTerrainLocked` then reads from the bundle instead of `_dats.Get`, makes zero `DatCollection` calls, and its `lock(_datLock)` is deleted. No `StreamingController`/delegate change — the bundle flows on the existing `LoadedLandblock`. - -**Tech Stack:** C# / .NET 10, `DatReaderWriter.DBObjs`, xUnit. - -**Design source:** `docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md` (A1). - -**Safety basis (verified):** `_datLock`'s only cross-thread job is serializing `DatCollection.Get`. Everything else the apply mutates is update-thread-only (`_physicsEngine`, `ShadowObjects`, `_envCellRenderer`, `_cellVisibility`, `_worldState`, `_buildingRegistries`) or already concurrent-safe (`PhysicsDataCache` is all `ConcurrentDictionary`). So once the apply makes no `Get` call, the lock is unnecessary around it. - -**The six `Get` sites in `ApplyLoadedTerrainLocked` to eliminate** (grep `_dats.Get<` / `_dats!.Get<` within `ApplyLoadedTerrainLocked`; lines drift, match by symbol): -1. `LandBlockInfo` — id `(LandblockId & 0xFFFF0000u) | 0xFFFEu` (cell count + buildings). -2. `EnvCell` — per cell id `firstCellId + offset`. -3. `Environment` — id `0x0D000000u | envCell.EnvironmentId`. -4. `Setup` — building shell, id `building.ModelId` (the `_dats!.Get` site in the `CacheBuilding` block). -5. `GfxObj` — per `meshRef.GfxObjId` in the BSP-cache loop. -6. `Setup` — entity-part setup in the ShadowObjects registration block. - -**Out of scope:** the H3 entity-leak / GPU heat-up, the invisible-char-on-portal bug, and variant A2 (moving the CPU build off-thread). The `[FRAME-DIAG]` apparatus stays in the tree until verification, then is stripped in a final commit. - ---- - -### Task 1: Define `PhysicsDatBundle` and attach it to `LoadedLandblock` - -**Files:** -- Create: `src/AcDream.Core/World/PhysicsDatBundle.cs` -- Modify: `src/AcDream.Core/World/LoadedLandblock.cs` -- Test: `tests/AcDream.Core.Tests/World/PhysicsDatBundleTests.cs` - -- [ ] **Step 1: Write the failing test** - -```csharp -// tests/AcDream.Core.Tests/World/PhysicsDatBundleTests.cs -using System.Collections.Generic; -using AcDream.Core.World; -using DatReaderWriter.DBObjs; -using Xunit; - -public class PhysicsDatBundleTests -{ - [Fact] - public void Empty_ReturnsNullsAndEmptyMaps() - { - var b = PhysicsDatBundle.Empty; - Assert.Null(b.Info); - Assert.Empty(b.EnvCells); - Assert.Empty(b.Environments); - Assert.Empty(b.Setups); - Assert.Empty(b.GfxObjs); - } - - [Fact] - public void LoadedLandblock_DefaultsBundleToNull_BackCompat() - { - var lb = new LoadedLandblock(0xA9B4FFFFu, new LandBlock(), - System.Array.Empty()); - Assert.Null(lb.PhysicsDats); - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter PhysicsDatBundleTests` -Expected: FAIL — `PhysicsDatBundle` / `PhysicsDats` do not exist (compile error). - -- [ ] **Step 3: Create the bundle type** - -```csharp -// src/AcDream.Core/World/PhysicsDatBundle.cs -using System.Collections.Generic; -using DatReaderWriter.DBObjs; - -namespace AcDream.Core.World; - -/// -/// The parsed dat objects ApplyLoadedTerrainLocked needs, pre-read by the -/// streaming worker under _datLock so the apply makes ZERO DatCollection -/// calls and the update thread never blocks on the worker's lock. Keyed by the -/// same dat id the apply would have passed to _dats.Get<T>. -/// -public sealed record PhysicsDatBundle( - LandBlockInfo? Info, - IReadOnlyDictionary EnvCells, - IReadOnlyDictionary Environments, - IReadOnlyDictionary Setups, - IReadOnlyDictionary GfxObjs) -{ - public static readonly PhysicsDatBundle Empty = new( - null, - new Dictionary(), - new Dictionary(), - new Dictionary(), - new Dictionary()); -} -``` - -- [ ] **Step 4: Add the optional field to `LoadedLandblock`** - -```csharp -// src/AcDream.Core/World/LoadedLandblock.cs -using DatReaderWriter.DBObjs; - -namespace AcDream.Core.World; - -public sealed record LoadedLandblock( - uint LandblockId, - LandBlock Heightmap, - IReadOnlyList Entities, - PhysicsDatBundle? PhysicsDats = null); -``` - -Trailing default keeps every existing 3-arg constructor call (tests, far-tier -early-out) compiling unchanged. - -- [ ] **Step 5: Run test to verify it passes** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter PhysicsDatBundleTests` -Expected: PASS (both facts). - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.Core/World/PhysicsDatBundle.cs src/AcDream.Core/World/LoadedLandblock.cs tests/AcDream.Core.Tests/World/PhysicsDatBundleTests.cs -git commit -m "feat(streaming): PhysicsDatBundle on LoadedLandblock (datLock fix scaffold)" -``` - ---- - -### Task 2: Gather the bundle on the worker (`BuildPhysicsDatBundle`) - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add `BuildPhysicsDatBundle`; call it in `BuildLandblockForStreamingLocked`) - -The gather MIRRORS the apply's six read sites exactly, reading from `_dats` -under the worker's existing `_datLock`. It only READS + fills maps — no build, -no registration. Some reads (entity GfxObj/Setup) duplicate reads the worker -already does for the mesh; that is fine (DatCollection caches; it is on the -worker, off the contended path). - -- [ ] **Step 1: Add the gather method** - -Add a private method to `GameWindow` (near `BuildLandblockForStreamingLocked`). -It is called from inside the worker build, which already holds `_datLock`. - -```csharp -// src/AcDream.App/Rendering/GameWindow.cs (new private method) -// Pre-reads (under the worker's _datLock) every dat ApplyLoadedTerrainLocked -// consumes, so the apply can run lock-free. MIRRORS the apply's read sites. -private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle( - uint landblockId, - System.Collections.Generic.IReadOnlyList entities) -{ - var envCells = new System.Collections.Generic.Dictionary(); - var environments = new System.Collections.Generic.Dictionary(); - var setups = new System.Collections.Generic.Dictionary(); - var gfxObjs = new System.Collections.Generic.Dictionary(); - - // (1) LandBlockInfo - var lbInfo = _dats!.Get( - (landblockId & 0xFFFF0000u) | 0xFFFEu); - - if (lbInfo is not null) - { - // (2)+(3) EnvCell + Environment, per cell - if (lbInfo.NumCells > 0) - { - uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u; - for (uint offset = 0; offset < lbInfo.NumCells; offset++) - { - uint envCellId = firstCellId + offset; - var envCell = _dats.Get(envCellId); - if (envCell is null) continue; - envCells[envCellId] = envCell; - if (envCell.EnvironmentId == 0) continue; - uint envId = 0x0D000000u | envCell.EnvironmentId; - if (!environments.ContainsKey(envId)) - { - var environment = _dats.Get(envId); - if (environment is not null) environments[envId] = environment; - } - } - } - - // (4) Building shell Setup, per building - foreach (var building in lbInfo.Buildings) - { - uint modelId = building.ModelId; - if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId)) - { - var bldSetup = _dats.Get(modelId); - if (bldSetup is not null) setups[modelId] = bldSetup; - } - } - } - - // (5)+(6) Entity GfxObj + Setup (BSP cache + ShadowObjects parts) - foreach (var entity in entities) - { - uint src = entity.SourceGfxObjOrSetupId; - if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src)) - { - var s = _dats.Get(src); - if (s is not null) setups[src] = s; - } - foreach (var meshRef in entity.MeshRefs) - { - uint gid = meshRef.GfxObjId; - if ((gid & 0xFF000000u) != 0x01000000u) continue; - if (gfxObjs.ContainsKey(gid)) continue; - var gfx = _dats.Get(gid); - if (gfx is not null) gfxObjs[gid] = gfx; - } - } - - return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs); -} -``` - -> NOTE: this mirrors the apply's iteration. If, while doing Task 4, you find the -> apply reads a dat id this gather does not produce, add it here in the same -> commit — gather and apply must enumerate the same ids. - -- [ ] **Step 2: Attach the bundle to the near-tier build result** - -In `BuildLandblockForStreamingLocked`, the near path returns a `LoadedLandblock` -built from `hydrated` entities. Find the final `return new ...LoadedLandblock(landblockId, heightmap, hydrated...)` and add the bundle. The LoadFar early-out -returns an empty-entity landblock — give it `PhysicsDatBundle.Empty` (far tier -has no cells/buildings/entities; the apply's near-only side effects are skipped): - -```csharp -// LoadFar early-out return — add the empty bundle: -return new AcDream.Core.World.LoadedLandblock( - landblockId, - heightmapOnly, - System.Array.Empty(), - AcDream.Core.World.PhysicsDatBundle.Empty); - -// Near-tier final return — add the gathered bundle (build it AFTER `hydrated` -// is populated so the entity GfxObj/Setup ids are known): -var physicsDats = BuildPhysicsDatBundle(landblockId, hydrated); -return new AcDream.Core.World.LoadedLandblock( - landblockId, - , - hydrated, - physicsDats); -``` - -(Match `` to whatever the current near return passes for -`Heightmap`.) - -- [ ] **Step 3: Build to verify it compiles** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` -Expected: Build succeeded, 0 errors. - -- [ ] **Step 4: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "feat(streaming): worker pre-reads ApplyLoadedTerrain dats into the bundle" -``` - ---- - -### Task 3: Consume the bundle in `ApplyLoadedTerrainLocked` (the six rewrites) - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (`ApplyLoadedTerrainLocked`) - -The apply receives `lb` (the `LoadedLandblock`). Add one local at the top of -`ApplyLoadedTerrainLocked`, then replace each `_dats.Get(id)` with a bundle -lookup. **Do NOT remove the lock yet** (Task 5) — this task only changes the -data source so the diff is reviewable in isolation. - -- [ ] **Step 1: Add the bundle local at the top of `ApplyLoadedTerrainLocked`** - -Right after the existing `if (_terrain is null || _dats is null || _heightTable is null) return;` guard: - -```csharp -var datBundle = lb.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty; -``` - -- [ ] **Step 2: Rewrite the six read sites** - -Replace each (match by surrounding context; lines drift). In every case the id -expression is unchanged — only the source changes from `_dats.Get(id)` to a -bundle lookup. Use `TryGetValue` so a gather/apply id mismatch fails loudly -rather than silently rendering wrong geometry: - -```csharp -// (1) LandBlockInfo — was: _dats.Get((lb.LandblockId & 0xFFFF0000u) | 0xFFFEu) -var lbInfo = datBundle.Info; - -// (2) EnvCell — was: _dats.Get(envCellId) -if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell)) continue; - -// (3) Environment — was: _dats.Get(0x0D000000u | envCell.EnvironmentId) -if (!datBundle.Environments.TryGetValue(0x0D000000u | envCell.EnvironmentId, out var environment)) continue; - -// (4) building Setup — was: _dats!.Get(building.ModelId) -datBundle.Setups.TryGetValue(building.ModelId, out var bldSetup); - -// (5) GfxObj (BSP loop) — was: _dats.Get(meshRef.GfxObjId) -if (!datBundle.GfxObjs.TryGetValue(meshRef.GfxObjId, out var gfx)) continue; - -// (6) entity-part Setup (ShadowObjects) — was: _dats.Get(src) -datBundle.Setups.TryGetValue(src, out var datSetup); -``` - -For (4) and (6) the originals already null-check the result, so a `TryGetValue` -that leaves the out-var null preserves identical behavior. For (2),(3),(5) the -originals `continue` on null — `TryGetValue` false maps to the same `continue`. - -> After this task there must be **zero** `_dats.` references left inside -> `ApplyLoadedTerrainLocked`. Verify: `grep -n "_dats" GameWindow.cs` shows no -> hit between the method's open brace and its close brace. - -- [ ] **Step 3: Build** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug --nologo` -Expected: Build succeeded, 0 errors. - -- [ ] **Step 4: Run the physics/collision conformance suite (behavior regression)** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` -Expected: PASS — the physics surfaces / BSP / cell tests exercise the same data; -identical ids → identical cached dat objects → identical results. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "refactor(apply): read dats from the bundle, not DatCollection (no lock change yet)" -``` - ---- - -### Task 4: Remove the apply's `lock(_datLock)` - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (`ApplyLoadedTerrain`) - -- [ ] **Step 1: Drop the lock wrapper** - -In `ApplyLoadedTerrain` (the wrapper, NOT `…Locked`), replace: - -```csharp -lock (_datLock) -{ - ApplyLoadedTerrainLocked(lb, meshData); -} -``` - -with: - -```csharp -// The apply makes zero DatCollection calls (Task 3) — all dats come from -// lb.PhysicsDats, pre-read on the worker. Its remaining mutations are -// update-thread-only or ConcurrentDictionary-safe (PhysicsDataCache), so the -// dat lock is no longer needed here. Removing it eliminates the measured -// 24ms-median / 88ms-p95 lockwait stall (the FPS 30↔200 swing). -ApplyLoadedTerrainLocked(lb, meshData); -``` - -(Keep the `[FRAME-DIAG]` `lockwait` timing wrapper around this call if present — -it is how we prove the wait drops to ~0. Leave the `[FRAME-DIAG]` apparatus in -place until Task 5 verification.) - -- [ ] **Step 2: Build + full test suite** - -Run: `dotnet build -c Release --nologo` then `dotnet test` -Expected: Build succeeded; all tests PASS. - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs -git commit -m "fix(streaming): drop _datLock from the terrain apply (FPS swing root cause)" -``` - ---- - -### Task 5: Empirical verification + apparatus strip - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs`, `src/AcDream.App/Streaming/StreamingController.cs` (strip `[FRAME-DIAG]`) -- Modify: `docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md` (mark verified; correct §5 to note `PhysicsDataCache` is ConcurrentDictionary) - -- [ ] **Step 1: Build Release** - -Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Release --nologo` -Expected: Build succeeded. - -- [ ] **Step 2: User runs the verification capture** - -User launches Release with `ACDREAM_WB_DIAG=1`, repeats the repro (town walk + -several portal-hops as `notan` → `+Je`), closes the window. -**Acceptance:** in the `[FRAME-DIAG]` lines, `lockwait` median+p95 drop from -~24/88 ms to ~0; `apply` collapses toward its in-lock body cost; and the visible -30↔200 swing is gone while streaming/portal-hopping. (This is a STOP-for-user -step — do not claim success without the user's capture.) - -- [ ] **Step 3: Strip the `[FRAME-DIAG]` apparatus** - -Remove every `[FRAME-DIAG]` field, the `_frameDiag` gate, the `FrameDiagPush` -helper, the apply/upload/cell/bsp/shadow/lockwait accumulators + sample arrays, -the OnUpdate flush block, the in-body checkpoints, the `MaybeFlushTerrainDiag` -`[FRAME-DIAG]` line, and the `StreamingController` `DeferredApplyBacklog` / -`ForceReloadCount` / `LastForceReloadDropCount` probe surface. Leave the -pre-existing `[TERRAIN-DIAG]` rollup intact. - -- [ ] **Step 4: Update the design doc** - -Mark the spec **verified** with the before/after `lockwait` numbers, and correct -§5 to state `PhysicsDataCache` is `ConcurrentDictionary` (so its shared writes -were never `_datLock`-dependent). - -- [ ] **Step 5: Build + test green, commit** - -Run: `dotnet build -c Release --nologo` then `dotnet test` -Expected: green. - -```bash -git add -A -git commit -m "chore: strip [FRAME-DIAG] probes; datLock FPS fix verified" -``` - -- [ ] **Step 6: Update ISSUES / roadmap** - -Move the FPS-swing item to Recently-closed in `docs/ISSUES.md` with the fix SHA; -note H3 (entity-leak/GPU heat-up) and the invisible-char bug remain OPEN. - ---- - -## Self-review - -- **Spec coverage:** worker pre-read (Task 2) ✓; bundle on `LoadedLandblock` (Task 1) ✓; six `Get` sites rewritten (Task 3) ✓; lock removed (Task 4) ✓; empirical `lockwait`→0 (Task 5) ✓; apparatus strip (Task 5) ✓; H3/invisible-char/A2 excluded ✓. -- **Test note (deviation from spec wording):** the spec named a "golden equivalence test." A GL-bound apply is not unit-testable in isolation, so equivalence is instead guaranteed-by-construction (identical ids → identical cached dat objects → identical math), enforced at runtime by the `TryGetValue` loud-fail in Task 3 Step 2, regression-covered by the existing physics/collision conformance suite (Task 3 Step 4), and proven empirically by Task 5. This satisfies the spec's intent more practically than a synthetic golden test. -- **Placeholder scan:** `` in Task 2 Step 2 is an intentional match-to-source marker, not a placeholder — the engineer copies the current near-return heightmap argument. -- **Type consistency:** `PhysicsDatBundle` fields (`Info`, `EnvCells`, `Environments`, `Setups`, `GfxObjs`) are used identically in Task 1 (definition), Task 2 (population), Task 3 (consumption). `PhysicsDats` property name consistent across Tasks 1–3. diff --git a/docs/superpowers/plans/2026-06-23-envcell-shell-batching.md b/docs/superpowers/plans/2026-06-23-envcell-shell-batching.md deleted file mode 100644 index d6a82dcd..00000000 --- a/docs/superpowers/plans/2026-06-23-envcell-shell-batching.md +++ /dev/null @@ -1,262 +0,0 @@ -# EnvCell Shell Batching Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Cut dense-town frame time by batching the per-cell `EnvCellRenderer.Render` calls (~94/frame, 24.75 ms) into one call per pass. - -**Architecture:** `RetailPViewRenderer.DrawEnvCellShells` and `DrawBuildingLookIns` currently loop visible/look-in cells and call the heavy per-frame `EnvCellRenderer.Render(pass, oneCell)` once per cell × 2 passes. Replace with: opaque → one `Render(Opaque, allCells)` (z-buffer handles order; lighting is per-instance/`CellId`-keyed so cross-cell batching is safe); transparent → skip cells with no transparent geometry, keep far→near per-cell for the rest. Verified safe in the spec. - -**Tech Stack:** C# / .NET 10, Silk.NET OpenGL, bindless multi-draw-indirect. Verification: `FrameProfiler` apparatus (`ACDREAM_FPS_PROF=1`) + live client + `dotnet build`/`dotnet test`. - -**Spec:** `docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md` - -**Verification model:** No unit tests — the draw path is heavy GL with no mockable seam, and render-perf is verified empirically (meter + visual), matching the project's render-change norm. Each task: edit → build → measure on the meter / visual → commit. - ---- - -### Task 1: Add `CellHasTransparent` predicate to `EnvCellRenderer` - -Phase 2 needs to skip the transparent `Render` for cells whose prepared snapshot has no transparent batch. Add a cheap read-only predicate mirroring the existing `[shell]` probe logic (`EnvCellRenderer.cs:1027-1034`). - -**Files:** -- Modify: `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` (add a public method near `Render`) - -- [ ] **Step 1: Add the predicate** - -Add this method to `EnvCellRenderer` (e.g. just after the `Render` method, before `GetCellLightSet`): - -```csharp -/// -/// True if the cell's prepared snapshot has any transparent render batch. -/// Used by the pview shell pass to skip the (heavy) transparent Render call -/// for opaque-only cells — most cell geometry is opaque walls/floors/ceilings. -/// Read-only; mirrors the [shell] probe's per-cell batch scan. -/// -public bool CellHasTransparent(uint cellId) -{ - var snapshot = _activeSnapshot; - if (snapshot is null || !snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) - return false; - foreach (var (gfxObjId, transforms) in gfxDict) - { - if (transforms.Count == 0) continue; - var rd = _meshManager.TryGetRenderData(gfxObjId); - if (rd is null) continue; - foreach (var b in rd.Batches) - if (b.IsTransparent) return true; - } - return false; -} -``` - -- [ ] **Step 2: Build** - -Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Release` -Expected: `Build succeeded. 0 Error(s)`. (If `_activeSnapshot` / `BatchedByCell` / `_meshManager.TryGetRenderData` / `rd.Batches` / `b.IsTransparent` names differ, match them to the existing usages at `EnvCellRenderer.cs:910-934` and `:1031-1034` before building.) - -- [ ] **Step 3: Commit** - -```bash -git add src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs -git commit -m "feat(envcell): CellHasTransparent predicate (shell-batching prep)" -``` - ---- - -### Task 2: Batch the shell pass in `DrawEnvCellShells` (the Arwic win) - -**Files:** -- Modify: `src/AcDream.App/Rendering/RetailPViewRenderer.cs` — the `_oneCell` field area + `DrawEnvCellShells` (currently `:659-665`) - -- [ ] **Step 1: Add a reusable batch set field** - -Find the `_oneCell` scratch field declaration (grep `_oneCell` in `RetailPViewRenderer.cs`) and add next to it: - -```csharp -// Shell-batch scratch: all of a pass's cells collected for ONE Render call -// (opaque). Reused across frames + across look-in buildings. See -// docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md. -private readonly HashSet _shellBatch = new(); -``` - -- [ ] **Step 2: Replace the per-cell loop body in `DrawEnvCellShells`** - -Replace this (currently `RetailPViewRenderer.cs:658-665`): - -```csharp - UseIndoorMembershipOnlyRouting(); - foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) - { - _oneCell.Clear(); - _oneCell.Add(entry.CellId); - _envCells.Render(WbRenderPass.Opaque, _oneCell); - _envCells.Render(WbRenderPass.Transparent, _oneCell); - } -``` - -with: - -```csharp - UseIndoorMembershipOnlyRouting(); - - // Opaque: ONE batched Render for all shell cells. Opaque needs no draw - // order (z-buffer), and lighting is per-instance (CellId-keyed SSBO, - // EnvCellRenderer RenderModernMDIInternal), so cross-cell batching is - // visually identical to the old per-cell loop — at ~1 call instead of N. - _shellBatch.Clear(); - foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) - _shellBatch.Add(entry.CellId); - if (_shellBatch.Count > 0) - _envCells.Render(WbRenderPass.Opaque, _shellBatch); - - // Transparent: far→near order matters for compositing, so keep these - // per-cell in ShellPass order — but skip cells with no transparent - // geometry (most cells are opaque-only), which removes the bulk of the - // per-cell Render calls. - foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) - { - if (!_envCells.CellHasTransparent(entry.CellId)) continue; - _oneCell.Clear(); - _oneCell.Add(entry.CellId); - _envCells.Render(WbRenderPass.Transparent, _oneCell); - } -``` - -- [ ] **Step 3: Build** - -Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Release` -Expected: `Build succeeded. 0 Error(s)`. - -- [ ] **Step 4: Measure on the meter (user-driven live run)** - -Launch with `ACDREAM_FPS_PROF=1` (see Apparatus in the spec), portal to Arwic, hold the worst facing ~15 s. Read `[PASS-GPU] cells=… (calls/frame=…)`. -Expected: `calls/frame` for cells drops sharply (opaque 1 + transparent = only the few transparent cells); `cells` ms drops; frame ms drops. Visual: **no missing walls, no half-culled geometry, windows/transparency still correct.** - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/Rendering/RetailPViewRenderer.cs -git commit -m "perf(pview): batch EnvCell shell opaque pass + skip empty transparent (dense-town FPS)" -``` - ---- - -### Task 3: Apply the same batching to `DrawBuildingLookIns` (interior roots) - -The look-in path (interior root — standing inside a building looking out at others) has the identical per-cell pattern, plus a per-building aperture **punch** pass that must stay ordered before that building's shells. Batch each building's shells; keep punches per building. - -**Files:** -- Modify: `src/AcDream.App/Rendering/RetailPViewRenderer.cs` — `DrawBuildingLookIns` pass-2 loop (currently `:348-355`) - -- [ ] **Step 1: Replace the pass-2 per-cell shell loop** - -Replace this (currently `RetailPViewRenderer.cs:346-355`, the "Pass 2: shells + statics, far→near." block's cell loop): - -```csharp - // Pass 2: shells + statics, far→near. - UseIndoorMembershipOnlyRouting(); - for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) - { - uint cellId = frame.OrderedVisibleCells[i]; - _oneCell.Clear(); - _oneCell.Add(cellId); - _envCells.Render(WbRenderPass.Opaque, _oneCell); - _envCells.Render(WbRenderPass.Transparent, _oneCell); -``` - -with: - -```csharp - // Pass 2: shells + statics. Opaque batched per building (this - // building's punches already ran above; z-buffer handles order). - UseIndoorMembershipOnlyRouting(); - _shellBatch.Clear(); - foreach (uint cellId in frame.OrderedVisibleCells) - _shellBatch.Add(cellId); - if (_shellBatch.Count > 0) - _envCells.Render(WbRenderPass.Opaque, _shellBatch); - // Transparent: far→near, skip opaque-only cells. - for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) - { - uint cellId = frame.OrderedVisibleCells[i]; - if (!_envCells.CellHasTransparent(cellId)) continue; - _oneCell.Clear(); - _oneCell.Add(cellId); - _envCells.Render(WbRenderPass.Transparent, _oneCell); -``` - -> NOTE: preserve whatever follows the old `_envCells.Render(WbRenderPass.Transparent, _oneCell);` line inside the original loop body (e.g. per-cell static/entity draws) — read `RetailPViewRenderer.cs:355-...` and keep the remainder of the loop body intact, only the shell-Render calls move. If the loop body does more than the two Render calls, the transparent loop must retain that per-cell work; in that case keep the per-cell `for` loop structure and only lift the **opaque** Render out into the batched call before the loop, leaving opaque's per-cell call removed. - -- [ ] **Step 2: Build** - -Run: `dotnet build src\AcDream.App\AcDream.App.csproj -c Release` -Expected: `Build succeeded. 0 Error(s)`. - -- [ ] **Step 3: Measure (interior-root scene)** - -Launch with `ACDREAM_FPS_PROF=1`, stand INSIDE a building looking out toward other buildings (interior root). Read `[PASS-GPU] cells` calls/frame — should drop. Visual: look-in building interiors through your doorway still render correctly (no missing far-building walls, the #124 behavior intact). - -- [ ] **Step 4: Commit** - -```bash -git add src/AcDream.App/Rendering/RetailPViewRenderer.cs -git commit -m "perf(pview): batch EnvCell look-in shell draws (interior-root parity)" -``` - ---- - -### Task 4: Full verification - -**Files:** none (verification only) - -- [ ] **Step 1: Build + test green** - -Run: `dotnet build` (solution) then `dotnet test tests\AcDream.App.Tests\AcDream.App.Tests.csproj -c Debug` and `dotnet test tests\AcDream.Core.Tests\AcDream.Core.Tests.csproj -c Debug` -Expected: build succeeds; test suites green (App / Core baselines). - -- [ ] **Step 2: Before/after meter record** - -Confirm at the same Arwic facing: cells calls/frame ~94 → low single digits, `cells` ms 24.75 → low single digits, frame ms 34 → target (~12 ms / ~80 fps). Record the numbers in the commit message / handoff. - -- [ ] **Step 3: User visual gate** - -User confirms at Arwic / Holtburg / Fort Tethana: dense-town fps up, no missing walls, transparency/windows correct, no flicker, interiors correct. - ---- - -### Task 5: Strip the profiling apparatus - -Once the fix is verified, remove all throwaway diagnostics (mirrors `92e95be`). - -**Files:** -- Delete: `src/AcDream.App/Rendering/FrameProfiler.cs` -- Delete: `tests/AcDream.Core.Tests/Conformance/DegradeCoverageProbeTests.cs` -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — remove `_fpsProf` + `_frameProfiler` + `_msaaSamples` fields, the `_msaaSamples = startupQuality.MsaaSamples;` line, the `OnRender` BeginFrame/EndFrame hooks, the `OnUpdate` `MarkUpdateStart()` hook, and the terrain glFinish bracket in `DrawRetailPViewLandscapeSlice`. -- Modify: `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` — remove the `Render` glFinish try/finally bracket (keep `CellHasTransparent` — it's part of the fix, not apparatus). - -- [ ] **Step 1: Remove the apparatus** (edits above) - -- [ ] **Step 2: Build + test green** - -Run: `dotnet build` + the two `dotnet test` suites. -Expected: green, no references to `FrameProfiler` / `ACDREAM_FPS_PROF` remain (grep to confirm: `grep -rn "FrameProfiler\|ACDREAM_FPS_PROF\|MarkUpdateStart\|AddRendererGpu" src tests` → only `CellHasTransparent`-unrelated zero hits). - -- [ ] **Step 3: Clean up throwaway logs** - -Remove the untracked `fps-prof*.log`, `wbdiag.log`, `degrade-probe.log` from the worktree root. - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "chore: strip dense-town FPS profiling apparatus (fix verified)" -``` - ---- - -## Notes for the implementer -- `_envCells` is the concrete `EnvCellRenderer` (field on `RetailPViewRenderer`). `Render(WbRenderPass, HashSet?)` already supports a multi-cell filter (filtered path, `EnvCellRenderer.cs:923-968`) — Task 2/3 just call it with the full set. -- Do NOT batch transparent across cells (loses far→near order → compositing artifacts). Skip-empty is the safe transparent win. -- Do NOT globally batch look-in shells across buildings — punches are per-building. -- If `DrawBuildingLookIns`'s loop body does per-cell work beyond the two Render calls, keep the per-cell `for` and only lift the opaque Render out (see Task 3 Step 1 note). -- Roadmap/issues: this is a tactical perf fix; update `docs/ISSUES.md` (note the dense-town FPS root cause + fix) when it lands. diff --git a/docs/superpowers/plans/2026-06-24-unified-collision-inclusion.md b/docs/superpowers/plans/2026-06-24-unified-collision-inclusion.md deleted file mode 100644 index 96816091..00000000 --- a/docs/superpowers/plans/2026-06-24-unified-collision-inclusion.md +++ /dev/null @@ -1,411 +0,0 @@ -# Unified Verbatim-Retail Collision-Inclusion — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. This is the **DO-NOT-RETRY collision area** — every task MUST follow grep-named-retail → pseudocode → port → conformance-test. **No guess-patches.** If a port step resists, STOP and report; do not improvise. - -**Goal:** Make acdream's "which objects collide, and how the engine decides that" verbatim with retail — one DAT-only collision-shape authority + one query predicate + four faithful channels + one anti-staleness invariant. - -**Architecture:** Complete the units that already implement retail's two-layer model (`ShadowShapeBuilder` = shape authority; `CollisionExemption` + dispatch = query predicate; per-cell `ShadowObjectRegistry` = registration; per-apply rebase). Delete the non-faithful mesh-AABB synthesis. Keep terrain/EnvCell/building/object as distinct channels (retail does). Five sequential slices, each build+test green and committed. - -**Tech Stack:** C# .NET 10, xUnit, Silk.NET. DAT via `DatReaderWriter`/`DatCollection`. Named-retail decomp at `docs/research/named-retail/` (main repo). - -**Spec:** [`docs/superpowers/specs/2026-06-24-unified-collision-inclusion-design.md`](../specs/2026-06-24-unified-collision-inclusion-design.md). **Audit:** [`docs/research/2026-06-24-collision-inclusion-audit.md`](../../research/2026-06-24-collision-inclusion-audit.md). - ---- - -## Pre-flight (every task) - -- **Read first:** the spec (above), `claude-memory/project_physics_collision_digest.md` (DO-NOT-RETRY table), and the audit row for the deviation you're closing. -- **Grep named-retail FIRST** for the cited `Class::method` in `docs/research/named-retail/acclient_2013_pseudo_c.txt` (main repo `C:\Users\erikn\source\repos\acdream\`). Confirm the behavior against the decomp before writing C#. Cross-ref `references/ACE/Source/ACE.Server/Physics/` and `references/ACViewer/Physics/Common/`. -- **Build:** `dotnet build` must be green before any commit. -- **Tests:** `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` (Core), plus the named replay harnesses per task. **The replay harnesses (`CellarUpTrajectoryReplayTests`, `HouseExitWalk*`, `CornerFlood*`, `Issue147ArwicBuildingsDumpTests`) must stay green for EVERY task** — they pin the shadow-list/building/cell machinery. -- **Register:** any deviation retired deletes its row in `docs/architecture/retail-divergence-register.md` in the SAME commit. -- **Visual gates are batched to the end** (user request) — do NOT stop mid-plan for them. Land all five slices, then hand off for testing. - ---- - -## Task 1 — DAT-only shape authority + delete mesh-AABB (D1, W3) - -**Retail oracle:** `CPartArray::InitParts@0x00517F40` (parts from Setup only), `CGfxObj::Serialize@0x00534970` (physics_bsp gated on serialized-flags bit-0), `CPhysicsPart::find_obj_collisions@0x0050D8D0` (null physics_bsp ⇒ returns OK = passable). Render-mesh bounds never enter collision. - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — delete the mesh-AABB synthesis block (begins at the comment block `:7397-7425`, the `if (!isPhantomSetup && !isPhantomGfxObj && !_isLandblockStab && (_isOutdoorMesh || (entityBsp==0 && entityCyl==0)) && entity.MeshRefs.Count > 0)` body, through the `ShadowObjects.Register(... ShadowCollisionType.Cylinder ...)` call that closes it ~`:7565`). Also remove the now-dead `isPhantomGfxObj`/`isPhantomSetup` locals (`:7384-7406`) and the scenery `_isOutdoorMesh`-driven synthesis. Route every object-registration site through `ShadowShapeBuilder` for its collision shape. -- Modify (if needed for the unified call): `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` — ensure it is the single shape authority; the registration radius comes from the shape's bounding sphere, never a synthetic constant (kills the `2f` default at `:97` and the `1f`/`2f` W3 split). -- Possibly delete: `PhysicsDataCache.IsPhantomGfxObjSource` (`:399-403`) becomes dead once the fallback it fenced is gone — remove it and its only call site (`GameWindow.cs:7406`). -- Test: `tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs` (new). - -- [ ] **Step 1: Write the failing test — shapeless Setup yields NO collision shape** - -```csharp -// tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs -using System.Collections.Generic; -using AcDream.Core.Physics; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; -using Xunit; - -public class ShadowShapeBuilderShapeSourceTests -{ - // Retail: an object with no DAT physics shape (no CylSphere, no Sphere, - // no part with a PhysicsBSP) emits NO collision shape and is passable. - // CPhysicsPart::find_obj_collisions@0x0050D8D0 returns OK when physics_bsp==null. - [Fact] - public void Setup_WithNoCylSpheres_NoSpheres_NoPhysicsBspParts_YieldsEmptyShapeList() - { - var setup = new Setup - { - CylSpheres = new List(), - Spheres = new List(), - Parts = new List { 0x01000ABCu }, // a part whose GfxObj has NO physics BSP - PlacementFrames = new System.Collections.Generic.Dictionary(), - }; - - var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false); - - Assert.Empty(shapes); // no synthetic mesh-AABB, no fallback cylinder - } -} -``` - -- [ ] **Step 2: Run it — expect PASS already (authority is already DAT-only); this is the GUARD that locks the invariant** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShadowShapeBuilderShapeSourceTests"` -Expected: PASS. (If FAIL, `ShadowShapeBuilder` itself synthesizes a fallback — fix it to be DAT-only first.) This test exists to keep it that way after the App-layer deletion. - -- [ ] **Step 3: Delete the mesh-AABB synthesis in GameWindow** - -Open `src/AcDream.App/Rendering/GameWindow.cs`. Read `:7370-7575` to find the exact block boundaries. Delete the entire `VISUAL mesh-bounds collision` synthesis (the `if (!isPhantomSetup && ...)` block and the `worldMin/worldMax` AABB computation and the `ShadowObjects.Register(..., ShadowCollisionType.Cylinder, ...)` it culminates in). Remove `isPhantomGfxObj`/`isPhantomSetup` locals and `PhysicsDataCache.IsPhantomGfxObjSource` (now dead). Ensure each registration site still registers the **real** shapes from `ShadowShapeBuilder.FromSetup(...)` / the existing BSP/CylSphere path — only the synthetic-from-mesh path is removed. **Do not** remove `ComputeVisualBounds`/`GetVisualBounds` usage that feeds CULLING; only the collision use. - -- [ ] **Step 4: Build + run the guard test + the replay harnesses** - -Run: `dotnet build` -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ShadowShapeBuilderShapeSourceTests|FullyQualifiedName~CellarUp|FullyQualifiedName~HouseExitWalk|FullyQualifiedName~CornerFlood|FullyQualifiedName~Issue147Arwic"` -Expected: build green; all PASS. (The replay harnesses prove statics/buildings/cells with REAL DAT shapes still collide — only shape-less objects became passable.) - -- [ ] **Step 5: Retire the register row + commit** - -Delete the **AP-2** row from `docs/architecture/retail-divergence-register.md` (the mesh-AABB fallback divergence is gone). If an AP-row also covers the scenery `GameWindow.cs:7408` site separately, delete that too. - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.Core/Physics/ShadowShapeBuilder.cs src/AcDream.Core/Physics/PhysicsDataCache.cs tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs docs/architecture/retail-divergence-register.md -git commit -m "fix(physics): D1 — collision shape is DAT-only; delete mesh-AABB synthesis - -Retail gets collision shape from physics_bsp (CGfxObj::Serialize flags bit-0) -or CSetup cylsphere/sphere only; no shape => passable (CPhysicsPart:: -find_obj_collisions@0x0050D8D0). Deletes the scenery/outdoor mesh-AABB -synthetic-cylinder fallback (GameWindow.cs) and the IsPhantomGfxObjSource -gate that fenced it. Shape-less objects now register no shape and are -passable, verbatim with retail. Retires register row AP-2. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2 — true Sphere collision primitive (D3) - -**Retail oracle:** `CSphere::intersects_sphere` (pc:276917) — the swept sphere-vs-sphere test on the `HAS_PHYSICS_BSP` clear → CylSphere-then-Sphere branch. `CPartArray::GetSphere@0x00518070`. **Port the intersection math from the decomp; conformance-test against golden values — do NOT eyeball.** - -**Files:** -- Modify: `src/AcDream.Core/Physics/ShadowObjectRegistry.cs:537` — add `Sphere` to the enum: `public enum ShadowCollisionType : byte { BSP, Cylinder, Sphere }`. -- Modify: `src/AcDream.Core/Physics/ShadowShapeBuilder.cs:68-81` — emit `ShadowCollisionType.Sphere` (not a coerced short cylinder) for `Setup.Spheres`. -- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` — narrow-phase dispatch: add a `ShadowCollisionType.Sphere` branch at the broad-phase (`:2503`, use 3-D `Length()` like the BSP/else branch) and at the test dispatch (`:2540` / the cylinder else-branch ~`:2734`) calling the new sphere primitive. -- Create/Modify: `src/AcDream.Core/Physics/CollisionPrimitives.cs` — add a swept-sphere-vs-sphere intersection ported from `CSphere::intersects_sphere`. -- Test: `tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs` (new). - -- [ ] **Step 1: Grep the oracle, write pseudocode** - -Grep `acclient_2013_pseudo_c.txt` for `CSphere::intersects_sphere` (and `CSphere::collide`). Write the pseudocode to `docs/research/2026-06-24-sphere-intersects-pseudocode.md` (closest-approach of the moving sphere center to the static sphere center over the sweep; collision when min distance ≤ sum of radii; return the time/normal retail returns). Cross-ref ACE `Physics/Common/Sphere.cs`. - -- [ ] **Step 2: Write the failing conformance test (golden values from the decomp/ACE)** - -```csharp -// tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -public class SphereIntersectsSphereConformanceTests -{ - // Golden cases derived from CSphere::intersects_sphere (pc:276917) / - // ACE Physics/Common/Sphere.cs. Replace EXPECTED with the values the - // oracle produces for each case (head-on hit, glancing miss, already-touching). - [Theory] - // moverCenter, moverRadius, sweep(delta), targetCenter, targetRadius, expectHit - [InlineData(0,0,0, 0.5f, /*sweep*/2,0,0, /*target*/1.0f,0,0, 0.5f, true)] // head-on - [InlineData(0,0,0, 0.5f, 0,2,0, 1.0f,0,0, 0.5f, false)] // perpendicular miss - public void SweptSphere_MatchesRetail( - float mx,float my,float mz, float mr, - float dx,float dy,float dz, - float tx,float ty,float tz, float tr, - bool expectHit) - { - bool hit = CollisionPrimitives.SweptSphereHitsSphere( - new Vector3(mx,my,mz), mr, new Vector3(dx,dy,dz), - new Vector3(tx,ty,tz), tr, out _); - Assert.Equal(expectHit, hit); - } -} -``` - -- [ ] **Step 3: Run it — expect FAIL (method does not exist)** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~SphereIntersectsSphereConformanceTests"` -Expected: FAIL — compile error / `SweptSphereHitsSphere` not defined. - -- [ ] **Step 4: Port the primitive + the enum + builder emission + dispatch** - -Implement `CollisionPrimitives.SweptSphereHitsSphere(...)` from the pseudocode. Add `Sphere` to `ShadowCollisionType`. Emit `Sphere` in `ShadowShapeBuilder` for `Setup.Spheres`. Add the `Sphere` dispatch branches in `TransitionTypes` so dispatch order matches retail: `HAS_PHYSICS_BSP` clear ⇒ CylSphere then Sphere. - -- [ ] **Step 5: Run conformance + replay harnesses + build** - -Run: `dotnet build` -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~SphereIntersectsSphereConformanceTests|FullyQualifiedName~CellarUp|FullyQualifiedName~CornerFlood"` -Expected: build green; all PASS. - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.Core/Physics/CollisionPrimitives.cs src/AcDream.Core/Physics/ShadowObjectRegistry.cs src/AcDream.Core/Physics/ShadowShapeBuilder.cs src/AcDream.Core/Physics/TransitionTypes.cs tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs docs/research/2026-06-24-sphere-intersects-pseudocode.md -git commit -m "feat(physics): D3 — true Sphere collision primitive (CSphere::intersects_sphere) - -Adds ShadowCollisionType.Sphere + a swept sphere-vs-sphere primitive ported -from CSphere::intersects_sphere (pc:276917); ShadowShapeBuilder emits Sphere -for Setup.Spheres instead of coercing to a short cylinder. Dispatch order -matches retail (HAS_PHYSICS_BSP clear => CylSphere then Sphere). Conformance- -tested vs golden values. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3 — `obstruction_ethereal` verbatim port (D2 + D5) — HIGHEST CARE - -**Retail oracle:** `CPhysicsObj::FindObjCollisions@0x0050f050` Gate-1 (pc:276782, instant-skip requires `0x4 AND 0x10`), ETHEREAL-alone branch (pc:276795-276806, sets `obstruction_ethereal=1` and CONTINUES), the per-call clear in `CEnvCell::find_env_collisions@0x0052c144` (pc:309580), and the consume sites in the BSP solid-containment gate (pc:321692 / 323742 / 324573: `if (obstruction_ethereal != 0 || insert_type == PLACEMENT_INSERT)`). **Port set + clear + consume TOGETHER** — a partial port hardens or softens walls unpredictably. - -**Files:** -- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs:378-383` (SpherePath "Misc flags") — add `public bool ObstructionEthereal;`. -- Modify: `src/AcDream.Core/Physics/CollisionExemption.cs:59-131` — change `ShouldSkip` so ETHEREAL-alone no longer returns `true`; instead the caller sets `SpherePath.ObstructionEthereal=true` and continues. (Instant-skip only when `0x4 AND 0x10`.) Update the call site at `TransitionTypes.cs:2519` to honor the new contract (set the flag, do not `continue` on ETHEREAL-alone). -- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs:2064` (top of `FindEnvCollisions`) — clear `sp.ObstructionEthereal = false;` before any dispatch (matches retail's per-call zero). This is **D5**. -- Modify: `src/AcDream.Core/Physics/BSPQuery.cs:1717` — the placement/solid-containment gate currently reads `if (path.InsertType == InsertType.Placement || obj.Ethereal)`; extend it to consume `path.ObstructionEthereal` exactly where retail's `obstruction_ethereal` weakens solid containment (pc:321692/323742/324573). -- Test: `tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs` (new); reuse `DoorCollisionApparatusTests`. - -- [ ] **Step 1: Grep the oracle + write pseudocode** - -Grep `FindObjCollisions` Gate-1 + the ETHEREAL-alone branch + `CEnvCell::find_env_collisions` clear + the three BSP consume sites. Write the set/clear/consume contract to `docs/research/2026-06-24-obstruction-ethereal-pseudocode.md`. Confirm: ETHEREAL-only target (ACE door open `0x0001000C`) → `obstruction_ethereal=1`, shape test still runs, solid containment weakened → **passable the retail way** (this subsumes the AD-7 shim; no wire-layer compat is added). - -- [ ] **Step 2: Write the failing tests (door passable on ETHEREAL-alone; wall unchanged)** - -```csharp -// tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs -using AcDream.Core.Physics; -using Xunit; - -public class ObstructionEtherealTests -{ - // Gate-1: instant-skip requires BOTH ETHEREAL (0x4) AND IGNORE_COLLISIONS (0x10). - [Fact] - public void ShouldSkip_RequiresBothEtherealAndIgnoreCollisions() - { - // both bits → skip - Assert.True(CollisionExemption.ShouldSkip(0x14u, EntityCollisionFlags.None, ObjectInfoState.IsPlayer)); - // ETHEREAL alone → NOT an instant skip (handled via ObstructionEthereal instead) - Assert.False(CollisionExemption.ShouldSkip(0x4u, EntityCollisionFlags.None, ObjectInfoState.IsPlayer)); - } -} -``` - -Plus a `SpherePath`-level test (or extend `DoorCollisionApparatusTests`): an ETHEREAL-only door target sets `ObstructionEthereal` and yields a passable transition; a non-ethereal wall in the same cell still blocks (`CollidedWithEnvironment`). - -- [ ] **Step 3: Run — expect FAIL** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ObstructionEthereal"` -Expected: FAIL (`ShouldSkip(0x4...)` currently returns `true`; `ObstructionEthereal` field missing). - -- [ ] **Step 4: Port set + clear + consume (one slice)** - -Add `SpherePath.ObstructionEthereal`. Change `CollisionExemption.ShouldSkip` (remove the `0x4`-alone early return; keep the `0x4 AND 0x10` instant-skip). At `TransitionTypes.cs:2519`, on ETHEREAL-alone set `sp.ObstructionEthereal = true` and continue to the shape test. Clear `sp.ObstructionEthereal=false` at the top of `FindEnvCollisions`. Consume it in `BSPQuery.cs:1717`'s solid-containment gate per the decomp. - -- [ ] **Step 5: Run tests + replay harnesses + build** - -Run: `dotnet build` -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ObstructionEthereal|FullyQualifiedName~DoorCollision|FullyQualifiedName~CellarUp|FullyQualifiedName~CornerFlood"` -Expected: build green; all PASS — **crucially the CellarUp/CornerFlood wall-collision harnesses unchanged** (the consume site must not weaken normal walls). - -- [ ] **Step 6: Retire AD-7 + commit** - -Delete the **AD-7** row from `docs/architecture/retail-divergence-register.md` (ETHEREAL-alone shim retired). - -```bash -git add src/AcDream.Core/Physics/TransitionTypes.cs src/AcDream.Core/Physics/CollisionExemption.cs src/AcDream.Core/Physics/BSPQuery.cs tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs docs/research/2026-06-24-obstruction-ethereal-pseudocode.md docs/architecture/retail-divergence-register.md -git commit -m "fix(physics): D2+D5 — verbatim obstruction_ethereal; retire ETHEREAL-alone shim - -Gate-1 instant-skip now requires ETHEREAL(0x4) AND IGNORE_COLLISIONS(0x10) -(pc:276782). ETHEREAL-alone sets SpherePath.ObstructionEthereal and continues -the shape test (pc:276795-276806); FindEnvCollisions clears it per call -(pc:309580); BSPQuery consumes it in the solid-containment gate (pc:321692/ -323742/324573). ACE's ETHEREAL-only opened doors become passable the retail -way, subsuming the AD-7 shim (deleted) with no wire-layer compat. Walls on -non-ethereal cells unchanged. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4 — `RemoveCellsForLandblock` (D8) — the anti-staleness invariant - -**Retail oracle:** the #146 pattern (`PhysicsDataCache.RemoveBuildingsForLandblock`, `:472-478`); retail rebuilds cell transforms from live cell pointers (eviction analog `CEnvCell::release`). Invariant: *every cached collision transform rebases to the current streaming center on each landblock apply.* - -**Files:** -- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs` — add `RemoveCellsForLandblock(uint landblockId)` symmetric with `RemoveBuildingsForLandblock`. -- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs:121-141` (`RemoveLandblock`) — call `DataCache?.RemoveCellsForLandblock(landblockId);`. -- Test: `tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs` (new). - -- [ ] **Step 1: Write the failing test** - -```csharp -// tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs -using AcDream.Core.Physics; -using Xunit; - -public class RemoveCellsForLandblockTests -{ - [Fact] - public void RemoveCellsForLandblock_EvictsOnlyMatchingPrefix() - { - var cache = new PhysicsDataCache(); - // Register two cell structs via the test seam (one in lb 0xAAAA, one in 0xBBBB). - cache.RegisterCellStructForTest(0xAAAA0100u, TestCells.MinimalCellPhysics()); - cache.RegisterCellStructForTest(0xBBBB0100u, TestCells.MinimalCellPhysics()); - - cache.RemoveCellsForLandblock(0xAAAA0000u); - - Assert.Null(cache.GetCellStruct(0xAAAA0100u)); // evicted - Assert.NotNull(cache.GetCellStruct(0xBBBB0100u)); // untouched - } -} -``` - -(If no `MinimalCellPhysics` helper exists, build a `CellPhysics` with a non-null `BSP.Root` inline or via the existing test fixtures used by `CellarUpTrajectoryReplayTests`.) - -- [ ] **Step 2: Run — expect FAIL (method missing)** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~RemoveCellsForLandblock"` -Expected: FAIL — `RemoveCellsForLandblock` not defined. - -- [ ] **Step 3: Implement** - -```csharp -// PhysicsDataCache.cs — symmetric with RemoveBuildingsForLandblock (:472) -/// -/// D8 (2026-06-24): drop every cached cell struct belonging to a landblock so -/// the next CacheCellStruct re-bases its STREAMING-RELATIVE WorldTransform -/// against the current _liveCenter — symmetric with RemoveBuildingsForLandblock -/// (#146). Without this, the _cellStruct first-wins guard LOCKS a dungeon cell's -/// transform at its first streaming frame; a teleport recenter then leaves the -/// cell BSP at a stale world offset. -/// -public void RemoveCellsForLandblock(uint landblockId) -{ - uint prefix = landblockId & 0xFFFF0000u; - foreach (var key in _cellStruct.Keys) - if ((key & 0xFFFF0000u) == prefix) - _cellStruct.TryRemove(key, out _); - // Mirror the building-channel symmetry: CellGraph eviction is already done - // by PhysicsEngine.RemoveLandblock -> CellGraph.RemoveLandblock. -} -``` - -Then in `PhysicsEngine.RemoveLandblock` add `DataCache?.RemoveCellsForLandblock(landblockId);` alongside the existing clears (near `:124`). - -- [ ] **Step 4: Run test + replay harnesses + build** - -Run: `dotnet build` -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~RemoveCellsForLandblock|FullyQualifiedName~CellarUp"` -Expected: build green; all PASS. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Core/Physics/PhysicsDataCache.cs src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs -git commit -m "fix(physics): D8 — RemoveCellsForLandblock; cell transforms rebase per apply - -Symmetric with RemoveBuildingsForLandblock (#146): PhysicsEngine.RemoveLandblock -now evicts _cellStruct entries for the landblock so the next CacheCellStruct -re-bases the cell BSP WorldTransform against the current streaming center. Closes -the #146-class stale-frame asymmetry for indoor cells (D8). Completes the -invariant: every cached collision transform rebases on each landblock apply. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 5 — entry-restrictions hook + predicate wiring (D4 + W1) — conservative - -**Retail oracle:** `CEnvCell::find_env_collisions@0x0052c130` calls `check_entry_restrictions` FIRST (pc:309576); `CObjCell::check_entry_restrictions@0x0052b6d0` returns COLLIDED when an access-locked cell's `restriction_obj` rejects the mover. Dispatch terms at pc:276861 (`HAS_PHYSICS_BSP_PS AND ebp_1==0 AND missile_ignore==0`). - -**Files:** -- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` — add a `check_entry_restrictions`-shaped gate at the front of `FindEnvCollisions` (`:2062`). Inert today (returns OK when the cell has no `restriction_obj`; current dev content has none) but structurally present. (D4) -- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs:669` `BspOnlyDispatch` + the call at `:2633` — wire the real `pvpExempt`/`missile_ignore` terms (currently hardcoded false at `:2629`) so dispatch matches `pc:276861`. Keep behavior identical in M1.5 (no PK/missiles) but remove the hardcoded-false smell. (W1) -- Test: extend `tests/AcDream.Core.Tests/Physics/` with a dispatch test pinning the door BSP-only case (must not regress A6.P7) and an entry-restriction no-op test. - -- [ ] **Step 1: Write the failing/guard tests** - -A test that `FindEnvCollisions` returns OK for a cell with no `restriction_obj` (entry-restrictions inert), and a dispatch test that a `HAS_PHYSICS_BSP_PS` door still dispatches BSP-only after the W1 wiring (pin A6.P7 behavior). - -- [ ] **Step 2: Run — expect FAIL or PASS-as-guard** - -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~EntryRestriction|FullyQualifiedName~BspOnlyDispatch"` - -- [ ] **Step 3: Implement the entry-restrictions gate (inert) + W1 wiring** - -Add the gate; wire `pvpExempt`/`missile_ignore` (still resolve to false in M1.5, but via the real predicate terms, not a hardcoded literal). - -- [ ] **Step 4: Run tests + the FULL Core suite + replay harnesses + build** - -Run: `dotnet build` -Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` -Expected: build green; full Core suite PASS (no behavior change in dev content). - -- [ ] **Step 5: Register note + commit** - -In `docs/architecture/retail-divergence-register.md`: add a one-line D4 row "entry-restrictions ported but inert until access-locked content" if the gate is a stub, OR no row if fully faithful; remove the W1 "hardcoded false" note if one exists. - -```bash -git add src/AcDream.Core/Physics/TransitionTypes.cs tests/AcDream.Core.Tests/Physics/ docs/architecture/retail-divergence-register.md -git commit -m "feat(physics): D4+W1 — entry-restrictions gate + wired PvP/missile dispatch terms - -Adds CObjCell::check_entry_restrictions gate at the front of FindEnvCollisions -(pc:309576), inert until access-locked content. Wires the real pvpExempt/ -missile_ignore dispatch terms (pc:276861) replacing the hardcoded-false at -TransitionTypes.cs:2629 — behavior identical in M1.5, smell removed. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Final — batched visual-gate handoff (STOP here, hand to user) - -After all five tasks land green, run the full suites once more and hand off the three feel-gates for the user to test in one session: - -Run: `dotnet build && dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj` -Expected: build + full Core suite green. - -Then present the launch command (CLAUDE.md "Running the client") and the three things to check: -1. **Slice 1 (scenery feel):** walk Holtburg + open world — objects that became passable must be exactly those retail walks through (no DAT physics shape); real statics/walls/buildings still solid. -2. **Slice 3 (doors):** open a door (ACE), walk through (passable), close it, walk into it (solid); confirm no house wall went soft. -3. **Slice 4 (dungeon re-entry):** teleport into a dungeon → out → back into the same dungeon; indoor walls collide correctly each time (no stale-offset clip). - -Update `docs/ISSUES.md` / the physics digest with the phase outcome after the user's gate passes. - ---- - -## Self-review notes (filled by plan author) - -- **Spec coverage:** D1→T1, D3→T2, D2+D5→T3, D8→T4, D4+W1→T5. D6/D7 refuted (no task). All 6 confirmed deviations + W1 covered. ✓ -- **Type consistency:** `ShadowCollisionType { BSP, Cylinder, Sphere }` (T2) used consistently; `SpherePath.ObstructionEthereal` (T3) set in CollisionExemption call site, cleared in FindEnvCollisions, consumed in BSPQuery — same name throughout; `RemoveCellsForLandblock` (T4) named identically at definition + call site. ✓ -- **Sequencing:** T1→T2 (Sphere extends the now-sole authority), T2→T3 (predicate completion after shapes), T3→T4 (independent), T5 last (conservative). Serial — shared files (ShadowShapeBuilder, TransitionTypes, SpherePath, BSPQuery). Do NOT parallelize. ✓ -- **Port-sensitive steps** (sphere math T2; obstruction_ethereal consume T3) are oracle-driven + conformance-tested, not pre-written — the DO-NOT-RETRY no-guess rule. The implementer derives the exact C# from the decomp. ✓ diff --git a/docs/superpowers/plans/2026-06-25-ui-studio-previewer.md b/docs/superpowers/plans/2026-06-25-ui-studio-previewer.md deleted file mode 100644 index 3fab83a5..00000000 --- a/docs/superpowers/plans/2026-06-25-ui-studio-previewer.md +++ /dev/null @@ -1,332 +0,0 @@ -# acdream UI Studio Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build a standalone Silk.NET dev tool that renders any acdream UI panel through the production render stack, with an ImGui click-to-inspect inspector, sample-data fixtures, the live 3-D doll, markup hot-reload + write-back, and doll-camera sliders. - -**Architecture:** A new `StudioWindow` (Silk GL app, launched via a `ui-studio` subcommand in `Program.cs`) builds its render stack through a new `RenderBootstrap` (a clean construction of just the subset the studio needs — GL, `DatCollection`, `TextureCache`, the WB mesh pipeline, `UiHost` — leaving `GameWindow` untouched). A `LayoutSource` loads a panel (dat `LayoutDesc` id or markup file) into a `UiElement` tree; a `FixtureProvider` populates it via the production controllers; a `StudioInspector` (ImGui) renders the tool chrome with the panel shown as an FBO image. - -**Tech Stack:** C# .NET 10, Silk.NET (Windowing/Input/OpenGL), ImGui.NET 1.91.6.1 + Silk.NET.OpenGL.Extensions.ImGui 2.23.0 (via the existing `AcDream.UI.ImGui` project), the existing `AcDream.App.UI` retained-mode tree + `LayoutImporter`/`DatWidgetFactory`, `DatReaderWriter`. - -**Spec:** `docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md` - -**Subagent note (project policy):** implementers are Sonnet and INHERIT CLAUDE.md; they read the cited code themselves. Tasks give the contract, the key code, the files to read, the test, the gate, and the commit message — not every line. GL/window tasks verify via a visual gate (the studio is itself the visual tool); pure-logic tasks (LayoutSource infos, FixtureProvider, MarkupWriteBack) use real unit tests. - -**Branch:** `claude/hopeful-maxwell-214a12`. Commit after every task. Full build (`dotnet build src/AcDream.App/AcDream.App.csproj`) green before any commit; relevant tests green. - -**Launch (for every visual gate):** -```powershell -$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call" -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug -- ui-studio "$env:USERPROFILE\Documents\Asheron's Call" --layout 0x2100006C -``` -Do NOT auto-kill a running client; if a rebuild is locked, ask the user to close it ([[feedback_dont_kill_clients_before_launch]]). - ---- - -## File structure - -New, all under `src/AcDream.App/`: -- `Rendering/RenderBootstrap.cs` — `RenderStack Create(GL, RenderBootstrapOptions)`; the shared render-stack builder + the `RenderStack` record. -- `Studio/StudioWindow.cs` — the Silk window + frame loop + owns the stack/inspector/sources. -- `Studio/StudioOptions.cs` — parsed `ui-studio` args (layout id | markup path | dat dir). -- `Studio/LayoutSource.cs` — load/reload a panel from a dat id or a markup file. -- `Studio/FixtureProvider.cs` — sample data + per-layout controller binding. -- `Studio/SampleData.cs` — the canned items / vitals / creature fixture constants. -- `Studio/StudioInspector.cs` — the ImGui chrome (canvas/tree/properties/render-config). -- `Studio/PanelFbo.cs` — render-to-texture target for the previewed panel (mirrors `PaperdollViewportRenderer`'s FBO). -- `Studio/MarkupWriteBack.cs` — targeted in-place markup attribute edits. -- `Studio/HotReloadWatcher.cs` — debounced `FileSystemWatcher`. -- Modify: `src/AcDream.App/Program.cs` — `ui-studio` subcommand dispatch. - -Tests under `tests/AcDream.App.Tests/Studio/`: -- `LayoutSourceTests.cs`, `FixtureProviderTests.cs`, `MarkupWriteBackTests.cs`. - ---- - -## Task 1: RenderBootstrap (shared render stack) - -**Files:** -- Create: `src/AcDream.App/Rendering/RenderBootstrap.cs` -- Read first: `src/AcDream.App/Rendering/GameWindow.cs:1026-1815,2313-2408` (the construction order), `src/AcDream.App/UI/UiHost.cs`, `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (ctor), `src/AcDream.App/UI/Layout/UiDatFont.cs`. - -**Contract:** -```csharp -namespace AcDream.App.Rendering; - -public sealed record RenderStack( - GL Gl, - DatReaderWriter.DatCollection Dats, - string ShaderDir, - BindlessSupport Bindless, - TextureCache TextureCache, - Shader MeshShader, - Wb.WbMeshAdapter MeshAdapter, - Wb.EntitySpawnAdapter EntitySpawnAdapter, - Wb.WbDrawDispatcher DrawDispatcher, - SceneLightingUboBinding LightingUbo, - AcDream.App.UI.UiHost UiHost, - AcDream.App.UI.Layout.UiDatFont? VitalsDatFont) -{ - /// sprite id (0x06xxxxxx) → (GL handle, width, height) for the importer's resolve func. - public (uint, int, int) ResolveChrome(uint spriteId) - => TextureCache.GetOrUploadRenderSurface(spriteId, out var w, out var h) is var h2 - ? (h2, w, h) : (0u, 0, 0); // match GameWindow.ResolveChrome — read its exact body first -} - -public sealed record RenderBootstrapOptions(QualitySettings Quality); - -public static class RenderBootstrap -{ - /// Build the subset of the production render stack the UI Studio needs — same classes, - /// same construction order as GameWindow.OnLoad, minus terrain/sky/physics/streaming. Throws - /// NotSupportedException if bindless/draw-params are missing (same as the game). - public static RenderStack Create(GL gl, DatReaderWriter.DatCollection dats, RenderBootstrapOptions opts); -} -``` - -- [ ] **Step 1:** Read the cited GameWindow construction lines and `ResolveChrome` (grep it) so the order + the exact `GetOrUploadRenderSurface`/sequencer-factory signatures are copied, not guessed. -- [ ] **Step 2:** Write `RenderBootstrap.Create` constructing, in order: `BindlessSupport.TryCreate` (throw if absent/no draw-params, copy the GameWindow message), `shadersDir = Path.Combine(AppContext.BaseDirectory,"Rendering","Shaders")`, `SceneLightingUboBinding(gl)`, `MeshShader = new Shader(gl, mesh_modern.vert/.frag)`, `TextureCache(gl, dats, bindless)`, `WbMeshAdapter(gl, dats, NullLogger.Instance)`, the `SequencerFactory` lambda (copy from GameWindow:2335-2361, capturing `dats` + an `AnimLoader`), `EntitySpawnAdapter(textureCache, sequencerFactory, meshAdapter)`, `WbDrawDispatcher(gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter, bindless, new )` with `AlphaToCoverage = opts.Quality.AlphaToCoverage`, the vitals dat font (copy GameWindow's load of `0x40000000` via `UiDatFont`), and `UiHost(gl, shadersDir, defaultFont)`. Return the `RenderStack`. -- [ ] **Step 3:** Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Expected: green (no test yet — GL construction is verified by Task 2's boot gate). -- [ ] **Step 4:** Commit. - -**Gate:** Build green. (Functional gate is Task 2.) - -**Commit:** `feat(studio): RenderBootstrap — shared render stack for the UI previewer` - ---- - -## Task 2: StudioWindow + LayoutSource (dat id) + Program dispatch - -**Files:** -- Create: `src/AcDream.App/Studio/StudioOptions.cs`, `src/AcDream.App/Studio/LayoutSource.cs`, `src/AcDream.App/Studio/StudioWindow.cs` -- Modify: `src/AcDream.App/Program.cs` (before `new GameWindow`) -- Test: `tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs` -- Read first: `src/AcDream.App/UI/Layout/LayoutImporter.cs:57-190`, `src/AcDream.App/Rendering/GameWindow.cs:972-1017` (window+Run), `src/AcDream.App/UI/UiHost.cs:65-96` (Draw/WireMouse/WireKeyboard). - -**`StudioOptions` contract:** -```csharp -namespace AcDream.App.Studio; -public sealed record StudioOptions(string DatDir, uint? LayoutId, string? MarkupPath) -{ - public static StudioOptions Parse(string[] args); // args AFTER the "ui-studio" token -} -``` -Parse: positional dat dir (or `ACDREAM_DAT_DIR`), `--layout 0xNNNN` (hex), `--markup `. Default layout when neither given: `0x2100006Cu` (vitals). - -**`LayoutSource` contract:** -```csharp -public enum LayoutSourceKind { DatLayout, Markup } -public sealed class LayoutSource -{ - public LayoutSource(DatCollection dats, Func resolve, UiDatFont? datFont); - public LayoutSourceKind Kind { get; } - public uint? LayoutId { get; } - public string? MarkupPath { get; } - public string? LastError { get; private set; } - /// Imported tree, or null on error (LastError set). Dat → LayoutImporter.Import; markup → MarkupDocument.Build with an empty binding. - public ImportedLayout? CurrentLayout { get; private set; } - public UiElement? Load(StudioOptions opts); // sets Kind/LayoutId/MarkupPath + CurrentLayout - public UiElement? Reload(); // re-run the current source; markup re-reads the file -} -``` - -- [ ] **Step 1 (test, GL-free):** `LayoutSourceTests.LoadsDatLayout_byId`. Open real dats via `ConformanceDats.ResolveDatDir()` (skip if null), build a `LayoutSource` with a STUB resolve `_ => (1u,1,1)` + null font, `Load(new StudioOptions(dir, 0x2100006Cu, null))`, assert `CurrentLayout` non-null and `FindElement(0x2100006Cu) != null` and `Kind == DatLayout`. (Use `LayoutImporter.ImportInfos` is GL-free; `Import`'s resolve is only called for sprites — the stub is fine for tree-shape.) -- [ ] **Step 2:** Run it — expect FAIL (LayoutSource missing). `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter LayoutSourceTests`. -- [ ] **Step 3:** Implement `StudioOptions.Parse` + `LayoutSource` (dat path only; markup path returns LastError "markup unsupported" for now — wired in Task 6). Dat path = `LayoutImporter.Import(dats, id, resolve, datFont)`. -- [ ] **Step 4:** Run the test — expect PASS. -- [ ] **Step 5:** Implement `StudioWindow`: mirror `GameWindow.Run()` window creation (1280×720, GL 4.3 Core, stencil 8). On `Load`: `gl = GL.GetApi(window)`, `input = window.CreateInput()`, `dats = new DatCollection(opts.DatDir, DatAccessType.Read)`, `stack = RenderBootstrap.Create(gl, dats, new(QualitySettings...))`, wire `stack.UiHost.WireMouse/WireKeyboard` for each mouse/keyboard, `source = new LayoutSource(dats, stack.ResolveChrome, stack.VitalsDatFont)`, `root = source.Load(opts)`, and if non-null add it to `stack.UiHost.Root` (use the same add-child API GameWindow uses — grep `_uiHost.Root` adds). On `Render`: `gl.ClearColor` mid-grey, `Clear`, `stack.UiHost.Tick(dt)` + `stack.UiHost.Draw(new Vector2(window.Size.X, window.Size.Y))`. On `Closing`: dispose. -- [ ] **Step 6:** `Program.cs`: before `new GameWindow`, add `if (args.Length >= 1 && args[0] == "ui-studio") { var so = AcDream.App.Studio.StudioOptions.Parse(args[1..]); using var sw = new AcDream.App.Studio.StudioWindow(so); sw.Run(); return 0; }`. -- [ ] **Step 7:** Build green, then VISUAL GATE (launch command above). Expect: a window showing the vitals panel chrome (three empty bars + frame) rendered by the real UiHost. -- [ ] **Step 8:** Commit. - -**Gate (visual):** `ui-studio --layout 0x2100006C` opens a window showing the vitals panel. - -**Commit:** `feat(studio): StudioWindow + LayoutSource — render a dat panel standalone` - ---- - -## Task 3: StudioInspector (ImGui) + canvas FBO + tree + props + click-to-inspect - -**Files:** -- Create: `src/AcDream.App/Studio/PanelFbo.cs`, `src/AcDream.App/Studio/StudioInspector.cs` -- Modify: `StudioWindow.cs` (init ImGui, render panel→FBO, draw inspector) -- Read first: `src/AcDream.UI.ImGui/ImGuiBootstrapper.cs:35-46`, `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs` (the FBO RTT pattern + `GLStateScope`), `src/AcDream.App/UI/UiRoot.cs` (grep `HitTestTopDown`), `src/AcDream.App/UI/UiElement.cs` (Id/Type/Left/Top/Width/Height/Anchors/ZOrder/Children accessors). - -**`PanelFbo`:** mirror `PaperdollViewportRenderer`'s FBO (RGBA8 + Depth24Stencil8, lazy resize). `uint Render(int w, int h, UiHost host)` = bind FBO, viewport, clear transparent, `host.Draw(new Vector2(w,h))` inside a `GLStateScope`, return color tex. Reuse the exact FBO setup from `PaperdollViewportRenderer.EnsureFramebuffer`. - -**`StudioInspector`:** ImGui windows, drawn between `BeginFrame`/`Render`: -```csharp -public sealed class StudioInspector -{ - public UiElement? Selected { get; set; } - // Canvas window: ImGui.Image(panelTex) sized to the panel; capture click → return panel-local (x,y) - public (int x,int y)? DrawCanvas(nint panelTex, int w, int h); - public void DrawTree(UiElement root); // recursive ImGui.TreeNode; click sets Selected - public void DrawProperties(); // Selected's Id/Type/Rect/Anchors/ZOrder/sprites (read-only here) -} -``` -Click-to-inspect: when `DrawCanvas` returns a coord, call `root` `HitTestTopDown(x,y)` (grep its exact signature/return) → set `Selected`. - -- [ ] **Step 1:** Implement `PanelFbo` (copy `PaperdollViewportRenderer` FBO lifecycle; swap the doll draw for `host.Draw`). -- [ ] **Step 2:** `StudioWindow.Load`: construct `new ImGuiBootstrapper(gl, window, input)` (no `DevToolsEnabled` gate — the studio is always devtools) + `new PanelFbo(gl)` + `new StudioInspector()`. -- [ ] **Step 3:** `StudioWindow.Render`: `panelTex = panelFbo.Render(w,h, stack.UiHost)`; `imgui.BeginFrame(dt)`; `var click = inspector.DrawCanvas((nint)panelTex, w, h); if (click is {} c) inspector.Selected = root.HitTestTopDown(c.x, c.y); inspector.DrawTree(root); inspector.DrawProperties();`; `imgui.Render()`. -- [ ] **Step 4:** Implement `StudioInspector` windows (ImGui.NET: `ImGui.Begin`, `ImGui.Image`, `ImGui.TreeNodeEx`, `ImGui.Text`). Tree recurses `element.Children`; each node label = `$"{name} 0x{Id:X8} [{Type}]"`. Properties: Id, Type, Rect `(Left,Top,Width,Height)`, Anchors, ZOrder, and each state sprite id (grep how `UiDatElement`/`UiButton` expose their sprite ids; if not trivially exposed, list "—" for v1 and note follow-up). -- [ ] **Step 5:** Build green, VISUAL GATE: window shows a Canvas pane (the panel) + a Tree pane + a Properties pane; clicking the panel or a tree node selects an element and shows its id/rect. -- [ ] **Step 6:** Commit. - -**Gate (visual):** click an element in the canvas or tree → its id / Type / rect appear in Properties. - -**Commit:** `feat(studio): ImGui inspector — canvas FBO, element tree, props, click-to-inspect` - ---- - -## Task 4: FixtureProvider — populate 2-D panels with sample data - -**Files:** -- Create: `src/AcDream.App/Studio/SampleData.cs`, `src/AcDream.App/Studio/FixtureProvider.cs` -- Modify: `StudioWindow.cs` (call `FixtureProvider.Populate` after `Load`) -- Test: `tests/AcDream.App.Tests/Studio/FixtureProviderTests.cs` -- Read first: `src/AcDream.Core/Items/ClientObjectTable.cs:41-420` (`AddOrUpdate`/`Get`/`GetContents`/`ClientObject` fields), the four controllers' `Bind` (signatures in the plan header research), `src/AcDream.Core/Items/ItemType.cs` + `EquipMask`. - -**`SampleData`:** static factory building a `ClientObjectTable` seeded with: a player object (guid `0xPLAYER`, `ItemsCapacity=102`, `ContainersCapacity=7`), ~6 loose items in the main pack (varied `Type`/`IconId` — pick real icon ids from existing tests or a known wcid; `ContainerId = player`, gapless `ContainerSlot`), ~2 side bags (`Type=Container`, `ItemsCapacity>0`), and a few equipped items (`CurrentlyEquippedLocation` set to Head/Chest/etc). Plus sample vitals (`health=0.8f, stam=0.6f, mana=0.9f` + text). Constants only — no wire. - -**`FixtureProvider.Populate(uint layoutId, ImportedLayout layout, RenderStack stack)`** switch on `layoutId`: -- `0x2100006C` → `VitalsController.Bind(layout, ()=>0.8f, ()=>0.6f, ()=>0.9f, ()=>"80/100", ()=>"60/100", ()=>"90/100")`. -- `0x21000016` → `ToolbarController.Bind(layout, sampleTable, ()=>PLAYER, IconIds, _=>{}, ...digits from GameWindow's load...)`. -- `0x21000023` → `InventoryController.Bind(layout, sampleTable, ()=>PLAYER, IconIds, ()=>100, stack.VitalsDatFont, contents/sideBag/mainPack sprites, ...)`. -- default → no-op (structural preview). -`IconIds` = reuse GameWindow's `iconIds` lambda (grep it — `IconComposer`-backed); if heavy, a stub returning the passed base id is acceptable for v1 (note it). - -- [ ] **Step 1 (test):** `FixtureProviderTests.SampleTable_hasPackContents`. Build `SampleData.BuildObjectTable()`, assert `table.GetContents(PLAYER).Count >= 6` and a known item's `Type`/`IconId` set. -- [ ] **Step 2:** Run — FAIL (missing). -- [ ] **Step 3:** Implement `SampleData` + the table seeding. -- [ ] **Step 4:** Run — PASS. -- [ ] **Step 5:** Implement `FixtureProvider.Populate`; call it from `StudioWindow` after `source.Load`. -- [ ] **Step 6:** Build green, VISUAL GATE: `--layout 0x21000023` shows the inventory with sample items in the grid + a non-zero burden bar; `--layout 0x21000016` shows toolbar slots populated; `--layout 0x2100006C` shows filled vital bars. -- [ ] **Step 7:** Commit. - -**Gate (visual):** inventory / toolbar / vitals panels render POPULATED (sample items, filled bars). - -**Commit:** `feat(studio): FixtureProvider — sample data populates the 2-D panels` - ---- - -## Task 5: Live doll in the paperdoll viewport - -**Files:** -- Modify: `FixtureProvider.cs` (build a sample creature entity for `0x21000023`/paperdoll), `StudioWindow.cs` (wire `PaperdollViewportRenderer` + the pre-UI doll render, mirroring GameWindow:9148-9160) -- Read first: `src/AcDream.App/Rendering/GameWindow.cs:3786-3897` (`RefreshPaperdollDoll`/`ApplyPaperdollPose`), `9148-9160` (the pre-UI doll hook), `src/AcDream.App/Rendering/DollEntityBuilder.cs` (`Build`), `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs` (ctor + `SetDoll`/`Render`), `EntitySpawnAdapter.OnCreate`. - -**Sample creature:** a base human Setup id (grep the test character / a known humanoid Setup `0x0200....`; or reuse the player Setup constant if one exists in tests). Build a `WorldEntity { SourceGfxObjOrSetupId = setupId, MeshRefs = , ... }`, run it through `stack.EntitySpawnAdapter.OnCreate` to resolve MeshRefs (same path GameWindow uses), then `DollEntityBuilder.Build` + `ApplyPaperdollPose`-equivalent. Keep a small `StudioDoll` helper if this grows. - -- [ ] **Step 1:** In `StudioWindow.Load`, construct `new PaperdollViewportRenderer(gl, stack.DrawDispatcher, stack.LightingUbo)`. -- [ ] **Step 2:** When the layout is the inventory/paperdoll, have `FixtureProvider` build the sample creature, call `RefreshPaperdollDoll`-equivalent (clone → `ApplyPaperdollPose`) → `paperdollRenderer.SetDoll(doll)`; expose the doll-viewport widget (FindElement `0x100001D5`). -- [ ] **Step 3:** In `StudioWindow.Render`, BEFORE the panel FBO draw, render the doll into the viewport widget's texture exactly as GameWindow:9156-9159 (`widget.TextureHandle = paperdollRenderer.Render(w,h)`), gated on the viewport widget being present + visible. -- [ ] **Step 4:** Build green, VISUAL GATE: `--layout 0x21000023` shows the paperdoll with a DRESSED 3-D doll in the held pose (reuses the camera/heading/pose shipped in `8fa66c2`). -- [ ] **Step 5:** Commit. - -**Gate (visual):** the paperdoll viewport shows the sample creature as a posed 3-D doll. - -**Commit:** `feat(studio): live 3-D doll in the paperdoll preview` - ---- - -## Task 6: Markup source + hot-reload - -**Files:** -- Create: `src/AcDream.App/Studio/HotReloadWatcher.cs` -- Modify: `LayoutSource.cs` (markup path via `MarkupDocument.Build`), `StudioWindow.cs` (watch + reload), `StudioOptions` already parses `--markup` -- Read first: `src/AcDream.App/UI/MarkupDocument.cs:21-82`, an existing first-party markup file (grep `assets/*.xml`, e.g. the retired `vitals.xml` in git history or a current plugin panel) for the schema. -- Test: extend `LayoutSourceTests`. - -**LayoutSource markup path:** `Kind=Markup`; `Load` reads the file → `MarkupDocument.Build(xml, EmptyBinding, resolve, null)` → wrap its `UiNineSlicePanel` as the root; `Reload` re-reads the file. `EmptyBinding` = `new object()` (no `{Binding}` resolution needed for preview; unresolved bindings render literally or empty — acceptable). - -**HotReloadWatcher:** -```csharp -public sealed class HotReloadWatcher : IDisposable -{ - public HotReloadWatcher(string filePath, Action onChanged); // debounce ~150ms; marshal onChanged via a thread-safe flag the window polls -} -``` -The watcher sets a `volatile bool _dirty`; `StudioWindow.Render` checks it and calls `source.Reload()` + `FixtureProvider.Populate` + re-add to the UiRoot on the GL thread (FileSystemWatcher fires on a worker thread — never touch GL off-thread). - -- [ ] **Step 1 (test):** `LayoutSourceTests.LoadsMarkupFile`. Write a temp `.xml` with a minimal `UiNineSlicePanel`, `Load(new StudioOptions(dir, null, path))`, assert non-null root + `Kind==Markup`. -- [ ] **Step 2:** Run — FAIL. -- [ ] **Step 3:** Implement the markup path in `LayoutSource` + `HotReloadWatcher`. -- [ ] **Step 4:** Run — PASS. -- [ ] **Step 5:** Wire the watcher in `StudioWindow` (poll `_dirty` in Render; reload on the GL thread). -- [ ] **Step 6:** Build green, VISUAL GATE: launch `--markup `; edit the file in an editor + save → the studio reloads the panel within ~1s. -- [ ] **Step 7:** Commit. - -**Gate (visual):** edit a markup file externally → studio re-renders it live. - -**Commit:** `feat(studio): markup source + hot-reload` - ---- - -## Task 7: Editable markup props + write-back - -**Files:** -- Create: `src/AcDream.App/Studio/MarkupWriteBack.cs` -- Modify: `StudioInspector.cs` (editable rect/anchor fields when `Kind==Markup`), `StudioWindow.cs` (apply edit live + persist) -- Test: `tests/AcDream.App.Tests/Studio/MarkupWriteBackTests.cs` - -**`MarkupWriteBack`:** targeted in-place attribute edits — NOT a full re-serialize (preserve formatting/comments): -```csharp -public static class MarkupWriteBack -{ - /// In the markup text, find the element whose id attribute == elementId and set attr=value - /// (add the attribute if absent). Returns the new text. Leaves all other bytes untouched. - public static string SetAttribute(string markup, uint elementId, string attr, string value); -} -``` -Implement by locating the element's opening tag (match the id attribute literal `Id="0x...."` — grep the markup schema for the exact id attribute name), then regex-replace just that attribute within that tag span. - -- [ ] **Step 1 (test):** `MarkupWriteBackTests.SetAttribute_updatesInPlace`. Given a 2-element markup string, `SetAttribute(m, id, "Width", "200")` → re-parse, assert the target's Width==200 AND the sibling element + any comment are byte-identical. -- [ ] **Step 2:** Run — FAIL. -- [ ] **Step 3:** Implement `MarkupWriteBack.SetAttribute`. -- [ ] **Step 4:** Run — PASS (add an absent-attribute case + a not-found case returning the input unchanged). -- [ ] **Step 5:** `StudioInspector.DrawProperties`: when `Kind==Markup`, render rect (`Left/Top/Width/Height`) + anchors as ImGui `InputInt`/combo; on change, set the live `UiElement` field AND call back to the window to `MarkupWriteBack.SetAttribute(...)` + write the file (the watcher then reloads, or skip the watcher for self-writes via a guard flag). -- [ ] **Step 6:** Build green, VISUAL GATE: select an element in a markup panel, change its Width in Properties → the panel updates AND the file on disk shows the new value. -- [ ] **Step 7:** Commit. - -**Gate (visual):** edit a rect in the inspector → live panel updates + the markup file is rewritten. - -**Commit:** `feat(studio): editable markup props with in-place write-back` - ---- - -## Task 8: Render-config sliders (doll camera) - -**Files:** -- Modify: `src/AcDream.App/Rendering/DollCamera.cs` (make Eye/Target/Fov instance-settable — currently `static readonly`), `StudioInspector.cs` (sliders), `PaperdollViewportRenderer.cs` (expose its `DollCamera` or accept overrides) -- Read first: `DollCamera.cs` (current static fields), `PaperdollViewportRenderer.cs:35` (`private readonly DollCamera _camera = new();`). - -**DollCamera change:** convert the `static readonly` Eye/Target/Up/FovRadians to instance properties with the retail defaults as initializers (keep the decomp citations). This is a safe, behavior-preserving change (same defaults) — verify the existing `DollCameraTests` still pass (they read `cam.View`). - -**Inspector:** a "Render config" ImGui window shown when a doll viewport is selected: `SliderFloat3("eye", ...)`, `SliderFloat("fov", ...)`, `SliderFloat("heading", ...)` bound to the `PaperdollViewportRenderer`'s camera (expose it) + the doll's heading. A "Copy to clipboard" button emits the C# literals. - -- [ ] **Step 1:** Make `DollCamera` Eye/Target/Up/FovRadians instance properties (defaults = current retail values). Build + run `DollCameraTests` — expect PASS (defaults unchanged). -- [ ] **Step 2:** Expose the camera from `PaperdollViewportRenderer` (a `public DollCamera Camera => _camera;`). -- [ ] **Step 3:** `StudioInspector`: render the Render-config window with sliders mutating `renderer.Camera.{Eye,FovRadians}` + the doll heading; a copy button. -- [ ] **Step 4:** Build green, VISUAL GATE: select the doll, drag the eye/FOV sliders → the doll re-frames live; copy emits the literals. -- [ ] **Step 5:** Commit. - -**Gate (visual):** dragging the camera sliders re-frames the doll in real time. - -**Commit:** `feat(studio): render-config sliders for the doll camera` - ---- - -## Wrap-up (after Task 8) - -- [ ] Update `claude-memory/project_d2b_retail_ui.md` (or a new `project_ui_studio.md`) with the studio's architecture + the launch command + DO-NOT-RETRY notes; add a `MEMORY.md` pointer. -- [ ] Update the roadmap: note the UI Studio tool shipped (it's tooling, not a milestone phase — a one-line ledger entry). -- [ ] Full suite green; final commit. - -## Self-review notes - -- **Spec coverage:** both sources (Tasks 2 dat / 6 markup) ✓; full fixtures (Task 4 + 5) ✓; inspector read (3) + editable markup (7) + render sliders (8) ✓; hot-reload (6) ✓; live doll (5) ✓; RenderBootstrap (1) ✓; ImGui chrome (3) ✓. -- **GL-untestable tasks** (1,2-window,3,5,6-window,8) use visual gates by design — the studio is a visual tool; pure-logic units (LayoutSource infos, FixtureProvider, MarkupWriteBack) have unit tests. -- **Type consistency:** `RenderStack`/`ImportedLayout`/`StudioOptions`/`LayoutSource`/`ClientObjectTable` member names match the research reference; `ResolveChrome` body must be copied from GameWindow (flagged in Task 1 Step 1). -- **Risk:** Task 1 builds NEW code (no GameWindow edit) → zero game-regression risk, the de-risk decision from the spec's open risk. Task 5's sample Setup id is the one unknown literal — Task 5 Step reads the test fixtures to find a real humanoid Setup before coding. diff --git a/docs/superpowers/plans/2026-07-05-mp-alloc-safe-batch.md b/docs/superpowers/plans/2026-07-05-mp-alloc-safe-batch.md deleted file mode 100644 index 6a64e785..00000000 --- a/docs/superpowers/plans/2026-07-05-mp-alloc-safe-batch.md +++ /dev/null @@ -1,41 +0,0 @@ -# MP-Alloc Safe Batch — kill the per-frame garbage that spikes frame time - -> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox syntax. - -**Goal:** Eliminate the largest *easy, bit-identical* per-frame allocations in the render/update hot path, so the GC-pause spikes that make FPS/ms fluctuate wildly go away. Target: the profiler's `alloc_kb/frame` drops sharply and cpu_ms p99/max tighten toward the p50, with **zero visual or gameplay change**. - -**Why this batch:** MP0 measured 1.5–3 MB/frame allocated in steady-state dense-town, driving gen2 GC ~1–2/s → 20–87 ms frame spikes (the wild fluctuation the user sees on the counter). A 54-site allocation audit ranked the sources. This batch takes ONLY the sites that are (a) high-ranked, (b) pure buffer/list reuse — identical values, just not re-allocated — and (c) NOT faithfulness-sensitive. The two risky sites (EnvCellRenderer.PrepareRenderBatches settled-camera dirty-gate; PhysicsEngine `Transition` pooling) are DEFERRED to their own careful slices — do not touch them here. - -**Binding rules:** -- **Bit-identical output.** Every change reuses a buffer/list that previously was `new`'d each frame. The produced values must be identical — reuse means `.Clear()` + refill or overwrite-in-place, never a semantic change. If a fix would change any drawn value, STOP and report. -- **Existing test suite stays green** (4120 tests). No test may be modified. -- **Thread-safety preserved.** Several of these run on the render thread only; some (particles) may be touched from multiple passes per frame — keep the existing access pattern; a per-owner reusable field is fine when the owner is single-threaded, otherwise report. -- **No new pooling framework.** Use plain reusable fields (`private readonly List _scratch = new()`) cleared per use, or per-entity cached arrays. No ArrayPool ceremony unless a site obviously needs it. -- Measure with the existing `[frame-prof]` meter (env `ACDREAM_FRAME_PROF=1`) — before/after `alloc_kb` and cpu_ms p99/max. - ---- - -## Sites (each its own commit; skip any that can't be made bit-identical and report) - -### Task 1 — Animation pose buffer reuse (highest effort:payoff) -**Files:** `src/AcDream.Core/Physics/AnimationSequencer.cs` (~747/770), `src/AcDream.App/Rendering/GameWindow.cs` (~10723). -Every animated entity (incl. idle NPCs on a breathe cycle) allocates a fresh `PartTransform[partCount]` in Advance AND a fresh `List(partCount)` reassigned to `entity.MeshRefs` each frame (old → garbage). Fix: cache a `PartTransform[]` and a reusable `List` per entity (sized to partCount), overwrite/clear in place, stop reassigning `entity.MeshRefs` to a new list. Verify: pose values identical (existing animation/conformance tests green); no consumer relies on `MeshRefs` being a fresh list each frame (check for cached references / identity comparisons before reusing). - -### Task 2 — Particle draw-list + accumulator reuse -**Files:** `src/AcDream.App/Rendering/ParticleRenderer.cs` (~147/181), `src/AcDream.Core/Vfx/ParticleSystem.cs` (~161 `EnumerateLive`). -`Draw` is called many times/frame (sky pre/post, scene passes, per visible cell, dynamics, unattached), each `new`ing `List` + `List` + a yield-iterator. Fix: reuse `_drawList`/`_run` fields with `.Clear()`; replace the `EnumerateLive()` yield-iterator with a struct enumerator or index walk (no per-call iterator allocation). Keep the per-cell filtering semantics identical. (The deeper O(cells×particles) re-walk is a SEPARATE later structural fix — this task is only the buffer/iterator allocations.) - -### Task 3 — Interior entity partition pooling -**File:** `src/AcDream.App/Rendering/InteriorEntityPartition.cs` (~46). -`Partition` news a `Result` (Dict + 2 Lists) + a `List` per visible cell every frame. Fix: make `Partition` reuse a cleared-in-place `Result` and pooled per-cell lists owned by the partitioner. Identical partitioning output. - -### Task 4 — Trivial per-frame HashSet / small-collection reuse -**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (~9092 `animatedIds`, note the near-duplicate at the DrawContext capture ~9314 — dedupe if they're the same intent), `src/AcDream.App/Rendering/RetailPViewRenderer.cs` (~113 `drawableCells`), `src/AcDream.App/Rendering/IndoorDrawPlan.cs` (~20 ShellPass lists), and `DrainCompletions` (per the audit, ~one-liner). -Each rebuilds a fresh `HashSet`/`List<>` from keys every frame. Fix: reusable cleared-in-place fields. These are one-liners; batch them together in this one commit. If the two `animatedIds` sites are the same set built twice, build once and share. - ---- - -## Verification (the gate) -- After each task: `dotnet build` + full `dotnet test` green. -- After the batch: `dotnet build -c Release`, then a live launch with `ACDREAM_FRAME_PROF=1`. The coordinator + user compare the `[frame-prof]` line in a dense town BEFORE (baseline: alloc_kb p50 ~1600–3000, cpu_ms max 20–87) vs AFTER. Success = alloc_kb p50 down materially AND cpu_ms max/p99 tightened toward p50, with the user confirming the scene looks and plays identically. -- If a site's numbers don't move or a fix risks a visual change, report it — we escalate to the dotnet-trace profile to re-rank rather than guessing. diff --git a/docs/superpowers/plans/2026-07-05-mp0-frame-profiler.md b/docs/superpowers/plans/2026-07-05-mp0-frame-profiler.md deleted file mode 100644 index 12f4dea3..00000000 --- a/docs/superpowers/plans/2026-07-05-mp0-frame-profiler.md +++ /dev/null @@ -1,754 +0,0 @@ -# MP0 — Honest Frame Profiler + Baseline Capture Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build the permanent frame profiler (CPU frame time, GPU frame time, per-stage CPU attribution, per-frame allocation) and capture the MP baseline report that gates the rest of the Modern Pipeline track. - -**Architecture:** A pure, unit-testable stats core (`FrameStatsBuffer`) + a GL query wrapper (`GpuFrameTimer`) + a facade (`FrameProfiler`) wired into `GameWindow` at exactly one frame-boundary call plus three stage scopes. Toggle lives in `RenderingDiagnostics` (the established diagnostic-owner pattern), mirrored in the DebugPanel. Spec: `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` §5. - -**Tech Stack:** .NET 10, Silk.NET OpenGL (`QueryTarget.TimeElapsed` ring, mirroring `WbDrawDispatcher`'s existing idiom), xunit in `tests/AcDream.App.Tests`. - -**Spec deviations (recorded):** -1. Spec §5 lists per-stage GPU attribution via `glQueryCounter` (GL_TIMESTAMP) markers. Deferred: the measured GPU total is ~0.5 ms (the frame is CPU-bound), so MP0 ships per-stage **CPU** attribution + whole-frame GPU only. GPU timestamps get added in the MP phase whose gate first needs GPU-side attribution (likely MP3). -2. The spec says "Toggles via `RuntimeOptions` + DebugPanel". `RuntimeOptions`' own doc comment says runtime diagnostic toggles belong in diagnostic owner classes, NOT RuntimeOptions ([RuntimeOptions.cs:13-19](../../src/AcDream.App/RuntimeOptions.cs)). We follow the codebase rule: the flag goes in `RenderingDiagnostics` (env-seeded, runtime-toggleable). No RuntimeOptions change. - -**Hard GL constraint (discovered during planning):** GL forbids two simultaneously active `TimeElapsed` queries. `WbDrawDispatcher` already brackets its opaque/transparent passes with `TimeElapsed` queries when `ACDREAM_WB_DIAG=1` ([WbDrawDispatcher.cs:1642](../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs)). Therefore the profiler's whole-frame GPU query is **disabled (with a one-time console notice) when `ACDREAM_WB_DIAG=1`**. This is also the 2026-06-23 measurement lesson (separate flags for whole-frame vs per-pass) enforced in code. - -**Threading assumption:** Silk.NET drives `OnUpdate` and `OnRender` on the same loop thread in this app. `FrameProfiler` captures the first caller's managed thread id and emits a one-time warning if any later call arrives on a different thread (guards the `GC.GetAllocatedBytesForCurrentThread` validity). - ---- - -## File map - -| File | Action | Responsibility | -|---|---|---| -| `src/AcDream.App/Diagnostics/FrameStatsBuffer.cs` | Create | Pure ring-buffer stats: samples, percentiles, stage accumulators, report line. Zero GL, zero statics. | -| `src/AcDream.App/Diagnostics/GpuFrameTimer.cs` | Create | GL `TimeElapsed` query ring (depth 4, `Begun` flags per the #125 lesson). | -| `src/AcDream.App/Diagnostics/FrameProfiler.cs` | Create | Facade: frame boundary, stage scopes, GC counters, 5-second `[frame-prof]` report. | -| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` | Modify | Add `FrameProfEnabled` flag (env `ACDREAM_FRAME_PROF`). | -| `src/AcDream.App/Rendering/GameWindow.cs` | Modify | One field + one call at `OnRender` top (~:8757), stage scope around `_wbMeshAdapter?.Tick()` (~:8813), around `_imguiBootstrap.Render()` (~:9821), and at `OnUpdate` top (~:7952). Wiring only — no logic in GameWindow. | -| `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` | Modify | `FrameProf` mirror property (copy the `ProbeResolve` pattern at :248). | -| `src/AcDream.UI.ImGui/` (renderer for DebugPanel) | Modify | Checkbox for the mirror (locate via `grep -rn "ProbeResolve" src/AcDream.UI.ImGui/`). | -| `tests/AcDream.App.Tests/FrameStatsBufferTests.cs` | Create | Percentiles, ring wrap, stage reset, report format. | -| `docs/research/2026-07-XX-mp0-baseline.md` | Create (Task 5) | The baseline report — MP0's deliverable. | - -Line numbers drift; match by the quoted anchor code, not the number. - ---- - -### Task 1: FrameStatsBuffer (pure stats core, TDD) - -**Files:** -- Create: `src/AcDream.App/Diagnostics/FrameStatsBuffer.cs` -- Test: `tests/AcDream.App.Tests/FrameStatsBufferTests.cs` - -- [ ] **Step 1: Write the failing tests** - -```csharp -using AcDream.App.Diagnostics; -using Xunit; - -namespace AcDream.App.Tests; - -public class FrameStatsBufferTests -{ - [Fact] - public void Percentiles_OnKnownDistribution_AreExact() - { - var buf = new FrameStatsBuffer(capacity: 100); - // 1..100 µs — p50 = 50, p95 = 95, p99 = 99, max = 100. - for (long i = 1; i <= 100; i++) buf.Push(i); - - Assert.Equal(50, buf.Percentile(0.50)); - Assert.Equal(95, buf.Percentile(0.95)); - Assert.Equal(99, buf.Percentile(0.99)); - Assert.Equal(100, buf.Max()); - } - - [Fact] - public void Push_PastCapacity_KeepsOnlyNewestWindow() - { - var buf = new FrameStatsBuffer(capacity: 4); - foreach (long v in new long[] { 1000, 1000, 1000, 1000, 1, 2, 3, 4 }) - buf.Push(v); - // The four 1000s were overwritten; window is {1,2,3,4}. - Assert.Equal(4, buf.Count); - Assert.Equal(4, buf.Max()); - Assert.Equal(2, buf.Percentile(0.50)); - } - - [Fact] - public void Percentile_Empty_ReturnsZero() - { - var buf = new FrameStatsBuffer(capacity: 8); - Assert.Equal(0, buf.Percentile(0.95)); - Assert.Equal(0, buf.Max()); - Assert.Equal(0, buf.Count); - } - - [Fact] - public void Reset_ClearsWindow() - { - var buf = new FrameStatsBuffer(capacity: 8); - buf.Push(5); buf.Push(7); - buf.Reset(); - Assert.Equal(0, buf.Count); - Assert.Equal(0, buf.Percentile(0.5)); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests --filter FrameStatsBufferTests -v minimal` -Expected: FAIL — `FrameStatsBuffer` does not exist (compile error). - -- [ ] **Step 3: Implement FrameStatsBuffer** - -```csharp -using System; - -namespace AcDream.App.Diagnostics; - -/// -/// MP0 (2026-07-05) — fixed-capacity ring buffer of long samples -/// (microseconds or bytes) with percentile/max over the current window. -/// Pure and allocation-free after construction: -/// sorts into a preallocated scratch array, so the 5-second report path -/// allocates nothing. Not thread-safe — owned by the window loop thread. -/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. -/// -public sealed class FrameStatsBuffer -{ - private readonly long[] _samples; - private readonly long[] _scratch; - private int _cursor; - private int _count; - - public FrameStatsBuffer(int capacity) - { - if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); - _samples = new long[capacity]; - _scratch = new long[capacity]; - } - - public int Count => _count; - - public void Push(long value) - { - _samples[_cursor] = value; - _cursor = (_cursor + 1) % _samples.Length; - if (_count < _samples.Length) _count++; - } - - public void Reset() - { - _cursor = 0; - _count = 0; - } - - /// - /// Nearest-rank percentile over the current window: element at - /// ceil(q·n) in the ascending sort (1-based), 0 when empty. - /// - public long Percentile(double q) - { - if (_count == 0) return 0; - Array.Copy(_samples, _scratch, _count); - Array.Sort(_scratch, 0, _count); - int rank = (int)Math.Ceiling(q * _count); // 1-based nearest rank - if (rank < 1) rank = 1; - if (rank > _count) rank = _count; - return _scratch[rank - 1]; - } - - public long Max() - { - long max = 0; - for (int i = 0; i < _count; i++) - if (_samples[i] > max) max = _samples[i]; - return max; - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests --filter FrameStatsBufferTests -v minimal` -Expected: 4 passed. - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/Diagnostics/FrameStatsBuffer.cs tests/AcDream.App.Tests/FrameStatsBufferTests.cs -git commit -m "feat(pipeline): MP0 - FrameStatsBuffer ring/percentile core" -``` - ---- - -### Task 2: RenderingDiagnostics flag + GpuFrameTimer - -**Files:** -- Modify: `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` (append inside the class, before the closing brace) -- Create: `src/AcDream.App/Diagnostics/GpuFrameTimer.cs` - -- [ ] **Step 1: Add the flag to RenderingDiagnostics** - -Append inside `public static class RenderingDiagnostics` (after `ShouldRenderIndoor`, before the final `}`): - -```csharp - /// - /// MP0 (2026-07-05) — master toggle for the permanent frame profiler - /// (AcDream.App.Diagnostics.FrameProfiler): CPU frame time - /// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution, - /// per-frame allocation counters, reported as one [frame-prof] - /// line every ~5 s. Permanent apparatus (every MP-track gate reads it) — - /// do NOT strip with session probes. The whole-frame GPU query is - /// self-disabled while ACDREAM_WB_DIAG=1 (GL forbids nested - /// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under - /// that flag — the 2026-06-23 "separate flags" measurement lesson). - /// Initial state from ACDREAM_FRAME_PROF=1; runtime-toggleable - /// via the DebugPanel mirror (DebugVM.FrameProf). - /// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. - /// - public static bool FrameProfEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1"; -``` - -- [ ] **Step 2: Create GpuFrameTimer** - -```csharp -using System; -using Silk.NET.OpenGL; - -namespace AcDream.App.Diagnostics; - -/// -/// MP0 (2026-07-05) — whole-frame GPU time via a ring of -/// queries (depth 4, so results are -/// read ~3 frames late and never stall). Mirrors WbDrawDispatcher's query -/// idiom including the #125 lesson: a glGenQueries name is not a query -/// OBJECT until first glBeginQuery, so never-begun slots are skipped via -/// the Begun flags. -/// -/// MUST NOT be active while ACDREAM_WB_DIAG=1: GL forbids two -/// simultaneously active TimeElapsed queries and WbDrawDispatcher brackets -/// its passes with them under that flag. The caller (FrameProfiler) -/// enforces the exclusion; this class just does the ring. -/// -internal sealed class GpuFrameTimer : IDisposable -{ - private const int RingDepth = 4; - - private readonly GL _gl; - private readonly uint[] _queries = new uint[RingDepth]; - private readonly bool[] _begun = new bool[RingDepth]; - private int _frameIndex; - private bool _queryActive; - - public GpuFrameTimer(GL gl) - { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); - for (int i = 0; i < RingDepth; i++) - _queries[i] = _gl.GenQuery(); - } - - /// - /// Call once per frame at the frame boundary. Ends the previous - /// frame's query, polls the oldest slot non-blocking, begins this - /// frame's query. Returns the completed GPU time in microseconds for - /// a ~RingDepth-frames-old frame, or null when no result is ready. - /// - public long? FrameBoundary() - { - if (_queryActive) - { - _gl.EndQuery(QueryTarget.TimeElapsed); - _queryActive = false; - } - - long? completedUs = null; - int readSlot = _frameIndex % RingDepth; // about to be reused — oldest - if (_begun[readSlot]) - { - _gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.ResultAvailable, out int avail); - if (avail != 0) - { - _gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.Result, out ulong ns); - completedUs = (long)(ns / 1000UL); - } - // Not available ⇒ sample silently dropped (same policy as - // WbDrawDispatcher) — percentiles tolerate missing samples. - } - - _gl.BeginQuery(QueryTarget.TimeElapsed, _queries[readSlot]); - _begun[readSlot] = true; - _queryActive = true; - _frameIndex++; - return completedUs; - } - - /// End any active query without beginning a new one (used when the profiler is toggled off mid-session). - public void Stop() - { - if (_queryActive) - { - _gl.EndQuery(QueryTarget.TimeElapsed); - _queryActive = false; - } - } - - public void Dispose() - { - Stop(); - for (int i = 0; i < RingDepth; i++) - _gl.DeleteQuery(_queries[i]); - } -} -``` - -Note: `_gl.GenQuery()` / `_gl.DeleteQuery(uint)` are the Silk.NET single-name helpers; if this overload doesn't resolve on the project's Silk.NET version, use the array forms exactly as `WbDrawDispatcher` does (`grep -n "GenQueries" src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`) and copy that call shape. - -- [ ] **Step 3: Build** - -Run: `dotnet build` -Expected: green (no tests possible for the GL class headless; correctness rides on the copied WbDrawDispatcher idiom + Task 5's live run). - -- [ ] **Step 4: Commit** - -```bash -git add src/AcDream.Core/Rendering/RenderingDiagnostics.cs src/AcDream.App/Diagnostics/GpuFrameTimer.cs -git commit -m "feat(pipeline): MP0 - FrameProfEnabled flag + GPU TimeElapsed ring" -``` - ---- - -### Task 3: FrameProfiler facade - -**Files:** -- Create: `src/AcDream.App/Diagnostics/FrameProfiler.cs` -- Test: `tests/AcDream.App.Tests/FrameProfilerReportTests.cs` - -- [ ] **Step 1: Write the failing test (report formatting is the testable part)** - -```csharp -using AcDream.App.Diagnostics; -using Xunit; - -namespace AcDream.App.Tests; - -public class FrameProfilerReportTests -{ - [Fact] - public void FormatReport_IsInvariantAndComplete() - { - var cpu = new FrameStatsBuffer(16); - var gpu = new FrameStatsBuffer(16); - var alloc = new FrameStatsBuffer(16); - var stages = new[] { new FrameStatsBuffer(16), new FrameStatsBuffer(16), new FrameStatsBuffer(16) }; - for (long i = 1; i <= 10; i++) - { - cpu.Push(i * 1000); // 1..10 ms in µs - gpu.Push(i * 100); - alloc.Push(i * 1024); // bytes - stages[0].Push(i * 200); - stages[1].Push(i * 50); - stages[2].Push(i * 10); - } - - string line = FrameProfiler.FormatReport( - frameCount: 10, cpu: cpu, gpu: gpu, gpuActive: true, - alloc: alloc, gc0: 3, gc1: 1, gc2: 0, stages: stages); - - Assert.StartsWith("[frame-prof]", line); - Assert.Contains("n=10", line); - Assert.Contains("cpu_ms p50=5.0 p95=10.0 p99=10.0 max=10.0", line); - Assert.Contains("gpu_ms p50=0.5", line); - Assert.Contains("alloc_kb p50=5.0 max=10.0", line); - Assert.Contains("gc=3/1/0", line); - Assert.Contains("upd p50=1.0", line); // stage 0: 200µs·5 = 1.0 ms - Assert.DoesNotContain(",0", line.Replace("gc=3/1/0", "")); // no comma decimals (invariant culture) - } - - [Fact] - public void FormatReport_GpuInactive_SaysWhy() - { - var empty = new FrameStatsBuffer(4); - string line = FrameProfiler.FormatReport( - frameCount: 0, cpu: empty, gpu: empty, gpuActive: false, - alloc: empty, gc0: 0, gc1: 0, gc2: 0, - stages: new[] { empty, empty, empty }); - Assert.Contains("gpu=off(wbdiag)", line); - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `dotnet test tests/AcDream.App.Tests --filter FrameProfilerReportTests -v minimal` -Expected: FAIL — `FrameProfiler` does not exist. - -- [ ] **Step 3: Implement FrameProfiler** - -```csharp -using System; -using System.Diagnostics; -using System.Globalization; -using System.Text; -using AcDream.Core.Rendering; -using Silk.NET.OpenGL; - -namespace AcDream.App.Diagnostics; - -/// Stage indices for per-frame CPU attribution. -public enum FrameStage -{ - /// Whole OnUpdate body (simulation + streaming apply). - Update = 0, - /// WbMeshAdapter.Tick — staged mesh/texture GPU upload drain. - Upload = 1, - /// ImGui Render (dev overlay). - ImGui = 2, -} - -/// -/// MP0 (2026-07-05) — the permanent honest frame profiler. One -/// FrameBoundary call at the top of GameWindow.OnRender -/// measures CPU frame time as the delta between consecutive boundaries -/// (captures the FULL frame including present), brackets the frame in a -/// GPU TimeElapsed query (via ), and -/// samples per-frame allocated bytes + GC collection counts. Stage scopes -/// () attribute CPU time to Update / Upload / -/// ImGui. Emits one [frame-prof] line every ~5 s while -/// is true; costs one -/// bool check per frame when off. -/// -/// Permanent apparatus — every MP-track gate reads it; do not strip. -/// Whole-frame GPU timing self-disables under ACDREAM_WB_DIAG=1 -/// (nested TimeElapsed is illegal GL; see GpuFrameTimer). -/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. -/// -public sealed class FrameProfiler : IDisposable -{ - private const int WindowCapacity = 2048; // ~12 s at 165 fps - private const long ReportIntervalTicks = 5 * TimeSpan.TicksPerSecond; - private static readonly int StageCount = Enum.GetValues().Length; - - private readonly FrameStatsBuffer _cpuUs = new(WindowCapacity); - private readonly FrameStatsBuffer _gpuUs = new(WindowCapacity); - private readonly FrameStatsBuffer _allocBytes = new(WindowCapacity); - private readonly FrameStatsBuffer[] _stageUs; - private readonly long[] _stageAccumTicks; - private readonly bool _wbDiagActive = - Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG") == "1"; - - private GpuFrameTimer? _gpuTimer; - private long _lastBoundaryTimestamp; - private long _lastAllocBytes; - private long _lastReportTicks; - private int _gc0Base, _gc1Base, _gc2Base; - private int _framesInWindow; - private int _ownerThreadId; - private bool _threadWarned; - private bool _wbDiagNoticePrinted; - private bool _wasEnabled; - - public FrameProfiler() - { - _stageUs = new FrameStatsBuffer[StageCount]; - for (int i = 0; i < StageCount; i++) _stageUs[i] = new FrameStatsBuffer(WindowCapacity); - _stageAccumTicks = new long[StageCount]; - } - - /// - /// Call as the FIRST statement of GameWindow.OnRender. - /// - public void FrameBoundary(GL gl) - { - bool enabled = RenderingDiagnostics.FrameProfEnabled; - if (!enabled) - { - if (_wasEnabled) { _gpuTimer?.Stop(); _wasEnabled = false; _lastBoundaryTimestamp = 0; } - return; - } - - if (_ownerThreadId == 0) _ownerThreadId = Environment.CurrentManagedThreadId; - else if (!_threadWarned && _ownerThreadId != Environment.CurrentManagedThreadId) - { - _threadWarned = true; - Console.WriteLine("[frame-prof] WARNING: frame boundary crossed threads; alloc counter is per-thread and now unreliable"); - } - - long now = Stopwatch.GetTimestamp(); - long allocNow = GC.GetAllocatedBytesForCurrentThread(); - - if (!_wasEnabled) - { - // First enabled frame (startup or runtime toggle-on): establish - // baselines, emit nothing. - _wasEnabled = true; - _lastReportTicks = DateTime.UtcNow.Ticks; - _gc0Base = GC.CollectionCount(0); _gc1Base = GC.CollectionCount(1); _gc2Base = GC.CollectionCount(2); - if (_gpuTimer is null && !_wbDiagActive) _gpuTimer = new GpuFrameTimer(gl); - if (_wbDiagActive && !_wbDiagNoticePrinted) - { - _wbDiagNoticePrinted = true; - Console.WriteLine("[frame-prof] GPU frame timing OFF: ACDREAM_WB_DIAG=1 owns TimeElapsed queries (nested queries are illegal GL)"); - } - } - else - { - long cpuUs = (now - _lastBoundaryTimestamp) * 1_000_000L / Stopwatch.Frequency; - _cpuUs.Push(cpuUs); - _allocBytes.Push(allocNow - _lastAllocBytes); - for (int i = 0; i < StageCount; i++) - { - _stageUs[i].Push(_stageAccumTicks[i] * 1_000_000L / Stopwatch.Frequency); - _stageAccumTicks[i] = 0; - } - _framesInWindow++; - } - - _lastBoundaryTimestamp = now; - _lastAllocBytes = allocNow; - - if (_gpuTimer?.FrameBoundary() is long gpuUs) - _gpuUs.Push(gpuUs); - - long nowTicks = DateTime.UtcNow.Ticks; - if (nowTicks - _lastReportTicks >= ReportIntervalTicks && _framesInWindow > 0) - { - int gc0 = GC.CollectionCount(0) - _gc0Base; - int gc1 = GC.CollectionCount(1) - _gc1Base; - int gc2 = GC.CollectionCount(2) - _gc2Base; - Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs, - gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs)); - _lastReportTicks = nowTicks; - _gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2; - _framesInWindow = 0; - _cpuUs.Reset(); _gpuUs.Reset(); _allocBytes.Reset(); - for (int i = 0; i < StageCount; i++) _stageUs[i].Reset(); - } - } - - /// - /// Attribute the enclosed CPU time to . - /// Usage: using var _ = profiler.BeginStage(FrameStage.Update);. - /// Zero-cost (default scope) when the profiler is off. - /// - public StageScope BeginStage(FrameStage stage) - => RenderingDiagnostics.FrameProfEnabled - ? new StageScope(this, stage, Stopwatch.GetTimestamp()) - : default; - - internal void EndStage(FrameStage stage, long startTimestamp) - => _stageAccumTicks[(int)stage] += Stopwatch.GetTimestamp() - startTimestamp; - - /// Pure report formatter — unit-tested; invariant culture. - public static string FormatReport( - int frameCount, - FrameStatsBuffer cpu, FrameStatsBuffer gpu, bool gpuActive, - FrameStatsBuffer alloc, int gc0, int gc1, int gc2, - FrameStatsBuffer[] stages) - { - var ci = CultureInfo.InvariantCulture; - var sb = new StringBuilder(256); - sb.Append("[frame-prof] n=").Append(frameCount); - sb.AppendFormat(ci, " | cpu_ms p50={0:0.0} p95={1:0.0} p99={2:0.0} max={3:0.0}", - cpu.Percentile(0.50) / 1000.0, cpu.Percentile(0.95) / 1000.0, - cpu.Percentile(0.99) / 1000.0, cpu.Max() / 1000.0); - if (gpuActive) - sb.AppendFormat(ci, " | gpu_ms p50={0:0.0} p95={1:0.0}", - gpu.Percentile(0.50) / 1000.0, gpu.Percentile(0.95) / 1000.0); - else - sb.Append(" | gpu=off(wbdiag)"); - sb.AppendFormat(ci, " | alloc_kb p50={0:0.0} max={1:0.0} gc={2}/{3}/{4}", - alloc.Percentile(0.50) / 1024.0, alloc.Max() / 1024.0, gc0, gc1, gc2); - string[] names = { "upd", "upl", "imgui" }; - for (int i = 0; i < stages.Length && i < names.Length; i++) - sb.AppendFormat(ci, " | {0} p50={1:0.0} p95={2:0.0}", - names[i], stages[i].Percentile(0.50) / 1000.0, stages[i].Percentile(0.95) / 1000.0); - return sb.ToString(); - } - - public void Dispose() => _gpuTimer?.Dispose(); -} - -/// Disposable stage scope; default instance is a no-op. -public readonly struct StageScope : IDisposable -{ - private readonly FrameProfiler? _owner; - private readonly FrameStage _stage; - private readonly long _start; - - internal StageScope(FrameProfiler owner, FrameStage stage, long start) - { - _owner = owner; _stage = stage; _start = start; - } - - public void Dispose() => _owner?.EndStage(_stage, _start); -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `dotnet test tests/AcDream.App.Tests --filter FrameProfilerReportTests -v minimal` -Expected: 2 passed. (If the `,0` invariant assertion trips on stage names, adjust only the assertion's exclusion — the formatter must keep `CultureInfo.InvariantCulture` everywhere.) - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.App/Diagnostics/FrameProfiler.cs tests/AcDream.App.Tests/FrameProfilerReportTests.cs -git commit -m "feat(pipeline): MP0 - FrameProfiler facade (CPU/GPU/alloc/stages, 5s report)" -``` - ---- - -### Task 4: GameWindow wiring + DebugPanel mirror - -**Files:** -- Modify: `src/AcDream.App/Rendering/GameWindow.cs` -- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` -- Modify: the DebugPanel ImGui renderer (locate in step 4) - -- [ ] **Step 1: Add the field and frame-boundary call** - -Near GameWindow's other diagnostic fields (search for `_frameDiag` field declaration to co-locate): - -```csharp - // MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call - // per OnRender + three stage scopes. All logic lives in - // AcDream.App.Diagnostics.FrameProfiler (structure rule 1). - private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new(); -``` - -At the very top of `OnRender` (anchor: `private void OnRender(double deltaSeconds)` ~line 8756 — the call goes BEFORE the `var kf = WorldTime.CurrentSky;` line): - -```csharp - _frameProfiler.FrameBoundary(_gl!); -``` - -In `Dispose`/`OnClosing` teardown (anchor: where `_wbMeshAdapter` is disposed — profiler goes before `_dats`): - -```csharp - _frameProfiler.Dispose(); -``` - -- [ ] **Step 2: Add the three stage scopes** - -`OnUpdate` (anchor: `private void OnUpdate(double dt)` ~line 7952) — first statement of the body: - -```csharp - using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update); -``` - -Upload drain (anchor: `_wbMeshAdapter?.Tick();` ~line 8813 — wrap it, keeping the existing `[FRAME-DIAG]` lines untouched): - -```csharp - using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload)) - { - _wbMeshAdapter?.Tick(); - } -``` - -ImGui render (anchor: `_imguiBootstrap.Render();` ~line 9821): - -```csharp - using (var _imguiStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui)) - { - _imguiBootstrap.Render(); - } -``` - -- [ ] **Step 3: Add the DebugVM mirror** - -In `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs`, after the `ProbeResolve` property (~line 252), following the same pattern: - -```csharp - /// - /// Runtime mirror of RenderingDiagnostics.FrameProfEnabled - /// (env var ACDREAM_FRAME_PROF). Toggling here starts/stops the - /// [frame-prof] 5-second report live — no relaunch required. - /// - public bool FrameProf - { - get => AcDream.Core.Rendering.RenderingDiagnostics.FrameProfEnabled; - set => AcDream.Core.Rendering.RenderingDiagnostics.FrameProfEnabled = value; - } -``` - -- [ ] **Step 4: Add the DebugPanel checkbox** - -Locate the renderer: `grep -rn "ProbeResolve" src/AcDream.UI.ImGui/` — expected: one hit in the DebugPanel renderer drawing a checkbox bound to `vm.ProbeResolve`. Add an identical checkbox line for `vm.FrameProf` labeled `"Frame profiler ([frame-prof])"` adjacent to it, copying the exact checkbox idiom used there. - -- [ ] **Step 5: Build + full test suite** - -Run: `dotnet build` -Expected: green. -Run: `dotnet test` -Expected: all green (no existing test touches these paths). - -- [ ] **Step 6: Commit** - -```bash -git add src/AcDream.App/Rendering/GameWindow.cs src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs src/AcDream.UI.ImGui/ -git commit -m "feat(pipeline): MP0 - wire FrameProfiler into GameWindow + DebugPanel toggle" -``` - ---- - -### Task 5: Baseline capture (user-driven; MP0's deliverable + gate) - -**Files:** -- Create: `docs/research/2026-07-XX-mp0-baseline.md` (use the actual capture date) - -- [ ] **Step 1: Confirm Release build is green** - -Run: `dotnet build -c Release` -Expected: green. Never launch on a red build. - -- [ ] **Step 2: Launch with the profiler on (user drives)** - -```powershell -$env:ACDREAM_DAT_DIR="$env:USERPROFILE\Documents\Asheron's Call"; $env:ACDREAM_LIVE="1" -$env:ACDREAM_TEST_HOST="127.0.0.1"; $env:ACDREAM_TEST_PORT="9000" -$env:ACDREAM_TEST_USER="testaccount"; $env:ACDREAM_TEST_PASS="testpassword" -$env:ACDREAM_FRAME_PROF="1" # NOT ACDREAM_WB_DIAG — mutually exclusive GPU timing -dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release 2>&1 | Tee-Object -FilePath "mp0-baseline.log" -``` - -Launch in the background (`run_in_background: true`); the user manages the client lifecycle per CLAUDE.md. Ask the user to run this route, pausing ~30 s (≥5 report lines) in each state: -1. Dense town, standing still, facing the dense view (Holtburg center; Fort Tethana/Arwic if reachable). -2. Dense town, panning the camera continuously. -3. Walking a full town traversal (streaming active). -4. 2–3 portal hops (the hitch scenario). -5. A dungeon interior walk. - -- [ ] **Step 3: Extract the numbers** - -Run: `Select-String -Path mp0-baseline.log -Pattern "\[frame-prof\]"` -Expected: one line per 5 s. Map line timestamps/order to the route states (the user says when each state started; correlate by order). - -- [ ] **Step 4: Write the baseline report** - -Create `docs/research/2026-07-XX-mp0-baseline.md` containing, per route state: cpu_ms p50/p95/p99/max, gpu_ms p50/p95, alloc_kb p50/max, gc counts, stage split (upd/upl/imgui p50/p95) — plus: -- **The attribution verdict:** where do the ~6 dense-town CPU ms actually go (update vs upload vs imgui vs unattributed-render)? Does it confirm the spec §1 split (render-submission dominant)? -- **The gate decision (spec §5):** if attribution confirms the assumed split → proceed to MP1 planning unchanged. If it contradicts (e.g. Update dominates) → amend the spec's phase order BEFORE MP1, in the same session, and say so in this doc. -- The hitch profile: worst max/p99 during portal hops and traversal — this is MP1's "before" number. - -- [ ] **Step 5: Commit + update the spec status** - -Mark the spec's MP0 row as baseline-captured (one-line edit in `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` §5 gate line: append "— PASSED , see baseline doc"). - -```bash -git add docs/research/2026-07-XX-mp0-baseline.md docs/superpowers/specs/2026-07-05-modern-pipeline-design.md -git commit -m "docs(pipeline): MP0 baseline captured - attribution verdict + gate decision" -``` - ---- - -## Post-plan notes for the executor - -- **Do not strip `[frame-prof]`** in future probe-cleanup commits — it is permanent apparatus (the flag doc comment says so; so does spec §5). -- The existing `[FRAME-DIAG]` / `[WB-DIAG]` apparatus is untouched by this plan — the distance-degrade handoff still owns its strip decision. -- The roadmap "Modern Pipeline side track" section + milestones-doc freeze-exception paragraph (spec §12) should be added in the same commit as Task 5's baseline doc if not already present. diff --git a/docs/superpowers/plans/2026-07-05-mp1a-content-extraction.md b/docs/superpowers/plans/2026-07-05-mp1a-content-extraction.md deleted file mode 100644 index 79aab3c1..00000000 --- a/docs/superpowers/plans/2026-07-05-mp1a-content-extraction.md +++ /dev/null @@ -1,221 +0,0 @@ -# MP1a — AcDream.Content Extraction Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Extract the GL-free CPU half of the WB mesh/texture pipeline out of `AcDream.App` into a new `AcDream.Content` assembly, so the MP1b bake tool can run it without an OpenGL context — with **zero behavior change** (the client keeps running the exact same code, relocated). - -**Architecture:** Mechanical move, not a rewrite. `ObjectMeshManager`'s `Prepare*` family (dat read → mesh extraction → inline texture decode → `ObjectMeshData`) moves verbatim into a new `MeshExtractor` class in `AcDream.Content`; `ObjectMeshManager` keeps its queue/worker/GL-upload lifecycle and delegates. The one GL-domain entanglement is `TextureAtlasManager.TextureKey` (a pure-data struct nested in a GL class) — it gets lifted out first. Spec: `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` §6.1 ("Extraction prerequisite"). MP1b (bake tool + pak) and MP1c (streaming cutover) are separate plans. - -**Tech Stack:** .NET 10 class library (no Silk.NET), `BCnEncoder.Net.ImageSharp` 1.1.2 + `SixLabors.ImageSharp` 3.1.12 (the inline DXT decode's packages), `AcDream.Core` project reference (TextureHelpers etc.). - -**Binding rules:** -- **Verbatim bodies.** Method bodies move unchanged — same statements, same inline decode (do NOT "clean up" by switching to `SurfaceDecoder`; the two decoders are not proven bit-identical and byte-identity is MP1b's conformance foundation). Only mechanical edits allowed: namespace/using lines, `private` → `public` on moved members the old class must still reach, field references rehomed onto the new class. -- **The compiler enumerates dependencies.** If moving a method drags in a type, move it if it's GL-free; if a dependency turns out to touch GL (`Silk.NET`, `OpenGLGraphicsDevice`, `GlobalMeshBuffer`, `TextureAtlasManager` beyond the lifted key, `ManagedGL*`), **STOP and report BLOCKED** — do not improvise a redesign. -- **No divergence-register rows** — a pure relocation introduces no behavior deviation. Any observed behavior change is a bug in the move. -- Verified boundary facts (2026-07-05, this worktree): the `Prepare*` region's only GL-domain reference is `TextureAtlasManager.TextureKey` (`ObjectMeshManager.cs:1286,1631,2236,2254`); first real GL call is `UploadGfxObjMeshData` at `:1833`. Line numbers drift — match by symbol. - ---- - -## File map - -| File | Action | Responsibility | -|---|---|---| -| `src/AcDream.Content/AcDream.Content.csproj` | Create | GL-free content/asset assembly (net10.0) | -| `src/AcDream.Content/TextureKey.cs` | Create (lift) | The atlas dedup key struct, moved out of `TextureAtlasManager` | -| `src/AcDream.Content/ObjectMeshData.cs` | Create (move) | The boundary records: `ObjectMeshData`, `MeshBatchData`, `TextureBatchData`, `VertexPositionNormalTexture`, and sibling GL-free data records from `ObjectMeshManager.cs`'s data region | -| `src/AcDream.Content/MeshExtractor.cs` | Create (move) | The `Prepare*` family + its private helpers/caches, verbatim | -| `src/AcDream.Content/` (as compiler demands) | Create (move) | `IDatReaderWriter.cs`, `DatCollectionAdapter.cs`, `AcSurfaceMetadata.cs`, `AcSurfaceMetadataTable.cs`, `EdgeLineBuilder.cs`, `GeometryUtils.cs` — GL-free deps of the extractor (move only the ones the build actually requires) | -| `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` | Modify | Keeps queue/worker/upload lifecycle; delegates mesh prep to `MeshExtractor`; loses the moved types/methods | -| `src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs` | Modify | Uses the lifted `TextureKey` | -| `src/AcDream.App/AcDream.App.csproj` | Modify | Add `ProjectReference` to Content | -| `AcDream.slnx` | Modify | Register the new project | -| `docs/architecture/worldbuilder-inventory.md` | Modify | Record the relocation (Code Structure Rule 2 requires the inventory note for project-reference changes) | - -`AcDream.Core` is untouched. `AcDream.Content` references Core; App references Content; Core must NOT reference Content. - ---- - -### Task 1: Create the AcDream.Content project - -**Files:** -- Create: `src/AcDream.Content/AcDream.Content.csproj` -- Modify: `AcDream.slnx`, `src/AcDream.App/AcDream.App.csproj` - -- [ ] **Step 1: Project file** - -```xml - - - - net10.0 - enable - enable - true - - - - - - - - - - - - - -``` - -NOTE: `Chorizite.DatReaderWriter` flows transitively via AcDream.Core. If the moved code fails to resolve DRW types, add the explicit `PackageReference` pinned to the same version as Core (2.1.7) rather than bumping anything. - -- [ ] **Step 2: Register in `AcDream.slnx`** — add the project entry following the existing `` pattern (open the file, copy the shape). - -- [ ] **Step 3: App references Content** — in `src/AcDream.App/AcDream.App.csproj`, add alongside the existing ProjectReferences: - -```xml - -``` - -- [ ] **Step 4: Build** - -Run: `dotnet build` -Expected: green (empty project compiles, references resolve). - -- [ ] **Step 5: Commit** - -```bash -git add src/AcDream.Content/ AcDream.slnx src/AcDream.App/AcDream.App.csproj -git commit -m "feat(pipeline): MP1a - AcDream.Content assembly scaffold" -``` - ---- - -### Task 2: Lift TextureKey out of TextureAtlasManager - -**Files:** -- Create: `src/AcDream.Content/TextureKey.cs` -- Modify: `src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs` (delete the nested struct, use the lifted one) -- Modify: every `TextureAtlasManager.TextureKey` reference site (find with `grep -rn "TextureAtlasManager.TextureKey" src/`) - -- [ ] **Step 1: Move the struct.** Copy the nested `TextureKey` struct out of `TextureAtlasManager` VERBATIM (fields, equality/GetHashCode if present, doc comments) into: - -```csharp -namespace AcDream.Content; - -// MP1a (2026-07-05): lifted verbatim out of TextureAtlasManager (GL class) -// so the GL-free mesh-extraction path (MeshExtractor / ObjectMeshData) can -// reference the atlas dedup key without a Silk.NET dependency. Field set, -// equality semantics, and hash behavior are UNCHANGED. -public struct TextureKey -{ - // ... the verbatim body from TextureAtlasManager.TextureKey ... -} -``` - -(The plan intentionally does not restate the body — copy it exactly from the source; it is pure data: SurfaceId/PaletteId/Stippling/IsSolid per the boundary survey.) - -- [ ] **Step 2: Update all reference sites.** `TextureAtlasManager.TextureKey` → `TextureKey` with `using AcDream.Content;`. Keep a `using` alias only if a name collision appears (none expected). - -- [ ] **Step 3: Build + full test suite** - -Run: `dotnet build` then `dotnet test` -Expected: green / all passing (this is a pure type relocation). - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "refactor(pipeline): MP1a - lift TextureKey out of the GL atlas class" -``` - ---- - -### Task 3: Move the boundary data records - -**Files:** -- Create: `src/AcDream.Content/ObjectMeshData.cs` -- Modify: `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` (types removed), all reference sites (`grep -rn "ObjectMeshData\|VertexPositionNormalTexture\|TextureBatchData\|MeshBatchData" src/ tests/`) - -- [ ] **Step 1: Move these types from `ObjectMeshManager.cs`'s data region into `AcDream.Content` (namespace `AcDream.Content`), VERBATIM:** `VertexPositionNormalTexture` (the 32-byte GPU vertex — this is the pak layout target, do not alter layout/stride), `ObjectMeshData`, `MeshBatchData`, `TextureBatchData`, plus any small GL-free records the compiler then demands (e.g. setup-part / emitter-info records referenced by `ObjectMeshData` fields). - - **Explicitly stays in App:** `ObjectRenderData` and `ObjectRenderBatch` (they hold `TextureAtlasManager Atlas` — a GL class) and anything else the compiler shows touching GL. If `ObjectMeshData` itself turns out to hold a GL-typed field the survey missed, STOP and report BLOCKED. - -- [ ] **Step 2: Update reference sites** (`using AcDream.Content;` in the App files + any tests). - -- [ ] **Step 3: Build + full test suite** — green / all passing. - -- [ ] **Step 4: Commit** - -```bash -git add -A -git commit -m "refactor(pipeline): MP1a - ObjectMeshData family moved to AcDream.Content" -``` - ---- - -### Task 4: Extract MeshExtractor (the Prepare* family) - -**Files:** -- Create: `src/AcDream.Content/MeshExtractor.cs` -- Move as the compiler demands: `IDatReaderWriter.cs`, `DatCollectionAdapter.cs`, `AcSurfaceMetadata.cs`, `AcSurfaceMetadataTable.cs`, `EdgeLineBuilder.cs`, `GeometryUtils.cs` from `src/AcDream.App/Rendering/Wb/` → `src/AcDream.Content/` (verbatim, namespace change only) -- Modify: `src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs` - -- [ ] **Step 1: Create the extractor class.** New class in Content: - -```csharp -namespace AcDream.Content; - -/// -/// MP1a (2026-07-05): the GL-free CPU half of the former ObjectMeshManager — -/// dat read → polygon walk → vertex/index build → inline texture decode → -/// . Extracted VERBATIM so the MP1b bake tool -/// and the live client run the SAME extraction code (byte-identical output -/// is the pak conformance foundation). ObjectMeshManager (App) retains the -/// queue/worker lifecycle and all GL upload; it delegates here. -/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §6.1. -/// -public sealed class MeshExtractor -{ - // Constructor takes the extraction dependencies the moved methods used - // as ObjectMeshManager fields — expected from the boundary survey: - // IDatReaderWriter dats, AcSurfaceMetadataTable surfaceMetadata - // plus the CPU-side caches that belong with extraction: - // the ThreadLocal, the decoded-texture cache, bounds cache. - // Let the compiler produce the exact list — add fields here, do not - // leave them behind in App if only Prepare* uses them. -} -``` - -- [ ] **Step 2: Move the methods VERBATIM onto `MeshExtractor`, making the entry points `public`:** `PrepareMeshData`, `PrepareGfxObjMeshData`, `PrepareEnvCellMeshData`, `PrepareCellStructMeshData`, `PrepareSetupMeshData`, `PrepareCellStructEdgeLineData`, and the private helpers they call (`CollectParts`, `ComputeBounds`, `CollectDrawingBspPolygonIds`, `BuildPolygonIndices`, `BuildCellStructPolygonIndices`, `CollectEmittersFromScript`, plus whatever else the compiler names). Move the fields/caches only they use. Bodies unchanged. - -- [ ] **Step 3: Delegate from ObjectMeshManager.** It constructs one `MeshExtractor` in its constructor (passing its dat reader + metadata table) and every former `Prepare*` call site becomes `_extractor.Prepare*`. The staged-queue enqueue, worker pool, retry, and `Dispose` quiesce logic stay in `ObjectMeshManager` untouched (the MP0-investigated teardown discipline is a runtime concern; the bake tool will drive the extractor with its own simple loop in MP1b). - -- [ ] **Step 4: Move the support files** the compiler demands (list above), namespace `AcDream.Content`, bodies verbatim. `Building.cs` is NOT in scope (its GL-query fields keep it in App for now). - -- [ ] **Step 5: Build + FULL test suite** - -Run: `dotnet build` then `dotnet test` -Expected: green / all passing. The full suite is the behavior gate — no test may change. - -- [ ] **Step 6: Update the inventory doc.** In `docs/architecture/worldbuilder-inventory.md`, add an MP1a note under the extracted-code section: the CPU mesh-extraction half now lives in `src/AcDream.Content/` (list the moved files), App retains GL upload/lifecycle, Core untouched, reason = MP1 bake tool needs GL-free extraction (spec §6.1). - -- [ ] **Step 7: Commit** - -```bash -git add -A -git commit -m "refactor(pipeline): MP1a - MeshExtractor extracted to AcDream.Content (verbatim move)" -``` - ---- - -### Task 5: Launch smoke (user-gated) - -- [ ] **Step 1:** `dotnet build -c Release` — green. -- [ ] **Step 2:** Launch per the standard live-launch recipe (CLAUDE.md), user in-world for ~2 minutes: Holtburg buildings + an interior + a dungeon teleport. Acceptance: world renders identically — no missing/white/wrong geometry or textures (the #105-class signature would indicate the move broke a per-frame driver; per `feedback_extraction_perframe_drivers` the lifecycle stayed in App precisely to avoid this, so any anomaly = STOP and investigate the move). -- [ ] **Step 3:** On user confirmation, update the roadmap Track MP table (MP1a slice shipped) and commit. - -```bash -git add docs/plans/2026-04-11-roadmap.md -git commit -m "docs(pipeline): MP1a shipped - Content extraction user-gated" -``` diff --git a/docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md b/docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md deleted file mode 100644 index 4f4adc7b..00000000 --- a/docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md +++ /dev/null @@ -1,113 +0,0 @@ -# MP1b — Pak Format + acdream-bake + PakReader Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** A versioned pak file containing every `ObjectMeshData` the client's decode workers would otherwise produce at runtime (GfxObj / Setup / EnvCell mesh+texture payloads), an offline `acdream-bake` CLI that produces it by driving MP1a's `MeshExtractor`, a zero-copy mmap `PakReader`, and an equivalence test proving pak round-trip output is identical to live extraction. - -**Architecture:** The bake tool and the client run the SAME extraction code (`AcDream.Content.MeshExtractor`, extracted verbatim in MP1a) — the pak stores its serialized output, so equivalence is by construction and verified by test. Nothing in the client consumes the pak yet (that is MP1c); MP1b has **no user visual gate** — its gates are round-trip tests, the dat-gated equivalence suite, and a timed full bake. Spec: `docs/superpowers/specs/2026-07-05-modern-pipeline-design.md` §6.2–6.5 including the 2026-07-05 amendments (v1 textures = RGBA8 as produced; `PakReader` lives in `AcDream.Content`, not Core). - -**Scope discipline (v1 pak content):** ONLY the `ObjectMeshData` asset classes — this is what the decode workers produce during the 211 ms / 75.7 MB teleport storms (the MP0 baseline's target). Terrain blittables, flattened physics BSPs, scenery instance lists, and degrade tables are LATER slices (MP1c+/MP2/MP4) — do not add them. - -**Tech Stack:** .NET 10; `System.IO.MemoryMappedFiles` + `MemoryMarshal` for the reader; plain `BinaryWriter`-style serialization (little-endian, versioned); xunit in a new `tests/AcDream.Content.Tests`. - ---- - -## File map - -| File | Action | Responsibility | -|---|---|---| -| `tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj` | Create | Content-layer test project (rule 6), registered in `AcDream.slnx` | -| `src/AcDream.Content/Pak/PakKey.cs` | Create | 64-bit asset key: `type:u8 \| fileId:u32 \| reserved:u24` | -| `src/AcDream.Content/Pak/PakFormat.cs` | Create | Header/TOC binary layout constants + structs | -| `src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs` | Create | `ObjectMeshData` ⇄ bytes, versioned, deterministic | -| `src/AcDream.Content/Pak/PakWriter.cs` | Create | Streams blobs to disk, writes TOC + header | -| `src/AcDream.Content/Pak/PakReader.cs` | Create | mmap, O(log n) TOC lookup, span access, typed reads | -| `src/AcDream.Bake/AcDream.Bake.csproj` + `Program.cs` | Create | The CLI: enumerate ids → parallel extract → pak | -| `tests/AcDream.Content.Tests/*.cs` | Create | Round-trip + format + dat-gated equivalence tests | -| `AcDream.slnx` | Modify | Register both new projects | - -`AcDream.Bake` references `AcDream.Content` (+ transitively Core/DRW). No Silk.NET anywhere in either new project — both csproj files get `TreatWarningsAsErrors` + `LangVersion latest` like Content. - -## Format v1 (normative) - -All integers little-endian. File layout: `[Header][Blob region][TOC]` (TOC last so the writer streams blobs without knowing counts up front). - -**Header (64 bytes, fixed):** -``` -offset size field -0 4 magic 'ACPK' (0x4B504341) -4 4 formatVersion = 1 -8 4 portalIteration (DatCollection.Portal.Iteration) -12 4 cellIteration -16 4 highResIteration -20 4 languageIteration -24 8 tocOffset (u64) -32 4 tocCount (u32) -36 4 bakeToolVersion = 1 -40 24 reserved (zero) -``` - -**PakKey (u64):** `((ulong)type << 56) | ((ulong)fileId << 24)` — low 24 bits reserved (variant/zero in v1). `type` enum (u8): `GfxObjMesh = 1`, `SetupMesh = 2`, `EnvCellMesh = 3`. (`fileId` is the dat id: `0x01xxxxxx` GfxObj, `0x02xxxxxx` Setup, cell ids for EnvCells.) - -**TOC entry (24 bytes each):** `key u64, offset u64, length u32, crc32 u32` — entries sorted ascending by key (binary search); `crc32` over the blob bytes (corruption tripwire; reader verifies lazily on first access of each blob, logs + treats as missing on mismatch). - -**Blobs:** each 64-byte aligned (writer pads). Content = `ObjectMeshDataSerializer` output (below). Uncompressed in v1 (spec: mmap + page cache; LZ4 is a recorded future option). - -**Serialized ObjectMeshData v1:** field-by-field, versioned by the header's formatVersion. Layout rules the implementer follows: primitives raw LE; arrays as `count:i32 + payload`; blittable arrays (`VertexPositionNormalTexture[]`, `ushort[]` indices, `byte[]` texture data) written via `MemoryMarshal.AsBytes` bulk copy; strings absent (none in the type); **the `TextureBatches` dictionary is written sorted by key tuple `(Width, Height, Format)` and each inner list in original order** — bakes must be byte-reproducible run-to-run; nullable fields as `present:byte + value`. Enumerate the actual fields from `src/AcDream.Content/ObjectMeshData.cs` (the moved records: `ObjectMeshData`, `MeshBatchData`, `TextureBatchData`, `StagedEmitter`, `TextureKey`, `UploadPixelFormat/Type`, plus the DRW `Sphere`/bounding types stored as their float components) — serialize EVERY field; if a field's type resists obvious serialization, STOP and report rather than skipping it (a skipped field is a silent equivalence failure later). - ---- - -### Task 1: Test project scaffold - -- [ ] Create `tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj` — copy the shape of `tests/AcDream.App.Tests/AcDream.App.Tests.csproj` (xunit, net10.0), with a `ProjectReference` to `..\..\src\AcDream.Content\AcDream.Content.csproj`. Register in `AcDream.slnx`. -- [ ] Add a trivial smoke test (`PakKeyTests` placeholder is fine — it becomes real in Task 2). -- [ ] `dotnet build` + `dotnet test tests/AcDream.Content.Tests` green. -- [ ] Commit: `test(pipeline): MP1b - AcDream.Content.Tests scaffold` - -### Task 2: PakKey + header/TOC primitives (TDD) - -- [ ] **Failing tests first** (`PakKeyTests`, `PakFormatTests`): key compose/decompose round-trip for each asset type incl. max fileId `0xFFFFFFFF`; key ordering matches (type, fileId) ordering; header write→read round-trip preserves every field; TOC entry write→read round-trip; TOC binary size = 24 bytes/entry exactly. -- [ ] Implement `PakKey` (static compose/decompose + the `PakAssetType` enum) and `PakFormat` (header struct + read/write over `Span`/`Stream`, TOC entry struct + read/write) per the normative layout above. -- [ ] Tests green; commit: `feat(pipeline): MP1b - pak key + header/TOC primitives` - -### Task 3: ObjectMeshDataSerializer (TDD) - -- [ ] **Failing tests first** (`ObjectMeshDataSerializerTests`): build synthetic `ObjectMeshData` instances covering — empty object; vertices+indices only; multiple texture batches across multiple `(W,H,Format)` groups; setup with parts; emitters present; nullable fields both present and absent; non-empty `EdgeLines`. Round-trip each and deep-compare EVERY field (write a `ObjectMeshDataEquality` test helper that compares field-by-field with clear failure messages — this helper is also Task 6's comparator). Plus: serializing the same instance twice yields byte-identical output (determinism), and dictionaries inserted in different orders yield byte-identical output (sorted-key rule). -- [ ] Implement `ObjectMeshDataSerializer.Write(ObjectMeshData, Stream)` + `Read(ReadOnlySpan) → ObjectMeshData` per the layout rules. -- [ ] Tests green; commit: `feat(pipeline): MP1b - ObjectMeshData binary serializer (deterministic round-trip)` - -### Task 4: PakWriter + PakReader (TDD) - -- [ ] **Failing tests first** (`PakRoundTripTests`): write a pak with N synthetic blobs (via Task 3 serializer) to a temp file → open with `PakReader` → header fields match; every key found; blob spans deserialize to deep-equal objects; missing key returns false; corrupted blob (flip one byte) → crc mismatch → treated as missing + logged once; blobs are 64-byte aligned; TOC binary-search lookup verified against a linear scan for all keys. -- [ ] Implement `PakWriter` (streaming: write header placeholder → blobs with padding → TOC → seek back and finalize header) and `PakReader` (`MemoryMappedFile` + `ReadOnlySpan` accessor, lazily-verified crc set, `TryReadObjectMeshData(PakKey, out ObjectMeshData)`). No locks; document the immutability argument on the class. -- [ ] Tests green; commit: `feat(pipeline): MP1b - PakWriter + mmap PakReader` - -### Task 5: acdream-bake CLI - -- [ ] Create `src/AcDream.Bake/` console project (register in slnx). Arguments: `--dat-dir ` (required), `--out ` (default `acdream.pak` next to the dats), `--ids ` and `--landblocks ` (optional dev filters), `--threads ` (default `Environment.ProcessorCount`). -- [ ] Wiring: `DatCollection(datDir, Read)` → `DatCollectionAdapter` → one `MeshExtractor` per worker OR one shared (extractor is documented 4-worker-safe — shared is fine; sink = a `ConcurrentBag` collector whose entries are serialized under their own GfxObj keys, deduplicated by id). -- [ ] Enumeration: GfxObj ids + Setup ids via `Portal.GetAllIdsOfType<...>` (mirror how `ObjectMeshManager`/CLI tools enumerate — check `src/AcDream.Cli/Program.cs` for the idiom); EnvCell ids by walking landblock `LandBlockInfo.NumCells` per the streamer's hydration path (find the idiom in `GameWindow.BuildPhysicsDatBundle` / the EnvCell loader — read, don't invent). -- [ ] Parallel extract (`Parallel.ForEach`, `--threads`), progress line every 5 s (`baked/total, failures, elapsed, ETA`), failures logged per-id and counted, never fatal (a malformed dat entry skips that id — same as the runtime's behavior). -- [ ] Write pak via `PakWriter`; stamp the four dat iterations into the header; print a summary (counts per type, failures, output size, elapsed). -- [ ] Smoke: `dotnet run --project src/AcDream.Bake -- --dat-dir "%USERPROFILE%\Documents\Asheron's Call" --ids 0x01000001,0x02000001 --out scratch-test.pak` produces a valid pak that `PakReader` opens (assert via a quick console read-back or a test). Delete the scratch file. -- [ ] Commit: `feat(pipeline): MP1b - acdream-bake CLI` - -### Task 6: Dat-gated equivalence suite - -- [ ] `PakEquivalenceTests` in Content.Tests, skipping cleanly when dats are absent (copy the skip pattern from `tests/AcDream.Core.Tests/Conformance/DatConcurrencyStressTests.cs`). For a fixture id set (≥10 GfxObjs incl. known-tricky ones — grep the test tree for ids used by `Issue119UpNullGfxObjDumpTests`/`StipplingSurfaceEquivalenceTests` and reuse them; ≥3 Setups; ≥5 EnvCells from the Holtburg landblock `0xA9B4`): run `MeshExtractor` live AND bake the same ids to a temp pak → read back → deep-compare with the Task 3 equality helper. Any field mismatch = failure naming the field. -- [ ] Tests green (on this machine, dats present); full `dotnet test` green. -- [ ] Commit: `test(pipeline): MP1b - live-vs-pak equivalence suite (dat-gated)` - -### Task 7: Full bake run + gate record (coordinator/user machine) - -- [ ] `dotnet build -c Release` green; run the full bake against the real dats (no filters), Release. -- [ ] Record in `docs/research/2026-07-XX-mp1b-bake-report.md`: total assets per type, failures (each listed), wall time, pak size, and the equivalence-suite result. Gate: equivalence green + bake completes + failure list reviewed (a failure on an id the runtime CAN decode = bug; a failure matching runtime behavior = recorded as expected). -- [ ] Update roadmap Track MP row (MP1b shipped); commit both docs. - ---- - -## Post-plan notes - -- No divergence-register rows: nothing consumed at runtime changes in MP1b. -- The pak file itself is user-machine output — never committed; add `*.pak` to `.gitignore` in the same commit as Task 5. -- MP1c (the cutover: decode workers try `PakReader` first, stale-pak refusal UX, hitch re-measure vs the 211 ms baseline) is the next plan; its "before" numbers are already in the MP0 baseline doc. diff --git a/docs/superpowers/specs/2026-06-13-dungeon-support-design.md b/docs/superpowers/specs/2026-06-13-dungeon-support-design.md deleted file mode 100644 index 95129126..00000000 --- a/docs/superpowers/specs/2026-06-13-dungeon-support-design.md +++ /dev/null @@ -1,455 +0,0 @@ -# Phase G.3 — Dungeon Support (Design Spec) - -> **Status:** APPROVED design (brainstorm 2026-06-13). Next: `writing-plans`. -> **Milestone:** M1.5 ("Indoor world feels right"). G.3 is the remaining M1.5 -> exit-gate. M2 (CombatMath) stays deferred until this lands. -> **Issue:** [#133](../../ISSUES.md) (teleport-into-dungeon snaps to ocean) + -> [#95](../../ISSUES.md) (dungeon portal-graph visibility blowup — re-assessed -> below). -> **Supersedes** the §12 port-plan of -> [`docs/research/deepdives/r09-dungeon-portal-space.md`](../../research/deepdives/r09-dungeon-portal-space.md): -> most of R9's "new types" (EnvCell loader/renderer/physics, PortalVisibility -> BFS, multi-cell transit) already shipped and power the building/cellar demo. -> r09 stays the **retail contract reference** for the wire formats, the -> EnvCell/CellPortal layout, and the recall taxonomy. - ---- - -## 0. TL;DR - -Dungeons don't work because of **one timing+placement gap on one code path**, -not a terrain-less-pipeline rewrite. A dungeon landblock (e.g. `0x0125`, the -Holtburg-area meeting hall) is a **flat-terrain** landblock (`LandBlock` -present, all-zero heights) + 71 EnvCells + no buildings — it already streams, -renders, and collides through the existing pipeline. The teleport-arrival -handler snaps the player **before** that landblock has streamed in, so Resolve -falls back to the resident Holtburg blocks and lands the player in ocean. - -The fix is retail's own shape: **hold the player in portal space until the -destination cell is hydrated, then place into the EnvCell** — reusing the -#107/#111 login machinery — and then layer retail's portal-tunnel visual -(`TeleportAnimState`) on top. We ship it in four installments, gated by one -visual acceptance test. - ---- - -## 1. Corrected root cause (verified) - -### 1.1 The "terrain-less landblock" framing is WRONG (dat-verified) - -A prior research pass assumed dungeon landblocks have no `LandBlock` record, so -`LandblockLoader.Load` returns null and the whole streaming/render/physics -pipeline needs terrain-less support. **A direct dat probe -(`DungeonLandblockDatProbeTests`, committed) refutes that:** - -``` -0x0125 (dungeon): LandBlock 0x0125FFFF PRESENT, Height[81] allZero=True (flat) - LandBlockInfo: NumCells=71, Buildings=0, Objects=0 - EnvCells 0x0100.. present (the 71 dungeon rooms) -0xA9B4 (Holtburg): LandBlock PRESENT, heights non-zero; NumCells=123, Buildings=12, Objects=114 -``` - -A dungeon landblock is a **flat-terrain landblock** (lowest/"ocean" terrain -height index) **plus its EnvCells, no buildings/objects**. `LandblockLoader.Load` -returns a valid flat landblock; the terrain mesh builds a flat plane; -`PhysicsEngine.AddLandblock` gets a valid flat `TerrainSurface`. **The existing -pipeline already streams a dungeon landblock.** This matches ACE's `IsDungeon` -(all heights 0 + `NumCells > 0` + no buildings — `Landblock.cs:575`) and the -single-landblock rule (`Player_Tick.cs:548-560` forbids moving between dungeon -landblocks without a teleport — so "multi-landblock dungeon LOD" is moot). - -### 1.2 The real blocker: teleport TIMING + PLACEMENT - -`OnLivePositionUpdated` (`src/AcDream.App/Rendering/GameWindow.cs:4877-4961`) -detects teleport arrival as **any** player position update while in PortalSpace -(correct, per #107), then **unconditionally**: - -1. Recenters streaming to the destination landblock (`_liveCenterX/Y`, `:4908-4925`). -2. **Immediately** calls `_physicsEngine.Resolve(destPos, destCell, …)` to snap - the player (`:4927-4931`) — **before the destination landblock has streamed in**. -3. Snaps entity + controller (`:4935-4939`), exits PortalSpace (`:4950`), sends - `LoginComplete` (`:4953-4959`). - -Because the dungeon landblock isn't resident yet, Resolve can't find the -destination cell, falls back to an **outdoor scan against the still-resident -Holtburg landblocks**, and snaps to `0xA9B3000E` (Holtburg's south edge — local -`(30,−60)` maps into the block south of the A9B4 spawn). Streaming then shifts -the frame out from under the player → they slide south into ocean. ACE logs the -matching `failed transition for +Acdream from 0x01250126 … to 0xA9B0000E …` -chain (captured in `launch-dungeon-diag.log`). - -**There is no hold-until-hydration on the teleport-arrival path.** The #107 -*login* path directly above it (`GameWindow.cs:1010-1024`) HAS exactly this gate; -the teleport path doesn't. - ---- - -## 2. Grounded seam facts (the design rests on these) - -All five verified against current code this session (high confidence). - -### 2.1 Teleport-arrival + PortalSpace FSM -- `OnTeleportStarted` (`GameWindow.cs:~4971-4976`) — on `PlayerTeleport (0xF751)` - sets `_playerController.State = PlayerState.PortalSpace`, freezing movement. -- `PlayerMovementController.Update` (`PlayerMovementController.cs:840-854`) returns - a zero-movement result while `State == PortalSpace` — **PortalSpace already - doubles as the input-freeze.** It can equally serve as the hydration-wait gate. -- Exit is **only** via the arrival detection in `OnLivePositionUpdated` - (`:4880`). No timeout, no cell-hydration gate today. - -### 2.2 #107/#111 login machinery (directly reusable) -- `PhysicsEngine.IsSpawnCellReady(cellId)` (`PhysicsEngine.cs:468-472`): outdoor - (`cellId & 0xFFFF < 0x0100`) → always ready; indoor → `DataCache.GetCellStruct(cellId) - is not null` (the cell's physics BSP has hydrated). -- `IsSpawnClaimUnhydratable(claim)` (`GameWindow.cs:11728-11748`): fetches the dat - `LandBlockInfo` at `(lb & 0xFFFF0000) | 0xFFFE`; a claim whose low word is - `>= 0x0100 + NumCells` (or `NumCells==0`) can **never** hydrate → reject fast - (distinguishes a bogus claim from a not-yet-streamed one). -- #107 login hold (`GameWindow.cs:1010-1024`): `isSpawnGroundReady` waits for - terrain AND (claim outdoor OR `IsSpawnCellReady` OR `IsSpawnClaimUnhydratable`). - No timeout today (login can afford to wait forever; teleport cannot — see §5). -- #111 validated-claim placement (`PhysicsEngine.cs:626-646`): when - `snapDiag (zero-delta) && adjustedFound && indoor`, place via - `WalkableFloorZNearest` (`:383-406`) — projects Z onto the claim cell's **own - physics walkable polygons** (`normal.Z >= PhysicsGlobals.FloorZ`, 0.6642), - cell-local, nearest to the reference Z. Returns `null` if the cell isn't - hydrated → falls through to the legacy `bestCell` scan (**the ocean bug**). -- **The teleport-arrival Resolve call is already the same shape as login entry.** - The gate only needs to sit in front of it; no change to Resolve or - WalkableFloorZNearest. (Both already key on the full prefixed cell id + - indoor/outdoor.) - -### 2.3 Streaming far recenter (works as-is) -- `StreamingRegion.RecenterTo` (`StreamingRegion.cs:180-283`) recomputes the - near/far Chebyshev window **from scratch** around the new center — a 42 km jump - is treated identically to a 1-step move. No incremental-movement assumption. -- Drain: `StreamingController` applies ≤ `MaxCompletionsPerFrame` (default 4) - results/frame; `ApplyLoadedTerrainLocked` (`GameWindow.cs:5941-6150`) does GPU - upload + cell-visibility registration + AABB + `PhysicsEngine.AddLandblock` + - EnvCell/portal registration. Estimate: **~7-8 frames (~120-130 ms)** to hydrate - a 5×5 near window; physics ready +1-2 frames. -- Recenter keeps the old neighborhood until hysteresis unload (NearRadius+2 - demote, FarRadius+2 unload), so the player isn't instantly stranded. -- **New code needed:** reuse the #107 login-gate **terrain-ready signal** - `_physicsEngine.SampleTerrainZ(x,y) is not null` (non-null once the destination - terrain landblock has applied) — no separate "landblock applied" query is - required. Plus dest-coord validation (reject out-of-world coords — a malformed - portal dest would otherwise leave the player in an invisible, unloadable - landblock). - -### 2.4 EnvCell hydration coupling (latent landmine — decouple) -- In `BuildInteriorEntitiesForStreaming` (`GameWindow.cs:5564-5651`), both - `BuildLoadedCell` (the portal-visibility node) **and** - `_physicsDataCache.CacheCellStruct` (the physics BSP) sit **inside** the render - guard `if (cellSubMeshes.Count > 0)` (`:5602`). A cell whose render mesh is empty - (`CellMesh.Build` returns nothing — e.g. all-untextured/`Stippling.NoPos` polys) - silently gets **no visibility node and no collision**, even if it has walkable - physics polygons. `CellTransit.FindTransitCellsSphere` then `GetCellStruct → null - → continue` (silently skips it) → fall-through-floor. -- A normal dungeon *room* has textured walls → non-empty submeshes → the guard - passes, so this is **probably not the meeting-hall blocker** — but it is a real - correctness landmine for any geometry-less collision cell, and decoupling is - cheap and retail-correct (physics/visibility do not depend on visible geometry). - **Fix:** gate `CacheCellStruct` on `cellStruct.PhysicsBSP != null` and - `BuildLoadedCell` on `cellStruct != null`, independent of the render submesh - count. (`CacheCellStruct` already early-returns on null BSP internally — - `PhysicsDataCache.cs:172` — so moving it out is safe.) - -### 2.5 #95 — dungeon portal-graph visibility blowup (RE-ASSESSED: likely superseded) -- ISSUES.md #95 (`888-913`): on a 2026-05-21 **A6.P1 scen5 (Town Network hub)** - trace, `visibleCells` per cell exploded to 135-145 with spurious cells from - landblocks `0x020A`/`0x0408` (other dungeons). Its "Files" point at the WB - `EnvCellRenderManager`/`VisibilityManager` + the Streaming cell-cache. -- **That code path was DELETED by the T1-T6 render rewrite (2026-06-11)** (T4: - "per-frame ACME BFS deleted… InteriorRenderer/DrawPortal deleted"). The current - flood, `PortalVisibilityBuilder.Build`, (a) confines neighbors to the camera - cell's landblock (`lbMask = cameraCell.CellId & 0xFFFF0000`, `:131`) and (b) has - **enqueue-once termination** (`queued` HashSet, `:165` — "at most N cells are - ever processed"). Since AC dungeons are single-landblock, that confinement is - *correct*, and the cross-landblock 135-cell blowup **structurally cannot - reproduce**: a single-landblock flood visits ≤ `NumCells` distinct cells (71 for - the meeting hall). -- **Verdict (pre-gate, 2026-06-13 AM):** #95's evidence is stale, from a deleted - path; the current pipeline looked bounded. Treated #95 as likely superseded. -- **⚠️ GATE CORRECTION (2026-06-13 PM — #95 is CONFIRMED LIVE):** the G.3a visual - gate ran a real `PlayerTeleport` into the `0x0007` dungeon (Town Network). The - core hold+place worked (player grounded on the dungeon floor, z=0 — no ocean), - but **WB-DIAG exploded to entSeen=6.5M / instances=9.1M / drawsIssued=590K per - frame** (vs. 3345 / 4667 at Holtburg), with a flood of `[mesh-miss] 0x000100xxxx` - interior re-requests → the dungeon renders as "thin air." **#95 reproduces under - the current Option-A pipeline.** The "bounded flood" reasoning was wrong for the - `0x0007` dungeon (the grounding agent's "still live" verdict was correct; this - doc over-discounted it). **G.3b is now REQUIRED, not conditional** (§3.2). The - retail-faithful fix shape stands: port `CEnvCell::grab_visible_cells` (:311878) - stab_list bounding — a `seen_outside==0` cell walks ONLY its `stab_list`. - ---- - -## 3. The plan (Approach C — phased full-G.3) - -Each installment lands a **complete retail behavior** (the BR-2 half-port -lesson). The visual gate sits as early as possible, right after the core. - -### 3.1 G.3a — Core teleport-into-dungeon (the blocker) - -**Goal:** teleporting into the meeting-hall dungeon lands the player standing in -the dungeon cell, on the floor, with walls blocking — no ocean, no ACE -`failed transition` spam. - -**New component — `TeleportArrivalController`** (`src/AcDream.App/World/`): -- Owns a small phase: `Idle / Holding / Placing`, plus `_pendingArrival` - `(destPos, destCellId, deadline)`. -- Lives outside `GameWindow` (Code Structure Rule 1: no new feature bodies in the - god-object). `GameWindow.OnLivePositionUpdated` hands the arrival to it and - calls its per-frame `Tick`; `GameWindow` keeps only the wiring. -- Unit-testable in isolation (no GL, fake readiness predicate + fake Resolve). - -**Control flow (replaces the unconditional snap at `GameWindow.cs:4927-4950`):** -1. On arrival update in PortalSpace: validate `destCellId`'s landblock coords are - in-world; recenter streaming + prioritize-load the dest landblock (existing - path); stash `_pendingArrival`; enter `Holding`. Re-send `LoginComplete` - immediately (holtburger-conformant — `messages.rs:434`; do **not** wait for - assets to send it). -2. Each frame in `Holding`, evaluate the **readiness predicate**: - - `IsSpawnClaimUnhydratable(destCell)` → impossible claim: stop holding, place - via the safety-net demote (loud log), exit PortalSpace. - - `now > deadline` (timeout, ~10 s) → force-snap via safety-net demote + loud - log, exit PortalSpace. (See §5 — failure-surfacing, not symptom-masking.) - - `SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell))` - → ready: go to 3. - - else stay frozen, retry next frame. -3. `Placing`: call the **existing** `Resolve(destPos, destCell, Vector3.Zero, …)`. - Because the cell is now hydrated, Resolve takes the #111 validated-claim branch - → `WalkableFloorZNearest` grounds the player on the EnvCell floor. Snap entity - + controller (existing `:4935-4939` code), exit PortalSpace, resume input. - -**Readiness predicate — reuse the #107 login triplet (no new query).** The -hold gates on exactly the three checks the login auto-entry gate already uses -(`GameWindow.cs:1010-1024`), evaluated against the teleport's `(destPos, -destCell)` instead of the spawn claim: `SampleTerrainZ(destPos.X, destPos.Y) is -not null` (destination terrain applied) ∧ (outdoor cell OR -`IsSpawnCellReady(destCell)`); `IsSpawnClaimUnhydratable(destCell)` short-circuits -an impossible claim to immediate placement. This reuses proven, validated code -rather than introducing a parallel "landblock applied" query. - -**Dest-coord validation:** in `OnLivePositionUpdated`, reject a destination whose -`(lbX, lbY)` is out of the world grid before recenter; log + abort the teleport -hold rather than recenter to a phantom block. - -**Hydration decouple (§2.4):** move `BuildLoadedCell` + `CacheCellStruct` out of -the `cellSubMeshes.Count > 0` guard in `BuildInteriorEntitiesForStreaming`. Gate -each on its own non-null precondition. - -**Acceptance (G.3a):** the visual gate in §6. This gate also empirically settles -#95 (does the flood blow up?) and the hydration coupling (does collision work?). - -### 3.2 G.3b — #95 visibility bounding (REQUIRED — gate-confirmed 2026-06-13) - -**The G.3a gate confirmed the blowup** (9.1M instances/frame in `0x0007`), so this -is the next blocker, not a conditional follow-up. The dungeon will not render -until the portal-visibility flood is bounded to the dungeon's own cell adjacency. - -**Fix:** port retail `CEnvCell::grab_visible_cells` (`:311878`) — a cell with -`seen_outside == 0` loads ZERO terrain and walks ONLY its `stab_list` of adjacent -EnvCells; the portal graph is bounded by the dungeon's own cell adjacency, never a -radius / never the whole resident cell set. This is a render-pipeline change in -`PortalVisibilityBuilder` (the flap-/DO-NOT-RETRY-sensitive area) and needs its own -grounding + brainstorm before implementation (verify the dat carries the stab_list -and acdream's EnvCell loader parses it; confirm the `seen_outside` flag is read; -decide how it composes with the outdoor-root look-in floods). **NOT a wing-it -inline fix.** - -**Open question surfaced at the gate (possible Bug C):** even with Bug A fixed -(placement keeps the dungeon prefix, `2ce5e5c`), the dungeon's negative-local-Y -coordinate frame may cause the per-tick membership/landblock resolution to drift -(the ACE `movement pre-validation failed` spam). Re-gate after Bug A to see if it -persists; if so, fold the dungeon-coordinate membership handling into G.3b's -grounding (it is plausibly the same `seen_outside` / cross-landblock root as #95). - -### 3.3 G.3c — Portal-tunnel loading visual (faithful `TeleportAnimState`) - -**Goal:** the retail portal-space transition, ported faithfully (user decision -2026-06-13). Reconciles the older r09 §6 ("there is no loading screen") with the -named-retail decomp where this FSM actually lives. - -**Oracle:** `gmSmartBoxUI::BeginTeleportAnimation` (`004d6300`, named-retail line -218888) + the per-frame FSM (`219405-219774`). States: -`TAS_WORLD_FADE_OUT → TAS_TUNNEL_FADE_IN → TAS_TUNNEL / TAS_TUNNEL_CONTINUE → -TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → (off)`. `m_pPortalSpace` is a -`UIElement_Viewport` rendering the tunnel scene (creature-mode objects + -`DISTANT_LIGHT` + smartbox FOV; `SetVisible(1)` on enter, `SetVisible(0)` on the -`TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN` edge at `219742-219747`). - -**Key architectural unification:** the `TAS_TUNNEL`/`TAS_TUNNEL_CONTINUE` **hold -state's exit gates on the same readiness predicate as G.3a** — retail's loading -visual and the hold-until-hydration gate are *one mechanism* (the tunnel is the -visual form of the hold). G.3a ships the bare PortalSpace freeze; G.3c wraps it -in the tunnel viewport + the fade FSM, exit-gated identically. - -**Port workflow:** grep-named → decompile `BeginTeleportAnimation` + the FSM → -pseudocode (durations, fade math, viewport scene construction) → port → test. -Detail deferred to the G.3c implementation phase; this spec fixes the design -(states, transitions, the readiness-gated hold) + the oracle pointers. - -### 3.4 G.3d — Recall game-actions - -Outbound **zero-payload** game-action builders (r09 §7.1): `TeleToLifestone -0x0063`, `TeleToHouse 0x0262`, `TeleToMansion 0x0278`, `TeleToMarketPlace 0x028D`, -`RecallAllegianceHometown 0x02AB`, `TeleToPkArena 0x0027`. The client only sends -the request; the server validates, plays the recall animation, then drives the -**same** `PlayerTeleport → UpdatePosition` arrival flow. - -Value: (1) doubles as the **easy test lever** for G.3a/G.3c — `/ls` triggers a -teleport with no portal-click choreography; (2) completes the recall UX (keybinds -exist; the wire sends + return handling did not). Wire through the existing -command bus. - ---- - -## 4. Data flow (the teleport happy path) - -``` -1. PlayerTeleport(0xF751) → OnTeleportStarted: enter PortalSpace, freeze input - [G.3c: BeginTeleportAnimation(TAS_WORLD_FADE_OUT)] -2. fake UpdatePosition(destCell) → validate dest coords → recenter streaming to dest lb - → prioritize-load dest lb → re-send LoginComplete -3. HOLD (TeleportArrivalController.Tick, each frame in PortalSpace): - ready = SampleTerrainZ(destPos) != null && (outdoor || IsSpawnCellReady(destCell)) - - not ready → stay frozen, retry [G.3c: tunnel holds in TAS_TUNNEL/_CONTINUE] - - impossible → IsSpawnClaimUnhydratable → safety-net demote + loud log - - timeout → force-snap + loud log + leave PortalSpace -4. READY → Resolve(destPos, destCell) → #111 validated-claim branch - → WalkableFloorZNearest places on the EnvCell floor - → SetPosition(entity + controller) → exit PortalSpace, resume input - [G.3c: TAS_TUNNEL_FADE_OUT → TAS_WORLD_FADE_IN → off] -``` - -(ACE server send-order, for reference — `Player_Location.Teleport:686`: -`PlayerTeleport(seq)` → fake `UpdatePosition` (start client load) → -`DoTeleportPhysicsStateChanges` (hidden / ignoreCollisions) → real -`UpdatePosition` → `OnTeleportComplete` after `CreateWorldObjectsCompleted`.) - ---- - -## 5. Error handling - -| Failure | Handling | No-workaround rationale | -|---|---|---| -| Impossible / poisoned claim (cell id ∉ `[0x0100, 0x0100+NumCells)`, or no struct + no surface) | `IsSpawnClaimUnhydratable` → safety-net demote (`PhysicsEngine.Resolve` head, `:536-570`) + loud log; never hold forever | Reuses the validated #107/#111 reject; no new masking | -| Dest LB fails to stream (worker crash / corrupt dat / OOB coords) | Timeout ceiling (~10 s) → force-snap + loud log + leave PortalSpace | **Surfaces** the failure (visible bad placement + log), does not freeze the client or silence the cause; gets a divergence-register row | -| Mid-hold entity-rescue race | Already serialized by `_datLock` during recenter (verified, seam-3) | No change | - -The timeout is the one judgment call: holding forever on a never-hydrating -landblock would soft-lock the client. The chosen behavior **fails loudly and -visibly** (force-snap + log), which is the opposite of a symptom-masking grace -period — it makes a broken teleport obvious rather than hiding it. It is recorded -as a deliberate adaptation (retail loads synchronously; async streaming has no -direct analog). - ---- - -## 6. Testing & acceptance - -### 6.1 Headless / unit -- `TeleportArrivalController` FSM: `Idle → Holding → Placing` happy path; - impossible-claim immediate reject; timeout force-snap; ready-predicate gating - (fake `IsLandblockApplied` / `IsSpawnCellReady`). -- Hydration-decouple test: a geometry-less EnvCell (empty render mesh, non-empty - physics BSP) still gets `CacheCellStruct` + `BuildLoadedCell`. -- `TeleportFlowTests`: fake `PlayerTeleport` + `UpdatePosition` wire → controller - phase transitions + input-gate flips. -- `DungeonLandblockDatProbeTests` (exists): pins `0x0125` = flat + 71 cells. -- G.3c: `TeleportAnimState` FSM transition test (state sequence + the - readiness-gated `TAS_TUNNEL` hold-exit). -- G.3d: recall-builder byte tests (opcode + empty payload, per builder). - -### 6.2 Visual gate (the acceptance test — after G.3a) -Teleport into the meeting-hall dungeon via the portal: -- Player stands **in the dungeon cell**, on the floor (not ocean, not falling). -- The dungeon renders; navigate **3–5 rooms**; **walls block** movement. -- **No ocean / no ACE `failed transition` spam.** -- (Implicitly) the portal flood does **not** blow up (#95 check) and collision - works in every room (hydration-coupling check). - -`ACDREAM_PROBE_CELL=1` + `ACDREAM_PROBE_VIEWER=1` + `ACDREAM_WB_DIAG=1` + the -always-on `[snap]` / `live: teleport` lines capture the chain (the -`launch-dungeon-diag.log` protocol from this session). - -### 6.3 Per-installment build/test gates -Each installment: `dotnet build` green + `dotnet test` green -(App / Core / UI / Net suites) before it's "done"; G.3a additionally requires the -visual gate. - ---- - -## 7. Retail divergence register impact - -- **G.3a timeout force-snap** → NEW row (adaptation: async streaming hold has no - synchronous-retail analog; retail loads the cell set synchronously before - `SetPositionInternal`). -- **Hydration decouple** → NO row (bug fix retiring an incidental render↔physics - coupling; restores retail-correct independence). -- **G.3c** → only a row if a faithful asset can't be reproduced (e.g. the tunnel - viewport scene) and a documented courtesy substitute is shipped. -- **#95 close-as-superseded** (if G.3b not triggered) → ISSUES.md note only. - ---- - -## 8. Component boundaries (what each unit does / depends on) - -| Unit | Location | Does | Depends on | -|---|---|---|---| -| `TeleportArrivalController` | `AcDream.App/World/` | Owns the `Idle/Holding/Placing` phase + `_pendingArrival`; decides hold-vs-place each frame | readiness predicate (injected), `Resolve` (injected), PortalSpace state | -| readiness predicate | `PhysicsEngine` (reused #107 triplet) | `SampleTerrainZ(pos)` ∧ (outdoor ∨ `IsSpawnCellReady(cell)`); `IsSpawnClaimUnhydratable(cell)` | `DataCache`, dat `LandBlockInfo` | -| hydration decouple | `GameWindow.BuildInteriorEntitiesForStreaming` | `BuildLoadedCell` + `CacheCellStruct` gated on cellStruct/BSP, not render mesh | `cellStruct`, `PhysicsBSP` | -| `TeleportAnimState` FSM (G.3c) | `AcDream.App` UI/render | Portal-tunnel fade FSM; hold-exit gated on the readiness predicate | `m_pPortalSpace` viewport, the readiness predicate | -| recall builders (G.3d) | `AcDream.Core/Network/Actions` | Zero-payload outbound game actions | command bus | - -`AcDream.Core` gains no GL/window dependency. The controller + FSM live in -`AcDream.App`; the readiness predicate's physics half lives in `AcDream.Core` -(pure), its streaming half in `AcDream.App`. - ---- - -## 9. References cited - -- **Current code (verified this session):** `GameWindow.cs` 4877-4961 (arrival), - ~4971-4976 (`OnTeleportStarted`), 1010-1024 (#107 login gate), 11728-11748 - (`IsSpawnClaimUnhydratable`), 5564-5651 (EnvCell hydration guard), 5941-6150 - (`ApplyLoadedTerrainLocked`); `PhysicsEngine.cs` 468-472 (`IsSpawnCellReady`), - 626-646 (#111 validated claim), 383-406 (`WalkableFloorZNearest`), 536-570 - (Resolve safety net); `StreamingRegion.cs` 180-283 (`RecenterTo`); - `StreamingController.cs` 120-149 (drain); `PortalVisibilityBuilder.cs` 131 - (lbMask), 165 (enqueue-once); `CellTransit.cs` 515-516 (null-skip); - `PhysicsDataCache.cs` 172 (null-BSP early-return). -- **Decomp (named-retail):** `BeginTeleportAnimation` `004d6300` (line 218888) + - the `TeleportAnimState` FSM 219405-219774; `m_pPortalSpace` viewport - 218829/219363; `CEnvCell::grab_visible_cells` `:311878` (G.3b stab_list). -- **holtburger:** `messages.rs:434` (client re-sends `LoginComplete` on teleport). -- **ACE:** `Player_Location.Teleport:686` (send order); `Landblock.cs:575` - (`IsDungeon`); `Player_Tick.cs:548-560` (single-landblock dungeons); recall - handlers + `Portal.ActOnUse`/`AdjustDungeon`. -- **r09 deepdive:** `docs/research/deepdives/r09-dungeon-portal-space.md` (EnvCell - / CellPortal wire layout, recall taxonomy, the retail contract). -- **Issues:** [#133](../../ISSUES.md), [#95](../../ISSUES.md). -- **Digests (DO-NOT-RETRY tables apply):** `project_render_pipeline_digest`, - `project_physics_collision_digest`. - ---- - -## 10. Open questions (resolved here; revisit only if the gate disagrees) - -1. **Loading visual now or later?** Faithful `TeleportAnimState` in G.3c (user - decision). Unified with the G.3a hold (the tunnel IS the hold's visual). -2. **Hold timeout/failure?** Reject impossible claims instantly - (`IsSpawnClaimUnhydratable`); hold plausible-but-slow with a ~10 s ceiling; - on timeout force-snap + loud log (fail visibly, never freeze). -3. **Big-jump streaming?** Verified to work (Chebyshev recenter). Add only - dest-coord validation; the readiness gate reuses `SampleTerrainZ` (no new - streaming query). -4. **EnvCell placement vs flat terrain?** The #111 `WalkableFloorZNearest` EnvCell - path (identical to the cellar path that already works); the flat terrain - renders below. The gate guarantees the cell is hydrated before Resolve runs. -5. **(New, deferred to G.3b/implementation)** Does the dat carry a parsed - `stab_list` for `grab_visible_cells` bounding? Only matters if the gate shows - the #95 blowup. diff --git a/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md b/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md deleted file mode 100644 index 70b8e20f..00000000 --- a/docs/superpowers/specs/2026-06-14-d2b-retail-panel-frame-design.md +++ /dev/null @@ -1,392 +0,0 @@ -# D.2b — Retail panel frame + live Vitals (Approach C: KSML-style engine) — Design - -**Date:** 2026-06-14 -**Status:** Design approved (brainstorm) + **re-grounded 2026-06-14** onto the existing `AcDream.App/UI/` retained-mode scaffold (see §0). Pending spec re-review → implementation plan. -**Phase:** D.2b — Custom retail-look UI backend ([roadmap:427](../../../docs/plans/2026-04-11-roadmap.md)) -**Milestone:** M5 "Looks like retail" — **explicitly PARALLELIZABLE with M3/M4** ([milestones:378](../../../docs/plans/2026-05-12-milestones.md)). Opened as a parallel track while M1.5 is the active critical-path milestone; the M5 parallelizable flag is the milestone-discipline carve-out. -**Grounding:** read-only research workflow `wf_39a90d37-e5a` (7 readers + gap-critic) + a direct read of `src/AcDream.App/UI/`. Every binding fact cites `file:line` in `src/` or a named-retail symbol. - ---- - -## 0. Re-grounding correction (read this first) - -The first draft of this spec proposed building a `RetailPanelHost : IPanelHost` + -`RetailPanelRenderer : IPanelRenderer` and a retained-mode toolkit *from scratch*. -**That was wrong.** A direct read of `src/AcDream.App/UI/` found a **complete, -dormant retained-mode toolkit** — the 2026-04-17 scaffold the roadmap names as -"the implementation foundation here" ([roadmap:427](../../../docs/plans/2026-04-11-roadmap.md)): - -- **`UiRoot`** ([UiRoot.cs](../../../src/AcDream.App/UI/UiRoot.cs)) — the hard - part is already built: mouse routing, keyboard focus, mouse capture, a full - drag-drop state machine, tooltip timer, modal handling, click/right-click - detection, world fall-through. Retail-faithful event codes in - [UiEvent.cs](../../../src/AcDream.App/UI/UiEvent.cs). -- **`UiElement`** (geometry/tree/hit-test), **`UiPanel`/`UiLabel`/`UiButton`** - ([UiPanel.cs](../../../src/AcDream.App/UI/UiPanel.cs)), **`UiHost`** - ([UiHost.cs](../../../src/AcDream.App/UI/UiHost.cs) — packages `UiRoot` + - `TextRenderer` + font, with `Tick`/`Draw`/`WireMouse`/`WireKeyboard`), - **`UiRenderContext`** ([UiRenderContext.cs](../../../src/AcDream.App/UI/UiRenderContext.cs) - — transform stack + `DrawRect`/`DrawString`). - -`UiHost` is **dormant** — never instantiated in `GameWindow` (verified: `new -UiHost(` appears only in a doc-comment). And `UiPanel.cs` is the *exact file* -divergence row TS-30 points at: it draws a flat translucent rect *"until our -AcFont/UiSpriteBatch consumes [9-slice dat sprites] directly."* - -**Consequence:** the retail UI is this existing `UiRoot` tree — a separate system -from the ImGui `IPanelRenderer` path, **not** an `IPanelRenderer` implementation. -Spec 1 *wires the dormant `UiHost`* and *adds the few missing pieces*, rather than -building a backend. This is strictly less code and more faithful. §4/§5/§8/§9/§10 -below are written against the scaffold. - -*(Process note: the grounding workflow's "UI" readers keyed on the ImGui/Abstractions -framing in their prompts and never globbed `src/AcDream.App/UI/`. Lesson: a -subsystem-discovery pass must glob by directory, not only by the framing the -parent already has in mind.)* - -## 1. Context & goal - -acdream needs a retail-faithful game UI. The shipped path (D.2a) is an ImGui -overlay gated on `ACDREAM_DEVTOOLS=1` — a debugger aesthetic, intentionally -temporary. D.2b stands up the *retail-look* UI (the dormant `UiHost` tree) that -draws retail's actual dat assets, while the ImGui devtools path stays untouched. - -**The user's framing (2026-06-14):** AC's UI engine is Keystone — and Keystone -was *already* markup + stylesheet (KSML, an HTML-clone XML defined by `ksml.xsd`, -+ `controls.ini`, a CSS-like INI stylesheet). So "make it look + behave like -retail, but author it in a CSS/HTML-style way" re-expresses AC's own design in -its modern equivalent. - -**Approach decision (Approach C).** Three integration families were weighed: -(A) embed a real web engine (Ultralight/CEF), (B) a native HTML/CSS-subset lib -(RmlUi), (C) our own KSML-style markup + stylesheet over a retained-mode toolkit -on Silk.NET. **C chosen** for: zero external deps (keeps the native-AOT goal -intact), lowest memory (~3–10 MB vs CEF's 150–300 MB), full control, and maximal -faithfulness — it mirrors Keystone directly, and the retained-mode toolkit C -needs *already exists* (§0). - -This spec covers **Spec 1**: wire the scaffold + add the markup/stylesheet/sprite -gaps, proven end-to-end on **one** panel — the universal window frame wrapping -the live Vitals bars. - -## 2. Scope - -**In Spec 1:** -- Wire the dormant **`UiHost`** into `GameWindow`, gated by a new - `RuntimeOptions.RetailUi` toggle (`ACDREAM_RETAIL_UI=1`). The ImGui devtools - path is untouched and may run simultaneously. -- Add dat-sprite drawing: `UiRenderContext.DrawSprite` (UV-rect) + a - `TextRenderer` textured-sprite path + a `ui_text.frag` `uUseTexture=2` branch. -- A **`UiNineSlicePanel : UiPanel`** that draws the 8-piece dat-sprite window - frame + center fill (upgrading the exact code TS-30 cites) — title bar - (`UiLabel`) + a close button (`UiButton`, which already exists). -- A **`UiMeter : UiElement`** vital bar bound to a `Func` reading - `VitalsVM`. -- The XML markup format (mirrors `ElementDesc`) + a `MarkupDocument` parser that - **instantiates a `UiElement` subtree** + a minimal `controls.ini` stylesheet - loader. -- The plugin-facing contract: plugins contribute a `UiElement`/markup subtree - added to `UiRoot` (§9) — designed now, first consumer first-party. - -**Deferred to later sub-phases (explicitly OUT):** -- **Wiring `UiHost`'s input** (`WireMouse`/`WireKeyboard`) into the existing - Phase-K `InputDispatcher`. The `UiRoot` input *machinery* exists; *integrating* - two input consumers (route unconsumed `WorldMouseFallThrough` back to the game) - is its own concern. Spec 1 is **render-only** (`Tick` + `Draw`), so the frame + - live bars show but the close button isn't clicked and the window isn't dragged. -- The dat A8 glyph font loader (`AcFont`) → numeric overlays. -- The full anchor solver (`StateDesc::UpdateSizeAndPosition` port). -- The `LayoutDesc` binary importer (sub-project 3). -- Reskinning Chat / Debug / Settings. -- Login / char-select / chargen screens (raw-JPEG backgrounds, sub-project 4). - -## 3. Source-verified facts (do-not-trust list) - -The grounding caught several load-bearing "facts" that were wrong/unverified. -These are binding: - -| Claimed (memory / first draft) | Reality (source-verified) | -|---|---| -| Build a retained-mode toolkit + `RetailPanelHost`/`RetailPanelRenderer` | The toolkit **exists** in `src/AcDream.App/UI/` (§0); the retail UI is the `UiRoot` tree, not an `IPanelRenderer` backend | -| `VitalsVM` is `record VitalsVM(int HpCurrent, …)` | Sealed class: `HealthPercent` (float), `StaminaPercent`/`ManaPercent` (float?), `*Current`/`*Max` (uint?), ctor `(CombatState, LocalPlayerState?)`, `SetLocalPlayerGuid(uint)` — [VitalsVM.cs:35](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs) | -| Chrome sprite IDs `0x06004CC2`/`0x21000040`/`0x060074BF..C6` are known | **Unverified + contradictory.** `0x06001125` (cited elsewhere) is the char-select highlight. **No chrome ID is trusted — Step 0 dat prove-out resolves them empirically.** | -| `#FFDBD6A8` "parchment cream" is the panel background | It is `[editbox]`/`[treeview]` **text** color. Real frame tokens: `[title]` bg `#FFFFFFFF`, font `Verdana-10-bold`, height 19; `[body]` bg `#00000000` (transparent), `color_border=#FF4F657D` | -| `DatCollection` is NOT thread-safe | Concurrent **reads are safe** since v2.1.7 ([2026-06-09 investigation](../../../docs/research/2026-06-09-dat-reader-thread-safety-investigation.md)). No UI-specific lock. | -| KSML is the panel-layout language | KSML is **rich-text-in-text-regions**; the panel layout format is the binary `LayoutDesc`/`ElementDesc` tree ([acclient.h:33693](../../../docs/research/named-retail/acclient.h)). Our markup mirrors `ElementDesc`. | - -## 4. Architecture & placement - -The retail UI lives in **`src/AcDream.App/UI/`** (where the scaffold already is). -New widgets/parsers join it as dedicated files. Code-Structure Rule 1 is honored -(nothing substantial added to `GameWindow.cs` — only a few wiring lines); Rule 2 -(Core stays GL-free) and Rule 3 (panels target `AcDream.UI.Abstractions`) are -unaffected because the retail UI is a *separate* tree, not an `IPanelRenderer` -panel. - -``` -┌──────────────────────────────────────────────────────────┐ -│ retail dat (read-only fidelity source) │ -│ controls.ini → style tokens · RenderSurface 0x06xxxxxx │ -│ → sprites · Font 0x40xxxxxx → glyphs (deferred) │ -└───────────────┬──────────────────────────────────────────┘ - │ TextureCache.GetOrUpload(id) → Texture2D -┌───────────────▼──────────────────────────────────────────┐ -│ src/AcDream.App/UI/ (scaffold EXISTS; + = new in Spec 1) │ -│ UiHost (exists, dormant) ─ wire into GameWindow │ -│ UiRoot/UiElement (exist) ─ input + tree + hit-test │ -│ UiRenderContext (exists) + DrawSprite(UV-rect) │ -│ UiPanel/UiLabel/UiButton (exist) │ -│ + UiNineSlicePanel : UiPanel (8-piece dat chrome) │ -│ + UiMeter : UiElement (vital bar) │ -│ + MarkupDocument (XML → UiElement subtree) │ -│ + ControlsIni (stylesheet loader) │ -│ uses Rendering/TextRenderer (+ sprite path, + DepthMask) │ -└───────────────┬──────────────────────────────────────────┘ - │ UiMeter.Fill = () => vm.HealthPercent -┌───────────────▼──────────────────────────────────────────┐ -│ AcDream.UI.Abstractions (exists) — VitalsVM (unchanged) │ -│ ↑ ImGui IPanelHost/IPanelRenderer path stays for │ -│ ACDREAM_DEVTOOLS, fully independent of the above │ -└──────────────────────────────────────────────────────────┘ -``` - -**Coexistence.** Two UI systems run side by side, independently: -`ACDREAM_DEVTOOLS=1` → the ImGui overlay (unchanged); `ACDREAM_RETAIL_UI=1` → -the `UiHost` tree. The retail pass renders in the post-3D slot -([GameWindow.cs:8232 region](../../../src/AcDream.App/Rendering/GameWindow.cs)) -with deterministic ordering relative to ImGui. `UiHost.Draw` already does -`TextRenderer.Begin → Root.Draw(ctx) → TextRenderer.Flush` -([UiHost.cs:58](../../../src/AcDream.App/UI/UiHost.cs)). - -## 5. Render foundation — extend the existing 2D path - -`UiHost` draws the `UiRoot` tree through a `UiRenderContext` backed by the shared -`TextRenderer` ([UiHost.cs:58-67](../../../src/AcDream.App/UI/UiHost.cs)). That -`TextRenderer` does solid rects + R8 text today but **not** textured RGBA sprites -([ui_text.frag:9](../../../src/AcDream.App/Rendering/Shaders/ui_text.frag), -[TextRenderer.cs](../../../src/AcDream.App/Rendering/TextRenderer.cs)). Spec 1 -adds the sprite path: - -- **`ui_text.frag`** += a `uUseTexture==2` branch: `FragColor = texture(uTex, - vUv) * vColor;` (the existing `0`=solid and `1`=R8-coverage branches are - untouched). -- **`TextRenderer`** += `DrawSprite(uint texture, float x,y,w,h, float - u0,v0,u1,v1, Vector4 tint)` accumulating into **per-texture** sprite buffers - (`Dictionary>`), and a `Flush` pass that, after rects+text, - draws each texture's batch with `uUseTexture=2`. Reuses the existing - `AppendQuad` (which already takes `u0,v0,u1,v1`) + `UploadBuffer` machinery. -- **`TextRenderer.Flush`** += explicit **`DepthMask(false)`** (queried + restored) - — it disables `DepthTest` today but never sets `DepthMask` - ([TextRenderer.cs:171](../../../src/AcDream.App/Rendering/TextRenderer.cs)). - Per the project's "render self-contained GL state" rule. -- **`UiRenderContext`** += `DrawSprite(uint texture, float x,y,w,h, float - u0,v0,u1,v1, Vector4 tint)` that adds the current transform and forwards to - `TextRenderer.DrawSprite` (mirrors the existing `DrawRect` forwarder at - [UiRenderContext.cs:50](../../../src/AcDream.App/UI/UiRenderContext.cs)). - -No new shader class, VAO, or batcher — we extend the proven path the scaffold -already uses. (`Shader` is the simple file-based class -[Shader.cs](../../../src/AcDream.App/Rendering/Shader.cs); `GLSLShader`'s bindless -machinery is not needed.) - -## 6. Dat assets & the Step-0 prove-out gate - -`TextureCache.GetOrUpload(uint surfaceId)` returns a conventional `Texture2D` -GL handle (1×1 magenta on failure) — exactly right for the UI batch -([TextureCache.cs:70](../../../src/AcDream.App/Rendering/TextureCache.cs)). The -decode chain + `PFID_*` formats already work -([SurfaceDecoder.cs:39](../../../src/AcDream.Core/Textures/SurfaceDecoder.cs)). -`GameWindow` already holds a `TextureCache`; `UiHost`/`UiNineSlicePanel` receive -it (or a `Func` sprite-resolver) by injection. - -**Step 0 is empirical and comes first.** Because no chrome sprite ID is verified, -the first implementation task draws each candidate ID -(`0x06004CC2`, `0x060074BF..C6`, `0x0600129C`, …) as a raw quad and visually -confirms which decode to frame-shaped art vs magenta vs the wrong sprite. The -confirmed IDs are recorded in code comments before any chrome layout is written. -**No ID is hardcoded on faith.** - -The frame is **8 quads + a center fill** (4 corner + 4 edge sprites + center), -not one stretched 9-slice texture. Slice/edge metrics are a **documented stopgap -constant** (with a divergence row) until the `LayoutDesc` tree is parsed -(sub-project 3). - -## 7. Markup + stylesheet model - -**Markup** mirrors `ElementDesc` 1:1 ([acclient.h:33693](../../../docs/research/named-retail/acclient.h)); -`MarkupDocument` parses it and **instantiates a `UiElement` subtree** (a -`UiNineSlicePanel` with child `UiLabel`/`UiMeter`/`UiButton`). Authoring shape: - -```xml - - - - - -``` - -This is the shape the future `LayoutDesc` importer will *emit*, so authoring and -imported formats converge. It is **not** KSML (rich-text, deferred). `{Binding}` -expressions resolve against a supplied binding object (the `VitalsVM`) via -reflection on the property name. - -**Anchor codes** are *defined* now (0=fixed, 1=stretch, 2=proportional-translate, -3=center, 4=proportional-scale — from `StateDesc::UpdateSizeAndPosition` -@`0x0069BF20`) but the **solver is deferred**: the Vitals window is fixed-size -(placed via the existing `UiElement.Left/Top`), so Spec 1 needs no solver. - -**Stylesheet.** A small INI loader parses `controls.ini` keyed by element-type -section, honoring `#AARRGGBB` (alpha-first) and `font://Face-Pt[-style]`. Cascade: -element-type defaults → per-element `class=` → inline attributes. **Optional** -(§10): absent AC install → source-verified `[title]`/`[body]` fallback tokens. - -## 8. VM binding (the Vitals slice) - -The vitals panel is a `UiNineSlicePanel` (chrome) containing a `UiLabel` (title) -and three `UiMeter`s. Each `UiMeter` holds a `Func Fill` bound to the -real `VitalsVM` ([VitalsVM.cs:67](../../../src/AcDream.UI.Abstractions/Panels/Vitals/VitalsVM.cs)): -`() => vm.HealthPercent`, `() => vm.StaminaPercent`, `() => vm.ManaPercent`. The -VM already does all server plumbing, so we do **not** re-derive vitals from the -retail `gmVitalsUI`/`CACQualities` decomp. - -`UiMeter.OnDraw` draws the empty bar (`ctx.DrawRect`), the filled portion as a -**partial-size rect** (`width = pct * Width`), and a centered `current/max` numeric -overlay (`Func Label`). **Retail's vitals ARE exactly this — three stacked -horizontal bars (confirmed against a live retail client 2026-06-14), NOT orbs.** -Colors: Health red, **Stamina gold** (the earlier `#10F0F0` cyan research note was -wrong), Mana blue. A `null` fill/label (pre-`PlayerDescription`) renders gracefully. -The remaining gap to pixel-retail is the **glassy gradient bar fill sprite** + the -**retail dat font** for the numbers (today the stub `BitmapFont` draws them) — both -polish, deferred to §15. - -The `VitalsVM` is constructed and given the player GUID the same way as today -([GameWindow.cs:1330](../../../src/AcDream.App/Rendering/GameWindow.cs) ctor, -:1984 `SetLocalPlayerGuid`); the retail-UI build path reuses that same VM -instance. - -## 9. Plugin contract (designed now, first consumer first-party) - -The plugin API is a day-1 constraint; plugin authors must be able to add retail -UI. The natural unit is a **`UiElement`/markup subtree added to `UiRoot`** (not -`IPanel`/`IPanelRenderer`, which is the ImGui devtools path). Spec 1 adds: - -- A small `IUiRegistry` (in `AcDream.Plugin.Abstractions`) — `void - AddMarkupPanel(string markupPath, object binding)` (and/or `void - AddElement(UiElement)` once a plugin-safe element surface is decided). For - Spec 1, `AddMarkupPanel` is enough. -- `IPluginHost` gains `IUiRegistry Ui { get; }` - ([IPluginHost.cs:8](../../../src/AcDream.Plugin.Abstractions/IPluginHost.cs) - has none today); `AppPluginHost` implements it - ([AppPluginHost.cs:5](../../../src/AcDream.App/Plugins/AppPluginHost.cs)). -- Because plugin `Enable()` runs in `Program.cs` **before** the GL window opens - ([Program.cs:55-60](../../../src/AcDream.App/Program.cs)), `AddMarkupPanel` - **buffers** registrations into a list that `GameWindow` drains into `UiRoot` - after `UiHost` is constructed. The threading/timing concern lives in the host; - the plugin call is unconditional. -- The first consumer is the first-party vitals panel (built directly in - `GameWindow`, not through the registry). Wiring an actual plugin-supplied markup - panel end-to-end is exercised by a smoke test but is otherwise the thin - follow-up. This task group is the **last** in the plan so the visible vitals - slice can land first if it slips. - -## 10. Confirmed decisions (approved 2026-06-14) - -1. **Render-only first slice.** `Tick` + `Draw` only; the `UiHost` input wiring - (`WireMouse`/`WireKeyboard`) is **not** connected to the existing Phase-K - `InputDispatcher` yet, so the close button isn't clickable and the window - isn't draggable. Rationale (corrected): the `UiRoot` input *machinery* already - exists — what's deferred is *integrating two input consumers* (routing - unconsumed `WorldMouseFallThrough` back to the game's dispatcher), which is its - own sub-phase. -2. **`controls.ini` optional.** Assume `C:\Turbine\Asheron's Call\` may or may not - exist. Add an `ACDREAM_AC_DIR` `RuntimeOptions` field; when absent, fall back - to the source-verified `[title]`/`[body]` token values. The build never fails - on a missing AC install. - -## 11. Build sequence - -| Step | Deliverable | Proves | -|---|---|---| -| 0 | Dat prove-out harness: draw candidate chrome IDs, confirm the real ones | Resolves the chrome-ID contradiction empirically | -| 1 | `ui_text.frag` `uUseTexture=2` + `TextRenderer.DrawSprite` + `DepthMask` + `UiRenderContext.DrawSprite` | A dat sprite composites over the 3D scene | -| 2 | `UiNineSlicePanel` draws an empty titled frame from confirmed dat sprites (stopgap insets) | Retail-shaped chrome renders | -| 3 | `UiMeter` + a hand-built vitals `UiNineSlicePanel` subtree bound to `VitalsVM`, wired via `UiHost` under `ACDREAM_RETAIL_UI` (render-only) | End-to-end live data + the scaffold lights up | -| 4 | `ControlsIni` parser (TDD) feeding the panel's title/colors | Stylesheet cascade | -| 5 | `MarkupDocument` parser (TDD) → builds the same vitals subtree from `vitals.xml` | The Approach-C markup engine | -| 6 *(last)* | `IUiRegistry` on `IPluginHost` + buffered drain into `UiRoot` + smoke test | Plugin-ready | - -## 12. Error handling & edge cases - -- **Missing/undecodable sprite** → `GetOrUpload` magenta fallback is visible; - Step 0 catches it. A null/zero DataID in markup logs a warning, draws nothing. -- **AC install absent** → `controls.ini` load skipped, baked fallback tokens used. -- **Vitals null percents** → empty bar (`UiMeter.Fill` returns null). -- **Window resize** → `UiHost.Draw` already sets `Root.Width/Height` to the - current screen size each frame ([UiHost.cs:61](../../../src/AcDream.App/UI/UiHost.cs)); - fixed-coord panels stay put. No DPI scaling (known out-of-scope gap). -- **Both toggles on** → ImGui Vitals and retail Vitals may both show (fine in dev). - -## 13. Testing - -- **`ControlsIni` parser** (pure, no GL) — unit tests for `#AARRGGBB`, `font://`, - cascade order. Lives in `src/AcDream.App/UI/`, tested in `tests/AcDream.App.Tests/` - (App-layer, Rule 6). -- **`MarkupDocument` parser** — unit tests for XML → `UiElement` tree shape - (types, geometry) and `{Binding}` resolution against a fake binding object. -- **`UiMeter` fill geometry** — unit test that fill fraction → partial rect width - (pure math; `UiMeter.ComputeFillRect(pct, w, h)` as a static helper so it's - testable without GL). -- **`UiNineSlice` geometry** — unit test that a frame size + insets → the 9 dst - rects (`UiNineSlicePanel.ComputeSliceRects` static helper). -- **Plugin smoke** — a test plugin calls `host.Ui.AddMarkupPanel` and the buffered - registration is drained (assert the panel is added to `UiRoot`). -- **Visual acceptance** (user) — retail-shaped Vitals frame with live bars under - `ACDREAM_RETAIL_UI=1`; ImGui path unaffected under `ACDREAM_DEVTOOLS=1`. -- `dotnet build` + `dotnet test` green. - -## 14. Bookkeeping - -- **Phase D.2b**, Milestone **M5** (parallelizable; NOT on the M1.5 critical - path). The CLAUDE.md "Current state" line stays on M1.5. -- **Divergence register:** in the commit that ships `UiNineSlicePanel` rendering a - real dat sprite, **delete row TS-30** ([retail-divergence-register.md:166](../../../docs/architecture/retail-divergence-register.md)) - — its cited file (`UiPanel.cs`) is upgraded by the subclass — and **add one** - new IA-row (Intentional Architecture; keystone.dll has no PDB/decomp) for the - markup/serialization layer. Assign the next sequential IA number at commit time. - Retail oracle: "LayoutDesc 0x21xxxxxx; controls.ini panel-property vocabulary; - keystone.dll layout evaluation (no PDB)". Do **not** duplicate IA-12 (UI - toolkit *behavioral* approximation). A second row for the stopgap slice insets - is added when they ship. -- **Spec file:** this document. - -## 15. Open gaps & deferred sub-projects - -- **Input integration** — connect `UiHost.WireMouse`/`WireKeyboard` to the Phase-K - `InputDispatcher`, routing unconsumed `WorldMouseFallThrough`/`WorldKeyFallThrough` - back to the game. Next sub-phase (lights up the close button + window drag that - `UiRoot` already supports). -- **`AcFont`** — dat A8 glyph loader (Font `0x40000xxx` → `ForegroundSurfaceDataId` - → RenderSurface, upload as **R8** so `ui_text.frag`'s `.r`-coverage branch works - unchanged) → numeric overlays + retail fonts. (Today `UiLabel` uses the - stb_truetype `BitmapFont`.) -- **Anchor solver** — port `StateDesc::UpdateSizeAndPosition`; with the importer. -- **`LayoutDesc` binary importer** (sub-project 3) — bulk-transpile retail layouts - → our markup, supplying real insets + coords. Symbols: `LayoutDesc::InqFullDesc` - @`0x0069A520`, `ElementDesc::Incorporate` @`0x0069B5A0` - ([2026-05-08 pseudocode](../../../docs/research/2026-05-08-retail-ui-layout-resolution-pseudocode.md)). -- **`PFID_CUSTOM_RAW_JPEG`** decode + login/char-select/chargen (sub-project 4). - -## 16. Acceptance criteria - -- [ ] Step 0 prove-out done; real chrome sprite IDs confirmed + recorded in code. -- [ ] In `ACDREAM_RETAIL_UI=1`: a retail-shaped Vitals window renders via the wired - `UiHost` — `UiNineSlicePanel` 8-piece dat-sprite border + title + drawn close - button — with three `UiMeter` bars tracking HP/Stam/Mana live as the - character takes damage / regens. -- [ ] In `ACDREAM_DEVTOOLS=1`: ImGui Vitals/Chat/Debug/Settings unchanged. -- [ ] `controls.ini` loads when present, falls back cleanly when absent. -- [ ] `MarkupDocument` builds the vitals subtree from `vitals.xml`; pure parsers - unit-tested; plugin smoke test drains a buffered `AddMarkupPanel`. -- [ ] TS-30 deleted + one new IA-row added, same commit as the chrome. -- [ ] `dotnet build` green, `dotnet test` green. -- [ ] Visual verification by the user. diff --git a/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md b/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md deleted file mode 100644 index 342ed53d..00000000 --- a/docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md +++ /dev/null @@ -1,267 +0,0 @@ -# D.2b — Chat-window re-drive (LayoutDesc importer, Plan 2 chat piece) — design - -**Date:** 2026-06-15 -**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track; lighting/M1.5 is a separate branch off main) -**Status:** design — approved scope, pending spec review -**Predecessor:** the LayoutDesc importer + the vitals re-drive -(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`, -`docs/research/2026-06-15-layoutdesc-format.md`, -`claude-memory/project_d2b_retail_ui.md`). -**Handoff that opened this work:** `docs/research/2026-06-15-chat-window-redrive-handoff.md`. - ---- - -## 1. Goal - -Replace the hand-authored retail chat window (a `UiNineSlicePanel` hosting a -`UiChatView` at a guessed rect, built inline in `GameWindow.cs` under -`if (_options.RetailUi)`) with the **data-driven retail chat window** read from -the dat `LayoutDesc 0x21000006` (`gmMainChatUI`) via the existing `LayoutImporter`, -with **faithful behavioral widgets ported from the named retail decomp** and the -**dat font** — the same way the vitals window became data-driven. - -**The code is modern. The behavior is retail.** Every widget algorithm is ported -from `docs/research/named-retail/acclient_2013_pseudo_c.txt` with a cited -`class::method @address`. - -## 2. Approved scope - -**In scope (faithful core):** -- Data-driven window frame from `0x21000006` (bg sprites, resize bar, grip chrome, - translucency). -- Transcript: dat-font `UIElement_Text` port — scrollable, bottom-pinned, - per-line chat-kind color, 10k-glyph behead cap. -- Scrollbar: right-side track + thumb + up/down buttons, **pixel-based** scroll, - `thumbRatio = view/content`, wheel = **1 line per notch**. -- Input: editable one-line field — caret, insert/delete, 100-entry command - history (up/down arrow), focus sprite, Enter→submit. -- Channel menu (`Chat ▸`): dropdown of channels; selection sets the active - outbound channel (the `ChatInputParser` default channel). -- Send button + max/min button. -- `ChatCommandRouter`: the shared submit pipeline, extracted from `ChatPanel` - so the ImGui devtools chat and the retail chat share one routing path. - -**Deferred (each gets a `retail-divergence-register.md` row — these need *non-UI* -plumbing acdream lacks, they are NOT UI scope cuts):** -- **Numbered chat tabs (1–4) — switching + per-tab chat-type filtering.** The tab - *sprites* render (they come free from the importer), but clicking a tab to filter - which chat kinds show needs the per-tab `m_llTextTypeFilter` / - `m_chatNewNonVisibleTextIndicator` system. -- **Squelch toggle** (menu item 0) — needs a squelch subsystem. -- **Clickable name-tags** (`StartTell` on click) — needs `StringInfo`/`TextTag` - styled runs in `ChatLog`. -- **In-element word-wrap at panel width** — the transcript renders pre-split - `ChatLog` lines 1:1; faithful `GlyphList::Recalculate` wrap reworks the - selection/hit-test model (visual-row ≠ record). Highest-risk UI piece; deferred. -- **Per-glyph mixed-color runs / configurable font face+size** (`gmClient::sm_nFontFace`). -- **Active/inactive opacity switch** — a single default translucency is in scope; - the focused-brighter / unfocused-dimmer transition is deferred. - -## 3. Retail reference (the port target) - -`gmMainChatUI` (registered element class `0x10000041`, built from `LayoutDesc -0x21000006`) extends a base **`ChatInterface`**. `ChatInterface` owns the -transcript, input, inbound routing, submit, history, truncate and opacity; -`gmMainChatUI` adds the channel menu, squelch, max/min, tab visibility and -clickable name-tags. - -### 3.1 Element → role map (`0x21000006`) - -| Element | Type | Role | Decomp anchor | -|---|---|---|---| -| `0x1000000E` | `0x10000041` | window root (`gmMainChatUI`), bg `0x0600114D`, 800×100 authored | `gmMainChatUI::Register @0x4cd350` | -| `0x1000000F` | 9 Resizebar | top resize bar, img `0x06001125`, cursor `0x06005E66` | — | -| `0x1000046F` | 0 | max/min button (`Maximized 0x06005E64` / `Minimized 0x06005E65`) | `gmMainChatUI::HandleMaximizeButton @0x4cce50` | -| `0x10000010` | 3 Field | transcript panel bg `0x06001115` | — | -| **`0x10000011`** | 0 (UIElement_Text) | **transcript** — read-only, multiline, scrollable | `ChatInterface::PostInit @0x4f3e47` | -| `0x1000048c` | 0 | scroll **thumb** (child of transcript) | `ChatInterface::PostInit @0x4f3e79` | -| `0x10000012` | 0 | scrollbar **track** (right edge, 16×68) | `UIElement_Scrollable::GetScrollbarPointer_ @0x473ec0` | -| `0x10000013` | 3 Field | input bar bg `0x0600113A` | — | -| `0x10000014` | 6 Menu | **channel menu** (`Chat ▸`) — 14 items (squelch + 13 channels) | `gmMainChatUI::InitTalkFocusMenu @0x4cdc50`, `HandleSelection @0x4cd540` | -| **`0x10000016`** | 0 (UIElement_Text) | **input** — editable, one-line, focus sprite `0x060011AB` | `ChatInterface::PostInit @0x4f3e86` | -| `0x10000017/18` | 3 | input focus edges (sprite `0x06004D67`) | — | -| `0x10000019` | 0 | **Send** button (`0x06001915`/pressed `…16`/ghost `…34`) | `ChatInterface::ListenToElementMessage @0x4f51ea` | -| `0x10000522–525` | 0 | **numbered chat tabs 1–4** (left strip; Normal `0x06006218`/Hi `0x06006219`) | `gmMainChatUI::RecvNotice_SetPanelVisibility @0x4ccd80` | - -> **Screenshot correction (user-provided retail ground truth, 2026-06-15):** the -> four `0x10000522–525` elements are the **left-edge numbered chat tabs**, NOT the -> "line/page scroll buttons" a research agent inferred from their 16×16 vertical -> geometry. The scrollbar (track + thumb + up/down) is on the **right**. The exact -> dat ids of the right-side scroll up/down buttons are located during Task D -> (likely children of track `0x10000012` not surfaced in the top-level dump). - -> **BN field-name caveat:** the decomp's `m_chatEntry` / `m_chatLog` / -> `m_fCurrentOpacity` names are applied inconsistently across functions (a -> Binary-Ninja artifact). The roles above are fixed by the decisive evidence — -> the `Normal_focussed` sprite is on `0x10000016` (only an editable field gets a -> focus state) and the multiline geometry is `0x10000011` — corroborated by both -> surviving research agents. Port by **role**, not by the C++ member name. - -### 3.2 Key retail algorithms (cited) - -**Inbound** — `ChatInterface::RecvNotice_DisplayFinalStringInfo @0x4f4640`: -append `arg4` (prefix/timestamp) then `arg3` (body) to the transcript via -`UIElement_Text::AppendStringInfoWithFont` with per-chat-type color `arg2` (color -table built by `BuildChatColorLookupTable`). If glyph count > `0x2710` (10000), -`TruncateChatLog @0x4f4290` beheads from the top at a newline boundary. **Bottom-pin:** -capture `IsAtVerticalEnd` *before* appending; if it was true, `ScrollToPosition` -to the new end; else light the unread-text indicator. - -**Submit** — `ChatInterface::HandleEnterKey @0x4f52d0` (fired by the *Accept* -input-action, not raw `\r` — one-line mode drops the `\r` char) → `ProcessCommand -@0x4f5100`: read input text, dispatch, push to `m_InputHistory` (cap 100, drop -index 0 when full), reset history cursor to `0xFFFFFFFF`, clear input. The Send -button (`0x10000019`) is an alternate trigger via `ListenToElementMessage`. - -**Scroll** — `UIElement_Scrollable`: pixel offset `m_iScrollableY`, clamped to -`[0, contentHeight − viewHeight]` (`SetScrollableXY @0x4740c0`). `thumbRatio = -view/content` clamped to 1, bar hidden when content ≤ view -(`UpdateScrollbarSize_ @0x4741a0`). `posRatio = scrollY/(content−view)` -(`UpdateScrollbarPosition_ @0x473f20`). Scroll quantum = one line-height -(`UIElement_Text::InqScrollDelta @0x4689b0`); page = view height; **wheel = 1 line -per notch** (`HandleMouseWheel @0x471450`). - -**Input** — caret `m_nCursorPos` (glyph index); `GlyphList::FindPixelsFromPos -@0x472b40` = Σ glyph advances to the caret; midpoint-snap hit-test -`FindPosFromLineAndPixels @0x4732d0`; ~1 Hz blink. Glyph advance = -`HorizontalOffsetBefore + Width + HorizontalOffsetAfter` (signed bytes, -`Font::GetCharWidthA @0x4433f0`) — **already implemented** by -`UiDatFont.GlyphAdvance`. History: `SelectCommandFromHistory` (up=back, down=fwd), -sentinel `0xFFFFFFFF` = "not browsing". - -**Channel menu** — `InitTalkFocusMenu @0x4cdc50` fills `UIElement_Menu 0x10000014` -with 14 items: item 0 = squelch toggle, items 1–13 = channels carrying attr -`0x1000000B` = channel enum (1=Say, 2=Tell/Target, 3=Emote, 4=Fellowship, -5=Patron, 6=Trade, 7=Allegiance, 8–0xD=area/custom). `HandleSelection @0x4cd540` -reads the enum, calls `SetTalkFocus(enum)`, updates the label, marks the item -selected. - -## 4. Architecture (acdream) - -Faithful structure: an importer builds the generic frame; a **controller** -(`ChatInterface`+`gmMainChatUI`::PostInit analogue) binds behavior by element id -and swaps the transcript/input placeholders for behavioral widgets. New classes -live in `src/AcDream.App/UI/` (widgets, GL-side) and `src/AcDream.UI.Abstractions/` -(the shared submit router). - -| Component | Kind | Retail analogue | Responsibility | -|---|---|---|---| -| `ChatWindowController` | new (`App/UI/Layout/`) | `ChatInterface` + `gmMainChatUI` PostInit | import `0x21000006`; `FindElement(id)`; swap transcript/input widgets; wire scrollbar/menu/send/max-min; route in/outbound | -| `UiChatView` | **extend** (`App/UI/`) | `UIElement_Text` (transcript) | + `UiDatFont? DatFont`; dat-font measure/advance/draw; **1-line** wheel quantum; keep bottom-pin + drag-select + Ctrl+C | -| `UiChatInput` | new (`App/UI/`) | `UIElement_Text` (editable) | caret, insert/delete, 100-entry history, focus sprite swap, dat font, `Action? OnSubmit` | -| `UiScrollable` | new (`App/UI/`) | `UIElement_Scrollable` | pixel scroll math: `ScrollY`, `ContentHeight`, `ViewHeight`, `ClampScroll`, `ThumbRatio`, `ThumbOffsetRatio`, line/page delta | -| `UiChatScrollbar` | new (`App/UI/`) | composed scrollbar | own the imported track/thumb/up-down sprites; size+place thumb from `UiScrollable`; clicks/drag → `UiScrollable` | -| `UiChannelMenu` | new (`App/UI/`) | `UIElement_Menu` | dropdown popup of channels; selection → active `ChatChannelKind`; label reflects selection | -| `ChatCommandRouter` | new (`UI.Abstractions/Panels/Chat/`) | `ProcessCommand` | shared submit: client-command intercept → unknown-verb guard → `ChatInputParser.Parse(text, channel, lastTell, lastOutgoing)` → `Publish(SendChatCmd)` | -| `UiDatFont` | no change | `Font` | already implements retail glyph advance | - -**Why two widget classes (`UiChatView` + `UiChatInput`) when retail uses one -`UIElement_Text` with mode bits:** acdream's retained-mode widget layer predates -D.2b; the behavioral contract (read-only multiline scroll vs editable one-line) is -identical, only the class split differs. Accepted **ADAPTATION** divergence; both -classes share the `UiDatFont.GlyphAdvance` measure seam so geometry is consistent. - -**Placeholder swap:** the transcript (`0x10000011`) and input (`0x10000016`) -render no background sprite of their own (bg comes from parent panels -`0x10000010` / `0x10000013`), so `ChatWindowController` reads each placeholder's -rect + anchors, instantiates `UiChatView` / `UiChatInput` there, adds it to the -placeholder's parent, and removes the placeholder. Mirrors `GetChildRecursive(id)` -binding in `ChatInterface::PostInit`. - -## 5. Data flow - -- **Inbound:** `ChatLog → ChatVM.RecentLinesDetailed()` (200-deep tail) → - `UiChatView.LinesProvider` (per-`ChatKind` color via `RetailChatColor`). Pipeline - unchanged. Bottom-pin + 10k cap are `UiChatView`/`UiScrollable` behavior. -- **Outbound:** `UiChatInput.OnSubmit(text)` → - `ChatCommandRouter.Submit(text, vm, commandBus, activeChannel)` → `SendChatCmd` - → `LiveCommandBus` → `WorldSession`. `activeChannel` comes from `UiChannelMenu`. -- **Channel:** `UiChannelMenu` selection → `ChatWindowController._activeChannel` - (→ `ChatInputParser` default channel) + menu label update. -- **Scroll:** transcript content height → `UiScrollable` → `UiChatScrollbar` thumb; - wheel/buttons/drag → `UiScrollable.ScrollY` → transcript draw offset. - -## 6. Faithfulness decisions / divergence-register rows - -Add on landing (category in parens): -1. **(Adaptation)** Transcript + input are two classes (`UiChatView`/`UiChatInput`) - not one mode-flagged `UIElement_Text`. Behavior identical. -2. **(Approximation)** Transcript renders pre-split `ChatLog` lines 1:1; no - in-element word-wrap at panel width. Symptom: long lines not re-wrapped on - horizontal resize. `file:line` = `UiChatView.cs`. -3. **(Approximation)** One color per display line, not per-glyph styled runs. -4. **(Stopgap)** Numbered chat tabs render but don't switch / filter chat kinds. -5. **(Stopgap)** Squelch toggle + clickable name-tags render/parse-absent. -6. **(Approximation)** Single default translucency; no focused/unfocused opacity - transition; default dat font face+size (no `sm_nFontFace` config). - -Retire nothing (no existing register row is fixed by this work). - -## 7. Build sequence (tasks for the plan) - -Pipelineable where independent; `ChatWindowController` (G) and the `GameWindow` -cutover (H) are the integration barrier. - -- **A. `ChatCommandRouter`** — extract the submit flow from `ChatPanel` into a - pure `UI.Abstractions` helper; `ChatPanel` calls it; tests for client-command / - unknown-verb / parse / publish parity. *(UI.Abstractions; no GL.)* -- **B. `UiChatView` dat-font seam** — add `UiDatFont? DatFont`; prefer it in draw + - `HitChar` advance + selection measure + `LineHeight`; change `WheelLines` 3→1; - keep `BitmapFont` fallback. Tests: advance/hit-test with a synthetic dat font. -- **C. `UiScrollable`** — port `UIElement_Scrollable` math (pixel clamp, thumb - ratio/offset, line/page delta). Pure, fully unit-tested (no GL). -- **D. `UiChatScrollbar`** — own imported track/thumb/up-down sprites; size+place - thumb from `UiScrollable`; wheel/button/drag → scroll. Locate the right-side - up/down button ids in the dat here. -- **E. `UiChatInput`** — editable one-line widget: caret (`MeasurePrefix` = - `UiDatFont.MeasureWidth(text[..caret])`), insert/delete, Home/End/arrows, - 100-entry history with `−1`=live sentinel, focus sprite swap, `OnSubmit`. Tests - for caret math + history. -- **F. `UiChannelMenu`** — channel dropdown (port `UIElement_Menu` minimally); - 13 channels → `ChatChannelKind`; selection event + label. -- **G. `ChatWindowController`** — `LayoutImporter.Import(0x21000006)`; bind by id; - swap transcript/input; wire scrollbar/menu/send/max-min; route inbound (ChatVM) - + outbound (`ChatCommandRouter`); translucency. -- **H. `GameWindow` cutover** — replace the hand-authored - `UiNineSlicePanel`+`UiChatView` block with `ChatWindowController`; default - bottom-left position + resizable; remove dead code; add divergence rows; - `dotnet build` + `dotnet test` green. - -## 8. Testing strategy - -- **Pure/unit (no GL, no dats):** `ChatCommandRouter` parity; `UiScrollable` - clamp/thumb/delta golden values from the decomp; `UiChatInput` caret index ↔ - pixel + history navigation; `UiChatView` dat-font advance/hit-test via the - `Func` seam. -- **Layout/import (dat-free fixture):** extend the importer fixture pattern with a - `chat_21000006.json` tree (via `ImportInfos`) asserting the element→role map and - rects. -- **Real-dat smoke:** `LayoutImporter.Import(0x21000006)` against the live dat - resolves the root + all bound ids before wiring (guarded, like the vitals smoke). -- **Visual acceptance (user):** launch live `ACDREAM_RETAIL_UI=1`; compare to the - retail screenshot — transcript scrolls, input types + sends, channel menu - switches, Send works, scrollbar drags, window moves/resizes, translucency. - -## 9. Acceptance criteria - -- [ ] Chat window is built from `LayoutDesc 0x21000006` via `LayoutImporter` — no - hand-authored chat rect remains in `GameWindow.cs`. -- [ ] Transcript renders inbound chat in the **dat font**, per-`ChatKind` color, - bottom-pinned, 10k-cap, mouse-wheel = 1 line/notch, drag-select + Ctrl+C kept. -- [ ] Right-side scrollbar: thumb sizes to content, drag + up/down scroll the - transcript. -- [ ] Input: type, caret moves, backspace/delete, up/down history, **Enter and the - Send button both submit** through `ChatCommandRouter` → wire. -- [ ] `Chat ▸` menu opens, lists channels, selection changes the outbound channel - + updates the label. -- [ ] Max/min toggles window height; window moves + resizes; translucent frame. -- [ ] Every ported widget cites a `class::method @address`; every deferral has a - divergence-register row. -- [ ] `dotnet build` + `dotnet test` green; user visual sign-off. - -## 10. Deferred / follow-ups (filed, not built) - -In-element word-wrap (+ selection rework); numbered-tab switching + per-tab chat -filtering; squelch; clickable name-tags; per-glyph styled runs; configurable font -face/size; active/inactive opacity transition; the unidentified top-level Type-5 -ListBox `0x1000001D` (not bound by `ChatInterface`; likely a floaty/options element). diff --git a/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md b/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md deleted file mode 100644 index 1fb36f07..00000000 --- a/docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md +++ /dev/null @@ -1,216 +0,0 @@ -# LayoutDesc Importer — Design - -**Date:** 2026-06-15 -**Status:** Approved (brainstorm) — pending spec review → implementation plan -**Track:** D.2b retail UI engine (next sub-phase; register the phase id in the roadmap before implementation) -**Supersedes nothing. Deletes nothing.** Coexists with the existing hand-authored path. - -## Context - -D.2b shipped a working retail vitals window and a scrollable chat window, but each was -built by **hand**: dump the dat `LayoutDesc`, transcribe sprite ids + rects into -`vitals.xml` / `UiMeter` / `UiNineSlicePanel`, then discover-and-patch missing details -(the bar fill model, the dat-font, the tiling, the resize-grip overlay) one at a time. -That archaeology does not scale to AC's dozens of windows, and it keeps *missing* details -that are already in the dat (the grip overlay was found only because the user spotted it). - -The `LayoutDesc` dat is a **complete, declarative description of every window** — element -tree, positions, sizes, anchors, sprites per state, draw-modes, fonts, borders, grips, -meters, labels, inheritance. It is retail's "HTML for windows." The fix is to **render the -dat** with one faithful interpreter rather than transcribe it per window. - -## Goal - -Build a faithful `LayoutDesc` interpreter that reads a retail layout from the dat and -produces a `UiElement` tree the existing toolkit renders — so opening any retail window is -one call, with **no per-window graphics/layout code**. The only per-window code is live -**data wiring** (which is inherently per-window and tiny). - -### Non-goals - -- Re-porting Keystone's C++ framework (its own renderer, string/container classes, vtable - dispatch, D3D blits). We port retail's **render algorithms**, not its framework — that is - what Silk.NET + .NET already provide. (See "Decisions → Structure".) -- Deleting or rewriting the existing toolkit/widgets/markup. They are reused. - -## Decisions (from brainstorm 2026-06-15) - -1. **Proof target = re-drive vitals.** Point the importer at the vitals `LayoutDesc` - (`0x2100006C`) and make it reproduce the hand-built window. Known-good baseline → clean - pass/fail. The hand-authored vitals path stays as the reference until the importer matches. -2. **Scope = full faithful interpreter.** Interpret the *complete* `LayoutDesc` format - (every element type, full `BaseElement`/`BaseLayoutId` inheritance, all draw-modes, - states, properties) — not just the slice vitals uses. Matches the project's - "behavior is retail" ethos. -3. **Structure = hybrid (Approach C).** Port each element type's render **algorithm** - verbatim from the decomp, onto our modern draw primitives. A single generic renderer - handles the trivial "stamp the sprite per draw-mode" types (the long tail, including - types not yet catalogued); dedicated widgets handle types with real behavior (meter, - text, scrollbar/chat, button). The decomp's render method for each type *decides* which - bucket it falls in — we do not guess. Faithfulness comes from porting the algorithms; - the hybrid is only about C# packaging. -4. **Coexistence, don't-delete.** `MarkupDocument` stays as the path for plugin/custom - panels (no dat layout). The existing widgets (`UiMeter`, `UiNineSlicePanel`, - `UiChatView`, `UiDatFont`) and primitives (tiling, scissor-fill, dat-font, nine-slice) - become the importer's behavioral renderers. - -## Architecture & data flow - -``` -RETAIL WINDOWS (data-driven from the dat) - client_portal.dat ─► LayoutImporter ─► UiElement tree ─► UiRoot ─► renderers ─► screen - (LayoutDesc 0x21..) │ (UiDatElement + - │ behavioral widgets) - ├─ resolve BaseElement / BaseLayoutId inheritance - ├─ walk ElementDesc tree → widget (hybrid factory) - └─ apply rect / anchors / states / media / props from the dat - - per-window Controller ─► binds LIVE data to elements by id (mirrors retail gm*UI) - WindowManager ─► open/close by layout id, z-order, focus, position persistence - -PLUGIN / CUSTOM PANELS (hand-authored, unchanged) - *.xml ─► MarkupDocument ─► UiElement tree ─► (same UiRoot + renderers) -``` - -Two input paths (dat importer for retail windows, markup for custom/plugin), one rendering -toolkit. Nothing in the bottom (`UiHost`/`UiRoot`/`UiElement`) or the render primitives -changes. - -## Components - -### 1. Format enumeration (Step 0 — foundational groundwork) - -Because we chose "full faithful," the first deliverable is a **documented map** of the -complete format, not code. Sources, cross-checked against each other: - -- **DatReaderWriter types** — `ElementDesc`, `StateDesc`, `MediaDesc*` and their enums - (`Type`, `DrawMode`, media kinds, state keys). Reflect/inspect as `dump-vitals-layout` - already does (props **and** fields). -- **Retail decomp** — the `UIElement_*` class hierarchy + each type's render method; the - property-key meanings; the **KSML keyword registrations** (the parser registers every - property name — the canonical vocabulary, e.g. `KW_DRAWMODE`, `KW_DURATION`, …). -- **Real layouts** — scan a sample of `LayoutDesc`s to confirm which Types/properties - actually occur and catch anything the above missed. - -Output: a reference doc mapping each `Type` → meaning + render method, each property key → -meaning, each `DrawMode` → behavior, and the inheritance rules. This doc drives every other -component and is committed alongside the importer. - -### 2. `LayoutImporter` - -Reads a `LayoutDesc` by id and returns a `UiElement` subtree: -- Walk the `ElementDesc` tree. -- For each element: resolve inheritance (§3), pick a widget via the factory (§4), set its - rect (`X/Y/W/H`), anchors (edge flags → `AnchorEdges`), z-order, states, media, and - properties from the (resolved) element. -- Recurse into children. -- Expose `FindElement(uint id)` on the result so controllers wire by id. - -Depends on: `DatCollection` (read layouts), the factory, the inheritance resolver, -`TextureCache.GetOrUploadRenderSurface` (sprites), `UiDatFont` (text). No GL itself — it -builds `UiElement`s; rendering stays in the toolkit. - -### 3. Inheritance resolution - -An element with `BaseElement`/`BaseLayoutId` inherits the base element's properties / states -/ media; the derived element overrides. Resolve by loading the base layout, finding the base -element, and merging (base first, then derived overrides) **before** instantiating. -Required even for vitals: the number-text element inherits its font/style from base layout -`0x2100003F`. Cycle-guard the resolution. - -### 4. Hybrid widget factory (`Type` → renderer) - -- **Behavioral** types → dedicated widgets (verbatim-algorithm ports): meter → `UiMeter`, - text → dat-font label, scrollable/list region → `UiChatView`/list widget, button → - `UiButton`, resizable window root → `UiNineSlicePanel`. -- **Trivial** types (image, container, border piece, grip) → `UiDatElement` (generic). -- **Unknown** type → `UiDatElement` (faithful fallback — still draws its media). - -The Step-0 enumeration assigns each `Type` to a bucket by reading its retail render method -(trivial blit → generic; real algorithm → widget). - -### 5. `UiDatElement` (generic renderer) - -A `UiElement` holding the resolved element's active-state media + draw-mode + rect. Its -`OnDraw` ports retail's base blit branch: -- `Normal` → **tile** at native size (UV-repeat; the UI texture is `GL_REPEAT`-wrapped) — - the mechanism already proven for the bars + chrome. -- `Alphablend` → blended overlay. -- `Stretch` (if present) → scale. -- image → sprite; cursor → hover cursor. -Reuses the tiling, dat-font, nine-slice draw primitives. - -### 6. Per-window controllers (live-data binding) - -Mirror retail's `gm*UI` classes. A small controller per window grabs elements by id from the -imported tree and pushes live data in: `VitalsController` binds `HealthPercent` → meter fill, -`cur/max` → number text; `ChatController` binds the chat tail → the chat region. **This is -the only per-window code, and it is data wiring, not graphics.** Retail-faithful: e.g. -`gmVitalsUI::PostInit` grabs child meter elements by id and sets attribute `0x69` (fill). - -### 7. `WindowManager` - -`OpenWindow(layoutId, controller)` → import + attach to `UiRoot`, place at the dat's default -position (then persist user move/resize), manage z-order / focus / close. Orchestrates the -focus/drag/resize mechanics `UiRoot` already provides. - -### 8. States / expand / hover - -Each element carries its named states (`HideDetail`/`ShowDetail`, normal/hover/pressed) from -the dat; the active state selects which media draws. A click or hover flips the active state. -Click-to-expand and hover highlight fall out generically — no per-window code. - -## Rollout order (milestones) - -1. **Enumerate the format** (§1) → reference doc. -2. **`LayoutImporter`** + **inheritance resolution** (read + resolve + walk). -3. **`UiDatElement`** generic renderer (port the draw-mode blit branch). -4. **Hybrid factory** (Type → widget/generic). -5. **`VitalsController`** (bind by id). -6. **Re-drive vitals → diff against the current window.** ✅ conformance gate. -7. **`WindowManager`** (open/close/persist). -8. **Extend** to chat (`ChatController`), then new windows for free. - -## Testing / conformance - -- **Golden tree checks** — the importer-built vitals tree has the expected element rects, - resolved sprites, and active states (assert against the known `0x2100006C` values). -- **Inheritance unit tests** — base+override merge, cycle-guard. -- **Draw-mode unit tests** — the UV math for tile vs stretch vs the partial last tile. -- **Bind-by-id unit tests** — controller wires the right element. -- **Headless visual diff** — `render-vitals-mockup` / a tree-render comparison vs the - hand-built reference (no live server needed). -- **Final** — in-client visual verification (the user) once the gate passes. - -## Coexistence / don't-delete (restated) - -- `MarkupDocument` + `*.xml` stay for plugin/custom panels. -- `UiMeter`, `UiNineSlicePanel`, `UiChatView`, `UiDatFont`, the tiling/scissor-fill/dat-font/ - nine-slice primitives stay — reused as the importer's behavioral renderers. -- The hand-authored vitals path stays as the conformance reference until the importer - matches it; only then is vitals flipped to the importer. - -## Risks & open questions - -- **The format enumeration is the foundational unknown.** If a Type/property/draw-mode is - mis-mapped, faithfulness breaks. Mitigation: Step 0 cross-checks three sources + real - layouts; the vitals conformance gate catches regressions. -- **Some behavioral types may need new widgets** (list, scrollbar, edit box). These are - generic, written once — not per-window. The generic fallback means an un-widgeted type - still renders its sprites in the meantime. -- **Position persistence** scope (per-window saved rects) — minimal at first (dat default + - in-session move/resize); durable persistence can follow. -- **Phase id** — register this as the next D.2b sub-phase in the roadmap before implementing. - -## Reference anchors - -- **Dat layouts:** vitals (stacked) `0x2100006C`; floaty row `0x21000014`; horizontal row - `0x21000075`; vitals number-text base layout `0x2100003F`. -- **Decomp:** `gmVitalsUI::PostInit` @`0x4bfce0` (bind by id), `UIElement_Meter::DrawChildren` - @`0x46fbd0` (scissor-fill), `SurfaceWindow::DrawCharacter` @`0x442bd0` (dat-font), - `ImgTex::TileCSI` @`0x53e740` (tiling), `UIRegion::DrawHere` @`0x69fa30` (element draw order), - the KSML keyword registrations (~`0x71b540`+). -- **Tools:** `AcDream.Cli dump-vitals-layout` (reflective full tree dump), - `dump-sprite-sheet` (composite sprite ids), `render-vitals-mockup` (headless window render). -- **Memory:** `project_d2b_retail_ui.md` (the two-layout lesson, sprite ids, render model, - dat-font, tools). diff --git a/docs/superpowers/specs/2026-06-16-d2b-toolbar-phase1-design.md b/docs/superpowers/specs/2026-06-16-d2b-toolbar-phase1-design.md deleted file mode 100644 index ade2dd7e..00000000 --- a/docs/superpowers/specs/2026-06-16-d2b-toolbar-phase1-design.md +++ /dev/null @@ -1,279 +0,0 @@ -# D.5.1 — Toolbar (action bar) — Phase 1 design - -**Date:** 2026-06-16 -**Status:** design approved (brainstorm), spec under review → writing-plans next -**Phase:** D.5.1 — first sub-phase of D.5 "Core panels" (D.2b retail-look track). NEW -sub-phase; roadmap registration is plan step 0 (roadmap discipline rule 4). -**Builds on:** the shipped D.2b widget toolkit (`b7f7e2b`→`89626cd`) — generic -Type-registered widgets built by `DatWidgetFactory`, assembled by `LayoutImporter`, -bound by thin `gm*UI::PostInit`-style controllers. See -[`claude-memory/project_d2b_retail_ui.md`](../../../claude-memory/project_d2b_retail_ui.md). - -**Research evidence base (the anchors live here — this spec cites, does not re-derive):** -- [`docs/research/2026-06-16-ui-panels-synthesis.md`](../../research/2026-06-16-ui-panels-synthesis.md) — the build plan + consolidated widget list + cross-panel wire table -- [`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](../../research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — `UIElement_UIItem`/`UIElement_ItemList` port spec, the icon composite, drag-drop spine -- [`docs/research/2026-06-16-action-bar-toolbar-deep-dive.md`](../../research/2026-06-16-action-bar-toolbar-deep-dive.md) — `gmToolbarUI` shortcut model + wire + element map - ---- - -## 1. Goal - -Ship the **action bar (`gmToolbarUI`)** as the first data-driven *game* panel (vitals -and chat were HUD). 18 shortcut slots built from `LayoutDesc 0x21000016` via the existing -`LayoutImporter`, populated from the persisted `PlayerDescription` shortcut block, each -pinned item rendering its **real composited icon**, with **click-to-use**. Gated -`ACDREAM_RETAIL_UI=1`, whole-window-drag. - -The point of doing the toolbar first is that it is the **thinnest end-to-end slice that -exercises the entire shared item spine** — the `UiItemSlot` widget, the icon composite -pipeline, the `UiItemList` widget, a find-by-id controller, and the `CreateObject` icon -extension — on the simplest of the three panels (no nested sub-windows, no 3D viewport, -no multi-column grid). Everything built here is reused verbatim by the inventory and -paperdoll phases. - -## 2. Scope - -**In scope (Phase 1):** -- `UiItemSlot` widget (port of `UIElement_UIItem`, class `0x10000032`) — empty-slot + icon render. -- `UiItemList` widget (port of `UIElement_ItemList`, class `0x10000031`) — single-cell instances. -- Icon composite pipeline (faithful CPU pre-composite — Approach A, §4.3). -- `CreateObject.TryParse` extension to capture `IconId` onto `ItemInstance`. -- `ToolbarController` — find-by-id bind, populate-from-shortcuts, deferred re-bind, click-to-use. -- Toolbar window mounted under `ACDREAM_RETAIL_UI=1`, whole-window-drag. - -**Out of scope (later D.5 sub-phases):** -- Drag/reorder within the bar; drag-to-add from inventory (needs inventory as a drag source). -- The `AddShortCut`/`RemoveShortCut` mutate wire (`0x019C`/`0x019D`) — builders already exist; wiring them is deferred to the drag phase. -- The hidden selected-object Health/Mana meters (`0x100001A1`/`A2`) + the stack-split slider (`0x100001A4`) — stay `SetVisible(0)`, matching `gmToolbarUI::PostInit`. -- Spell shortcuts (`ItemList_InsertSpellShortcut`, `CM_Magic` path). -- Faithful window manager (Dragbar/Resizebar drag-resize) — uses the accepted IA-12 whole-window-drag approximation. -- Inventory and paperdoll panels. - -## 3. Retail anchors (the load-bearing facts, verified) - -All confirmed against the named decomp during the research phase and re-verified for this -spec. Lines are `acclient_2013_pseudo_c.txt`. - -- **Window:** `gmToolbarUI` element class `0x10000007` → `LayoutDesc 0x21000016` (300×122). - `gmToolbarUI::Register` (decomp 196897), `GetUIElementType`→`0x10000007` (196707). -- **18 slots, two rows of 9:** element ids `0x100001A7-AF` (top) + `0x100006B7-BF` (bottom), - wired in `gmToolbarUI::InitShortcutArray` (decomp 197051); each is a `DynamicCast(0x10000031)` - = `UIElement_ItemList`, pushed into `m_shortcutSlots` in slot-index order. -- **Slot content:** each slot list holds one `UIElement_UIItem` (item-cell, class - `0x10000032`). The cell's bound weenie guid is `UIElement_UIItem::itemID` (offset `+0x5FC`), - read in `UIItem_Update` (decomp 230230: `uint32_t itemID = this->itemID; … GetWeenieObject(itemID)`). -- **Persisted model:** `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`); the struct is - `ShortCutData { int index_; uint objectID_; uint spellID_; }` (`acclient.h:36484`). Delivered - at login in the `PlayerDescription` `SHORTCUT` block (`CharacterOptionDataFlag.SHORTCUT 0x1`). - acdream already parses it → `PlayerDescriptionParser.cs:345-356` → `Parsed.Shortcuts` - (`ShortcutEntry{Index, ObjectGuid, SpellId, Layer}`). -- **Populate at login:** `gmToolbarUI::UpdateFromPlayerDesc` (decomp 198838) — `FlushShortcuts` - then for i in 0..0x12 read `shortCuts_[i]->objectID_` and `AddShortcut(this, objId, i, send=0)`. -- **Deferred bind:** `UIElement_UIItem::SetDelayedShortcutNum` / `AddShortcut` (decomp 196867) - re-binds a slot whose weenie hasn't loaded yet once `CreateObject` for that guid arrives. -- **Activation (click-to-use):** `gmToolbarUI::UseShortcut` (decomp 196395) → `ItemHolder::UseObject` - (decomp 402923, 0.2s throttle `m_timeLastUsed + 0.2`) → ordinary use-item dispatch (NOT a - shortcut-specific wire message). acdream's use-item path = `InteractRequests.BuildUse` (`0x0036`). -- **Icon composite:** `UIElement_UIItem::UIItem_SetIcon` (230143) → `ACCWeenieObject::GetIconData` - (408224) → `IconData::RenderIcons` (407524). Five layers, bottom→top: item-type default - underlay `DBObj::GetByEnum(0x10000004, lsb(itemType)+1)`; custom underlay `_iconUnderlayID`; - base `_iconID`; custom overlay `_iconOverlayID` + `SurfaceWindow::ReplaceColor` tint; effect - overlay `DBObj::GetByEnum(0x10000005, lsb(effects)+1)`. **Every layer is DBObj type `0xc` - = RenderSurface, id range `0x06000000-0x07FFFFFF`** — decoded DIRECTLY via - `TextureCache.GetOrUploadRenderSurface` (the D.2b RenderSurface-vs-Surface gotcha: feeding - a `0x06` id to `GetOrUpload` returns 1×1 magenta). Icon is NOT appraise-gated (no appraise - branch in the icon path; appraise gates `UpdateTooltip` only). -- **acdream gap:** `CreateObject.TryParse` currently DISCARDS `IconId` (`CreateObject.cs:516`: - `_ = ReadPackedDwordOfKnownType(..., IconTypePrefix)`). `ItemInstance` already has the - `IconId`/`IconUnderlayId`/`IconOverlayId`/`StackSize`/`ContainerId` fields. - -## 4. Architecture & components - -Five new/extended units, each with one purpose and a defined interface. The pattern -mirrors the shipped vitals/chat re-drive exactly: dat `LayoutDesc` → `LayoutImporter` → -`DatWidgetFactory` builds widgets generically → a thin controller binds by id. - -### 4.1 `UiItemSlot` (new behavioral widget) — port of `UIElement_UIItem` (`0x10000032`) - -- **Location:** `src/AcDream.App/UI/UiItemSlot.cs`. -- **Registration:** `DatWidgetFactory` dispatches it on the resolved element **class id** - `0x10000032`. NOTE: the shipped factory keys off the small *numeric* Types (1–0x12); the - item-slot/item-list are `UIElement` subclasses identified by a high class id, so the plan - must add a class-id dispatch branch (the class id is already surfaced — `ElementReader.Merge` - resolves it through the `BaseElement` chain, and `UIElement_UIItem` derives from - `UIElement_Field`/Type 3, so do NOT register numeric Type 3 — that stays chrome `UiDatElement`, - per the shipped toolkit's deliberate Type-3 rule). Behavioral **leaf** — overrides - `ConsumesDatChildren => true` so the importer does NOT build its dat sub-elements (it - reproduces them procedurally). -- **State:** `uint ItemId` (the bound weenie guid, retail `+0x5FC`). Phase 1 needs only this. - Quantity / selection / drag-accept / ghost / open-container overlay states are *structurally - reserved* (documented as later-phase hooks) but inert. -- **Render:** if `ItemId == 0` → draw the empty-slot sprite (the dat state `ItemSlot_Empty` - → `0x060074CF`, read from the element's states like every other `UiDatElement` sprite). Else - → draw the composited icon (§4.3) into the 32×32 cell. Phase 1 draws no quantity text / no - overlays. -- **Depends on:** the icon pipeline (§4.3), `UiRenderContext.DrawSprite`. - -### 4.2 `UiItemList` (new behavioral widget) — port of `UIElement_ItemList` (`0x10000031`) - -- **Location:** `src/AcDream.App/UI/UiItemList.cs`. -- **Registration:** `DatWidgetFactory` keyed off class id `0x10000031`. Behavioral leaf - (`ConsumesDatChildren => true`) — manages its `UiItemSlot` children procedurally. -- **Phase-1 API subset:** `AddItem(UiItemSlot)` / `Flush()` / `GetNumUIItems()` / - `GetItem(int)`. The toolbar uses 18 **single-cell** instances (one `UiItemSlot` each), so - the N-cell grid layout (column wrap, cell pitch) is NOT needed yet — deferred to the - inventory phase. A single-cell list just hosts at most one slot. -- **Depends on:** `UiItemSlot`. - -### 4.3 Icon pipeline (Approach A — faithful CPU pre-composite) - -- **Location:** `src/AcDream.App/UI/IconComposer.cs` (App layer — it touches GL texture - upload). Pure-decode helpers may live alongside `TextureCache`. -- **Behaviour:** port `IconData::RenderIcons` (407524). For a given item's icon ids, build a - single 32×32 BGRA composite on the CPU by alpha-compositing the layers bottom→top - (§3 list), apply the `ReplaceColor` palette tint to the custom-overlay layer, then upload - the result once as a GL texture and **cache it keyed by the icon-id tuple** (so identical - items share one composite). The slot draws one sprite. -- **Layer decode:** each layer id is a `0x06` RenderSurface decoded DIRECTLY (Portal/HighRes - `TryGet` → `SurfaceDecoder.DecodeRenderSurface(palette:null)`), the same path - `TextureCache.GetOrUploadRenderSurface` already uses — but composited on the CPU rather than - drawn as separate sprites. -- **Enum-mapper layers:** the type-default underlay (`GetByEnum(0x10000004, …)`) and effect - overlay (`GetByEnum(0x10000005, …)`) require reading the two DBObj enum-mapper tables. These - are bounded lookups (index → RenderSurface id); port them as part of this unit. If a mapper - proves more involved than the research suggests, the base + custom underlay/overlay layers - still composite correctly and the enum layers can land as a tight follow-up within the phase - (documented, not silently dropped). -- **Why pre-composite, not stacked draws:** the custom-overlay `ReplaceColor` tint is a - per-pixel palette operation, not a simple alpha-blend — it cannot be reproduced by a tinted - `DrawSprite`. CPU compositing is therefore the faithful path, and it's the shared spine for - all three panels, so it's built correctly once. -- **Depends on:** `DatCollection` (RenderSurface decode), GL texture upload. - -### 4.4 `CreateObject` icon extension + `ItemInstance` - -- **Location:** `src/AcDream.Core.Net/Messages/CreateObject.cs`, `src/AcDream.Core/Items/ItemInstance.cs`. -- **Change:** in `CreateObject.TryParse`, capture the `IconId` (currently discarded at - `CreateObject.cs:516`) — and the underlay/overlay/effect ids if present in the same block — - onto the parsed object so `ItemRepository` stores them on `ItemInstance` (fields already exist). -- **Planning delta (see the plan):** fact-gathering found this is wider than "just capture IconId." - acdream has NO `CreateObject`→`ItemRepository` wiring at all (the repo is populated only from - `PlayerDescription` with stub `ItemInstance`s), and `Parsed.Shortcuts` is parsed then discarded - in `GameEventWiring`. So the plan adds three small wiring pieces: capture IconId (Task 1), enrich - the repo from the `WorldSession.EntitySpawned` event (Tasks 2–3, `ItemRepository.EnrichItem`), - and persist the shortcut list (Task 4). The icon source is CONFIRMED to be `CreateObject` for - contained pack items (ACE `WorldObject_Networking.cs:79` writes IconId unconditionally). -- **Step 0 verification:** confirm against **ACE source** (`WorldObject.SerializeCreateObject` - / the weenie property serialization) that a *contained* pack item's `CreateObject` actually - carries `IconId` (synthesis risk #3 — LIKELY, not yet byte-traced). Reading ACE is sufficient; - no live capture needed. If ACE only sends `IconId` for world-visible objects and relies on - `PlayerDescription` for pack items, fall back to the PD inventory block as the icon source — - this is a branch the plan must resolve before the icon pipeline is wired. - -### 4.5 `ToolbarController` (new) — the `gmToolbarUI::PostInit` analogue - -- **Location:** `src/AcDream.App/UI/ToolbarController.cs` (alongside `VitalsController`, - `ChatWindowController`). -- **Bind:** `Bind(LayoutDesc 0x21000016, …)` — find the 18 slot `UiItemList`s by id - (`0x100001A7-AF` + `0x100006B7-BF`) into an ordered `_slots[18]`. Force the 2 meters - (`0x100001A1`/`A2`) + slider (`0x100001A4`) hidden (matches `gmToolbarUI::PostInit`). -- **Populate (port `UpdateFromPlayerDesc`):** on the `PlayerDescription` arriving, `Flush` all - slots, then for each `Parsed.Shortcuts` entry resolve `ObjectGuid` → `ItemRepository` item → - set `_slots[Index]`'s cell `ItemId`. The cell renders the composited icon from the item's - `IconId`. -- **Deferred re-bind (port `SetDelayedShortcutNum`):** if a shortcut's guid is not yet in - `ItemRepository`, record it pending; when `ItemRepository` raises item-added for that guid, - bind the waiting slot. (Reuse `ItemRepository`'s existing item-change events.) -- **Click-to-use (port `UseShortcut`):** a slot click → controller → existing - `InteractRequests.BuildUse` (`0x0036`) for the cell's `ItemId`, gated by the 0.2s - use-throttle (`ItemHolder::UseObject`). No special shortcut wire. -- **Depends on:** `PlayerDescriptionParser.Parsed.Shortcuts`, `ItemRepository`, the slot - widgets, the command/interact send path. - -### 4.6 Wiring & gating - -- The toolbar window is built by `LayoutImporter` from `0x21000016` and mounted in `UiRoot` - under `ACDREAM_RETAIL_UI=1`, like vitals/chat. Always-on this phase. Root is `Anchors=None` - + `Draggable` (whole-window-drag, IA-12 approximation) — NOT `Resizable` (faithful resize is - the deferred window manager). -- `GameWindow` wiring follows the existing vitals/chat drain pattern (one controller - constructed + bound; per-panel try/catch fault isolation already exists). - -## 5. Data flow (login → visible toolbar) - -1. Login → `PlayerDescription` arrives → `PlayerDescriptionParser` fills `Parsed.Shortcuts`. -2. In parallel, the player's pack items arrive as `CreateObject` messages → `ItemRepository` - stores `ItemInstance`s **including `IconId`** (the §4.4 extension). -3. `ToolbarController` (bound to the imported `0x21000016` window) runs its populate pass: - for each shortcut, resolve guid → item → set slot `ItemId`. Missing items → pending, - re-bound on item-added. -4. Each filled `UiItemSlot` asks `IconComposer` for the composited 32×32 texture (cached by - icon-id tuple) and draws it; empty slots draw `0x060074CF`. -5. Click a filled slot → use-item (`0x0036`) with throttle. - -## 6. Testing strategy - -Conformance tests in the layer matching each unit; dat-free fixtures where possible (mirror -the vitals `0x2100006C` golden-fixture approach). - -- **`CreateObject` IconId** (`tests/AcDream.Core.Net.Tests`): a golden `CreateObject` byte - buffer parses with the expected `IconId` (and the previously-discarded fields). -- **`IconComposer`** (`tests/AcDream.App.Tests`): layer ORDER + presence given a synthetic - icon-id tuple (assert the composite requests layers bottom→top in the `RenderIcons` order; - assert the cache returns the same texture for the same tuple). The `ReplaceColor` tint math - gets a small unit test against a known palette index. -- **`UiItemSlot`** (`tests/AcDream.App.Tests`): `ItemId==0` selects the empty sprite; - `ItemId!=0` requests the composite. `ConsumesDatChildren==true`. -- **`UiItemList`**: `AddItem`/`Flush`/`GetNumUIItems`/`GetItem` over single-cell instances. -- **`ToolbarController`**: find-by-id binds 18 slots from a fixture tree; shortcut→item - resolution sets the right slot; an item arriving late triggers the deferred re-bind; a slot - click emits a use-item for the bound guid with the throttle respected. Meters/slider hidden. -- **Build + full suite green** before the visual gate. - -## 7. Acceptance criteria - -- `dotnet build` + `dotnet test` green. -- **Visual (the user's gate):** launch, log in `+Acdream` → an 18-slot action bar renders with - the correct dat chrome + empty-slot sprites; any persisted shortcuts show their **real - composited item icons**; clicking a pinned item **uses** it (observable server-side / - in-world). Whole-window drag works. -- Every AC-specific algorithm cites its named-decomp anchor in a comment (per the phase - checklist). -- Divergence rows added (§8); D.5.1 registered in the roadmap; memory updated if a durable - lesson emerges. - -## 8. Divergence register + roadmap (bookkeeping) - -- **Whole-window-drag** instead of faithful Dragbar-driven drag — already covered by the - existing **IA-12** row (reuse, no new row). -- **Icon enum-mapper layers**: if the type-default-underlay / effect-overlay layers land as a - follow-up rather than in the first commit, add a register row noting the temporarily-absent - layers (and delete it when they land). The base + custom underlay/overlay layers are faithful - from the first commit. -- **Roadmap:** register **D.5.1 — Toolbar** under D.5 "Core panels" as plan step 0 (avoids the - retroactive-registration deviation that the D.2b importer hit at roadmap line 428). - -## 9. Open items carried from research (resolve in the plan, before the dependent step) - -- **Step 0 — `CreateObject` IconId for contained items** (synthesis risk #3): read ACE source - to confirm pack-item `CreateObject` carries `IconId`; if not, use the PD inventory block. - Gates §4.3/§4.4. -- **Use-item opcode** (synthesis risk #4): `ItemHolder::UseObject` dispatch is confirmed; the - precise `0x0035` vs `0x0036` branch was not traced to the send. acdream has both in - `InteractRequests`; the toolbar uses single-item use (`0x0036`). Reconcile when wiring §4.5. -- The empty-slot baseline is itself a valid visual verification even if `+Acdream` has no - persisted shortcuts; pinning real items to verify icons may require the inventory phase - (drag-to-add) or a server-side pre-pin. - -## 10. Component boundary summary (isolation check) - -| Unit | One purpose | Interface | Depends on | -|---|---|---|---| -| `UiItemSlot` | render one item-in-a-slot | `ItemId` setter; standard `UiElement` draw/hit | `IconComposer`, render context | -| `UiItemList` | hold N item slots | `AddItem`/`Flush`/`GetNumUIItems`/`GetItem` | `UiItemSlot` | -| `IconComposer` | icon-id tuple → composited 32×32 texture | `GetIcon(iconIds) → texture` (cached) | `DatCollection`, GL upload | -| `CreateObject`/`ItemInstance` | carry `IconId` from wire to model | existing parse + fields | — | -| `ToolbarController` | bind + populate + use | `Bind(layout, deps)` | shortcuts, `ItemRepository`, slots, send path | - -Each can be understood and tested without reading the others' internals; the controller is -the only unit that knows about wire + model, keeping the widgets pure-presentation. diff --git a/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md b/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md deleted file mode 100644 index 8c61043b..00000000 --- a/docs/superpowers/specs/2026-06-16-d2b-widget-generalization-design.md +++ /dev/null @@ -1,410 +0,0 @@ -# D.2b — Widget generalization (LayoutDesc importer, Plan 2 widget piece) — design - -**Date:** 2026-06-16 -**Branch:** `claude/hopeful-maxwell-214a12` (D.2b retail-UI track) -**Status:** design — approved scope ("full registry, vitals last & gated"), pending spec review -**Predecessor:** the LayoutDesc importer, the vitals re-drive, and the chat-window re-drive -(`docs/superpowers/specs/2026-06-15-layoutdesc-importer-design.md`, -`docs/superpowers/specs/2026-06-15-chat-window-redrive-design.md`, -`docs/research/2026-06-15-layoutdesc-format.md`, -`claude-memory/project_d2b_retail_ui.md`). -**Opening context:** the "GENERALIZATION PASS — START-COLD CONTEXT" note in -`claude-memory/project_d2b_retail_ui.md`. - ---- - -## 1. Goal - -Refactor the hand-named chat widgets (`UiChatView` / `UiChatInput` / -`UiChatScrollbar` / `UiChannelMenu`) and the inline Send/Max-Min `UiDatElement` -click-wiring into **generic, Type-registered widgets** built by -`DatWidgetFactory`, so that `ChatWindowController` (and, as the final gated step, -`VitalsController`) collapses to a thin **find-widget-by-id → bind-data/behavior** -controller — the acdream analogue of retail `gm*UI::PostInit`. - -**The code is modern. The behavior is retail.** This pass changes the -*construction path* of widgets, not their on-screen behavior. The chat window -must stay visually and behaviorally identical through every step except the final -(gated) vitals rewire. - -### 1.1 Why this is mostly already done - -The trace that opened this work (re-confirmed in this design session) established -two facts that make the generalization a *registration* task, not a new mechanism: - -1. **The importer's base-chain Type resolution is already retail-faithful.** - `ElementReader.Merge` resolves a Type-0 placement element up its - `BaseElement`/`BaseLayoutId` chain to the base's real registered Type - (`ElementReader.cs:137-140`). Every chat/vitals element therefore already - resolves to the retail class it would instantiate. - -2. **Type 12 is `UIElement_Text` — a real behavioral class, not a "style - prototype to skip."** Verified directly in the decomp: - `UIElement::RegisterElementClass(0xc, UIElement_Text::Create)` - (`docs/research/named-retail/acclient_2013_pseudo_c.txt:115655`). The - `Type==12 → return null` rule in `DatWidgetFactory` is a *vitals-only Plan-1 - expedient* (AP-37: skip the vitals number elements so they render via - `UiMeter.Label`), **not** a structural truth. - -So the "wrinkle" feared in the start-cold note (Type-0/12 elements hiding their -real widget type) **dissolves**: the resolved Type is already correct. The factory -just needs to *register* generic widgets for those Types instead of skipping them -or dropping to `UiDatElement`. - -### 1.2 Why this matters beyond chat (the strategic purpose) - -Chat is the **proving ground**, not the destination. The payoff is that every -future panel — **inventory, spell bar, vendor, character sheet, trade, skills** — -becomes *assembled from dat data + a thin controller* instead of being hand-built -from scratch. That is exactly how retail did it (`gm*UI::PostInit` everywhere on a -shared `UIElement` toolkit), and it is the reason to do this pass carefully now. - -**What this pass gives all future windows (the foundation):** -- The **generic widget toolkit** — `UiButton`, `UiField`, `UiScrollbar`, `UiText`, - `UiMenu` — built automatically by `DatWidgetFactory` from the dat layout. -- The **thin-controller pattern** — find-widget-by-id → bind-live-data — proven and - cemented on chat. Inventory's controller, vendor's controller, etc. all take the - same shape. - -**What those specific windows additionally need (out of scope here; cheap once the -pattern exists):** -- **A few more widget Types** — inventory/vendor lists want `UiListBox` (Type 5) - and `UiPanel` (Type 8); item slots want **drag-drop**, which retail builds into - `UIElement_Field` (the decomp shows `Field` has `CatchDroppedItem` / - `MouseOverTop` drag-drop hooks — so drag-drop rides on the Field widget this pass - already builds). Each gets *registered when that window needs it* — which is - exactly why §3 bounds "full registry" to the Types chat+vitals use today rather - than speculatively building all 14 retail classes. -- **The window manager** — open/close/z-order/persist, drag-bars (Type 2), - resize-grips (Type 9). This is the *other* half of Plan 2 — a sibling piece to - this one — and lands alongside, because pop-up/stackable windows (inventory, - vendor) need it. -- **Per-domain data plumbing** — item icons, live container contents, vendor stock - lists. Game-state work, separate from the UI toolkit. - -This pass is therefore the **reusable toolkit + assembly pattern** that makes those -later windows mostly-free to build. It is the load-bearing first half of the road -to inventory/vendor/spell-bar, not the whole road. - ---- - -## 2. Retail reference (the registry + the PostInit pattern) - -### 2.1 The Type → class registry (`UIElement::RegisterElementClass`) - -Confirmed verbatim from `acclient_2013_pseudo_c.txt` (line numbers cited): - -| Type | Retail class | Reg. line | | Type | Retail class | Reg. line | -|---|---|---|---|---|---|---| -| 1 | `UIElement_Button` | :125828 | | 9 | `UIElement_Resizebar` | :118938 | -| 2 | `UIElement_Dragbar` | :119926 | | 0xb (11) | `UIElement_Scrollbar` | :124137 | -| 3 | `UIElement_Field` (editable) | :126190 | | 0xc (12) | `UIElement_Text` | :115655 | -| 5 | `UIElement_ListBox` | :121788 | | 0xd (13) | `UIElement_Viewport` | :119126 | -| 6 | `UIElement_Menu` | :120163 | | 0xe (14) | `UIElement_Browser` | :118718 | -| 7 | `UIElement_Meter` | :123316 | | 0x10/0x11 | `ColorPicker`/`GroupBox` | :118396/:118177 | -| 8 | `UIElement_Panel` | :119820 | | — | **Type 0 and 4: NOT registered** | — | - -Type 0 has no class of its own — a Type-0 element is a placement/override that -inherits its class from its base. That is exactly what `ElementReader.Merge` -already does. - -> **Implementation correction (2026-06-16, settled during execution).** Two of -> this design's registration assumptions changed once the empirical resolved -> Types were in hand (Task 1): -> 1. **The editable input `0x10000016` resolves to Type 12 (Text), not Type 3.** -> So the input is **Variant B** — the factory builds it as a `UiText` -> placeholder and `ChatWindowController` removes that and controller-places a -> `UiField` at its rect. (Confirmed by the chat golden fixture.) -> 2. **Type 3 is NOT registered → `UiField` in this pass.** In acdream's vitals -> (`0x2100006C`) and chat (`0x21000006`) layouts, Type-3 dat elements are -> sprite-bearing **chrome** (the 8-piece bevel corners/edges, e.g. vitals -> `0x10000633` → sprite `0x060074C3`) and the transcript/input **container** -> panels — NOT editable fields. Retail draws those as inert media-bearing -> Fields, which our generic `UiDatElement` reproduces pixel-for-pixel and -> without a spurious focus/edit affordance. Registering Type 3 → `UiField` -> (which draws no dat sprite) would blank the vitals bevel. So the factory -> switch registers **Button (1), Menu (6), Meter (7), Scrollbar (11), Text -> (12)**; Type 3 stays on the `UiDatElement` fallback. `UiField` still ships -> (the renamed editable widget) — it is just controller-placed, not -> factory-wired. Register Type 3 → `UiField` only when a window carries a -> factory-built editable Type-3 field (and `UiField` then grows a -> background-media draw + an opt-in editable flag). Guarded by -> `VitalsTree_ChromeCornerHasExpectedSprite` (asserts the corner stays a -> `UiDatElement` drawing its sprite). - -### 2.2 The `gm*UI::PostInit` binding pattern (the controller target) - -`gmVitalsUI::PostInit` (`acclient_2013_pseudo_c.txt:199170-199228`) and -`gmMainChatUI::PostInit` (`:212585-212636`) do, per child widget: - -``` -UIElement* e = UIElement::GetChildRecursive(this, 0x100000e6); // find by id -UIElement_Meter* m = e->vtable->DynamicCast(7); // cast to Type -this->m_pHealthMeter = m; // store -if (!m) { /* skip */ } // null-check -``` - -acdream analogue (already half-present in `ChatWindowController`): - -```csharp -var send = layout.FindElement(SendId) as UiButton; // GetChildRecursive + DynamicCast -if (send is not null) send.OnClick = () => input.Submit(); // bind behavior -``` - -The faithful end-state is: **the factory builds every widget from the dat; the -controller only finds-by-id and binds data/callbacks** — it never constructs a -widget. - -### 2.3 Empirically resolved Types of the chat elements (`LayoutDesc 0x21000006`) - -Traced against the live dat (HIGH confidence; base ids in parentheses): - -| Element | Resolves to | Retail class | Today | -|---|---|---|---| -| `0x10000014` channel menu | **6** (own Type 6, no base) | Menu | `UiDatElement` → controller replaces w/ `UiChannelMenu` | -| `0x10000012` scrollbar track | **11** (base `0x10000367` in `0x2100003E`) | Scrollbar | `UiDatElement` → controller replaces w/ `UiChatScrollbar` | -| `0x10000011` transcript | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatView` | -| `0x10000016` input | **12** (base `0x10000372` in `0x2100003F`) | Text | skipped → controller adds `UiChatInput` | -| `0x10000019` send | **1** (base chain → `0x1000047F` Type 1) | Button | `UiDatElement` + `OnClick` | -| `0x1000046F` max/min | **1** (base `0x1000047F` Type 1 in `0x21000040`) | Button | `UiDatElement` + `OnClick` | - -> **Plan-phase verification #1 (load-bearing):** the editable **input** -> `0x10000016` traced to **Type 12 (Text)**, the same base as the read-only -> transcript — surprising for an editable field (retail's editable text is -> Field=3). Element ids are layout-*local*, so the decomp's `ChatInterface` -> Field-id does **not** cross-map; re-dump `0x10000016`'s exact resolved Type and -> the `0x10000372` base prototype's Type before relying on it. The design is -> robust either way — see §4.3(a). - ---- - -## 3. Approved scope - -**Decision (this session):** *Full registry, chat-first, vitals rewire as the -final, separately-committed, separately-gated step.* - -**In scope:** -- Register generic widgets for the Types the chat + vitals windows actually use: - **Button (1), Field (3), Menu (6), Scrollbar (11), Text (12)** — plus Meter (7) - already done. -- Delete the `Type==12 → return null` skip; Type 12 becomes `UiText`. -- Collapse `ChatWindowController.Bind` to a find-by-id binder (no widget - construction). -- **Final gated step:** rewire `VitalsController` to bind generic `UiText` for the - vitals numbers (retail-faithful: vitals numbers *are* `UIElement_Text`), - retiring `UiMeter.Label` for vitals. - -**Explicitly NOT in scope ("full registry" is bounded to what these windows use):** -- The long tail retail also registers — `Panel` (8), `Dragbar` (2), `Resizebar` - (9), `ListBox` (5), `Viewport` (13), `Browser` (14), `ColorPicker` (16), - `GroupBox` (17). Those elements **continue to render correctly as - `UiDatElement`** (the universal fallback is non-negotiable). No - `UIElement_ColorPicker` port for a window that has no color picker. When a future - window needs one of these, it gets registered then. -- No new chat *features* (tabs/squelch/name-tags/word-wrap remain as the chat - re-drive deferred them — see that spec's §2). -- `UiMeter.Label` is **not deleted** — it stays for plugin/markup panels; vitals - simply stops using it. - ---- - -## 4. Design - -### 4.1 `DatWidgetFactory` — the faithful Type switch - -`DatWidgetFactory.Create` grows from `{7 → UiMeter, _ → UiDatElement}` to: - -```csharp -UiElement e = info.Type switch -{ - 1 => BuildButton(info, resolve, datFont), // UIElement_Button - 3 => BuildField(info, resolve, datFont), // UIElement_Field (see §4.3a) - 6 => BuildMenu(info, resolve, datFont), // UIElement_Menu - 7 => BuildMeter(info, resolve, datFont), // UIElement_Meter (unchanged) - 11 => BuildScrollbar(info, resolve), // UIElement_Scrollbar - 12 => BuildText(info, resolve, datFont), // UIElement_Text - _ => new UiDatElement(info, resolve), // generic fallback (unchanged) -}; -``` - -The rect/anchor/z-order propagation at the bottom of `Create` is unchanged. The -`Type==12 && StateMedia.Count==0` skip is **removed** — but a *pure base -prototype* (Type 12 with no own geometry that is only referenced via -`BaseLayoutId`, never placed) must still not draw. In practice such prototypes are -never top-level placed elements in `0x21000006`/`0x2100006C`; the importer only -builds placed elements. **Plan-phase verification #2:** confirm no Type-12 -prototype is double-built after the skip is removed (the chat/vitals golden -fixtures catch this). - -Each `BuildX` extracts the widget's dat-derived data (sprite ids per state, label -font) the same way `BuildMeter` extracts its 3-slice grandchild sprites. The -controller binds providers/callbacks afterward. - -### 4.2 The generic widgets - -Each generic widget extends `UiElement`, is constructed by the factory from -`ElementInfo`, and exposes **data providers + callbacks** for the controller to -bind. The chat-specific knowledge moves *out* of the widgets and *into* the -controller (faithful: retail's `gmMainChatUI`, not `UIElement_Menu`, owns the -talk-focus channel list). - -| Generic widget | Type | Derived from | Generic surface (dat-built + provider-bound) | Controller binds | -|---|---|---|---|---| -| `UiScrollbar` | 11 | `UiChatScrollbar` (already 100% generic) | track/thumb/cap/arrow sprite ids from dat; `Model : UiScrollable` | `Model = transcript.Scroll` | -| `UiButton` | 1 | `UiDatElement`+`OnClick` | state sprites (Normal/Pressed/Disabled), `Label`, `LabelFont`, `LabelColor`, `OnClick`, `NaturalWidth()` autosize | `OnClick`, caption | -| `UiMenu` | 6 | `UiChannelMenu` | popup toggle, 2-col layout, 8-piece bevel, row highlight; `Items : IReadOnlyList<(string label, bool enabled, object payload)>`, `OnSelect : Action`, `Selected`, `NaturalButtonWidth()` | populate 14 channel `Items`; map payload↔`ChatChannelKind`; `AvailabilityProvider` | -| `UiText` | 12 | `UiChatView` | scrollable + selectable multi-color line list, clipboard, dat-font; `LinesProvider : Func>`; shares `UiScrollable` (`Scroll`) | `LinesProvider` → ChatVM + per-kind colors | -| `UiField` | 3 | `UiChatInput` | editable one-line: caret/selection/clipboard/history/auto-repeat/focus-sprite-swap; `Text`, `OnSubmit`, `MaxCharacters` | `OnSubmit` → `ChatCommandRouter` | - -**Placement.** The generic widgets live in `src/AcDream.App/UI/` alongside -`UiMeter` (toolkit widgets). The factory in `src/AcDream.App/UI/Layout/` -references them. This matches the current split (`UiMeter` in `UI/`, -`UiDatElement` in `UI/Layout/`). - -**Naming.** `UiX` mirrors retail `UIElement_X`. The old chat-prefixed names are -removed (or kept as thin obsolete aliases only if needed mid-migration). - -### 4.3 The two wrinkles - -**(a) The editable input (Type 12 vs Type 3).** Robust to either resolution: -- If `0x10000016` resolves to **Type 3** → factory builds `UiField` directly; the - controller only binds `OnSubmit`. -- If it resolves to **Type 12** → the dat element is a display Text in this - layout; the controller *replaces* it with a controller-placed `UiField` at its - rect (today's pattern for the track/menu). `UiField` exists as a registered - generic widget regardless; only *who places it* differs. - -Editing behavior (caret/clipboard/history) is never purely dat-derivable, so the -input is always provider-bound — the open question only affects whether the -factory or the controller *instantiates* it. - -**(b) Vitals rewire — the final gated step.** Removing the Type-12 skip means the -vitals number elements (Type-0 → base Type-12 Text) *could* build as real -`UiText`. Today they are **meter children, consumed** (the importer does not -recurse a meter's children — `LayoutImporter.cs:113`), rendered via -`UiMeter.Label`. The faithful move: `VitalsController` constructs/binds a `UiText` -for each number (matching retail `UIElement_Text` vitals numbers) and drops -`UiMeter.Label` for vitals. - -This is **step 7 — the last commit, separately gated**, with its own fixture -update and the user's visual sign-off, because vitals shipped pixel-identical and -is fixture-locked (`vitals_2100006C.json`). If the rewire risks the pixel-identical -result, we **stop and keep the meter-label path** for vitals — a smaller, -documented divergence (AP-37 narrowed, not retired). The decision to land step 7 -is the user's, made on the running client. - -### 4.4 The thin controller (after step 6) - -`ChatWindowController.Bind` collapses to: for each known element id, `FindElement(id) -as UiX`, null-check, bind data/callback. The reflow/maximize/resize-bar-drop logic -(`ChatWindowController.cs:155-297`) stays — it is window-layout policy, not widget -construction. The `BuildLines` / `WrapText` / `RetailChatColor` helpers stay (chat -data shaping). What *leaves* the controller: the construction of `UiChatView`, -`UiChatInput`, `UiChatScrollbar`, `UiChannelMenu` (now factory-built) — the -controller binds them instead. - ---- - -## 5. Migration sequence (one widget per commit; build + test green each step) - -Ordered least-risk → most-risk; the chat window is fully generalized before vitals -is touched. Each step: `dotnet build` green, `dotnet test` (AcDream.App.Tests) -green, its own commit naming the widget; the live chat window stays visually -identical through steps 1–6. - -1. **`UiScrollbar`** (Type 11) — promote `UiChatScrollbar` (already generic); - register; factory builds it; controller binds `Model`. -2. **`UiButton`** (Type 1) — extract from `UiDatElement`+`OnClick`; register; Send + - Max/Min build from the dat. -3. **`UiMenu`** (Type 6) — generalize `UiChannelMenu`; register; controller - populates channel `Items` + maps payload↔`ChatChannelKind`. -4. **`UiText`** (Type 12) — generalize `UiChatView`; register; **delete the Type-12 - skip**; controller binds transcript lines. Guard: verify vitals still renders - (its numbers are meter-consumed → no auto-double-draw) via the vitals fixture + - a live launch. -5. **`UiField`** (Type 3) — generalize `UiChatInput`; register; wire the input per - §4.3(a) (verification #1 resolves factory-built vs controller-placed). -6. **Thin the controller** — collapse `ChatWindowController.Bind` to pure - find-by-id binding now that the factory builds everything. -7. **Vitals rewire (gated)** — `VitalsController` binds `UiText` numbers; fixture - update + the user's visual sign-off. **Stop-and-confirm gate.** - ---- - -## 6. Testing & conformance - -- **Generic-widget unit tests** (pure, no GL/dat) — mostly *moved* from the - existing chat-widget tests, renamed to the generic widgets: caret↔pixel + history - (`UiField`), thumb ratio / page-delta (`UiScrollbar` via `UiScrollable`), menu - item-pick + availability (`UiMenu`), line wrap / selection / dat-font hit-test - (`UiText`). -- **Factory tests** — `DatWidgetFactoryTests` grows one assert per newly registered - Type → correct widget class. -- **New chat-layout golden fixture** `tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json` - (peer of `vitals_2100006C.json`): the resolved chat tree — each element's id, - rect, resolved Type, sprite ids — asserting the factory builds the right widget - per element. This locks the generalization. -- **Vitals fixture `vitals_2100006C.json` stays green, untouched, through steps - 1–6**; updated only at step 7, with visual sign-off. -- **Visual acceptance** — the user launches `ACDREAM_RETAIL_UI=1` and confirms the - chat window is unchanged through steps 1–6, and the vitals window is unchanged - after step 7. - ---- - -## 7. Divergence-register impact - -- **AP-37** (`DatWidgetFactory.cs`/`LayoutImporter.cs`: Type-0 text skipped + meter- - collapse + vitals numbers via `UiMeter.Label`): **amended as steps land** — the - "standalone Type-0 text elements are skipped / a dedicated dat-text widget is - Plan 2" clause is retired when `UiText` ships (step 4); the vitals-numbers-via- - `UiMeter.Label` clause is retired at step 7, or **narrowed** (not retired) if - step 7 is deferred. The meter-collapse clause (reuse `UiMeter` 3-slice vs porting - `UIElement_Meter::DrawChildren` over nested dat elements) **remains** — this pass - does not port `DrawChildren`. -- **IA-15** (the importer *is* the retail-UI render path): unchanged; reinforced - (more Types now data-driven). -- **AP-41** (scrollbar thumb single stretched sprite): **re-check at step 1** — the - controller already passes 3-slice cap ids (`ThumbTopSprite`/`ThumbBotSprite`); the - row may be retire-able when `UiScrollbar` lands. -- **New rows** only if a generic widget introduces a *new* approximation (e.g., a - `UiMenu` item model simpler than retail's hierarchical popup chain in - `UIElement_Menu::MakePopup`). Add the row in the same commit per register rule 1. - ---- - -## 8. Acceptance criteria - -- [ ] `DatWidgetFactory` registers Types 1, 3, 6, 11, 12 (+ 7) → generic widgets; - `_` still falls back to `UiDatElement`. -- [ ] The `Type==12 → null` skip is removed; no Type-12 element is double-built - (golden fixtures green). -- [ ] The four chat widgets are generic (no `ChatChannelKind` / chat-color / - command-routing knowledge inside a widget); `ChatWindowController` only finds- - by-id and binds. -- [ ] Chat window is visually + behaviorally identical to the shipped version - through steps 1–6 (user-confirmed). -- [ ] New `chat_21000006.json` golden fixture + moved generic-widget unit tests; - all green. -- [ ] Vitals window unchanged after step 7 (user-confirmed), or step 7 deferred - with AP-37 narrowed. -- [ ] Every generic widget cites its retail `UIElement_X` class + reg. line in a - code comment. -- [ ] Divergence register updated (AP-37 amended; AP-41 re-checked) in the same - commits. -- [ ] Roadmap / `claude-memory/project_d2b_retail_ui.md` updated when the pass lands. - ---- - -## 9. Open items for the plan phase - -1. **Verification #1 (load-bearing):** re-dump `0x10000016` (input) + the - `0x10000372` base prototype to confirm input resolved Type (3 vs 12) → decides - factory-built vs controller-placed `UiField` (§4.3a). -2. **Verification #2:** confirm no Type-12 base prototype double-builds once the - skip is removed (§4.1). -3. Confirm the `UiMenu` generic item model (`(label, enabled, payload)`) is enough - for the 14 talk-focus channels without losing the greyed/available distinction - the chat menu currently shows. -4. Decide whether to keep thin obsolete-aliases for the old chat widget names - during migration or rename in-place (prefer in-place; the names are internal). diff --git a/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md b/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md deleted file mode 100644 index af66b641..00000000 --- a/docs/superpowers/specs/2026-06-17-d2b-stateful-icon-design.md +++ /dev/null @@ -1,215 +0,0 @@ -# D.2b — Stateful item-icon system (D.5.2) — design - -**Date:** 2026-06-17 -**Phase:** D.2b retail-UI engine → D.5.2 (the shared icon infrastructure before the -inventory / equipment / vendor / trade panels). -**Research basis (READ FIRST):** [`docs/research/2026-06-17-stateful-icon-RESOLVED.md`](../../research/2026-06-17-stateful-icon-RESOLVED.md) -— the definitive, source-verified answers (clean Ghidra decompile + live-dat probe + ACE -oracle). It **supersedes** the hypotheses in `docs/research/2026-06-17-stateful-icon-system-handoff.md`. - -## 1. Goal - -The displayed item icon must **always be a function of the item's current state** — the -shared compositor every item panel reuses. Two concrete gaps remain after D.5.1: - -1. The **effect treatment** (retail's `UiEffects`-driven recolor) is unbuilt, and acdream - **discards** the `UiEffects` bitfield at `CreateObject.cs` (the UiEffects skip). -2. There is no **live** re-trigger: when an item's state changes (the user's "item with - mana vs out of mana"), the icon must re-composite. - -User decisions (2026-06-17): **(a)** port the effect treatment **faithfully** (retail's -subtle white-pixel recolor, not a bold overlay); **(b)** D.5.2 **includes** the live -`PublicUpdatePropertyInt(0x02CE)` wire-up so the icon updates in real time. - -## 2. Scope - -**In scope** -- Capture `UiEffects` (weenieFlags `0x80`) from `CreateObject` onto the item. -- The faithful 2-stage effect composite in `IconComposer`. -- The live `PublicUpdatePropertyInt(0x02CE)` parser → `UiEffects` → re-composition. -- Conformance tests + divergence-register bookkeeping. - -**Out of scope (with reasons)** -- **Appraise-driven icon enrichment** — DROPPED. ACE proves appraise carries no icon / - UiEffects data (`Icon`/`IconOverlay`/`IconUnderlay` and `PropertyInt.UiEffects` all lack - `[AssessmentProperty]`). It is a no-op, and acdream never sends an appraise anyway. -- `IsThePlayer` paperdoll container icon (`GetDIDByEnum(0x10000004, 7)`) — paperdoll phase. -- `PrivateUpdatePropertyInt(0x02CD)` (player's own object, no guid) — not an item path. - -## 3. Background — the corrected retail facts (from the RESOLVED doc) - -- **All icon inputs are CreateObject-only.** `_iconID` (always), `_iconOverlayID` - (weenieFlags `0x40000000`), `_iconUnderlayID` (weenieFlags2 `0x01`), `_effects`/UiEffects - (weenieFlags `0x80`). D.5.1 already captures the first three; `_effects` is discarded. -- **The effect overlay is a `ReplaceColor` tint SOURCE, not a blit layer.** Clean decompile - of `IconData::RenderIcons` (`0x0058d180`) + `SurfaceWindow::ReplaceColor` (`0x00441530`): - - ``` - drag surface = Blit base (Blit_Normal) + Blit custom overlay (Blit_4Alpha) - + if effect: ReplaceColor(this=drag, src=WHITE(1,1,1,1), dest=) - slot icon = Blit type-default underlay (Blit_Normal, opaque) - + Blit custom underlay (Blit_3Alpha) - + Blit drag surface (Blit_3Alpha) - ``` - `ReplaceColor` replaces pixels exactly equal to `0xFFFFFFFF` with the dest color. The - effect tiles (`enum 0x10000005`) are 32×32 **fully-opaque** colored squares — they cannot - be blitted on top (would erase the icon); they source the recolor. -- **Effect index** = `LowestSetBit(_effects)+1` into `enum 0x10000005`; if the resolved DBObj - is null → fallback index `0x21`. (No `lsb==-1 → 0x21` pre-check on the effect path, unlike - the type-underlay path.) -- **Dirty-check** (`UpdateIcons`): re-render on change of `iconID / overlayID / underlayID / - itemType / _effects`. acdream's per-tuple icon cache keyed on exactly these IS the - re-composition contract. - -### Golden effect-submap values (live dat — MasterMap `0x25000000` → submap `0x25000009`) - -| UiEffects | bit | index | effect DID | tile mean RGB | -|---|---|---|---|---| -| Magical | 0x0001 | 1 | `0x060011CA` | blue | -| Poisoned | 0x0002 | 2 | `0x060011C6` | green | -| BoostHealth | 0x0004 | 3 | `0x06001B05` | red | -| BoostStamina | 0x0010 | 5 | `0x06001B06` | yellow | -| Nether | 0x1000 | 13 (absent) | → fallback `0x060011C5` | black | -| (none, `_effects==0`) | — | 0 (zero) | → fallback `0x060011C5` | black | - -Full table + the type-underlay (`0x10000004`) cross-check are in the RESOLVED doc. - -## 4. Architecture & data flow - -``` -CreateObject (0xF745) ──UiEffects(0x80)──┐ - ├──► ItemInstance.Effects ──► ItemRepository.ItemPropertiesUpdated -PublicUpdatePropertyInt(0x02CE) ──────────┤ │ - prop==UiEffects(18), guid==item │ ▼ - └──────────► UiItemSlot re-calls IconComposer.GetIcon(…, effects) - (new cache key ⇒ fresh composite) -``` - -The re-composition contract (`ItemPropertiesUpdated` → widget re-resolve via the -toolbar's `Populate`) already exists; D.5.2 feeds it the effect state from two sources. -The live `0x02CE` event is bound in `GameWindow`'s session-event binding (next to the -existing `VitalUpdated` subscription) — NOT `GameEventWiring`, which only handles the -`0xF7B0` GameEvent sub-opcode dispatcher. - -## 5. Components - -Each component below states **what it does / how it's used / what it depends on.** - -### 5.1 `ItemInstance.Effects` (`AcDream.Core/Items/ItemInstance.cs`) -- **What:** a `uint Effects` field — the live UiEffects bitfield (0 = no effect). -- **Use:** read by the icon-id resolver; written by `EnrichItem` (CreateObject) and - `UpdateIntProperty` (live update). -- **Depends on:** nothing (pure data). - -### 5.2 `CreateObject.Parsed.UiEffects` (`AcDream.Core.Net/Messages/CreateObject.cs`) -- **What:** capture the `UiEffects` u32 (weenieFlags `0x80`) currently read-and-discarded; - add `uint UiEffects = 0` to the `Parsed` record. -- **Use:** threaded into `EntitySpawn`. -- **Depends on:** the existing weenie-tail walk (no order change — UiEffects already sits at - its correct position in the walk). - -### 5.3 `WorldSession.EntitySpawn.UiEffects` + the `0x02CE` route (`AcDream.Core.Net/WorldSession.cs`) -- **What:** add `uint UiEffects = 0` to `EntitySpawn`, thread `parsed.Value.UiEffects`; add a - message-loop branch for `PublicUpdatePropertyInt.Opcode (0x02CE)` that parses the body and - fires a new `ObjectIntPropertyUpdated(guid, property, value)` event. -- **Use:** `GameWindow` consumes `EntitySpawn`; `GameEventWiring` consumes the new event. -- **Depends on:** `CreateObject.Parsed.UiEffects`, `PublicUpdatePropertyInt` parser. - -### 5.4 `PublicUpdatePropertyInt` parser (`AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs`, NEW) -- **What:** a static parser mirroring `PrivateUpdateVital.cs`. Wire layout (ACE - `GameMessagePublicUpdatePropertyInt`, size hint 17): - ``` - u32 opcode = 0x02CE - u8 sequence (single byte, per the PrivateUpdateVital note) - u32 guid - u32 property (PropertyInt enum; UiEffects = 18) - i32 value - ``` - `TryParse(body) -> (uint Guid, uint Property, int Value)?` — null on opcode mismatch / - truncation. (Sequence parsed-past, not honored — latest-wins; see divergence DR-4.) -- **Use:** called from the `WorldSession` `0x02CE` branch. -- **Depends on:** nothing. - -### 5.5 `ItemRepository` (`AcDream.Core/Items/ItemRepository.cs`) -- **What:** - - `EnrichItem(..., uint effects = 0)` — assign `item.Effects = effects` (unconditional; 0 - is a meaningful "no effect" state). - - `UpdateIntProperty(uint itemId, uint propertyId, int value)` — NEW extensible hook: - stores into `Properties.Ints[propertyId]`, and for known typed ints maps to the typed - field (`propertyId == 18 (UiEffects) → item.Effects = (uint)value`), then fires - `ItemPropertiesUpdated`. Returns false if the item is unknown. -- **Use:** `EnrichItem` from `GameWindow.OnLiveEntitySpawned`; `UpdateIntProperty` from - `GameEventWiring` on `ObjectIntPropertyUpdated`. -- **Depends on:** `ItemInstance.Effects`. - -### 5.6 `IconComposer` (`AcDream.App/UI/IconComposer.cs`) — the compositor -- **What:** `GetIcon(ItemType, iconId, underlayId, overlayId, effects)` — 5-arg, cache key - widened to include `effects`. Implements the faithful 2-stage composite (§3): - - **Stage 1 (drag):** `Compose([base, customOverlay])`; if `effects != 0` and the effect - color resolves, `ReplaceColor(white → effectColor)` on the drag buffer. - - **Stage 2 (slot):** `Compose([typeUnderlay, customUnderlay, drag])`. - - `ResolveEffectDid(effects)` mirrors `ResolveUnderlayDid` but via `enum 0x10000005` - (`EnsureEffectSubMap`), index `LowestSetBit(effects)+1`, fallback `0x21`. - - `TryGetEffectColor(effects)` decodes the effect tile and returns its **mean-opaque** - color (the faithful representative; the exact retail byte is a decompiler-ambiguous - `SurfaceWindow`-header read — see DR-2). - - `ReplaceColorWhite(rgba, w, h, dest)` — retail `ReplaceColor` (`0x00441530`): replace - pixels `== (255,255,255,255)` with `dest`. - - **Effect recolor applies only when `effects != 0`** (DR-3: retail nominally runs the - `effects==0` black-fallback recolor; we skip it — likely a no-op but a regression risk). -- **Use:** called by the toolbar's `iconIds` delegate (and future item panels). -- **Depends on:** `DatCollection`, `TextureCache`, `SurfaceDecoder`, `EnumIDMap`. -- **Note:** the 2-stage form is associative-equivalent to D.5.1's single Compose for the - non-effect case (Porter-Duff "over" is associative), so shipped D.5.1 visuals are - unchanged when `effects == 0`. - -### 5.7 Delegate widening (`ToolbarController.cs` + `GameWindow.cs`) -- **What:** the `iconIds` delegate becomes `Func` - (+effects); `ToolbarController.Populate` passes `item.Effects`; `GameWindow`'s closure + - `OnLiveEntitySpawned` pass `spawn.UiEffects`. -- **Depends on:** §5.1, §5.6. - -### 5.8 `GameWindow` session-event binding (`AcDream.App/Rendering/GameWindow.cs`) -- **What:** subscribe to `WorldSession.ObjectIntPropertyUpdated` (alongside the existing - `VitalUpdated` subscription, ~line 2630); route `Property == 18 (UiEffects)` to - `Items.UpdateIntProperty(guid, 18, value)`. (Top-level session events bind here, not in - `GameEventWiring` — that class only handles the `0xF7B0` GameEvent dispatcher.) -- **Depends on:** §5.3, §5.5. - -## 6. Divergence-register changes - -- **Retire `IA-16`** (item-icon composite PARTIAL) — the composite is now complete. -- **Add DR-1** — effect overlay is a `ReplaceColor` recolor SOURCE, not a blit layer (this - IS the faithful retail behavior; row documents the model so future readers don't "fix" it - back to a blit). Anchor: `RenderIcons` `0x0058d180`, `ReplaceColor` `0x00441530`. -- **Add DR-2** — the effect tint color uses the effect tile's mean-opaque color; the exact - retail color byte (`effectTile + 0xac` reinterpreted as `RGBAColor`) is decompiler- - ambiguous. Approximation; visual/cdb confirmation pending. -- **Add DR-3** — we skip the `_effects==0` black-fallback recolor that retail nominally runs. -- **Add DR-4** — `PublicUpdatePropertyInt(0x02CE)` sequence not honored (latest-wins). - -## 7. Tests (conformance + acceptance) - -- **Resolve (dat-gated golden):** `ResolveEffectDid` → Magical `0x060011CA`, Poisoned - `0x060011C6`, BoostHealth `0x06001B05`, None & Nether → fallback `0x060011C5`. -- **Recolor (dat-free):** `ReplaceColorWhite` turns `0xFFFFFFFF` pixels into the dest color - and leaves non-white pixels untouched; a 2-layer compose + recolor yields the expected - pixels. -- **Parse:** `CreateObject.TryParse` captures `UiEffects` from a synthetic body with the - `0x80` flag; `PublicUpdatePropertyInt.TryParse` returns `(guid, prop, value)` from golden - bytes and rejects a wrong opcode / truncation. -- **Repository:** `EnrichItem(effects:…)` sets `Effects`; `UpdateIntProperty(guid, 18, v)` - sets `Effects` and fires `ItemPropertiesUpdated`; returns false for an unknown guid. -- **Acceptance (visual):** build + `dotnet test` green, then the user confirms in the live - client — a magical item shows the effect tint, and an item draining mana updates live. - -## 8. Acceptance criteria checklist - -- [ ] `UiEffects` captured on `CreateObject`, threaded to `ItemInstance.Effects`. -- [ ] `IconComposer.GetIcon` 5-arg with the faithful 2-stage composite + effect recolor. -- [ ] `ResolveEffectDid` golden test passes against the live dat. -- [ ] `PublicUpdatePropertyInt(0x02CE)` parsed; `UiEffects` updates re-composite live. -- [ ] Appraise path left as-is (no speculative icon enrichment added). -- [ ] Register: `IA-16` retired; `DR-1..DR-4` added (same commits as the code they describe). -- [ ] `dotnet build` + `dotnet test` green; roadmap + memory digest updated. -- [ ] Visual verification by the user. diff --git a/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md b/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md deleted file mode 100644 index 1ad1a645..00000000 --- a/docs/superpowers/specs/2026-06-18-a7-fixd-torch-overbright-design.md +++ /dev/null @@ -1,211 +0,0 @@ -# A7 Fix D — warm torch over-brightness on indoor walls (#140) - -**Date:** 2026-06-18 **Milestone:** M1.5 (Indoor world feels right) → A7 lighting -**Status:** design approved (user pre-approved 2026-06-18); ready for implementation plan. -**Investigation source of truth:** -[`docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md`](../../research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md) -(RESOLVED section) + `claude-memory/reference_retail_ambient_values.md`. - -## Problem - -The Holtburg meeting-hall walls (and outdoor objects near torches) blow out -**warm/bright** in acdream vs **dim** in retail. Fix A/B/C (shipped) did not touch this. - -The handoff "contradiction" (D3D-FF math `color×100×N·L/d` says walls should go WHITE, -yet retail is DIM) is **resolved**: the D3D-FF hardware model is the **wrong oracle** -for these walls. Two SEPARATE retail light systems (Ghidra xrefs, unambiguous): - -- **STATIC lights → CPU vertex BAKE**: `DrawEnvCell` (0x0059F170) → - `SetStaticLightingVertexColors` (0x0059CFE0) → `calc_point_light` (0x0059C8B0, its - SOLE caller). Wall torches are STATIC objects → baked into vertex colours. -- **DYNAMIC lights → D3D hardware FF**: `add_dynamic_light` → `config_hardware_light` - (0x0059AD30); `minimize_envcell_lighting` (0x0054C170) enables ONLY the dynamic - subset for a cell. The previously-captured `intensity=100` light is on THIS path. - -`calc_point_light` is mathematically **bounded**: range gate `d < falloff×1.3`; the -decisive **per-channel cap `min(scale·color, color)`** (a torch adds at most its own -sub-1.0 colour, any intensity); caller sums from **BLACK** then clamps the sum to -`[0,1]` (no ambient/sun in the bake accumulator). White needs many in-range lights; -a hall has a handful, each warm-capped. - -### Ground truth (live cdb, `tools/cdb/a7-fixd-*.cdb`; `Render::world_lights` @ 0x008672a0) - -Holtburg: **38 static + 2 dynamic** lights. - -| Light | path | type | intensity | falloff | colour (r,g,b) | -|---|---|---|---|---|---| -| viewer light | dynamic / HW | point | 2.25 | 10 | (1, 1, 1) white | -| **portal** | dynamic / HW | point | **100** | 6 | **(0.784, 0, 0.784) magenta** ← the captured "intensity=100"; NOT a wall torch | -| 38× wall torch | static / **bake** | point | 100 | 3–5 | **(1.0, 0.588, 0.314) orange** / (0.980, 0.843, 0.612) cream | - -Torches carry `intensity=100` too, but the per-channel cap pins each to its warm -colour ⇒ retail walls go warm, never white. - -## Root cause in acdream (both verified in source) - -Two independent bugs, both touching the meeting-hall walls; this spec fixes both. - -**D-1 (math, primary): unclamped accumulator folding ambient + sun + torches.** -[`mesh_modern.vert`](../../../src/AcDream.App/Rendering/Shaders/mesh_modern.vert) -`accumulateLights` starts `lit = uCellAmbient.xyz` (:184), adds sun (:196), adds each -capped torch (:206), returns UNCLAMPED (:208); the only clamp is `min(lit,1.0)` in -`mesh_modern.frag:92` after a lightning bump. The per-light cap (vert:180) is faithful. -But pouring ambient + sun + up-to-8 intensity-100 WARM torches into ONE bucket and -trimming only at the end overflows to warm-white. Retail clamps the torch sum on its -OWN (from black); ambient/sun are a separate material-lit term. - -**D-2 (state, compounding): EnvCell shell SSBO binding leak.** -[`EnvCellRenderer.RenderModernMDIInternal`](../../../src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs) -binds SSBO 0/1/2/3 only, NEVER **4** (`gLights`) or **5** (`instanceLightIdx`) — which -the shared `mesh_modern.vert` reads unconditionally (:204-206). Only `WbDrawDispatcher` -binds 4/5. Indoor `DrawInside` interleaves the two, so a cell shell reads whatever -LEAKED light set the last `WbDrawDispatcher` draw left bound (a different entity's -torches, wrong per-instance indices) ⇒ wrong/over-bright walls. - -`LightBake.cs` (verbatim CPU port of the bake) exists but is UNWIRED (zero callers). - -## Design - -Decisions (user, 2026-06-18): **D-1 = small in-shader clamp split** (not a CPU bake); -**D-1 + D-2 land together**, single visual verification. - -### D-1 — clamp the torch sum on its own (mirrors `SetStaticLightingVertexColors`) - -In `mesh_modern.vert` `accumulateLights`, give point/spot lights their own accumulator, -saturate it to `[0,1]` BEFORE it joins ambient + sun. The per-light cap and -`pointContribution` are unchanged; the only new operation is one `min(pointAcc, 1.0)`. - -```glsl -vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { - // ambient + sun = retail's material-lit term - vec3 lit = uCellAmbient.xyz; - int activeLights = int(uCellAmbient.w); - for (int i = 0; i < 8; ++i) { - if (i >= activeLights) break; - if (int(uLights[i].posAndKind.w) != 0) continue; // directional only - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; - } - // point/spot torches: their OWN accumulator, clamped to [0,1] (retail baked emissive) - vec3 pointAcc = vec3(0.0); - int base = instanceIndex * 8; - for (int k = 0; k < 8; ++k) { - int gi = instanceLightIdx[base + k]; - if (gi < 0) continue; - pointAcc += pointContribution(N, worldPos, gLights[gi]); // per-light cap unchanged - } - lit += min(pointAcc, vec3(1.0)); // <-- THE FIX - return lit; // frag still does final min(lit, 1.0) -} -``` - -Behaviour change is confined to surfaces whose torch sum currently exceeds 1.0 — -normally-lit surfaces are byte-identical (no regression). Shared by every mesh using -this shader (outdoor objects AND cell walls), matching the issue's scope. -`mesh_modern.frag:92`'s final `min(lit, 1.0)` stays as-is (it clamps the total to the -retail FF pixel clamp). The lightning bump (frag:89) is unaffected. - -### D-2 — the EnvCell shell binds its OWN light set - -`EnvCellRenderer` must own its lighting like `WbDrawDispatcher` does, instead of reading -leaked SSBO state. Mirror `WbDrawDispatcher`'s proven pattern -(`ComputeEntityLightSet`/`AppendCurrentLightSet`/`UploadGlobalLights`): - -1. **Wire `LightManager` in** via `Initialize(...)` (alongside `_shader`). Self-contained - pass — per `feedback_render_self_contained_gl_state`, EnvCellRenderer already - re-uploads its own `uViewProjection`; it now also uploads/binds its own lights. -2. **Binding 4 (global lights):** upload `LightManager.PointSnapshot` itself, packed - identically to `WbDrawDispatcher.UploadGlobalLights` (the `GlobalLight` SSBO layout: - `posAndKind`, `dirAndRange`, `colorAndIntensity`, `coneAngleEtc`). Same snapshot → - same indices both renderers reference. `BuildPointLightSnapshot` is already called - once per frame before rendering. **Extract the packing into a shared helper** so the - two renderers cannot drift (a `GlobalLightPacker` in `AcDream.App/Rendering/Wb/` or a - static on the snapshot type) — do not copy-paste the struct layout. -3. **Binding 5 (per-instance light set):** per **cell** (keyed on `allInstances[i].CellId`), - compute the set ONCE with `LightManager.SelectForObject(snapshot, cellCenter, - cellRadius, set)` (camera-independent; cache per CellId, reuse for all that cell's - part-instances — like `WbDrawDispatcher` reuses one set per entity). Write the 8-int - set per instance into a new buffer parallel to `_gpuInstanceTransforms` (same shape - as `_clipSlotData`); bind at binding 5. On a no-lights frame, fill -1 (shader adds no - point light) and still bind a ≥1-element buffer so the SSBO is never unbound. -4. **Cell centre/radius:** world-space bounding sphere of the cell geometry — reuse the - cell's existing visibility bound (the BSP/AABB sphere already computed for culling). - The exact field is pinned during planning by reading the cell-storage structs in - `EnvCellRenderer` / `EnvCellLandblock`; fallback = centre from the cell-part transform - translation, radius from the cell vertex AABB. **This is the one detail to confirm - against code in the plan.** - -Order independence: D-1 and D-2 are orthogonal (shader math vs buffer binding) and can -be implemented in either order, but ship together. - -## Testing (TDD) - -`LightBake.cs` already encodes the correct math: `PointContribution` = per-light capped -(matches `mesh_modern.vert` pointContribution line-for-line), `ComputeVertexColor` = sum -reaching point lights → clamp `[0,1]`, skip directional. The new shader `pointAcc` clamp -mirrors `ComputeVertexColor`'s final clamp exactly. - -New conformance test in `tests/AcDream.Core.Tests/` (e.g. `LightBakeConformanceTests`): - -- **Golden warm torch, bounded:** an orange `(1, 0.588, 0.314)` `intensity=100` - `falloff=4` (Range = 4×1.3 = 5.2 m) torch lighting a wall vertex (facing it) at - d = 1, 2, 3, 4, 5 m → result is warm (R ≥ G ≥ B, hue preserved) and **every channel - ≤ 1.0** (never white); at d ≥ Range the contribution is 0 (range gate). -- **No-blowout under stacking:** 8 overlapping `intensity=100` near-white torches summed - via `ComputeVertexColor` → each channel clamps to ≤ 1.0 (the `[0,1]` saturate holds). -- **Hue preserved:** a single orange torch's bounded result keeps B < G < R (warm), not - desaturated toward white. - -These pin the contract the shader must match. GLSL is not unit-testable in-process -(standard for this project per the render digest); the shader `pointContribution` + -`pointAcc` clamp are matched to `LightBake` by **line-for-line review** with the C# -oracle as the pinned reference (call it out in the implementation commit). - -## Bookkeeping — divergence register - -- **Correct stale row AP-35** (`docs/architecture/retail-divergence-register.md`): it - describes the point-light path as per-pixel `mesh_modern.frag:52` with the half-Lambert - wrap "NOT ported". Reality since Fix A (`aa94ced`): per-vertex Gouraud in - `mesh_modern.vert:163` WITH the wrap ported. Update the row to match; the D-1 clamp - makes the accumulator MORE faithful (no new deviation introduced). -- **EnvCell shell per-cell 8-light selection** (D-2) inherits Fix B's existing - per-object approximation (retail bakes per-VERTEX over the full static list; acdream - selects up to 8 per cell-sphere then gates per-vertex in-shader). Confirm Fix B's - register row covers EnvCell shells; extend that row if needed — do NOT add a - contradicting row. - -## Files - -- `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` — D-1 clamp split. -- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` — verify final clamp stays correct - (expected no change). -- `src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs` — D-2: `LightManager` ref, per-cell - light sets, bind SSBO 4 + 5, per-instance light-set buffer. -- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (+ a shared `GlobalLightPacker`) — - extract the binding-4 global-lights packing so both renderers share it. -- `src/AcDream.App/Rendering/GameWindow.cs` — wire `LightManager` into - `EnvCellRenderer.Initialize` (minimal). -- `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs` — new. -- `docs/architecture/retail-divergence-register.md` — AP-35 update. - -## Acceptance criteria - -- `dotnet build` green; `dotnet test` green including the new conformance test. -- Conformance test passes on the captured golden torch values (warm, bounded, hue-preserved). -- Shader `pointContribution` + new `pointAcc` clamp reviewed line-for-line against - `LightBake` (cited in the commit). -- AP-35 corrected; any D-2 register note reconciled with Fix B's row. -- **Visual (user):** outdoor objects near torches no longer blow out warm-white, and the - Holtburg meeting-hall walls render warm-but-dim like retail. - -## Out of scope (explicit) - -- **Do NOT port the D3D-FF hardware model** (`config_hardware_light`'s - `color×intensity`, `(0,1,0)=1/d`, `Range=falloff×1.5`) — it lights GfxObjs/dynamics, - not the baked walls. Wrong oracle (handoff warning stands). -- **Do NOT** wire the CPU vertex bake (`LightBake.cs` as the runtime path) — chosen - approach is the in-shader clamp split. `LightBake.cs` stays the test oracle. -- Sun handling on indoor walls is unchanged (kept in the material-lit term as today); - any "should indoor walls receive sun at all" refinement is a separate question. -- The purple portal is correct — do not touch it. diff --git a/docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md b/docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md deleted file mode 100644 index 482670b4..00000000 --- a/docs/superpowers/specs/2026-06-18-d53a-selected-object-meter-design.md +++ /dev/null @@ -1,292 +0,0 @@ -# D.5.3a — Selected-object meter (Stream A) — design - -**Date:** 2026-06-18 -**Phase:** D.5.3a (the action bar's bottom strip). Roadmap: D.2b retail-UI track, issue #140. -**Branch:** `claude/hopeful-maxwell-214a12`. -**Handoff parent:** `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §2. - -## Goal - -When the player selects a world object (LMB pick → `PickAndStoreSelection`, or Tab/Q combat-target -→ `SelectClosestCombatTarget`), the action bar's bottom strip shows: - -- the selected object's **name** (always, for any selection), and -- a live **Health** meter — only for targets that are a player, a pet, or attackable - (retail's `IsPlayer() || pet_owner || ObjectIsAttackable()` gate). - -On deselect (or despawn of the selected object) the strip clears. - -**Out of scope (deferred):** the **Mana** meter (`0x100001A2`, issue #140 — owned-item-only), -the stack-size entry box + slider (`0x100001A3`/`0x100001A4`), and the formatted stack-count name -suffix. Mana is a tracked feature gap, not a runtime deviation. - -## Retail oracle - -`gmToolbarUI::HandleSelectionChanged` — `docs/research/named-retail/acclient_2013_pseudo_c.txt:198635`. -Verbatim behavior (the spec follows this exactly): - -1. **Clear-then-populate.** On any selection change (`m_iidSelectedObject != selectedID`): - - `UIElement_Text::SetText(m_pSelObjectName, "")` — clear the name. - - `m_pSelObjectField->SetState(0)` — reset the overlay field (`0x100001A0`) to its blank - DirectState. - - If the health meter was visible: `Event_QueryHealth(0)` (cancel) + `SetVisible(0)`. - - If the mana meter was visible: `Event_QueryItemMana(0)` + `SetVisible(0)`. - - Hide stack entry box + slider. -2. **Selection == 0** → set the use-object button to disabled state and return (strip stays cleared). -3. **Selection != 0** (weenie object resolved): - - Name = `ACCWeenieObject::GetObjectName(NAME_APPROPRIATE)`. `_stackSize <= 1` → plain name; - `_stackSize > 1` → formatted with count (**deferred**). - - For a non-stack (`_stackSize == 0 || _stackSize <= 1`): - - `eax_29 = IsPlayer()`; if not player and no `pet_owner`, `eax_32 = ObjectIsAttackable(selectedID)`. - - **If `IsPlayer || pet_owner != 0 || attackable`:** `m_pSelObjectField->SetState(0x1000000b)` - (the "ObjectSelected" state) **and** `CM_Combat::Event_QueryHealth(m_iidSelectedObject)`. - (Health meter becomes visible via the subsequent `RecvNotice_UpdateObjectHealth` path, - which `SetVisible(1)`s it and sets the fill — see handoff §2.) - - **Else:** `m_pSelObjectField->SetState(0x1000000b)`; if `IsOwnedByPlayer`, - `CM_Item::Event_QueryItemMana(selectedID)` (**mana deferred**). - -Supporting anchors: `RecvNotice_UpdateObjectHealth` (`:196213`) → `SetAttribute_Float(meter, 0x69, pct)` -(property `0x69` = fill ratio); `UIElement_Meter::Initialize` (`:123328`), `OnSetAttribute` (`:123712`). - -State/sprite ids (from `.layout-dumps/toolbar-0x21000016.txt`): the overlay field `0x100001A0` -carries states **ObjectSelected** (id `0x1000000b`, sprite `0x06001937`) and **StackedItemSelected** -(sprite `0x06004CF4`); health meter `0x100001A1` back-track DirectState `0x0600193E`, fill child -`0x00000002` DirectState `0x0600193F`; mana meter `0x100001A2` back `0x060022D5` / fill `0x060022D6`. - -## Current-code facts (verified at HEAD) - -- **Selection state** is a private field `_selectedGuid` (`GameWindow.cs` ~`:848`), assigned at 3 sites: - `PickAndStoreSelection` (~`:11571`), `SelectClosestCombatTarget` (~`:11961`), and the despawn-clear - (`if (_selectedGuid == serverGuid) _selectedGuid = null;` ~`:3710`). No change event exists. - `TargetIndicatorPanel` polls it via `selectedGuidProvider: () => _selectedGuid`. -- **`CombatState`** (`AcDream.Core.Combat`) has `GetHealthPercent(guid)` (returns `1f` if unseen) and - `HealthChanged`. `UpdateHealth (0x01C0)` → `OnUpdateHealth` is already wired (`GameEventWiring`). -- **`SocialActions.BuildQueryHealth(uint seq, uint targetGuid)`** exists (opcode `0x01BF`, replies - `UpdateHealth 0x01C0`). No `WorldSession.SendQueryHealth` wrapper yet. -- **`IsLiveCreatureTarget(uint guid)`** (`GameWindow.cs` ~`:11979`): not-self + in-world + - `ItemType.Creature` flag. Used to gate Tab/Q targeting and `UseItemByGuid`. -- **`VitalsController.Bind`** is the proven bind pattern: find meter by id, set `m.Fill = () => pct()` - (polled each draw), attach a centered `UiText` child (dat font, `ClickThrough`) for text. -- **`UiMeter.DrawHBar`** already renders a *single full-width sprite* correctly: with `tile`/`right` - ids = 0, the left-cap spans the whole bar and the fill UV-crops to the fraction. **No `UiMeter` - change is needed** for the single-image toolbar meters. -- **`DatWidgetFactory.BuildMeter`** assumes **2** Type-3 slice containers (vitals 3-slice). The toolbar - selected-object meters have **1** Type-3 child (the fill, on its own DirectState) with the back-track - on the *meter element's own* DirectState → the `containers.Count != 2` branch mishandles them. -- **`UiDatElement.ActiveState`** (string) drives `ActiveMedia()`; `""` = blank DirectState. This is the - overlay-state switch for `0x100001A0`. -- **`ClientObject`** exposes `Name` and `StackSize`. `ClientObjectTable.Get(guid)` returns the object - (or null). `ToolbarController` already binds with `Objects` (the `ClientObjectTable`). -- **`ToolbarController.HiddenIds`** currently hides `0x100001A1` (health), `0x100001A2` (mana), - `0x100001A4` (stack slider) at bind. - -## Decisions (settled in brainstorm) - -- **Selection signal: event via property setter.** Convert `_selectedGuid` → a `SelectedGuid` property - whose setter fires `event Action? SelectionChanged` only when the value actually changes. - Replace the 3 assignment sites with the property; reads unchanged. (Retail-faithful — selection is - event-driven; the setter centralizes the fire and auto-dedups.) -- **Send `QueryHealth (0x01BF)` on select** for health-bearing targets (retail-faithful; builder - exists). Continuous updates still come from server `UpdateHealth` broadcasts. -- **Mana deferred** (issue #140). - -## Architecture - -Three new units + one refactor + one wiring change. Each unit is independently testable. - -### 1. `GameWindow.SelectedGuid` property + `SelectionChanged` event (refactor) - -```csharp -public event Action? SelectionChanged; -private uint? _selectedGuid; -private uint? SelectedGuid -{ - get => _selectedGuid; - set - { - if (_selectedGuid == value) return; // dedup: fire only on real change - _selectedGuid = value; - SelectionChanged?.Invoke(value); - } -} -``` - -Replace the 3 *write* sites (`_selectedGuid = …`) with `SelectedGuid = …`. Leave all *read* sites -(`_selectedGuid is uint`, `() => _selectedGuid`, the despawn comparison's read half) on the field — -they observe the same backing store. The despawn-clear becomes -`if (_selectedGuid == serverGuid) SelectedGuid = null;`. - -### 2. `DatWidgetFactory.BuildMeter` — handle the single-image meter shape - -After ordering the Type-3 child containers by `ReadOrder`: - -- **`containers.Count >= 2`** (vitals): unchanged — `SliceIds(containers[0])` → Back\*, - `SliceIds(containers[1])` → Front\*. -- **`containers.Count == 1`** (toolbar selected-object meter): single-image back+fill. - - `m.BackLeft = info.StateMedia[""].File` (the meter element's own DirectState back-track), - `BackTile = BackRight = 0`. - - `m.FrontLeft = containers[0].StateMedia[""].File` (the fill child's own DirectState), - `FrontTile = FrontRight = 0`. - - The fill child has **no** image grandchildren, so `SliceIds` must **not** be used for it; read the - container's own `StateMedia[""]` directly. -- **`containers.Count == 0`**: leave the warning (genuinely malformed). - -Keep a `Console.WriteLine` only for the genuinely-unexpected `Count == 0` (or `> 2`) case; the -`Count == 1` case is now a handled shape, not a warning. - -`UiMeter` is unchanged — `DrawHBar(BackLeft=fullSprite,0,0,clipW=Width)` draws the back once, -`DrawHBar(FrontLeft=fullSprite,0,0,clipW=Width*p)` UV-crops the fill to the health fraction. - -### 3. `SelectedObjectController` (new — `src/AcDream.App/UI/Layout/SelectedObjectController.cs`) - -The `HandleSelectionChanged` analogue. A sealed class (like `ToolbarController`) bound once. - -**Element ids** (constants): name `0x1000019F`, overlay field `0x100001A0`, health meter `0x100001A1`. -(`0x100001A2` mana / `0x100001A3`/`0x100001A4` stack are not touched here — deferred.) - -**`Bind` signature:** - -```csharp -public static SelectedObjectController Bind( - ImportedLayout layout, - Action> subscribeSelectionChanged, // hands the controller its handler to register - Func isHealthTarget, // IsLiveCreatureTarget proxy - Func name, // ClientObjectTable.Get(g)?.Name - Func healthPercent, // CombatState.GetHealthPercent - Func stackSize, // ClientObjectTable.Get(g)?.StackSize ?? 0 (overlay state) - Action sendQueryHealth, // WorldSession.SendQueryHealth (no-op if offline) - UiDatFont? datFont) -``` - -`subscribeSelectionChanged` is invoked once with the controller's `OnSelectionChanged` handler so the -host can do `c => SelectionChanged += c` without the controller referencing `GameWindow`. (Keeps the -Core-clean delegate-seam style of `TargetIndicatorPanel`.) - -**Bind-time setup:** -- Find the three elements (silently skip any that are absent — partial/test layouts). -- `_healthMeter.Visible = false` (this controller now **owns** the meter's initial-hidden state). -- Attach a centered `UiText` child to the name element (mirror `VitalsController.BindMeter`'s number - attach): `Centered`, `DatFont = datFont`, `ClickThrough`, `AcceptsFocus=false`, `IsEditControl=false`, - `CapturesPointerDrag=false`, anchored to fill the parent, `LinesProvider = () =>` the current name as - a single white line (empty → no lines). Color: white for D.5.3a (`new Vector4(1,1,1,1)`). -- `_healthMeter.Fill = () => _current is uint g ? healthPercent(g) : 0f` (polled each draw). -- Register the handler via `subscribeSelectionChanged(OnSelectionChanged)`. - -**`OnSelectionChanged(uint? guid)`** (mirrors the decomp's clear-then-populate): -- **Clear first:** `_healthMeter.Visible = false`; overlay `ActiveState = ""`; `_currentName = null`. -- Set `_current = guid`. -- If `guid is null` → done (strip cleared). -- Else: - - `_currentName = name(guid)` (the name `UiText` reads this). - - overlay `ActiveState = stackSize(guid) > 1 ? "StackedItemSelected" : "ObjectSelected"`. - - If `isHealthTarget(guid)`: `_healthMeter.Visible = true`; `sendQueryHealth(guid)`. - - (else: name + overlay only — friendly NPC / non-owned item / scenery.) - -State held: `_current` (uint?), `_currentName` (string?). The meter `Fill` + name `LinesProvider` -read these closures, so the per-frame draw reflects live data without a tick. - -> **Note on the meter-visible timing.** Retail makes the health meter visible from -> `RecvNotice_UpdateObjectHealth` (when the queried value arrives), not from -> `HandleSelectionChanged` itself. acdream shows it immediately on select for a health target (the -> fill polls `GetHealthPercent`, which is `1.0` until the `QueryHealth` reply lands a beat later). -> This avoids a one-round-trip blank-then-pop and is visually indistinguishable for a full-HP target; -> for a damaged target the bar corrects within one server round-trip. Recorded as a divergence row. - -### 4. `WorldSession.SendQueryHealth(uint targetGuid)` (new) - -```csharp -/// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). -public void SendQueryHealth(uint targetGuid) -{ - uint seq = NextGameActionSequence(); - byte[] body = SocialActions.BuildQueryHealth(seq, targetGuid); - SendGameAction(body); -} -``` - -(Pattern = `SendChangeCombatMode`, `WorldSession.cs:1134`.) - -### 5. GameWindow wiring (minimal) - -After `ToolbarController.Bind` (the toolbar layout is in scope as `toolbarLayout`, dat font as -`vitalsDatFont`): - -```csharp -AcDream.App.UI.Layout.SelectedObjectController.Bind( - toolbarLayout, - subscribeSelectionChanged: h => SelectionChanged += h, - isHealthTarget: IsLiveCreatureTarget, - name: g => Objects.Get(g)?.Name, - healthPercent: g => Combat.GetHealthPercent(g), - stackSize: g => Objects.Get(g)?.StackSize ?? 0u, - sendQueryHealth: g => _liveSession?.SendQueryHealth(g), - datFont: vitalsDatFont); -``` - -Also: remove **only** `0x100001A1` from `ToolbarController.HiddenIds` — the health meter is now owned -by `SelectedObjectController` (it hides A1 at bind, shows on a health-target select). `0x100001A2` -(mana, deferred #140) and `0x100001A4` (stack slider, deferred) **stay** in `HiddenIds`: they have no -controller yet, so they must stay hidden or their dat back-track sprites render as stray empty bars. -(`HiddenIds = { 0x100001A2, 0x100001A4 }`.) Convert the `_selectedGuid` field → the `SelectedGuid` -property (unit 1). - -## Data flow - -select → `SelectedGuid` setter → `SelectionChanged(guid)` → `SelectedObjectController.OnSelectionChanged` -→ name + overlay set, meter shown (health target), `SendQueryHealth(guid)` → server `UpdateHealth 0x01C0` -→ `GameEventWiring` → `CombatState.OnUpdateHealth` → cache → meter `Fill` poll reads -`GetHealthPercent` → bar fills. Deselect / despawn → `SelectionChanged(null)` → strip cleared. - -## Error handling / edge cases - -- **Unknown guid** → `GetHealthPercent` returns `1.0` (full) until the `QueryHealth` reply arrives. -- **Selected entity despawns** → existing despawn-clear sets `SelectedGuid = null` → `SelectionChanged(null)`. -- **Partial / test layout** (missing elements) → controller silently skips absent elements - (`VitalsController` pattern). -- **No live session** → `_liveSession?.SendQueryHealth` no-ops. -- **Re-select the same guid** → property setter dedups; no redundant query / re-show. - -## Testing (conformance) - -All App-layer tests in `tests/AcDream.App.Tests/`; net test in `tests/AcDream.Core.Net.Tests/`. - -1. **`DatWidgetFactoryTests`** (extend): feed a synthetic 1-container meter `ElementInfo` (back on the - element's `StateMedia[""]`, fill on the single Type-3 child's `StateMedia[""]`) → assert - `BackLeft == backFile`, `FrontLeft == fillFile`, `BackTile/BackRight/FrontTile/FrontRight == 0`, - and no warning path taken. Add/keep a 2-container case asserting the vitals 3-slice path is - unchanged. -2. **`SelectedObjectControllerTests`** (new — mirror `ToolbarControllerTests`): build a minimal - `ImportedLayout` containing `0x1000019F`/`0x100001A0` (as `UiDatElement`)/`0x100001A1` (as - `UiMeter`). Use recording delegates. Assert: - - bind → health meter `Visible == false`, a name `UiText` child attached. - - select health target → meter `Visible == true`, overlay `ActiveState == "ObjectSelected"`, name - provider returns the object name, `sendQueryHealth` invoked exactly once with the guid. - - select stack (`stackSize > 1`) → overlay `ActiveState == "StackedItemSelected"`. - - select non-health target → meter stays hidden, name set, `sendQueryHealth` **not** invoked. - - deselect (`null`) → meter hidden, overlay `ActiveState == ""`, name provider returns empty. - - re-fire same guid path is driven by the event, so the dedup is the property's job (covered in 3). -3. **`SendQueryHealth`** (net test): drive `WorldSession.SendQueryHealth(guid)` through the existing - send-capture seam (the same harness `SendChangeCombatMode` / chat sends use) and assert the captured - GameAction bytes equal `SocialActions.BuildQueryHealth(seq, guid)`. -4. **`SelectedGuid` dedup**: the property is on `GameWindow` (not unit-testable in isolation). Its - contract — "fires once on change, never on same value, fires `null` on clear" — is asserted - indirectly by test 2's reliance on single-fire and confirmed at the visual gate. No standalone test. - -## Divergence register rows (add in the implementation commit) - -- **Health-meter gate approximation.** Retail shows the health meter for - `IsPlayer() || pet_owner || ObjectIsAttackable()`; acdream uses `IsLiveCreatureTarget` - (the `ItemType.Creature` flag). Risk: a friendly (non-attackable) NPC shows a health meter where - retail would show name+overlay only. Cite `SelectedObjectController` + `HandleSelectionChanged:198754`. -- **Meter-visible timing.** acdream shows the health meter on select; retail shows it from - `RecvNotice_UpdateObjectHealth` when the queried value arrives. Risk: a freshly-selected - off-screen-damaged target reads full for one server round-trip. Cite - `SelectedObjectController.OnSelectionChanged` + `HandleSelectionChanged:198757`. - -## Acceptance criteria - -- `dotnet build` green; `dotnet test` green (new + existing). -- Every AC-specific behavior cites its named-retail anchor in comments. -- Divergence rows added. -- Visual gate (user): selecting a creature shows its name + a correct HP bar; deselecting clears the - strip; selecting a non-creature object shows the name only. diff --git a/docs/superpowers/specs/2026-06-18-d54-object-item-model-design.md b/docs/superpowers/specs/2026-06-18-d54-object-item-model-design.md deleted file mode 100644 index ee5c7118..00000000 --- a/docs/superpowers/specs/2026-06-18-d54-object-item-model-design.md +++ /dev/null @@ -1,336 +0,0 @@ -# D.5.4 — Client object/item data model (foundation) — design - -**Date:** 2026-06-18 -**Status:** design approved (brainstorm) → spec under review → writing-plans next -**Phase:** D.5.4 — the data-model foundation under D.5 "Core panels" (D.2b retail-look track). -Registered in the roadmap D.5 sub-phase ledger; blocks D.5.5+ (inventory / paperdoll / -vendor / trade panels resolve items from this table). -**Branch:** `claude/hopeful-maxwell-214a12` (D.5.1 + D.5.2 already landed here; this continues it). -**User constraint:** *"architecturally solid, no quick fixes"* — do NOT band-aid `EnrichItem` -to add new items; design the model properly. - -**Research evidence base (this spec cites; it does not re-derive):** -- [`docs/research/2026-06-18-item-object-model-handoff.md`](../../research/2026-06-18-item-object-model-handoff.md) — the phase framing + the crux -- [`docs/research/deepdives/r06-items-inventory.md`](../../research/deepdives/r06-items-inventory.md) — item/property/container model + `PublicWeenieDesc` wire layout (§4) + burden (§6) + 2-deep containers (§7) -- [`docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md`](../../research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md) — `ClientObjMaintSystem` / resolve-by-guid model -- [`docs/research/2026-06-16-inventory-deep-dive.md`](../../research/2026-06-16-inventory-deep-dive.md) — inventory wire catalog + container learning -- The named-retail decomp `acclient_2013_pseudo_c.txt` / `acclient.h` (the oracle for the two-table model) - ---- - -## 1. Goal - -Replace acdream's **enrich-existing-only** item scaffold with retail's **canonical-create** -object model. Today `ItemRepository.EnrichItem` (`ItemRepository.cs:162`) returns `false` -and silently drops a `CreateObject` for any item that wasn't pre-seeded as a stub from -`PlayerDescription` — so items acquired mid-session, ground items, vendor items, and pack -items the login snapshot didn't enumerate never enter the model and render no icon (confirmed -live on Coldeve, character Barris: 4 of 6 hotbar slots blank). - -After this phase: **`CreateObject (0xF745)` is the canonical create-or-update for every -server object**, the data table holds the data side of *every* object (items and creatures -alike), `PlayerDescription`/shortcuts are references, the container membership index is live, -and all UI resolves objects by guid. The Coldeve blank-icon bug is fixed at the root, and the -foundation D.5.5's panels sit on is in place. - -This is a **data-model + ingestion** phase. No new panels ship; the toolbar (D.5.1) is the -only live consumer and must keep working (visually unchanged). - -## 2. The crux — settled (the three brainstorm decisions) - -The handoff's §2 framing ("retail unifies everything under one `ClientObjMaintSystem`") was a -misread. The named decomp shows retail is a **two-table** design, and the brainstorm settled -the architecture against that ground truth: - -1. **Two tables, fix ingestion** (not unify). Retail's `CObjectMaint` holds *two* hash tables - keyed by the same guid — `object_table` (`CPhysicsObj`, render/physics) and - `weenie_object_table` (`ACCWeenieObject`, data/UI) — cross-linked by pointer, created and - destroyed together. The UI *only* calls `GetWeenieObject(guid)`; physics *only* calls - `GetPhysicsObject(guid)`. acdream's existing `WorldEntity` + item-table split already - mirrors this. We keep them separate (joined by guid) and fix the *ingestion*, not the - structure. A merge would also violate Code Structure Rule 2 (`WorldEntity` carries - `MeshRef`/GfxObj dat handles and rendering-coupled AABB math; merging drags GL into - `AcDream.Core`). - -2. **Complete model + container index.** Capture the full item weenie-field set (currently - parsed-then-discarded) into the data object, maintain a live container-membership index - (`containerGuid → ordered items`), evict on `DeleteObject`, fix the `WeenieClassId` misuse, - and expose a formal resolve-by-guid surface. Defer only the panel UIs and panel-driven - flows to D.5.5. - -3. **All objects (true weenie table).** `CreateObject` upserts *every* object (creatures, - players, NPCs, items) into the data table, making it acdream's `ACCWeenieObject`-equivalent - and retiring the redundant `GameWindow._liveEntityInfoByGuid` (Name+ItemType duplicate) so - selection/target also resolves from the one table. End state = exactly retail's two tables. - -## 3. Retail anchors (the load-bearing facts) - -All from the named decomp (`acclient_2013_pseudo_c.txt` / `acclient.h`), verified during the -D.5.4 code-map research: - -- **Two parallel tables, one manager.** `CObjectMaint` (`acclient.h:33078`) holds - `object_table : LongHash`, `weenie_object_table : LongHash`, - matching `null_object_table` / `null_weenie_object_table` placeholders (for out-of-order - create), `visible_object_table`, and `object_inventory_table : LongHash` - (the per-container contents lists). Both object tables keyed by the same `uint32` guid. -- **`CreateObject` is create-OR-update (timestamp-driven upsert), not create-only.** - `SmartBox::HandleCreateObject` (decomp ~93740) first calls - `CObjectMaint::GetObjectA(guid, &phys, &weenie)` (the 3-arg overload, ~269768) to detect - whether *either* table already has the guid. Fresh → `ACCObjectMaint::CreateObject` - (~356155) which allocates the `ACCWeenieObject` (`ACCFactory::MakeCWeenieObject_Internal`, - 0x150 bytes, ~354698), inserts it, fills `pwd` via `SetWeenieDesc`, cross-links, and inserts - the `CPhysicsObj`. Existing → the **update branch** patches in place via `SetWeenieDesc` - (data) + per-timestamp physics updates. **There is no full-object replace** — updates merge. -- **The weenie object holds all item game-data** in `pwd` (`PublicWeenieDesc`, `acclient.h:37163+`): - `_iconID/_iconOverlayID/_iconUnderlayID`, `_effects`, `_type`, `_stackSize/_maxStackSize`, - `_value`, `_burden`, `_containerID/_wielderID/_location/_priority`, - `_itemsCapacity/_containersCapacity`, `_structure/_maxStructure`, `_workmanship`, … -- **Every object is an `ACCWeenieObject`** — creatures/players included; the UI resolves a - selected creature's name/health from the same table via `GetWeenieObject`. -- **`DeleteObject` frees both objects atomically** (`ACCObjectMaint::DeleteObject` ~355020 → - `CObjectMaint::DeleteObject(guid)` ~270149) — physics and weenie removed in one call. -- **The wire layout** of the `PublicWeenieDesc` flag-gated tail is r06 §4; acdream's - `CreateObject.cs:558-806` already walks every field in exact ACE order (it skips the ones it - doesn't keep — capturing them is changing `pos += N` to read the value). - -## 4. Scope - -**In scope (D.5.4):** -- Rename + broaden: `ItemRepository` → `ClientObjectTable`, `ItemInstance` → `ClientObject`, - events `Item*` → `Object*`. The data table holds the data side of **all** server objects. -- `CreateObject.TryParse` captures the full item field set (see §6.1) — currently discarded. -- **Upsert is a field-level merge** (create-if-absent, else patch wire-carried fields in - place, preserving the `PropertyBundle` and move-state). `EnrichItem` is deleted. -- Ingestion wiring moves **off `GameWindow`** into `AcDream.Core.Net` (`ObjectTableWiring`): - `CreateObject`→upsert, `DeleteObject`→remove, the `0x02CE` UiEffects path→`UpdateIntProperty`. -- Container membership index (`containerGuid → ordered item guids`), live on upsert + move + - remove, exposed via `GetContents(guid)`. -- `WeenieClassId` captured from `CreateObject` (stop misusing `PlayerDescription`'s - `ContainerType` as the class id). -- `PlayerDescription` becomes a membership manifest (records "this guid is mine / in - container / equipped at slot"); out-of-order with `CreateObject` is safe (whichever arrives - first creates the entry, the other merges). -- Retire `GameWindow._liveEntityInfoByGuid`; migrate its consumers - (`IsLiveCreatureTarget`/`DescribeLiveEntity`/target-indicator) to `ClientObjectTable.Get`. -- `ToolbarController` resolves via `ClientObjectTable.Get` and **filters its event handler by - guid** (only re-binds when a changed guid is one of its 18 shortcuts). -- `DeleteObject` (0xF747) evicts from the table. -- Conformance tests throughout (§8). Preserve the D.5.2 effects-contract tests. - -**Out of scope (D.5.5+, explicit non-goals):** -- The panel UIs themselves (inventory / paperdoll / vendor / trade / spellbook). -- `ViewContents (0x0196)` open/close flow + the still-unwired inbound move events - (`InventoryPutObjectIn3D 0x019A`, `CloseGroundContainer 0x0052`, - `InventoryServerSaveFailed 0x00A0`) and their builders (`DropItem`/`GetAndWieldItem`/ - `NoLongerViewingContents`). -- Drag-drop mutate wire (`AddShortcut`/`RemoveShortcut`, `PutItemInContainer` from UI, etc.). -- `ShortCutManager` durable persistence (shortcuts stay in the current closure path). -- The broader `PublicUpdateProperty*` family beyond the existing `UiEffects (0x02CE)` path - (live StackSize/Value/Structure updates) — captured at create time, but the per-property - live-update parsers are D.5.5/M2. -- `null_object_table`-style pre-queuing of a child `CreateObject` that arrives before its - parent. (Our upsert already makes plain out-of-order PD↔CreateObject safe; the parent/child - parenting edge case is deferred — see §10 risks.) - -## 5. Architecture & components - -Two guid-keyed tables, joined by guid, both mutated on the render thread: - -| Table | acdream type | retail analogue | holds | layer | -|---|---|---|---|---| -| Render/physics | `WorldEntity` (+ `GpuWorldState`) | `object_table` / `CPhysicsObj` | mesh, position, AABB, cell | `AcDream.Core/World` + `AcDream.App` | -| **Data/UI** | **`ClientObjectTable`** of **`ClientObject`** | `weenie_object_table` / `ACCWeenieObject` | icon, name, type, stack, value, container/equip, properties | `AcDream.Core/Items` (pure data) | - -**Components (file → responsibility → change):** - -1. **`ClientObject`** (`AcDream.Core/Items/ItemInstance.cs` → renamed file/type from - `ItemInstance`). Per-object data record. *Change:* add the §6.1 fields; make `WeenieClassId` - settable; keep `PropertyBundle`. Item-specific fields are simply unset for creatures - (faithful to retail's `ACCWeenieObject` for non-items). - -2. **`ClientObjectTable`** (`AcDream.Core/Items/ItemRepository.cs` → renamed). The guid-keyed - store + container index + event surface. *Change:* - - `AddOrUpdate` becomes a **field-level merge upsert** (§7.2), not a whole-object replace. - - Add the container index: `Dictionary>` keyed by containerGuid, kept - ordered by slot; updated on upsert / `MoveItem` / `Remove`; exposed via - `IReadOnlyList GetContents(uint containerGuid)`. - - Events renamed `ObjectAdded/ObjectUpdated/ObjectRemoved/ObjectMoved`. - - `EnrichItem` deleted. - - Keep `ConcurrentDictionary` (plugin reads) + `GetItem`→`Get` resolve surface. - -3. **`ObjectTableWiring`** (new, `AcDream.Core.Net/ObjectTableWiring.cs`). Static - `Wire(WorldSession session, ClientObjectTable table)` subscribing the WorldSession - GameMessage-level events: `EntitySpawned`→`AddOrUpdate(merge)`, `EntityDeleted`→`Remove`, - `ObjectIntPropertyUpdated`→`UpdateIntProperty`. This is the seam that moves item ingestion - off `GameWindow` (Rule 1) while keeping `AcDream.Core` GL-free (Rule 2). - -4. **`CreateObject.cs`** (`AcDream.Core.Net/Messages`). *Change:* capture the §6.1 fields into - `Parsed` (extend the record); the wire-cursor walk already exists — replace the `pos += N` - skips with value reads. **Risk:** the `Parsed` positional ctor + `WorldSession.EntitySpawn` - mirror must both grow; cursor arithmetic must stay byte-identical (locked by tests). - -5. **`WorldSession.EntitySpawn`** (`AcDream.Core.Net/WorldSession.cs:47`). *Change:* add the - new fields so they reach the ingestion wiring. - -6. **`GameEventWiring.cs`** (`AcDream.Core.Net`). *Change:* `PlayerDescription` handler stops - creating "source of truth" stubs with `WeenieClassId = ContainerType`; instead it records - membership (a merge upsert that sets container/equip placement + marks the guid as the - player's). `WieldObject`/`InventoryPutObjInContainer` → `MoveItem` stays (already wired). - -7. **`GameWindow.cs`** (`AcDream.App`). *Change:* delete the `EnrichItem` call; construct - `ClientObjectTable` + call `ObjectTableWiring.Wire`; retire `_liveEntityInfoByGuid` and - point its consumers at `ClientObjectTable.Get`. Render-entity build is unchanged. - -8. **`ToolbarController.cs`** (`AcDream.App/UI/Layout`). *Change:* resolve via - `ClientObjectTable.Get`; event handler filters by guid (only re-bind affected shortcut - slots); subscribe to `ObjectRemoved` too (today it doesn't, leaving stale slots). - -9. **`IconComposer.cs`** — unchanged (takes fields, not the table). - -## 6. Data model - -### 6.1 `ClientObject` fields to add (capture from `CreateObject`) - -The `ClientObject` type **already declares** most of these fields (they exist on today's -`ItemInstance`), but `CreateObject` **does not populate them** — it walks past them on the -wire. This table is the wire-capture work: rows marked **new** also need a field added to the -type; the rest just need the parser to read the value into the existing field instead of -skipping it. The cursor walk already exists in `CreateObject.cs:558-806` (each field has a -`pos += N` skip today). Wire bits per r06 §4 / `PublicWeenieDesc`: - -| Field | Wire bit | field state | Notes | -|---|---|---|---| -| `WeenieClassId` | fixed prefix PackedDword (`CreateObject.cs:538`) | **make settable** | discarded today; init-only on the type | -| `Value` | `0x00000008` | exists | `pos += 4` today | -| `StackSize` / `StackSizeMax` | `0x00001000` / `0x00002000` | exists | skipped today | -| `Burden` | `0x00200000` | exists | skipped today | -| `ContainerId` | `0x00004000` | exists | item's parent container guid (drives the index) | -| `ValidLocations` | `0x00010000` | exists | EquipMask (paperdoll needs it) | -| `CurrentWieldedLocation` | `0x00020000` | exists → `CurrentlyEquippedLocation` | EquipMask | -| `ItemsCapacity` / `ContainersCapacity` | `0x00000002` / `0x00000004` | **new** | feed `Container` (u8 each) | -| `WielderId` | `0x00008000` | **new** | equip placement | -| `Priority` (ClothingPriority) | `0x00040000` | **new** | layer order | -| `Structure` / `MaxStructure` | `0x00000400` / `0x00000800` | **new** | charges/uses | -| `Workmanship` | `0x01000000` (f32) | **new** | salvage/tinker display | - -`ContainerType` (PD inventory entry, 0/1/2) moves to its own field on the entry/`Container`, -no longer aliased onto `WeenieClassId`. - -### 6.2 Container index - -`ClientObjectTable` maintains the equivalent of retail's `object_inventory_table`: -`containerGuid → ordered list of item guids` (ordered by `ContainerSlot`). It is derived data, -rebuilt from each object's `ContainerId`/`ContainerSlot`: -- **on upsert:** if the object has a non-zero `ContainerId`, (re)index it under that parent. -- **on `MoveItem`:** remove from old container list, add to new (or to equip if `WielderId`). -- **on `Remove`:** drop from its container list. -- **expose** `GetContents(containerGuid)` → ordered item guids (inventory panel reads this). - -Equip placement (`WielderId` + `CurrentWieldedLocation`) is tracked the same way so paperdoll -can ask "what's equipped in slot X" without scanning. - -## 7. Ingestion lifecycle - -### 7.1 The flow -- **`CreateObject (0xF745)`** → `WorldSession` parses (full field set) → fires `EntitySpawned` - → **`ObjectTableWiring`** calls `ClientObjectTable.AddOrUpdate(merge)` for **every** object, - independent of whether it also becomes a `WorldEntity` (inventory items have no position). - `GameWindow` keeps its own `EntitySpawned` subscription for the render-entity build. -- **`DeleteObject (0xF747)` / Pickup** → `EntityDeleted` → `ClientObjectTable.Remove(guid)` - (today this leaks until `Clear()`). Render teardown unchanged. -- **`PlayerDescription (0x0013)`** → membership manifest: a merge upsert that marks each - inventory/equipped guid as the player's and records placement (container/equip slot). The - *data* (icon/name/type/…) arrives from `CreateObject`. Shortcuts stay on the existing path. -- **`WieldObject 0x0023` / `InventoryPutObjInContainer 0x0022`** → `MoveItem` (already wired) → - re-parents in the container index. -- **`PublicUpdatePropertyInt 0x02CE` (UiEffects)** → `UpdateIntProperty` (already wired, - preserved). - -### 7.2 Upsert = field-level merge (the key correctness rule) -`AddOrUpdate` must NOT replace the whole object (today's `_items[id] = item` clobbers appraise -`PropertyBundle` + move-state on a `CreateObject` re-send; retail's update branch patches via -`SetWeenieDesc`). The merge rule: -- **Absent** → insert the new object; fire `ObjectAdded`. -- **Present** → patch only the wire-carried fields onto the existing object (Name, Type, - Icon*, Effects, Stack, Value, Burden, capacities, `WeenieClassId`, and placement - `ContainerId`/`CurrentWieldedLocation`/`WielderId` when the wire carries them); **preserve** - the `PropertyBundle` (appraise detail) and any state the wire didn't carry; fire - `ObjectUpdated`. -- **Effects** keeps the D.5.2 contract: assign unconditionally from the parsed value (0 = "no - effect", a meaningful state) so re-composition reflects the current server state. - -### 7.3 Out-of-order safety -Because upsert is create-or-merge, the PD↔CreateObject arrival order is irrelevant: whichever -arrives first creates the entry; the other merges its fields in. No drops (the root fix for -the Coldeve bug), no silent races. - -### 7.4 Threading -Unchanged: the net channel drains on the render-thread `OnUpdate`; both tables mutate on the -render thread; `ConcurrentDictionary` is retained only for safe plugin reads. Events fire -synchronously on the render thread (matching today). - -## 8. Testing (conformance throughout) - -xUnit, hand-built byte fixtures (matching `CreateObjectTests` / `ItemRepositoryTests` style; -no pcap, no Moq). New + changed tests: -- **Full-field-capture parse:** each new weenie-header field reads correctly; cursor - arithmetic stays byte-identical (a packet with a mid-tail field set still reaches - IconOverlay/IconUnderlay). Extend `CreateObjectTests`. -- **Upsert creates a brand-new object** (no PD stub) — the Coldeve bug; this test would have - failed before the fix and locks it. -- **Upsert merge** preserves `PropertyBundle` (appraise) + move-state across a `CreateObject` - re-send; does not clobber. -- **Out-of-order:** CreateObject-before-PD and PD-before-CreateObject converge to identical - state. -- **Container index:** add/move/remove keeps `GetContents` correct and slot-ordered; 2-deep - container depth (r06 §7); equip placement queryable. -- **`DeleteObject` eviction** removes from the table + the container index. -- **`WeenieClassId`** is the real class id from CreateObject, not the PD ContainerType. -- **`_liveEntityInfoByGuid` retirement regression:** selection/describe still resolve - name+type for a creature via `ClientObjectTable.Get`. -- **Toolbar guid-filter:** an unrelated object's `ObjectAdded` does not re-bind a shortcut - slot; a shortcut's `ObjectUpdated` does. -- **Preserve** the D.5.2 effects tests (`effects==0` clears; per-pixel tint) under the new - merge path. - -## 9. Divergence register - -- **Retire** the enrich-only stopgap rows (the `EnrichItem` drops-unseeded-items behavior is - gone). Delete those rows in the same commit that lands the fix. -- **Add** a row for the global-event-with-guid-filter consumer model vs. retail's per-object - `NoticeRegistrar` observer dispatch (a deliberate simplification — consumers filter by guid - rather than registering per-object observers). Note it; don't hide it. -- **Add** a row (or note under it) for the deferred `null_object_table`-style parent/child - pre-queue (out-of-order *parented* create) — see §10. - -## 10. Risks & open questions - -- **Cursor arithmetic regression** in `CreateObject.cs` is the highest-risk change: turning - skips into reads must not shift any offset. Mitigation: the field walk already exists and is - test-covered; add per-field value assertions and a "mixed flags reach IconOverlay" test. -- **`AddOrUpdate` merge vs. replace** touches existing `AddOrUpdate` callers (PD seeding, - appraise `UpdateProperties`). Audit every caller; the merge must be a strict superset of - prior behavior for the toolbar path. -- **Event volume:** upserting all objects fires `ObjectAdded` per creature spawn. The toolbar - guid-filter handles it; future panels must filter too (documented in the table's event - XML-doc). -- **`_liveEntityInfoByGuid` retirement timing:** the ingestion wiring and `GameWindow`'s render - handler both subscribe to `EntitySpawned`; ensure the table is populated before any consumer - queries (consumers run on later user interaction, so this is safe, but assert it). -- **Parented item ordering** (a child `CreateObject` arriving before its parent) — retail uses - `null_object_table` pre-queuing. Deferred; PD↔CreateObject ordering is handled, but document - the parent/child gap so D.5.5 picks it up if a panel needs it. -- **Naming churn:** the rename touches `GameEventWiring`, `ToolbarController`, tests, and the - `IconComposer` call site. Mechanical but wide; do it as a focused rename commit so the diff - reads cleanly. - -## 11. Acceptance criteria - -- `dotnet build` + `dotnet test` green (the full suite, including the new conformance tests). -- A `CreateObject` for an item with **no** prior PD stub registers it in the table and the - toolbar renders its icon (the Coldeve repro, exercised by a unit test; visual confirmation - on a live server is the user's gate). -- The toolbar still renders correctly for pre-seeded items (no regression). -- Selection/target still resolves creature name+type after `_liveEntityInfoByGuid` retirement. -- Roadmap D.5 ledger updated (D.5.4 → shipped); divergence register rows added/retired; - memory digest updated if there's a durable lesson. diff --git a/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md b/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md deleted file mode 100644 index 56cb25d6..00000000 --- a/docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md +++ /dev/null @@ -1,367 +0,0 @@ -# D.2b drag-drop spine (Stream B.1) — design - -**Date:** 2026-06-20 -**Phase:** D.2b retail-UI engine → D.5 core panels. Stream **B.1** of the -2026-06-18 handoff (the shared drag-drop infra that BOTH the shortcut-drag stream -(B) and the inventory window (C) sit on). Build it once, well — it is the -critical-path lynchpin. -**Branch:** `claude/hopeful-maxwell-214a12` (== main == `31d7ffd`). -**Spec author:** lead-engineer brainstorm 2026-06-20 (4 design decisions confirmed -by the user). - -**Read alongside:** -- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 / §5.7 - (the pseudocode IS the spec; sub-element + flag anchors). -- `docs/research/2026-06-18-d53-bar-finish-and-inventory-handoff.md` §B.1 - (current-code readiness). -- `claude-memory/project_d2b_retail_ui.md` (the toolkit), `feedback_ui_resolve_zero_magenta` - (the 0-id → magenta footgun; guard on the id, not the GL handle). - ---- - -## 1. Goal & non-goals - -**Goal.** Complete the widget-level drag-drop machine so a player can pick up an -item icon, see a translucent ghost track the cursor, watch hovered slots flip to -**accept (green) / reject (red)**, and drop — at which point the drop is dispatched -to the owning panel's handler with a fully-resolved payload. `UiRoot` already holds -the *device-level* drag state machine (`BeginDrag`/`UpdateDragHover`/`FinishDrag`, -promoted on >3 px move, live-wired to Silk.NET). This stream supplies the five -missing pieces (handoff §B.1): payload injection, the cursor ghost, drop-target -hooks on the cell, the accept/reject overlay, and the panel-handler interface. - -**In scope (the spine — generic, shared):** -1. Payload injection into `UiRoot.BeginDrag` (today passes `payload: null`). -2. A cursor-following drag ghost, painted by `UiRoot`, item-agnostic. -3. Drop-target hooks on `UiItemSlot` (DragEnter/Over/DropReleased → accept/reject - overlay + dispatch). -4. The `IItemListDragHandler` interface + registration on `UiItemList`. -5. A typed `ItemDragPayload` describing what is dragged and where from. -6. Moving the item-cell's use-trigger from MouseDown → Click (drag/click - disambiguation; also more retail-faithful). -7. A **visible toolbar stub handler** so the chain is verifiable this session. - -**Out of scope (explicitly deferred — later streams):** -- The `AddShortcut 0x019C` / `RemoveShortcut 0x019D` wire and the mutable - `ShortcutStore` (Stream **B.2**). -- The inventory window, `UiItemList` N-cell grid mode, window manager - (Streams **C / window-mgr**). -- Stack-split drag (the entry/slider `0x100001A3/A4`), give-to-NPC, drop-to-ground, - wield wire — all per-panel **HandleDropRelease** opcode selection (the deep-dive - §5.7 opcode table; lands with each consuming panel). -- The faithful `m_pDragIcon` (base+overlay-no-underlay) drag composite — MVP reuses - the existing full icon at reduced alpha (AP-47). -- Esc-to-cancel-drag (retail has it; future polish). - ---- - -## 2. Retail grounding (the four confirmed decisions) - -The named decomp (`acclient_2013_pseudo_c.txt`) was greped to anchor each piece; -the spine introduces **no new wire format** (opcodes are B.2/C), so the workflow's -grep→cross-ref→pseudocode step applies to the *event-chain semantics* and -`InqDropIconInfo`, both confirmed below. - -### 2.1 The retail chain (deep-dive §5.1–5.5) -- The cell `UIElement_UIItem` is BOTH drag source and drop target. On - left-press-and-move it walks to its parent `UIElement_ItemList` and calls - `ItemList_BeginDrag` (`ListenToElementMessage` decomp 229344, msg `0x21`). -- Every cell is a drop target *by construction* — `PostInit` sets the - `CatchDroppedItem` attribute `0x36` true (decomp 229744). -- On drag-over the cell forwards to `ItemList_DragOver`; the LIST routes to its - registered `m_dragHandler` (`RegisterItemListDragHandler`, decomp 230461; - confirmed at acclient 0x004a539e + the gmToolbarUI block 0x004bdd89). -- `InqDropIconInfo(dragElement, &objId, &containerId, &flags)` reads the dragged - element's properties (decomp 230533); the **flags** word (confirmed live at - `gmToolbarUI` 0x004bd162 / 0x004bd1af): **`flags & 0xE == 0`** ⇒ fresh-from- - inventory; **`flags & 4`** ⇒ within-list reorder. Accept/reject overlay state ids: - neutral `0x1000003f`, **reject `0x10000040`**, accept `0x10000041` - (`SetDragAcceptState`, confirmed 0x004bd16d). -- The drag ghost `m_dragIcon` (id `0x10000345`) is a root-level translucent copy of - the icon, tracking the cursor (decomp 229738; §5.5). - -### 2.2 How the four picks map (all confirmed with the user) -| # | Decision | Pick | Retail basis | -|---|---|---|---| -| 1 | Payload shape | typed `ItemDragPayload` record, snapshotted at begin | mirrors `InqDropIconInfo`'s {objId, container, flags}; our `flags` derived at drop from SourceKind+target | -| 2 | Ghost render | painted by `UiRoot` via a generic `GetDragGhost()` hook | retail's root-level `m_dragElement` floats above all windows | -| 3 | Drop unit | **cell** hits + shows overlay; **list** owns the handler | retail cell→`ItemList_DragOver`→`m_dragHandler` | -| 4 | PR scope | infra + a **visible toolbar stub** handler | so the ghost/overlay/dispatch are confirmable now; wire is B.2 | - ---- - -## 3. Architecture — components & boundaries - -All code lives in `src/AcDream.App/UI/` (the retail UI tree; NOT the ImGui devtools -path). No new project references; `UiRoot` stays item-agnostic (it learns the -payload/ghost only through two `UiElement` virtuals). - -``` - Silk.NET mouse ─► UiRoot (device drag machine; UNCHANGED chain, + payload pull + ghost) - │ BeginDrag(source): payload = source.GetDragPayload(); cancel if null - │ Draw(): paint source.GetDragGhost() at the cursor (overlay layer) - ▼ - UiItemSlot (the CELL — drag source + drop target + overlay owner) - │ GetDragPayload() → ItemDragPayload | null (null = empty cell, no drag) - │ GetDragGhost() → (tex,w,h) | null - │ OnEvent: Click→use; DragEnter→ask handler→overlay; DropReleased→dispatch - ▼ (walks Parent to) - UiItemList (the GRID — owns the registered handler) - │ DragHandler : IItemListDragHandler - ▼ - IItemListDragHandler (a panel controller implements this) - bool OnDragOver(list, cell, payload) → accept/reject (overlay only) - void HandleDropRelease(list, cell, payload) → the action (wire = per panel) - ▲ - ToolbarController (registers itself; STUB this stream — logs, no wire = TS-33) -``` - -**Boundary rules honored:** Rule 1 (no fat feature body in `GameWindow` — all logic -in the UI classes + the controller); Rule 3 (panels target the toolkit, never a -backend); `UiRoot` depends on nothing item-specific (Rule-2-spirit: the generic -toolkit core doesn't reach up into the item layer — it pulls through virtuals). - ---- - -## 4. New & changed types (precise signatures) - -### 4.1 NEW — `ItemDragPayload` + `ItemDragSource` (`UI/ItemDragPayload.cs`) -```csharp -namespace AcDream.App.UI; - -/// Where a dragged item came from — the retail InqDropIconInfo flag -/// distinction (flags&0xE==0 fresh-from-inventory vs flags&4 within-list reorder) -/// expressed as a typed enum. The drop handler maps SourceKind+target back to the -/// fresh-vs-reorder decision. Decomp: gmToolbarUI 0x004bd162 / 0x004bd1af. -public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground } - -/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move -/// arriving mid-drag can't mutate it under us). Port of retail's m_dragElement + -/// InqDropIconInfo out-params (objId/container/flags, decomp 230533). -/// SourceContainer is NOT stored — the handler resolves the LIVE container from -/// ClientObjectTable.Get(ObjId).ContainerId at drop (single source of truth; same -/// value retail reads off the element, see §7). -public sealed record ItemDragPayload( - uint ObjId, // dragged weenie guid (retail itemID, +0x5FC) - ItemDragSource SourceKind, // what kind of slot it left - int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged) - UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost texture -``` - -### 4.2 CHANGED — `UiElement` (two new virtuals; default null) -```csharp -/// The data this element carries when a drag begins. UiRoot.BeginDrag pulls -/// this; a NULL return CANCELS the drag (retail: ItemList_BeginDrag only arms an -/// occupied cell). Default null = not draggable. -public virtual object? GetDragPayload() => null; - -/// The texture UiRoot paints at the cursor while this element is the drag -/// source: (GL handle, width, height). Null = no ghost. Keeps UiRoot item-agnostic. -public virtual (uint tex, int w, int h)? GetDragGhost() => null; -``` - -### 4.3 CHANGED — `UiRoot` -- `BeginDrag(UiElement source)` (drop the `payload` param): pull - `var payload = source.GetDragPayload();` → if `null`, set `_dragCandidate = false` - and **return without arming** (no `DragSource`, no event). Else set - `DragSource`/`DragPayload`, fire `DragBegin` with the payload. Call site (line 188) - becomes `BeginDrag(Captured);`. -- `Draw(ctx)`: after `DrawOverlays`, still inside the overlay layer, draw the ghost: - ```csharp - if (DragSource?.GetDragGhost() is { tex: var t, w: var gw, h: var gh } && t != 0) - ctx.DrawSprite(t, MouseX - gw/2f, MouseY - gh/2f, gw, gh, 0,0,1,1, - new Vector4(1,1,1, GhostAlpha)); // GhostAlpha = 0.6f - ``` - Root transform is at origin during `Draw`, so cursor coords are absolute. The - ghost is NOT a tree element → it never intercepts hit-tests. - -### 4.4 CHANGED — `UiItemSlot` -- Add `public int SlotIndex { get; set; } = -1;` (the cell's own index within its - panel — 0..17 toolbar, container slot for inventory; distinct from `ShortcutNum`, - the 1–9 LABEL which is -1 on the bottom row). -- Add `public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory;` - (controller overrides; toolbar = `ShortcutBar`). -- Add drag-accept overlay sprites (confirmed ids; configurable; guard `id != 0` - before resolving — the magenta footgun): - ```csharp - public uint DragAcceptSprite { get; set; } = 0x060011F9u; // ItemSlot_DragOver_Accept - public uint DragRejectSprite { get; set; } = 0x060011F8u; // ItemSlot_DragOver_Reject - private enum DragAccept { None, Accept, Reject } - private DragAccept _dragAccept = DragAccept.None; - ``` -- `GetDragPayload()` → `ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null`. -- `GetDragGhost()` → `ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null`. -- **Move use from MouseDown → Click** (the disambiguation fix): `OnEvent` keeps - `MouseDown` → `return true` (still consume the press, but DON'T fire use), and adds - `Click` (0x01) → `Clicked?.Invoke(); return true`. (`UiRoot` already suppresses the - post-drag Click at line 311, so a completed drag won't also use the item; a - press-release-without-move still emits Click → use.) `WireClick` in - `ToolbarController` is unchanged — it still assigns `Clicked`. -- `OnEvent` new cases (acdream's UiEventType is the contract — these are acdream's - DragEnter/DragOver/DropReleased, NOT retail's raw per-cell 0x21/0x3e/0x15 codes; - see §6 note): - ```csharp - case UiEventType.DragBegin: return true; // we're the source; payload already pulled - case UiEventType.DragEnter: // pointer entered me mid-drag - _dragAccept = (FindList() is {} l && l.DragHandler is {} h - && e.Payload is ItemDragPayload p && h.OnDragOver(l, this, p)) - ? DragAccept.Accept : DragAccept.Reject; - return true; - case UiEventType.DragOver: _dragAccept = DragAccept.None; return true; // UiRoot fires on LEAVE - case UiEventType.DropReleased: - _dragAccept = DragAccept.None; - if (e.Data0 == 1 // accepted = dropped on a DIFFERENT element (skips self/empty) - && FindList() is {} dl && dl.DragHandler is {} dh - && e.Payload is ItemDragPayload dp) - dh.HandleDropRelease(dl, this, dp); - return true; - ``` - `FindList()` walks `Parent` until a `UiItemList`. The accept/reject overlay draws - in `OnDraw` on top of the icon+digit, `id != 0` guarded. - -### 4.5 NEW — `IItemListDragHandler` (`UI/IItemListDragHandler.cs`) -```csharp -/// A panel controller implements this and registers itself on each of its -/// UiItemLists. Port of retail's m_dragHandler vtable (RegisterItemListDragHandler, -/// decomp 230461). OnDragOver decides the accept/reject OVERLAY only (advisory); -/// HandleDropRelease is authoritative — it does the action (or no-ops to reject). -public interface IItemListDragHandler -{ - bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); - void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); -} -``` - -### 4.6 CHANGED — `UiItemList` -- Add `public IItemListDragHandler? DragHandler { get; private set; }` + - `public void RegisterDragHandler(IItemListDragHandler h) => DragHandler = h;`. - -### 4.7 CHANGED — `ToolbarController` (the stub handler — TS-33) -- `: IItemListDragHandler`. In the ctor, after caching slots, set each cell's - `SlotIndex = i` and `SourceKind = ItemDragSource.ShortcutBar`, and - `list.RegisterDragHandler(this)`. -- `OnDragOver` → `payload.ObjId != 0` (accept any real item; retail's - `IsShortcutEligible` gate is B.2). -- `HandleDropRelease` → `Console.WriteLine($"[B.2 TODO] drop obj 0x{payload.ObjId:X8} " + - $"from {payload.SourceKind} slot {payload.SourceSlot} → toolbar slot {targetCell.SlotIndex}");` - No store mutation, no wire. (TS-33; replaced wholesale in B.2.) - ---- - -## 5. Data flow — frame by frame - -1. **Press** on an occupied toolbar slot → `UiRoot.OnMouseDown` sets `Captured = cell`, - `_dragCandidate = true` (cell isn't `CapturesPointerDrag`, isn't a window-drag). -2. **Move > 3 px** → `UiRoot.OnMouseMove` calls `BeginDrag(cell)` → pulls - `cell.GetDragPayload()` = `ItemDragPayload{ObjId, ShortcutBar, srcSlot, cell}` - (non-null since occupied) → arms `DragSource`/`DragPayload`, fires `DragBegin`. -3. **Each move while dragging** → `UpdateDragHover(x,y)` hit-tests; on target CHANGE - it fires `DragOver` (LEAVE) on the old cell → its overlay resets to neutral, and - `DragEnter` on the new cell → it asks its list's handler `OnDragOver` → overlay - flips accept/reject. Meanwhile `UiRoot.Draw` paints the ghost at the cursor. -4. **Release** → `UiRoot.OnMouseUp`: `DragSource` set → `FinishDrag(x,y)` delivers - `DropReleased` to the cell under the cursor (`Data0 = 1` if a different element). - The cell → its list's `HandleDropRelease(list, cell, payload)` → (stub) logs. - `DragSource`/`DragPayload` cleared → ghost stops. Capture released. -5. **Empty-slot drag attempt** → step 2 `GetDragPayload()` returns null → - `_dragCandidate=false`, nothing armed; the eventual mouse-up emits Click (no-op, - `Clicked` guards `ItemId != 0`). - ---- - -## 6. Edge cases & notes - -- **Event-id namespace.** acdream's `UiEventType` (DragBegin 0x15, DragEnter 0x21, - DragOver 0x1C, DropReleased 0x3E) is the contract the cell handles — these are - `UiRoot`'s constants and differ from retail's raw per-cell `ListenToElementMessage` - codes (0x21 begin / 0x3e over / 0x15 drop). Do NOT try to match the retail per-cell - codes; the SEMANTICS match, the numbers are `UiRoot`'s. (IA-12 already covers "UI - toolkit mirrors behavior, not byte-layout.") -- **Hover re-eval cadence.** `UiRoot.UpdateDragHover` early-returns when the target - is unchanged, so accept/reject is computed once per cell-enter, not every move. - For item cells the decision is constant per (cell, payload), so this is - behavior-equivalent to retail's per-move `MouseOverTop` — no register row. -- **Self-drop / drop-on-nothing.** `FinishDrag` sets `Data0=0` when the target is the - source or null; the cell gates `HandleDropRelease` on `Data0==1`, so both are clean - no-ops (= "put it back"). -- **Magenta footgun.** `DragAcceptSprite`/`DragRejectSprite` default non-zero; the - `OnDraw` overlay still guards `if (id != 0)` before `SpriteResolve`, never on the - returned GL handle (`feedback_ui_resolve_zero_magenta`). -- **No heavy diagnostics in the render loop.** The stub's `Console.WriteLine` fires - only on a discrete drop event (not per frame), so it can't eat dt-based animations. - ---- - -## 7. Divergence register rows (add in the IMPLEMENTATION commit) - -- **AP-47** (Documented approximation): *Drag ghost reuses the full composited - `m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated - `m_pDragIcon` (base + custom-overlay, NO underlay).* Where: `UiRoot.Draw` + - `UiItemSlot.GetDragGhost`. Why safe: cosmetic only — the dragged item is still - unambiguously identifiable; building the second composite is deferred polish. Risk: - the ghost shows the opaque type-default underlay backing rather than retail's - underlay-less translucent copy. Oracle: `IconData::RenderIcons` 407594-407625 - (m_pDragIcon path); deep-dive §3.2/§5.5. -- **TS-33** (Temporary stopgap): *Toolbar `IItemListDragHandler.HandleDropRelease` - is a logging stub — no `AddShortcut 0x019C`/`RemoveShortcut 0x019D` wire, no - `ShortcutStore` mutation.* Where: `ToolbarController.HandleDropRelease`. Awaiting: - Stream B.2. Risk: a drop onto the bar visibly does nothing but log; reorder/add/ - remove are inert until B.2. Oracle: `gmToolbarUI::HandleDropRelease` - acclient_2013_pseudo_c.txt:197971. - -**Note (not a register row):** the payload omits `SourceContainer`; the handler -resolves the live container via `ClientObjectTable.Get(ObjId).ContainerId` at drop. -Same container id retail reads off the dragged element — single source of truth, no -stale snapshot → no observable behavior deviation. - ---- - -## 8. Testing (conformance) - -App-layer tests in `tests/AcDream.App.Tests/UI/` (no GL — pure logic): -1. `GetDragPayload` returns null for an empty cell, a correct `ItemDragPayload` for a - bound cell (ObjId/SourceKind/SourceSlot/SourceCell). -2. `BeginDrag` does NOT arm (`DragSource == null`) when the source's payload is null; - arms + fires `DragBegin` when non-null. -3. Drop dispatch: a fake `IItemListDragHandler` registered on a `UiItemList` receives - `OnDragOver` on DragEnter and `HandleDropRelease` on DropReleased with the right - `(list, cell, payload)`; NOT called on a `Data0==0` self/empty drop. -4. Accept/reject overlay state flips to Accept when the handler returns true, Reject - when false, None on leave. -5. Use-vs-drag: a Click (no intervening drag) fires `Clicked`; a completed drag does - not (drive `UiRoot` MouseDown→move>3px→MouseUp and assert `Clicked` didn't fire). -6. `FindList()` resolves the parent `UiItemList` through the cell's `Parent` chain. - -Drive these through `UiRoot`'s public `OnMouseDown/OnMouseMove/OnMouseUp` where -possible (integration-level), so the device chain is exercised, not just the cell in -isolation. - ---- - -## 9. Acceptance criteria - -- [ ] `dotnet build` green; `dotnet test` green (existing + the new §8 tests). -- [ ] `UiRoot` has zero compile-time dependency on `UiItemSlot`/`ItemDragPayload` - (item-agnostic; only the two `UiElement` virtuals bridge). -- [ ] AP-47 + TS-33 rows added in the implementation commit; counts bumped. -- [ ] **Visual (user):** with `ACDREAM_RETAIL_UI=1`, in-world, grab a hotbar item - and drag it: a translucent ghost follows the cursor; the hovered slot shows the - accept (green) frame, a slot that rejects shows red; on release over another - slot the log prints `[B.2 TODO] drop obj … → toolbar slot N`; the source item - stays put (no wire yet). A plain click still USES the item; a drag does not. -- [ ] Memory updated if a durable lesson emerged; roadmap/ISSUES note if needed. - ---- - -## 10. Subagent task slices (for the plan) - -1. **Toolkit core** — `ItemDragPayload`/`ItemDragSource`, the two `UiElement` - virtuals, `UiRoot.BeginDrag` payload pull + cancel, `UiRoot.Draw` ghost. -2. **Cell hooks** — `UiItemSlot`: `SlotIndex`/`SourceKind`, `GetDragPayload`/ - `GetDragGhost`, MouseDown→Click move, the DragEnter/Over/DropReleased cases + - accept/reject overlay; `IItemListDragHandler`; `UiItemList.DragHandler`. -3. **Toolbar stub + wiring** — `ToolbarController : IItemListDragHandler`, register + - set SlotIndex/SourceKind, the logging `HandleDropRelease`; register rows. -4. **Tests** — the §8 suite. - -(Slices 1→2 are sequential; 3 depends on 2; 4 can follow each.) diff --git a/docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md b/docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md deleted file mode 100644 index 2aa129cc..00000000 --- a/docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md +++ /dev/null @@ -1,235 +0,0 @@ -# Design — D.2b Sub-phase B-Grid (inventory sub-window mount + UiItemList grid mode) - -**Date:** 2026-06-20 -**Phase:** D.2b retail-UI arc, Sub-phase **B** (inventory window), step **B-Grid** — the first of -four (B-Grid → B-Controller → B-Wire → B-Drag). Decomposition approved 2026-06-20. -**Status:** approved design shape, dat-grounded, pre-implementation. -**Predecessor:** Sub-phase A (window manager) shipped; F12 toggles a hidden placeholder window -registered as `WindowNames.Inventory`. B-Grid makes that window show the real nested frame. - ---- - -## 1. Context & goal - -B-Grid makes `LayoutImporter.Import(0x21000023)` (gmInventoryUI) produce the **full nested -inventory frame** and gives `UiItemList` an **N-cell grid** so item cells tile. After B-Grid, -F12 shows the real bordered inventory frame — outer chrome + a backpack strip + a 3D-items area -+ an (empty) paperdoll panel — replacing the placeholder. Cells are empty until B-Controller -populates them; paperdoll *content* is Sub-phase C. - -**Goal is structural, not interactive:** the frame renders with the correct nested geometry and -a working grid widget. No wire, no controller, no drag (those are B-Wire / B-Controller / B-Drag). - ---- - -## 2. The dat-grounded finding (why this design) - -Dumping `LayoutDesc 0x21000023` (via `AcDream.Cli dump-vitals-layout`) overturned the research -agent's "game-class Type + recursive Import" model. The actual structure: - -- Root `0x100001CC`, Type `0x10000023` (gmInventoryUI), 300×362. Children: - - `0x100001CD` paperdoll panel — **Type 0**, `BaseElement 0x100001D4`, **`BaseLayoutId 0x21000024`**, childless, **no own media**, 224×214 @ (0,23) - - `0x100001CE` backpack panel — **Type 0**, `BaseElement 0x100001C8`, **`BaseLayoutId 0x21000022`**, childless, **no own media**, 61×339 @ (239,23) - - `0x100001CF` 3D-items panel — **Type 0**, `BaseElement 0x100001C4`, **`BaseLayoutId 0x21000021`**, childless, **no own media**, 234×120 @ (0,237) - - `0x100001D2` close button — Type 0, inherits, **has own media** (Normal/Normal_pressed) - - `0x100001D3` title — Type 0, inherits, **has own media** (DirectState) - - `0x100001D0` backdrop, `0x100001D1` bottom rule — Type 3, `BaseElement 0`, own media - -So the three panels are **Type-0 pure-container leaves that nest via the existing -`BaseElement`+`BaseLayoutId` inheritance path** — *not* game-class Types. Their content lives in -the separate layouts `0x21000024/22/21`. - -`LayoutImporter.Resolve` already loads `BaseLayoutId`, finds `BaseElement`, recurses it, and -`Merge`s — **but** `ElementReader.Merge` (`ElementReader.cs:162`) sets -`Children = new List(derived.Children)`, dropping the base's children. So today the panels import -as **empty containers**. The fix is a surgical attach of the base's resolved children to the -panel — not a new Type-dispatch mechanism. - ---- - -## 3. Scope - -**In scope (B-Grid):** -- **Sub-window mount** — `LayoutImporter.Resolve` attaches a base element's resolved children to - a childless, media-less inheriting element (the panels), so `Import(0x21000023)` yields the - full nested tree. -- **`UiItemList` grid mode** — column count + cell pitch so `AddItem`/`OnDraw` tile cells in a - grid; single-cell (toolbar) behavior preserved. -- Unit tests; a regression guard that vitals/chat/toolbar import unchanged. - -**Out of scope (deferred):** -- Populating cells from `ClientObjectTable`, the burden meter, find-by-id binding → **B-Controller**. -- Inventory wire gaps (`DropItem`/`ViewContents`/…) → **B-Wire**. -- Inventory cell as a drag source → **B-Drag**. -- Paperdoll *content* rendering (the `UiViewport` doll) → **Sub-phase C**. (B-Grid's mount will - pull in the paperdoll panel's equip-slot frames as ordinary elements, but the 3D doll viewport - is C.) -- `0x10000032` (UiItemSlot) factory registration — **not needed**: `UiItemList.ConsumesDatChildren` - is true, so the importer skips cell templates; cells are built procedurally. -- Cell pitch *values* + exact column counts per panel — read by **B-Controller** from the nested - layouts at bind time. B-Grid supplies the mechanism, not the numbers. - ---- - -## 4. Component 1 — sub-window mount (`LayoutImporter.Resolve`) - -Restructure `Resolve` to capture the base's children and attach them when the derived element is a -pure container that inherits from a base with content: - -```csharp -private static ElementInfo Resolve( - DatCollection dats, ElementDesc d, HashSet<(uint, uint)> baseChain) -{ - var self = ToInfo(d); - var result = self; - List? baseChildren = null; - - if (d.BaseElement != 0 && d.BaseLayoutId != 0 - && baseChain.Add((d.BaseLayoutId, d.BaseElement))) - { - var baseLd = dats.Get(d.BaseLayoutId); - var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement); - if (baseDesc is not null) - { - var baseInfo = Resolve(dats, baseDesc, baseChain); - result = ElementReader.Merge(baseInfo, self); - baseChildren = baseInfo.Children; // capture the base's resolved subtree - } - } - - // Derived's own children (authoritative when present). - foreach (var kv in d.Children) - result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>())); - - // Sub-window mount: a PURE-CONTAINER leaf (no own children AND no own media) that inherits - // from a base WITH content attaches the base's subtree. This targets the gmInventoryUI - // panels (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window) - // and is inert for: media-bearing inheritors (close button/title keep their own media, - // so self.StateMedia is non-empty → skipped), normal elements with their own children - // (result.Children already populated → skipped), and childless style-prototype inheritors - // (vitals/chat/toolbar text — the base prototype has no children → baseChildren empty). - if (LayoutImporter.ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren?.Count ?? 0)) - result.Children.AddRange(baseChildren!); - - return result; -} - -/// True when a pure-container leaf should inherit its base's subtree (sub-window mount): -/// the derived element has no own children, no own state media, and the base resolved to ≥1 child. -internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount) - => derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0; -``` - -The predicate is extracted as `ShouldMountBaseChildren` so it is **unit-testable without dats**. - -**Coordinate correctness:** the panel inherits the base window-root's fields via `Merge` (so it -gets that window's size) and the base's children via the mount. The children's X/Y are relative to -the panel; `ScreenPosition` composes parent offsets, so a child at panel-local (x,y) with the panel -at (0,23) lands at the right screen pixel. - -**Cycle safety:** `baseChain` already guards the inheritance recursion; the nested layouts are -distinct ids, so no cycle. - ---- - -## 5. Component 2 — `UiItemList` grid mode - -Add a column count + cell pitch; keep single-cell as the default. - -```csharp -public int Columns { get; set; } = 1; // grid columns; 1 = single column -public float CellWidth { get; set; } // 0 = "fill the list" (single-cell legacy) -public float CellHeight { get; set; } -``` - -Layout rule (applied in `AddItem` and re-applied in `OnDraw` so a list resize reflows): - -- **Fill mode** (`CellWidth <= 0`): the single cell fills the list — `cell[0]` at - `(0, 0, Width, Height)`. Unchanged toolbar behavior. -- **Grid mode** (`CellWidth > 0`): `cell[i]` at `(col·CellWidth, row·CellHeight, CellWidth, - CellHeight)` where `col = i % Columns`, `row = i / Columns`. - -The toolbar constructor keeps adding its one cell with `CellWidth = 0` (defaults), so it stays in -fill mode. B-Controller sets `Columns` + `CellWidth/CellHeight` (read from the nested layout's -ItemList element + cell template) and `Flush()` + `AddItem()`s the grid cells. - -The grid math is a pure helper for testing: - -```csharp -internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH) -{ - int col = index % columns, row = index / columns; - return (col * cellW, row * cellH); -} -``` - ---- - -## 6. Testing - -**Grid mode (pure, `tests/AcDream.App.Tests/UI/`):** -- `CellOffset(4, 3, 36, 36)` ⇒ `(36, 36)` (index 4 → col 1, row 1). -- Build a `UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 }`, `AddItem` 7 cells, assert - `GetItem(4)` is at `Left == 36 && Top == 36 && Width == 36`. -- Single-cell legacy: a default `UiItemList` (CellWidth 0) keeps its cell at the list size after - `OnDraw` — assert the existing toolbar behavior is unchanged. - -**Mount predicate (pure):** -- `ShouldMountBaseChildren(0, 0, 5)` ⇒ true (panel: childless, media-less, base has 5 kids). -- `ShouldMountBaseChildren(0, 1, 5)` ⇒ false (close button: has own media). -- `ShouldMountBaseChildren(2, 0, 5)` ⇒ false (has own children). -- `ShouldMountBaseChildren(0, 0, 0)` ⇒ false (style prototype: base childless — vitals text). - -**Regression guard (hard acceptance):** vitals (`0x2100006C`), chat (`0x21000006`), and toolbar -(`0x21000016`) must import + render unchanged. Their inherited bases are childless prototypes, so -the mount is inert for them — but this is verified, not assumed (the existing importer tests + -the visual check). - ---- - -## 7. Divergence register - -**No new row.** The mount makes `BaseElement`/`BaseLayoutId` inheritance carry the base's content -subtree, which is what retail must do for the gmInventoryUI panels to render at all (they are -childless in the dat — the content is only reachable through the base reference). This is -*completing* faithful inheritance, not diverging from it. IA-12 (toolkit-defined UI) already -covers the importer's reconstruction of keystone semantics. If a future retail-decomp pass shows -retail's child-inheritance differs in the children-present merge case (which no inventory element -exercises), that is a separate, later concern. - ---- - -## 8. Acceptance criteria - -- `dotnet build` green; `dotnet test` green (grid + mount-predicate tests added). -- `Import(0x21000023)` yields a tree where the three panels (`0x100001CD/CE/CF`) each have - children (the nested layout content), confirmed by a dat-backed assertion or the dump. -- Vitals/chat/toolbar import + render unchanged (regression guard). -- With `ACDREAM_RETAIL_UI=1`, F12 shows the real nested inventory frame (chrome + backpack strip + - 3D-items area + empty paperdoll panel) instead of the blank placeholder. -- Visual verification by the user. - ---- - -## 9. Open / confirmed-at-build items - -- **Cell pitch + column counts** — B-Controller reads them from the nested layouts - (`0x21000022` backpack, `0x21000021` 3D-items) at bind time. Geometry from the outer dump - suggests the backpack strip is ~1 column and the 3D-items panel ~6 columns of 36×36, but the - exact values are B-Controller's to read, not B-Grid's to hardcode. -- The placeholder mount in `GameWindow` (Sub-phase A) is replaced here: instead of a bare - `UiNineSlicePanel`, the inventory window becomes `LayoutImporter.Import(0x21000023)`'s root, - registered under the same `WindowNames.Inventory`. (Mechanically a few lines in the RetailUi - block; the F12 wiring is untouched.) - ---- - -## 10. References - -- Dat dump: `AcDream.Cli dump-vitals-layout "" 0x21000023` (the structure in §2). -- Format: `docs/research/2026-06-15-layoutdesc-format.md` (§8 Type table, §10/§12 inheritance). -- Code: `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`Resolve`/`Merge` path), - `src/AcDream.App/UI/Layout/ElementReader.cs:162` (the children-drop), `UiItemList.cs`, - `UiItemSlot.cs` (the shipped drag spine — reused in B-Drag, untouched here). -- Handoff/decomposition: `docs/research/2026-06-20-window-manager-inventory-handoff.md` §4; - the Sub-phase B decomposition (B-Grid → B-Controller → B-Wire → B-Drag). diff --git a/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md b/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md deleted file mode 100644 index 92cd7f25..00000000 --- a/docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md +++ /dev/null @@ -1,181 +0,0 @@ -# D.2b toolbar collapse-to-one-row — design - -**Date:** 2026-06-20 -**Phase:** D.2b retail-UI, toolbar polish (follows D.5.3 B.1/B.2, both visually confirmed this session). -**Branch:** `claude/hopeful-maxwell-214a12`. -**Driver:** user request — the toolbar frame should resize vertically between **one row** (row 2 hidden, -the minimum) and **two rows** (row 2 shown), **snapping** between the two stops. - ---- - -## 1. Goal & non-goals - -**Goal.** The toolbar window can be collapsed to show only the top quickslot row (slots 1–9) or -expanded to show both rows (slots 1–18), by dragging its **bottom edge**. The drag **snaps** to the -nearer of two height stops — collapsed (row 2 hidden) or expanded (row 2 shown). Default = expanded -(today's look). Horizontal size stays fixed; the window still moves by grabbing empty cells / chrome -(IA-12). This is a toolkit UX defined from the user's retail observation — the real mechanism lives in -`keystone.dll` (no decomp); our research notes the dat just stacks two always-present rows, so the dat -encodes no collapse. Recorded as an amendment to **IA-17** (toolbar frame is toolkit-supplied). - -**Non-goals:** a collapse/expand BUTTON (it's a bottom-edge resize); horizontal resize; persisting the -collapsed state across sessions (it resets to expanded each launch — persistence is the deferred -window-manager Plan-2); animating the snap. - ---- - -## 2. Geometry (from the layout, not hardcoded) - -The toolbar `LayoutDesc 0x21000016` root is **300×122**; the two rows are top `0x100001A7..AF` and -bottom `0x100006B7..BF`, with the bottom row's slots at content-y ≈ 90 (deep-dive §2a table, slot 9 at -`6,90`). Heights are computed at mount time from the actual layout, so there is no magic constant: - -- `border` = `RetailChromeSprites.Border` (5 px). -- `ExpandedHeight` = `contentHeight + 2·border` (today's frame height; `contentHeight` = the imported - root's `Height`, 122). -- `CollapsedHeight` = `minRow2Top + 2·border`, where `minRow2Top` = the smallest `Top` among the nine - resolved row-2 slot elements (`0x100006B7..BF`). That cuts the frame just above row 2. -- `snapMidpoint` = `(CollapsedHeight + ExpandedHeight) / 2`. - ---- - -## 3. Components - -### 3.1 `UiElement` — `MaxHeight` + a `ResizableEdges` mask — `src/AcDream.App/UI/UiElement.cs` -Today resize clamps to a minimum only and (with `ResizeY`) treats BOTH vertical edges as grips. Add two -small generic members: -```csharp -/// Maximum height enforced while resizing (default unbounded). Pairs with MinHeight. -public float MaxHeight { get; set; } = float.MaxValue; - -/// Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all. -/// Set to e.g. ResizeEdges.Bottom to allow only a bottom-edge drag (the collapse toolbar). -public ResizeEdges ResizableEdges { get; set; } = - ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom; -``` -(`MaxWidth` is YAGNI — only height needs it here.) - -- **`UiRoot.HitEdges`** applies the mask at the end: `e &= w.ResizableEdges;` (after the existing - `ResizeX`/`ResizeY` masking). So a toolbar with `ResizableEdges = Bottom` only grips its bottom edge; - a press near the top edge falls through to window-move, not resize. -- **`UiRoot.ResizeRect`** gains a `maxH` parameter and clamps the Bottom/Top height branches: - `h = Math.Clamp(startH + dy, minH, maxH)` (Bottom) and the Top branch likewise. `OnMouseMove`'s resize - call passes `_resizeTarget.MaxHeight` (and `float.MaxValue` for the width's maxW). **This changes - `ResizeRect`'s signature — update its existing callers + the `UiRootInputTests.ResizeRect_*` tests to - pass the new `maxW`/`maxH` args** (`float.MaxValue` where unbounded, preserving their current - assertions). - -### 3.2 `UiCollapsibleFrame : UiNineSlicePanel` (new) — `src/AcDream.App/UI/UiCollapsibleFrame.cs` -A toolbar-frame variant that snaps between two heights and toggles a set of "second-row" elements. -One clear responsibility: reconcile its height to a stop and the rows to that stop, every tick. -```csharp -public sealed class UiCollapsibleFrame : UiNineSlicePanel -{ - public UiCollapsibleFrame(Func resolveChrome) : base(resolveChrome) { } - - public float CollapsedHeight { get; set; } - public float ExpandedHeight { get; set; } - /// Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed. - public IReadOnlyList SecondRow { get; set; } = System.Array.Empty(); - - /// True when the frame is currently at (or nearer) the expanded stop. - public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f; - - protected override void OnTick(double dt) - { - base.OnTick(dt); - if (ExpandedHeight <= CollapsedHeight) return; // not configured yet - // Snap to the nearer stop (the resize drag sets Height live; we resolve it to a stop so the - // frame always rests collapsed or expanded — never a half-row). - bool expanded = IsExpanded; - Height = expanded ? ExpandedHeight : CollapsedHeight; - // Row 2 is shown only when expanded. (No clipping needed — the dat content is top-anchored, - // so row-2 slots simply stop drawing when hidden; row 1 never moves.) - for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded; - } -} -``` -Notes: -- The snap runs in `OnTick` (after the frame's `MinHeight`/`MaxHeight`-clamped resize drag set `Height` - that frame), so the rendered height is always a stop. With only two stops one row apart, this reads - as: drag the bottom edge past the midpoint → it jumps to the other stop + row 2 appears/hides. -- `IsExpanded`/the snap use the midpoint; `MinHeight`/`MaxHeight` (set by the mount) keep the drag - within `[Collapsed, Expanded]` so the midpoint test is well-defined. - -### 3.3 GameWindow toolbar mount — `src/AcDream.App/Rendering/GameWindow.cs` (~line 2045) -- Build the frame as `UiCollapsibleFrame` instead of `UiNineSlicePanel` (same `ResolveChrome` ctor arg). -- After the content (`toolbarRoot`) is sized: compute `expandedH = toolbarContentH + 2·border`, - `collapsedH = minRow2Top + 2·border` where `minRow2Top` = `min` of the nine row-2 lists' `Top` - (resolve each via `toolbarLayout.FindElement(0x100006B7..BF)`; reuse `ToolbarController`'s row-2 id - list or inline the nine ids). -- Set on the frame: `Resizable = true; ResizableEdges = ResizeEdges.Bottom` (bottom-edge only — top - edge stays a move grip); `MinHeight = collapsedH; MaxHeight = expandedH; Height = expandedH` (default - expanded); `CollapsedHeight = collapsedH; ExpandedHeight = expandedH; SecondRow = `. (`ResizeX`/`ResizeY` keep defaults; the `ResizableEdges = Bottom` mask is the operative - restriction.) -- Change `toolbarRoot.Anchors` from all-four-edges to **`Left | Top | Right`** (drop `Bottom`) so the - dat content keeps its full height and row 1 never reflows when the frame collapses; row 2 hides via - `Visible`. (Width is fixed — `ResizeX=false` — so the horizontal anchors are inert but harmless.) - ---- - -## 4. Behavior walk-through - -- **Launch:** frame at `ExpandedHeight`, both rows visible (unchanged from today). -- **Collapse:** grab the bottom edge, drag up past the midpoint → `OnTick` snaps `Height` to - `CollapsedHeight` and hides the nine row-2 slots. The frame is now a single-row bar; row 1 unchanged. -- **Expand:** drag the bottom edge down past the midpoint → snaps to `ExpandedHeight`, row 2 reappears. -- **Move:** unchanged — drag an empty cell / chrome to reposition (IA-12); occupied cells drag items - (B.1/B.2). -- **Edge cases:** `MinHeight`/`MaxHeight` clamp the drag to `[Collapsed, Expanded]`; the snap is - idempotent when not dragging (Height already at a stop). The collapsed state is per-session (resets - to expanded on relaunch). - ---- - -## 5. Divergence register - -**Amend IA-17** (toolbar window FRAME is toolkit-supplied): add that the frame also supports a -toolkit-defined **collapse-to-one-row** (bottom-edge resize snapping between a row-1-only and a -two-row height, row-2 visibility tied to the stop). Retail's real collapse mechanism is keystone.dll -(no decomp) and the dat encodes no collapse (both rows always present) — so this is our toolkit UX from -the user's retail observation, same justification class as the rest of IA-17. No new row; extend IA-17's -text + cite this spec. - ---- - -## 6. Testing - -`tests/AcDream.App.Tests/UI/`: -1. `UiCollapsibleFrame.OnTick` snap: set `CollapsedHeight=96`, `ExpandedHeight=128`; set `Height` just - below the midpoint (e.g. 100) + tick → `Height == 96` and every `SecondRow` element `Visible==false`; - set `Height` just above (e.g. 120) + tick → `Height == 128` and `SecondRow` `Visible==true`. -2. `UiCollapsibleFrame` not-configured guard: `ExpandedHeight==CollapsedHeight==0` → `OnTick` is a - no-op (no divide/no forced height). -3. `UiRoot.ResizeRect` MaxHeight clamp: a Bottom-edge resize with `dy` huge clamps `h` to `maxH`; - a Top-edge resize likewise; min still honored. (Drive via the existing `ResizeRect` static test - pattern in `UiRootInputTests`; also update the two pre-existing `ResizeRect_*` tests to the new - `maxW`/`maxH` signature — pass `float.MaxValue`, assertions unchanged.) -4. `UiRoot.HitEdges` honors `ResizableEdges`: a panel with `ResizableEdges = ResizeEdges.Bottom` returns - only `Bottom` when pressed near its bottom edge, and `None` near its top edge (which would otherwise - be a grip with `ResizeY` true). - -A `UiCollapsibleFrame` needs a chrome resolver in tests — pass `_ => (1u,1,1)` (the existing -`UiNineSlicePanel` test pattern); `OnTick` doesn't draw, so no GL. - ---- - -## 7. Acceptance - -- [ ] `dotnet build` + `dotnet test` green. -- [ ] IA-17 amended. -- [ ] **Visual (user):** default shows both rows; dragging the toolbar's **bottom edge up** snaps it to - a single row (row 2 gone); dragging **down** snaps back to two rows; row 1 never moves/squishes; - the window still moves by dragging empty cells/chrome; item drag (B.1/B.2) still works. - ---- - -## 8. Plan size - -One small task (TDD): `UiElement.MaxHeight` + `ResizeRect` clamp → `UiCollapsibleFrame` + its tests → -the GameWindow mount swap + IA-17 amend. ~3 files + 1 test file. Suitable for a single implementer pass. diff --git a/docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md b/docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md deleted file mode 100644 index 994f05c7..00000000 --- a/docs/superpowers/specs/2026-06-20-d2b-toolbar-shortcut-drag-design.md +++ /dev/null @@ -1,386 +0,0 @@ -# D.2b toolbar shortcut drag interactivity (Stream B.2) — design - -**Date:** 2026-06-20 -**Phase:** D.2b retail-UI → D.5 core panels. Stream **B.2** of the 2026-06-18 handoff — make the -toolbar shortcut drag *functional* (reorder / remove) and *retail-faithful*, on top of the B.1 -spine (`9d48346..acdefc2`, shipped + visually confirmed this session). -**Branch:** `claude/hopeful-maxwell-214a12`. -**Driver:** user visual-gate feedback 2026-06-20 (6 points) + the retail research below. - -**Read alongside:** -- `docs/research/2026-06-16-action-bar-toolbar-deep-dive.md` §5 (HandleDropRelease/RemoveShortcut/wire). -- `docs/research/2026-06-16-ui-item-slot-icon-dragdrop-spine-deep-dive.md` §5 (the cell drag chain). -- `docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md` (B.1 — what this extends). -- `claude-memory/project_d2b_retail_ui.md`. - ---- - -## 1. Goal & non-goals - -**Goal.** A player drags a hotbar shortcut, sees the item icon lift off the slot (slot empties) and -follow the cursor at full opacity with a green-cross drop indicator; dropping on another slot moves -it there (reordering, bumping any occupant back to the source slot); dropping anywhere else removes -it from the bar. All changes are sent to the server (`AddShortcut 0x019C` / `RemoveShortcut 0x019D`) -so they persist. This realizes the user's 6 visual-gate points as retail's single **remove-on-lift / -place-on-drop / no-restore** mechanism. - -**In scope:** -1. **Spine extensions** (small, to the B.1 toolkit): a drag-LIFT hook to the source's handler; - full-opacity ghost snapshotted at drag-begin (survives the source emptying); `FinishDrag` - delivers a drop only when released on a real element (off-bar = no drop fires). -2. **`ShortcutStore`** — a mutable 18-slot model (port of retail `ShortCutManager::shortCuts_[18]`). -3. **`ToolbarController` as the live drag handler** — `OnDragLift` removes; `HandleDropRelease` - places + bumps displaced → source; both drive the store, the wire, and a re-`Populate()`. -4. **The wire** — fix `BuildAddShortcut` param names; add `WorldSession.SendAddShortcut` / - `SendRemoveShortcut`. -5. **Green-cross accept overlay** (`0x060011FA`) on toolbar cells (verified against the dat). - -**Out of scope (later streams):** -- **Drag FROM inventory onto the bar** (the `flags & 0xE == 0` "fresh-from-inventory" branch) — needs - the inventory window as a drag source (Stream C). This spec is reorder-within-bar + remove only - (the `flags & 4` branch). -- Spell shortcuts (the `SpellId|Layer` payload) — items only (`spellId=layer=0`); the store carries a - spell field for forward-compat but the toolbar binds item guids. -- Server-pushed shortcut mutations after login (the store is client-authoritative post-`Load`). -- The selected-object mana meter / stack-split UI (issue #141 deferred bits). - ---- - -## 2. Retail grounding (the model — CONFIRMED by research 2026-06-20) - -Retail's toolbar drag is **remove-on-lift, place-on-drop, no-restore** (decomp trace, this session): - -- **On drag-begin** (`UIElement_ItemList::ItemList_BeginDrag` 0x004e32d0 → broadcasts msg `0x21` → - `gmToolbarUI::RecvNotice_ItemListBeginDrag` 0x004bd930): `PrepareDragIcon` makes the **full icon** - the cursor ghost (m_dragIcon), then `gmToolbarUI::RemoveShortcut` (0x004bd450) **removes the - shortcut**: `ItemList_Flush` → cell → empty state `0x1000001c`, `Event_RemoveShortCut` sends - `0x019D`, and `m_lastShortcutNumDragged` = the source slot. The slot is now empty; the item is "in - hand." -- **On mouse-up over a slot** (`UIElementManager::StopDragandDrop` 0x00459810 → success → - `gmToolbarUI::HandleDropRelease` 0x004be7c0): within-bar reorder branch (`flags & 4`): - `RemoveShortcutInSlotNum(target)` (evict the occupant, get its objId) → `AddShortcut(draggedObjId, - target)` (`0x019C`); if an item was displaced and the source slot is free, `AddShortcut(displaced, - m_lastShortcutNumDragged)` — the bumped item lands in the vacated source slot. (deep-dive §5.) -- **On mouse-up over nothing** (`m_pElementLastDragCursorOver == null` → success=0): no - `HandleDropRelease`, no re-add — the shortcut stays gone (already removed + `0x019D` sent at lift). -- **No cancel/restore path.** `gmToolbarUI` has no end-drag/restore vtable entry. Lifted-then-not- - landed = permanently removed (server already told). -- **Drop indicator** is a per-slot OVERLAY sprite on `m_elem_Icon_DragAccept` (`0x1000045A`), NOT a - cursor change (`UpdateCursorState` is combat/target-only). The toolbar accept sprite is the **green - cross `0x060011FA`** (verified by exporting the `0x060011F7..FA` family from `client_portal.dat`: - F7=green move-arrow, F8=red ∅ reject, F9=green **ring** [inventory], FA=green **cross** [toolbar]); - reject = `0x060011F8`. - -**Wire (`ShortCutData`, CONFIRMED 3 refs — deep-dive §131-145):** -- `AddShortCut 0x019C` body after the 12-byte GameAction envelope: `Index(u32), ObjectId(u32), - SpellId(u16), Layer(u16)` (item → `ObjectId=guid, SpellId=Layer=0`). -- `RemoveShortCut 0x019D` body: `Index(u32)`. -- ACE handles the remove-then-add reorder pattern (`Player_Character.cs:254`). - ---- - -## 3. Architecture — components & boundaries - -``` - press+move on OCCUPIED slot ─► UiRoot.BeginDrag - │ snapshot ghost (full icon) BEFORE the lift clears the source cell - │ fire DragBegin on the source cell - ▼ - UiItemSlot.OnEvent(DragBegin) ─► FindList().DragHandler.OnDragLift(list, cell, payload) - ▼ - ToolbarController.OnDragLift ─► ShortcutStore.Remove(srcSlot); SendRemoveShortcut(srcSlot); Populate() - (source slot now empty; ghost still shows the snapshot) - drag-over target slot ─► UiItemSlot DragEnter ─► handler.OnDragOver → green-cross accept overlay (FA) - release ─► UiRoot.FinishDrag: - ├─ over a slot ─► that cell.OnEvent(DropReleased) ─► handler.HandleDropRelease(target, payload) - │ ShortcutStore place + bump displaced→srcSlot; SendAddShortcut×N; Populate() - └─ over nothing ─► no DropReleased delivered → lift's removal stands (item gone) -``` - -**Boundaries:** `UiRoot`/`UiElement`/`UiItemSlot`/`UiItemList` stay item-agnostic — they gain a lift -*hook* and a ghost snapshot, nothing item-specific. `ShortcutStore` is pure logic in `AcDream.Core` -(testable, no GL). The wire lives in `AcDream.Core.Net`. `ToolbarController` (App) orchestrates. -Honors structure Rules 1/2/3/6. - ---- - -## 4. New & changed types (precise) - -### 4.1 Spine — `IItemListDragHandler` gains the lift hook (`UI/IItemListDragHandler.cs`) -```csharp -public interface IItemListDragHandler -{ - /// The drag STARTED from a cell in this list — retail's - /// RecvNotice_ItemListBeginDrag → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler - /// removes the lifted item from its model + wire, so the source slot empties immediately. The - /// item is "in hand" until HandleDropRelease (place) or the drag ends off-target (stays removed). - void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload); - - bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); - void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); -} -``` - -### 4.2 Spine — `UiItemSlot` (`UI/UiItemSlot.cs`) -- `OnEvent` `DragBegin` case changes from `return true` (no-op) to: notify the list's handler so it - can lift: - ```csharp - case UiEventType.DragBegin: - if (FindList() is { DragHandler: { } h } list && e.Payload is ItemDragPayload p) - h.OnDragLift(list, this, p); - return true; - ``` -- `DropReleased` case: drop the `e.Data0 == 1` gate — reaching the cell means a real slot was hit - (`FinishDrag` only delivers on a hit). The handler is authoritative (place; drop-on-self re-adds): - ```csharp - case UiEventType.DropReleased: - _dragAccept = DragAcceptState.None; - if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp) - dh.HandleDropRelease(dl, this, dp); - return true; - ``` - (`DragEnter`/`DragOver` overlay handling unchanged from B.1.) - -### 4.3 Spine — `UiRoot` (`UI/UiRoot.cs`) -- Add `private (uint tex, int w, int h)? _dragGhost;`. In `BeginDrag`, **snapshot the ghost before - firing `DragBegin`** (the lift will empty the source cell, so a live re-read would vanish): - ```csharp - private void BeginDrag(UiElement source) - { - var payload = source.GetDragPayload(); - if (payload is null) { _dragCandidate = false; return; } - DragSource = source; - DragPayload = payload; - _dragGhost = source.GetDragGhost(); // snapshot NOW — survives the source emptying - var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload); - source.OnEvent(in e); // → cell → handler.OnDragLift (clears the source slot) - } - ``` -- `DrawDragGhost` uses the snapshot, not a live read; `GhostAlpha` → **1.0** (full opacity, retail - m_dragIcon): - ```csharp - private const float GhostAlpha = 1.0f; - private void DrawDragGhost(UiRenderContext ctx) - { - if (_dragGhost is not { } g || g.tex == 0) return; - ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h, - 0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha)); - } - ``` -- `FinishDrag` delivers a drop ONLY to a real hit element; off-bar (null hit) fires nothing; clear - the ghost: - ```csharp - private void FinishDrag(int x, int y) - { - var (t, lx, ly) = HitTestTopDown(x, y); - if (t is not null) - { - var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased, - Data1: (int)lx, Data2: (int)ly, Payload: DragPayload); - t.OnEvent(in e); // the hit cell's handler places; a non-item target ignores it → item stays removed - } - // else: dropped off any element — no drop fires; the lift's removal stands (retail). - DragSource = null; DragPayload = null; _dragGhost = null; _lastDragHoverTarget = null; - } - ``` - (`GetDragGhost()` on `UiElement`/`UiItemSlot` stays as-is — `BeginDrag` calls it once.) - -### 4.4 NEW — `ShortcutStore` (`src/AcDream.Core/Items/ShortcutStore.cs`) -Port of retail `ShortCutManager::shortCuts_[18]` (`acclient.h:36492`). Pure logic. -```csharp -public sealed class ShortcutStore -{ - public const int SlotCount = 18; - private readonly uint[] _objIds = new uint[SlotCount]; // 0 = empty - - /// Replace all slots from the login PlayerDescription shortcut list. - public void Load(IReadOnlyList entries) - { - System.Array.Clear(_objIds); - foreach (var e in entries) - if (e.Index < SlotCount && e.ObjectGuid != 0) _objIds[e.Index] = e.ObjectGuid; - } - - public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u; - public bool IsEmpty(int slot) => Get(slot) == 0u; - public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; } - public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; } -} -``` - -### 4.5 Wire — `InventoryActions.BuildAddShortcut` rename (`Core.Net/Messages/InventoryActions.cs`) -Byte layout is already correct; FIX the misleading param names (handoff §B.2; deep-dive §131-145): -```csharp -/// Pin an item/spell to a quickbar slot. ShortCutData = Index(u32),ObjectId(u32), -/// SpellId(u16),Layer(u16). For an item: objectGuid + spellId=layer=0. -public static byte[] BuildAddShortcut(uint seq, uint index, uint objectGuid, ushort spellId, ushort layer) -{ - byte[] body = new byte[24]; - BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); // 0x019C - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid); - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId); - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer); - return body; -} -``` -(`BuildRemoveShortcut(seq, slotIndex)` is correct — unchanged.) Any existing caller of the old -4-arg `BuildAddShortcut` must be updated; grep first (likely none wired today). - -### 4.6 Wire — `WorldSession` sends (`Core.Net/WorldSession.cs`, mirror `SendChangeCombatMode` :1134) -```csharp -public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0) -{ - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer)); -} -public void SendRemoveShortcut(uint index) -{ - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index)); -} -``` - -### 4.7 `ToolbarController` — the live handler (`UI/Layout/ToolbarController.cs`) -- Holds a `ShortcutStore _store`. `Populate()` reads `_store` (18 slots → `repo.Get(objId)` → icon) - INSTEAD of the read-only `_shortcuts()` provider. **Lazy-load-once:** the PlayerDescription shortcut - list arrives AFTER Bind (at login), so load the store the first time `_shortcuts()` is non-empty, - then treat it as authoritative: - ```csharp - // top of Populate(), before rendering: - if (!_storeLoaded && _shortcuts().Count > 0) { _store.Load(_shortcuts()); _storeLoaded = true; } - ``` - Bind's initial `Populate` (pre-login) sees an empty list and skips the load; the existing - `repo.ObjectAdded → Populate` path (shortcut items arriving via `CreateObject`, by which time the PD - shortcut list is set) triggers the one-time load. After load, drag ops mutate `_store` directly and - it is never reloaded within the session (a relog = a fresh process → `_storeLoaded` resets). No - GameWindow change is needed for loading. -- `IsShortcutGuid(guid)` (the repo-event gate) checks `_store` (any slot holds `guid`) rather than - `_shortcuts()`, so an item dragged ONTO the bar (a new guid) still gets its later updates rendered. -- Each toolbar cell: `DragAcceptSprite = 0x060011FAu` (green cross). (`DragRejectSprite` stays - `0x060011F8`.) -- Inject two `Action`s for the wire (so the controller stays testable without a live session): - `Action sendAdd` = `(index, objId) => session.SendAddShortcut(index, objId)`, - `Action sendRemove` = `index => session.SendRemoveShortcut(index)`. Null in tests. -- `OnDragLift(sourceList, sourceCell, payload)` — retail `RemoveShortcut`: - ```csharp - _store.Remove(payload.SourceSlot); - _sendRemove?.Invoke((uint)payload.SourceSlot); - Populate(); - ``` -- `HandleDropRelease(targetList, targetCell, payload)` — retail reorder branch (deep-dive §5): - ```csharp - int target = targetCell.SlotIndex; - uint evicted = _store.Get(target); // RemoveShortcutInSlotNum - if (evicted != 0) { _store.Remove(target); _sendRemove?.Invoke((uint)target); } - _store.Set(target, payload.ObjId); _sendAdd?.Invoke((uint)target, payload.ObjId); // AddShortcut - if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot)) - { // bump displaced → vacated source slot - _store.Set(payload.SourceSlot, evicted); - _sendAdd?.Invoke((uint)payload.SourceSlot, evicted); - } - Populate(); - ``` -- `OnDragOver` unchanged from B.1 (accept any real item → green-cross overlay). - ---- - -## 5. Data flow — frame by frame (reorder slot 3 → occupied slot 5) - -1. Press+move on slot 3 (objId A). `BeginDrag`: snapshot ghost = A's icon; fire `DragBegin`. -2. Cell 3 → `OnDragLift`: `_store.Remove(3)` + `SendRemoveShortcut(3)` (0x019D) + `Populate()` → - slot 3 shows empty. Ghost (A's full icon) follows the cursor. -3. Drag over slot 5 (objId B) → green-cross accept overlay on slot 5. -4. Release on slot 5 → `FinishDrag` delivers `DropReleased` to cell 5 → `HandleDropRelease`: - `evicted=B`; `Remove(5)`+`SendRemoveShortcut(5)`; `Set(5,A)`+`SendAddShortcut(5,A)`; source slot 3 - is empty → `Set(3,B)`+`SendAddShortcut(3,B)`; `Populate()` → slot 5 = A, slot 3 = B (swapped). -5. (Off-bar variant) release over the 3D world → no `DropReleased` → A stays removed → slot 3 empty, - A gone from the bar (server already told at step 2). - ---- - -## 6. Edge cases - -- **Drop on self (slot 3 → slot 3):** at lift slot 3 emptied; at drop `evicted=0`, `Set(3,A)` + - `SendAddShortcut(3,A)` → A back at 3. Net no-op (2 wire msgs — retail does the same). -- **Drop on a non-item element** (chrome / another window): `FinishDrag` delivers `DropReleased` to - it; its `OnEvent` doesn't handle it → no place → A stays removed (off-bar). Correct. -- **Ghost lifetime:** snapshotted at `BeginDrag`, cleared in `FinishDrag` — independent of the source - cell emptying. A cancelled drag (mouse-up off-bar) still clears `_dragGhost`. -- **Empty-slot lift:** an empty slot is not a drag source (`IsDragSource => ItemId != 0` from B.1) → - no drag arms → no lift. (And empty slots still move the window — IA-12, from B.1.) -- **Spell shortcuts in the loaded list** (`ObjectGuid == 0`, a spell): `ShortcutStore.Load` skips - them (item-only this stream); they neither render nor drag. Forward-compat: the store could hold - spell entries later. - ---- - -## 7. Divergence register (update in the implementation commits) - -- **AP-47 (amend):** the ghost is now **full opacity** (retail m_dragIcon) — retire the - `GhostAlpha=0.6` reduced-alpha approximation. The row's REMAINING approximation: the ghost reuses - the full composited `m_pIcon` (which includes the type-default underlay) rather than retail's - dedicated underlay-less `m_pDragIcon`. Reword AP-47 to drop the alpha claim, keep the underlay note. -- **TS-33 (retire):** the toolbar `HandleDropRelease` logging stub is replaced by the real - store-mutation + `AddShortcut`/`RemoveShortcut` wire. Delete the row. -- **No new row** for remove-on-lift / off-bar-remove — it is the faithful retail mechanism (it - *reduces* divergence). The store being client-authoritative post-login matches retail's - `ShortCutManager` (client owns the array; server is notified via the wire). - ---- - -## 8. Testing (conformance) - -Core tests (`tests/AcDream.Core.Tests/`): -1. `ShortcutStore`: `Load` maps Index→ObjId (skips ObjectGuid==0 + out-of-range); `Set`/`Remove`/ - `Get`/`IsEmpty`; bounds-safe. -2. `BuildAddShortcut`: 24-byte body, envelope+seq+`0x019C`+index+objectGuid+spellId(u16)+layer(u16) at - the right offsets (golden bytes). `BuildRemoveShortcut`: 16 bytes, index at +12. - -App tests (`tests/AcDream.App.Tests/`): -3. Spine: `BeginDrag` snapshots the ghost so it survives the source cell being cleared mid-drag (clear - the source cell after begin; assert the drawn ghost source is still the snapshot — assert via a - testable `UiRoot` accessor for `_dragGhost`). -4. Spine: `FinishDrag` over null delivers NO `DropReleased` (a spy cell/handler isn't called); over a - real cell, delivers it (drop the Data0 gate — drop-on-self now dispatches). -5. `ToolbarController.OnDragLift`: removes the source slot from the store, invokes `sendRemove(src)`, - and the source cell empties after `Populate`. -6. `ToolbarController.HandleDropRelease` reorder onto an OCCUPIED target: store ends with dragged@target - + displaced@source; `sendRemove`+`sendAdd` called with the retail sequence/args. Onto an EMPTY - target: dragged@target, source stays empty, displaced-branch not taken. Drop-on-self: re-adds to - source. -7. Update the B.1 tests changed by §4.2/§4.3: `DropReleased_notAccepted_skipsDispatch` → - `DropReleased_alwaysDispatchesToHandler` (the cell dispatches whenever it receives a DropReleased); - the full-chain drag tests still pass (a completed reorder fires `HandleDropRelease`, not `Clicked`). - ---- - -## 9. Acceptance criteria - -- [ ] `dotnet build` + `dotnet test` green (Core + App). -- [ ] AP-47 reworded (no alpha); TS-33 deleted; wire builder renamed; no stray old-signature callers. -- [ ] `UiRoot`/`UiElement`/`UiItemSlot` remain item-agnostic (the lift hook is generic; only - `ToolbarController` knows shortcuts). -- [ ] **Visual (user), `ACDREAM_RETAIL_UI=1`, in-world:** lift a hotbar item → **slot empties - immediately**, **full-opacity** icon follows the cursor with a **green cross** over hovered - slots; drop on another slot → it **moves there** (occupant bumps to the source slot); drop - off-bar → it's **removed**; a single click still **uses** the item. Changes **persist after - relog** (the wire reached ACE). -- [ ] Memory crib + roadmap/ISSUES updated; #141's note refreshed if relevant. - ---- - -## 10. Subagent task slices - -1. **Core: `ShortcutStore` + wire** — `ShortcutStore.cs` + `BuildAddShortcut` rename + - `WorldSession.SendAddShortcut`/`SendRemoveShortcut` + Core tests (§8.1-2). -2. **Spine extensions** — `IItemListDragHandler.OnDragLift`; `UiItemSlot` DragBegin→lift + DropReleased - ungate; `UiRoot` ghost snapshot + full opacity + `FinishDrag` deliver-on-hit-only; update the B.1 - tests (§8.3-4, §8.7). -3. **ToolbarController handler** — `_store` + `Load` + `Populate`-from-store; green-cross sprite; - `OnDragLift`/`HandleDropRelease`; wire `Action`s; GameWindow injects the session sends; controller - tests (§8.5-6). Update AP-47, delete TS-33. - -(1 and 2 are independent; 3 depends on both.) diff --git a/docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md b/docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md deleted file mode 100644 index 201739a0..00000000 --- a/docs/superpowers/specs/2026-06-20-d2b-window-manager-design.md +++ /dev/null @@ -1,245 +0,0 @@ -# Design — D.2b window manager (open/close + F12 inventory toggle) - -**Date:** 2026-06-20 -**Phase:** D.2b retail-UI arc, Sub-phase **A** (window manager) — the first of three -(A window manager → B inventory window → C paperdoll). Handoff: -`docs/research/2026-06-20-window-manager-inventory-handoff.md` §3. -**Status:** approved design, pre-implementation. - ---- - -## 1. Context & goal - -Every retail-UI window today is **always-on at a hardcoded position**: vitals, chat, and -the toolbar are added to `_uiHost.Root` unconditionally at startup in the `_options.RetailUi` -block of `GameWindow.OnLoad` (grep `_uiHost.Root.AddChild`). `UiHost`/`UiRoot` have **no -open/close API** — `UiRoot` exposes the widget tree, input routing, focus, capture, modal, -and drag-drop, but nothing to show/hide/raise a named top-level window. - -**Goal:** the minimal shared infrastructure to **open/close a top-level window** and a -keybind to **toggle the inventory window**. This is the prerequisite for Sub-phase B -(the inventory window) and C (paperdoll) — both need a window that starts hidden and is -summoned on demand. - -**Non-goal:** the inventory window's *contents*. Sub-phase A toggles a throwaway -**placeholder** window; Sub-phase B replaces its body with the real `gmInventoryUI` -layout. See §9. - ---- - -## 2. Scope - -**In scope (A):** -- A named-window registry on `UiRoot` with `Show` / `Hide` / `Toggle` / `BringToFront`. -- Raise-on-click for top-level windows (retail-faithful window stacking). -- F12 → toggle the inventory window, via the **existing** `InputAction.ToggleInventoryPanel`. -- A minimal placeholder inventory window (default-hidden) so A is visually verifiable and - gives B a concrete mount point. -- Unit tests for the registry logic; divergence-register bookkeeping. - -**Out of scope (deferred):** -- Inventory window contents, grid, sub-window mount, wire gaps, `InventoryController` → **B**. -- Paperdoll / `UiViewport` → **C**. -- Disk persistence of open-state + window positions → handoff "Plan-2". -- A toolbar inventory *button* (radar/menu buttons) — F12 only for now; the button can be a - later cheap addition once the inventory window exists. -- Spell bar — explicitly deferred by the handoff. - ---- - -## 3. Architecture decision — registry on `UiRoot` - -`UiRoot` already owns exactly the state a window manager manipulates: the top-level -`Children`, `ZOrder` (drives both paint order in `DrawSelfAndChildren` and hit-test order in -`HitTestTopDown`), keyboard focus, mouse capture, and the modal overlay. "Show / hide / raise -a top-level window" is the job it already does — so the registry lives there. - -This mirrors retail, where the Keystone root (`DAT_00870c2c`) owns the widget tree and window -stacking. (Keystone is an external DLL with no decomp, so the manager is **toolkit-defined**, -not a byte-faithful port — see §7.) - -**Alternatives considered and rejected:** -- **Dedicated `WindowManager` class wrapping `UiRoot`** — cleaner single-responsibility on - paper, but it must reach into `UiRoot.Children` / `ZOrder` to mount + raise, so it just - borrows `UiRoot`'s internals through a new seam. Ceremony for ~30 lines of logic. -- **Thin API on `UiHost`** — `UiHost` is the facade `GameWindow` talks to, but the tree and - `ZOrder` live in `UiRoot`; this would be a pure forwarding layer with no added value. - -`UiHost` gets **two convenience forwarders** (`ToggleWindow`, `RegisterWindow`) that delegate -to `Root`, because `GameWindow` holds a `UiHost`, not a `UiRoot`, at the call sites. - ---- - -## 4. Components & interfaces - -### 4.1 `UiRoot` window registry - -A private `Dictionary _windows` plus: - -```csharp -/// Register a top-level window under a name for Show/Hide/Toggle. -/// Idempotent (re-register replaces). The dict is the source of truth — -/// independent of UiElement.Name, which is init-only and not reassigned here. -public void RegisterWindow(string name, UiElement window); - -/// Make the named window visible and raise it to the front. -/// No-op if unknown. Returns true if a window was shown. -public bool ShowWindow(string name); - -/// Hide the named window (Visible = false). No-op if unknown. -public bool HideWindow(string name); - -/// Flip the named window's visibility; Show (raise) if it was hidden, -/// Hide if it was visible. Returns the new IsVisible state. -public bool ToggleWindow(string name); - -/// Set element.ZOrder to one above the current max among top-level -/// children, so it paints + hit-tests above its peers. -public void BringToFront(UiElement window); -``` - -- `Show`/`Hide` only flip `UiElement.Visible`; `Visible` already gates Draw, Tick, and - HitTest (all early-return when false), so a hidden window is fully inert — no draw, no - input, no tooltip timer. -- `RegisterWindow` does **not** add the element to the tree — the caller mounts it with - `AddChild` (so registration and tree-membership stay independent and the caller controls - initial `Visible`). Registration only records the name→element mapping. -- `BringToFront`: `window.ZOrder = 1 + max(child.ZOrder for child in Children)`. The existing - default windows mount at `ZOrder = 0`, so an opened/clicked window rises above them. - -### 4.2 Raise-on-click - -In `UiRoot.OnMouseDown`, after the existing `FindWindow(target)` resolves the top-level -window under the press, call `BringToFront(window)` so the clicked window comes to the top of -the stack (retail raises clicked windows). This is the existing `FindWindow` result reused — -~3 lines, no new traversal. Applies to **any** top-level window (vitals, chat, toolbar, -inventory), not just registered ones; the registry is only for named Show/Hide/Toggle. - -### 4.3 `UiHost` forwarders - -```csharp -public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window); -public bool ToggleWindow(string name) => Root.ToggleWindow(name); -``` - -### 4.4 F12 wiring — `OnInputAction` - -`InputAction.ToggleInventoryPanel` already exists, is already bound to **F12** in -`KeyBindings.RetailDefaults()` (matching `docs/research/named-retail/retail-default.keymap.txt` -line 149: `ToggleInventoryPanel [ "" [ 0 DIK_F12 ] ]`), and is already listed in the Settings -rebind UI — so it's user-rebindable with no new wiring. Today **nothing consumes it.** - -Add one arm to the `switch (action)` in `GameWindow.OnInputAction` (the `InputDispatcher.Fired` -multicast subscriber; the switch begins at GameWindow.cs:11421): - -```csharp -case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel: - _uiHost?.ToggleWindow(WindowNames.Inventory); - break; -``` - -- Gating is already correct: the dispatcher is suppressed by `WantsKeyboard` when the chat - input holds focus, so F12 cannot toggle inventory while you're typing. -- `WindowNames.Inventory` is a `const string "inventory"` (a small `WindowNames` holder in - `AcDream.App.UI`), so the mount, the registry, and the toggle agree on one literal. - -### 4.5 Placeholder inventory window mount - -In the `_options.RetailUi` block of `GameWindow.OnLoad`, alongside the vitals/chat/toolbar -mounts, build a minimal framed window: - -```csharp -var inventoryWindow = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) -{ - Left = 200, Top = 120, Width = 320, Height = 400, - Visible = false, // starts hidden; F12 reveals it - Draggable = true, - Anchors = AcDream.App.UI.AnchorEdges.None, -}; -_uiHost.Root.AddChild(inventoryWindow); -_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryWindow); -``` - -This uses the same `UiNineSlicePanel(ResolveChrome)` + `RetailChromeSprites.Border` chrome -the vitals/chat windows use, so the placeholder already has the retail beveled frame. It is -**throwaway scaffolding**: Sub-phase B replaces the body (and likely the whole construction) -with the real `gmInventoryUI 0x21000023` nested layout, keeping the same registry name so the -F12 wiring is untouched. - ---- - -## 5. Persistence - -In-memory for the session only. Open/closed state lives in `UiElement.Visible`; window -positions are the hardcoded mount positions (already reset each session). No disk persistence -— deferred to the handoff's Plan-2. - ---- - -## 6. Testing - -Unit tests in `tests/AcDream.App.Tests/UI/UiRootInputTests.cs` (or a sibling -`UiRootWindowManagerTests.cs`) — the registry logic is pure (no GL), so it tests directly: - -- `RegisterWindow` + `ToggleWindow` flips `Visible` false→true→false and returns the new state. -- `ShowWindow` raises `ZOrder` above all peers; `HideWindow` sets `Visible=false` without - touching `ZOrder`. -- `BringToFront` on the lowest window makes its `ZOrder` strictly greatest among siblings. -- `ToggleWindow`/`ShowWindow`/`HideWindow` on an unknown name are no-ops returning false. -- Raise-on-click: an `OnMouseDown` on a registered window with a peer in front leaves the - clicked window with the greatest `ZOrder` (reuses the existing `UiRootInputTests` harness - pattern for synthesizing mouse events). - -`dotnet build` + `dotnet test` green (full suite, currently 2752+). - ---- - -## 7. Divergence register - -The window manager is **toolkit-defined** — retail's window stacking lives in `keystone.dll` -(no decomp), so this is the existing IA-12 / IA-15 toolkit-adaptation umbrella, not a -byte-faithful port. Confirm whether an existing register row covers -"retail-UI windows are toolkit-managed, not Keystone-ported"; if not, add a one-line row in -`docs/architecture/retail-divergence-register.md` in the implementation commit. **F12 itself -is retail-faithful** (matches the retail default keymap) — no divergence for the keybind. - ---- - -## 8. Acceptance criteria - -- `dotnet build` green; `dotnet test` green. -- With `ACDREAM_RETAIL_UI=1`, pressing **F12** shows the placeholder inventory window above - vitals/chat/toolbar; pressing **F12** again hides it. -- Clicking any window (vitals, chat, toolbar, inventory) raises it above the others. -- F12 does **nothing** while the chat input is focused (write mode). -- Window manager logic is unit-tested; divergence-register row confirmed/added. -- Visual verification by the user (the toggle + raise behavior). - ---- - -## 9. Seam for Sub-phase B - -A leaves B a clean handoff: -- The registry name `WindowNames.Inventory` and the F12 → `ToggleWindow` wiring are stable; - B keeps both. -- B replaces the placeholder `UiNineSlicePanel` with the real `gmInventoryUI 0x21000023` - nested layout (paperdoll + backpack + 3D-items), re-registering it under the same name. -- B adds the dat **close button** → `HideWindow(WindowNames.Inventory)` wiring (the manager - already exposes `HideWindow`; A leaves the placeholder closable only via F12). -- B wires the inventory cell as a drag **source**, completing B.2's drag-from-inventory via - the `SourceKind == Inventory` branch in `ToolbarController.HandleDropRelease`. - ---- - -## 10. References - -- Handoff: `docs/research/2026-06-20-window-manager-inventory-handoff.md` §3 (Sub-phase A). -- Retail keymap: `docs/research/named-retail/retail-default.keymap.txt` lines 139–153 - (panel-toggle commands; inventory = F12) + line 246 (`Laugh` = `DIK_I`). -- Toolkit: `src/AcDream.App/UI/UiRoot.cs`, `UiElement.cs`, `UiHost.cs`, - `UiNineSlicePanel.cs`; `claude-memory/project_d2b_retail_ui.md`. -- Input pipeline: `OnInputAction` switch at `src/AcDream.App/Rendering/GameWindow.cs:11421`; - `InputAction.ToggleInventoryPanel`; `KeyBindings.cs:205`; - `claude-memory/project_input_pipeline.md`. -- Object model (for B): `claude-memory/project_object_item_model.md`, - `src/AcDream.Core/Items/ClientObjectTable.cs`. diff --git a/docs/superpowers/specs/2026-06-20-indoor-sun-gate-design.md b/docs/superpowers/specs/2026-06-20-indoor-sun-gate-design.md deleted file mode 100644 index eccf2528..00000000 --- a/docs/superpowers/specs/2026-06-20-indoor-sun-gate-design.md +++ /dev/null @@ -1,245 +0,0 @@ -# #142 — windowed-building interiors read "like outdoors": per-instance sun gate - -**Date:** 2026-06-20 **Status:** user pre-approved (2026-06-20), ready to implement -**Milestone:** M1.5 "Indoor world feels right" -**Predecessor brainstorm:** `docs/research/2026-06-20-indoor-lighting-regime-handoff.md` -**Companion memory:** `claude-memory/reference_retail_ambient_values.md` - ---- - -## 1. Problem - -Standing inside (or looking into) a **windowed** building — e.g. the Holtburg -Agent of Arcanum — the interior reads "like outdoors": flat, sun-shaded, not the -warm torch-lit room retail shows. Dungeons (sealed cells) already look right. - -## 2. Root cause (decomp-verified + in-game-probe-confirmed) - -acdream's lighting **REGIME** has two halves. One is already faithful; one is not. - -**Ambient — already faithful, DO NOT CHANGE.** Retail sets a single global ambient -once per frame in `CellManager::ChangePosition` (0x004559B0), keyed on the player's -current cell: - -- `curr_cell->seen_outside != 0` (windowed or outdoor) → ambient = **sky ambient** - (`SetWorldAmbientLight(smartbox_2, calc_object_light(), LScape::ambient_color)`) -- `seen_outside == 0` (sealed) → ambient = **flat (0.2,0.2,0.2)** - (`SetWorldAmbientLight(smartbox_1, 0.2f, 0xffffffff)`) - -acdream's `UpdateSunFromSky(playerInsideCell)` already does exactly this (flat 0.2 -for sealed, sky ambient otherwise). Adding a *second* indoor ambient would diverge -**away** from retail. The in-game probe (2026-06-20) confirmed the interior ambient -is the sky value `(0.43,0.43,0.51)` — correct. - -**Sun — the bug. Retail gates it per-DRAW-STAGE; acdream gates it per-FRAME.** -`PView::DrawCells` (0x005a4840) draws each frame in two stages: - -1. outdoor stage: `useSunlightSet(1)` (0x005a485a) → sun ON -2. interior-cell stage: `useSunlightSet(0)` (0x005a49f3) → sun OFF - -`useSunlightSet` (0x0054d450) **only toggles the sun** (`Render::useSunlight` + the -sun's active-light slot) — it never touches ambient. Per-object torch lighting is -gated on the same flag: `minimize_object_lighting` (0x0054d480) runs only when -`Render::useSunlight == 0` (`DrawMeshInternal` 0x0059f398). So **every interior -draw gets no sun + torches**, even while the player stands in a windowed building -with sky visible. - -acdream gates the sun **per frame**: `UpdateSunFromSky` zeroes the sun only when the -player is in a **sealed** cell. A windowed building is `seen_outside = true`, so -acdream leaves the sun on for the whole frame and the interior objects get sunlit. - -### In-game probe evidence (`agent-arcanum-probe.log`, 2026-06-20) - -The Agent of Arcanum is a **windowed EnvCell building**: -``` -[cell-transit] 0xA9B40031 -> 0xA9B40170 -> 0xA9B40171 -> 0xA9B40172 (land cell → EnvCells) -[light] insideCell=False ambient=(0.426,0.426,0.507) sun=1 ... playerCell=0xA9B40172 -``` -`playerCell` low-16-bits `0x0172 >= 0x0100` ⟹ EnvCell. `insideCell=False` inside it -⟹ `SeenOutside=true` (windowed). **`sun=1` while indoors** ⟹ the bug. Warm torches -ARE present + selected (`[light-detail]` intensity-100 cream/orange points). So the -only wrong term is the directional sun washing over the interior objects. - -The mode-1 EnvCell **walls** are already correct: the shader's sun loop is gated on -`uLightingMode == 0`, so walls never get the sun, and `EnvCellRenderer` already -binds their torch set (the old "D-2" gap is fixed). **Only mode-0 objects (furniture, -NPCs, the player) inside an EnvCell get the erroneous sun.** - -## 3. Design — per-instance sun gate - -Mirror AP-43's per-object torch decision. Add a 1-bit-per-instance "indoor" flag, -derived from the **same** `IndoorObjectReceivesTorches(ParentCellId)` predicate -AP-43 already computes, and gate the sun off for indoor mode-0 objects. This makes -the sun per-draw, exactly matching retail's per-stage `useSunlight`. - -**Rejected alternatives (record why, do not revisit):** -- *Dual indoor/outdoor ambient* (the handoff's Fork-1): retail uses one global - ambient keyed on the player cell, which acdream already replicates. Diverges from - retail. The probe confirmed the ambient is already correct. -- *Split indoor/outdoor into separate draw passes*: DO-NOT-RETRY — fights the - batched bindless-MDI (same reasoning that put AP-43 per-object). - -### 3.1 Shader — `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` - -Add the binding=6 SSBO declaration alongside the existing per-instance buffers -(binding=3 clip slot, binding=5 light set): - -```glsl -// #142: per-instance "indoor" flag, 1 per instance, parallel to the binding=0 -// instance buffer (same instanceIndex). 1 = object parented to an EnvCell (skip the -// sun — retail's useSunlight==0 interior stage); 0 = outdoor object (gets the sun). -// Read ONLY inside the uniform `uLightingMode == 0` branch below, so the mode-1 -// (EnvCell shell) path provably never touches it — EnvCellRenderer need not bind it. -layout(std430, binding = 6) readonly buffer InstanceIndoorBuf { - uint instanceIndoor[]; -}; -``` - -In `accumulateLights`, nest the indoor check **inside** the existing -`if (uLightingMode == 0)` sun block: - -```glsl -if (uLightingMode == 0) { - if (instanceIndoor[instanceIndex] == 0u) { // #142: outdoor objects only get the sun - int activeLights = int(uCellAmbient.w); - for (int i = 0; i < 8; ++i) { - if (i >= activeLights) break; - if (int(uLights[i].posAndKind.w) != 0) continue; // directional only - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; - } - } -} -``` - -`uLightingMode` is a uniform, so the `instanceIndoor[]` read is uniform-control-flow -gated — a mode-1 draw never executes it. The torch/point-light accumulator below is -untouched (indoor objects keep their torches). - -### 3.2 Dispatcher — `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` - -The per-instance flag is a `uint` per instance — mechanically identical to the -**binding=3 clip-slot** buffer (`_clipSlotData` / `_clipSlotSsbo`). Mirror it. The -per-entity *value* is the **same decision** as the torch set, computed in -`ComputeEntityLightSet`. Sites (line numbers @ HEAD, will drift — match by symbol): - -1. **Per-entity decision.** Add a field beside `_currentEntityLightSet` (~:152): - ```csharp - private bool _currentEntityIndoor; - ``` - In `ComputeEntityLightSet` (~:2050), set it at the top from the existing - predicate, and reuse it for the early-return so the two can't disagree: - ```csharp - _currentEntityIndoor = IndoorObjectReceivesTorches(entity.ParentCellId); - Array.Fill(_currentEntityLightSet, -1); - var snap = _pointSnapshot; - if (snap is null || snap.Count == 0) return; - if (!_currentEntityIndoor) return; // was: if (!IndoorObjectReceivesTorches(entity.ParentCellId)) return; - ... - ``` - -2. **Per-instance storage.** Add to `InstanceGroup` (~:2419), after `LightSets`: - ```csharp - // #142: per-instance "indoor" flag, parallel to Matrices. 1 = EnvCell-parented - // (skip the sun); 0 = outdoor. Written into _indoorData at the same cursor as - // Matrices, so binding=6 instanceIndoor[] tracks the binding=0 instance. - public readonly List IndoorFlags = new(); - ``` - Clear it where the other per-group lists are cleared (~:926): - ```csharp - grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); grp.IndoorFlags.Clear(); - ``` - Append it per instance in `AppendCurrentLightSet` (~:2084) — it already runs once - per instance from both emit sites (~:2019, ~:2145), so folding it in covers both: - ```csharp - for (int k = 0; k < LightManager.MaxLightsPerObject; k++) - grp.LightSets.Add(_currentEntityLightSet[k]); - grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block - ``` - -3. **Backing array + SSBO.** Beside `_clipSlotData` / `_clipSlotSsbo`: - ```csharp - private uint[] _indoorData = new uint[256]; // grown to totalInstances like _clipSlotData - private uint _instIndoorSsbo; // binding=6 - ``` - Generate it where `_clipSlotSsbo` / `_instLightSetSsbo` are generated (~:352); - delete it where they are deleted (~:2226). - -4. **Pack + upload.** In the cursor loop (~:1419), beside the clip-slot write: - ```csharp - _clipSlotData[cursor] = grp.Slots[i]; - _indoorData[cursor] = grp.IndoorFlags[i]; // #142, parallel to binding=0 - ``` - Add the grow-check beside the others (~:1390) and upload+bind beside the clip-slot - upload (~:1515), using the existing `UploadSsbo` helper (it binds via - `BindBufferBase`): - ```csharp - fixed (uint* dp = _indoorData) - UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint)); - ``` - -### 3.3 `UpdateSunFromSky` — UNCHANGED - -Its per-frame ambient regime (flat 0.2 sealed / sky otherwise) IS retail's -`ChangePosition` and the probe confirmed it correct. Its per-frame sun-zero for -sealed frames stays: it correctly models "a sealed dungeon runs only the interior -stage" and harmlessly guards transient null-cell frames; the new per-instance flag -adds the windowed/look-in coverage it was missing. The two together reproduce -retail's per-stage `useSunlight` for every case. **Do not touch the ambient values -or the sun colors here.** - -### 3.4 `EnvCellRenderer` — UNCHANGED - -Mode-1 draws set `uLightingMode = 1`, so the `instanceIndoor[]` read (inside the -uniform `uLightingMode == 0` branch) is never executed for EnvCell shells. The walls -already skip the sun and already get their torch set. No binding=6 bind needed. - -## 4. Divergence register - -Update **AP-43** in `docs/architecture/retail-divergence-register.md` in the same -commit: the regime is now per-draw for **both** torches (AP-43 original) and the sun -(this change), matching retail's per-stage `useSunlight`. Narrow the row to whatever -residual remains (e.g. the `ebp_2` term in `ChangePosition`'s seen_outside test, -unaudited), or close it if nothing remains. Do not leave it claiming the sun half is -unfixed. - -## 5. Tests - -`tests/AcDream.App.Tests/` (or the nearest existing dispatcher test project): - -1. **Predicate-to-flag pin.** Assert the per-instance indoor flag equals - `WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)` for representative - ids: EnvCell `0xA9B40172` → 1; land sub-cell `0xA9B40031` → 0; landblock - `0xA9B4FFFF` → 0; `null` → 0. (The static predicate is the single source of truth - for both torches and the sun gate.) -2. If a lightweight path exists to drive `WbDrawDispatcher` instance emission in a - test (mirror any existing light-set/clip-slot dispatcher test), assert the - uploaded `_indoorData[cursor]` matches each entity's `ParentCellId` decision. - Optional if no such harness exists — do not build GL plumbing for it. - -`dotnet build` + `dotnet test` green (App / Core / UI / Net suites) before done. - -## 6. Faithfulness guardrails (DO NOT DRIFT) - -- Do **not** add a second ambient or change `UpdateSunFromSky`'s ambient/sun values. -- Do **not** touch the torch/point-light path or shorten `Falloff × 1.3`. -- Keep the predicate exactly `IndoorObjectReceivesTorches` — same one AP-43 uses. -- Do **not** split the MDI into indoor/outdoor passes. -- The flag is per-INSTANCE (parallel to Matrices), never per-group. - -## 7. Acceptance gate (visual) - -Launch with `testaccount2` / `testpassword2`, go to the **Agent of Arcanum**: looking -in from outside AND standing inside, the interior should read warm/torch-lit (no sun -wash) instead of "like outdoors"; outdoor world through the windows still sunlit; -dungeons + the sealed meeting hall unchanged. - -## 8. Decomp anchors - -`CellManager::ChangePosition` 0x004559B0 (per-frame ambient + sun keyed on player -`curr_cell->seen_outside`; the 0.2/sky split @ pc:94682-94712) · `PView::DrawCells` -0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3) · -`useSunlightSet` 0x0054d450 (sun only, never ambient) · `DrawMeshInternal` 0x0059f398 -(`useSunlight==0` gate on per-object lighting) · `minimize_object_lighting` 0x0054d480 -· `IndoorObjectReceivesTorches` (`WbDrawDispatcher.cs`, AP-43). diff --git a/docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md b/docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md deleted file mode 100644 index a4c9d44e..00000000 --- a/docs/superpowers/specs/2026-06-21-145-cell-relative-physics-frame-design.md +++ /dev/null @@ -1,191 +0,0 @@ -# Design — #145 Option B: cell-relative physics `Position` frame (retire `_liveCenter` from physics) - -**Date:** 2026-06-21 -**Issue:** #145 (far-town teleport resolver runaway — residual of the 2026-06-20 source-drop fix) -**Milestone:** M1.5 "Indoor world feels right" (last blocker for far-town / dungeon round-trips) -**Status:** APPROVED design — ready for implementation plan -**Decision authority:** user, 2026-06-21 ("Lock in B") -**Supersedes the targeted patch (Option A):** rejected by the user in favor of the architectural fix -("It should be agnostic." / "done right, never again.") - ---- - -## 1. Problem (verified root cause — condensed) - -A teleport to a FAR town places the player correctly, then the per-frame outdoor resolve **marches the -cell-id LABEL one landblock south per physics quantum (~33 ms)** while the **body barely moves**. It is a -cell-membership *label* cascade, NOT a free-fall. The `17410` ACE rejects is a wire-conversion artifact. - -**Single proximate trigger:** `CellTransit.cs:736` discards the `bool` from -`CellGraph.TryGetTerrainOrigin`. For an unstreamed neighbor that returns `(Vector3.Zero, false)`, and the -resolve uses `(0,0)` as the block origin ("legacy anchor-frame" fallback, self-documented at -`CellTransit.cs:732-735`). `GetOutsideLcoord` then does `floor(-0.088/24) = -1`, so the cell marches; the -correct origin `(0,-192)` would give `+7`. Far-town-only because the streaming center follows login/teleport, -so a fresh teleport lands at the *edge* of the streamed region where neighbors are unregistered. - -**The non-agnostic assumption:** acdream's physics position is a **streaming-relative world `Vector3`** -(`worldXY = (lb − _liveCenter)·192 + local`). The `(cell, local)` pair is *reconstructed* from a moving -reference (`_liveCenter`) every frame, so it can drift apart. Retail's cannot — see §2. - -Full verified mechanism + apparatus: `docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md` -and the physics digest (`claude-memory/project_physics_collision_digest.md`, top banner). - -## 2. The retail architecture (decomp-verified) - -Retail has **no world/global physics frame anywhere.** Every stored spatial quantity carries the cell it -lives in, and per-tick collision is computed in a working frame **anchored at the player's current -landblock**. There is no streaming center. - -| Retail symbol | Address | Verified role | -|---|---|---| -| `struct Position { uint objcell_id; Frame frame }` | `acclient.h:30658` | The position IS the `(cell, local)` pair. | -| `struct Frame { float qw,qx,qy,qz; float m_fl2gv[9]; Vector3 m_fOrigin }` | `acclient.h:30647` | `m_fOrigin` is LOCAL to the cell's landblock — outdoor `X/Y ∈ [0,192)`, `Z` = height. `m_fl2gv` = 3×3 local→global rotation. | -| `LandDefs::get_block_offset` | `0x0043E630` | **The ONLY cross-cell translation.** Verified body (pc:69189): `if ((src>>16)==(dst>>16)) return Zero;` else `x=((dstLbX<<3)−(srcLbX<<3))·24`, `y=((dstLbY<<3)−(srcLbY<<3))·24` → **nets to `(Δlandblock)·192 m` per axis**. (`block_byte<<3` = lcoord units; `·24 m/cell`.) Degenerate `arg==0` fallbacks present — port verbatim. A delta of two NAMED cell ids — never an accumulation vs a moving center. | -| `LandDefs::adjust_to_outside` | `0x005A9BC0` | The canonicalizer. Wraps `m_fOrigin` into `[0,192)` and rebuilds `objcell_id` from the FIXED absolute lcoord grid (no terrain registration). **Already ported** at `LandDefs.cs:124-142` WITH the `[0,192)` wrap (`:134-135`). | -| `LandDefs::get_outside_lcoord` | `0x005A9B00` | `gX = blockLcoordX + floor(localX/24)`. **Already ported** `LandDefs.cs:105-113`. | -| `Position::adjust_to_outside` | `0x00504A40` | Rebases `this->frame.m_fOrigin` + `this->objcell_id` IN PLACE (calls `LandDefs::adjust_to_outside`). | -| `SPHEREPATH::cache_global_curr_center` | `0x0050C740` | Builds the working sphere center: `m_fl2gv·local + m_fOrigin` — cell-landblock-local; rebuilt from `curr_pos` each step. | -| `SPHEREPATH::get_curr_pos_check_pos_block_offset` | `0x00509FD0` | `get_block_offset(curr_pos.objcell_id, check_pos.objcell_id)` — the curr↔check working-frame offset. | -| `CTransition::validate_transition` | `0x0050AA70` | On each accepted step adopts `curr_pos.objcell_id = check_pos.objcell_id` AND `curr_pos.frame = check_pos.frame` **in lockstep**, then caches the global center. The label and local position can never be adopted separately. | -| contact-plane translation | `0x0050A592` | `get_block_offset(sphere_path.check_pos.objcell_id, contact_plane_cell_id)` then offsets the contact plane — **iff `contact_plane_cell_id != 0`**. Confirms retail stores the contact plane in ITS cell's frame + a cell id, translated into the working frame per tick. This is the Approach-B model. | - -**The invariant that makes the cascade structurally impossible:** `m_fOrigin` is ALWAYS `[0,192)` and -`objcell_id` is the container — neither is reconstructed from a moving reference. An inconsistent -`(cell, local)` pair cannot persist because `adjust_to_outside` re-canonicalizes on every placement and -boundary crossing. - -## 3. acdream end-state design - -### 3.1 New value types (`AcDream.Core.Physics`) - -```csharp -// Type named CellFrame (retail "Frame") to avoid colliding with -// DatReaderWriter.Types.Frame, which is used in physics-adjacent code. -public readonly record struct CellFrame(Vector3 Origin, Quaternion Orientation); -// Origin is LOCAL to ObjCellId's cell: -// outdoor → Origin.X/Y ∈ [0,192) within the landblock; Origin.Z = height. -// indoor → Origin is the EnvCell-relative placement the BSP path already uses. - -public readonly record struct Position(uint ObjCellId, CellFrame Frame); // member keeps retail's name -``` - -One `Position` type covers indoor and outdoor (retail uniformity). `PhysicsBody.Position` becomes a -`Position` and is **the source of truth.** (The value type is `CellFrame`; the `Position.Frame` member -keeps retail's name. `Frame` collided with `DatReaderWriter.Types.Frame` — renamed during Slice 1.) - -### 3.2 The seam: `_liveCenter` is render-only - -A single conversion lives on the **render** side: `WorldFromPosition(Position, renderCenter) -> Vector3`. -Render/camera/mesh placement keep using `_liveCenterX/Y` + the per-landblock baked `WorldOffset`. **Physics -never reads `_liveCenter` or `WorldOffset`.** During migration, `PhysicsBody.WorldPosition` is a computed -accessor (`WorldFromPosition`) so render and wire keep working slice-by-slice; it is deleted from the physics -read paths by the end. - -### 3.3 The per-tick working frame - -The collision sweep computes in a `Vector3` frame **anchored at `curr_pos`'s landblock**, derived from -`ObjCellId` via the fixed lcoord grid (NOT `_liveCenter`). Geometry in a neighbor landblock is pulled in via -`GetBlockOffset(currBlock, otherBlock)`. This is exactly `cache_global_curr_center`. Inter-tick collision -state — contact plane + walkable polygon — is stored **in its owning cell's frame + its cell id** -(`ContactPlaneCellId` already exists, `PhysicsBody.cs:136`) and translated into the working frame per tick via -`GetBlockOffset` (retail `0x0050A592`). - -### 3.4 Canonicalization + lockstep - -`AdjustToOutside` runs on every `SetPosition` / teleport / cross-landblock step (`Position::adjust_to_outside` -shape). `validate_transition` adopts `check_pos.ObjCellId` AND `check_pos.Frame` together. `curr_pos` / -`check_pos` in `TransitionTypes` become `Position`. - -## 4. Divergence sites → what changes - -| Site | Today | Change | -|---|---|---| -| `PhysicsBody.cs:95` | `Vector3 Position` (world) | `Position` (cell-relative) + computed `WorldPosition` | -| `ResolveResult.cs:21-37` | `(Vector3 Position, uint CellId)` | returns a `Position` (or keeps both during migration) | -| `PhysicsEngine.cs:606-625, :513-514, :341-342, :113-235` | terrain-sampling + `Resolve` loop read baked `WorldOffset` | read the cell frame via `GetBlockOffset`; no `WorldOffset` | -| `PhysicsEngine.cs:858-861` | `return ... lbPrefix | (targetCellId & 0xFFFF)` | return the canonicalized `Position` | -| `CellTransit.cs:732-736, :242, :845, :484` | world XY − `TryGetTerrainOrigin` (Zero fallback) → `GetOutsideLcoord` | run `AdjustToOutside` on the carried `(cell, local)`; **delete the `(0,0)` fallback + legacy-anchor assumption**; drop physics use of `TryGetTerrainOrigin` | -| `TransitionTypes.cs ~:398-401, ~:2315` | `SetCheckPos` commits marched cell id without moving position | `curr_pos`/`check_pos` are `Position`; `validate_transition` lockstep adoption | -| `LandDefs.cs` | `GetBlockOffset` MISSING | port it (verified §2) | -| `GameWindow.cs:7753-7765` (outbound) | `localY = Position.Y − (lbY − _liveCenterY)·192` + `_liveCenter` fallback `:7755-7761` | outbound `(cell, local)` IS the carried `Position` — no subtraction, no `17410` | -| `GameWindow.cs:4989-4995`, `RemoteMoveToDriver.cs:271-276` (inbound) | wire `(cell, local)` + `_liveCenter` origin → world | build a `Position` directly from wire `(cell, local)` | -| `GameWindow.cs:5415-5416, :5441-5448` (teleport recenter, physics reads) | compute old LB from frozen pos; encode stale center | pass cell coords; `_liveCenter` recenter stays a RENDER decision only | -| `PlayerMovementController.cs:803, :943-977` | `SetPosition` zeros velocity; next `Update()` re-applies stale run vector | idle the motion state on arrival (Slice 7) | - -`_liveCenter` RENDER reads (keep): the ~20 sites mapped in the frame-map output (origin calcs at -`:2930-2931`, `:4992-4993`, `:5832`, `:5950`, `:6132`, `:6573`, observer centers `:7406-7472`, debug -`:12746-12883`, `AddLandblock` bake `:6770-6771`, `ParticleEmitterRenderer`). - -## 5. Conformance strategy — transform-on-read (guarded, no circular goldens) - -The committed world-space fixtures stay **byte-for-byte**: `CellarUpTrajectoryReplayTests`, -`DoorBugTrajectoryReplayTests`, `CellarLipWedgeTests`, `DoorwayMembershipReplayTests`, -`CameraCornerSealReplayTests`, `ThresholdPortalCrossingReplayTests`, `Issue98CellarUpReplayTests`, -`Issue112MembershipTests`. The harness converts each captured `(world position + cell id) → Position` at load -and asserts in the appropriate frame. **Goldens are unchanged → green proves no regression.** We do NOT -re-capture (that would let the new code grade its own homework). - -**New test:** `TeleportFarTownRunawayTests` built from `desync-capture.jsonl` (runaway records -`0xC95B0001`→`0xC9000008`; first far-town velocity `(3.637,−11.276,0)`). Assert: with `0xC95A` unregistered, -the cell-relative resolve keeps `(cell, local)` consistent (no march). Include an **east-edge** spawn case — -the cascade is direction-agnostic. - -## 6. Slice plan (parallel-frame incremental) - -Each slice: `dotnet build` green + full Core / App / UI / Net suites green; divergence-register rows updated -in the SAME commit; commit message names the slice + cites the decomp address per ported function. Each -implementer follows the grep-named-first workflow (Step 0–1) and verifies their function against the decomp -before porting. - -1. **Types + `GetBlockOffset`.** Introduce `Position` / `Frame`; port `LandDefs.GetBlockOffset` (verified - §2) with conformance tests (including same-landblock→Zero, neighbor→±192, map-edge, the `arg==0` - degenerate cases). No behavior change — types unused. -2. **Body carries `Position`.** `PhysicsBody.Position : Position`; computed `WorldPosition`; run - `AdjustToOutside` on every `SetPosition`/teleport. Render + wire read `WorldPosition`. Behavior unchanged - (assert via the existing suites). -3. **Membership through the carried `Position`.** Route `CellTransit` pick + `AddAllOutsideCells` through - `AdjustToOutside` on the `(cell, local)` pair; **delete the `(0,0)` physics fallback** + the legacy-anchor - assumption; drop physics use of `TryGetTerrainOrigin`. **← closes the cascade.** Land - `TeleportFarTownRunawayTests` here (must go green). -4. **Inter-tick collision state cell-relative.** Store contact plane + walkable poly in their cell's frame + - cell id; translate via `GetBlockOffset` per tick (retail `0x0050A592`); `validate_transition` lockstep - adoption; `curr_pos`/`check_pos` as `Position`. **← the B-specific work.** Hold every physics replay green. -5. **Wire off `Position`.** Outbound + inbound `(cell, local)` derive from the carried `Position`; remove - `_liveCenter` from the wire path (kills the `17410` leak at the source). -6. **`_liveCenter` render-only.** Delete physics reads of `_liveCenter` + baked `WorldOffset` - (`PhysicsEngine` terrain-sampling + `Resolve` loop switch to the cell frame). Retire register row AP-36 - (streaming-center physics gate) + the wire-leak row. -7. **(separable) Teleport velocity-idle.** Idle the motion state on arrival - (`DoMotion(Ready,1.0f)` / `StopCompletely()` after `PlaceTeleportArrival`) so the player doesn't resume the - pre-teleport run. Not a cascade fix anymore — the last user-visible bit of the #145 repro. - -**Scope:** the OUTDOOR frame is the target (where the leak lives). Indoor cell tracking is already cell-keyed -and its behavior is UNCHANGED — the `Position` type only unifies representation. Map-edge towns resolve via -the absolute lcoord grid (Slice 1 conformance). - -## 7. Verification items the implementer MUST confirm against the decomp (do NOT guess) - -- `validate_transition` (`0x0050AA70`): exact lockstep ordering + what else it caches (`cache_global_curr_center`). -- `cache_global_curr_center` (`0x0050C740`): `m_fl2gv` matrix row/column order vs acdream's `Quaternion` - convention (transposition risk — the oracle flagged this). -- contact-plane translation sign (`0x0050A592`): direction of the `GetBlockOffset` add relative to the plane. -- `Position::adjust_to_outside` (`0x00504A40`): the by-ref cell-id output signature. -- Map-edge behavior: a destination whose needed neighbor genuinely doesn't exist resolves sanely (retail - clamps via `get_outside_lcoord` / absolute lcoord grid) — confirm the acdream port matches, no march, no - throw. - -## 8. Divergence-register bookkeeping - -- Slices retiring the streaming-relative physics frame **delete** the relevant rows in the same commit: - AP-36 (streaming-center gate) and the wire-leak row. Re-point AP-48 (#138 re-hydrate) if touched. -- Any NEW deviation introduced (e.g. a temporary dual-frame accessor that outlives the migration) gets a row - in the same commit, retired when the migration completes. - -## 9. Pointers - -- Handoff (full verified mechanism + apparatus + retail port table): `docs/research/2026-06-21-145-cell-relative-physics-frame-handoff.md` -- Physics digest (SSOT + DO-NOT-RETRY): `claude-memory/project_physics_collision_digest.md` -- Decomp oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` + `acclient.h` + Ghidra patchmem :8081 -- Wire cross-check: `references/holtburger` (client frame) + `references/ACE` (server validation) -- Apparatus: `desync-capture.jsonl` (runaway) + `PhysicsResolveCapture` + the `CellarUp` `LiveCompare_*` replay template -- Frame map (this session's exploration): the `map-physics-frame-145` workflow output diff --git a/docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md b/docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md deleted file mode 100644 index 79cd75c3..00000000 --- a/docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md +++ /dev/null @@ -1,265 +0,0 @@ -# D.2b Sub-phase B-Controller — inventory population design - -**Date:** 2026-06-21 -**Phase:** D.2b retail-UI engine → core panels → Sub-phase B (inventory) → **B-Controller** -**Branch:** `claude/hopeful-maxwell-214a12` -**Predecessor:** B-Grid (sub-window mount + `UiItemList` grid mode) + #145 (ZLevel z-order) — shipped. -Handoff: `docs/research/2026-06-21-d2b-bgrid-shipped-bcontroller-next-handoff.md`. - -## 0. Goal - -Make the F12 inventory window show the player's live contents. Bind the imported -`gmInventoryUI` (`LayoutDesc 0x21000023`) tree by element id and populate it from -`ClientObjectTable`: - -1. the **"Contents of Backpack" grid** with the player's pack items, -2. the **right-strip pack-selector** (main-pack cell + side-bag container icons), -3. the **vertical burden meter** + the `%` text, -4. the **Type-0 captions** ("Burden", "Contents of Backpack"). - -This is the `gmInventoryUI::PostInit` / `gmBackpackUI::PostInit` / `gm3DItemsUI::PostInit` -analogue. **Read-only**: no container switching, no drag-as-source, no wield/drop wire -(those are B-Wire / B-Drag / Sub-phase C). - -Both scope calls were taken by the user during brainstorming: **faithful pack-selector** -(populate the right strip too) and **full faithful burden meter** (vertical fill + ported -`InqLoad` formula). - -## 1. The inventory tree (confirmed by dat dump + decomp) - -`gmInventoryUI` `0x21000023` (300×362) is a frame nesting three sub-windows (mounted by -B-Grid's sub-window mount; all reachable via `ImportedLayout.FindElement`): - -| Sub-window | LayoutDesc | Mounted at | Size | This phase | -|---|---|---|---|---| -| paperdoll | `0x21000024` | `0x100001CD` | 224×214 | **Sub-phase C** (not here) | -| backpack strip | `0x21000022` | `0x100001CE` | 61×339 | **here** | -| 3D-items ("Contents of Backpack") | `0x21000021` | `0x100001CF` | 234×120 | **here** | - -### Elements this controller binds - -| Id | Role | Resolved widget | Source | -|---|---|---|---| -| `0x100001C6` | 3D-items **contents grid** (192×96) | `UiItemList` (grid) | `gm3DItemsUI::PostInit` m_itemList `DynamicCast(0x10000031)` (decomp 176734-176742) | -| `0x100001C5` | "Contents of Backpack" caption (192×15) | Type-0 → caption pass | `gm3DItemsUI::PostInit` m_contentsText, `SetText` 176745 | -| `0x100001CA` | backpack **m_containerList** (36×252, 1 col) | `UiItemList` (grid) | `gmBackpackUI::PostInit` `DynamicCast(0x10000031)` (176621-176629) | -| `0x100001C9` | backpack **m_topContainer** (36×36, 1 cell) | `UiItemList` (single) | `gmBackpackUI::PostInit` (176612-176620) | -| `0x100001D9` | **burden meter** (vertical 11×58, Type 7) | `UiMeter` | `gmBackpackUI::PostInit` `DynamicCast(7)` (176601-176610) | -| `0x100001D8` | burden **`%` text** (36×15) | Type-0 → caption pass | `gmBackpackUI` m_burdenText, `SetText "%d%%"` (176583) | -| `0x100001D7` | "Burden" caption (36×15) | Type-0 → caption pass | dat | - -The three list elements (`0x100001C6/C9/CA`) are Type-0 in the dat, inheriting -`BaseElement 0x10000339 / BaseLayoutId 0x2100003D` (the `UIElement_ItemList` prototype) → -`ElementReader.Merge` resolves them to Type `0x10000031` → `DatWidgetFactory` builds them as -`UiItemList`. Already verified reachable by id through the mount. - -## 2. Architecture - -New `src/AcDream.App/UI/Layout/InventoryController.cs`, mirroring `ToolbarController` / -`VitalsController` (Code-Structure Rule 1 — no new feature body in `GameWindow`): - -```csharp -public sealed class InventoryController -{ - public static InventoryController Bind( - ImportedLayout invLayout, - ClientObjectTable objects, - Func playerGuid, // _playerServerGuid - Func iconIds, // IconComposer.GetIcon - Func strength, // LocalPlayerState Strength.Current - UiDatFont? datFont); - public void Dispose(); // unsubscribe (controllers currently don't, but - // the inventory rebuild is event-driven — be tidy) -} -``` - -Bound **inline in GameWindow's existing inventory-init block** (the `invLayout` local from -B-Grid, `GameWindow.cs` ~2141), right after `RegisterWindow(WindowNames.Inventory, …)` — -exactly where `ToolbarController.Bind(toolbarLayout, …)` sits for the toolbar. - -Lifecycle: `Bind` find-by-id binds all elements, subscribes to -`objects.ObjectAdded/ObjectMoved/ObjectRemoved` and `LocalPlayerState` attribute/`Changed` -events, then calls `Populate()` once. Each subscription calls `Populate()` (filtered: only -when the changed object is in the player's possession subtree, mirroring -`ToolbarController.Populate`'s `IsShortcutGuid` gate). Missing elements are skipped silently -(partial-layout tolerance, like `SelectedObjectController`). - -## 3. Population (`Populate()`) - -Reads `var contents = objects.GetContents(playerGuid())` (→ `IReadOnlyList`, slot-ordered). -Partition each guid's `ClientObject`: -- **side bag** = `Type.HasFlag(ItemType.Container)` OR `ItemsCapacity > 0`. -- **loose item** = everything else. - -Then: - -- **3D-items grid `0x100001C6`** — `Columns=6`, `CellWidth=CellHeight=32` (192÷32=6 cols × - 96÷32=3 rows; cell template `UIItem 0x21000037` = 32×32). `Flush()`, then `AddItem(new - UiItemSlot{…})` + `cell.SetItem(guid, iconIds(item.Type, item.IconId, item.IconUnderlayId, - item.IconOverlayId, item.Effects))` per **loose item**. (Pitch 32 vs 36 → **visual-confirm**; - 192/32=6 is the clean fit and matches the handoff "6 cols" note.) Overflow past 3 rows is - clipped by the panel for now; scroll = follow-up (the `0x100001C7` gutter is the scrollbar). -- **m_containerList `0x100001CA`** — `Columns=1`, 32px cells. One cell per **side bag**, same - `SetItem`. Empty on a char with no side bags (correct). -- **m_topContainer `0x100001C9`** — single cell = the **main pack**. Icon: a fixed backpack - RenderSurface DID (pin from the decomp / a known UI icon; if none found, leave the - empty-slot art and file a follow-up — do NOT invent a guid). Bound object id = `playerGuid()` - (the main pack ≡ the player container). - -Reuse the shipped `UiItemList` grid mode (`Columns`/`CellWidth`/`CellHeight`/`Flush`/`AddItem`) -and `UiItemSlot.SetItem(guid, tex)` + `IconComposer.GetIcon`. **Do not rebuild the spine.** - -## 4. Burden meter (faithful vertical port) - -### 4.1 Retail formula (ported, with anchors) - -`gmBackpackUI::SetLoadLevel(double load)` (decomp 176536, `0x004a6ea0`): -- `fill = clamp(load × 0.3333…, 0, 1)` → pushed to the meter as float attribute `0x69` - (`UIElement::SetAttribute_Float`, 176565-176573). -- `%` text = `floor((load×⅓) × 300) = floor(load × 100)` formatted `"%d%%"` → - `UIElement_Text::SetText(m_burdenText)` (176576-176583). - -`load = CACQualities::InqLoad()` (decomp 409756, `0x0058f130`): -``` -strength = InqAttribute(1) // primary attr 1 = Strength, default 10 -aug = InqInt(0xE6) // capacity augmentation, default 0 -capacity = EncumbranceSystem::EncumbranceCapacity(strength, aug) -burden = InqInt(5) // PropertyInt 5 = EncumbranceVal (total carried) -load = EncumbranceSystem::Load(capacity, burden) -``` - -`EncumbranceSystem::EncumbranceCapacity(str, aug)` (decomp 256393, `0x004fcc00`): -``` -if str <= 0: return 0 -bonus = clamp(aug × 30, 0, 150) // 0x1e=30, cap 0x96=150 -return str × 150 + bonus × str // = str × (150 + bonus) -``` -`EncumbranceSystem::Load(capacity, burden)` (decomp 256413, `0x004fcc40`): the decompiler -mangled the FP body to `if cap<=0 return cap; return burden`; the structure (a divide-by-zero -guard + a single divide) is unambiguously `load = burden / capacity` — the encumbrance ratio -(1.0 = at capacity, up to ~3.0). **Cross-ref:** matches acdream's existing -`BurdenMath.ComputeMax = 150×str + str×bonus` (`ClientObject.cs:215`) and the r06 research doc -("maxBurden = 150 × Strength + Strength × bonusBurden; carry limit 3 × maxBurden"). ACE was -not checked out in this worktree; the decomp + existing port + r06 doc are three corroborating -sources. - -**acdream port:** `maxBurden = BurdenMath.ComputeMax(strength, bonus)` (reuse; `bonus = clamp(aug×30,0,150)`, -`aug` from player PropertyInt `0xE6`, default 0 → `str×150`). `load = burden / maxBurden`. -`fill = clamp(load/3, 0, 1)`. `%text = floor(load×100)`. - -### 4.2 Burden data source - -acdream does **not** track the player's wire `EncumbranceVal` today (WorldSession parses only -per-item Burden). Priority: -1. player `ClientObject`'s PropertyInt 5: `objects.Get(playerGuid())?.Properties.GetInt(5)` — - populated iff PD/`0x02CE` carried it (B-Wire guarantees this later). -2. fallback: client-side sum of carried `Burden` over the player's possession subtree - (`GetContents(player)` + recurse side bags + items with `WielderId == player`). -3. neither → `currentBurden = 0` → bar empty / "0%" (correct "no data" state). - -`strength` from `LocalPlayerState.GetAttribute(Strength)?.Current` (default 10 if absent, -matching `InqAttribute`'s `0xa` default). **Divergence row** if (2) is used (client recompute -vs server EncumbranceVal — same quantity). - -Drive the meter via `UiMeter.Fill = () => fill` and a `Label`/caption provider for the `%` -text, recomputed on `ObjectAdded/Moved/Removed` + `LocalPlayerState.Changed/AttributeChanged`. - -### 4.3 Vertical fill (the one `UiMeter` change) - -Per `UIElement_Meter::DrawChildren` (decomp 123574, `0x0046fbd0`): the meter draws its **own -DirectState media as the track (full)** and the **`m_pcChildImage` child clipped to attribute -`0x69` (the fill fraction)**. So `BuildMeter`'s existing single-image assignment is **already -correct** (meter-own `0x0600121D` → `BackTile`/track; child `0x0600121C` → `FrontTile`/fill). -**No sprite-role change.** - -The clip axis/direction is `m_eDirection` (read from LayoutDesc property `0x6f` at -`UIElement_Meter::Initialize`, decomp 123334-123336): `1`=L→R, `2`=T→B, `3`=R→L, `4`=B→T. -`UiMeter` is hardcoded `1`. Add: -- a `UiMeter.Vertical` (or `Direction`) flag, -- a `DrawVBar` that draws `BackTile` over the full rect then `FrontTile` clipped to - `Height × fraction` along the fill direction. The burden child sprite (11×61) ≈ the bar - (11×58) — effectively single-sprite, no 3-slice tiling needed. - -Direction source: read property `0x6f` if `ElementReader` surfaces it; **else** infer vertical -from `Width < Height` and default **B→T (dir 4)** — the AC convention for a vertical resource -bar — with a **visual-confirm** and an AP register row for the geometry-inference. (Reading -`0x6f` is preferred; decide in the plan based on how cheaply `ElementReader` can expose it.) - -## 5. Captions (Type-0 text) - -Controller **caption pass** (not a factory Type-0→`UiText` promotion — that would risk other -panels' inert Type-0 elements). For each caption id, attach a centered `UiText` child carrying -the known string, mirroring `SelectedObjectController`'s name-overlay attach and -`VitalsController`'s number overlay: -- `0x100001D7` → "Burden" -- `0x100001C5` → "Contents of Backpack" (procedural in retail via `SetText`, 176743-176745) -- `0x100001D8` → the live `%` text (`floor(load×100)` + "%"), updated with the meter. - -Caption strings are hardcoded with their decomp citation (retail sets `0x100001C5`/`0x100001D8` -procedurally; `0x100001D7` "Burden" is the dat element's label). Font = the retail dat font -(`datFont`), color/justification per the Type-0 base (left/normal). If the dat string for -`0x100001D7` is trivially readable from the layout, prefer it over the hardcode. - -## 6. Divergence register rows (add in the impl commit) - -- **AP** — burden `currentBurden` computed client-side (Σ carried Burden) when the wire - `EncumbranceVal` (PropertyInt 5) is absent; retail reads the server-maintained value. - Retire when B-Wire parses it. -- **AP** — capacity augmentation (`aug`, PropertyInt `0xE6`) not tracked → `bonus = 0` → - un-augmented `str×150`. Buffed/augmented chars read slightly high. -- **AP** (only if dir not read from `0x6f`) — burden meter orientation inferred from `W 18 loose items the grid shows 3 rows + a working scrollbar; picked-up items are -reachable by scrolling; the grid no longer draws past the frame; the bottom backdrop is intact. - -### B — Backdrop full coverage - -The backdrop `0x100001D0` is already full-window (300×362). **Primary fix is A** (clipping the grid -removes the overflow that tears the bottom). After A lands, the implementer verifies at the visual -gate that the dark backing covers the whole window. **If a residual gap remains** (e.g. the Alphablend -backdrop sprite drawn at native size instead of filled to the element's 300×362 bounds, or a sub-window -region uncovered), root-cause it then — the candidate is `UiDatElement`'s Alphablend draw not -stretching/tiling to bounds. This is a **root-cause-then-fix** task gated on the post-A screenshot, not -a pre-committed code change (a render-coverage bug must be confirmed visually before fixing). - -**Acceptance:** no world/grass shows through the inventory window anywhere (matches retail's solid -backing). - -### C — Side-bag slot column - -The side-bag column `0x100001CA` (36×252 = 7 slots) currently renders **only the actual side bags** -(via `InventoryController.Populate`'s `isBag → _containerList` branch), so a character with one bag -shows one lone cell. Retail shows the bag(s) **plus empty slot frames** filling the column. - -- `InventoryController.Populate`: after adding the player's side bags to `_containerList`, **pad with - empty `UiItemSlot`s** up to the slot count. Slot count = the column capacity (252 / cellPitch = 7), - optionally clamped to the player's `ContainersCapacity` when known (> 0); default to the 7-slot - column height otherwise. Empty cells draw the empty-slot art already (the toolbar empty-slot path). -- **Cell pitch = 36** for `_containerList` + `_topContainer` (match `0x100001C9`/`0x100001CA`'s 36px - geometry), not the 32px used for the contents grid. (Today the controller sets `CellPx = 32` for all - three — split the contents-grid pitch (32) from the backpack-column pitch (36).) - -**Acceptance:** the side-bag column shows the character's bag(s) in the top slot(s) + empty frames -below, a clean column like retail's. - ---- - -## 4. Design decisions - -- **Reuse `UiScrollable`, don't fork it.** It already models content/view/offset + thumb ratios and is - battle-tested by the chat transcript. `UiItemList` becomes a second consumer. (DRY; matches the - widget-generalization ethos.) -- **Scroll by whole rows vs. pixels:** scroll by **pixels** (smooth), clamped so the last row aligns to - the view bottom — matches the transcript's pixel scroll and avoids a janky row-snap. (Plan may choose - row-snap if the dat/retail behavior says so; default pixels.) -- **Backdrop fix is gated on the post-A screenshot** (decision §B) — don't pre-change the Alphablend - draw before confirming A didn't already fix it; avoids touching shared `UiDatElement` render for - every window without cause. -- **Side-bag slot count from `ContainersCapacity` when known, else 7** — the column is physically 7 - slots; clamping to capacity matches retail (you see only as many slots as you can use) without a wire - dependency (capacity ships in the weenie header, already parsed). - ---- - -## 5. Testing - -- **`UiItemList` clip/scroll (unit, App.Tests):** content-height for N cells; the visible-cell index - range at scroll offset 0 and at max offset; wheel delta updates the offset (clamped); `UiScrollable` - thumb/position ratios for a known content/view. (No GL — assert the model + which cells are in-view.) -- **`InventoryController` (unit, App.Tests, `BuildLayout` harness):** the side-bag column renders - `bags + empties = slotCount` cells (filled first, then empty); the contents grid is bound to the - scrollbar (the `0x100001C7` widget's scroll model is the grid's). A >18-item grid reports content - height > view height (scrollable). -- **Backdrop:** visual gate only (no unit test). -- Full `dotnet build` + `dotnet test` green; existing inventory tests (incl. the B-Wire burden tests) - unbroken. - ---- - -## 6. Divergence register - -- If the side-bag slot count uses a fallback (7) when `ContainersCapacity` is absent, add a row noting - the approximation. If B's residual fix stretches the Alphablend backdrop in a way that differs from - retail's exact draw, add a row. Otherwise no new deviations (faithful 2D layout). Any row lands in the - same commit as its deviation (register rule). - ---- - -## 7. Out of scope (explicit) - -Paperdoll doll + equip-slot silhouettes (Stage 2); drag-to-drop / drag-from-inventory (B-Drag); the -side-bag column scrollbar `0x100001CB` (inert — 7 slots fit); contents-grid drag-reorder; stack-quantity -overlays. - ---- - -## 8. Acceptance + visual gate - -- [ ] Contents grid clips to 3 rows + scrolls (wheel + scrollbar); picked-up items reachable; no - overflow past the frame. -- [ ] Backdrop covers the whole window (no grass through), confirmed at the gate. -- [ ] Side-bag column shows bag(s) + empty slot frames (36px pitch). -- [ ] `dotnet build` + `dotnet test` green. -- [ ] **Visual gate:** open F12 with a full pack → matches retail screenshot 2 (minus the doll): - scrollable grid, solid backdrop, bag-slot column. - -Then Stage 2 (paperdoll) gets its own brainstorm → spec → plan. diff --git a/docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md b/docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md deleted file mode 100644 index f1a3d5e7..00000000 --- a/docs/superpowers/specs/2026-06-21-d2b-inventory-wire-design.md +++ /dev/null @@ -1,272 +0,0 @@ -# D.2b-B B-Wire — inventory wire layer (design / spec) - -**Date:** 2026-06-21 -**Phase:** D.2b inventory, sub-phase **B-Wire** (follows B-Controller, shipped `c38f098`). -**Branch:** `claude/hopeful-maxwell-214a12`. -**Predecessor handoff:** `docs/research/2026-06-21-d2b-bcontroller-shipped-next-handoff.md` §4(a). -**Research oracle:** `docs/research/2026-06-16-inventory-deep-dive.md` §4 (the wire catalog, -with CONFIRMED ACE `file:line` + holtburger fixtures per format). -**Status:** approved design → write plan next. - ---- - -## 1. Goal / non-goals - -**Goal.** Make the inventory **wire layer** retail-faithful and complete: - -1. Deliver the player's **server-authoritative properties** to the player's `ClientObject`, - so the burden bar reads the wire `EncumbranceVal` instead of a client-side estimate. - (Retires divergence **AP-48** and **AP-49**.) -2. Fix two **latent parse bugs** in existing inbound handlers (dropped fields). -3. Add the missing inventory **builders** (C→S) and **parsers** (S→C) so the next - sub-phases (B-Drag, container-open) have their wire ready and tested. - -**Non-goals (deferred to the consuming sub-phase).** The *behavior* that calls these -builders/parsers: drag-release → DropItem/GetAndWieldItem (B-Drag), opening a side-pack / -ground container → ViewContents into a second `UiItemList` (container-open), speculative-move -rollback UI (B-Drag). B-Wire lands wire + parsers + tests + minimal table-apply; it does not -build the UI actions. A parser with no UI consumer yet registers as **parsed-and-applied to the -object table** (or parsed-and-logged where there is genuinely nothing to apply) so the wire is -correct and the later phase only adds behavior. - -**Mandatory workflow (binding on the implementer + any subagent).** Every wire format goes -through **grep-named → cross-ref (ACE + holtburger) → pseudocode → port → conformance test**. -The deep-dive §4 already did the cross-ref and cites the ACE writer `file:line` + holtburger -fixture for each format; re-confirm each against `docs/research/named-retail/` before porting -(the named symbols to anchor on are listed per component below). Do not guess a field order. - ---- - -## 2. Background — the root cause (why the burden bar is wrong today) - -The burden binding is **already correct**: `InventoryController.RefreshBurden` -([InventoryController.cs:215](../../../src/AcDream.App/UI/Layout/InventoryController.cs)) reads -`EncumbranceVal` (PropertyInt 5) from `_objects.Get(playerGuid).Properties.Ints` and only falls -back to `ClientObjectTable.SumCarriedBurden` when it is absent. The value **never arrives**. -Three independent gaps, all confirmed in source: - -1. **Login path.** `GameEventWiring`'s PlayerDescription (`0x0013`) handler - ([GameEventWiring.cs:285](../../../src/AcDream.Core.Net/GameEventWiring.cs)) parses the player's - full PropertyInt table into a `PropertyBundle` (`PlayerDescriptionParser` → `bundle.Ints[5]`) - but **never applies that bundle to the player's `ClientObject`** — it extracts vitals / - attributes / skills / spells / enchantments and records item *membership*, then drops the - player's own property bundle. (The "PD is a membership manifest, not the data source" comment - at line 403 is about *items* — whose weenie data comes from CreateObject — and does **not** - apply to the player's own stats, which legitimately come from PD.) -2. **Live path.** Burden changes (pick up / drop) ride `PrivateUpdatePropertyInt (0x02CD)` — - the player's own object, **no guid on the wire**. acdream does not parse `0x02CD` at all (the - `PublicUpdatePropertyInt` 0x02CE doc-comment says exactly this). -3. **Apply gate.** Even parsed `0x02CE` updates only reach the table when - `Property == UiEffects` — [ObjectTableWiring.cs:28](../../../src/AcDream.Core.Net/ObjectTableWiring.cs) - hard-gates every other int out. - -A **consumer-side** bug compounds this: `InventoryController.Concerns(o)` returns *false* for the -player's own object (its `ContainerId`/`WielderId` are not the player guid), so even once -`EncumbranceVal` updates live, the burden bar would not refresh. Fixed in C1 below. - ---- - -## 3. Components - -All wire code lives in `AcDream.Core.Net` (pure data, no GL). Tests in -`tests/AcDream.Core.Net.Tests` (+ a burden end-to-end test in `tests/AcDream.App.Tests`). - -### C1 — Player-property delivery (the core; retires AP-48/AP-49) - -**C1a — Login delivery.** In the PD handler ([GameEventWiring.cs:285](../../../src/AcDream.Core.Net/GameEventWiring.cs)), -after the existing attribute/skill/membership extraction, apply the parsed player `PropertyBundle` -to the player's `ClientObject`. Requires: -- A new `Func playerGuid` threaded into the GameEvent wiring (the local player's server - guid; `GameWindow._playerServerGuid`, already passed elsewhere). -- A new `ClientObjectTable.UpsertProperties(uint guid, PropertyBundle bundle)` — **create-if-absent - then merge** (PD may arrive before the player's own `CreateObject`; the existing `UpdateProperties` - no-ops on an unknown object, [ClientObjectTable.cs:152](../../../src/AcDream.Core/Items/ClientObjectTable.cs)). - The later `CreateObject` `Ingest` is already a merge-upsert, so either arrival order converges. -- Apply the **whole** bundle (Ints/Floats/Bools/Int64s/Strings/DataIds/InstanceIds), not a - whitelist — the bundle is dictionaries; storing the player's full property set is faithful - (retail keeps them on the ACCWeenieObject) and future-proofs Str/aug/etc. (Decision §4.1.) - -**C1b — Live delivery.** New `PrivateUpdatePropertyInt (0x02CD)` GameMessage parser -(`src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs`, mirroring `PublicUpdatePropertyInt.cs`): -- Wire body: `u32 opcode(0x02CD), u8 seq, u32 property, i32 value` — **no guid** (size 13). - Seq is a single byte (same `ByteSequence` as 0x02CE; not honored, latest-wins, DR-4). -- `WorldSession` dispatches it (new `else if (op == PrivateUpdatePropertyInt.Opcode)` in the - GameMessage switch, [WorldSession.cs:954](../../../src/AcDream.Core.Net/WorldSession.cs)) → - new event `PlayerIntPropertyUpdated(uint Property, int Value)` (no guid — implicitly the player). -- `ObjectTableWiring.Wire` gains the `Func playerGuid` and subscribes - `PlayerIntPropertyUpdated` → `table.UpdateIntProperty(playerGuid(), property, value)`. -- Anchor: named-retail `ACCWeenieObject` int-property apply (the client-side `0x02CD` consumer); - ACE writer `GameMessagePrivateUpdatePropertyInt`. - -**C1c — Apply gate.** Loosen [ObjectTableWiring.cs:26-30](../../../src/AcDream.Core.Net/ObjectTableWiring.cs) -so the `0x02CE` (`ObjectIntPropertyUpdated`) path applies **all** ints via -`table.UpdateIntProperty(u.Guid, u.Property, u.Value)` (which stores in the bundle and still -mirrors UiEffects→`Effects`). (Decision §4.2.) - -**C1d — Consumer refresh.** In `InventoryController.Concerns(o)` -([InventoryController.cs:115](../../../src/AcDream.App/UI/Layout/InventoryController.cs)) also return -true when `o.ObjectId == playerGuid` so a live player-property update refreshes the burden bar. -(The burden source IS the player object; today it is excluded.) - -### C2 — Latent-bug fixes (existing inbound handlers) - -**C2a — `InventoryPutObjInContainer (0x0022)`** GameEvent: read the dropped **4th** field -`containerType` (`u32 itemGuid, u32 containerGuid, u32 placement, u32 containerType`). -`GameEvents.ParsePutObjInContainer` stops after 3 u32s today. Oracle: ACE -`GameEventItemServerSaysContainId.cs` + holtburger `events.rs` (fixture slot=3 type=1). - -**C2b — `InventoryServerSaveFailed (0x00A0)`** GameEvent: read the dropped **`weenieError`** u32 -(`u32 itemGuid, u32 weenieError`). `GameEvents.ParseInventoryServerSaveFailed` reads only the guid. -Oracle: ACE `GameEventInventoryServerSaveFailed.cs` + holtburger `events.rs:147`. - -### C3 — New C→S builders (`InventoryActions.cs`, matching the existing builder style) - -All ride the `0xF7B1` GameAction envelope (`u32 0xF7B1; u32 seq; u32 subOpcode; …`). Anchor each -against ACE `GameAction*/…Handle` + holtburger `inventory/actions.rs` (both cited in deep-dive §4.1). - -| Builder | Opcode | Body | -|---|---|---| -| `BuildDropItem` | `0x001B` | `u32 itemGuid` | -| `BuildGetAndWieldItem` | `0x001A` | `u32 itemGuid, u32 equipMask` | -| `BuildNoLongerViewingContents` | `0x0195` | `u32 containerGuid` | - -Opcode source CONFIRMED: ACE `GameActionType.cs` (`GetAndWieldItem=0x001A, DropItem=0x001B, -NoLongerViewingContents=0x0195`). Add `WorldSession.Send*` wrappers (mirroring -`SendAddShortcut`/`SendRemoveShortcut`) so GameWindow can inject them via `_liveSession?.Send*`. - -### C4 — New S→C parsers - -**C4a — `ViewContents (0x0196)` — GameEvent** (rides `0xF7B0`). New `GameEvents.ParseViewContents` -+ a `GameEventType.ViewContents = 0x0196` entry. Body: `u32 containerGuid, u32 count, -[u32 guid, u32 containerType]×count` (a leading guid then a PackableList). -Oracle: ACE `GameEventViewContents.cs` + named-retail `ClientUISystem::OnViewContents` -(`?OnViewContents@ClientUISystem@@…PackableList`) + holtburger `events.rs`. -No UI consumer yet → parse + apply to the table where meaningful (the listed guids are the -container's contents) or parse-and-log; container-open phase wires it to a second `UiItemList`. - -**C4b — `SetStackSize (0x0197)` — top-level GameMessage** (UIQueue), **not** a GameEvent. New -`src/AcDream.Core.Net/Messages/SetStackSize.cs` + dispatch in `WorldSession`'s GameMessage switch. -Body: `u32 opcode(0x0197), u8 seq, u32 guid, u32 stackSize, u32 value` (size 17 ⇒ byte seq). -Apply via the table (update the object's `StackSize` + `Value`; add a typed -`ClientObjectTable.UpdateStackSize(guid, stackSize, value)` or route through the property path). -Oracle: ACE `GameMessageSetStackSize.cs` (writer) + named-retail -`ACCWeenieObject::ServerSaysSetStackSize` (the client consumer). - -**C4c — `InventoryRemoveObject (0x0024)` — top-level GameMessage** (UIQueue), **not** a GameEvent. -New `src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs` + dispatch in `WorldSession`'s switch. -Body: `u32 opcode(0x0024), u32 guid`. Apply: remove the object from the player's inventory **view**. -Confirm evict-vs-unparent against named-retail `ClientUISystem` + ACE send sites before choosing; -default to the faithful "no longer in my inventory" semantics (unparent from its container + fire -the table's refresh event, so the grid drops the cell) rather than a hard global evict unless the -oracle shows full destruction. Oracle: ACE `GameMessageInventoryRemoveObject.cs`. - -> **Namespace caution.** `SetStackSize 0x0197` / `InventoryRemoveObject 0x0024` are **GameMessage -> opcodes** (top-level `WorldSession` switch, like `0x02CE`/`0xF745`). `ViewContents 0x0196` / -> `InventoryPutObjInContainer 0x0022` are **GameEvent eventTypes** (inside the `0xF7B0` envelope, -> dispatched by `GameEvents.Dispatch`). Adjacent numbers, different dispatchers — do not cross them. - -### C5 — Registration - -- **GameEventWiring.WireAll** ([GameEventWiring.cs](../../../src/AcDream.Core.Net/GameEventWiring.cs)): - register the GameEvent parsers that exist (or are added) but are not dispatched today: - `ViewContents (0x0196)`, `InventoryPutObjectIn3D (0x019A)`, `CloseGroundContainer (0x0052)`, - `InventoryServerSaveFailed (0x00A0)`. Where there is no UI consumer yet, the handler applies to - the table if meaningful, else logs at debug — the registration makes the wire correct so B-Drag - only adds behavior. -- **WorldSession** GameMessage switch: dispatch `PrivateUpdatePropertyInt (0x02CD)`, - `SetStackSize (0x0197)`, `InventoryRemoveObject (0x0024)`. -- **GameWindow** wiring: pass `_playerServerGuid` into `ObjectTableWiring.Wire` and the GameEvent - wiring; inject the new `WorldSession.Send*` builders where the existing shortcut sends are wired. - ---- - -## 4. Design decisions - -### 4.1 Apply the whole player `PropertyBundle` at login (vs. whitelist) -**Chosen: whole bundle via upsert.** The bundle is plain dictionaries; storing the player's full -property set mirrors retail (all live on the ACCWeenieObject) and means future readers (Str, aug, -coin value, etc.) get their value for free without re-touching the PD handler. Whitelisting -EncumbranceVal+aug would be smaller but would re-introduce the same "value never arrives" class of -bug for the next property we need. No downside: the player object is the client's own authoritative -store. - -### 4.2 Loosen the int-apply gate to all ints (vs. extend the whitelist) -**Chosen: apply all ints.** Same reasoning. `UpdateIntProperty` already stores any int in the -bundle and only special-cases UiEffects for the typed mirror; the gate in `ObjectTableWiring` is the -sole thing dropping non-UiEffects ints. Removing it is the faithful behavior (the server is the -authority on object properties). Typed-field mirrors stay opt-in per property. - -### 4.3 Consumer-less parsers register now (vs. defer) -**Chosen: register now, apply-or-log.** The user chose the full wire pass to unblock B-Drag in one -trip. A registered parser that applies to the object table (or logs where there's nothing to apply) -keeps the wire correct and conformance-tested; the consuming phase adds only UI behavior. Any parser -that ends up a pure no-op gets a one-line divergence/TODO note so it isn't mistaken for "wired". - ---- - -## 5. Testing / conformance - -One conformance test per format, golden bytes from the ACE writer / holtburger fixture cited above: - -- `PrivateUpdatePropertyInt.TryParse` — valid 0x02CD (no guid) → (property, value); wrong opcode → - null; truncation → null. -- **Login delivery** — feed a PlayerDescription payload carrying PropertyInt 5 (and aug 0xE6) → - assert the player `ClientObject.Properties.Ints[5]` is set after the handler runs (covers - `UpsertProperties` create-if-absent both orders: PD-before-CreateObject and after). -- **Gate** — a 0x02CE for a non-UiEffects int lands in the target object's bundle. -- `ParsePutObjInContainer` — 4-field read (the 4th = containerType). -- `ParseInventoryServerSaveFailed` — reads guid + weenieError. -- `BuildDropItem` / `BuildGetAndWieldItem` / `BuildNoLongerViewingContents` — exact byte layout - (envelope + body), round-tripped against the ACE handler's expected read. -- `ParseViewContents` — guid + count + N×{guid,containerType}; zero-count edge. -- `SetStackSize` parse — op + byte-seq + guid + stackSize + value (size 17). -- `InventoryRemoveObject` parse — op + guid. -- **Burden end-to-end** (App.Tests) — with the wire `EncumbranceVal` present, `RefreshBurden` uses - it (not `SumCarriedBurden`); a live player-int update fires a burden refresh (C1d). - -All existing tests stay green (App 532 / Core 1526 baseline). `dotnet build` + `dotnet test` green -before the phase is claimed. - ---- - -## 6. Divergence register changes (`docs/architecture/retail-divergence-register.md`) - -- **Retire AP-48** (client-side `SumCarriedBurden` fallback) — burden now reads the wire value. - Keep `SumCarriedBurden` in code as a defensive fallback for "wire value genuinely absent" and - note that in the row's deletion commit; if a residual divergence remains (fallback ever used), - reword the row instead of deleting. -- **Retire AP-49** (carry-aug unwired) — the aug (`0xE6`) now arrives via the PD bundle. -- No new deviations expected (faithful ports). If C4c's evict-vs-unparent choice, or any - consumer-less no-op, ends up an approximation, add its row **in the same commit** per the - register rules. - ---- - -## 7. Out of scope (explicit) - -- Drag-to-drop / drag-to-wield **behavior** (B-Drag wires the C3 builders to drag-release). -- Opening side-packs / ground containers into a second `UiItemList` (container-open wires C4a). -- Speculative-move rollback **UI** (B-Drag; C2b lands the parser only). -- Paperdoll `UiViewport` + per-slot art (Sub-phase C). -- `CreateObject` field extraction — **already done** (`ObjectTableWiring.ToWeenieData` captures - IconId/StackSize/Value/capacities/Burden); the deep-dive's "discards" note is stale. Verify no - field is still `_`-discarded in `CreateObject.TryParse`; extend only if a real gap remains. - ---- - -## 8. Acceptance criteria - -- [ ] Login PD delivers the player's PropertyInt table to the player `ClientObject` (EncumbranceVal - + aug present after login), via `UpsertProperties` (both arrival orders covered by tests). -- [ ] `PrivateUpdatePropertyInt (0x02CD)` parsed + dispatched + applied to the player object; the - apply gate passes all ints. -- [ ] Burden bar reads the wire `EncumbranceVal` and refreshes live on a player-int update (C1d). -- [ ] `0x0022` reads containerType; `0x00A0` reads weenieError. -- [ ] Builders `0x001B` / `0x001A` / `0x0195` added with `WorldSession.Send*` wrappers + byte tests. -- [ ] Parsers `ViewContents 0x0196` / `SetStackSize 0x0197` / `InventoryRemoveObject 0x0024` added, - dispatched in the correct dispatcher, applied-or-logged. -- [ ] `WireAll` registers ViewContents / InventoryPutObjectIn3D / CloseGroundContainer / - InventoryServerSaveFailed. -- [ ] Every AC-specific format cites its named-retail symbol/address + ACE `file:line` in a comment. -- [ ] AP-48 + AP-49 retired in the register; any new approximation has a row. -- [ ] `dotnet build` + `dotnet test` green; existing tests unbroken. diff --git a/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md b/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md deleted file mode 100644 index d646e873..00000000 --- a/docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md +++ /dev/null @@ -1,326 +0,0 @@ -# Design — Retail teleport flow (the unified `TeleportAnimState` controller) - -**Date:** 2026-06-21 -**Feature:** Work item B from `docs/research/2026-06-21-teleport-issues-handoff.md` — the keystone of the -teleport-issues cluster (#138, #145 residual, the floating-camera / input-not-locked / "takes too long" -symptoms). -**Milestone:** M1.5 "Indoor world feels right" (far-town + dungeon round-trips). -**Status:** APPROVED design — ready for implementation plan. -**Decision authority:** user (retail oracle), 2026-06-21. Five binding calls: -1. Visual cover = **full retail TAS** (fade → portal tunnel → fade), not a simplified fade. -2. Readiness gate = **hold outdoor until the physics landblock is loaded** (folds Work item A / #145 residual in). -3. Unification = **all four** entry points (login / logout / death / portal) this pass. -4. Tunnel swirl = **resolve the dat asset + build the literal 3D swirl this pass** (net-new sub-viewport infra). -5. Logout = **animation + `0xF653` + clean disconnect now**; the char-select-return UI is deferred (§9). - -**Relates to:** #145 (the readiness-gate fix subsumes the reopened residual / Work item A), #138 (teleport-OUT -of a dungeon rides the same hold-until-loaded path), Work item C (FPS leak — separate, not addressed here). - ---- - -## 1. Problem - -A teleport in acdream **places the player immediately** on the server-authoritative position, then lets the -world stream in around them. Three user-reported symptoms all trace to that one design choice: - -1. **Camera floats** to the destination — the old camera is snapped to a placed-but-unstreamed spot and the - world materializes around it. -2. **Feels too long / wrong vs retail** — there is no animation pacing the wait; it is a raw snap plus a - visible streaming-in. -3. **Input is not locked** during the transition (yaw still moves — §3.6). - -A fourth, deeper consequence is the **#145 residual / Work item A**: an immediate placement onto a -**not-yet-streamed** landblock near an edge lands the player on a `branch=NO-LANDBLOCK` empty world, where the -per-tick resolve marches the cell label and free-falls Z (the `0xC98C…` stuck-at-`−21684` capture in -`launch5.log`). - -Retail does none of this. It plays a **fade + portal-tunnel animation that COVERS the world-load wait**, holds -input and the camera, **waits until the world has actually loaded**, then places the player at a single exit -point. The same flow drives **login, logout, death, and portal** — it is the client's one "go somewhere" -primitive. Porting it faithfully fixes the float, the timing, the input-lock, **and** the #145 residual (the -hold removes the unstreamed-arrival gap entirely). - -acdream already has the *skeleton*: `PlayerState.PortalSpace` (input freeze) + `TeleportArrivalController` -(hold-until-ready) + `TeleportArrivalRules` (the readiness verdict). What is missing is the **animation / -visual cover**, a **strong enough readiness gate**, and **unification** of the four entry points (which today -duplicate the readiness + recenter logic and, for logout/death, barely exist). - ---- - -## 2. Retail reference (decomp-verified) - -All citations are to `docs/research/named-retail/acclient_2013_pseudo_c.txt` (line ≈ pseudo-C line) and -`acclient.h`, captured by the 5-agent research workflow `wf_f0c07c93-7aa` and spot-verified. - -### 2.1 The `TeleportAnimState` machine - -`enum TeleportAnimState` (`acclient.h:6871`) — 7 states. The machine lives in `gmSmartBoxUI::UseTime` -(pc 0x004d6e30, ≈219400); `BeginTeleportAnimation` (218888) enters it; `EndTeleportAnimation` (218994) -advances a running tunnel to `TUNNEL_CONTINUE`. - -``` -TAS_OFF(0) - → TAS_WORLD_FADE_OUT(1) ~1s fade the world to black - → TAS_TUNNEL_FADE_IN(2) ~1s fade the portal tunnel in - → TAS_TUNNEL(3) HOLD wait for the world to load (the gate) - → TAS_TUNNEL_CONTINUE(4) 2–5s min/max spin-hold after load - → TAS_TUNNEL_FADE_OUT(5) ~1s fade the tunnel out - → TAS_WORLD_FADE_IN(6) ~1s fade the (now-loaded) world in - → TAS_OFF -``` - -**Golden constants** (read directly from the binary at the cited VAs): - -| Constant | Value | VA | Role | -|---|---|---|---| -| `TELEPORT_ANIM_FADE_TIME` | **1.0 s** | 0x007BD278 | duration of each of the 4 fade segments | -| `TELEPORT_ANIM_MIN_CONTINUE_TIME` | **2.0 s** | 0x007BD268 | `TUNNEL_CONTINUE` floor before `FADE_OUT` | -| `TELEPORT_ANIM_MAX_CONTINUE_TIME` | **5.0 s** | 0x007BD270 | forces `FADE_OUT` even if not loaded | -| `TELEPORT_ANIM_FPS` | **40.0** | 0x007BD280 | swirl `CPhysicsObj` animation rate | -| `TRANSITION_VIEW_PLANE_DISTANCE` | **0.001** | 0x007BD260 | the near-plane value that reads as "black" | - -**Timing budget that results (this is "faithful", a floor we cannot shave):** -- Portal / login / **death** (enter at `TUNNEL`): `TUNNEL`(load) + `CONTINUE`(≥2s) + `FADE_OUT`(1s) + - `WORLD_FADE_IN`(1s) ≈ **4 s + load**. -- **Logout** (enters at `WORLD_FADE_OUT`): + `WORLD_FADE_OUT`(1s) + `TUNNEL_FADE_IN`(1s) on the front ≈ **6 s + load**. - -### 2.2 The fade mechanism - -The fade is **not** an alpha quad in retail — it collapses the camera near-plane via -`Render::set_vdst` (≈342121) to `TRANSITION_VIEW_PLANE_DISTANCE` (0.001), which renders the scene black. -Interp: `vdst = lerp(gameVDist, 0.001, GetAnimLevel(t)/1024)` on fade-out and the reverse on fade-in, with -`t = clamp(elapsed / FADE_TIME, 0, 1)`. `UIGlobals::GetAnimLevel` is a table-driven ease curve mapping -`t∈[0,1]` → `int∈[0,1024]` (exact curve unread — see §8). The **visible result is identical to a fade to -black**, which is what acdream will reproduce (§3.3). - -### 2.3 The tunnel swirl - -During the four `TUNNEL*` states the world is hidden (`SmartBox::Hide`) and a **portal viewport is shown**: -a `UIElement_Viewport` (Type 0xD, child id `0x10000436`) hosting a `CPhysicsObj` made from -`GetDIDByEnum(0x10000001, 7)` (the portal setup object), with a distant light, camera at `(0.24, −2.7, 0.88)`, -playing animation `GetDIDByEnum(0x10000002, 7)` at **40 fps**, plus a **randomized camera-direction rotation** -each segment (`CreatureMode::SetCameraDirection_Degrees`, new random end-angle + duration via `RandDouble`). -The "In Portal Space - Please Wait..." string is (re)displayed each time the rotation segment restarts. -The `GetDIDByEnum` ids are runtime EnumIDMap lookups — the concrete dat ids must be resolved by a runtime -trace (§8, the swirl slice's prerequisite). - -### 2.4 The "world has loaded" signal (the hold gate) - -`SmartBox::teleport_in_progress` (pc 90815) `= (player != 0) && (position_update_complete == 0)`. -`TAS_TUNNEL` holds while this is true. `position_update_complete` is **cleared** on receipt of a server -teleport (`SmartBox::HandlePlayerTeleport`, 91200, sets `waiting_for_teleport = 1`); it is **set to 1** in -`SmartBox::UseTime` (94184) once `waiting_for_teleport == 0` — which clears when the player's physics state -settles (`DoSetState`, state bit `0x40` cleared). i.e. retail advances on **state-settled**, not a timer. The -acdream analogue is "the destination collision landblock is resident" (§3.4) — the async-streaming equivalent -of retail's synchronous load. - -### 2.5 Per-entry start state, sounds, lock, exit - -| Entry | Start state | Trigger (decomp) | -|---|---|---| -| **Portal** | `TAS_TUNNEL` (skips world-fade-out) | `teleport_in_progress` 0→1 from the server teleport message (219440) | -| **Login** | `TAS_TUNNEL` | same predicate at first tick; exits fast (`waiting_for_teleport==0`) (85, 219440) | -| **Logout** | `TAS_WORLD_FADE_OUT` (full fade-out first) | `logOffRequested && cur_time ≥ logOffRequestTime` (219415) | - -- **Death is a server teleport**, not a client flag: ACE teleports the corpse-owner to the sanctuary after the - death anim (`Player_Death.cs:247`, `Player_Location.cs:686`) — the client sees an ordinary teleport. So death - rides the **portal** path, entering at `TAS_TUNNEL` (NOT the `logOffRequested` `WORLD_FADE_OUT` path, which is - logout-only — retail shares the `logOffRequested` *code* between logout and self-destruct, but client-observed - death arrives as a teleport). -- **Sounds:** `Sound_UI_EnterPortal` in `BeginTeleportAnimation` (218903); `Sound_UI_ExitPortal` at - `TAS_TUNNEL_FADE_OUT → WORLD_FADE_IN` (219745). (The `0x6A/0x6B` ids in the handoff are **wrong** for our - enum — they map to creature sounds; the real Wave ids come from the dat SoundTable — §8.) -- **Input lock:** `PlayerModule::SetLockUI` sets `options2_` bit `0x1000000` for the whole animation; - `SetTeleportInProgress(1)` also bumps a UI busy-count (`IncrementBusyCount`, 361733). -- **Exit:** at `WORLD_FADE_IN` completion → `SendLoginCompleteNotification` (367516) (sends GameAction `0xA1` - to the server) — fired on **both** initial login and after **every** portal (holtburger confirms: - `messages.rs:464,480`). Gated on the player weenie + `AllContainedObjectsExist`. -- **Camera is NOT frozen by the TAS machine** — the regular `UpdateCamera` keeps running; the "freeze" is just - the world not being drawn during the tunnel. - ---- - -## 3. acdream end-state design - -### 3.1 Components (boundaries) - -Two pure pieces + thin App wiring, mirroring the existing `TeleportArrivalController` (pure hold) + -`TeleportArrivalRules` (pure verdict) split. - -- **`TeleportAnimSequencer`** (`AcDream.Core`, pure, no GL/dat/net — **unit-tested against §2.1 golden - timings**). The 7-state machine. Per `Tick(dt, worldReady, …)` it returns an immutable snapshot: - ```csharp - public readonly record struct TeleportAnimSnapshot( - TeleportAnimState State, - float FadeAlpha, // 0 = clear world, 1 = full black - bool ShowTunnel, // true during TUNNEL_FADE_IN..TUNNEL_FADE_OUT - bool ShowPleaseWait); // true during TUNNEL_CONTINUE - public enum TeleportAnimEvent { None, PlayEnterSound, PlayExitSound, Place, FireLoginComplete } - ``` - `Begin(TeleportEntryKind kind)` sets the start state per §2.5. The sequencer owns the durations/transitions; - it knows nothing about *how* the fade or tunnel are drawn. `worldReady` is supplied by the controller (the - arrival hold). It emits `Place` when leaving `TUNNEL` (world loaded) and `FireLoginComplete` at `OFF`. - -- **`TeleportFlowController`** (`AcDream.App.World`). Owns one `TeleportAnimSequencer` + the existing - `TeleportArrivalController`. Each frame: ask the arrival controller whether the destination is ready, feed - that as `worldReady`, tick the sequencer, then apply the snapshot/events: drive the fade overlay alpha, - show/hide the tunnel viewport, play sounds, hold/clear the input lock, and on `Place` invoke the existing - placement (`PlaceTeleportArrival`), on `FireLoginComplete` send GameAction `0xA1`. It is the **single owner** - of "a teleport is happening"; the four entry points all call into it. - -- **`TeleportFadeOverlay`** (`AcDream.App.UI`) — a `UiRoot` child (last, top-most among retail-UI) that fills - the screen with `(0,0,0,FadeAlpha)` via `TextRenderer.DrawRect` inside the existing `UiHost.Draw` pass - (`GameWindow.cs:8885`). No new GL infra. - -- **`PortalTunnelView`** (`AcDream.App.Rendering`) — **net-new**: an offscreen 3D sub-scene rendering the - resolved swirl `CPhysicsObj` at 40 fps with the randomized camera spin, blitted to the screen during - `ShowTunnel`. This is the heaviest slice and is gated on the asset trace (§7, §8). - -Relationship to the existing controller: `TeleportArrivalController` stays the **pure hold** ("is the world -ready, and place when so"); `TeleportFlowController` is the **visual/timing/lock state machine** wrapped -around it. The arrival controller's "ready" verdict is the sequencer's `worldReady` gate (the -`teleport_in_progress` analogue). - -### 3.2 State machine - -Port §2.1 verbatim: states, the 1.0/2.0/5.0 s constants, the per-entry start states (§2.5). `FadeAlpha` is -`lerp` per §2.2 with `t = clamp(elapsed/FADE_TIME, 0, 1)` and a **smoothstep** ease in place of retail's -`GetAnimLevel` table (§8 — upgrade to the real table if a cdb read resolves it; *register row*). The -`TUNNEL → TUNNEL_CONTINUE` edge fires when `worldReady` first becomes true (retail's `EndTeleportAnimation`); -`MAX_CONTINUE_TIME` forces progress if it never does (the safety net, replacing the current 600-frame timeout -semantics). - -### 3.3 Visual cover - -- **Fade:** the `TeleportFadeOverlay` alpha-black quad (§3.1). *Register row: black-fade via alpha overlay, - not retail's near-plane collapse — visually identical, no camera-projection surgery.* -- **Tunnel swirl:** `PortalTunnelView` (§3.1) rendering the resolved swirl object. Prerequisite: a cdb trace - on a live retail teleport (bp `CPhysicsObj::makeObject`) to capture the concrete `GetDIDByEnum(0x10000001,7)` - / `(0x10000002,7)` dat ids (§8). During `ShowTunnel` the world is not drawn (sequencer drives this), matching - retail `SmartBox::Hide`. -- **"In Portal Space - Please Wait…"** text during `TUNNEL_CONTINUE` (`ShowPleaseWait`), drawn by the - retail-UI text layer. - -### 3.4 Readiness gate — Call-2 (folds in Work item A / #145 residual) - -Today `TeleportArrivalRules.Decide` returns `Ready` for **any outdoor** destination → immediate placement → -the NO-LANDBLOCK gap. The fix: - -- Add `PhysicsEngine.IsLandblockLoaded(uint landblockId) => _landblocks.ContainsKey(landblockId)`. Because - `AddLandblock` registers terrain + collision cells + portals **atomically** (`PhysicsEngine.cs:70`), - `ContainsKey` ⟹ the collision mesh is present and resolvable. -- `TeleportArrivalRules.Decide` gains an `outdoorLandblockReady` input; the outdoor branch returns `Ready` - **iff** the destination landblock is loaded, else `NotReady` (hold). Indoor still gates on - `IsSpawnCellReady` (unchanged). `Impossible` (un-hydratable claim) and the timeout safety-net are unchanged. -- **Why this is safe and self-resolving:** streaming **progresses during the hold** — `_streamingController.Tick` - runs unconditionally with the observer pinned to the destination (`GameWindow.cs:7420-7531`), and the #145 - `RemoveLandblock` of the stale source clears `CurrCell` so the dungeon-streaming gate ExitDungeonExpands to - the destination (`PhysicsEngine.cs:84-95`). The gate flips `Ready` the same frame `AddLandblock` fires - (`_teleportArrival.Tick` runs after the streaming drain — `GameWindow.cs:7551`). The player is therefore - **never** placed onto an empty world → no cell-march, no Z free-fall. The 10 s timeout remains a loud net. -- **Verified-by-apparatus first** (task #4): before relying on this, add a probe that logs the readiness verdict - transition + `_landblocks.Count` and confirms on a `0xC98C…`-style edge arrival that the gate flips `Ready` - *before* placement (pair with `ACDREAM_PROBE_RESOLVE` / `ACDREAM_CAPTURE_RESOLVE`). No guess-patch (the - original #145 burned 5 attempts). - -This is the single change that subsumes Work item A. After it lands, re-test the `launch5.log` scenario. - -### 3.5 Unification + de-dup - -The login auto-entry readiness (`GameWindow.cs:1036-1064`) and the teleport readiness -(`TeleportArrivalReadiness`, `:5419-5438`) are **verbatim duplicates** (the code comment says so). Extract a -single `ComputeArrivalReadiness(destPos, destCell)` used by both sites. The two `_liveCenter` recenters -(login `:2874-2882`, teleport `:5364-5371`) stay in their respective handlers but share one helper. This kills -the drift hazard where #135/#145-class bugs lived. - -### 3.6 Input / camera lock - -PortalSpace already freezes keyboard turns (the `Update` early-return precedes the yaw block). Two yaw leaks -remain (the handoff gotcha), both in the Silk mouse-move handler, bypassing the `Update` guard: -- **MMB mouse-look** (`GameWindow.cs:1098`) → `_mouseLook.ApplyDelta` writes `Yaw`. -- **RMB orbit** (`GameWindow.cs:1120`) → writes `_retailChaseCamera.YawOffset` / `_chaseCamera.YawOffset`. - -Fix: a `State == PortalSpace` early-return at the top of each branch. (No change to PortalSpace logic itself.) - -### 3.7 Sounds - -Resolve the EnterPortal / ExitPortal Wave ids from the dat SoundTable (§8) and play via -`OpenAlAudioEngine.PlayUiWave` (the UI-pool path; the `IAudioEngine.PlayUi(SoundId)` stub is a no-op — do not -use it). Enter on `PlayEnterSound`, exit on `PlayExitSound`. - ---- - -## 4. The four entry points - -| Entry | Start | Trigger in acdream | Scope this pass | -|---|---|---|---| -| **Portal** | `TUNNEL` | `OnTeleportStarted` (0xF751) **or** the large-jump branch of `OnLivePositionUpdated` | Full TAS — the keystone | -| **Death** | `TUNNEL` | server teleport-to-lifestone (same path as portal) | Free — verify it routes through the controller (§8 confirms the trigger) | -| **Login** | `TUNNEL` | the #107 auto-entry place (`EnterPlayerModeNow`) | Unify: route through the controller (tunnel→fade-in on spawn) + the §3.5 de-dup. **Regression-gated: login must stay FPS-steady.** | -| **Logout** | `WORLD_FADE_OUT` | a new "log off" command | Animation + `0xF653` + clean disconnect. **Char-select-return UI deferred (§9).** | - -The controller keys off both the `0xF751` start **and** the existing large-position-jump detection, so a -teleport that arrives only as an `UpdatePosition` (no `0xF751`) still triggers the flow — more robust than -relying on the opcode alone. - ---- - -## 5. Divergence-register rows (added in the implementing commits) - -1. **Fade via alpha overlay, not near-plane collapse** — adaptation; visually identical (§3.3). -2. **`GetAnimLevel` ease approximated as smoothstep** — approximation; retire if the 1024-entry table is - resolved by a cdb read (§8). -3. **Tunnel-swirl camera/rotation parameters approximated** — approximation; the exact `RandDouble` angular - range + the frame-120 exit detail were medium-confidence in the decomp (§8); tune to match a live trace. -4. **Logout returns to disconnect/window-close, not a char-select screen** — stopgap; retired when the - char-select / re-login UI is built (§9). - -(Any row that a later port retires is deleted in the same commit, per the register rules.) - ---- - -## 6. Testing & verification - -- **Unit:** `TeleportAnimSequencer` against §2.1 golden timings — each transition fires at the right elapsed - time; per-entry start states; `worldReady` gating of `TUNNEL`; `MAX_CONTINUE` force; the event sequence - (Enter sound → Place → Exit sound → LoginComplete). -- **Readiness conformance:** `TeleportArrivalRules.Decide` truth table incl. the new `outdoorLandblockReady` - axis (outdoor-not-loaded → `NotReady`; outdoor-loaded → `Ready`; indoor unchanged; impossible → `Impossible`). -- **Apparatus (task #4):** confirm the gate flips `Ready` before placement on an edge arrival (§3.4); re-run - the `launch5.log` `0xC98C` scenario — expect no march, no Z free-fall. -- **Build/test green**, then **visual verification by the user** (the one mandatory stop): portal feel, the - swirl, login spawn, death-to-lifestone, logout fade — does it match retail? - -## 7. Implementation slicing (ordered; writing-plans details these) - -1. **`TeleportAnimSequencer`** (pure Core) + unit tests — no wiring yet. -2. **Readiness gate** (`IsLandblockLoaded` + `Decide` change) + apparatus probe + the `launch5.log` re-test. - *(Lands the #145 residual / Work item A fix independently of the visuals.)* -3. **`TeleportFlowController` + `TeleportFadeOverlay`** — wire portal through the sequencer with the fade - (tunnel states show black until slice 6). Kills the float + input float for portals. -4. **Input/camera yaw freeze** (§3.6) + sounds (§3.7) — resolve Wave ids first. -5. **Unify login + death + logout** (§3.5, §4) — de-dup readiness/recenter; logout command + `0xF653`. -6. **`PortalTunnelView`** (the swirl sub-viewport) — **prerequisite: the asset-id cdb trace (§8).** Slot it - into the tunnel states. Heaviest, asset-dependent; last so everything else ships without it. - -## 8. Open prerequisites / questions (resolve during build, not blocking the spec) - -- **Swirl dat asset ids** — cdb-trace `CPhysicsObj::makeObject` on a live retail teleport to capture the - resolved `GetDIDByEnum(0x10000001,7)` / `(0x10000002,7)` ids. Gates slice 6. -- **EnterPortal / ExitPortal Wave ids** — from the dat SoundTable (the handoff's `0x6A/0x6B` are creature - sounds in our enum). Gates slice 4's audio. -- **`GetAnimLevel` curve** — cdb read of the 1024-entry table, else smoothstep (register row 2). -- **Death trigger confirmation** — confirm ACE sends a teleport (`0xF751`/position-jump) the controller already - catches on death; the large-jump fallback covers it regardless. -- **Tunnel exit detail** — the frame-120 + min/max-continue compound exit was medium-confidence; tune to a live - trace (register row 3). - -## 9. Out of scope (deferred) - -- **Char-select / re-login UI.** acdream has no login or character-select screen — login is a synchronous - auto-select at startup; the only exit today is window-close. Faithful logout-to-char-select requires building - that screen + session-teardown-and-restart + re-entry. This pass builds the logout **animation + `0xF653` + - clean disconnect** only; the return-to-char-select UI is its own future feature (register row 4 tracks the - stopgap). -- **Work item C** (FPS leak after teleports) — separate perf investigation, unaffected by this design. -- **#145 Slices 4–6** (invisible Option-B completeness) — unrelated background work. diff --git a/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md b/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md deleted file mode 100644 index 9d32c455..00000000 --- a/docs/superpowers/specs/2026-06-22-d2b-container-switching-design.md +++ /dev/null @@ -1,151 +0,0 @@ -# D.2b inventory container-switching — design - -**Date:** 2026-06-22 -**Branch:** `claude/hopeful-maxwell-214a12` -**Phase:** D.2b retail-UI inventory arc — container-switching (the first inventory feature with a live two-way wire round-trip). -**Brainstorm:** this doc. Handoff that scoped it: `docs/research/2026-06-22-container-switching-handoff.md`. SSOT: `claude-memory/project_d2b_retail_ui.md`. - ---- - -## Goal - -Click a side bag in the inventory's pack-selector column → the contents grid shows **that bag's** contents (opened server-side via a `Use → ViewContents` round-trip), with the open bag marked by the retail **open-container triangle** and the selected cell marked by the retail **selected-item square**. Click the main-pack cell → back to the main pack. - -This is the first inventory feature that **talks to the server and consumes the reply** (a `Use 0x0036` → `ViewContents 0x0196` round-trip), a step up from the read-only display work so far. - -## Scope - -**In:** -1. `_openContainer` state in `InventoryController`; the contents grid shows `GetContents(_openContainer)`. -2. Click a side-bag cell → open it: `SendUse(bagGuid)`; on switching away from a previously-open side bag, `SendNoLongerViewingContents(prevGuid)`. -3. `ViewContents 0x0196` consumed as a **full REPLACE** of the container's membership (consumes the `GameEventWiring.cs:256` TODO), not the current additive merge. -4. Click the main-pack cell (`0x100001C9`) → `_openContainer = player`; the grid shows the main pack again. -5. **Open-container triangle** `0x06005D9C` on the cell whose `ItemId == _openContainer`. -6. **Selected-item square** `0x06004D21` on the cell whose `ItemId == _selectedItem` — panel-wide (one selection across grid + column), applied **uniformly** to grid items AND bag cells. **Visual-only** this phase. -7. Caption `0x100001C5` follows the open container ("Contents of Backpack" → "Contents of "). - -**Out (deferred, with reasons):** -- **Wiring the green square to the selected-object bar** (toolbar name/info strip) or to global 3D-world selection — the square is *visual-only* this phase. The selection→selected-object-bar integration is its own follow-up (it's a selection-system concern, not container-switching). -- **Preferred-pack auto-fit** (opening a pack makes it the auto-add target). Noted in `reference_retail_inventory_paperdoll`; out of the display-only first cut. -- **Drag INTO a side bag** (that's B-Drag) and **double-click-to-use** an inventory item (interaction work). -- The side-bag column scrollbar `0x100001CB` (inert — 7 fit). - -## Retail anchors (decomp + wire, all verified this session) - -| Concern | Retail anchor | Fact | -|---|---|---| -| Open a container | `Use 0x0036` (`InteractRequests.BuildUse`) | Use-on-container opens it server-side; ACE replies `ViewContents`. | -| Contents reply | `GameEventViewContents` (ACE) | `containerGuid, count, [guid, containerType]×count`. Entries written `OrderBy(PlacementPosition)` — **no explicit slot field; list order encodes the slot.** Our `ParseViewContents` (8 bytes/entry) is correct. | -| Close a view | `NoLongerViewingContents 0x0195` (`WorldSession.SendNoLongerViewingContents`) | Sent when switching away from an open side bag. | -| Open-container indicator | `UIElement_ItemList::UpdateOpenContainerIndicator` `0x004e3070` → `SetOpenContainerState` `0x004e1200` | shows element `0x10000450` (sprite `0x06005D9C`) on the cell where `item.itemID == openContainerId`. | -| Selected-item indicator | `UIElement_ItemList::ItemList_SetSelectedItem` `0x004e2fe0` → `SetSelectedState` `0x004e1240` | shows element `0x10000342` (sprite `0x06004D21`, pixel-confirmed green/yellow frame) on the cell where `item.itemID == selectedItemId`. Uniform across item + container cells. | - -**Indicator reconciliation (user-confirmed retail model — axiom):** the two indicators are orthogonal and can co-occur on one cell. Triangle = "this is the *open* backpack (its contents fill the grid)". Square = "this item is *selected*" — a backpack is just an item, so a clicked bag gets both. Side-bag cells use the 36×36 container prototype `0x1000033F` (triangle child only, no square child) — so the square is drawn as a procedural overlay (see Components / divergence). - -## Components - -### 1. `ClientObjectTable.ReplaceContents` (Core — new) - -``` -public void ReplaceContents(uint containerId, IReadOnlyList guids) -``` - -Full-replace the container's membership so `GetContents(containerId)` returns exactly `guids` in order: -- **Detach** each current member NOT in the new set: `ContainerId = 0`, `Reindex`, fire `ObjectMoved(o, containerId, 0)`. (It left the container server-side.) -- **Record** each new guid in order: create-if-absent, set `ContainerId = containerId` and `ContainerSlot = index` (reconstructs ACE's `PlacementPosition`), `Reindex`, fire `ObjectAdded` (new) or `ObjectMoved` (existing). containerType is not needed by the grid (dropped). - -Mirrors the existing `RecordMembership`/`Reindex` machinery; the only new behavior is the authoritative-snapshot semantics (flush-then-record). - -### 2. `GameEventWiring` ViewContents handler (Core.Net) - -Replace the additive `foreach … RecordMembership` (`:260`–`:266`) with one `items.ReplaceContents(p.Value.ContainerGuid, [entry.Guid …])`. Delete the `:256` TODO. This is the authoritative full snapshot per ACE. - -### 3. `WorldSession.SendUse` (Core.Net — new) - -``` -public void SendUse(uint targetGuid) // NextGameActionSequence + BuildUse + SendGameAction -``` - -A thin wire wrapper (mirrors `SendAddShortcut` etc.). Distinct from `GameWindow.SendUse` (the world-interaction path with autowalk/close-range hold) — opening a pack in your own inventory needs no movement, just the wire. - -### 4. `UiItemSlot` overlays (App) - -Two new procedural overlays in `OnDraw`, modeled exactly on the existing `DragAccept` overlay (resolve via `SpriteResolve`, **guard `id != 0` before resolving** per `feedback_ui_resolve_zero_magenta`): -- `bool IsOpenContainer` + `uint OpenContainerSprite = 0x06005D9C` → triangle. -- `bool Selected` + `uint SelectedSprite = 0x06004D21` → square. - -Draw order in `OnDraw`: icon/empty → digits → open-container triangle → selected square → drag-accept (transient, stays on top). `UiItemSlot` is already a behavioral leaf that paints DragAccept/digits procedurally, so drawing these procedurally (rather than via prototype state elements) is consistent — and it lets the square render on a 36×36 bag cell whose container prototype lacks the `m_elem_Icon_Selected` child. - -### 5. `InventoryController` (App) - -- **State:** `uint _openContainer` (default `= _playerGuid()`), `uint _selectedItem` (default 0). -- **`Populate` split:** - - Side-bag column (`_containerList`) + main-pack cell (`_topContainer`): always the **player's** bags (constant across switches; only indicators move). Unchanged logic + padding (AP-52). - - Contents grid (`_contentsGrid`): the **open container's** loose items — `GetContents(_openContainer)`, `if (isBag) continue` (bags live in the column; a side bag has no sub-bags so this is a no-op when a bag is open), equipped excluded. Pad to the open container's `ItemsCapacity` (player 102 / side bag 24). - - Caption `0x100001C5`: `"Contents of " + (_openContainer == player ? "Backpack" : Get(_openContainer)?.Name ?? "Backpack")`. - - Ends with `ApplyIndicators()`. -- **Click wiring** (set per-cell in `Populate`, the loop knows the cell's role): - - Grid item cell → `SelectItem(guid)`: `_selectedItem = guid`; `ApplyIndicators()` (square moves; no wire, no repopulate). - - Container cell (side bag or main-pack) → `OpenContainer(guid)`: - - `_selectedItem = guid` (the bag is also selected). - - If `guid != _openContainer`: if the *previous* `_openContainer` was a side bag (`!= player && != 0`), `sendNoLongerViewing(prev)`; set `_openContainer = guid`; if `guid != player`, `sendUse(guid)`; `Populate()` (repaint grid for the new container + indicators). Else (same container) `ApplyIndicators()`. - - Empty cell (`ItemId == 0`) → no-op. -- **`ApplyIndicators()`:** for every cell in the three lists, `cell.Selected = ItemId != 0 && ItemId == _selectedItem`; `cell.IsOpenContainer = ItemId != 0 && ItemId == _openContainer`. (Light: toggles bools on existing cells; no Flush.) -- **`Concerns`** += `|| o.ContainerId == _openContainer` — so the `ViewContents`-driven membership changes (which fire `ObjectAdded`/`Moved` for the open container's items) trigger a `Populate()` that fills the grid when the reply lands. - -### 6. `GameWindow` wiring (App) - -`InventoryController.Bind` gains two optional callbacks (default null, so existing tests/callers compile): -- `sendUse: g => _liveSession?.SendUse(g)` -- `sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g)` - -Wired at the existing bind site (`GameWindow.cs:2231`), mirroring the toolbar's `sendAddShortcut`/`sendRemoveShortcut`. - -## Data flow - -``` -click side-bag cell - → _selectedItem = bag (square), _openContainer = bag (triangle) - → switching from another side bag? SendNoLongerViewingContents(prev) [0x0195] - → SendUse(bag) [0x0036] - → Populate(): grid = GetContents(bag) (EMPTY initially) + indicators painted - ───────────── server ───────────── - → ViewContents(bag, [items]) [0x0196] - → ReplaceContents(bag, guids) → ObjectAdded/Moved - → Concerns(o.ContainerId == _openContainer) → Populate() → grid fills -``` - -Robust to lazy-load either way: if the bag's contents were already known, the grid fills on the first `Populate()`; if they arrive only on open (the expected retail behavior), the `Concerns` extension fills it when `ViewContents` lands. - -## Divergence register - -- **Retire AP-56** (both indicators now drawn) and the container-switch clause of **AP-53** (side-pack contents now shown; keep/reword the capacity-default part). -- **Add** (same commit): - - The open-container triangle + selected-item square are drawn as **procedural `UiItemSlot` overlays** keyed by controller state (`_openContainer`/`_selectedItem`), reproducing the retail *keying* (`item.itemID == openContainerId / selectedItemId`) but not via the dat prototype's `m_elem_Icon_OpenContainer`/`m_elem_Icon_Selected` state elements + `SetOpenContainerState`/`SetSelectedState` calls. Lets the square render on the 36×36 container cell (whose prototype lacks the square child). Outcome matches retail. - - Inventory item **selection is panel-local and visual-only** — not wired to the selected-object bar (`SelectedObjectController`) or to global 3D-world selection. (Deferred to the selection follow-up.) - -## Test plan - -- **Core (`ClientObjectTable.Tests`):** `ReplaceContents` — records a fresh list in order; a second replace with a *smaller* list detaches the dropped guids (`GetContents` shrinks, dropped items `ContainerId == 0`); ordering follows list index. -- **Core.Net (`GameEventWiring`/parse tests):** `ViewContents` → full replace (a second smaller `ViewContents` shrinks membership — proves it's not the old additive merge). -- **App (`InventoryControllerTests`):** extend the existing fake-layout harness (`BuildLayout`/`Bind`) — add optional `sendUse`/`sendNoLongerViewing` capture lambdas. Cases: - - default `_openContainer` = player; grid shows player contents (existing tests still pass). - - click a side-bag cell → `sendUse(bag)` fired; grid shows `GetContents(bag)`; caption follows the bag name. - - switch bag A → bag B → `sendNoLongerViewing(A)` then `sendUse(B)`. - - click main-pack cell after a bag → `sendNoLongerViewing(bag)`, **no** `sendUse`; grid back to player. - - indicators: `IsOpenContainer` on the open cell, `Selected` on the selected cell; select-only grid click moves the square, leaves `_openContainer` + grid unchanged, sends no wire. -- **App (`UiItemSlot`):** overlay state — `Selected`/`IsOpenContainer` gate the draw; `id != 0` guard. - -Run the **full** suite at the phase boundary (not just filtered batches — the B-Wire process lesson). - -## Acceptance criteria - -- [ ] Decomp anchors cited in code comments (the `0x004exxxx` functions + sprite ids + the ACE wire ref). -- [ ] Divergence register updated in the same commit (AP-56 retired, AP-53 reworded, new rows added). -- [ ] `dotnet build` + full `dotnet test` green. -- [ ] **Visual gate (the oracle):** F12 → click a side bag → grid swaps to its contents; the open bag shows the triangle; the clicked cell shows the green/yellow square; click another bag → grid + indicators follow; click the main-pack cell → back to the main pack. WireMCP capture of `127.0.0.1:9000` confirms the `Use 0x0036 → ViewContents 0x0196` round-trip (and settles lazy-load-on-open). -- [ ] SSOT (`project_d2b_retail_ui.md`) + roadmap updated. - -## Open verification (at the live gate, not a blocker) - -Whether a side bag's contents are pre-known or arrive only on open — confirmed by the WireMCP/log capture at the gate. The design works either way; this just documents the observed behavior + retires the handoff's open question. diff --git a/docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md b/docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md deleted file mode 100644 index c8bedd75..00000000 --- a/docs/superpowers/specs/2026-06-22-d2b-empty-slot-art-design.md +++ /dev/null @@ -1,145 +0,0 @@ -# D.2b — Inventory empty-slot art: faithful cell-template resolution (design) - -**Date:** 2026-06-22 -**Status:** DESIGN — approach + scope approved in the 2026-06-22 brainstorm. No code yet. -**Branch:** `claude/hopeful-maxwell-214a12` (tip after the inventory window-finish Stage 1). -**Closes (inventory portion):** the OPEN issue *"Inventory + equipment slots show the wrong empty-slot background art"* (`docs/ISSUES.md`). The paperdoll equip silhouettes stay Sub-phase C. -**Line numbers drift — grep the symbol.** - ---- - -## 1. Problem - -At the inventory window's visual gate the empty cells in the **contents grid** (`0x100001C6`) and the **side-bag column** (`0x100001CA`) render the wrong background — the generic toolbar empty square — instead of the retail pack-slot art. - -The handoff framed the fix as "swap the toolbar's `0x060074CF` for template `0x21000037`'s empty state." Investigation showed that framing is malformed: - -- **`0x060074CF` is not "the toolbar's sprite"** — it is the *generic* `ItemSlot_Empty` shared by many `0x21000037` catalog elements (the toolbar shortcut prototypes derive from base `0x1000045C`/`0x10000445` and use it). -- **`0x21000037` is a catalog of ~50 per-slot-kind empty backgrounds**, not "a template with an empty state": the generic square, ~25 equip-slot silhouettes (`0x06006Dxx`/`0x06000Fxx`/…), a numbered group, and standalone `UIElement_UIItem` (class `0x10000032`) cell **prototypes**. -- **The dat doesn't say which prototype a list uses.** The itemlist base `0x2100003D` (element `0x10000339`) and the contents grid `0x100001C6` are bare 32×32 containers; the cell art is bound at *runtime* by `UIElement_ItemList`. -- **acdream synthesizes bare cells** (`new UiItemSlot` with the hardcoded `EmptySprite = 0x060074CF`), bypassing retail's per-list cell-template inheritance entirely. *That* is the mechanism gap. - -## 2. The retail mechanism (verified against the named decomp) - -Every cell — empty or filled — is created by `UIElement_ItemList::InternalCreateItem` (decomp `004e3570`, named-retail line 231486; verified, not agent-reported). The cache-miss path: - -```c -eax_1 = UIElement::GetAttribute_Enum(this, 0x1000000e, &this_1); // 231517: cell-template ELEMENT ID, read off THIS list's ElementDesc -... var_18=0x10000038; var_14=5; var_10_3=0x23 ... // 231518-231520 -eax_2 = DBObj::GetByEnum(0x10000038, 5, 0x23); // the shared UIItem-catalog LayoutDesc (= 0x21000037, strong inference) -eax_3 = UIElementManager::CreateChildElement(s_pInstance, this, eax_2 /*catalog*/, this_1 /*element id*/); // 231526: clone that prototype -return eax_3->DynamicCast(0x10000032); // 231534: a UIElement_UIItem cell -``` - -`ItemList_AddEmptySlot` (`004e36b0`) → `InternalCreateItem` → `UIItem_SetState(0x1000001c)` (= the `ItemSlot_Empty` state). The cloned prototype's baked `ItemSlot_Empty` media **is** the empty-slot background. `gm3DItemsUI::PostInit` (`004a7190`) and `gmBackpackUI::PostInit` (`004a6f70`) only bind the lists (`GetChildRecursive` + `DynamicCast(0x10000031)`); they set **no** cell art. - -**Conclusion:** the empty-slot sprite is *data-driven per list* via attribute **`0x1000000e`** on the list's own ElementDesc → an element in the catalog `0x21000037` → that prototype's `ItemSlot_Empty` (`0x1000001c`) media. acdream's hardcoded `0x060074CF` is wrong for the pack/backpack lists. - -## 3. Goal / non-goals - -**Goal:** the contents grid + side-bag + main-pack empty cells render the correct per-list empty-slot art, **resolved from the dat exactly as retail does** (attribute `0x1000000e` → catalog `0x21000037` prototype's `ItemSlot_Empty`), retiring the hardcoded default for those lists. - -**Non-goals (explicitly out):** -- **Paperdoll equip silhouettes** — Sub-phase C (needs the `0x10000032` `UiItemSlot` registration + the `UiViewport`). This design *lays the groundwork* (the same resolver serves them) but does not wire them. -- **Main-pack backpack *icon*** — AP-51 (the *filled* state of `0x100001C9`), separate. -- **Container switching** — deferred (AP-53). -- **The toolbar** — its hardcoded `0x060074CF` is the *correct* outcome (its `0x1000000e` resolves to a generic prototype); left untouched to avoid regression. The new resolver is reusable for it later. - -## 4. Design - -### 4.1 Components (smallest faithful change) - -| Unit | Change | Why | -|---|---|---| -| `src/AcDream.App/UI/Layout/ItemListCellTemplate.cs` (**new**, static helper) | `ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId) → uint` | The pure dat→sprite resolver. Mirrors the **existing** `0x21000037` read in `GameWindow` (the digit-array block, `GameWindow.cs:1928-1994`) but extracted into a testable helper instead of another inline block (CLAUDE.md rule #1: no new feature bodies in `GameWindow`). | -| `src/AcDream.App/UI/UiItemList.cs` | add `uint CellEmptySprite` property | The list's per-cell empty sprite; applied to every cell. | -| `src/AcDream.App/UI/Layout/InventoryController.cs` | `Bind`/ctor gain the three resolved sprites; set `CellEmptySprite` on `_contentsGrid` / `_containerList` / `_topContainer` | Where the lists are owned. | -| `src/AcDream.App/Rendering/GameWindow.cs` | call the helper for the 3 lists, pass results into `InventoryController.Bind` | Thin wiring only (≤ a dozen lines), mirroring the `ToolbarController.Bind(..., peaceDigits, …)` seam at `GameWindow.cs:2015`. | - -`UiItemSlot.EmptySprite` and its `0x060074CF` default are **unchanged** (still correct for the toolbar). The inventory cells get their value from `UiItemList.CellEmptySprite`. - -### 4.2 `UiItemList.CellEmptySprite` - -```csharp -private uint _cellEmptySprite; -/// Empty-slot sprite for THIS list's cells, resolved from the dat cell template -/// (retail attribute 0x1000000e → catalog 0x21000037 prototype's ItemSlot_Empty). 0 = leave the -/// UiItemSlot default (0x060074CF). Applied to every existing + future cell. -public uint CellEmptySprite -{ - get => _cellEmptySprite; - set { _cellEmptySprite = value; if (value != 0) foreach (var c in _cells) c.EmptySprite = value; } -} -``` -`AddItem` applies it to each new cell: `if (CellEmptySprite != 0) cell.EmptySprite = CellEmptySprite;` (placed with the existing `cell.SpriteResolve ??=` line). Order-independent: the ctor's default cell is re-stamped by the setter when the controller assigns `CellEmptySprite`. - -### 4.3 The resolver (explicit, single-sprite contract) - -`ResolveEmptySprite(dats, listLayoutId, listElementId)`: - -1. `listLd = dats.Get(listLayoutId)`; find `listElem` (id `listElementId`) in `listLd.Elements` (recursively into `Children`). Not found → return `0`. -2. **Read the cell-template id:** `cellProtoId = listElem.StateDesc.Properties[0x1000000e]` as a single id value (`DataIdBaseProperty`/`IntBaseProperty` — exact type confirmed in §6 step 1). Absent on the element → walk the `BaseElement`/`BaseLayoutId` chain once (the itemlist base `0x10000339` is bare, so this just confirms absence). Still absent → return `0` (caller keeps the `UiItemSlot` default; logged). -3. `catalogLd = dats.Get(0x21000037)`; `proto = catalogLd.Elements[cellProtoId]`. Not found → return `0`. -4. **Pick the prototype's single empty-background sprite, in this priority** (explicit — handles both observed prototype shapes): - a. **Frame child** — if `proto` has a child whose `StateDesc` (DirectState) carries a background image (the recessed pack-slot frame; e.g. the 36×36 prototype `0x1000033F`'s child `0x10000450` → `0x06005D9C`), return that `File`. - b. **m_elem_Icon empty** — else find the icon sub-element (id `0x1000033B`, recursively through any inner wrapper such as `0x10000340`) and return its `ItemSlot_Empty` (state `0x1000001c`) `MediaDescImage.File` (e.g. the 32×32 prototype `0x1000033A` → `0x06004D20`). - c. else → return `0`. - -The catalog id `0x21000037` is hardcoded — a **strong inference** (it's the only LayoutDesc that is a catalog of standalone `0x10000032` prototypes; `GetByEnum(0x10000038,5,0x23)` resolves through a master enum-map *dat* object with no code literal). The `0x10000038` enum domain is not wired in acdream. §6 step 1 validates the hardcode (the resolved id must name a real `0x21000037` prototype with a sane sprite). - -### 4.4 Data flow - -``` -GameWindow (composition root, ≤12 lines, under _datLock): - contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000021, 0x100001C6) // gm3DItemsUI contents grid - sideBagEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022, 0x100001CA) // gmBackpackUI side-bag column - mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022, 0x100001C9) // gmBackpackUI main-pack cell - ↓ (named params, mirroring ToolbarController.Bind(..., peaceDigits, …)) -InventoryController.Bind(..., contentsEmpty, sideBagEmpty, mainPackEmpty): - _contentsGrid.CellEmptySprite = contentsEmpty - _containerList.CellEmptySprite = sideBagEmpty - _topContainer.CellEmptySprite = mainPackEmpty - ↓ (UiItemList.AddItem / setter) -each cell.EmptySprite = (per-list resolved sprite) → UiItemSlot.OnDraw draws it when ItemId == 0 -``` - -Each list reads its **own** `0x1000000e` (the contents grid and the backpack lists live in different layouts), so the resolution is per-list-faithful, not a size guess. - -### 4.5 Why GameWindow-resolves rather than the importer - -The fully-automatic alternative — resolve inside `LayoutImporter`/`DatWidgetFactory` so *every* `UiItemList` gets `CellEmptySprite` for free — would touch the `ElementInfo` POCO (which carries an explicit "don't add members without updating consumers" warning), `ElementReader`, and the factory, and would route the **toolbar** through the new path (regression risk on frozen, working art). The chosen design keeps the blast radius to the inventory, puts the logic in one testable helper, and reuses the proven `GameWindow`-reads-`0x21000037` + passes-to-`Bind` pattern. The importer route remains available for Sub-phase C if we later want all lists automatic. - -## 5. Divergence register impact - -- **No new deviation is introduced** — this *ports* the retail resolver for the inventory lists (a deviation retired, not added). -- The **hardcoded empty-slot art was never registered** (a pre-existing gap — a deviation without a row). This change makes it honest by adding **one narrow row**: *"The toolbar's item slots use the hardcoded `UiItemSlot.EmptySprite = 0x060074CF` rather than resolving their cell template via `0x1000000e`; correct in outcome (the toolbar's `0x1000000e` resolves to a generic prototype) but not dat-resolved. Retire when the toolbar is routed through `ItemListCellTemplate`."* (AP-55 or next free id, added in the implementation commit.) -- A second row for the **flat-cell approximation**: *"acdream's `UiItemSlot` is a flat single-sprite cell; for a prototype with both a frame child and an inner icon (the 36×36 backpack `0x1000033F`: frame `0x06005D9C` + inner `0x06000F6E`), only the dominant background (the frame) is drawn — retail clones the full prototype subtree. Revisit at the visual gate / a layered cell if the backpack slots read wrong."* (next free id.) - -## 6. Implementation outline + step-1 validation (this is how we "pin the exact asset") - -1. **Pin the values (first task).** Extend the layout dumper (`AcDream.Cli dump-vitals-layout`, the reflective ElementDesc walker) to surface each element's scalar **attribute/property table** (it currently prints geometry + states + media, omitting `Properties` keys like `0x1000000e`). Dump `0x1000000e` for `0x100001C6` (layout `0x21000021`), `0x100001C9` and `0x100001CA` (layout `0x21000022`). Record: the property type, the resolved prototype id, and that prototype's chosen sprite per §4.3. This **confirms the catalog is `0x21000037`** and **locks the golden test values**. (If `0x1000000e` is absent on a list element, escalate to the cdb fallback: break at `InternalCreateItem` `004e3616`, read arg4 + the catalog DID — per the research report's §"How to verify next".) -2. `ItemListCellTemplate.ResolveEmptySprite` + unit/real-dat tests. -3. `UiItemList.CellEmptySprite` + unit test. -4. `InventoryController.Bind` params + apply; update affected InventoryController tests. -5. `GameWindow` wiring. -6. Divergence rows; ISSUES update; visual gate. - -## 7. Testing - -- **Resolver, real-dat smoke** (`tests/AcDream.App.Tests`, dat-gated like the existing Layout real-dat smokes): `ResolveEmptySprite(dats, 0x21000021, 0x100001C6)` returns **non-zero**, **≠ `0x060074CF`**, and **== the `ItemSlot_Empty` of the prototype named by that element's `0x1000000e`** (structural assertion — no magic literal until step 1 locks it). Same for `0x100001C9`/`0x100001CA` (layout `0x21000022`). -- **`UiItemList.CellEmptySprite`** unit test: assigning it stamps existing **and** subsequently-added cells; `0` leaves the `UiItemSlot` default. -- **`InventoryController`**: after `Bind` with the three sprites, the contents-grid / side-bag / main-pack cells carry the expected `EmptySprite`; the padded empty cells too. -- **Unchanged:** `UiItemSlotTests.DefaultEmptySprite_isToolbarBorder` stays green (the default is still `0x060074CF`). -- `dotnet build` + full suite green (run the **full** suite at the phase boundary — a filtered batch hid a cross-file regression in B-Wire). - -## 8. Acceptance criteria - -- Build + full test suite green. -- The resolver's golden values are pinned from the dat (step 1) and locked by a real-dat test. -- Divergence rows added in the same commit; ISSUES entry moved to *Recently closed* (inventory portion) with the commit SHA. -- **Visual gate (user, F12, `ACDREAM_RETAIL_UI=1`):** the contents-grid and side-bag empty cells show the retail pack-slot background, not the generic toolbar square. The user is the arbiter on whether the 36×36 backpack frame approximation reads correctly; if not, escalate to a layered cell (noted, not in scope now). - -## 9. Open questions (resolved during step 1, not blocking the design) - -- Exact `0x1000000e` property **type** + **value** per list (→ the exact prototypes/sprites). *Resolved by the step-1 dump.* -- Whether the 36×36 backpack cells need both the frame **and** the inner layer. *Resolved at the visual gate; approximation + escalation path recorded as a divergence row.* diff --git a/docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md b/docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md deleted file mode 100644 index c183e0e7..00000000 --- a/docs/superpowers/specs/2026-06-22-d2b-inventory-drag-drop-design.md +++ /dev/null @@ -1,121 +0,0 @@ -# D.2b inventory drag-drop (item moving) — design - -**Date:** 2026-06-22 -**Branch:** `claude/hopeful-maxwell-214a12` -**Phase:** D.2b retail-UI inventory arc — B-Drag (inventory drag SOURCE + drop placement). -**Brainstorm:** this doc. Builds on the shipped drag-drop spine (B.1) + container-switching. - ---- - -## Goal - -Drag an inventory item and drop it to move it, **instantly**: -1. **on an empty grid slot** → the item goes to the **first empty slot** of the open pack (pack-to-front), -2. **on an occupied item** → **insert before** it (the rest shift down), -3. **on a side-bag cell** → the item goes **into that container**, -with a **green insert-arrow** overlay while a valid drop is hovered and a **red circle** when the target container is **full**. - -The move **feels instant**: the grid updates locally the moment you drop, with the server reconciling in the background (and snapping the item back only if it bounces the move). - -## Scope - -**In:** -- Inventory cells as drag **sources** (already: `UiItemSlot.IsDragSource` when occupied, `SourceKind = Inventory`). -- `InventoryController` as the drop **handler** (`IItemListDragHandler`) on the contents grid + the side-bag list. -- The three drop placements (goal 1–3) → `PutItemInContainer 0x0019`. -- **Optimistic local move** on drop (instant repaint) + **rollback** on `InventoryServerSaveFailed`. -- **Accept/reject overlays**: green insert-arrow (valid) / red circle (target bag full). - -**Out (separate work, with reasons):** -- **Equipping via the paperdoll** (`GetAndWieldItem 0x001A`) — Sub-phase C / its own drop path. -- **Drop-to-ground** (`DropItem 0x001B`) — separate interaction. -- **Dragging the side-bag cells themselves** (reordering bags) — bags are drop *targets* here, not sources. -- **Stack split/merge** on drop — deferred (the Selected-Target-Bar split is its own feature). - -## Retail anchors (the spec is a faithful port) - -| Concern | Retail | Note | -|---|---|---| -| Drop placement + accept/reject flags | `UIElement_ItemList::InqDropIconInfo 0x004e26f0` | reads the cell's drop-info properties → placement + `DropItemFlags` | -| Insert-before | `UIElement_ItemList::ItemList_InsertItem` | the dragged item takes the target slot; rest shift | -| Commit the drop | `UIElement_ItemList::HandleDropRelease` | issues the move | -| Local move + server reconcile | `ItemList_InsertItem` (local) + `RecvNotice_ServerSaysMoveItem`/`ServerSaysMoveItem` | retail moves locally then reconciles — this is the "instant + rollback" model | -| Wire | `PutItemInContainer 0x0019` (`item, container, placement`) → ACE `Player.HandleActionPutItemInContainer` | placement = the target slot index (server packs/shifts) | - -## Components - -### 1. `WorldSession.SendPutItemInContainer` (Core.Net — new wrapper) -Thin wrapper over the existing `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` (opcode `0x0019`), mirroring `SendUse`/`SendNoLongerViewingContents`: -``` -public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement) -``` - -### 2. `InventoryController : IItemListDragHandler` (App) -Registered on the contents grid + the side-bag list in `Populate` (`list.RegisterDragHandler(this)`), like `ToolbarController`. Cells already default `SourceKind = Inventory`; set each cell's `SlotIndex` so the drop target knows its position. -- **`OnDragLift` → no-op.** The item **stays in its slot** during the drag (unlike the toolbar's remove-on-lift); it moves only on drop. (Retail dims the source; we leave it in place + the floating ghost — a minor approximation.) -- **`OnDragOver(targetList, targetCell, payload)` → accept/reject overlay (advisory):** - - target is a **grid slot** (the open pack) → **accept** (reorder/insert is always valid in the open container). - - target is a **side-bag cell** → **accept** unless that bag is **known-full** (`GetContents(bag).Count >= ItemsCapacity` when we know the count) → **reject**. A *closed* bag whose count we don't know → **accept** (advisory; the server is authoritative). - - Sets `targetCell.DragAcceptSprite` = the green insert-arrow / `DragRejectSprite` = the red circle (ids §Overlays). -- **`HandleDropRelease(targetList, targetCell, payload)` → optimistic move + wire:** - 1. Resolve `(targetContainer, placement)`: - - grid empty slot → `targetContainer = EffectiveOpen()`, `placement = append` (first empty = end of the packed list). - - grid occupied slot N → `targetContainer = EffectiveOpen()`, `placement = targetCell.SlotIndex` (insert-before). - - side-bag cell → `targetContainer = targetCell.ItemId` (the bag guid), `placement = append`. - 2. If the target is **known-full** (the reject case) → no-op (the red overlay already showed; nothing moves). - 3. Else: record the item's **pre-move** `(ContainerId, ContainerSlot)` in a pending-move map; **`_objects.MoveItem(item, targetContainer, placement)` locally** (instant repaint via `ObjectMoved` → `Concerns` → `Populate`); **`SendPutItemInContainer(item, targetContainer, placement)`**. - -### 3. Optimistic reconcile + rollback — `ClientObjectTable` (Core) -The pending-move tracking lives in **`ClientObjectTable`** (Core), so BOTH the App drop handler and the Core.Net wire handlers reach it (Core.Net can't depend on App): -- `MoveItemOptimistic(itemId, newContainer, newSlot)` — snapshot the item's current `(ContainerId, ContainerSlot)` into a small pending map, then `MoveItem` (fires `ObjectMoved` → instant repaint). Called by `HandleDropRelease`. -- `ConfirmMove(itemId)` — clear the pending entry (the move stuck). -- `RollbackMove(itemId)` — `MoveItem` back to the snapshotted `(container, slot)`, then clear. Returns false if no pending entry. - -Wiring (`GameEventWiring`): -- **Reconcile (success):** the `InventoryPutObjInContainer 0x0022` echo already routes to `MoveItem` (no-op/correction for a move we initiated) — add `items.ConfirmMove(itemGuid)` after it. -- **Rollback (failure):** `InventoryServerSaveFailed 0x00A0` (today it only logs) → `items.RollbackMove(itemGuid)` (snap back). B-Drag is what the B-Wire note reserved this handler for. - -### 4. Overlays (sprites) -Green insert-arrow + red circle, **dat-exported + visual-gate-confirmed** before pinning (per the empty-slot-art / backpack-icon lesson). Candidates from the shipped spine: green move-arrow `0x060011F7`, red ∅ `0x060011F8`, green ring `0x060011F9` (inventory accept). `UiItemSlot` already draws `DragAcceptSprite`/`DragRejectSprite` overlays on DragEnter/Reject — the controller sets the inventory ids. - -## Data flow - -``` -drop item on a target cell - → OnDragOver already showed green (valid) / red (full) - → HandleDropRelease: resolve (container, placement) - known-full? → no-op (red, nothing moves) - else → record pre-move pos - → MoveItem(item, container, placement) LOCALLY → instant repaint - → SendPutItemInContainer(item, container, placement) - ─────────── server ─────────── - → InventoryPutObjInContainer 0x0022 (confirm) → MoveItem (no-op/correct) → clear pending - OR - → InventoryServerSaveFailed 0x00A0 (reject) → MoveItem back to pre-move → clear pending -``` - -## Divergence register -- **`OnDragLift` no-op / source not dimmed** — retail dims the lifted item's source cell; we leave it in place + the floating ghost (minor visual). -- **Closed-bag drop is advisory-accept** — the client can't know a closed bag's fullness (contents aren't indexed until opened), so it shows green + relies on the server's reject + rollback. Retail knows the count if loaded. -- Accept/reject overlay ids set by the controller (procedural), pinned by dat-export (cf. AP-55/57). -- Retire/relax the B-Wire note on `InventoryServerSaveFailed` ("B-Drag wires the rollback") — now wired. - -## Test plan -- **Core.Net:** `SendPutItemInContainer` wire (opcode `0x0019`, item/container/placement) — extend `InteractRequestsTests` (the builder is already covered; assert the wrapper path if a seam exists, else rely on the builder test). -- **App (`InventoryControllerTests`):** the handler — extend the fake-layout harness with a `SendPutItemInContainer` capture. - - drop on empty grid slot → `MoveItem(item, openContainer, append)` locally + wire fired with the open container. - - drop on occupied slot N → wire `placement == N` (insert-before); local grid shows the item at N. - - drop on a side-bag cell → wire `container == bagGuid`. - - `OnDragOver`: grid → accept; full side bag → reject; closed bag → accept. - - rollback: simulate `InventoryServerSaveFailed` → item returns to its pre-move container/slot. -- **Core:** a pending-move rollback unit (record pre-move, restore on failure) if the mover lives in Core. - -## Acceptance -- [ ] Decomp anchors cited; divergence rows added same-commit. -- [ ] `dotnet build` + full `dotnet test` green. -- [ ] **Visual gate (instant):** drag an item onto an empty slot → it lands (first empty) the instant you release; onto an item → inserts before it; onto a side bag → goes in; a full bag shows the red circle and bounces; a valid target shows the green insert-arrow. WireMCP confirms `PutItemInContainer 0x0019` + the `0x0022` echo (and a `0x00A0` bounce on a full bag). -- [ ] SSOT + roadmap updated. - -## Open verification (at the gate) -- Pin the green-arrow / red-circle sprite ids by dat-export + the visual gate. -- Confirm ACE's `placement` interpretation (insert-before-N vs append) against a live capture; adjust the placement mapping if ACE packs differently. diff --git a/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md b/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md deleted file mode 100644 index 212dae14..00000000 --- a/docs/superpowers/specs/2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md +++ /dev/null @@ -1,341 +0,0 @@ -# D.2b Sub-phase C, Slice 1 — Paperdoll equip slots (design) - -**Date:** 2026-06-22 -**Branch:** `claude/hopeful-maxwell-214a12` (tip `702058f`; `main` is a clean ff ancestor) -**Status:** DESIGN — approved in brainstorm; spec under review before the implementation plan. -**Supersedes the stale framing in:** `docs/research/2026-06-22-paperdoll-handoff.md` §"THE WIRE GAP" -(the handoff quoted the 2026-06-16 deep-dive, which pre-dated B-Wire; the wire already exists — see §2). -**Authoritative research:** `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` (§3a element-id→mask, §4 wire). -**Line numbers drift — grep the symbol.** - ---- - -## 1. Goal + scope - -Bind the ~25 mounted paperdoll equip slots (under `0x100001CD` / `gmPaperDollUI 0x21000024`, -already imported + positioned inside the inventory frame) to live equipped-item data and make them -drag-drop **wield**/**unwield** targets. After this slice: you SEE your equipped gear as icons in the -correct doll slots, dragging a wieldable item onto a slot wields it (optimistic + rollback), and -dragging a slot's item to the pack unwields it. - -**This is the lighter, functional half of Sub-phase C.** No 3D doll. - -### Non-goals (Slice 2 or later — do NOT build here) - -- The 3D doll `UIElement_Viewport` (Type `0xD`) + the Core→App `IUiViewportRenderer` seam. -- Part-selection highlight, doll rotation, auto-wield-on-doll-body (drop on the body, not a slot). -- Aetheria sigil slots (`0x10000595/96/97`; `SetVisible(0)` by default in retail — left unbound). -- The richer `InventoryPlacement` priority list (PlayerDescription equipped section, deep-dive §3c) — - the per-item `CurrentlyEquippedLocation` is sufficient for slot icons (each item has one location). -- Dual-wield-into-shield-slot special-case (deep-dive §3b line 174302). - ---- - -## 2. What already exists (reuse, do NOT rebuild) - -Verified against source this session — the handoff's "build the wire gap" premise is **stale**. - -| Capability | Where | Status | -|---|---|---| -| `GetAndWieldItem 0x001A` builder | `InventoryActions.BuildGetAndWieldItem(seq,itemGuid,equipMask)` `InventoryActions.cs:153` (20-byte body) | EXISTS | -| `GetAndWieldItem` sender | `WorldSession.SendGetAndWieldItem(itemGuid,equipMask)` `WorldSession.cs:1205` | EXISTS | -| Unwield wire | `WorldSession.SendPutItemInContainer(item,container,placement)` `:1230` → `BuildPickUp 0x0019` | EXISTS, wired | -| Optimistic move + rollback | `ClientObjectTable.MoveItemOptimistic/ConfirmMove/RollbackMove` + `_pendingMoves` (outstanding-count, I1/I2-hardened) | EXISTS | -| Drag-drop spine | `IItemListDragHandler`; `UiItemSlot.OnEvent` (DragBegin→OnDragLift, DragEnter→OnDragOver, DropReleased→HandleDropRelease) | EXISTS | -| Pattern to mirror | `InventoryController : IItemListDragHandler` `InventoryController.cs` | EXISTS | -| Equip-state per item | `ClientObject.CurrentlyEquippedLocation` + `ValidLocations` (parsed `CreateObject.cs:752-760` → `ObjectTableWiring` → `Ingest`) | EXISTS | -| Wield confirm parse | `WieldObject 0x0023` handler `GameEventWiring.cs:238` → `MoveItem(item,wielder,equip)` | EXISTS (gap: see §5) | -| Slots imported + positioned | inventory frame `GameWindow.cs:2145`; paperdoll mount `PinTopLeft(0x100001CD)` `:2208`; `InventoryController.Bind` `:2231` | EXISTS | -| Transparent empty slot | `UiItemSlot.OnDraw:222` — `EmptySprite != 0` gates the draw; `EmptySprite = 0` ⇒ nothing drawn | EXISTS | - -**Unwield is therefore free:** dragging an equipped item (a valid drag source, `IsDragSource ⇒ ItemId != 0`) -onto the inventory grid already hits `InventoryController.HandleDropRelease` → -`MoveItemOptimistic` (clears equip) + `SendPutItemInContainer`. The PaperdollController only implements **wield**. - ---- - -## 3. The `EquipMask` enum is wrong — correct it first (Core) - -acdream's `EquipMask` (`ClientObject.cs:65`) **diverges from canonical AC** starting at bit `0x2000`. -It invented two phantom bits — `HandArmor = 0x2000` and `FootArmor = 0x10000` — that do not exist in -retail's `INVENTORY_LOC` enum (verbatim header `docs/research/named-retail/acclient.h:3193`), shifting -every slot above `0x1000` out of alignment. It has not bitten yet only because nothing compares against a -**named** mask (`InventoryController` checks `!= None`, numeric-agnostic; wire values are stored as raw -`(EquipMask)uint` casts that preserve the numeric bits). The paperdoll is the first code to compare -against named masks, so it would expose the bug. - -**Wrong vs right (sample):** acdream `0x200000 = RightRing`; retail `0x200000 = SHIELD_LOC`. acdream -`0x800000 = Shield`; retail `0x800000 = MISSILE_AMMO_LOC`. - -### Fix: replace the enum body with the verbatim retail `INVENTORY_LOC` values - -```csharp -[Flags] -public enum EquipMask : uint -{ - None = 0, - HeadWear = 0x00000001, - ChestWear = 0x00000002, - AbdomenWear = 0x00000004, - UpperArmWear = 0x00000008, - LowerArmWear = 0x00000010, - HandWear = 0x00000020, - UpperLegWear = 0x00000040, - LowerLegWear = 0x00000080, - FootWear = 0x00000100, - ChestArmor = 0x00000200, - AbdomenArmor = 0x00000400, - UpperArmArmor = 0x00000800, - LowerArmArmor = 0x00001000, - UpperLegArmor = 0x00002000, // was wrongly 0x4000 (phantom HandArmor at 0x2000) - LowerLegArmor = 0x00004000, // was wrongly 0x8000 - NeckWear = 0x00008000, // acdream had no NeckWear (called it "Necklace" at 0x20000) - WristWearLeft = 0x00010000, // was wrongly "FootArmor" - WristWearRight= 0x00020000, - FingerWearLeft= 0x00040000, - FingerWearRight=0x00080000, - MeleeWeapon = 0x00100000, // was wrongly 0x400000 - Shield = 0x00200000, // was wrongly 0x800000 - MissileWeapon = 0x00400000, - MissileAmmo = 0x00800000, - Held = 0x01000000, - TwoHanded = 0x02000000, // acdream lacked it (had Held here) - TrinketOne = 0x04000000, // was wrongly 0x10000000 - Cloak = 0x08000000, // the ONLY high bit acdream had right - SigilOne = 0x10000000, - SigilTwo = 0x20000000, - SigilThree = 0x40000000, -} -``` - -Removed (no longer exist): `HandArmor`, `FootArmor`, `Necklace`, `LeftBracelet`, `RightBracelet`, -`LeftRing`, `RightRing`, `AetheriaRed/Yellow/Blue`. **Blast radius is safe:** the only references in -the tree are 4 test files using `EquipMask.MeleeWeapon` in **round-trips** (write `(uint)…` to the wire, -parse back, assert equality) — value-agnostic, so they stay green. No test pins a wrong numeric value -against external truth. - -**Anti-regression: a numeric-pin test** (`tests/AcDream.Core.Tests/Items/EquipMaskTests.cs`) asserting the -exact value of every member against `acclient.h:3193` (e.g. `Assert.Equal(0x200000u, (uint)EquipMask.Shield)`). -This converts the "named mask == canonical bit" contract into a hard test so it can never silently drift. - ---- - -## 4. Optimistic wield (Core — `ClientObjectTable`) - -`MoveItemOptimistic` is **unwield-shaped**: it hardcodes `CurrentlyEquippedLocation = None` (`:162`) and -does a gapless container-index insert. Wield is the opposite. Add a sibling. - -### 4a. Extend the pending-move snapshot to remember the pre-move equip location - -Today `_pendingMoves` is `Dictionary`. Extend to -`(uint container, int slot, EquipMask equip, int outstanding)`. Extract a shared private helper: - -```csharp -private void RecordPending(uint itemId, ClientObject item) -{ - if (_pendingMoves.TryGetValue(itemId, out var p)) - _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1); - else - _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, - item.CurrentlyEquippedLocation, 1); -} -``` - -`MoveItemOptimistic` calls `RecordPending` (unchanged behavior — it records `equip` which is `None` for -pack items). `RollbackMove` restores all three via the existing `MoveItem` overload that already takes an -`EquipMask`: - -```csharp -return MoveItem(itemId, pre.container, pre.slot, pre.equip); -``` - -This makes rollback faithful in **both** directions (today an unwield-reject loses the item's slot mask). - -### 4b. `WieldItemOptimistic` - -```csharp -/// Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set -/// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask (matching the -/// server's WieldObject 0x0023 confirm), firing ObjectMoved for an immediate repaint. The caller -/// sends GetAndWieldItem; ConfirmMove (on the 0x0023 echo) / RollbackMove (on 0x00A0) reconcile. -public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask) -{ - if (!_objects.TryGetValue(itemId, out var item)) return false; - RecordPending(itemId, item); - return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask); -} -``` - -`MoveItem` already sets `ContainerId` + `CurrentlyEquippedLocation` + reindexes + fires `ObjectMoved`. -It does **not** write `WielderId` — acdream's `WieldObject 0x0023` confirm is also ContainerId-based (it -never sets `WielderId`), so the optimistic state equals the confirmed state and rollback fully restores -through `MoveItem` alone. (An equipped item is detected as the player's via `ContainerId == player`; -login-equipped items match via `WielderId` from their own CreateObject. Decided at the Task-3 code review.) - ---- - -## 5. Close the wield-confirm gap (Core.Net — `GameEventWiring`) - -The `WieldObject 0x0023` handler (`GameEventWiring.cs:238`) does `MoveItem(item, wielder, equip)` but — -unlike the `InventoryPutObjInContainer` handler at `:251` — does **not** call `ConfirmMove`. Add it so an -optimistic wield's snapshot clears on the server echo (decrement the outstanding count). No-op when nothing -is pending (server-initiated / login wields), so it's safe and unconditional: - -```csharp -items.MoveItem(p.Value.ItemGuid, newContainerId: p.Value.WielderGuid, - newEquipLocation: (EquipMask)p.Value.EquipLoc); -items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler) -``` - -Wield **rollback** rides the existing `InventoryServerSaveFailed 0x00A0` handler (`:280` → -`RollbackMove`) unchanged — verify ACE emits `0x00A0` for `GetAndWieldItem` rejections at the gate (§9). - ---- - -## 6. `PaperdollController` (App — `UI/Layout/PaperdollController.cs`) - -Mirrors `InventoryController`: a `gm*UI::PostInit`-style find-by-id binder that owns the equip slots, -populates them from `ClientObjectTable`, and is their `IItemListDragHandler`. - -### 6a. Element-id → EquipMask map (verified: dump `paperdoll-0x21000024.txt` ↔ deep-dive §3a ↔ `acclient.h:3193`) - -A `static readonly (uint Element, EquipMask Mask)[]` of the 21 functional slots: - -| element | mask | element | mask | -|---|---|---|---| -| `0x100005AB` HeadWear `0x1` | | `0x100005B2` LowerLegArmor `0x4000` | | -| `0x100001E2` ChestWear `0x2` | | `0x100001DA` NeckWear `0x8000` | | -| `0x100001E3` UpperLegWear `0x40` | | `0x100001DB` WristWearLeft `0x10000` | | -| `0x100005B0` HandWear `0x20` | | `0x100001DD` WristWearRight `0x20000` | | -| `0x100005B3` FootWear `0x100` | | `0x100001DC` FingerWearLeft `0x40000` | | -| `0x100005AC` ChestArmor `0x200` | | `0x100001DE` FingerWearRight `0x80000` | | -| `0x100005AD` AbdomenArmor `0x400` | | `0x100001E1` Shield `0x200000` | | -| `0x100005AE` UpperArmArmor `0x800` | | `0x100001E0` MissileAmmo `0x800000` (LIKELY) | | -| `0x100005AF` LowerArmArmor `0x1000` | | `0x100001DF` weapon composite `0x3500000` | | -| `0x100005B1` UpperLegArmor `0x2000` | | `0x1000058E` TrinketOne `0x4000000` | | -| | | `0x100005E9` Cloak `0x8000000` | | - -Weapon composite `0x3500000` = `WEAPON_READY_SLOT_LOC` (`acclient.h:3235`) = -`MeleeWeapon|MissileWeapon|Held|TwoHanded`. `0x100001E0`'s mask is LIKELY (the decomp immediate was -corrupted; inferred MissileAmmo from the gap + neighbors — gate-verify, §9). - -### 6b. Construction / binding - -For each `(element, mask)`: `layout.FindElement(element) as UiItemList`; if non-null: -`list.RegisterDragHandler(this)`; `list.Cell.SourceKind = ItemDragSource.Equipment`; -`list.Cell.SlotIndex = `; **`list.Cell.EmptySprite = 0`** (transparent, per the -brainstorm); `list.Cell.SpriteResolve = …` (the same chrome resolver the factory set). Leave the -`UiItemSlot` **default** accept/reject sprites — the retail `ItemSlot_DragOver_Accept`/`_Reject` -`0x060011F9`/`0x060011F8` (the discrete-slot ring/circle, NOT the inventory grid's insert-arrow -`0x060011F7`, which is for insert-between-cells). Keep a `mask → UiItemList` map for populate (so -`MaskFor(list)` in §6d is the reverse lookup). Subscribe `ObjectAdded/Moved/Removed/Updated`. - -`Bind(layout, objects, playerGuid, iconIds, sendWield)` static factory mirroring `InventoryController.Bind`. - -### 6c. Populate - -One pass over `_objects.Objects`, building `equipped: List<(EquipMask loc, ClientObject item)>` of the -**player's** gear: `o.CurrentlyEquippedLocation != None && (o.WielderId == player || o.ContainerId == player)`. -Then for each slot: find the item whose `(loc & slotMask) != 0` (handles the weapon composite + paired -jewelry — a single equip bit intersects exactly one slot mask). If found, `cell.SetItem(guid, -iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects))`; else -`cell.Clear()` (transparent — nothing drawn). - -`Concerns(o)`: repaint when `o.WielderId == player || o.ContainerId == player` (player-scoped — an NPC's -wielded item carries `CurrentlyEquippedLocation` too and must NOT leak onto the doll; a player-equipped item -always has `WielderId==player` or `ContainerId==player`). `OnObjectMoved` also repaints when from/to is the -player (an item being wielded/unwielded into a side bag). Mirror `InventoryController`'s debounce. - -### 6d. `IItemListDragHandler` - -```csharp -void OnDragLift(...) { } // no-op — item stays until the server confirms (same as inventory) - -bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload payload) -{ - var item = _objects.Get(payload.ObjId); - if (item is null) return false; - return (item.ValidLocations & MaskFor(list)) != EquipMask.None; -} - -void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload payload) -{ - var item = _objects.Get(payload.ObjId); - if (item is null) return; - EquipMask wieldMask = item.ValidLocations & MaskFor(list); // resolves the specific weapon/finger bit - if (wieldMask == EquipMask.None) return; - _objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask); - _sendWield?.Invoke(payload.ObjId, (uint)wieldMask); -} -``` - -`wieldMask = item.ValidLocations & slotMask` is the uniform rule: a head item on the head slot → `0x1`; a -sword on the weapon slot → `MeleeWeapon`; a ring valid in both fingers dropped on the left-finger slot → -`FingerWearLeft`. (Holtburger's `resolve_and_clear_slots` intent.) - ---- - -## 7. Wire it up (App — `GameWindow.cs`) - -At the inventory bind site (`:2231`, right after `InventoryController.Bind`), add: - -```csharp -_paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind( - invLayout, Objects, - playerGuid: () => _playerServerGuid, - iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), - sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask)); -``` - -(`invLayout` already contains the paperdoll subtree via the sub-window mount.) - ---- - -## 8. Testing - -- **Probe (plan task 1 — de-risk):** extend `InventoryFrameImportProbe` to assert every equip-slot id - (`0x100005AB`, `0x100001E1`, `0x100001DF`, …) resolves to a `UiItemList` in the imported tree. High - confidence (the contents grid proves the same `0x2100003D` base path), but load-bearing — if it fails, - fix the importer/factory before continuing. -- **Core — `EquipMaskTests`:** numeric pin of every member vs `acclient.h:3193`. -- **Core — `ClientObjectTableTests`:** `WieldItemOptimistic` sets equip + container (ContainerId-based; does - NOT write WielderId); `RollbackMove` restores the pre-wield equip mask (the new snapshot field); - outstanding-count across wield+move of the same item. -- **Core.Net — `GameEventWiringTests`:** `WieldObject 0x0023` now calls `ConfirmMove` (an optimistic wield's - snapshot clears on the echo). -- **App — `PaperdollControllerTests`:** element-id→mask map matches the dump; populate shows the equipped item - in the right slot (incl. weapon composite + paired finger); `OnDragOver` gates on `ValidLocations`; - `HandleDropRelease` computes the correct `wieldMask`, optimistically equips, and sends `SendGetAndWieldItem`. - Reuse the slot-inside-a-Draggable-frame topology from the B-Drag tests. -- Full suite green at the phase boundary (not just filtered subsets — the B-Wire process lesson). - ---- - -## 9. Divergence register rows (add in the implementing commit) - -- **AP-xx:** `0x100001E0` MissileAmmo `0x800000` mask is LIKELY (corrupted decomp immediate, deep-dive §7) — gate-verify. -- **AP-xx:** dual-wield-into-shield-slot special (deep-dive §3b line 174302) not implemented; a melee weapon - cannot be dropped on the Shield slot in acdream. -- **AP-xx:** wield-reject rollback assumes ACE emits `InventoryServerSaveFailed 0x00A0` for `GetAndWieldItem` - rejections; if it does not, an optimistic wield is corrected only by the next authoritative message — gate-verify via WireMCP. - -(The `EquipMask` correction removes a latent bug rather than adding a deviation — it's locked by the -numeric-pin test, no register row.) - ---- - -## 10. Risks / gate-verify - -1. **Equip slots resolve to `UiItemList`** — mitigated by probe task 1. -2. **ACE wield-reject message** — gate-verify (§9); graceful fallback (next authoritative correction). -3. **`0x100001E0` MissileAmmo mask** — gate-verify; ammo slot is low-priority for MVP. - ---- - -## 11. Acceptance criteria - -- F12 inventory: equipped gear shows as icons in the correct doll slots; empty slots are transparent. -- Drag a wieldable item from the pack onto a matching slot → it wields (icon appears, instant), survives the - server echo, and a wrong-slot/invalid drop is rejected (red) or rolled back. -- Drag an equipped item from a slot to the pack → it unwields (via the existing inventory grid handler). -- `dotnet build` + full `dotnet test` green. Visual gate by the user. Divergence rows added. diff --git a/docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md b/docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md deleted file mode 100644 index 031953d0..00000000 --- a/docs/superpowers/specs/2026-06-22-retail-teleport-residency-tunnel-design.md +++ /dev/null @@ -1,227 +0,0 @@ -# Retail-faithful teleport — priority residency + fade cover — design (2026-06-22) - -**Status:** approved design, pre-implementation. -**Branch:** `claude/thirsty-goldberg-51bb9b`. **Baseline:** `dd2eb8b` (+ the uncommitted `tp-probe` diagnostic). -**Supersedes the framing of:** `docs/research/2026-06-21-teleport-foundation-handoff.md` (the `_datLock`-starvation -hypothesis is **refuted** below by direct measurement). - -> The fix makes the teleport **foundation** fast and correct first, then covers the short transit with a -> retail-style fade. This is explicitly NOT the reverted Slice-2 hold: that band-aided slow streaming; -> this fixes the streaming-apply so the cover is polish over a fast load, not a crutch over a slow one. - ---- - -## 1. The problem, as measured (not hypothesized) - -A live capture (`ACDREAM_PROBE_TELEPORT=1`, four teleports as `+Je`) plus the ACE server log produced -hard numbers that overturn the handoff's central hypothesis. - -**The `tp-probe` timeline for one outdoor teleport (dest landblock `0xA9B4`):** - -| Event | Timestamp (ms) | Δ from placement | Note | -|---|---|---|---| -| `PLACED` | 961809984 | 0 | placed the SAME tick as arrival — on an **empty** world (`lbs=0`) | -| `BUILD 0xA9B4FFFF` | 961810156 | **+172 ms** | worker built it almost immediately; **`waited=0ms`** (no lock contention) | -| `APPLY 0xA9B4FFFF` | 961824328 | **+14,344 ms** | render thread took **14.3 s** to apply it | - -The second outdoor teleport (`0xC6A9`) showed the same shape: `BUILD +250 ms`, `APPLY +10.7 s`. Across the -whole session the worst `_datLock` `waited` was **74 ms** (one login landblock); every teleport-destination -build logged `waited=0ms`. - -**Three distinct root causes (NOT one):** - -1. **`_datLock` starvation — REFUTED.** The worker is never meaningfully blocked (`waited≈0`). The bottleneck - is the render-thread **APPLY**, not the worker BUILD. `BuildLandblockForStreaming` - (`src/AcDream.App/Rendering/GameWindow.cs`, the `lock(_datLock)` at the build site) is fast and uncontended. - -2. **APPLY throughput — the "long transition."** A built landblock sits in the streamer outbox while the - render thread, saturated by the synchronous unbounded CreateObject flood (`WorldSession.Tick`, - `src/AcDream.Core.Net/WorldSession.cs:598-607`, drains the entire inbound queue per frame) and metered to - `MaxCompletionsPerFrame = 4` (`src/AcDream.App/Streaming/StreamingController.cs:90`, applied in - `DrainAndApply`), crawls through it. That is the 10–14 s the user feels. - -3. **Resolve-against-empty-world — the "dropped at the wrong position."** Outdoor teleports place - immediately on an unloaded world (`TeleportArrivalRules.Decide` returns `Ready` for outdoor; - `src/AcDream.App/World/TeleportArrivalController.cs:134-142`), so for the whole 10–14 s window the - per-frame swept-transition cell-march runs against nothing and emits a `result.CellId` with the - landblock-X byte **zeroed** (`0x00B40039` instead of `0xA9B40039`). The outbound encoder trusts that cell - id verbatim (the #107 fix, `src/AcDream.App/Rendering/GameWindow.cs:7794-7807`), so ACE receives a move - naming the wrong landblock and rejects **every** one (`MOVEMENT SPEED` → `failed transition` in the ACE - log). The player is desynced for the entire window. - -4. **`PortalSpace` input-freeze — the "stops at the portal" (separate).** `OnTeleportStarted` sets - `PlayerState.PortalSpace` (`GameWindow.cs:5582`); `PlayerMovementController.Update` early-returns a - zero-movement result (`src/AcDream.App/Input/PlayerMovementController.cs:862-876`), freezing both local - prediction and the outbound send. Independent of streaming. - -**What retail actually does (verified — ACE `Player_Location.cs:679-760`):** place the position immediately, -set `Hidden = true; IgnoreCollisions = true`, poll every 0.1 s until `CurrentLandblock.CreateWorldObjectsCompleted`, -then clear both. No input freeze, no position hold — collision/visibility are suppressed until the -destination is resident, then the player materializes. The retail *client* covers the same interval with the -portal-tunnel animation while physics keeps ticking. - -**Out of scope here (FPS):** the user observed 30–70 fps outdoors. This was a **Debug** build, which is not -representative. The general FPS question is deferred to its own measure-in-Release pass (see §6). - ---- - -## 2. Target experience (approved) - -A **retail tunnel cover**: starting a teleport plays a cover animation that hides the (now-short) load; the -player is "in transit" — not moving in the world — and pops out at the destination once it is resident. -Fidelity for this spec is a **fade cover** (fade-out → hold → fade-in), reusing the dormant -`TeleportAnimSequencer`. The authentic 3D portal-swirl is deferred to a later polish pass. - -This single model resolves all three teleport symptoms at once: -- The transit state means **no movement resolve runs against an empty world** → the cell frame cannot corrupt - (fixes the wrong-position desync). -- The fade **covers** the transit → the awkward freeze-in-place is gone (fixes "stops at the portal"). -- Materialization is gated on the destination being **resident**, which the foundation fix makes fast - (fixes the "long transition"). - ---- - -## 3. Architecture & new sequence - -Replaces "place-immediately + `PortalSpace` freeze." Phases: - -1. **TRANSIT-IN** — `OnTeleportStarted` (`0xF751`) drives `TeleportAnimSequencer` into fade-out → cover. - Input and outbound movement are suppressed *for the duration of transit*, but the fade covers it. - No movement resolve runs. -2. **AIM** — `OnLivePositionUpdated` (destination `UpdatePosition`) drops the stale source landblock, - recenters streaming on the destination, pre-collapses if the destination is a sealed dungeon, and begins - the arrival hold. The destination load is enqueued by the normal streaming `Tick` (unchanged; the probe - confirmed `ENQ` fires correctly during the hold). -3. **PRIORITY-APPLY** — when the destination landblock's completion lands in the streamer outbox, it is - applied immediately (out-of-band of the 4/frame budget), so the player's cell becomes resident in - ~hundreds of ms instead of 10–14 s. -4. **READY** — the arrival readiness flips when the player's **own** destination landblock terrain (+ the - EnvCell struct for an indoor destination) is resident. Outdoor changes from "place immediately" to - "hold-until-terrain-resident." -5. **MATERIALIZE** — `PlaceTeleportArrival` resolves against the now-resident destination (grounds - correctly; never takes the `NO-LANDBLOCK` verbatim branch), seeds the body's (cell, local) frame, - fades in, exits transit → `InWorld`, sends `LoginComplete`. - -### Components - -- **A — Priority-apply** *(`src/AcDream.App/Streaming/StreamingController.cs`, small/surgical)* - `StreamingController` gains a settable "priority landblock id" (the active teleport target). In - `DrainAndApply`, when a priority id is set, its completion is located and applied first/out-of-band so - the player materializes as soon as the worker finishes it (already ~170 ms). The id is cleared on - materialize. No change to the inbound network drain. Apply remains render-thread-only. - -- **B — Arrival controller** *(`src/AcDream.App/World/TeleportArrivalController.cs` + `TeleportArrivalRules`, - exist)* Outdoor readiness changes from unconditional `Ready` to **hold-until-terrain-resident**, using a - **correctly-keyed** residency check (the `EncodeLandblockId` 0xFFFF form — the bug fixed at `c880973` and - then reverted with Slice 2; do NOT reintroduce the `& 0xFFFF0000` raw-key mismatch). The controller drives - the TAS transit and materializes on `Ready`, or loud-fails on `Impossible`/timeout. The ~10 s frame-count - timeout is **retained as a loud safety net** (a real failure signal — worker crash / corrupt dat / OOB - coords) but should now rarely fire because residency is fast. - -- **C — Fade cover** *(`src/AcDream.Core/World/TeleportAnimSequencer.cs`, exists, 29 tests + a render overlay)* - Wire the dormant 7-state TAS to drive a full-screen fade overlay during transit, using its existing - `ComputeFadeAlpha`. The overlay is a render-layer addition (a screen-space quad whose alpha the TAS - drives). The authentic 3D swirl is explicitly NOT built here. - -- **D — Cell-march hardening** *(`src/AcDream.Core/Physics/` — the swept-transition cell-march)* - The swept-transition path must **preserve the body's landblock id** (never emit `lbX=0`) when the swept - position isn't covered by a resident landblock — return the body's seeded (last-known) cell rather than a - partially-derived one. `ResolveCellId` (`PhysicsEngine.cs:330-351`) already preserves its fallback; the - zeroing is in the swept `Transition`/`SpherePath` cell update, whose exact site is identified and fixed - during implementation. Defense-in-depth: with the transit model the player never moves against an empty - world on the normal path, but this also clips the #145 edge-arrival residual. - ---- - -## 4. Why this is not the reverted band-aid - -The reverted Slice-2 hold (`docs/research/2026-06-21-teleport-foundation-handoff.md`) was a band-aid because -(a) the foundation was slow, so it waited ~10 s, and (b) it force-placed on `NO-LANDBLOCK` after the timeout. -This design is different on both counts: - -- **(a) The foundation is fixed.** Priority-apply makes the player's destination resident in ~hundreds of ms - (the worker build is already ~170 ms; the only thing that was slow was the metered render-thread apply, - which we now bypass for the player's own landblock). -- **(b) Placement is on a real, grounded landblock.** Materialization resolves against a resident - destination — never the `NO-LANDBLOCK` verbatim branch — so the cell frame is correct and outbound - movement is accepted by ACE. - -The fade is the retail cover over a now-fast load, which is exactly what the handoff said is legitimate -"(and only then) a retail-style visual cover is polish, not a crutch." - ---- - -## 5. Error handling - -- **Destination never becomes resident** (worker crash / corrupt dat / OOB coords): the frame-count timeout - fires, force-materializes, and logs loudly (it is a genuine failure, not normal flow). With component D, - even a forced placement does not corrupt the outbound frame. -- **Impossible indoor claim** (cell id outside `LandBlockInfo.NumCells`): the existing - `IsSpawnClaimUnhydratable` → `ArrivalReadiness.Impossible` short-circuit is retained. -- **Re-sent server position mid-transit**: `BeginArrival` is server-authoritative and resets the hold - (existing behavior). - ---- - -## 6. Scope — what this spec does NOT do - -- **CreateObject-flood timeslicing (the deferred "#2").** Even after the player materializes onto correct - terrain, the town's *objects* (buildings, NPCs) keep flooding in synchronously, causing post-materialization - pop-in and an FPS sag for a few seconds. Bounding `WorldSession.Tick` per frame is a real improvement that - also helps general FPS, but it touches the inbound-drain path and is not strictly a *teleport* bug. Filed - as a follow-up; **measure in Release first.** -- **The authentic 3D portal-swirl** (fade cover ships now). -- **General outdoor FPS** (30–70 in Debug — re-measure in Release before treating it as a regression/leak; - the AP-48 unbounded-entity-map leak is a separate, already-registered concern). - ---- - -## 7. Testing & acceptance - -**Unit (per layer):** -- Priority-apply ordering — a priority id's completion is applied before the per-frame budget is spent - (`tests/AcDream.Core.Tests/Streaming/`). -- Outdoor + indoor readiness decision — outdoor now holds until terrain-resident; correctly-keyed lookup - (`tests/AcDream.App.Tests/World/` + `tests/AcDream.Core.Tests/`). -- Cell-march landblock preservation — a swept step whose position is over no resident landblock returns the - seeded cell (correct `lbX`), never `lbX=0` (`tests/AcDream.Core.Tests/Physics/`). -- The `TeleportAnimSequencer` keeps its 29 existing tests green. - -**Live acceptance (re-run the `tp-probe`):** -- The destination-LB `APPLY` lands within ~1–2 frames of its `BUILD` (was +10–14 s). -- The ACE log shows **no** `00B4…` / `MOVEMENT SPEED` / `failed transition` lines after a teleport. -- Visual: a covered (faded) transition that pops out onto real, grounded terrain — no run-in-place stutter, - no desync. -- The death→lifestone building keeps its collision (component A makes the dat-static building's landblock - resident before the player materializes; component D closes the cell-resolution residual). - -**Probe lifecycle:** the `tp-probe` (`PhysicsDiagnostics.ProbeTeleportEnabled` + the 5 log points) is the -acceptance apparatus. It stays until the fix is verified, then is removed (it is tagged `REMOVABLE`). If it -proves durably useful, promote it instead of removing — decide at verification time. - ---- - -## 8. Divergence register impact - -- **Retire** the "outdoor teleport places immediately because streaming doesn't progress during a hold" - divergence (the comment + behavior at `TeleportArrivalController.cs:134-142` / `GameWindow.cs:5519-5523`): - the premise was measured false (streaming DOES progress; only APPLY was slow). The new transit-until-resident - model is retail-faithful, so this retires a deviation rather than adding one. -- **Add** a row for the **fade cover instead of the authentic 3D portal-swirl** (an approximation deviation, - sibling to AP-49's fade-curve note). Cite `gmSmartBoxUI` tunnel rendering as the unported mechanism. -- **Add** a row for the deferred CreateObject-flood timeslicing if post-materialization object pop-in is - observed (it is an adaptation/stopgap until #2 lands). - -All register edits land in the same commits as the behavior they describe. - ---- - -## 9. Retail / reference anchors - -- ACE teleport (place + `Hidden`/`IgnoreCollisions` + poll `CreateWorldObjectsCompleted`): - `references/ACE/Source/ACE.Server/WorldObjects/Player_Location.cs:679-760`. -- TAS decomp + the 7-state machine: `docs/research/2026-06-21-teleport-issues-handoff.md`, - `src/AcDream.Core/World/TeleportAnimSequencer.cs`. -- Outbound (cell, position) self-consistency (#107): `src/AcDream.App/Rendering/GameWindow.cs:7783-7807`. -- Streaming threading + apply: `src/AcDream.App/Streaming/StreamingController.cs`, - `src/AcDream.App/Streaming/LandblockStreamer.cs`. diff --git a/docs/superpowers/specs/2026-06-23-cellobject-draw-batching-design.md b/docs/superpowers/specs/2026-06-23-cellobject-draw-batching-design.md deleted file mode 100644 index 15a6b4f3..00000000 --- a/docs/superpowers/specs/2026-06-23-cellobject-draw-batching-design.md +++ /dev/null @@ -1,143 +0,0 @@ -# Spec — batch cell-object draws (dense-town FPS, iteration 1) — 2026-06-23 - -## Context - -The dense-town (Arwic) FPS investigation -([`docs/research/2026-06-23-dense-town-fps-attribution-report.md`](../../research/2026-06-23-dense-town-fps-attribution-report.md)) -proved the frame is **~96% CPU-bound** — measured GPU is **0.5 ms**; the ~8–13 ms -frame is CPU work inside `OnRender`, scaling with how many buildings/cells are in -view. The clean-split + `[CPU-PHASE]` measurement (no glFinish) attributed the CPU -cost of `RetailPViewRenderer.DrawInside`: - -| phase | ms/frame | | -|---|---|---| -| **cellobjects** | **3.3–4.5** | `DrawCellObjectLists` — per-cell static draw + per-cell particles | -| landscape | 2.1–2.9 | sky + terrain + scenery + late draws | -| partition | 0.7–3.2 | entity partition + viewcone build | -| dynamics | 0.6–1.3 | dynamics draw + particles | -| flood / assemble / shells / portalmask | < 0.3 each | NOT the cost (refuted the static guess) | - -`cellobjects` is the biggest single lever. Root cause: `DrawCellObjectLists` calls -`WbDrawDispatcher.Draw` **once per visible cell**, each call orphaning 6 SSBOs via -`glBufferData` + full state setup (`WbDrawDispatcher.cs:1521-1558`). This is the -same class of bug the shipped cells-shell fix solved for shell geometry (94 per-cell -`Render` calls → 1 batched; 29→75 fps). We apply the same pattern to cell objects. - -**Target:** dense town **solidly 144+ fps** facing the densest view (~7 ms budget). -Iterate hotspots, re-measuring after each. This spec is **iteration 1** (cellobjects). - -## Goal - -Collapse the per-cell `WbDrawDispatcher.Draw` calls in `DrawCellObjectLists` into a -**single** batched draw covering all visible cells' surviving statics, while -preserving every rendering invariant (visibility, transparency order, particle -occlusion). Identical pixels, far fewer draw submissions. - -## Design - -`RetailPViewRenderer.DrawCellObjectLists` (`RetailPViewRenderer.cs:772-825`) is -restructured from one per-cell loop (cull → draw → particles) into **two loops**: - -**Loop 1 — cull + accumulate (no GL draws):** -``` -_allCellStatics.Clear(); // reused List scratch field -_cellObjCells.Clear(); // reused HashSet scratch field -for (i = OrderedVisibleCells.Count - 1; i >= 0; i--): // far → near - cellId = OrderedVisibleCells[i] - if (!drawableCells.Contains(cellId)) continue - if (!partition.ByCell.TryGetValue(cellId, out bucket) || bucket.Count == 0) continue - survivorsBefore = _allCellStatics.Count - foreach (e in bucket): - EntitySphere(e, out c, out r) - if (viewcone.SphereVisibleInCell(cellId, c, r)) _allCellStatics.Add(e) - if (_allCellStatics.Count > survivorsBefore) _cellObjCells.Add(cellId) - if (ProbePhantomEnabled) EmitPhantomObjsProbe(cellId, _allCellStatics.Count - survivorsBefore) -``` - -**One batched draw:** -``` -if (_allCellStatics.Count > 0): - UseIndoorMembershipOnlyRouting() - DrawEntityBucket(ctx, _allCellStatics, _cellObjCells) // N WbDispatcher.Draw → 1 -``` - -**Loop 2 — per-cell particles, after statics are in the depth buffer:** -``` -for (i = OrderedVisibleCells.Count - 1; i >= 0; i--): // far → near - cellId = OrderedVisibleCells[i] - if (!drawableCells.Contains(cellId)) continue - if (!partition.ByCell.TryGetValue(cellId, out bucket) || bucket.Count == 0) continue - _cellStaticScratch.Clear() - foreach (e in bucket): - EntitySphere(e, out c, out r) - if (viewcone.SphereVisibleInCell(cellId, c, r)) _cellStaticScratch.Add(e) - if (_cellStaticScratch.Count == 0) continue - foreach (slice in GetCellSlicesOrNoClip(clipAssembly, cellId)): - ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, _cellStaticScratch)) -``` - -New reused scratch fields on `RetailPViewRenderer` (cleared per call, render-thread -only — match the existing `_cellStaticScratch`/`_dynamicsScratch` pattern): -`private readonly List _allCellStatics = new();` and -`private readonly HashSet _cellObjCells = new();`. - -Re-culling in loop 2 is deliberate: it is a handful of sphere-vs-plane dot products -per static and is negligible next to the draw-submission cost being removed; it -avoids storing per-cell survivor ranges. - -## Correctness invariants (must hold) - -1. **Same entity set drawn.** Per-cell, the bucket only ever held that cell's - entities and the filter was `{cellId}`; the batched form passes all survivors - with the *union* `_cellObjCells`, so the dispatcher's `visibleCellIds` membership - gate admits exactly the same set. -2. **Transparency compositing.** `WbDrawDispatcher` sorts opaque front-to-back - (`:1469`) and transparent back-to-front by group distance (`:1470`, - `CompareTransparentSubmissionOrder`). Global batching yields true back-to-front - ordering across cells — equal or better than the old per-cell-bucketed order. - Opaque is z-buffered (order-independent). -3. **Particle occlusion.** Particles depth-test but do not write depth; loop 2 runs - after the batched static draw so same-cell statics already occupy the depth - buffer. (This is the reason for two loops — a single-loop batch would draw - particles before the statics and lose same-cell occlusion.) -4. **Frame order unchanged elsewhere.** Shells, exit-portal masks, and - dynamics-last all keep their positions in `DrawInside`. `lbId`, - `neverCullLandblockId`, `animatedEntityIds` are passed exactly as before. -5. **No double-draw.** Each surviving static is added once to `_allCellStatics`; - the batched draw runs once. - -## Scope - -- **In:** `RetailPViewRenderer.DrawCellObjectLists` only. -- **Out (explicitly):** - - `DrawBuildingLookIns` per-cell draws — interior-root only, does not fire at - outdoor Arwic; an analogous follow-up if a later profile shows it. - - Particle-pass consolidation (`DrawCellParticles` re-walks the particle system - per cell) — separate concern, separate fix. - - `landscape` (sky-submesh batching) and `partition` optimization — iteration 2, - pursued **only if** re-measuring after this fix shows we are not solidly 144+. - - `WbDrawDispatcher` persistent-SSBO change (Approach 3) — back-pocket follow-on - that compounds, if the single remaining draw's SSBO orphaning still shows up. - -## Testing & acceptance - -- `dotnet build` green, `dotnet test` green. Pview ordering / cell-object tests must - stay green — especially `HouseExitWalkReplayTests` and any viewcone/cell-object - coverage. If no test pins cross-cell cell-object batching, add one asserting the - batched survivor set equals the union of per-cell survivor sets. -- **Visual (user, faces dense Arwic):** all statics present (no missing furniture); - no transparency or flame-through-furniture artifacts; FPS up. -- **Perf (re-measure `[CPU-PHASE]` under `ACDREAM_FPS_PROF=2`, facing densest town):** - `cellobjects` drops from ~3.5 ms toward ~0.5 ms; `wall` falls accordingly. Decide - from this whether iteration 2 (`landscape` + `partition`) is needed for solidly-144+. -- Retail-faithfulness: pixels identical; this is a draw-mechanism speed change only - (per the project steer that render-perf is not faithfulness-gated as long as - pixels and game feel are unchanged). No divergence-register row required. - -## References - -- Attribution report: `docs/research/2026-06-23-dense-town-fps-attribution-report.md` -- Precedent (the blessed pattern): the cells-shell batching fix — - `RetailPViewRenderer.DrawEnvCellShells` (`RetailPViewRenderer.cs:664-701`), - commits `3af7d00` / `8067d3b`. -- Dispatcher sort: `WbDrawDispatcher.cs:1469-1470`, `:1750-1756`. diff --git a/docs/superpowers/specs/2026-06-23-d2b-paperdoll-slice2-design.md b/docs/superpowers/specs/2026-06-23-d2b-paperdoll-slice2-design.md deleted file mode 100644 index ed7defbf..00000000 --- a/docs/superpowers/specs/2026-06-23-d2b-paperdoll-slice2-design.md +++ /dev/null @@ -1,251 +0,0 @@ -# D.2b Sub-phase C, Slice 2 — paperdoll 3-D doll + the "Slots" toggle (design) - -**Date:** 2026-06-23 -**Branch:** `claude/hopeful-maxwell-214a12` (Slice 1 shipped; `main` ff-merged to tip). -**Status:** APPROVED design (brainstorm complete). Next: writing-plans → subagent-driven implementation → visual gate. -**Predecessor:** `docs/research/2026-06-23-paperdoll-slice2-handoff.md`; Slice-1 spec `2026-06-22-d2b-paperdoll-slice1-equip-slots-design.md`. -**Read with:** the corrected model in the D.2b SSOT (`claude-memory/project_d2b_retail_ui.md`, the SLICE 1 entry) + `docs/research/2026-06-16-equipment-paperdoll-deep-dive.md` §5. - ---- - -## 1. Goal + corrected model (user axiom) - -The paperdoll panel has **two mutually-exclusive views** toggled by the **"Slots" button** -(`0x100005BE` = `m_SlotCheckbox`): - -- **Doll-view (default, button OFF):** the **live 3-D character** (the "doll" — naked if nothing - equipped) + the **12 non-armor** equip slots. The 9 armor slots are hidden. -- **Slot-view (button ON):** the doll disappears; the **9 armor slots** become visible. - -The "figure" **IS** the live 3-D character (a re-dressed clone of the local player), **not** per-slot -silhouette sprites. Slice 1 already shipped the 21 equip slots + wield/unwield + visible empty-slot -frames, showing all slots at once. **Slice 2 adds (A) the toggle and (B) the 3-D doll viewport on top, -extending — never rewriting — `PaperdollController`.** - -### Scope - -**In:** the Slots toggle; the `UiViewport` (dat Type `0xD`) hosting a re-dressed player clone; -render-to-texture compositing; live re-dress on equip/unequip (ObjDescEvent `0xF625`); fixed camera, -one distant light, heading 191.37°, idle animation. - -**Deferred to polish (NOT this slice):** per-race camera framing (`UpdateForRace`), part-selection -lighting ("which piece is this?" highlight), click-to-rotate, the doll click-map (`m_paperDollDragMask` -interactivity), Aetheria sigil slots. - ---- - -## 2. Decomp anchors (named-retail `acclient_2013_pseudo_c.txt`) — do not re-derive - -| What | Function / addr | Lines | Facts | -|---|---|---|---| -| **The toggle** | `gmPaperDollUI::ListenToElementMessage` `0x004a5c30` | 175674-175706 | `idMessage==1 && idElement==0x100005be`: read checkbox attr `0xe`. **Checked → slot-view:** `m_pPaperDoll.SetVisible(0)`, `m_paperDollDragMask.SetVisible(0)`, then `SetVisible(1)` on the 9 armor slots. **Unchecked → doll-view:** the inverse. | -| **The 9 armor slots** | `gmPaperDollUI::PostInit` `0x004a5360` | 175412-175508 | The exact set `PostInit` calls `SetVisible(0)` on AND the toggle flips: head `0x100005ab`, chest(armor) `0x100005ac`, abdomen `0x100005ad`, upper-arm `0x100005ae`, lower-arm `0x100005af`, hand `0x100005b0`, upper-leg(armor) `0x100005b1`, lower-leg(armor) `0x100005b2`, foot `0x100005b3`. The other 12 slots are **never** hidden at init. | -| **Viewport init** | `gmPaperDollUI::PostInit` | 175509-175535 | Find `0x100001d5` → cast Type `0xd`; `SetCamera(&dir,&pos)`; `SetLight(DISTANT_LIGHT, 2.0, &dir)`; `CreatureMode::UseSharpMode`; `RedressCreature`. | -| **Camera immediates** | `PostInit` | 175521-175527 | cam pos `(0x3df5c28f, 0xc019999a, 0x3f6147ae)` = **(0.12, −2.4, 0.88)**; target/look `(0,0,0)`. (Arg order pos-vs-dir confirmed at impl via `UIElement_Viewport::SetCamera`.) | -| **Light immediates** | `PostInit` | 175529-175533 | dir `(0x3e99999a, 0x3ff33333, 0x3f266666)` = **(0.3, 1.9, 0.65)**; intensity **2.0**; type `DISTANT_LIGHT`. (3rd float recovered from the `"ff&?"` strncpy artifact = `0x3f266666`.) | -| **The checkbox default** | `PostInit` | 175585 | `SetAttribute_Bool(m_SlotCheckbox, 0xe, 0)` → start unchecked = doll-view. | -| **Re-dress** | `gmPaperDollUI::RedressCreature` `0x004a3bc0` | 173997-174012 | Built **once** (lazy): `makeObject(player)` clone → `set_heading(191.367905°)` → `set_sequence_animation(m_didAnimation.id, 1,1,0)` → `CreatureMode::AddObject`. **Every call:** `DoObjDescChangesFromDefault(clone, get_player_visualdesc())` = re-dress with the same ObjDesc apply the in-world renderer uses. | -| **Per-race camera (DEFERRED)** | `gmPaperDollUI::UpdateForRace` `0x004a3ed0` | 174138-174180 | Camera distance varies per body-type: case 6/7 y=`0xc0400000`=−3.0; case 8 y=`0xc059999a`=−3.4, z=`0x3f800000`=1.0; etc. Polish. | - ---- - -## 3. acdream seams (verified against source this session) - -- **Render order:** `GameWindow.OnRender` runs all 3-D world passes, then `_uiHost.Tick()`/`_uiHost.Draw()` - at **GameWindow.cs:9009-9012**. The doll RTT pass slots in after the world passes, before the UI pass. -- **Single-entity draw:** `WbDrawDispatcher.Draw(ICamera camera, IEnumerable<(LandblockId, AabbMin, - AabbMax, IReadOnlyList Entities, IReadOnlyDictionary? AnimatedById)>, - …, HashSet? animatedEntityIds, …)` (WbDrawDispatcher.cs:881). Sets `uViewProjection` from - `camera.View*camera.Projection` (`:894`) and `uLightingMode=0` (Lambert sun + per-entity point sets, - `:898`). **A second pass with a doll camera + a one-entry landblock tuple is supported.** -- **Camera:** `ICamera` is `{ Matrix4x4 View; Matrix4x4 Projection; float Aspect; }` (ICamera.cs:5). - A fixed `DollCamera` impl is trivial. -- **RTT infra exists:** `ManagedGLFramebuffer` (color `ITexture` + Depth24Stencil8 renderbuffer, - completeness-checked, `GpuMemoryTracker`-tracked) and `GLStateScope` (RAII save/restore of viewport, - scissor, depth, cull, blend, program, VAO, FBO bindings, UBO binding 0, …). Both in - `src/AcDream.App/Rendering/Wb/`. -- **Animated-char pipeline:** `EntitySpawnAdapter.OnCreate(WorldEntity)` (EntitySpawnAdapter.cs:100) - requires `ServerGuid != 0`, builds `AnimatedEntityState` via the injected sequencer factory, applies - `HiddenPartsMask` + `PartOverrides`, registers each `MeshRef.GfxObjId` + override GfxObj with the mesh - adapter (so meshes background-load), and stores `_stateByGuid[ServerGuid]`. -- **Player model data is available:** the local player **does** get a fully-resolved `WorldEntity` - (`_entitiesByServerGuid[_playerServerGuid]`, GameWindow.cs:12700) with MeshRefs/PaletteOverride/ - PartOverrides AND an `AnimatedEntityState` (`_animatedEntities[playerEntity.Id]`, `:12805`); its spawn - is cached in `_lastSpawnByGuid[_playerServerGuid]`. **(Corrects the handoff's "the local player isn't a - WorldEntity" — it is.)** The WorldEntity build template is GameWindow.cs:3390-3431. Appearance updates - flow through `OnLiveAppearanceUpdated` (ObjDescEvent `0xF625`, GameWindow.cs:3733). - ---- - -## 4. Design - -### A. The Slots toggle — `PaperdollController` extension - -Extend `PaperdollController` (`src/AcDream.App/UI/Layout/PaperdollController.cs`); do not rewrite the -slot bindings / wield / unwield. - -- **Partition the existing `SlotMap` by element-id** into the decomp's **9 armor** ids - (`0x100005ab/ac/ad/ae/af/b0/b1/b2/b3`) vs the **12 non-armor** ids. (Keyed by element-id, exactly the - set the decomp toggles — NOT an `EquipMask` heuristic; HeadWear/HandWear/FootWear are in the armor set, - ChestWear/UpperLegWear (shirt/pants) are non-armor.) -- Keep refs to the 9 armor-slot `UiItemList`s + the `UiViewport` widget. -- Find the Slots button (`0x100005BE`, a `UiButton`) via `layout.FindElement`. On click, flip - `_slotView` and apply: doll-view → `viewport.Visible=true`, 9 armor `.Visible=false`; slot-view → - `viewport.Visible=false`, 9 armor `.Visible=true`. Non-armor slots untouched. -- Default `_slotView=false` (doll-view), mirroring `SetAttribute_Bool(0xe,0)`. - -**AP-66 resolved:** the Slice-1 empty-slot frame **stays** in both views. The corrected model arranges -the slots *beside* the doll (the viewport is a ~100-px column), so nothing overlays the doll and nothing -flips to transparent. The toggle only changes `Visible`, never the empty-cell art. Reword the AP-66 row. - -### B. The `UiViewport` widget (Type `0xD`) - -New leaf widget `UiViewport : UiElement` (`src/AcDream.App/UI/UiViewport.cs`): - -- Register in `DatWidgetFactory` (`src/AcDream.App/UI/Layout/DatWidgetFactory.cs`, the Type switch): - `0xD => new UiViewport(...)`. `ConsumesDatChildren => true` (leaf). -- Holds a reference to its **doll scene** (a `UiViewportScene` handle, §C) and the latest rendered - color-texture handle. In `OnDraw(UiRenderContext)` it simply **blits that cached handle** as one sprite - at its own `ScreenPosition`/`Width`/`Height`. The 3-D render itself happens in the pre-UI hook (§E), - *not* in `OnDraw` (which only has a 2-D context). Because the blit is an ordinary sprite in the 2-D - pass, it lands in correct painter order (backdrop behind, slots/chrome in front). -- When `Visible == false` (slot-view), it draws nothing and the pre-UI hook skips its 3-D pass. - -### C. RTT path + the `IUiViewportRenderer` seam (the crux) - -The 3-D doll renders into an off-screen buffer in the **pre-UI hook** (GameWindow, after the world -passes, before `_uiHost.Draw`); the `UiViewport` widget then blits the result during the 2-D UI pass. - -**Seam (layering decision — diverges from the handoff's "Core interface", with user approval):** -define `IUiViewportRenderer` in the **`AcDream.App.UI` namespace** (where `UiViewport` lives), -implemented by `PaperdollViewportRenderer` in `AcDream.App.Rendering`. `App.UI` is a namespace **within -the `AcDream.App` project**, not a separate project — so this is an **intra-App decoupling** (keep the UI -widget from depending directly on `WbDrawDispatcher`/`GameWindow`), not a cross-project seam. -Code-Structure Rule 2 keeps **Core** free of GL; Core has no consumer for "render an entity into a rect", -so a Core interface would over-apply the rule. Narrow surface: - -```csharp -// AcDream.App.UI -public interface IUiViewportRenderer -{ - // Renders the scene into an internal FBO sized (w,h); returns the GL color-texture handle. - // Called by the per-frame pre-UI hook, NOT from UiViewport.OnDraw. - uint Render(UiViewportScene scene, int width, int height); -} -``` - -`UiViewportScene` carries the doll entity ref + camera params (plain data; no GL). The pre-UI hook calls -`Render` and stores the returned handle on the widget; `UiViewport.OnDraw` blits that handle (§B). - -**`PaperdollViewportRenderer` (App rendering layer)** owns: -1. A `ManagedGLFramebuffer` sized to the widget rect, recreated when the rect changes. -2. The doll `DollCamera` (`ICamera`): eye `(0.12, −2.4, 0.88)`, look-at origin, perspective with the - rect's aspect. Decode/confirm the exact float→matrix mapping against `UIElement_Viewport::SetCamera`. -3. Per frame, gated on **inventory-open ∧ doll-view**: open a `GLStateScope`; bind the FBO; set viewport - to (w,h); clear color to transparent `(0,0,0,0)` + clear depth; write a **doll lighting UBO** (one - `DISTANT_LIGHT` = retail `(0.3,1.9,0.65)`@2.0, fixed ambient) reusing the scene-lighting UBO builder - (GameWindow ~8289); call `WbDrawDispatcher.Draw(DollCamera, [dollLandblockTuple], - animatedEntityIds:{dollGuid})`; dispose the scope (restores all GL state). Overwriting the shared - lighting UBO is safe — nothing 3-D draws after the doll this frame, and GameWindow rebuilds it next - frame. - -### D. The doll entity — build, re-dress, animate - -The doll is a **dedicated `WorldEntity`** (retail's `makeObject(player)` clone), distinct from the live -player entity (that one is posed/animated in the world): - -- **Build** from `_lastSpawnByGuid[_playerServerGuid]` + `_entitiesByServerGuid[_playerServerGuid]`: same - Setup id, `MeshRefs`, `PaletteOverride`, `PartOverrides` — but at the **scene origin**, heading - **191.37°**. Assign a **reserved synthetic `ServerGuid`** (non-zero, distinct from every real guid) so - `EntitySpawnAdapter.OnCreate` accepts it and registers its meshes. Extract the WorldEntity-build - helper from GameWindow.cs:3390-3431 so both paths share it. -- **Animate:** route the doll through `EntitySpawnAdapter.OnCreate(dollEntity)` to get a private - `AnimatedEntityState`; play the idle animation (`m_didAnimation` analog — the Setup's idle motion). - Tick the doll sequencer each frame the doll is visible. Draw via a synthetic one-entry landblock tuple - `(dollLandblockId, aabb, [dollEntity], {dollGuid: dollEntity})`. -- **Re-dress** (C# `RedressCreature`): hook `OnLiveAppearanceUpdated` for `update.Guid == - _playerServerGuid` to rebuild the doll entity's `PaletteOverride`/`PartOverrides` from the new spawn + - refresh the adapter state + re-register meshes. Naked when nothing equipped; re-geared on wield. - -**Idle animation source:** confirm the Setup's idle motion id at impl (retail `m_didAnimation` set by -`UpdateForRace` via `DBObj::GetDIDByEnum` per body-type). MVP may use the Setup's default idle; per-race -idle DID swap is polish. - -### E. Wiring + lifecycle (GameWindow) - -- Extend the existing `PaperdollController.Bind` call site: also `FindElement(0x100001d5)` for the - `UiViewport`, construct `PaperdollViewportRenderer` (closing over `WbDrawDispatcher`, the - `EntitySpawnAdapter`, the player spawn/entity accessors, the lighting-UBO builder), give the controller - the viewport ref for the toggle, and register the viewport with the per-frame doll-render hook. -- **Per-frame:** drive the doll RTT pass only when the **inventory window is open** AND the paperdoll is - in **doll-view** AND the player spawn is available. Otherwise no FBO work. -- **Teardown:** dispose the FBO + the doll entity's adapter state on logout/teleport (`Clear()`), and - rebuild lazily on next show. - -### F. Testing + gate - -- **Unit (App.UI / App.Tests, no GL/dat):** the armor partition is exactly the decomp's 9 ids; toggling - flips the correct `Visible` set on {viewport, 9 armor slots} and leaves the 12 non-armor untouched; - default = doll-view. `DollCamera` golden View/Projection values. FBO-resize recreation logic - (rect-change → new framebuffer). The WorldEntity-build helper produces the expected - Setup/PaletteOverride/PartOverrides from a player spawn fixture. -- **Visual gate (the acceptance test):** user compares to retail — the doll renders the re-dressed local - player (correct race/gender/gear, naked if bare), faces the viewer, idles; the Slots button toggles - doll-view ↔ armor-slots; live re-dress on wield/unwield via `0xF625`. -- **Full suite green** (Core / Core.Net / App / UI) at the phase boundary, not just filtered subsets - ([[feedback-ui-resolve-zero-magenta]] process lesson). - ---- - -## 5. Component boundaries - -| Unit | Project / file | Responsibility | Depends on | -|---|---|---|---| -| `PaperdollController` (extend) | `AcDream.App.UI.Layout` | armor/non-armor partition; Slots-button click → toggle `Visible`; hold the viewport ref | `UiButton`, `UiViewport`, `UiItemList` | -| `UiViewport` | `AcDream.App.UI` | leaf widget; blit its scene texture at its rect; gate the 3-D pass on `Visible` | `IUiViewportRenderer`, `UiRenderContext` | -| `IUiViewportRenderer` + `UiViewportScene` | `AcDream.App.UI` | the narrow UI↔3-D seam (data in, texture handle out) | BCL + Core `WorldEntity` only | -| `PaperdollViewportRenderer` | `AcDream.App.Rendering` | own the FBO + `DollCamera` + lighting; run the scissor-free RTT pass via `WbDrawDispatcher` inside a `GLStateScope` | `ManagedGLFramebuffer`, `GLStateScope`, `WbDrawDispatcher`, `EntitySpawnAdapter` | -| `DollCamera` | `AcDream.App.Rendering` | fixed `ICamera` from the retail immediates | `ICamera` | -| Doll-entity builder | `AcDream.App.Rendering` (shared helper) | build/refresh the doll `WorldEntity` from the player spawn | `WorldEntity`, player spawn/entity accessors | -| `DatWidgetFactory` (extend) | `AcDream.App.UI.Layout` | register Type `0xD → UiViewport` | — | - ---- - -## 6. Divergence register impact - -- **Reword AP-66** (empty-slot frame): stays in both views (no doll-through / transparent); the toggle - changes `Visible` only. -- **New rows (as applicable):** per-race camera framing deferred (single default camera); idle-DID - per-race swap deferred; the `IUiViewportRenderer` seam placed in App.UI rather than Core (intentional - adaptation, with rationale §4C); doll lighting = one distant light written to the shared UBO - (approximation iff it differs from retail's `CreatureMode` lighting). -- **Gate-verify AP-62** (MissileAmmo `0x100001E0` mask) carried from Slice 1 — opportunistic. - ---- - -## 7. Items to pin during the plan (scoped, not open-ended) - -1. `UiButton` click/checkbox API — how `PaperdollController` subscribes to the Slots-button click and - reads/sets its checked state. -2. The inventory window's **open/closed** query for the per-frame gate (the F12 window manager / the - frame's `Visible`). -3. The exact `WbDrawDispatcher.Draw` animation contract for a single entity — whether `animatedEntityIds` - + the `AnimatedById` tuple is sufficient, or the doll's `AnimatedEntityState` must also be reachable - via the shared `_animatedEntities`/sequencer-tick path; and where the doll sequencer is ticked. -4. The scene-lighting UBO layout at GameWindow ~8289 — to write the doll's single distant light. -5. `UIElement_Viewport::SetCamera` arg order (pos vs dir) + the exact float→view-matrix construction, so - `DollCamera` frames the doll like retail. -6. The idle motion id for the player Setup (MVP default vs per-race DID). - -These are mechanism confirmations with identified sources — not unresolved requirements. - ---- - -## 8. Acceptance criteria - -- Slots button toggles doll-view (3-D character + 12 non-armor slots) ↔ slot-view (9 armor slots), - matching the user's two retail screenshots. -- The doll renders the re-dressed local player (race/gender/equipped gear; naked if bare), faces the - viewer, idles; updates live on equip/unequip via `0xF625`. -- Build + full suite green; **visual gate** passed (user). Divergence rows updated; the seam respects - the layering decision in §4C. diff --git a/docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md b/docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md deleted file mode 100644 index 64e51547..00000000 --- a/docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md +++ /dev/null @@ -1,177 +0,0 @@ -# Design — eliminate `_datLock` contention in the terrain apply (FPS swing fix) - -**Date:** 2026-06-23 -**Branch:** `claude/thirsty-goldberg-51bb9b` (worktree) -**Status:** approved (A1), ready for implementation plan -**Scope:** the FPS 30↔200 swing / GPU rev only. NOT the entity leak / GPU -heat-up (H3) and NOT the invisible-char-on-portal bug — both are separate, -already-scoped follow-ups in the same code area. - ---- - -## 1. Problem (measured, not inferred) - -FPS swings wildly (~30↔200) and the GPU spins up while moving/jumping in -town, during streaming, and after portal-hops. A per-frame cost-split probe -(`[FRAME-DIAG]`, `ACDREAM_WB_DIAG=1`) measured the dominant cost directly: - -| Phase | `apply` (med/p95) | **`lockwait` (med/p95)** | in-lock work | -|---|---|---|---| -| Initial world load | 35 / 100 ms | **24 / 88 ms** | ~6 ms | -| Town, streaming | 0.8 / 35 ms | **~0 / 23 ms** | ~1–13 ms | - -`apply` is the per-`OnUpdate` cost of `ApplyLoadedTerrain`. On idle frames -`lockwait ≈ 0` → 200 fps; on streaming frames the update thread blocks -**23–88 ms** acquiring `_datLock`. The terrain GPU upload is 0.05–0.13 ms and -the entity GPU upload ~1 µs, so the prior "GPU-upload churn" theory is -refuted. **The swing is `_datLock` contention.** - -## 2. Root cause (confirmed by source + the code's own comment) - -`DatCollection` is not thread-safe, so a single global `_datLock` -(`GameWindow.cs:164`) serializes **all** `DatCollection.Get` calls. The -streaming worker holds that lock for the **entire** per-landblock build -(`BuildLandblockForStreaming`, `GameWindow.cs:5910`) — "tens of ms worst -case." The update thread's `ApplyLoadedTerrainLocked` (`GameWindow.cs:6704`) -runs under the **same** lock (`ApplyLoadedTerrain`, `:6485`) because it makes -its own `Get` calls — so it blocks until the worker releases. The existing -comment at `GameWindow.cs:5885` already documents this and names the fix: -*"a future pass can reduce contention by pre-building render-thread work on -the worker."* - -The six `Get` sites inside the apply that force it to take the lock: - -| Line | Dat | Purpose | -|---|---|---| -| 6785 | `LandBlockInfo` | cell count + building list | -| 6793 | `EnvCell` (per cell) | indoor physics cell surfaces | -| 6797 | `Environment` (per cell) | cell vertex/polygon geometry | -| ~6885 | `Setup` (per building) | building shell part-0 for `CacheBuilding` | -| 6988 | `GfxObj` (per entity meshRef) | BSP tree cache for collision | -| 7025 | `Setup` (entity parts) | ShadowObjects registration | - -## 3. Goal / non-goals - -**Goal:** `ApplyLoadedTerrainLocked` makes **zero** `DatCollection` calls, so -its `lock(_datLock)` is removed and the update thread never waits on the -worker. Target: `lockwait` median+p95 → ~0; the 30↔200 swing gone. - -**Non-goals (separate work):** the entity-leak / GPU heat-up over hops (H3); -the invisible-char-on-portal bug; making `DatCollection` itself thread-safe; -moving the per-landblock CPU *build* off-thread (that is variant A2 — see §7). - -## 4. Approach (A1, recommended) — pre-read on the worker, drop the apply lock - -The streaming worker already holds `_datLock` for its build and already reads -many of these dats for rendering. Extend the worker's per-landblock output to -carry a **physics/registration dat bundle**: the parsed dat objects the apply -needs, read by the worker under the lock it already holds. The apply then -reads from the bundle instead of calling `_dats.Get`, makes zero -`DatCollection` calls, and its `lock(_datLock)` is deleted. - -Everything else in the apply stays exactly where it is and runs **on the -update thread, lock-free**: -- the terrain GL upload (`AddLandblockWithMesh`) — must stay on the GL thread; -- the CellSurface/PortalPlane **world-space build** (it needs the apply-time - `origin` = `_liveCenter`-relative; building it here keeps it in the correct - frame and sidesteps the trees-in-sky frame-mismatch class); -- all physics/registry mutation (`_physicsEngine.AddLandblock`, - `_physicsDataCache.CacheGfxObj`/`CacheBuilding`, `ShadowObjects.Register`, - `_envCellRenderer.FinalizeLandblock`). - -These touch only update-thread-owned structures; the worker never touches -them. Reading them from the bundle (instead of `Get`) changes *where the bytes -come from*, not *what is computed* — behavior is identical. This is a -threading change, not a behavior change; it is retail-neutral. - -## 5. Why dropping the lock is safe - -`_datLock`'s only cross-thread responsibility is serializing `DatCollection` -access (the streaming worker vs. the update thread). The apply's other -work — GL upload, physics engine, shadow registry, dat-data cache, -EnvCell renderer — is all update-thread-only; the worker (which only runs the -factory closures that read dats) never touches any of it. Therefore, once the -apply makes no `DatCollection` call, `_datLock` is unnecessary around it and -removing it introduces no new race. The live-spawn handlers -(`OnLiveEntitySpawnedLocked`) that also mutate the registry run on the **same** -update thread (via `_liveSessionController.Tick()` in `OnUpdate`), so they -never raced the apply regardless of the lock. - -**Required correctness check (implementation):** the bundled dat objects are -handed by reference from the worker to the update thread. This is safe iff -`DatCollection.Get` returns objects that are immutable after parse (read- -only DBObjs). The plan must verify this; if any returned object is mutated -post-parse, the bundle must carry a copy or the extracted primitive data -instead of the shared instance. - -## 6. Interface / data-flow changes - -- **`LandblockStreamResult.Loaded`** (and `Promoted`) gains a - `PhysicsDatBundle` payload (a small record holding: the `LandBlockInfo`, a - map of `EnvCell`+`Environment` by cell id, building `Setup`s by id, and the - entity `GfxObj`/`Setup`s by id needed for BSP + ShadowObjects). -- The worker's load closure (`BuildLandblockForStreamingLocked` → - `_loadLandblock`) populates the bundle under the lock it already holds, - reusing reads it already performs where possible. -- `ApplyLoadedTerrainLocked(lb, meshData, bundle)` reads every dat from - `bundle.X[id]` instead of `_dats.Get(id)`. The six `Get` sites in §2 - become bundle lookups. -- `ApplyLoadedTerrain` removes its `lock(_datLock) { … }` wrapper. - -The bundle is built once per landblock on the worker; the apply consumes it -once. No new per-frame allocation on the hot path beyond the bundle hand-off. - -## 7. Rejected / deferred alternatives - -- **A2 — also move the CPU build off-thread.** Build cell surfaces in - landblock-local coords on the worker, add `origin` at apply. Saves ~0.6 ms - of update-thread CPU but adds origin-frame handling and more moved code, for - a benefit dwarfed by the eliminated 88 ms wait. Deferred; revisit only if - residual apply CPU matters after A1. -- **Shrink the worker's lock-hold** (read-then-release inside the worker). The - apply still calls `Get`, still takes the lock, still contends. Partial only. -- **Thread-safe `DatCollection`** (concurrent reads). Deepest change, touches - the core dat reader that memory flags as not-thread-safe and risky. Out of - scope. - -## 8. Verification - -- **Empirical (the proof):** re-run the same repro (town walk + portal-hops, - Release, `ACDREAM_WB_DIAG=1`) with the `[FRAME-DIAG]` apparatus still in the - tree. Acceptance: `lockwait` median+p95 drop from ~24/88 ms to ~0, `apply` - collapses toward its in-lock-body cost, and the visible 30↔200 swing is gone - while streaming/portal-hopping. Read clean FPS with the probe off. -- **Unit tests:** the worker bundle is populated for a landblock with cells + - buildings + entities; the apply produces identical physics surfaces / BSP / - ShadowObjects registrations whether fed via `Get` (old path) or via the - bundle (new path) — a golden equivalence test in - `tests/AcDream.Core.Tests/` (or `AcDream.App.Tests` for the wiring). -- **Build + full `dotnet test` green.** - -## 9. Risks - -- *Bundle misses a dat the apply needs* → a `KeyNotFoundException`/null at - apply. Mitigation: the equivalence test + a fallback assert listing the six - known sites; the apply must consume only ids the worker enumerated. -- *Shared mutable dat object* (the §5 check fails) → cross-thread read race. - Mitigation: verify DBObj immutability; copy/extract if not. -- *Removing the lock exposes a previously-masked race* on a structure I have - not enumerated. Mitigation: the §5 argument enumerates every mutation in the - apply; the plan re-audits each against the worker's touch-set before - deleting the lock. - -## 10. Apparatus / rollout - -The `[FRAME-DIAG]` probe (uncommitted: `GameWindow.cs` + `StreamingController.cs`) -stays in the tree through verification to capture before/after, then is -stripped in a final cleanup commit (mirrors the `92e95be` probe-strip pattern). -The fix commit is separate from the apparatus. - -## 11. References - -- `GameWindow.cs:5885` — the worker's lock-hold comment naming this fix. -- `GameWindow.cs:5910` — worker `lock(_datLock)` (the contended hold). -- `GameWindow.cs:6485/6704` — `ApplyLoadedTerrain` / `…Locked` (the waiter). -- `LandblockStreamer.cs:27` — "_datLock serialises all DatCollection.Get". -- `docs/research/2026-06-23-fps-gpu-churn-handoff.md` — the FPS deep-dive. -- Probe results: `frame-diag.log` / `frame-diag2.log` / `frame-diag3.log`. diff --git a/docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md b/docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md deleted file mode 100644 index 200978e4..00000000 --- a/docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md +++ /dev/null @@ -1,123 +0,0 @@ -# Spec — Batch EnvCell shell draws (dense-town FPS fix) — 2026-06-23 - -## Status -Design approved 2026-06-23. Supersedes the handoff's distance-degrade theory -(`docs/research/2026-06-23-fps-distance-degrade-handoff.md`), which a live -profiling investigation **refuted** as the cause (see Problem below). - -## Problem (measured, not assumed) - -Dense town (Arwic), **29 fps / 34 ms frame**. Live-profiled with a throwaway -frame profiler (`ACDREAM_FPS_PROF=1`, see Apparatus). Root cause: - -- **`EnvCellRenderer.Render` is called ~94× per frame = 24.75 ms = 75 % of the - 32.8 ms GPU frame.** Everything else is negligible: terrain 0.45 ms (1 draw), - entity dispatch 0.22 ms, update thread 0.1 ms. -- Caller: `RetailPViewRenderer.DrawEnvCellShells` (`RetailPViewRenderer.cs:659-665`) - and `DrawBuildingLookIns` (`:348-354`) loop over every visible / look-in cell - and call `Render(pass, _oneCell)` **once per cell × two passes - (opaque + transparent)**. With ~47 cells that is 94 calls. -- `Render` is a **heavy per-frame method**: it resets the GL-state caches, - re-uploads uniforms + per-instance SSBOs, rebuilds the cross-cell instance - groups, and issues an MDI — *every call*. It is designed to be called once per - frame with a multi-cell filter; calling it per-cell pays that setup 94 times. -- The per-cell loop exists **only** for far→near transparency ordering - (`RetailPViewRenderer.cs:656-657, 648-649`). - -### Eliminated with evidence (do not re-investigate) -| Suspect | Evidence it's NOT the cause | -|---|---| -| Distance-degrade / triangle count (handoff theory) | entity-draw GPU = 0.22 ms | -| MSAA / fill / overdraw / shading | **resolution-independent** (window-resize test: FPS unchanged by size) + MSAA-off didn't help | -| Update thread (physics/anim/net/apply) | `update = 0.1 ms` | -| Far terrain draw distance | `ACDREAM_FAR_RADIUS=4` didn't help (had streaming side-effects) | -| Terrain draw | 1 draw / 0.45 ms | -| Entity leak | modest (`esg=288`), and entity GPU is 0.22 ms regardless | - -## Invariants verified before designing (load-bearing) - -1. **Lighting is per-instance, not per-call.** `RenderModernMDIInternal` - (`EnvCellRenderer.cs:1324-1325`) looks up `GetCellLightSet(allInstances[i].CellId)` - per instance and uploads a per-instance light-set SSBO (binding 5). ⇒ batching - multiple cells into one `Render` call keeps each cell's torch/lantern lighting - correct. -2. **The filtered path already collapses cells into one MDI.** `Render`'s - `filter != null` branch (`EnvCellRenderer.cs:923-968`) groups instances by - `gfxObjId` across *all* filtered cells, then one `RenderModernMDIInternal`. -3. **Membership routing is per-pass, not per-cell.** `UseIndoorMembershipOnlyRouting()` - is called once before each loop, not inside it. -4. **Opaque order is the z-buffer; only transparent needs far→near.** -5. **Arwic (outdoor root) hits `DrawEnvCellShells`** (the merged nearby-building - cells via `MergeNearbyBuildingFloods`). `DrawBuildingLookIns` is interior-roots - only (`RetailPViewRenderer.cs:95` gate). So the outdoor-town win is in - `DrawEnvCellShells`; `DrawBuildingLookIns` is fixed in parallel for interior - scenes. -6. A cell's transparent content is detectable from its prepared batches - (`renderData.Batches[].IsTransparent`, used by the `[shell]` probe at - `EnvCellRenderer.cs:1034`). - -## Design - -**Core idea:** call `EnvCellRenderer.Render` **once per pass with all the pass's -cells**, instead of once per cell. Identical visual output (same cells, same -far→near transparency); a modern-implementation optimization of the bindless-MDI -path. Retail draws cells per-cell but cheaply (prebuilt mesh, one submit); we get -the same result with fewer, fatter MDI calls. - -### Phase 1 — opaque batch (the Arwic win) -- `DrawEnvCellShells`: gather every `IndoorDrawPlan.ShellPass(pvFrame)` cell id - into one reusable `HashSet`; call `Render(Opaque, allCells)` **once**. - Opaque needs no order (z-buffer). ~47 opaque calls → 1. -- `DrawBuildingLookIns` (interior roots): keep the per-building aperture **punch** - pass (pass 1) as-is, then replace the per-cell shell loop (pass 2) with **one - `Render(Opaque, thisBuilding'sCells)` per building** — punch-before-shell order - preserved per building. - -### Phase 2 — transparent -- Add a cheap predicate "does this cell have any transparent batch?" (probe the - prepared snapshot like the `[shell]` diag does). **Skip `Render(Transparent, cell)` - for opaque-only cells** — most cell geometry is opaque walls/floors/ceilings, so - this removes most of the remaining ~47 transparent calls outright. -- For cells that *do* have transparent surfaces, preserve far→near. First cut: - keep those per-cell (expected small count). If the meter still shows the - transparent pass hot, batch them with a per-instance depth sort. Decide on the - meter — do not pre-optimize. -- Apply the same skip-empty to `DrawBuildingLookIns`. - -## Implementation notes -- No change to `EnvCellRenderer.Render`'s multi-cell filter path is required for - Phase 1 — just call it with the full set. (A small `bool CellHasTransparent(uint)` - helper or a per-pass empty-check is the only EnvCellRenderer addition, for Phase 2.) -- Replace the `_oneCell` single-cell scratch with a reusable `HashSet` - populated once per pass. -- `IndoorDrawPlan.ShellPass(pvFrame)` order: opaque ignores it; the transparent - remainder respects it. - -## Risks / correctness checks -- **Transparency reorder artifacts** — mitigated by skip-empty + keeping order for - the few transparent cells. Verify windows/water/decals composite correctly. -- **Missing walls (the #52 / cull-state class)** — `Render` resets the cull-state - cache per call and runs per-batch `SetCullMode`; batching = fewer resets but the - per-batch cull still runs. Verify no missing/half-culled walls after batching. -- **Look-in punch ordering** — batch shells *per building* (after that building's - punches), never globally, or punches won't mark the right apertures. -- **Seals** — `DrawEnvCellShells` only draws shells; seals run elsewhere. Batching - the shell Render does not move the seal pass. - -## Verification -1. `FrameProfiler` `[PASS-GPU] cells=… (calls/frame)` before/after at the **same - Arwic facing** — expect ~94 calls → ~2–5, `cells` 24.75 ms → low single digits, - frame 34 ms → ~12 ms (~80 fps). -2. `dotnet build` + `dotnet test` green. -3. User visual: no missing walls, windows/transparency correct, no flicker, dense - town fps up — at Arwic / Holtburg / Fort Tethana. -4. **Strip all apparatus** when it lands (mirrors `92e95be`): `FrameProfiler.cs`, - the `OnRender`/`OnUpdate` hooks + `_fpsProf`/`_msaaSamples` fields, the terrain - glFinish bracket (`GameWindow.cs`), the `EnvCellRenderer.Render` glFinish - try/finally, and `DegradeCoverageProbeTests.cs`. - -## Out of scope -- Reducing the *number* of nearby buildings flooded (look-in / flood culling) — - a secondary lever; batching addresses the measured per-call overhead. Revisit - only if batching alone doesn't reach target. -- Distance-degrade / LOD — refuted as the cause; not pursued here. diff --git a/docs/superpowers/specs/2026-06-24-unified-collision-inclusion-design.md b/docs/superpowers/specs/2026-06-24-unified-collision-inclusion-design.md deleted file mode 100644 index 71cdce51..00000000 --- a/docs/superpowers/specs/2026-06-24-unified-collision-inclusion-design.md +++ /dev/null @@ -1,223 +0,0 @@ -# Unified verbatim-retail collision-inclusion — design spec — 2026-06-24 - -**Phase goal:** make acdream's "which objects collide, and how the engine decides that" -**verbatim with retail**, replacing the last non-faithful per-channel artifacts. World feel — -what you bump into vs. walk through — should match the retail client object-for-object. - -**Source of truth for the divergences:** [`docs/research/2026-06-24-collision-inclusion-audit.md`](../../research/2026-06-24-collision-inclusion-audit.md) -(19-agent verified audit: 6 confirmed deviations D1/D2/D3/D4/D5/D8, 2 refuted D6/D7). -**Read first:** `claude-memory/project_physics_collision_digest.md` (DO-NOT-RETRY table). - -**Branch:** `claude/thirsty-goldberg-51bb9b`. Named-retail + `references/` live in the MAIN repo. - ---- - -## 1. The retail model we are matching (two layers, four channels) - -Retail decides collision in two layers (verified addresses in the audit): - -- **Layer B — registration (broad phase).** Every object that has a *DAT physics shape* - registers a shadow into every cell its sphere overlaps (`CObjCell::find_cell_list@0x0052b4e0`, - `add_shadows_to_cells@0x00514ae0`). **No attribute is tested at registration** (only - `PARTICLE_EMITTER_PS 0x1000` diverts). "Registerable" ⇔ "has a DAT shape". -- **Layer A — inclusion predicate (narrow phase).** `CPhysicsObj::FindObjCollisions@0x0050f050` - decides per-query skip/dispatch via flag logic, then dispatches **one** shape branch - (BSP-only iff `HAS_PHYSICS_BSP_PS 0x10000`, else CylSphere-then-Sphere). - -The object's **shape comes from DAT only** — a part's `physics_bsp` (`CGfxObj::Serialize@0x00534970`, -gated on serialized-flags bit-0) or `CSetup` cylsphere/sphere (`CPartArray::InitParts@0x00517F40`). -No shape ⇒ `CPhysicsPart::find_obj_collisions@0x0050D8D0` returns OK (passable). **Render-mesh bounds -never enter collision.** - -The four channels stay **distinct** (retail keeps them separate; we do too): -terrain (`CLandCell::find_env_collisions`), EnvCell (`CEnvCell::find_env_collisions@0x0052c130`, -**no building leg**), building shell (`CBuildingObj::find_building_collisions@0x006b5300`, -portal-independent), object shadows (`CObjCell::find_obj_collisions@0x0052b750` → Layer A). - -acdream **already has this two-layer shape** (`ShadowObjectRegistry` + `CollisionExemption` + -`ShadowShapeBuilder` + the A6.P4/BR-7 flood). Verified faithful: the DAT loader reads -`gfxObj.PhysicsBSP` (gated on `HasPhysics`) + `setup.CylSpheres`/`setup.Spheres` -([`PhysicsDataCache.cs:61-68,157-158`](../../../src/AcDream.Core/Physics/PhysicsDataCache.cs)). -This spec closes the remaining gaps. - ---- - -## 2. Target architecture (the "1 fix" mapped onto retail's four pieces) - -1. **One DAT-only collision-shape authority.** `ShadowShapeBuilder` becomes the **single** place - an object's collision shape is derived — from `PhysicsBSP` / `CylSpheres` / `Spheres` only. - Every object-registration site (statics, scenery, weenies, building shells) funnels through it. - The inline mesh-AABB synthesis is **deleted** from `GameWindow.cs`. Because there is exactly one - shape site, the DAT-only rule cannot be violated per-site, and the W3 radius inconsistency - (`2f` vs `1f` fallback) dies. *(Satisfies CLAUDE.md Structure Rule #1 — removes a ~150-line - feature body from the 10k-line `GameWindow.cs` god-object.)* - -2. **One query-time object predicate.** `CollisionExemption` (the skip gate) + the cyl/sphere/BSP - dispatch form the object channel's Layer A, completed with the `ObstructionEthereal` state - (D2/D5) and the PvP/missile dispatch terms wired (W1). - -3. **Env / building / terrain stay distinct channels** (retail-faithful), each with its specific - deviation fixed in place. - -4. **One per-apply rebase invariant:** *every cached collision transform rebases to the current - streaming center on each landblock apply.* Buildings already comply (`RemoveBuildingsForLandblock`, - #146); cells get the symmetric `RemoveCellsForLandblock` (D8); shadow positions are already absolute. - -**What we do NOT do:** no `ObjectCollides` facade migrating all four channels onto one predicate -(retail keeps `find_env`/`find_building`/`find_obj` separate — collapsing them is *less* faithful). -No new abstraction layer; we complete the units that already implement retail's model. - -### Component boundaries - -| Unit | Responsibility | Depends on | Layer | -|---|---|---|---| -| `ShadowShapeBuilder` (extended) | **Sole** DAT-only collision-shape authority: object → `{BSP \| CylSphere[] \| Sphere[] \| None}`. No render bounds. | `PhysicsDataCache` (DAT shapes) | Core | -| `ShadowObjectRegistry` (unchanged) | Per-cell shadow lists; overlap-based flood registration (BR-7) | shape rows from builder | Core | -| `CollisionExemption` (extended) | Query-time skip gate + ObstructionEthereal decision | `PhysicsState`, `EntityCollisionFlags`, `ObjectInfoState` | Core | -| `SpherePath` (extended) | carries `ObstructionEthereal` state across the transition | — | Core | -| `BSPQuery` (extended) | sphere primitive; consume `ObstructionEthereal` in the solid-containment gate | shape rows | Core | -| `PhysicsDataCache` (extended) | `RemoveCellsForLandblock` symmetric with buildings | — | Core | -| `TransitionTypes` (edited) | EnvCell entry-restrictions + obstruction_ethereal clear; dispatch wiring | above | Core | -| `GameWindow` (reduced) | registration sites call the builder; **mesh-AABB block deleted** | builder | App | - ---- - -## 3. Slices (each: build+test green, conformance where applicable, its own visual gate) - -Collision is the DO-NOT-RETRY area — **grep named-retail → pseudocode → port → conformance-test** -for every slice; no guess-patches. Each slice is one-to-few commits and an independent visual gate. - -### Slice 1 — DAT-only shape authority + delete mesh-AABB (D1, D3-prep, W3) -- **Retail oracle:** `CPartArray::InitParts@0x00517F40`, `CGfxObj::Serialize@0x00534970` (physics_bsp - on flags bit-0), `CPhysicsPart::find_obj_collisions@0x0050D8D0` (null shape ⇒ OK/passable). -- **Change:** Make `ShadowShapeBuilder` the single object-shape entry point. **Delete** the mesh-AABB - synthesis block at [`GameWindow.cs:7408-7565`](../../../src/AcDream.App/Rendering/GameWindow.cs) - and the `IsPhantomGfxObjSource`/`isPhantomSetup` gates that only existed to fence it (the phantom - concept becomes the default: no DAT shape ⇒ no shape). Unify the static/live/scenery registration - sites to one builder call. Remove `ComputeVisualBounds` use *for collision* (visual bounds stay for culling). -- **Shape vs broad-phase sphere (do not conflate):** the *collision shape* is DAT-only and may be `None` - (object registers no shape, is passable — no synthetic radius). The *broad-phase registration sphere* - (which cells the flood touches) derives from the shape's own bounding sphere; a shapeless object does not - register at all. Kill the W3 fallback-radius (`2f` vs `1f`) by sourcing the registration radius from the - shape's bounding sphere, never a synthetic constant — and never let a render-mesh bound produce a - *collision* shape (a culling AABB is fine). -- **Acceptance:** every object's collision shape originates from `PhysicsBSP`/`CylSpheres`/`Spheres`; - an object with none registers no shape. `dotnet build`/`dotnet test` green. Existing replay - harnesses (`CellarUp*`, `HouseExitWalk*`, `CornerFlood*`) unchanged-green. -- **Register:** **delete** AP-2 (the divergence is gone). Note AP-6 still covers analytic cylinder math. -- **Visual gate:** walk Holtburg + open world; objects that become passable must be exactly those - retail walks through (DAT has no physics shape). The `ACDREAM_PROBE_BUILDING` `[entity-source]` - line (BSP-vs-Cylinder per static) is the lens. - -### Slice 2 — true Sphere collision primitive (D3) -- **Retail oracle:** `CSphere::intersects_sphere` (pc:276917), `CPartArray::GetSphere@0x00518070`. -- **Change:** add a `Sphere` variant to `ShadowCollisionType` (`ShadowObjectRegistry.cs:537`) and a - real sphere-vs-sweep primitive in `BSPQuery`; `ShadowShapeBuilder` emits `Sphere` (not coerced - cylinder) for `Setup.Spheres`. Dispatch order matches retail: `HAS_PHYSICS_BSP` clear ⇒ CylSphere - then Sphere. -- **Acceptance:** new conformance fixture vs `CSphere::intersects_sphere` golden values (port the - decomp's intersection math; do not eyeball). Build/test green. -- **Register:** D3 is a pre-existing *unregistered* gap being fixed; no row needed (fixed before it had one). -- **Visual gate:** an object whose Setup carries a Sphere (not CylSphere) collides correctly — pick a - known sphere-shape object from the Slice-1 inventory probe. - -### Slice 3 — ObstructionEthereal verbatim port (D2 + D5) — highest care -- **Retail oracle:** `FindObjCollisions@0x0050f050` Gate-1 (pc:276782, requires `0x4 AND 0x10`), - ETHEREAL-alone branch (pc:276795-276806, sets `obstruction_ethereal=1` and continues), the - per-call clear in `CEnvCell::find_env_collisions@0x0052c144` (pc:309580), and the consume sites - in the BSP solid-containment gate (pc:321692 / 323742 / 324573: `if (obstruction_ethereal || insert_type==PLACEMENT)`). -- **Change (port set + clear + consume *together*):** - 1. add `ObstructionEthereal` field to `SpherePath`; - 2. `CollisionExemption`: instant-skip only when `0x4 AND 0x10`; on `0x4`-alone set - `ObstructionEthereal=true` and **continue** to shape test (remove the AD-7 `0x4`-alone return); - 3. clear `ObstructionEthereal=0` at the front of the EnvCell **and** LandCell env dispatch - (this is D5 — the clear that currently doesn't exist because the field doesn't exist); - 4. consume it in `BSPQuery`'s placement/solid-containment gate alongside the existing - `InsertType.Placement` check. -- **The AD-7 shim is subsumed, not patched around.** ACE broadcasts ETHEREAL-only (`0x0001000C`) - for opened doors; the faithful obstruction_ethereal path makes that door passable the retail way, - so **no wire-layer compat is needed** — verify this is what happens, do not add a shim. -- **Acceptance:** build/test green; a door-collision replay (reuse `DoorCollisionApparatusTests` / - the flipped-door apparatus) shows ETHEREAL-alone → passable, ETHEREAL-absent → solid; wall - collision on non-ethereal cells **unchanged** (the consume site must not weaken normal walls). -- **Register:** **delete AD-7** (divergence retired). -- **Visual gate:** **the critical one** — open a door in-world (ACE), walk through it (passable), - close it, walk into it (solid); confirm no house wall went soft. Live: `notan`/`MittSnus81!`. - -### Slice 4 — RemoveCellsForLandblock (D8) — the anti-staleness invariant -- **Retail oracle:** the #146 pattern (`RemoveBuildingsForLandblock`, `PhysicsDataCache.cs:472`); - retail rebuilds cell transforms from live cell pointers (eviction analog `CEnvCell::release`). -- **Change:** add `PhysicsDataCache.RemoveCellsForLandblock(landblockId)` (drop `_cellStruct` entries - whose prefix matches), call it from `PhysicsEngine.RemoveLandblock` (`:121-141`) alongside the - existing `_landblocks`/`ShadowObjects`/`CellGraph` clears, so the next `CacheCellStruct` re-bases the - cell BSP `WorldTransform` against the current `_liveCenter`. Completes invariant #4 (all cached - collision transforms rebase per apply). -- **Acceptance:** build/test green; a teleport-OUT then re-stream of the same dungeon yields a fresh - cell transform (add a targeted test mirroring the building re-base test). Interacts with #135/#138 - — verify no re-stream ordering regression. -- **Register:** new row not needed (divergence eliminated, not introduced). -- **Visual gate:** teleport into a dungeon → out → back into the same dungeon; indoor walls collide - correctly each time (no stale-offset clip). - -### Slice 5 — entry-restrictions hook + predicate wiring (D4 + W1) — conservative -- **Retail oracle:** `CEnvCell::find_env_collisions@0x0052c130` (calls `check_entry_restrictions` - first, pc:309576), `CObjCell::check_entry_restrictions@0x0052b6d0`; dispatch terms at pc:276861. -- **Change:** add the `check_entry_restrictions` gate at the front of the EnvCell/LandCell env path - (returns OK when no `restriction_obj` — a no-op in current dev content but structurally present); - wire the real `pvpExempt`/`missile_ignore` terms into the cyl-vs-BSP dispatch - (`TransitionTypes.cs:2629`) instead of the hardcoded `false`. -- **Acceptance:** build/test green; no behavior change in dev content (no locked cells, no PK/missiles). - Conformance-test the dispatch against the door BSP-only case (must not regress A6.P7). -- **Register:** D4 gets a one-line "ported, inert until locked-cell content" note or no row if fully - faithful; W1 wiring removes the "hardcoded false" smell. -- **Visual gate:** none required (no live trigger in M1.5) — rides the suite + the Slice-3 door gate. - ---- - -## 4. Testing strategy - -- **Conformance tests** (Core): Slice 2's `CSphere::intersects_sphere` golden-value fixture is the - load-bearing one (new physics math). Slices 3/5 reuse the door apparatus. -- **Replay harnesses** (regression guard, every slice): `CellarUpTrajectoryReplayTests`, - `HouseExitWalk*`, `CornerFlood*`, `Issue147ArwicBuildingsDumpTests` must stay green — they pin the - shadow-list/building/cell machinery this phase touches. -- **Live capture** when a slice needs evidence: `ACDREAM_PROBE_BUILDING` (`[entity-source]` - BSP-vs-Cylinder per object — the Slice-1 lens), `ACDREAM_CAPTURE_RESOLVE` (per-resolve JSONL), - short targeted runs only. -- **Visual gates** are the acceptance test for feel — Slices 1, 3, 4 each require the user's eyes - (the only thing that genuinely requires stopping). Slices 2, 5 ride the suite. - -## 5. Risks (each gated) - -- **Slice 1 changes scenery feel** — objects with no DAT shape become passable. Retail-faithful *by - construction* (loader verified faithful, §1), but visible; the Holtburg/open-world gate confirms the - passable set matches retail. -- **Slice 3 is the riskiest** — `obstruction_ethereal` touches the BSP solid-containment gate. A - partial port (set-but-not-consumed, or cleared inconsistently) hardens or softens walls - unpredictably. Mitigation: port set+clear+consume in one slice; door gate + "walls unchanged" check. -- **Slice 4 interacts with dungeon collapse/recenter (#135/#138)** — eviction could surface a - re-stream ordering bug currently masked by "never evict." Mitigation: teleport-OUT gate. -- **Slice 5 wiring** has no live M1.5 trigger — risk is regressing the A6.P7 BSP-only door dispatch. - Mitigation: conformance-test the door case before/after. - -## 6. Divergence-register bookkeeping (per CLAUDE.md — same-commit rule) -- **Delete** AP-2 (Slice 1), AD-7 (Slice 3) — divergences retired, rows removed in the landing commit. -- **Keep** AP-6 (analytic cylinder math vs retail CylSphere — unchanged this phase). -- **Add** a clarifying note (optional) that terrain pass-through on an unstreamed block is the - #135/#138 streaming-gap (D7 refuted), not a collision divergence — so nobody re-files it. - -## 7. Out of scope (filed, not touched) -- **W2** — `BldPortalInfo.ExactMatch` decoded-but-unconsumed: a *transit-feature*, not collision-inclusion. -- **W4** — `CLandCell` ENTIRELY_WATER skip clause: revisit before deep-water content. -- **W5** — static-prune `VisibleCellIds` vs retail `do_not_load` stab_list: verify before it bites. -- **The full #145 cell-relative-frame port** — the eventual "collision frames can never go stale by - construction" home; Slice 4's `RemoveCellsForLandblock` is the interim faithful-enough invariant and - is a clean delete when #145 lands. -- **D6 / D7** — refuted by the audit's adversarial pass; not deviations. - -## 8. Definition of done -All five slices landed; AP-2 + AD-7 deleted from the register; conformance + replay suites green; -the three feel-gates (Slice 1 scenery, Slice 3 door, Slice 4 dungeon re-entry) passed by the user. -Collision-inclusion is verbatim with retail: an object collides iff retail would collide with it, -decided by one DAT-only shape authority + one query predicate + four faithful channels + one -anti-staleness invariant. diff --git a/docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md b/docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md deleted file mode 100644 index 2dcebd28..00000000 --- a/docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md +++ /dev/null @@ -1,195 +0,0 @@ -# acdream UI Studio — design (live previewer + inspector) - -- Date: 2026-06-25 -- Status: approved (user pre-approved full v1 scope 2026-06-25) -- Branch: claude/hopeful-maxwell-214a12 -- Supersedes/foundation for: the deferred phase-2 drag-drop *designer* - -## Goal - -A standalone, fast-booting dev tool that renders **any acdream UI panel** through the -**production renderer**, with a click-to-inspect element inspector, sample-data fixtures, -markup hot-reload + write-back editing, and render-config sliders. - -It collapses the panel-iteration loop — today `edit → build → login to ACE → wait ~8s → -F12 → eyeball vs retail → close → repeat` (~30–60 s/iteration) — into `edit → glance` -(~1 s). Everything we did on the paperdoll this session (confirming the pose, framing the -camera, catching the mirrored heading) would have been a few clicks here instead of a dozen -relaunches. - -**Fidelity is the whole point.** The previewed panel is drawn by the SAME -`UiHost` / `LayoutImporter` / `DatWidgetFactory` / dat-sprite path that ships, so what you -see is what the game renders. This is the decisive reason NOT to build the tool in a -separate stack (Godot/HTML) and NOT to rely on the existing `render-vitals-mockup` (a -separate CPU `SurfaceDecoder` composite that can drift from the GL path). - -## Scope — v1 is the maximum capability - -IN: -- Load a panel from EITHER a dat `LayoutDesc` id OR a markup file (`MarkupDocument`). -- Render via the production GL `UiHost` (2-D panels) AND the full WB mesh pipeline (the - 3-D paperdoll **doll**). -- **Sample-data fixtures** so data-bound panels look populated: canned inventory items, - sample vital values, a dressed sample creature for the doll. -- **Inspector (ImGui):** element tree, click-to-inspect, read-only props - (id / Type / rect / anchors / state-sprites / ZOrder), **editable** markup props - (write back to the file), and **render-config sliders** (the doll camera eye / FOV / - heading — values that live in code, not markup). -- **Hot-reload:** file-watch the markup source; reload-on-demand (id picker / reload button) - for dat layouts. - -OUT (phase 2): the drag-drop visual **designer** (compose *new* layouts on a canvas and -emit markup). The previewer is the foundation that phase sits on. Its natural home is the -plugin-UI story, not the retail-panel work. - -Not faithfulness-gated: this is a DEV tool. The previewed *panels* are faithful (same -renderer); the tool *chrome* (the ImGui inspector) is dev-only. No -`retail-divergence-register` rows are created by this work. - -## Architecture - -Seven isolated, single-purpose units. The previewed panel is drawn by the production -renderer; the tool chrome is **ImGui** (already in `AcDream.App` for the `ACDREAM_DEVTOOLS` -overlay — ideal for a tree / sliders / editable fields at near-zero cost). - -``` -Program.cs ──"ui-studio"──▶ StudioWindow - │ owns: Silk window, ImGui ctx, frame loop - ▼ - RenderBootstrap.Create(gl, opts) ──▶ RenderStack - │ (GL + DatCollection + TextureCache + UiHost - │ + ObjectMeshManager + WbDrawDispatcher + lighting UBO) - │ ALSO consumed by GameWindow (shared = de-tangles OnLoad) - ┌─────────────────────┼───────────────────────────────┐ - ▼ ▼ ▼ - LayoutSource FixtureProvider StudioInspector (ImGui) - dat id | markup sample item table / vitals / tree · props(read+edit) · - → UiElement tree dressed creature → controllers render sliders · canvas(FBO) - │ │ click → HitTestTopDown - └────────── HotReloadWatcher (markup) ─────────────────┘ - MarkupWriteBack (edited props → file) -``` - -### 1. `RenderBootstrap` — NEW, `src/AcDream.App/Rendering/` -Extracts the **render-stack construction** currently tangled inside `GameWindow.OnLoad` -(the GL api, dat-dir → `DatCollection`, `TextureCache`, the WB mesh pipeline — -`ObjectMeshManager` / `WbDrawDispatcher` / `SceneLightingUboBinding` — and `UiHost`) into a -reusable unit that BOTH `GameWindow` and `StudioWindow` build through. -- Interface: `RenderStack Create(GL gl, RenderBootstrapOptions opts)`; `RenderStack` is a - record of the constructed pieces (dats, textureCache, meshManager, drawDispatcher, - lightingUbo, uiHost, shaderDir). -- `GameWindow.OnLoad` is refactored to consume it — **behavior-preserving**, locked by the - existing App/Core suites + a visual gate that the GAME still renders. -- Highest-risk step → extract the SMALLEST cohesive slice only; do it first, gate it, build - the studio on it second. Fallback if extraction is too entangled: `StudioWindow` builds a - studio-local duplicate of the mesh-pipeline setup (more drift, lower regression risk) — to - be decided at implementation, not assumed away. - -### 2. `StudioWindow` — NEW, `src/AcDream.App/Studio/` -A Silk.NET windowed GL app — the studio entry. Owns the window, the `RenderStack`, the ImGui -context, the inspector, the hot-reload watcher, and the per-frame loop. **No** ACE / -world-streaming / physics / movement input. Launched from `Program.cs`: -`AcDream.App ui-studio [--layout 0x21000023 | --markup path.xml]`. Config flows -through `RuntimeOptions` (one env/arg read), per the code-structure rules. - -### 3. `LayoutSource` — NEW, `src/AcDream.App/Studio/` -Loads a panel into a `UiElement` subtree from a dat id (`LayoutImporter.Import`) or a markup -file (`MarkupDocument.Build`). Owns the current source + `Reload()`. -- Interface: `UiElement Load()`, `void Reload()`, `SourceKind Kind`, `string? MarkupPath`, - `uint? LayoutId`, plus a `string? LastError` for the inspector to surface parse failures. - -### 4. `FixtureProvider` — NEW, `src/AcDream.App/Studio/` -Supplies sample data so data-bound panels render populated. A fake `ClientObjectTable` -seeded with a handful of canned items (icon ids + types); sample vital values; a sample -creature (Setup + ObjDesc) for the doll. Maps a known `layoutId` → its real controller -(`VitalsController` / `InventoryController` / `ToolbarController` / `PaperdollController`) -bound to the fixtures — REUSING the production controllers, not re-implementing them. -- Interface: `void Populate(uint layoutId, UiElement root, RenderStack stack)`. Unknown - layout id → no fixture (structural preview only), not an error. - -### 5. `StudioInspector` — NEW, `src/AcDream.App/Studio/` -The ImGui tool chrome, four docked windows: -- **Canvas** — the panel rendered to an FBO (RTT, the `PaperdollViewportRenderer` technique - applied to the whole panel) shown via `ImGui.Image`. Click → map canvas-local coords → - `UiRoot.HitTestTopDown` → select. The FBO route gives a clean dockable IDE layout + - accurate click mapping, and isolates the panel's GL state from ImGui's. -- **Tree** — the live `UiElement` hierarchy with each node's registered Type; select syncs - with the canvas highlight. -- **Properties** — the selected element's id / Type / rect / anchors / state-sprites (with - swatches) / ZOrder. Rect/anchor fields are **editable** (markup source) → applied live + - queued to `MarkupWriteBack`. -- **Render config** — sliders for the doll's `DollCamera` eye / FOV / heading, mutating the - live camera so the doll updates next frame; a "copy values" button emits the literals to - paste into code. - -### 6. `MarkupWriteBack` — NEW, `src/AcDream.App/Studio/` -Serializes an edited element's changed props back to the markup XML file (markup source -only; dat layouts are read-only). v1 = **targeted in-place attribute updates** (find the -element by id, rewrite the changed attribute values), NOT a full tree re-serialize — so -hand-authored formatting/comments survive. - -### 7. `HotReloadWatcher` — NEW, `src/AcDream.App/Studio/` -`FileSystemWatcher` on the markup file → debounced (≈150 ms) reload event → `LayoutSource.Reload` -→ `FixtureProvider.Populate` → re-select the previously-selected element by id when possible. - -## Data flow - -- **Boot:** `StudioWindow` → `RenderBootstrap.Create` → `LayoutSource.Load(initial)` → - `FixtureProvider.Populate` → loop. -- **Frame:** bind panel FBO → `UiHost.Tick` + `Draw` (panel, incl. the doll's own nested - RTT) → unbind → ImGui frame (Canvas image + Tree + Properties + Render-config) → present. -- **Click canvas:** map coords → `HitTestTopDown` → select → Properties shows props. -- **Edit prop:** apply to the live `UiElement` (instant) + (markup) queue write-back. -- **File change:** watcher → `Reload` → `Populate` → re-select by id. -- **Render slider:** mutate `DollCamera` → next frame the doll re-renders. - -## Error handling - -- Bad layout id / unparseable markup → `LayoutSource.LastError` shown in the inspector; the - previous panel stays (or an empty canvas), no crash. -- Missing dats at boot → clear error + exit non-zero. -- Panel build/draw wrapped in try/catch → a thrown panel surfaces its exception text in the - inspector; the tool stays alive (you're often previewing half-broken markup). -- Write-back failure (file locked) → non-fatal inline notice, edit stays applied in-memory. - -## Testing - -- `LayoutSource`: load a known dat id + a markup string → assert the `UiElement` tree shape - (reuse the existing golden-fixture pattern, e.g. `vitals_2100006C.json`). -- `FixtureProvider`: assert the seeded `ClientObjectTable` + vitals shapes. -- `MarkupWriteBack`: round-trip — parse → edit a rect → serialize → re-parse → assert the - changed attribute + that siblings/formatting are untouched. -- `RenderBootstrap`: the existing suites stay green + a manual game visual gate (the refactor - is behavior-preserving). -- Window / ImGui / doll render: manual visual gate — the studio is itself the visual tool. -- Tests live in `tests/AcDream.App.Tests/Studio/`. - -## Build order (staged; each ≈ one subagent chunk + a gate) - -1. **`RenderBootstrap` extraction** — behavior-preserving. GATE: game renders unchanged; - suites green. -2. **`StudioWindow` skeleton + `LayoutSource`(dat id)** — render a 2-D panel to the window. - GATE: `ui-studio --layout 0x2100006C` shows vitals. -3. **`StudioInspector`** — ImGui + canvas-FBO + tree + read-only props + click-to-inspect. - GATE: click an element → see its id/rect/sprites. -4. **`FixtureProvider`** — 2-D panels populated (vitals / toolbar / inventory). GATE: - inventory shows sample items. -5. **Live doll** — `PaperdollViewportRenderer` in the studio + sample creature. GATE: the - paperdoll shows a dressed doll. -6. **Markup source + `HotReloadWatcher`** — GATE: edit a markup file → studio reloads. -7. **Editable markup props + `MarkupWriteBack`** — GATE: edit a rect in the inspector → file - updates → reloads. -8. **Render-config sliders** — GATE: drag the eye/FOV → doll updates live. - -Steps 1–4 deliver the high-value 2-D previewer + inspector + fixtures fast; 5–8 add the doll -and the editing power. The end-state is the full maximum-capability tool. - -## Open risks - -- **`RenderBootstrap` extraction from the tangled `GameWindow.OnLoad`** is the riskiest step. - Mitigation: smallest cohesive slice, behavior-preserving, gated by the game visual + the - suites BEFORE the studio depends on it; documented fallback (studio-local duplicate). -- **`MarkupWriteBack` formatting preservation** — keep v1 to in-place attribute edits, never - a full re-serialize, so hand-authored markup isn't clobbered. -- **ImGui + UiHost GL-state interplay** — the panel renders to its own FBO and ImGui owns the - default framebuffer, so their GL state can't fight (the same seal the paperdoll RTT uses). diff --git a/docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md b/docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md deleted file mode 100644 index 4b9cb126..00000000 --- a/docs/superpowers/specs/2026-06-30-movement-wire-parity-design.md +++ /dev/null @@ -1,292 +0,0 @@ -# Movement / Animation Wire-Parity Slice — Design - -Date: 2026-06-30 -Phase: **L.2b** (movement wire/contact authority) + **L.1b** (command router + motion-state cleanup) -Standard: **decomp-verbatim, tests-first. No approximations, no "feels like retail."** - -## Source documents (read first) - -- `docs/research/2026-06-26-movement-animation-retail-parity-audit.md` — the 12 - confirmed divergences (D1–D12) and the Phase 0–5 plan. -- `docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md` — the ACE-vs-2013 - command-catalog divergence (130 common names with different values; the `+3` - low-word shift beginning at `SnowAngelState`). -- Oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` (Sept 2013 EoR - build) + `docs/research/named-retail/acclient.h`. Live confirmation via the - Ghidra HTTP bridge at `http://127.0.0.1:8081` (decompile-by-address). -- Plans of record this slice serves: `docs/plans/animation-system-audit.md` (L.1) - and `docs/plans/2026-04-29-movement-collision-conformance.md` (L.2). - -## Goal - -Make the **outbound movement wire bytes** (`MoveToState 0xF61C`, `Jump 0xF61B`, -`AutonomousPosition 0xF753`) and the **inbound→animation command catalog** -byte-for-byte / value-for-value faithful to retail, proven by tests whose golden -values are derived directly from the 2013 decomp packers and cross-checked -against holtburger. - -This slice fixes the *packing* and the *catalog*. It does **not** port the -motion-interpreter input-state construction (`adjust_motion` / -`apply_raw_movement`) — that is the next slice (D6 / audit Phase 2), explicitly -deferred below. - -## Scope - -### In scope -- **D1** — `RawMotionState::Pack` default-difference flag packing (replaces - presence-based flags). -- **D3** — `MoveToStatePack` trailing byte = `contact | standingLongjump`. -- **D4** — `JumpPack` layout (extent, velocity, full Position, four timestamps; - drop the spurious objectGuid/spellId). -- **Catalog gap** — dual command catalog (`AceModernCommandCatalog` runtime - default + `Retail2013CommandCatalog` conformance), built behind an - `IMotionCommandCatalog` seam; full 2013 `command_ids[0x198]` extraction; - DAT-availability tests. -- **Phase 0 oracle tests** — written first and failing, encoding the four retail - `Pack` functions and the catalog matrices. - -### Out of scope (later L.1/L.2 slices — see "Deferred / unresolved") -D2 (runtime actions-list/current_style emission), D5 (heartbeat-clock stamping — -*audit only* this slice), D6 (`adjust_motion`/`apply_raw_movement` input-state), -D7 (animation/motion application reorder), D8 (MoveToManager/force-walk), D9 -(inbound types 8/9), D10 (spawn-time MoveTo), D11 (sequence/autonomy -propagation), D12 (jump/contact gates). - -## Decisions (locked with the user 2026-06-30) - -1. **First slice = catalog + outbound wire** (not catalog-only, not + motion core). -2. **Golden-byte oracle = decomp-derived + holtburger cross-check**, confirmed via - the Ghidra bridge. No live retail capture for this slice (captures are reserved - for Phase 2, where retail's *input state* is the genuine unknown). -3. **Catalog mechanism = Option A** — `IMotionCommandCatalog` interface; static - `MotionCommandResolver.ReconstructFullCommand(ushort)` is retained, delegating - to an `AceModernCommandCatalog` singleton so all ~10 runtime call sites are - unchanged. Tests instantiate `Retail2013CommandCatalog` explicitly. -4. **2013 table depth = full extraction** (~408 `command_ids[0x198]` entries with - provenance), not a test-matrix stub. - -## Component design - -### 1. Command catalog (L.1b) - -**Seam.** New interface in `AcDream.Core.Physics`: - -``` -public interface IMotionCommandCatalog -{ - /// Reconstruct the full 32-bit MotionCommand from a 16-bit wire value. - /// Returns 0 if no entry matches. - uint ReconstructFullCommand(ushort wireCommand); -} -``` - -**`AceModernCommandCatalog : IMotionCommandCatalog`** — the runtime default. -Built from the DatReaderWriter / ACE `MotionCommand` enum (these share values; -ACE's enum is the authoritative ACE-modern catalog and matches the local DATs). -Reconstruction = wire-low → full lookup, with collisions resolved by the -documented retail class priority (lower class byte wins: Action 0x10 < SubState -0x41 < Style 0x80 …) applied **at build time**, producing a flat -`IReadOnlyDictionary`. - -- **Verify and (likely) delete the magic override.** The current - `MotionCommandResolver.ApplyNamedRetailOverrides` force-maps `0x016E–0x0197` - to `0x10000000 | lo`. Once `AceModernCommandCatalog` is built cleanly from the - ACE enum, assert the ACE test matrix passes (`0x0153→0x10000153`, - `0x0166→0x10000166`, `0x0171→0x10000171`, `0x0173→0x10000173`). If it passes - **without** the override loop, delete the loop (unexplained magic range; see - the magic-number-audit memory). If a specific command still resolves to the - wrong class, add a **per-command, cited** override — never a blind range. -- **Fix the wrong comment.** The shift starts at `SnowAngelState` (`0x43000115` - → `0x43000118`), not `AllegianceHometownRecall`. - -**`Retail2013CommandCatalog : IMotionCommandCatalog`** — conformance/reference. -Built from the **full** 2013 `command_ids[0x198]` table at `0x007c73e8` paired -with the command-name table at `0x008041ec..0x0080444c`. Deterministic -extraction from `acclient_2013_pseudo_c.txt` (and/or the Ghidra bridge), emitted -as a generated/static name→value table in the source with a provenance header -citing both addresses. ~408 entries. - -**`MotionCommandResolver`** keeps `public static uint ReconstructFullCommand(ushort)` -delegating to a private `static readonly AceModernCommandCatalog s_aceModern`. -All current callers (`AnimationCommandRouter`, `CombatAnimationPlanner`, 8× -`GameWindow`) are untouched. - -**DAT-availability tests.** A command animates only if the entity's `MotionTable` -has a `Links` or `Modifiers` entry for the full 32-bit value. Tests -(`MotionCommandCatalogTests`, real-DAT-backed via `Chorizite.DatReaderWriter`): -- ACE mode: `0x0153→0x10000153 LifestoneRecall`, `0x0166→0x10000166`, - `0x0171→0x10000171`, `0x0173→0x10000173`. -- 2013 mode: `0x0150→0x10000150 LifestoneRecall`, `0x0163→0x10000163`, - `0x016E→0x1000016E`, `0x0170→0x10000170`. -- Availability: `LifestoneRecall (ACE 0x10000153)` and `HouseRecall` exist in - local DAT MotionTables; `MarketplaceRecall / AllegianceHometownRecall / - PKArenaRecall / OffhandSlashHigh` exist **only** under the ACE-shifted IDs - (reproduces the gap doc's link-hit scan as a fixture assertion). - -### 2. RawMotionState model + default-difference packing (D1) - -**Data type** `AcDream.Core.Physics.RawMotionState` — a value type mirroring the -retail struct (`acclient.h RawMotionState::PackBitfield`, decomp `0x0051ed10`): - -| field | retail default | bit | -|---|---|---:| -| current_holdkey | `HoldKey.None` (1) | 0x001 | -| current_style | `0x8000003D` | 0x002 | -| forward_command | `0x41000003` | 0x004 | -| forward_holdkey | `HoldKey.Invalid` (0) | 0x008 | -| forward_speed | `1.0f` | 0x010 | -| sidestep_command | `0` | 0x020 | -| sidestep_holdkey | `HoldKey.Invalid` (0) | 0x040 | -| sidestep_speed | `1.0f` | 0x080 | -| turn_command | `0` | 0x100 | -| turn_holdkey | `HoldKey.Invalid` (0) | 0x200 | -| turn_speed | `1.0f` | 0x400 | -| actions (list) | empty | `num_actions<<11` (bits 11–15) | - -Pure data, no GL/Net dependency. Lives in Core.Physics so the Phase-2 motion -interpreter (also Core.Physics) can populate it without a Core.Net dependency. -A `RawMotionState.Default` exposes the retail defaults. - -**Packer** in `AcDream.Core.Net` (Core.Net may reference Core.Physics; the -reverse is forbidden by Code Structure Rule #2). Ports `RawMotionState::Pack` -verbatim: -1. Build flags dword: set bit *only* when the field **differs from its default** - (`forward_speed != 1.0f`, `current_holdkey != None`, etc.), OR in - `num_actions << 11`. **Note the bitfield is bits 0–15 only** — bits 16–31 are - unused (retail `num_actions` is 5 bits, max 31). The current - `MoveToState.cs` comment "bits 11–31 = command list length" is wrong; correct - it to bits 11–15 = `num_actions`. -2. Write the flags dword. -3. Write each set field in bit order (holdkey, style, fwd_cmd, fwd_holdkey, - fwd_speed, ss_cmd, ss_holdkey, ss_speed, turn_cmd, turn_holdkey, turn_speed). -4. Write each action: `u16 command` then - `u16 (stamp & 0x7FFF) | (autonomous ? 0x8000 : 0)` (decomp lines 293945–293960). - -`MoveToState.Build` is refactored to take a `RawMotionState` (plus position, -sequences, contact, standingLongjump) instead of the flat nullable param list. - -**Behavioral effect vs ACE:** the only runtime change is *removing* over-sent -defaults (`forwardSpeed=1.0`, `currentHoldKey=None`, default per-axis hold keys). -ACE's `UnPackNet` defaults omitted fields, so this is strictly more correct. The -slice does **not** change which RawMotionState values the caller constructs -(that is D6) — it threads the same values the caller passes today through the new -struct. Smoke-test against ACE confirms no regression. - -### 3. MoveToState trailing byte (D3) - -From `MoveToStatePack::Pack` (`0x005168f0`, lines 284717–284722): the trailing -byte is `((longjump_mode != 0 ? 0x02 : 0) | (contact != 0 ? 0x01 : 0))`. Then -ALIGN to 4. - -`MoveToState.Build` gains explicit `bool contact` and `bool standingLongjump` -params. `standingLongjump` is wired to its true current value (`false` — standing -longjump is not implemented yet). The *packing* is faithful; the *input* is -honest. When standing longjump lands (later), the param flips. This is not a -masking shortcut — it is the correct byte with the correct current input. - -### 4. JumpPack (D4) - -From `JumpPack::Pack` (`0x00516d10`, lines 284934–284963), field order: -1. `f32 extent` -2. `f32 velocity.x, velocity.y, velocity.z` -3. `Position.Pack` (objcell_id + frame) -4. `u16 instance_timestamp, server_control_timestamp, teleport_timestamp, force_position_ts` -5. ALIGN to 4 - -`JumpAction.Build` is rewritten to this layout. The current spurious -`u32 objectGuid` + `u32 spellId` are **removed** (no retail evidence). New params: -`cellId`, `position`, `rotation`. Caller at `GameWindow.cs:8343` supplies them -(it already has `wireCellId` / `wirePos` / `wireRot` in scope). - -### 5. AutonomousPosition (D5 audit + timestamp order) - -`AutonomousPositionPack::Pack` (`0x00516af0`): Position, then `u16` instance / -server_control / teleport / force_position, then a **contact-only** byte -(`contact != 0`; no longjump bit), then ALIGN. Current code matches. Add: -- a timestamp-**order** golden test (instance, serverControl, teleport, - forcePosition); -- a contact-byte test. - -**D5 audit (no behavior change unless decomp proves divergence):** read -`CommandInterpreter::SendMovementEvent` (`0x006b4680`) and `SendPositionEvent` -(`0x006b4770`) and compare what they stamp (`last_sent_position_time` vs -position vs contact_plane) to acdream's `NotePositionSent`. Document the finding; -change behavior only if the decomp shows a real divergence. - -### 6. Position::Pack verification - -`Position::Pack` is shared by all three messages and currently writes -`cellId, x, y, z, qw, qx, qy, qz`. It works against ACE, but for strict parity: -decompile `Position::Pack` / `Frame::Pack` by address, confirm the field order, -and lock it with a golden test on the Position block. Fix only if the decomp -diverges; otherwise the test pins it. - -## Test inventory (written first, failing) - -Core.Net.Tests: -- `RawMotionStatePackTests` — default state (only the flags dword, value `0`); - walk-forward speed 1.0 (forward_command + forward_holdkey set, **forward_speed - omitted**); run forward (holdkey + fwd_cmd + fwd_holdkey + fwd_speed); backward; - sidestep right/left; turn right/left; non-default current_style; a populated - actions list (num_actions bits + per-action u16 pair); contact/longjump - trailing byte combinations. -- `MoveToStateGoldenTests` — full-message golden bytes for walk, run, slow-walk - (toggle-run), sidestep, turn, contact, standing-longjump. -- `JumpActionTests` — retail `JumpPack` layout, full Position present, no - object/spell fields. -- `AutonomousPositionTests` — timestamp order + contact byte. -- `PositionPackTests` — Position block byte order. - -Core.Tests: -- `MotionCommandCatalogTests` — ACE-mode + 2013-mode matrices; class-priority - collisions; the "override deleted" assertion (or per-command cited override). -- `MotionCommandCatalogDatTests` — real-DAT availability assertions. - -Golden bytes are computed by hand from the decomp `Pack` functions (cited by -address in each test) and cross-checked against holtburger's Rust packers. - -## Layering & placement - -- `IMotionCommandCatalog`, `AceModernCommandCatalog`, `Retail2013CommandCatalog`, - `RawMotionState` → `src/AcDream.Core/Physics/`. -- `RawMotionState` packer + the three message builders → `src/AcDream.Core.Net/Messages/`. -- Caller wiring → `src/AcDream.App/Rendering/GameWindow.cs` (MoveToState ~8277, - Jump ~8343) — minimal: build a `RawMotionState`, pass new contact/longjump and - jump position params. No new feature body in `GameWindow` (Code Structure Rule #1). -- Tests in the project matching the layer (Rule #6). - -## Deferred / unresolved (called out, not guessed) - -- **D6 (next slice):** the runtime does not yet construct the exactly-retail - `RawMotionState` (run-vs-walk `forward_command`, hold-key derivation, backward - / sidestep-left speed normalization). This slice makes the *packer* verbatim; - state construction is `adjust_motion`/`apply_raw_movement` (audit Phase 2). -- **D5 (CONFIRMED 2026-06-30, deferred):** the audit found a real divergence — - retail's `SendMovementEvent` (0x006b4680) stamps ONLY `last_sent_position_time` - after an MTS, while `SendPositionEvent` (0x006b4770, AP) stamps all three - (time + position + contact-plane); acdream's `NotePositionSent` stamps all three - after both. Broader: acdream gates APs on a plain interval (`HeartbeatDue`) where - retail uses `ShouldSendPositionEvent` (0x006b45e0). Left unchanged this slice (code - comments added at both call sites); recorded as register row **TS-33** and deferred - to a dedicated cadence-port follow-up slice (port `ShouldSendPositionEvent` + split - MTS/AP stamping). -- **D2:** actions-list / current_style packing is structurally supported and - tested, but the runtime emits 0 actions; populated emission is Phase 2+. -- **D9/D8/D10/D11/D12, D7:** later L.1/L.2 slices. - -## Bookkeeping (mandatory) - -- **Divergence register:** delete the rows for D1, D3, D4 in the same commit that - ports each. If any new deviation is introduced, add its row in the same commit. -- **Roadmap:** update L.2b (wire) and L.1b (catalog) "shipped" notes when landed. -- **Memory:** if a durable lesson emerges (e.g., the default-difference packing - pattern, the dual-catalog seam), capture it. - -## Acceptance criteria - -- Failing oracle tests written first; then green after implementation. -- `dotnet build` + `dotnet test` green at each commit. -- Every ported algorithm cites the named retail symbol + address in test/comment. -- ACE smoke pass: login, walk/run/sidestep/turn/jump, observe no server - rejection; confirm the over-sent defaults are gone on the wire (dump or capture). -- Divergence register rows for D1/D3/D4 retired; roadmap updated. diff --git a/docs/superpowers/specs/2026-07-05-modern-pipeline-design.md b/docs/superpowers/specs/2026-07-05-modern-pipeline-design.md deleted file mode 100644 index 1a5293ef..00000000 --- a/docs/superpowers/specs/2026-07-05-modern-pipeline-design.md +++ /dev/null @@ -1,414 +0,0 @@ -# Design — Modern Pipeline side track (MP): baked assets + ECS render world + zero-alloc frame loop - -**Date:** 2026-07-05 -**Status:** brainstorm-approved; awaiting user spec review -**Origin:** user-commissioned performance side track ("fully modern pipeline, with ECS"). -Explicitly NOT M1.5 work — see §12 for the firewall. -**Phase prefix:** `MP0–MP5` (MP = Modern Pipeline; avoids collision with the existing -P0 conformance-apparatus plan). -**Implementation cadence:** each MP phase gets its own implementation plan -(`docs/superpowers/plans/`) when it starts; this spec is the umbrella design. The -first plan covers MP0 (+ MP1 scaffold) only. - ---- - -## 1. Motivation + measured baseline - -acdream's steady-state dense-town frame rate is ~165 FPS (~6 ms/frame) after the -2026-06-23/24 fixes, versus 300+ FPS in comparable modern titles on the same machine. -Traversal (streaming, portal hops) still hitches. The measured facts this design -builds on (do NOT re-derive; sources in §14): - -| Fact | Evidence | -|---|---| -| GPU is nearly idle: ~0.5 ms/frame real GPU time in dense town | 2026-06-23/24 deep-dive; the "12 ms GPU" was a glFinish artifact | -| The frame is CPU-submission-bound | dense-town attribution report; per-cell batch fix took 75→~165 FPS | -| `_datLock` contention (the 30↔200 swing) is FIXED — lockwait 88 ms p95 → 0.2 µs | `536f1c04` + handoff §1 | -| No distance-degrade: every frustum-visible object draws at full detail at any distance (~1,400 draws / ~24,000 instances) | 2026-06-23 handoff §2; `GfxObjDegradeResolver.cs` "always slot 0" comment | -| Parked CPU levers: `WbDrawDispatcher` per-frame static-scenery rebuild (~2.1 ms), cross-frame dispatcher cache (judged HIGH-risk as a *patch* to the frozen renderer) | feedback_render_perf_measurement.md | -| Load hitches come from runtime dat decode (mesh extraction, texture decode, BSP graph construction) + GC | thread-safety investigation; two-tier streaming architecture | -| Runtime dat reads carry an unbounded second-layer cache (`DatDatabaseWrapper._cache`) stacked on DRW's own | `DatCollectionAdapter.cs:119` | - -The conclusion the whole design follows from: **the language and the GPU are not the -bottleneck; the data layout and the per-frame/at-load CPU work are.** Modernizing means -(a) moving decode work offline, (b) making the renderer process *changes* instead of -re-describing the scene every frame, (c) flattening hot data, (d) removing frame-loop -allocation. - -## 2. Goals & acceptance - -1. **Smoothness (primary):** no frame over ~16 ms during any traversal — town walking - while streaming, portal hops, dungeon transitions. Measured: p99 frame time from the - MP0 profiler across a scripted traversal route, plus user-eyes confirmation. -2. **Throughput:** 300+ FPS sustained in the dense-town worst cases (Fort Tethana, - Arwic, Holtburg), Release build, vsync off, user's machine, standing + panning. -3. **Architecture:** baked asset pipeline (pak), Arch-based ECS render world, - zero-allocation steady-state frame loop. - -**Non-goals (binding):** -- No Rust. Decision recorded: the bottleneck is architectural, not language; a rewrite - discards ~200K lines of verified retail-faithful ports. -- No ECS in the simulation. `MovementManager`, the animation sequencer, physics - transitions, weenies keep their retail-mirroring OO structure — that correspondence - is the project's debugging methodology. -- No retail-visible behavior changes. Everything here is perf-only - (per feedback_render_perf_not_faithfulness_gated) except MP2, which is a *faithful - port* that retires a known divergence. -- Not M1.5 work. #137/#138/A7 continue independently and win all conflicts. - -## 3. Decisions recorded from the brainstorm - -| Decision | Choice | Why | -|---|---|---| -| Language | C#/.NET 10 | Perf gap to Rust for optimized code is ~10–30%; our gap is architectural multiples; 200K LOC of verified ports retained | -| ECS flavor | **Arch framework** (NuGet `Arch`, ~2.1.x) — render world only | User choice (craft/learning value); fastest .NET archetype ECS; contained + swappable behind our interfaces | -| Bake UX | CLI tool (`acdream-bake`) now; client auto-detect + offer later | Iteration speed while the format churns | -| Sequencing | Measure → smooth → throughput | User priority: smoothness first | -| Cutover style | Per-phase gated slices, legacy path deleted at each gate | A8/N.5 history: big-bang cutovers and lingering dual paths both failed here | - -## 4. Architecture overview - -``` -┌────────────────────────────────────────────────────────────────────┐ -│ SIMULATION (unchanged structure) │ -│ retail-shaped OO: physics, MovementManager, sequencer, weenies │ -└──────────────┬─────────────────────────────────────────────────────┘ - │ one seam: transform/lifecycle events (single-owner) -┌──────────────▼─────────────────────────────────────────────────────┐ -│ ECS RENDER WORLD (Arch, AcDream.App) [MP3] │ -│ entities = drawables; components = Transform, MeshRef, Degrade, │ -│ Residency, Flags; systems = TransformSync → Degrade → Visibility │ -│ → Upload(dirty ranges) → Draw(MDI) │ -└──────────────┬─────────────────────────────────────────────────────┘ - │ handles into -┌──────────────▼─────────────────────────────────────────────────────┐ -│ ASSET LAYER (pak) [MP1] │ -│ acdream-bake (offline, uses DRW + existing tested interpretation) │ -│ → pak file → PakReader (mmap, zero-copy, no locks, no decode) │ -└──────────────┬─────────────────────────────────────────────────────┘ - │ persistent-mapped staging, budgeted uploads -┌──────────────▼─────────────────────────────────────────────────────┐ -│ GPU (unchanged N.5 foundation: bindless + MDI, 3 SSBOs) │ -└────────────────────────────────────────────────────────────────────┘ -``` - -The N.5 bindless+MDI foundation is kept — it is already the modern half. This design -replaces what feeds it (per-frame rebuild → deltas) and what feeds *that* (runtime -decode → baked pak). - -## 5. MP0 — Apparatus (honest measurement) - -New `FrameProfiler` (dedicated class in `src/AcDream.App/Diagnostics/`, NOT GameWindow; -CLAUDE.md structure rule 1): - -- **CPU frame time**: swap-to-swap delta, ring buffer, p50/p95/p99 + histogram. -- **GPU frame time**: whole-frame `GL_TIME_ELAPSED` query, **never enabled together - with any glFinish-style per-pass bracketing** (the 2026-06-23 lesson — separate - flags, enforced in code, not convention). -- **Per-stage attribution**: `glQueryCounter` (GL_TIMESTAMP) markers per pass — no - glFinish, no nesting conflict with the frame query. -- **Allocation counter**: `GC.GetAllocatedBytesForCurrentThread()` delta per frame on - the update thread + Gen0/1/2 collection counts. This is MP4's before/after meter. -- Toggles via `RuntimeOptions` + DebugPanel (structure rules 4/5); env - `ACDREAM_FRAME_PROF=1`. **Stays in the tree permanently** (unlike the throwaway - `ACDREAM_FPS_PROF`), because every MP gate reads it. - -**Gate:** a captured baseline report (dense-town steady + scripted traversal route) with -the CPU 6 ms attributed per stage. If the attribution contradicts §1's assumed split, -the MP phase order is re-sequenced and this doc amended before MP1 starts. - -**Gate PASSED 2026-07-05** — see `docs/research/2026-07-05-mp0-baseline.md`. -Verdict: render-side-CPU split CONFIRMED (GPU ≤ ~2.7 ms, stages ≈ 0); steady -medians better than assumed (worst town p50 3.6 ms; Fort Tethana axiom view -not yet re-measured — re-check at the MP2/MP3 gates). AMENDMENT: steady-state -allocation churn (1.5–3 MB/frame → gen2 GC ~1–2/s → all town p99/max -violations) is promoted to a first-class smoothness lever: after MP1, one -bounded allocation-triage session fixes top churn sites OUTSIDE the MP3 -rewrite surface; sites inside it wait for MP3; the full zero-alloc pass -remains MP4. Teleport hitch quantified: 211 ms worst frame, 75.7 MB -single-frame allocation — MP1's "before" number. - -## 6. MP1 — Bake + pak (the smoothness backbone) - -### 6.1 The bake tool - -`acdream-bake` — new project `src/AcDream.Bake/` (console; registered in -`AcDream.slnx`). Reads the user's dats via DRW + **the same interpretation code the -client uses today**, run offline: - -- Mesh extraction (GfxObj/Setup → GPU-layout vertex/index + batch tables) via the - existing ObjectMeshManager build path. -- Texture decode (palette/INDEX16/etc. → RGBA) via the existing TextureHelpers path. - **AMENDED 2026-07-05 (MP1a ground truth):** v1 pak stores **decoded RGBA8 exactly - as the runtime path produces today** (the atlas consumes RGBA8 via - `TextureBatchData` — storing BC7/BC1 would change delivered pixels and violate - this phase's byte-identical conformance gate). BC compression (quarter the disk - + VRAM, lossy) is an explicit post-conformance option requiring its own visual - gate and user approval, not v1 scope. Mip chains remain runtime-generated - (`GenerateMipmaps` flush) in v1 for the same reason. -- BSPs (physics, cell, drawing) flattened to index-based node arrays (blittable). -- Landblock terrain data (heights/attributes) as blittable arrays. -- EnvCells (geometry + portals + surfaces) as blittable records. -- Degrade tables (per-GfxObj slot `ideal_dist`/`max_dist`/ids) — MP2/MP3 read these. -- **Scenery placement**: `SceneryGenerator` evaluated per landblock at bake; the pak - stores final instance lists (deterministic function of dat data, so bake-safe). - -**Extraction prerequisite:** the CPU-side mesh/texture build code currently lives in -`AcDream.App` next to GL code. MP1 moves the GL-free parts into a new -`src/AcDream.Content/` assembly (no GL dependency) that both `AcDream.App` and -`AcDream.Bake` reference. This is a mechanical extraction (move + namespace), not a -rewrite — the same lines keep running. Inventory-doc update required (structure rule 2). - -### 6.2 Pak format (v1) - -Single file `acdream.pak` next to the dats (path via `RuntimeOptions`): - -- Header: magic `ACPK`, format version, **dat iteration stamps** (all four dats) + - bake-tool version. Stale/missing pak at launch → clear error naming the rebake - command (CLI era) → later: auto-offer (per §3). -- TOC: flat sorted array `(assetKey: u64 [type:u8 | fileId:u32 | variant:u24]) → - (offset: u64, length: u32, flags: u32)`; binary-search or hash lookup, O(1)-ish, - loaded once. -- Blobs 64-byte aligned, uncompressed in v1 (mmap + OS page cache + NVMe; LZ4 block - compression is a noted future option, not v1 scope). - -### 6.3 Runtime consumption - -- `PakReader` in **`AcDream.Content`** (AMENDED 2026-07-05: the spec originally said - Core, but MP1a established the dependency direction Content→Core, and the pak's - blob shapes are the `ObjectMeshData` family living in Content — a Core-resident - reader would be circular. Content is GL-free, so the layering intent is - preserved; Core stays untouched): one `MemoryMappedFile`, `GetBlob(key)` → - `ReadOnlySpan` into the view; typed access via `MemoryMarshal.Cast` over - blittable segments. **No locks anywhere** — immutable mapped memory is - thread-safe to read by construction. -- Streaming workers stop decoding: a landblock load becomes TOC lookups + handing - spans to the GL upload queue. The decode worker pool shrinks to the residual - runtime-decode set (below). Request-key dedup stays in the streaming layer - (cheap loads still shouldn't be duplicate loads). -- **Retires:** runtime DRW decode on the world *render/streaming* hot path; the - unbounded `DatDatabaseWrapper._cache`. The `PhysicsDatBundle` worker pre-read - **stays through MP1–MP3** (the physics apply consumes parsed DBObj types until MP4 - restructures physics data consumption) and is retired in MP4 when physics reads the - pak's flat arrays directly. - -### 6.4 What stays on runtime DRW (v1 scope control) - -| Stays runtime | Why | -|---|---| -| UI dat assets (sprites, fonts, LayoutDescs — D.2b) | low volume, cold path | -| Audio waves (`DatSoundCache`) | low volume, already cached | -| Character-appearance palette overlays (ObjDesc) | runtime-parameterized per entity; keeps existing decode + cache | -| Motion tables / animations (sequencer) | sim-side, small, load-once | - -`DatCollection` therefore stays in-process, off the hot path. Baking these later is a -listed follow-up, not v1. - -### 6.5 Conformance + gate - -- **Equivalence test:** load a fixture set (Holtburg blocks + a dungeon + a dense-town - block) through the legacy path and the pak path; diff meshes (vertex/index bytes), - textures (pre-compression pixels), BSP structures, scenery instance lists - field-by-field. Golden = legacy path. -- **Gate:** equivalence green; portal-hop / traversal p99 from MP0 shows the hitch - class gone; user visual gate (no missing/wrong geometry); legacy decode path deleted - from the streaming hot path in the gate commit. - -### 6.6 EnvCell dedup — REQUIRED (corrected 2026-07-05 after the full-bake gate) - -The runtime deduplicates interior-cell geometry under a **content hash** -(`EnvCellRenderer.GetEnvCellGeomId(environmentId, cellStructure, surfaces)`, -bit 33 set — verbatim WB), NOT under the cell's dat fileId; **many cells share -one geometry** (AC dungeons repeat a small palette of tile geometries thousands -of times). - -**The full-bake gate (2026-07-05) proved dedup is mandatory, not optional.** -Baking all 729,888 EnvCells as separate fileId-keyed blobs produced an -**865 GB pak in 52 minutes** — 97% of the blobs were near-duplicate cell -geometry. A prior note here called that duplication "v1-acceptable"; **that was -wrong by ~100×.** Dedup is a v1 requirement. - -**The fix (bake-tool only, no pak format change):** dedup at bake time. -Compute each cell's geomId (cheap — reads the EnvCell/CellStruct header, no mesh -extraction), extract each **unique geometry once**, and emit one TOC entry per -cell-fileId all pointing at the one shared blob offset. The TOC already maps -key→offset with no uniqueness constraint on offsets, and `PakReader` is oblivious -to the sharing (pure offset lookup) — so aliasing needs no format or reader -change. Expected result: ~730K blobs → single-digit-thousand unique blobs, pak -865 GB → a few GB, bake 52 min → minutes (all three collapse from the same fix). - -**MP1c cutover (unchanged):** `RegisterCell` reads `pak[cellFileId]` → the shared -blob → registers under the runtime geomId; post-load dedup continues exactly as -today. Keying blobs by geomId *directly* remains non-viable (the hash exceeds -PakKey's 56 usable bits) — the fileId-key + aliased-offset scheme is why the -format needs no change. - -## 7. MP2 — Distance-degrade port (hide-only first cut) - -The already-scoped retail port from the 2026-06-23 handoff §3 (read it + the named -retail sources FULLY before porting — mandatory workflow): - -- Retail anchors: `GfxObjDegradeInfo::get_degrade` (`0x0051e4b0`, pseudo-C :293086), - `CPhysicsPart::UpdateViewerDistance` (`0x0050e030`, :275517), - `get_max_degrade_distance` (`0x0051e2d0`, :292918), `Render_DegradeDistance` - user setting (:3270). -- **Hide-only cut (this phase):** per-entity max-draw-distance resolved at spawn from - the degrade table; distance check in the entity walk; player + near NPCs exempt from - hiding (handoff caution). Full per-slot mesh selection deliberately lands in MP3 - where the ECS DegradeSystem owns it — accepted small double-touch, avoids building - slot-swap plumbing into a dispatcher MP3 replaces. -- Register bookkeeping: the "no distance-degrade" deviation gets its row updated to - "partial (hide faithful; slot selection pending MP3)" in the same commit; the row is - deleted in MP3's gate commit. -- **Gate:** retail side-by-side is the acceptance (same objects visible/hidden at the - same distances, user-confirmed); drawn-instance count before/after recorded from the - MP0 counters (no invented threshold — retail is the oracle); conformance test on the - `get_max_degrade_distance` math vs decomp. - -## 8. MP3 — ECS render world (Arch) + delta submission (the 300 lever) - -### 8.1 The world - -Arch `World` in `AcDream.App` owning every drawable: statics, baked scenery instances, -live entities, cell objects. Components (all blittable structs): - -- `WorldTransform` (position/rotation/scale, cell-relative frame per #145 rules) -- `MeshRef` (pak mesh handle + part index) -- `DegradeState` (current slot, per-slot table handle) -- `Residency` (landblockId, cellId) -- `RenderFlags` (translucent, hidden, player-owned, …) -- `DirtyTag` (tag component; added on change, cleared by Upload) - -### 8.2 Systems (fixed order, update thread in v1) - -1. **TransformSync** — consumes the simulation's transform/lifecycle events through - ONE seam (single-owner-state rule; the sim never writes render state directly). - Static world entities receive a transform exactly once at residency. -2. **Degrade** — retail `get_degrade` banded slot selection (ideal/max band = no - popping/hysteresis by construction), per entity per frame. Completes the MP2 port; - divergence row deleted here. -3. **Visibility** — frustum + cell/PVS gate (Option A "one gate" preserved from the - render digest — visibility computed once, enforced once). -4. **Upload** — iterates `DirtyTag` entities only; writes changed instance records - into the persistent-mapped SSBO mirror (SoA layout matching the N.5 SSBO), budgeted - per frame. -5. **Draw** — builds the MDI command buffer from the visible set and submits. Instance - *data* is GPU-resident and untouched unless dirty; only commands are per-frame. - GPU compute culling is a measured-need stretch inside this phase, not default scope. - -Steady-state per-frame cost becomes: degrade + visibility iteration (linear over -packed arrays) + uploads proportional to *what changed* (a handful of moving -entities), replacing today's full per-frame walk-and-emit (`WalkEntitiesInto` emitting -every MeshRef of every in-frustum entity every frame + the ~2.1 ms scenery rebuild). - -### 8.3 Encoded lessons (from the DO-NOT-RETRY tables — binding on implementation) - -- Cache/dirty reset gates on actual entity change via tracker (the #53 class). -- Visibility volumes derive from drawn data, never synthetic constants (#119/#128). -- Every GL state the draw uses is set, not inherited (render-self-contained rule). -- Player-owned render state written only at the player chokepoint (#131 class). -- H3 (unbounded live-entity growth across hops) is fixed here structurally: residency - eviction drops render entities (render-only eviction; sim state untouched), - re-materialized on re-entry. - -### 8.4 Conformance + gate - -- **Instance-set diff:** probe dumps the drawn (meshId, transform, slot) set from the - legacy dispatcher and the ECS path on identical fixture frames; sets must match - exactly (modulo MP2's intended hides). -- **Screenshot diff:** fixed camera poses (Holtburg outdoor set + dungeon set + - dense-town set), old vs new, pixel-identical requirement. -- Bring-up happens behind a temporary flag; **the gate commit deletes the legacy - dispatcher path** (no lingering dual pipeline — N.5 ship-amendment precedent). -- **Gate:** diffs green; dense-town steady FPS ≥ 300 OR the MP0 profile names the next - dominant cost (which then decides MP4/MP5 emphasis); user visual gate. - -## 9. MP4 — Zero-alloc frame loop + flat physics data - -**Sequencing constraint: starts only after M1.5 #137 (dungeon collision) has landed** -— it touches the same physics data structures. - -- Allocation audit driven by MP0's per-frame counter: identify every steady-state - allocation site (closures, LINQ, params arrays, list growth); fix via structs, - pooled buffers, `Span`, preallocated scratch. -- Physics consumes the pak's flattened BSP arrays: `PhysicsDataCache` swaps the - managed BSP node graph for index-walks over blittable arrays. Same traversal - order, same arithmetic — layout change only. -- **Conformance:** the existing physics test suites + the trajectory replay harness - must pass unchanged (bit-identical trajectories); that harness exists precisely for - this class of change. -- **Gate:** steady-state allocations/frame = 0 (measured); physics suites green; - traversal p99 re-measured. - -## 10. MP5 — Jobs (stretch, evidence-gated) - -Only if the MP0 profile after MP4 still shows the frame short of target: parallelize -the independent stages (Arch has built-in multithreaded query support; visibility and -degrade are embarrassingly parallel once data is flat). **Explicitly skipped with -recorded numbers if targets are already met** — parallelism is a multiplier, not a -goal. - -## 11. Testing strategy (summary) - -| Apparatus | Built in | Used by | -|---|---|---| -| FrameProfiler (CPU/GPU/alloc, percentiles) | MP0 | every gate | -| Pak equivalence diff (mesh/texture/BSP/scenery) | MP1 | MP1 gate, rebakes | -| Instance-set diff probe | MP3 | MP3 gate | -| Screenshot diff harness (fixed poses) | MP3 | MP3 gate, regressions | -| Physics trajectory replay harness | exists | MP4 gate | -| Existing suites (Core ~1568 tests, conformance sweeps) | exist | every phase (green required) | - -`DatConcurrencyStressTests` stays until MP1 retires hot-path DRW, then is scoped to -the residual runtime-DRW set (§6.4). - -## 12. Firewall, process, bookkeeping - -- **Track identity:** phases commit as `feat(pipeline): MP1 — …`. The roadmap gains a - "Modern Pipeline side track" section; the milestones doc gets an explicit - **freeze-exception paragraph** (this track deliberately reopens the frozen - streaming/rendering subsystems under new architecture, user-authorized 2026-07-05). -- **One milestone rule preserved:** M1.5 remains the active milestone; MP work happens - in dedicated side-track sessions in a separate worktree. M1.5 critical path wins - every conflict; MP4 explicitly queues behind #137. -- **Divergence register:** MP2/MP3 update-then-delete the degrade row as described. - MP1/MP3/MP4 are perf-only and must introduce **zero** rows — any behavioral - difference discovered at a gate is a bug to fix, not a deviation to register. -- **Structure rules honored:** no new GameWindow feature bodies (new classes: - `FrameProfiler`, `PakReader`, `AcDream.Bake`, `AcDream.Content`, ECS systems); - env vars through `RuntimeOptions`; tests in matching layer projects; new project - references documented in the inventory doc. -- **Dependency:** `Arch` (NuGet) added to `AcDream.App` only. License verified at - adoption (expected Apache-2.0); if ever abandoned, vendor the source - (WorldBuilder precedent) or swap for hand-rolled SoA behind the same system - interfaces. - -## 13. Risks & mitigations - -| Risk | Mitigation | -|---|---| -| Pak staleness confusion (dat patch → wrong assets) | iteration stamps in header; hard refuse + rebake instruction; auto-offer later | -| ECS cutover regresses rendering (the #53/#119/#128 class) | instance-set + screenshot diffs as hard gates; lessons encoded as binding rules (§8.3); legacy deleted only at gate | -| MP1 extraction (`AcDream.Content`) breaks the mesh path | mechanical move, no rewrite; pak equivalence test catches interpretation drift | -| Degrade double-touch (MP2 hide-only, MP3 slots) | accepted, small; hide plumbing (spawn-time max-dist) carries into MP3 unchanged | -| Arch abandonment / license surprise | verify license before adoption; vendorable; swappable behind system interfaces | -| Side track leaks into M1.5 time or files | separate worktree; MP4 hard-queued behind #137; sessions labeled | -| MP0 profile contradicts the assumed cost split | that's MP0's job — re-sequence and amend this doc before MP1 | -| Baked scenery diverges from runtime generation | scenery placement is a pure function of dat data; equivalence diff covers it | - -## 14. References - -- `docs/research/2026-06-23-fps-distance-degrade-handoff.md` — baseline numbers, degrade - retail anchors, measurement caveats (the §2/§3 this design consumes). -- `claude-memory/feedback_render_perf_measurement.md` — profiler lies + CPU-bound - attribution + parked levers. -- `claude-memory/project_render_pipeline_digest.md`, `project_physics_collision_digest.md` - — DO-NOT-RETRY tables binding on MP3/MP4. -- `docs/superpowers/specs/2026-06-23-datlock-contention-fix-design.md` — the shipped - `_datLock` fix MP1 supersedes (bundle → pak). -- `docs/research/2026-06-09-dat-reader-thread-safety-investigation.md` — DRW read-path - safety + the teardown fix + `DatConcurrencyStressTests`. -- `memory/reference_modern_rendering_pipeline.md` — N.5 SSBO layout the ECS Upload - system mirrors. -- `memory/reference_two_tier_streaming.md` — residency tiers the pak loads plug into. -- Arch ECS: https://github.com/genaray/Arch (NuGet `Arch`). diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index c07ae6c3..d50c6b46 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -38,7 +38,6 @@ - @@ -51,11 +50,6 @@ PreserveNewest - - - PreserveNewest - diff --git a/src/AcDream.App/Diagnostics/FrameProfiler.cs b/src/AcDream.App/Diagnostics/FrameProfiler.cs deleted file mode 100644 index 2ac85dcd..00000000 --- a/src/AcDream.App/Diagnostics/FrameProfiler.cs +++ /dev/null @@ -1,213 +0,0 @@ -using System; -using System.Diagnostics; -using System.Globalization; -using System.Text; -using AcDream.Core.Rendering; -using Silk.NET.OpenGL; - -namespace AcDream.App.Diagnostics; - -/// Stage indices for per-frame CPU attribution. -public enum FrameStage -{ - /// Whole OnUpdate body (simulation + streaming apply). - Update = 0, - /// WbMeshAdapter.Tick — staged mesh/texture GPU upload drain. - Upload = 1, - /// ImGui Render (dev overlay). - ImGui = 2, -} - -/// -/// MP0 (2026-07-05) — the permanent honest frame profiler. One -/// FrameBoundary call at the top of GameWindow.OnRender -/// measures CPU frame time as the delta between consecutive boundaries -/// (captures the FULL frame including present), brackets the frame in a -/// GPU TimeElapsed query (via ), and -/// samples per-frame allocated bytes + GC collection counts. Stage scopes -/// () attribute CPU time to Update / Upload / -/// ImGui. Emits one [frame-prof] line every ~5 s while -/// is true; costs one -/// bool check per frame when off. -/// -/// Permanent apparatus — every MP-track gate reads it; do not strip. -/// Whole-frame GPU timing self-disables under ACDREAM_WB_DIAG=1 -/// (nested TimeElapsed is illegal GL; see GpuFrameTimer). -/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. -/// -public sealed class FrameProfiler : IDisposable -{ - private const int WindowCapacity = 2048; // ~12 s at 165 fps - private const long ReportIntervalTicks = 5 * TimeSpan.TicksPerSecond; - private static readonly int StageCount = Enum.GetValues().Length; - - private readonly FrameStatsBuffer _cpuUs = new(WindowCapacity); - private readonly FrameStatsBuffer _gpuUs = new(WindowCapacity); - private readonly FrameStatsBuffer _allocBytes = new(WindowCapacity); - private readonly FrameStatsBuffer[] _stageUs; - private readonly long[] _stageAccumTicks; - private readonly bool _wbDiagActive = - Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG") == "1"; - - private GpuFrameTimer? _gpuTimer; - private long _lastBoundaryTimestamp; - private long _lastAllocBytes; - private long _lastReportTicks; - private int _gc0Base, _gc1Base, _gc2Base; - private int _framesInWindow; - private int _ownerThreadId; - private bool _threadWarned; - private bool _wbDiagNoticePrinted; - private bool _wasEnabled; - - public FrameProfiler() - { - _stageUs = new FrameStatsBuffer[StageCount]; - for (int i = 0; i < StageCount; i++) _stageUs[i] = new FrameStatsBuffer(WindowCapacity); - _stageAccumTicks = new long[StageCount]; - } - - /// - /// Call as the FIRST statement of GameWindow.OnRender. - /// - public void FrameBoundary(GL gl) - { - bool enabled = RenderingDiagnostics.FrameProfEnabled; - if (!enabled) - { - if (_wasEnabled) - { - // Dispose (not just Stop) so a later re-enable rebuilds the - // query ring fresh — a kept instance would poll slots left - // pending from BEFORE the pause and report temporally stale - // GPU samples. Safe here: this runs at the top of OnRender - // with the GL context current. - _gpuTimer?.Dispose(); - _gpuTimer = null; - _wasEnabled = false; - _lastBoundaryTimestamp = 0; - } - return; - } - - if (_ownerThreadId == 0) _ownerThreadId = Environment.CurrentManagedThreadId; - else if (!_threadWarned && _ownerThreadId != Environment.CurrentManagedThreadId) - { - _threadWarned = true; - Console.WriteLine("[frame-prof] WARNING: frame boundary crossed threads; alloc counter is per-thread and now unreliable"); - } - - long now = Stopwatch.GetTimestamp(); - long allocNow = GC.GetAllocatedBytesForCurrentThread(); - - if (!_wasEnabled) - { - // First enabled frame (startup or runtime toggle-on): establish - // baselines, emit nothing. Clear any stage ticks a StageScope - // disposed after toggle-off may have accumulated mid-pause — - // EndStage still runs on scopes that were live when the flag - // flipped, and that partial delta must not leak into the first - // re-enabled frame. - _wasEnabled = true; - _lastReportTicks = DateTime.UtcNow.Ticks; - Array.Clear(_stageAccumTicks); - _gc0Base = GC.CollectionCount(0); _gc1Base = GC.CollectionCount(1); _gc2Base = GC.CollectionCount(2); - if (_gpuTimer is null && !_wbDiagActive) _gpuTimer = new GpuFrameTimer(gl); - if (_wbDiagActive && !_wbDiagNoticePrinted) - { - _wbDiagNoticePrinted = true; - Console.WriteLine("[frame-prof] GPU frame timing OFF: ACDREAM_WB_DIAG=1 owns TimeElapsed queries (nested queries are illegal GL)"); - } - } - else - { - long cpuUs = (now - _lastBoundaryTimestamp) * 1_000_000L / Stopwatch.Frequency; - _cpuUs.Push(cpuUs); - _allocBytes.Push(allocNow - _lastAllocBytes); - for (int i = 0; i < StageCount; i++) - { - _stageUs[i].Push(_stageAccumTicks[i] * 1_000_000L / Stopwatch.Frequency); - _stageAccumTicks[i] = 0; - } - _framesInWindow++; - } - - _lastBoundaryTimestamp = now; - _lastAllocBytes = allocNow; - - if (_gpuTimer?.FrameBoundary() is long gpuUs) - _gpuUs.Push(gpuUs); - - long nowTicks = DateTime.UtcNow.Ticks; - if (nowTicks - _lastReportTicks >= ReportIntervalTicks && _framesInWindow > 0) - { - int gc0 = GC.CollectionCount(0) - _gc0Base; - int gc1 = GC.CollectionCount(1) - _gc1Base; - int gc2 = GC.CollectionCount(2) - _gc2Base; - Console.WriteLine(FormatReport(_framesInWindow, _cpuUs, _gpuUs, - gpuActive: _gpuTimer is not null, _allocBytes, gc0, gc1, gc2, _stageUs)); - _lastReportTicks = nowTicks; - _gc0Base += gc0; _gc1Base += gc1; _gc2Base += gc2; - _framesInWindow = 0; - _cpuUs.Reset(); _gpuUs.Reset(); _allocBytes.Reset(); - for (int i = 0; i < StageCount; i++) _stageUs[i].Reset(); - } - } - - /// - /// Attribute the enclosed CPU time to . - /// Usage: using var _ = profiler.BeginStage(FrameStage.Update);. - /// Zero-cost (default scope) when the profiler is off. - /// - public StageScope BeginStage(FrameStage stage) - => RenderingDiagnostics.FrameProfEnabled - ? new StageScope(this, stage, Stopwatch.GetTimestamp()) - : default; - - internal void EndStage(FrameStage stage, long startTimestamp) - => _stageAccumTicks[(int)stage] += Stopwatch.GetTimestamp() - startTimestamp; - - /// Pure report formatter — unit-tested; invariant culture. - public static string FormatReport( - int frameCount, - FrameStatsBuffer cpu, FrameStatsBuffer gpu, bool gpuActive, - FrameStatsBuffer alloc, int gc0, int gc1, int gc2, - FrameStatsBuffer[] stages) - { - var ci = CultureInfo.InvariantCulture; - var sb = new StringBuilder(256); - sb.Append("[frame-prof] n=").Append(frameCount); - sb.AppendFormat(ci, " | cpu_ms p50={0:0.0} p95={1:0.0} p99={2:0.0} max={3:0.0}", - cpu.Percentile(0.50) / 1000.0, cpu.Percentile(0.95) / 1000.0, - cpu.Percentile(0.99) / 1000.0, cpu.Max() / 1000.0); - if (gpuActive) - sb.AppendFormat(ci, " | gpu_ms p50={0:0.0} p95={1:0.0}", - gpu.Percentile(0.50) / 1000.0, gpu.Percentile(0.95) / 1000.0); - else - sb.Append(" | gpu=off(wbdiag)"); - sb.AppendFormat(ci, " | alloc_kb p50={0:0.0} max={1:0.0} gc={2}/{3}/{4}", - alloc.Percentile(0.50) / 1024.0, alloc.Max() / 1024.0, gc0, gc1, gc2); - string[] names = { "upd", "upl", "imgui" }; - for (int i = 0; i < stages.Length && i < names.Length; i++) - sb.AppendFormat(ci, " | {0} p50={1:0.0} p95={2:0.0}", - names[i], stages[i].Percentile(0.50) / 1000.0, stages[i].Percentile(0.95) / 1000.0); - return sb.ToString(); - } - - public void Dispose() => _gpuTimer?.Dispose(); -} - -/// Disposable stage scope; default instance is a no-op. -public readonly struct StageScope : IDisposable -{ - private readonly FrameProfiler? _owner; - private readonly FrameStage _stage; - private readonly long _start; - - internal StageScope(FrameProfiler owner, FrameStage stage, long start) - { - _owner = owner; _stage = stage; _start = start; - } - - public void Dispose() => _owner?.EndStage(_stage, _start); -} diff --git a/src/AcDream.App/Diagnostics/FrameStatsBuffer.cs b/src/AcDream.App/Diagnostics/FrameStatsBuffer.cs deleted file mode 100644 index 60773235..00000000 --- a/src/AcDream.App/Diagnostics/FrameStatsBuffer.cs +++ /dev/null @@ -1,69 +0,0 @@ -using System; - -namespace AcDream.App.Diagnostics; - -/// -/// MP0 (2026-07-05) — fixed-capacity ring buffer of long samples -/// (microseconds or bytes) with percentile/max over the current window. -/// Pure and allocation-free after construction: -/// sorts into a preallocated scratch array, so the 5-second report path -/// allocates nothing. Not thread-safe — owned by the window loop thread. -/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. -/// -public sealed class FrameStatsBuffer -{ - private readonly long[] _samples; - private readonly long[] _scratch; - private int _cursor; - private int _count; - - public FrameStatsBuffer(int capacity) - { - if (capacity <= 0) throw new ArgumentOutOfRangeException(nameof(capacity)); - _samples = new long[capacity]; - _scratch = new long[capacity]; - } - - public int Count => _count; - - public void Push(long value) - { - _samples[_cursor] = value; - _cursor = (_cursor + 1) % _samples.Length; - if (_count < _samples.Length) _count++; - } - - public void Reset() - { - _cursor = 0; - _count = 0; - } - - /// - /// Nearest-rank percentile over the current window: element at - /// ceil(q·n) in the ascending sort (1-based), 0 when empty. - /// - public long Percentile(double q) - { - if (_count == 0) return 0; - Array.Copy(_samples, _scratch, _count); - Array.Sort(_scratch, 0, _count); - int rank = (int)Math.Ceiling(q * _count); // 1-based nearest rank - if (rank < 1) rank = 1; - if (rank > _count) rank = _count; - return _scratch[rank - 1]; - } - - public long Max() - { - if (_count == 0) return 0; // documented empty behavior, matches Percentile - // Seed from the first live sample so all-negative windows (the alloc - // channel can go negative if the boundary ever crosses threads) return - // the true max instead of clamping to 0. Slots [0.._count) are always - // the live window regardless of ring wraparound. - long max = _samples[0]; - for (int i = 1; i < _count; i++) - if (_samples[i] > max) max = _samples[i]; - return max; - } -} diff --git a/src/AcDream.App/Diagnostics/GpuFrameTimer.cs b/src/AcDream.App/Diagnostics/GpuFrameTimer.cs deleted file mode 100644 index d75621e9..00000000 --- a/src/AcDream.App/Diagnostics/GpuFrameTimer.cs +++ /dev/null @@ -1,87 +0,0 @@ -using System; -using Silk.NET.OpenGL; - -namespace AcDream.App.Diagnostics; - -/// -/// MP0 (2026-07-05) — whole-frame GPU time via a ring of -/// queries (depth 4, so results are -/// read ~3 frames late and never stall). Mirrors WbDrawDispatcher's query -/// idiom including the #125 lesson: a glGenQueries name is not a query -/// OBJECT until first glBeginQuery, so never-begun slots are skipped via -/// the Begun flags. -/// -/// MUST NOT be active while ACDREAM_WB_DIAG=1: GL forbids two -/// simultaneously active TimeElapsed queries and WbDrawDispatcher brackets -/// its passes with them under that flag. The caller (FrameProfiler) -/// enforces the exclusion; this class just does the ring. -/// -internal sealed class GpuFrameTimer : IDisposable -{ - private const int RingDepth = 4; - - private readonly GL _gl; - private readonly uint[] _queries = new uint[RingDepth]; - private readonly bool[] _begun = new bool[RingDepth]; - private int _frameIndex; - private bool _queryActive; - - public GpuFrameTimer(GL gl) - { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); - for (int i = 0; i < RingDepth; i++) - _queries[i] = _gl.GenQuery(); - } - - /// - /// Call once per frame at the frame boundary. Ends the previous - /// frame's query, polls the oldest slot non-blocking, begins this - /// frame's query. Returns the completed GPU time in microseconds for - /// a ~RingDepth-frames-old frame, or null when no result is ready. - /// - public long? FrameBoundary() - { - if (_queryActive) - { - _gl.EndQuery(QueryTarget.TimeElapsed); - _queryActive = false; - } - - long? completedUs = null; - int readSlot = _frameIndex % RingDepth; // about to be reused — oldest - if (_begun[readSlot]) - { - _gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.ResultAvailable, out int avail); - if (avail != 0) - { - _gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.Result, out ulong ns); - completedUs = (long)(ns / 1000UL); - } - // Not available ⇒ sample silently dropped (same policy as - // WbDrawDispatcher) — percentiles tolerate missing samples. - } - - _gl.BeginQuery(QueryTarget.TimeElapsed, _queries[readSlot]); - _begun[readSlot] = true; - _queryActive = true; - _frameIndex++; - return completedUs; - } - - /// End any active query without beginning a new one (used when the profiler is toggled off mid-session). - public void Stop() - { - if (_queryActive) - { - _gl.EndQuery(QueryTarget.TimeElapsed); - _queryActive = false; - } - } - - public void Dispose() - { - Stop(); - for (int i = 0; i < RingDepth; i++) - _gl.DeleteQuery(_queries[i]); - } -} diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 60b38412..5332fe37 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -52,12 +52,14 @@ public readonly record struct MovementResult( float? SidestepSpeed, float? TurnSpeed, bool IsRunning = false, + uint? LocalAnimationCommand = null, // which cycle to play on the local player (RunForward when running) // K-fix5 (2026-04-26): cycle-pace multiplier for the LOCAL animation // sequencer. Decoupled from ForwardSpeed so the wire can keep sending // 1.0 for WalkBackward (ACE-compatible) while the animation plays at // runRate × so the cycle visually matches the run-speed velocity. // Forward+Run = runRate (same as ForwardSpeed); Backward+Run, Strafe+Run // = runRate (where ForwardSpeed is 1.0 / null); everything else = 1.0. + float LocalAnimationSpeed = 1f, bool JustLanded = false, // true on the single frame we transitioned airborne → grounded float? JumpExtent = null, // non-null when a jump was triggered this frame Vector3? JumpVelocity = null); // BODY-LOCAL launch velocity (forward/right/up relative to facing) — see PlayerMovementController jump path for the inverse-yaw conversion. Server rotates body→world on broadcast. @@ -180,24 +182,13 @@ public sealed class PlayerMovementController // to drive the landing animation cycle. private bool _wasAirborneLastFrame; - // Previous frame's motion commands for change detection (wire cadence). + // Previous frame's motion commands for change detection. private uint? _prevForwardCmd; private uint? _prevSidestepCmd; private uint? _prevTurnCmd; private float? _prevForwardSpeed; private bool _prevRunHold; - - // R3-W6: previous frame's HELD-KEY state — the edge detector feeding - // retail's DoMotion/StopMotion/set_hold_run calls (retail's - // CommandInterpreter altitude: motion dispatch happens on key EDGES, - // never level-triggered per-frame). - private bool _prevForwardHeld; - private bool _prevBackwardHeld; - private bool _prevStrafeLeftHeld; - private bool _prevStrafeRightHeld; - private bool _prevTurnLeftHeld; - private bool _prevTurnRightHeld; - private bool _prevRunHeld; + private uint? _prevLocalAnimCmd; // Heartbeat timer. // Cadence is 1.0 sec to match holtburger's @@ -260,69 +251,83 @@ public sealed class PlayerMovementController private Vector3 _prevPhysicsPos; private Vector3 _currPhysicsPos; - // ── R4-V5: the verbatim retail MoveToManager replaces B.6 auto-walk ── - // The B.6 DriveServerAutoWalk overlay (synthesized turn-first phase, - // 30° walk-while-turning band, one-shot walk/run decision, invented - // arrival epsilon — registers AD-26/AD-8-local) is DELETED; server - // MoveTos for the local player now run through the SAME verbatim - // MoveToManager remotes use (R4-V4), bound below by GameWindow's - // EnterPlayerModeNow beside the R3-W6 DefaultSink bind. + // ── B.6 slice 2 (2026-05-14): local-player server-initiated auto-walk ── + // When ACE sends a MoveToObject motion for the local player (out-of-range + // Use / PickUp triggers ACE's server-side CreateMoveToChain), the wire + // payload includes a destination, arrival predicates, and a run rate. + // Retail's MovementManager::PerformMovement (0x00524440 case 6) runs a + // LOCAL auto-walk in response: heading correction toward the target, + // run-forward velocity at the wire's runRate, arrival detection via + // MoveToManager::HandleMoveToPosition. Here we keep the active auto-walk + // state and inject it into Update() as a synthesized Forward+Run input + // so the existing motion-interpreter / body-velocity pipeline runs + // unchanged. Spec: docs/superpowers/specs/2026-05-14-phase-b6-design.md. + private bool _autoWalkActive; + private Vector3 _autoWalkDestination; + private float _autoWalkMinDistance; + private float _autoWalkDistanceToObject; + private bool _autoWalkMoveTowards; + // 2026-05-16 (retail-faithful) — walk-vs-run is a ONE-SHOT + // decision at chain start. Per user observation 2026-05-16: if + // initial distance is at or above the walk-run threshold, the + // body runs all the way to the target; otherwise it walks all + // the way. No per-frame switching as the player closes distance. + // + // Formula matches retail's MovementParameters::get_command + // (decomp 0x0052aa00, line 308000+): + // running = (initialDist - distance_to_object) >= walk_run_threshhold + // The "distance left to walk" (current minus use-radius) is + // compared against the wire-supplied threshold (15m default, + // retail constant at 0x005243b5). The retail function reads + // `arg2` as the current distance but in practice is called at + // chain setup with the initial distance, and the resulting + // decision is cached for the rest of the chain — matching the + // user-observed "run all the way / walk all the way" behaviour. + private bool _autoWalkInitiallyRunning; /// - /// R5-V5: retail CPhysicsObj::movement_manager (acclient.h - /// /* 3463 */) — the ONE owner of this controller's - /// + pair. Constructed in the - /// ctor around the interp; the MoveToManager side arrives via - /// MoveToFactory + MakeMoveToManager() in - /// GameWindow.EnterPlayerModeNow (the same facade shape - /// EnsureRemoteMotionBindings gives remotes). - /// ticks UseTime() (0x005242f0) at the slot the deleted - /// DriveServerAutoWalk occupied and relays HitGround() - /// (0x00524300, minterp first then moveto). + /// True while a server-initiated auto-walk (MoveToObject inbound) is + /// active on the local player. Update drives the body's velocity + /// and motion state machine DIRECTLY from the wire-supplied path + /// data, NOT via synthesized player-input. The + /// motion-state-change detection downstream sees no user input + /// during auto-walk, so no MoveToState wire packet is built — ACE's + /// server-side MoveToChain can run uninterrupted until its callback + /// fires. /// - public AcDream.Core.Physics.Motion.MovementManager Movement { get; } + public bool IsServerAutoWalking => _autoWalkActive; + + // 2026-05-16 (issue #75) — tracks whether the auto-walk overlay is + // actually advancing the body this frame. False during the + // turn-first phase (rotating in place toward target) and after + // arrival. Drives the animation cycle override: walking animation + // only plays when the body is actually moving forward. + private bool _autoWalkMovingForwardThisFrame; + + // 2026-05-16 (issue #69 fix) — turn direction this frame. + // +1 = rotating counter-clockwise (Yaw increasing) → TurnLeft cycle + // -1 = rotating clockwise (Yaw decreasing) → TurnRight cycle + // 0 = aligned or not turning + // Drives the animation cycle override during turn-first phase so + // the body plays the actual turn animation instead of statue-pivoting. + private int _autoWalkTurnDirectionThisFrame; /// - /// R4-V5: the local player's verbatim retail MoveToManager - /// (decomp 0x00529010-0x0052a987), constructed + seam-bound by - /// GameWindow.EnterPlayerModeNow against this controller's - /// /body/Yaw (the same wiring shape - /// EnsureRemoteMotionBindings uses for remotes). GameWindow - /// routes inbound mt 6-9 movement events through - /// . - /// User input cancels a moveto through the retail chain: key edge → - /// DoMotion (ctor-default params, CancelMoveTo bit set) → - /// → - /// CancelMoveTo(ActionCancelled) (register TS-36 retired). - /// R5-V5: a view of 's moveto child; the setter is - /// sugar over the facade's factory path (kept for the - /// PlayerMoveToCutoverTests rig and any pre-facade bind shape). + /// Fires once when an auto-walk reaches its destination naturally + /// (i.e. called with + /// reason="arrived"). Does NOT fire on user-input cancel or + /// on a re-target (BeginServerAutoWalk overwriting state). + /// + /// + /// Host () subscribes to re-send + /// the Use/PickUp action that triggered the auto-walk — without + /// this, ACE's server-side MoveToChain may have already timed out + /// by the time our local body arrives, so the action wouldn't + /// fire. Re-sending the action close-range hits ACE's WithinUseRadius + /// fast-path and completes immediately. + /// /// - public AcDream.Core.Physics.Motion.MoveToManager? MoveTo - { - get => Movement.MoveTo; - set - { - var mtm = value ?? throw new ArgumentNullException(nameof(value)); - Movement.MoveToFactory = () => mtm; - Movement.MakeMoveToManager(); - } - } - - /// - /// R5-V3 (#171): the player's PositionManager facade (retail - /// CPhysicsObj::position_manager — owned by the player's - /// EntityPhysicsHost, handed here by EnterPlayerModeNow). - /// drives it at the two retail per-tick points: - /// AdjustOffset inside the physics-tick block (retail - /// UpdatePositionInternal @0x00512d0e, BEFORE - /// UpdatePhysicsInternal so the sticky steer is part of the swept - /// motion) and UseTime after the completed-motions sweep (retail - /// UpdateObjectInternal tail @0x005159b3 — the sticky 1 s lease - /// watchdog). tears any - /// stick down (retail teleport_hook @0x00514eee). - /// - public AcDream.Core.Physics.Motion.PositionManager? PositionManager { get; set; } + public event Action? AutoWalkArrived; public PlayerMovementController(PhysicsEngine physics) { @@ -334,10 +339,8 @@ public sealed class PlayerMovementController }; // Default skills — tuned toward mid-retail feel. Real characters' - // skills come from PlayerDescription (0xF7B0/0x0013) — GameWindow - // pushes them via SetCharacterSkills once the controller exists - // (K-fix7; PD arrives at login before auto-entry). These env-var - // defaults only cover tests / pre-PD frames: + // skills come from PlayerDescription (0xF7B0/0x0013) which we don't + // parse yet; override via env vars: // ACDREAM_RUN_SKILL, ACDREAM_JUMP_SKILL // K-fix6 (2026-04-26): bumped default jump skill from 200 → 300. // Retail formula: height = (skill/(skill+1300))*22.2 + 0.05 (extent=1): @@ -350,15 +353,6 @@ public sealed class PlayerMovementController int jumpSkill = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_JUMP_SKILL"), out var jsv) ? jsv : 300; _weenie = new PlayerWeenie(runSkill: runSkill, jumpSkill: jumpSkill); _motion = new MotionInterpreter(_body, _weenie); - // R5-V5: the MovementManager facade owns the interp from birth - // (retail CPhysicsObj::movement_manager); the moveto child binds - // later via MoveToFactory (EnterPlayerModeNow / the test rigs). - Movement = new AcDream.Core.Physics.Motion.MovementManager(_motion); - // R3-W4 (A3): the local player's movement is input-driven — - // movement_is_autonomous true so apply_current_movement's dual - // dispatch routes apply_raw_movement (IsThePlayer && autonomous). - // R3-W6 refines this per-motion (server-controlled MoveTo clears it). - _body.LastMoveWasAutonomous = true; } public void SetCharacterSkills(int runSkill, int jumpSkill) @@ -366,39 +360,6 @@ public sealed class PlayerMovementController _weenie.SetSkills(runSkill, jumpSkill); } - /// - /// R3-W2 (r3-port-plan.md §4): the player's - /// — GameWindow binds the player sequencer's MotionDone seam to it so the - /// pending_motions queue pops in step with animation completion, same - /// path remotes use. R3-W6 widens this into the full local-player - /// unification. - /// - internal MotionInterpreter Motion => _motion; - - /// R4-V5: CONTACT transient-state bit (retail - /// transient_state & 1) — the manager's - /// UseTime tick gate reads exactly this bit (decomp §6a @307781; a - /// strict subset of the funnel's Contact+OnWalkable gate). - internal bool BodyInContact => _body.InContact; - - /// R4-V5: body orientation for the - /// manager's position seam (re-derived from every - /// Update — heading reads/writes go through Yaw, not this). - internal Quaternion BodyOrientation => _body.Orientation; - - /// R4-V5 wedge fix — the P1 unpack store - /// (CPhysics::SetObjectMovement @00509730: - /// last_move_was_autonomous = arg7, written on the unpack path - /// for every applied movement event). GameWindow's local-player 0xF74C - /// branch stores the wire autonomous byte here (always false — the P1 - /// gate drops autonomous echoes); the speculative use install stores - /// false too (it models the wire mt-6 ACE sends moments later). Routes - /// the per-tick pump's A3 dual dispatch to the INTERPRETED branch - /// during a server moveto so apply_raw can't clobber the manager's - /// dispatched motions with the idle raw state. - internal void SetLastMoveWasAutonomous(bool autonomous) - => _body.LastMoveWasAutonomous = autonomous; - /// /// Wire the player's AnimationSequencer current cycle velocity into /// . When attached, @@ -426,6 +387,97 @@ public sealed class PlayerMovementController _motion.GetCycleVelocity = accessor; } + /// + /// Apply a server-echoed run rate (ForwardSpeed from UpdateMotion) to the + /// player's MotionInterpreter. The server broadcasts the real RunRate + /// derived from the character's Run skill; wiring it here ensures + /// get_state_velocity produces the correct speed instead of the default 1.0. + /// + public void ApplyServerRunRate(float forwardSpeed) + { + _motion.InterpretedState.ForwardSpeed = forwardSpeed; + _motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + } + + /// + /// B.6 slice 2 (2026-05-14). Install a server-initiated auto-walk + /// against this body. will synthesize + /// Forward+Run input and steer toward + /// until the body reaches the + /// arrival predicate (moveTowards: dist ≤ distanceToObject; + /// !moveTowards: dist ≥ minDistance) or the user presses any + /// movement key (which auto-cancels). + /// + /// + /// Retail reference: MovementManager::PerformMovement + /// (0x00524440) case 6 — unpacks the wire's target + + /// origin + run rate and calls CPhysicsObj::MoveToObject on + /// the local body. We do the equivalent at acdream's altitude: + /// hold the destination + thresholds + run rate locally, let the + /// existing per-tick motion machinery do the walking, and arrive + /// when the horizontal distance hits the threshold. + /// + /// + /// + /// The run-rate parameter is the EFFECTIVE rate after the + /// mtRun=0 fallback chain — the caller (GameWindow) is + /// responsible for substituting a non-zero rate when ACE sends 0.0 + /// on the wire, per the trace finding in the design spec. + /// + /// + public void BeginServerAutoWalk( + Vector3 destinationWorld, + float minDistance, + float distanceToObject, + bool moveTowards, + bool canCharge) + { + _autoWalkActive = true; + _autoWalkDestination = destinationWorld; + _autoWalkMinDistance = minDistance; + _autoWalkDistanceToObject = distanceToObject; + _autoWalkMoveTowards = moveTowards; + + // Issue #77 fix (2026-05-18) — retail-faithful walk-vs-run. + // + // Retail's MovementParameters::get_command (decomp 0x0052aa00) + // gates run on the CanCharge flag (bit 0x10 of + // MovementParameters). Cleared → fall through to the inner + // walk_run_threshold check, which ACE's 15 m wire default + + // 0.6 m use-radius makes practically always walk for any + // chase under 15.6 m. Set → unconditional HoldKey_Run. + // + // ACE's Creature.SetWalkRunThreshold sets CanCharge when + // (server-side player→target distance) >= WalkRunThreshold / + // 2 (= 7.5 m for the 15 m default), and clears it otherwise. + // The CanCharge bit IS the wire-side walk-vs-run answer; we + // just relay it. + // + // Previously we hardcoded a 1.0 m threshold against + // initialDist - distanceToObject, which forced run at any + // chase past ~1.6 m — including the 3-5 m "walk range" the + // user expected to walk in (issue #77 reproduction). Honoring + // CanCharge restores the retail bucket: walk under ~7.5 m, + // run beyond. + _autoWalkInitiallyRunning = canCharge; + } + + /// + /// B.6 slice 2 (2026-05-14). Cancel any active server-initiated + /// auto-walk. Idempotent. is logged when + /// is on so + /// the trace shows why the auto-walk ended. + /// + public void EndServerAutoWalk(string reason) + { + if (!_autoWalkActive) return; + _autoWalkActive = false; + if (PhysicsDiagnostics.ProbeAutoWalkEnabled) + Console.WriteLine($"[autowalk-end] reason={reason}"); + if (reason == "arrived") + AutoWalkArrived?.Invoke(); + } + /// /// 2026-05-16. Called by the network outbound layer after every /// AutonomousPosition or MoveToState that carries the player's @@ -449,6 +501,270 @@ public sealed class PlayerMovementController _lastSentInitialized = true; } + /// + /// B.6 slice 2 (2026-05-14). If a server-initiated auto-walk is + /// active, either cancel it (user pressed a movement key) or + /// synthesize a Forward+Run input with stepped + /// toward the destination. Returns the (possibly modified) input + /// for the rest of to consume. + /// + /// + /// Heading correction matches + /// — ± + /// snap-on-aligned, otherwise rotate at + /// . Arrival + /// predicate matches retail's + /// MoveToManager::HandleMoveToPosition: chase arrives at + /// distanceToObject; flee arrives at minDistance. + /// + /// + /// + /// 2026-05-16 (issue #75 refactor) — drive the body directly from + /// the wire-supplied path data during server-initiated auto-walk, + /// without synthesizing player-input. Replaces the earlier + /// ApplyAutoWalkOverlay which returned a synthesized Forward+Run + /// MovementInput; that synthesis leaked to the wire as an outbound + /// MoveToState packet ("user is RunForward") which ACE read as + /// user-took-manual-control and cancelled its own MoveToChain. The + /// architecture now mirrors retail's MovementManager::PerformMovement + /// case 6 (decomp 0x00524440): step the body's velocity + motion + /// state directly; the user-input pipeline downstream sees no input + /// because the user didn't press anything, so no MoveToState gets + /// built. + /// + /// + /// Returns true when this method consumed motion control for + /// the frame (auto-walk active, no user override, no arrival). + /// Caller () must skip the user-input motion + + /// body-velocity sections to avoid them overriding the auto-walk's + /// velocity assignment. + /// + /// + private bool DriveServerAutoWalk(float dt, MovementInput input) + { + _autoWalkMovingForwardThisFrame = false; + _autoWalkTurnDirectionThisFrame = 0; + if (!_autoWalkActive) return false; + + // User-input cancellation. Any direct movement key takes over. + // Mouse-only turning (no movement key) doesn't cancel — the + // user might just be looking around mid-walk. + bool userOverride = input.Forward || input.Backward + || input.StrafeLeft || input.StrafeRight + || input.TurnLeft || input.TurnRight; + if (userOverride) + { + EndServerAutoWalk("user-input"); + return false; + } + + // Horizontal distance to target — server owns Z, our local body + // Z snaps to UpdatePosition broadcasts when ACE sends them. + var pos = _body.Position; + float dx = _autoWalkDestination.X - pos.X; + float dy = _autoWalkDestination.Y - pos.Y; + float dist = MathF.Sqrt(dx * dx + dy * dy); + + // Arrival predicate. With the 10 Hz heartbeat from 301281d the + // server-side Player.Location tracks our body within ~100 ms, so + // the previous "subtract 0.2 m safety margin" workaround is no + // longer needed. Tiny 0.05 m margin remains to absorb the + // sub-tick race between local arrival-fire and the next + // heartbeat's outbound packet. + // + // ARRIVAL IS GATED ON ALIGNMENT: we only end the auto-walk once + // the body is BOTH within use-radius AND facing the target. + // Without the alignment gate, a Use on a close target while + // facing away would end immediately and the body wouldn't turn + // at all (user feedback 2026-05-15: 'when I'm close I'm not + // facing'). The alignment check is computed below in the same + // block as the heading-step; we defer the arrival fire-and-end + // until after we've inspected `aligned`. + float arrivalThreshold = _autoWalkMoveTowards + ? _autoWalkDistanceToObject + : _autoWalkMinDistance; + // 2026-05-16 — retail "stop at the radius" semantics. + // Previously had a 0.05 m TinyMargin inside the threshold to + // ensure ACE's server-side WithinUseRadius poll saw us inside + // the radius before our next AP heartbeat. With the + // diff-driven AP cadence (Task B2) ACE sees the final position + // the same frame we arrive — no margin needed. Retail's + // arrival check is `dist <= radius` exact at + // CMotionInterp::apply_interpreted_movement integration. + bool withinArrival = + (_autoWalkMoveTowards + && dist <= arrivalThreshold) + || (!_autoWalkMoveTowards + && dist >= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon); + + // Step Yaw toward target. Convention from Update line 364: + // _body.Orientation = Quaternion.CreateFromAxisAngle(Z, Yaw - π/2), + // so local-forward (+Y) maps to world (cos Yaw, sin Yaw, 0). + // Therefore Yaw that faces (dx,dy) is atan2(dy, dx). + // + // User feedback (2026-05-15): 'I should face that object and then + // start moving. Now it starts running before facing is complete.' + // Track the current heading delta — if we're more than the + // walk-while-turning threshold off, suppress Forward this frame + // so the body turns IN PLACE first. Once we're within the + // threshold, the synthesised Forward+Run kicks in below. + bool aligned = true; + bool walkAligned = true; + if (dist > 1e-4f) + { + float desiredYaw = MathF.Atan2(dy, dx); + float delta = desiredYaw - Yaw; + while (delta > MathF.PI) delta -= 2f * MathF.PI; + while (delta < -MathF.PI) delta += 2f * MathF.PI; + + // Retail-faithful local rotation: rotate continuously at + // TurnRate, never snap until overshoot would occur. Retail's + // MoveToManager::HandleTurnToHeading (0x0052a0c0) only snaps + // when heading_greater() detects we've crossed the target — + // there's no "snap when close" tolerance band. The earlier + // 20° snap was borrowed wrongly from RemoteMoveToDriver + // (which is the sparse-update-fudge path for remotes). + // + // MathF.Min(|delta|, maxStep) naturally clamps the final + // fractional step to exactly delta, so we land on the + // target heading without overshoot. + // 2026-05-16 — retail-faithful turn rate. Auto-walk's + // run/walk decision (one-shot at chain start) drives the + // turn rate: running rotation is 50% faster per + // run_turn_factor at retail 0x007c8914. + float maxStep = RemoteMoveToDriver.TurnRateFor(_autoWalkInitiallyRunning) * dt; + float yawStep = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep); + Yaw += yawStep; + while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI; + while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI; + + // 2026-05-16 (issue #69) — record rotation direction so the + // animation override can pick the TurnLeft/TurnRight cycle. + // Sign convention matches user-driven A/D in Update: + // yawStep > 0 ⇔ TurnLeft (Yaw increases) + // yawStep < 0 ⇔ TurnRight (Yaw decreases) + // Small dead-zone avoids flickering between Turn cycles + // when the residual delta is effectively zero. + if (MathF.Abs(yawStep) > 1e-5f) + _autoWalkTurnDirectionThisFrame = yawStep > 0f ? +1 : -1; + + // Two alignment thresholds: + // walkWhileTurning (30°): outside this, body turns in place. + // Inside, body walks forward while + // finishing residual alignment. + // fullyAligned (5°): the arrival-fire alignment. ACE + // rotates server-side via Rotate(target) + // BEFORE invoking the Use callback — + // user reported 'it does not face it + // completely', so the final-alignment + // check must be tighter than the + // walking gate. + const float WalkWhileTurningRad = 30f * MathF.PI / 180f; + const float FullyAlignedRad = 5f * MathF.PI / 180f; + walkAligned = MathF.Abs(delta) <= WalkWhileTurningRad; + aligned = MathF.Abs(delta) <= FullyAlignedRad; + } + + // End the auto-walk once the body is BOTH within use radius + // AND aligned with the target. This is the alignment-gated + // arrival the comment above flagged: a close-range Use on a + // target behind the player still rotates the body first. + if (withinArrival && aligned) + { + EndServerAutoWalk("arrived"); + return false; + } + + // Walk vs run uses the one-shot decision from BeginServerAutoWalk + // (initial distance minus use-radius vs walkRunThreshold). + // Held for the rest of the auto-walk so the body runs all + // the way to a far target, or walks all the way to a near + // one — matching user-observed retail behaviour. + bool shouldRun = _autoWalkInitiallyRunning; + + // Turn-first gate: if not yet within the 30° walking band, + // suppress forward motion so the body turns in place rather + // than walking an arc. Also suppress when already within + // arrival — we just turned to face it; no need to step forward + // into it. + bool moveForward = walkAligned && !withinArrival; + + if (!moveForward) + { + // Turn-in-place phase. Two sub-cases land here: + // (a) initial turn — body must rotate to face the target + // before we drive forward (walkAligned == false at chain + // start, body is stationary). + // (b) overshoot recovery — body crossed the destination, so + // desiredYaw flipped ~180° and walkAligned dropped to + // false; body needs to turn around before walking back. + // (c) settling — body is within use-radius but not aligned + // enough to fire arrival (withinArrival == true, + // !aligned); body holds position while finishing rotation + // so the arrival predicate fires on the next tick. + // + // Issue #77 fix: explicitly zero horizontal velocity. Without + // this, in case (b) the body keeps the prior frame's running + // velocity (RunAnimSpeed × runRate ≈ 11 m/s) and slides past + // the destination by several meters before the turn-around + // rotation completes — the "runs and slides away, runs back, + // picks up" symptom reported in issue #77 / bug B. Cases (a) + // and (c) zero a velocity that's already zero, so the change + // is a no-op there. + // + // The motion-interpreter state also has to step out of + // WalkForward so get_state_velocity (used downstream) reports + // standing-velocity, not the prior frame's run-speed. + _motion.DoMotion(MotionCommand.Ready, 1.0f); + if (_body.OnWalkable) + { + float savedWorldVz = _body.Velocity.Z; + _body.set_local_velocity(new Vector3(0f, 0f, savedWorldVz)); + } + return true; + } + + // Drive motion state machine + body velocity directly. This + // mirrors what the user-input section would have done with + // synthesized Forward+Run, but without putting anything into + // MovementInput — so the outbound-packet pipeline never builds + // a MoveToState packet for auto-walk frames. + uint forwardCmd; + float forwardCmdSpeed; + if (shouldRun && _weenie.InqRunRate(out float runRate)) + { + // Wire-compatible: WalkForward command @ runRate triggers + // ACE's auto-upgrade to RunForward for observers. Same + // shape as the user-input section's running path. + forwardCmd = MotionCommand.WalkForward; + forwardCmdSpeed = runRate; + } + else + { + forwardCmd = MotionCommand.WalkForward; + forwardCmdSpeed = 1.0f; + } + + _autoWalkMovingForwardThisFrame = true; + + // Update interpreted motion state — drives the animation cycle + // via UpdatePlayerAnimation downstream + the MotionInterpreter's + // state-velocity getter (used for our velocity assignment below). + _motion.DoMotion(forwardCmd, forwardCmdSpeed); + + // Set body velocity directly. Only meaningful when grounded; + // mirror the user-input section's `if (_body.OnWalkable)` gate + // so we don't override gravity/jump velocity mid-air. + if (_body.OnWalkable) + { + float savedWorldVz = _body.Velocity.Z; + var stateVel = _motion.get_state_velocity(); + _body.set_local_velocity(new Vector3(0f, stateVel.Y, savedWorldVz)); + } + + return true; + } + // L.2a slice 1 (2026-05-12): centralized CellId mutation so the // [cell-transit] probe fires from a single chokepoint. Both the // server-snap path (SetPosition) and the per-frame resolver path @@ -476,22 +792,8 @@ public sealed class PlayerMovementController } public void SetPosition(Vector3 pos, uint cellId) - // #145: tests + legacy callers run in the world==block-local frame (no - // streaming center), so the cell-local seed IS the world position. This - // makes the carried anchor (body.Position − CellPosition.Origin) == (0,0,0), - // identical to the legacy Zero terrain-origin fallback → behaviour unchanged. - => SetPosition(pos, cellId, pos); - - /// - /// Server-snap / teleport placement. is the - /// LANDBLOCK-relative position (the wire's local, or world − landblock origin) - /// which seeds the body's cell-relative CellPosition WITHOUT any streaming - /// center (#145). A teleport is a large jump, so this snaps the cell frame - /// directly via SnapToCell rather than delta-syncing through the setter. - /// - public void SetPosition(Vector3 pos, uint cellId, Vector3 cellLocal) { - _body.SnapToCell(cellId, pos, cellLocal); + _body.Position = pos; _prevPhysicsPos = pos; _currPhysicsPos = pos; UpdateCellId(cellId, "teleport"); @@ -500,34 +802,6 @@ public sealed class PlayerMovementController _body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable; _body.Velocity = Vector3.Zero; - // #145 Slice 7: idle the motion interpreter on a server snap / teleport arrival. - // SetPosition zeros the body velocity, but the motion interpreter still holds the - // PRE-teleport ForwardCommand (e.g. RunForward), so the next Update() would - // reconstruct that run vector via get_state_velocity and the player would sprint - // off in the old direction the instant input resumes. Resetting the forward - // command to Ready makes the player arrive at rest. - // R3-W6: retail's teleport idle is a FULL stop (StopCompletely - // 0x00527e40: resets fwd/sidestep/turn COMMANDS, zeroes velocity, - // enqueues the A9 jump-snapshot node) — not a bare DoMotion(Ready). - _motion.StopCompletely(); - // R5-V3 (#171): retail teleport_hook (0x00514ed0) — PositionManager:: - // UnStick (@0x00514eee) right after the moveto cancel: a teleport - // tears down any active stick. (StopInterpolating/UnConstrain have no - // armed acdream counterparts — no local-player InterpolationManager, - // constraint leash unarmed per #167.) - PositionManager?.UnStick(); - // Reset the edge tracker: the stop wiped the motion state, so keys - // still physically held must re-fire as press edges on the next - // Update (matches the pre-W6 level-triggered behavior of walking - // straight out of a teleport while W stays held). - _prevForwardHeld = false; - _prevBackwardHeld = false; - _prevStrafeLeftHeld = false; - _prevStrafeRightHeld = false; - _prevTurnLeftHeld = false; - _prevTurnRightHeld = false; - _prevRunHeld = false; - // Reset physics clock so any subsequent update_object calls start fresh. _body.LastUpdateTime = 0.0; _physicsAccum = 0f; @@ -543,37 +817,22 @@ public sealed class PlayerMovementController { _simTimeSeconds += dt; - // R4-V5: tick the verbatim MoveToManager at the slot the deleted - // DriveServerAutoWalk occupied — after inbound wire routing, before - // the input-driven motion sections. UseTime runs the retail per-tick - // moveto drivers (HandleMoveToPosition aux-turn steering + arrival + - // fail-distance / HandleTurnToHeading); the motions it dispatches - // land in InterpretedState through _DoMotion → DoInterpretedMotion, - // and sections 1/2 below turn that state into Yaw rotation + body - // velocity — the same per-frame apply user input gets. No - // synthesized input exists, so the outbound-packet pipeline sees no - // user motion and never builds a MoveToState mid-moveto (the #75 - // invariant, now by construction). Provisional tick placement until - // R6 ports retail's UpdateObjectInternal ordering (r4-port-plan.md - // §3 placement decision). R5-V5: the facade relay - // (MovementManager::UseTime 0x005242f0 — moveto side only). - Movement.UseTime(); - - // R4-V5 wedge fix (2026-07-03 live door bug), retail's per-tick - // completion slot: CPartArray::HandleMovement — a tailcall chain to - // MotionTableManager::CheckForCompletedMotions (0x00517d60 → - // 0x0051bfd0) — runs every tick right AFTER MovementManager::UseTime - // in UpdateObjectInternal (@005159a4). It flushes pending_animations - // entries whose animations already ran out so their AnimationDone → - // MotionDone pops land each tick even when no op fired that frame. - // (apply_current_movement is NOT part of the per-tick order — its - // retail callers are all event-driven: hold-key toggles, HitGround/ - // LeaveGround, ReportExhaustion.) Full tick ordering remains R6 - // scope; the companion fix is StopCompletely's previously-missing - // StopCompletely_Internal animation dispatch (see - // MotionInterpreter.StopCompletely), whose completable entry this - // slot drains. - _motion.CheckForCompletedMotions?.Invoke(); + // 2026-05-16 (issue #75 refactor): server-initiated auto-walk + // drives the body's velocity + motion state machine DIRECTLY. + // When _autoWalkActive, DriveServerAutoWalk steps Yaw, computes + // velocity from wire-supplied runRate, calls _motion.DoMotion, + // and sets _body.set_local_velocity. The user-input motion + + // velocity sections below are SKIPPED so they don't override + // the auto-walk's assignments. Critically, no synthesized input + // gets put back into `input` — the outbound-packet pipeline at + // GameWindow.cs:6410 sees user-input null/Ready throughout the + // auto-walk and never builds a MoveToState packet, leaving + // ACE's server-side MoveToChain to run uninterrupted until its + // TryUseItem/TryPickUp callback fires. Retail equivalent: + // MovementManager::PerformMovement case 6 (decomp 0x00524440) + // calls CPhysicsObj::MoveToObject server-side; the local body + // is moved without ever touching CommandInterpreter input. + bool autoWalkConsumedMotion = DriveServerAutoWalk(dt, input); // Portal-space guard: while teleporting, no input is processed and // no physics is resolved. Return a zero-movement result so the caller @@ -594,96 +853,6 @@ public sealed class PlayerMovementController TurnSpeed: null); } - // ── R3-W6: EDGE-DRIVEN retail input (replaces the D6.2 per-frame - // RawMotionState rebuild — the level-triggered substitute for - // retail's edge-triggered CommandInterpreter). Each key EDGE fires - // DoMotion/StopMotion (0x00528d20/0x00528530) which mutate the - // interpreter's OWN RawState via ApplyMotion/RemoveMotion and - // dispatch through the funnel + DefaultSink — the SAME pipeline - // remotes use. The Shift edge is retail's set_hold_run - // (0x00528b70, caller 0x006b33ca shape: interrupt=true). RAW speeds - // stay 1.0 (apply_run_to_command applies the run rate — pre-scaling - // would double-scale; TS-22 unchanged). R4-V5: the ctor-default - // params carry retail's 0x1EE0F bitfield whose CancelMoveTo bit - // (0x8000) is SET — any key edge mid-moveto fires - // InterruptCurrentMovement → MoveTo.CancelMoveTo(ActionCancelled), - // the retail user-input cancel chain (TS-36 retired; set_hold_run's - // interrupt:true is the same chain for the Shift edge). - bool motionEdgeFired = false; - { - var p = new AcDream.Core.Physics.Motion.MovementParameters(); - - // Shift/run edge FIRST — retail's set_hold_run re-applies - // movement, so the hold-key state is current before any - // same-frame directional dispatch. - if (input.Run != _prevRunHeld) - { - _motion.set_hold_run(input.Run, interrupt: true); - motionEdgeFired = true; - } - - // Forward channel (W / S share one raw channel — retail - // RawMotionState.ApplyMotion's forward-class switch). W wins on - // a same-frame double-press; releasing one key while the - // opposite is still held re-issues the survivor (equivalent to - // the old level-triggered build, which always reflected the - // currently-held set). - if (input.Forward && !_prevForwardHeld) - { _motion.DoMotion(MotionCommand.WalkForward, p); motionEdgeFired = true; } - else if (input.Backward && !_prevBackwardHeld && !input.Forward) - { _motion.DoMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; } - if (!input.Forward && _prevForwardHeld) - { - if (input.Backward) - _motion.DoMotion(MotionCommand.WalkBackward, p); - else - _motion.StopMotion(MotionCommand.WalkForward, p); - motionEdgeFired = true; - } - else if (!input.Backward && _prevBackwardHeld && !input.Forward) - { _motion.StopMotion(MotionCommand.WalkBackward, p); motionEdgeFired = true; } - - // Sidestep channel. - if (input.StrafeRight && !_prevStrafeRightHeld) - { _motion.DoMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; } - else if (input.StrafeLeft && !_prevStrafeLeftHeld) - { _motion.DoMotion(MotionCommand.SideStepLeft, p); motionEdgeFired = true; } - else if (!input.StrafeRight && !input.StrafeLeft - && (_prevStrafeRightHeld || _prevStrafeLeftHeld)) - { _motion.StopMotion(MotionCommand.SideStepRight, p); motionEdgeFired = true; } - - // Turn channel. - if (input.TurnRight && !_prevTurnRightHeld) - { _motion.DoMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; } - else if (input.TurnLeft && !_prevTurnLeftHeld) - { _motion.DoMotion(MotionCommand.TurnLeft, p); motionEdgeFired = true; } - else if (!input.TurnRight && !input.TurnLeft - && (_prevTurnRightHeld || _prevTurnLeftHeld)) - { _motion.StopMotion(MotionCommand.TurnRight, p); motionEdgeFired = true; } - - // Retail stores last_move_was_autonomous = 1 at the CPhysicsObj - // INPUT boundary — CPhysicsObj::DoMotion @00510030 / - // CPhysicsObj::StopMotion @005100e0 (per call, before routing - // to MovementManager) and CommandInterpreter:: - // TakeControlFromServer @006b32f4. The MoveToManager's - // _DoMotion goes through CMotionInterp internals and NEVER - // touches the flag; the wire unpack path stores the wire byte - // (P1, 00509730). This edge block IS acdream's input boundary, - // so an edge firing is exactly retail's store site. The flag - // routes the per-tick pump's A3 dual dispatch (raw for - // input-driven motion, interpreted for server/manager-driven). - if (motionEdgeFired) - _body.LastMoveWasAutonomous = true; - } - - _prevForwardHeld = input.Forward; - _prevBackwardHeld = input.Backward; - _prevStrafeLeftHeld = input.StrafeLeft; - _prevStrafeRightHeld = input.StrafeRight; - _prevTurnLeftHeld = input.TurnLeft; - _prevTurnRightHeld = input.TurnRight; - _prevRunHeld = input.Run; - // ── 1. Apply turning from keyboard + mouse ──────────────────────────── // 2026-05-16 — retail-faithful turn rate. // Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt @@ -695,23 +864,11 @@ public sealed class PlayerMovementController // Effective: walking ≈ 90°/s, running ≈ 135°/s. // Previously: WalkAnimSpeed*0.5 ≈ 89.4°/s — coincidentally // close to retail walking but no run differentiation. - // D6.2: local keyboard turn rate now comes from the interpreted turn - // state (adjust_motion normalized TurnLeft→TurnRight with sign + - // apply_run_to_command ×RunTurnFactor when running). omega.Z = - // BaseTurnRateRadPerSec × turn_speed — the same π/2 formula the remote - // path uses (GameWindow ObservedOmega seed). Numerically identical to the - // former TurnRateFor(input.Run); AP-9 (π/2 base rate) is unchanged. - // R4-V5: runs during a moveto too — the manager's aux-turn steering - // and TurnToHeading nodes dispatch TurnRight/TurnLeft through - // _DoMotion into the SAME interpreted turn state, so this one - // integrator rotates the player for both input and moveto turns. - // Mouse turn stays a direct Yaw delta (deliberately generates no - // turn command — avoids MoveToState spam from mouse jitter). - if (_motion.InterpretedState.TurnCommand == MotionCommand.TurnRight) - { - Yaw -= (MathF.PI / 2.0f) // BaseTurnRateRadPerSec (AP-9), ex-RemoteMoveToDriver - * _motion.InterpretedState.TurnSpeed * dt; - } + float keyboardTurnRate = RemoteMoveToDriver.TurnRateFor(input.Run); + if (input.TurnRight) + Yaw -= keyboardTurnRate * dt; + if (input.TurnLeft) + Yaw += keyboardTurnRate * dt; Yaw -= input.MouseDeltaX * MouseTurnSensitivity; // Wrap yaw to [-PI, PI] so it doesn't grow unbounded. while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI; @@ -723,16 +880,61 @@ public sealed class PlayerMovementController _body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f); // ── 2. Set velocity via MotionInterpreter state machine ─────────────── - // R4-V5: runs during a moveto too — the manager's BeginMoveForward - // dispatched WalkForward/RunForward through _DoMotion into the SAME - // interpreted state, so this one per-frame apply turns it into body - // velocity exactly like input-driven motion (the #75 "two writers" - // hazard is gone: there is only this writer now). + // 2026-05-16 (issue #75): skip when DriveServerAutoWalk owns + // motion control this frame — it has already called + // _motion.DoMotion + _body.set_local_velocity from the auto- + // walk's path data + runRate. Running this section would + // overwrite the auto-walk velocity with the user-input + // (Ready/Stand) velocity, freezing the body. + if (!autoWalkConsumedMotion) + { + // Determine the dominant forward/backward command and speed. + uint forwardCmd; + float forwardCmdSpeed; + if (input.Forward) + { + forwardCmd = input.Run ? MotionCommand.RunForward : MotionCommand.WalkForward; + // When running, use the PlayerWeenie's RunRate as ForwardSpeed. + // The retail server computes this from Run skill + encumbrance and + // broadcasts it in UpdateMotion, but it doesn't echo to the sender. + // We compute locally using the same formula. + if (input.Run && _weenie.InqRunRate(out float runRate)) + forwardCmdSpeed = runRate; + else + forwardCmdSpeed = 1.0f; + } + else if (input.Backward) + { + forwardCmd = MotionCommand.WalkBackward; + // K-fix3 (2026-04-26): backward also honors Run. Without + // this, holding X with Run=true (default) still produced + // walk-tier backward speed because forwardCmdSpeed was + // hardcoded to 1.0. Now scale by runRate the same way + // RunForward does. + if (input.Run && _weenie.InqRunRate(out float runRateBack)) + forwardCmdSpeed = runRateBack; + else + forwardCmdSpeed = 1.0f; + } + else + { + forwardCmd = MotionCommand.Ready; + forwardCmdSpeed = 1.0f; + } - // D6.2: the forward/sidestep command determination + DoMotion + - // DoInterpretedMotion moved into the apply_raw_movement call above, so - // the interpreted state (and thus get_state_velocity) is already - // populated + normalized for all directions. + // Update interpreted motion state (needed for animation + server messages). + _motion.DoMotion(forwardCmd, forwardCmdSpeed); + + // Sidestep. + if (input.StrafeRight) + _motion.DoInterpretedMotion(MotionCommand.SideStepRight, 1.0f, modifyInterpretedState: true); + else if (input.StrafeLeft) + _motion.DoInterpretedMotion(MotionCommand.SideStepLeft, 1.0f, modifyInterpretedState: true); + else + { + _motion.StopInterpretedMotion(MotionCommand.SideStepRight, modifyInterpretedState: true); + _motion.StopInterpretedMotion(MotionCommand.SideStepLeft, modifyInterpretedState: true); + } // Only replace velocity with motion interpreter output when grounded. // While airborne, the physics body's integrated velocity (from LeaveGround) @@ -741,28 +943,44 @@ public sealed class PlayerMovementController if (_body.OnWalkable) { float savedWorldVz = _body.Velocity.Z; - // D6.2: velocity for ALL directions comes from the normalized - // interpreted state — backward (WalkForward ×-0.65) and strafe-left - // (SideStepRight ×-1×1.248) are handled inside get_state_velocity now - // that adjust_motion ran above. The hand-mirrored formulas are gone - // (register TS-22 retired). var stateVel = _motion.get_state_velocity(); - // R4-V5 wedge fix: PRESERVE the flag — retail's - // last_move_was_autonomous is stored at EVENT boundaries only - // (input DoMotion/StopMotion edges above, the P1 wire-unpack - // store, LeaveGround's own autonomous=1 write), never re-stamped - // by the per-tick velocity write. V5's first cut stamped it per - // frame here, which mis-routed the pump's A3 dual dispatch on - // event-transition frames (apply_raw clobbering a just-armed - // moveto's dispatched motion with the raw Ready state). - _body.set_local_velocity(new Vector3(stateVel.X, stateVel.Y, savedWorldVz), - autonomous: _body.LastMoveWasAutonomous); + + float localY = 0f; + float localX = 0f; + + // K-fix3 (2026-04-26): unified run-multiplier for backward + // + strafe. Forward already scales correctly because it uses + // stateVel.Y (which the motion state machine fed runRate + // into via DoMotion). Backward + strafe bypass the state + // machine and hardcoded speed; previously they capped at + // walk speed regardless of Run, which made the ~2.4× + // forward-vs-back/strafe ratio feel wrong. Now both scale + // with the same runRate the forward branch uses. + float runMul = 1.0f; + if (input.Run && _weenie.InqRunRate(out float vrr)) + runMul = vrr; + + if (input.Forward) + localY = stateVel.Y; + else if (input.Backward) + localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * runMul); + + // Strafe scales with the same runMul so sidestep matches + // the forward pace at run speed (retail uses speed=1.0 for + // SideStep + the same hold-key-driven run/walk multiplier). + if (input.StrafeRight) + localX = MotionInterpreter.SidestepAnimSpeed * runMul; + else if (input.StrafeLeft) + localX = -MotionInterpreter.SidestepAnimSpeed * runMul; + + _body.set_local_velocity(new Vector3(localX, localY, savedWorldVz)); } + } // end of `if (!autoWalkConsumedMotion)` — section 2 // ── 3. Jump (charged) ───────────────────────────────────────────────── // Hold spacebar to charge (0→1 over JumpChargeRate seconds). // Release to execute: jump(extent) validates + sets JumpExtent, - // then LeaveGround() applies the scaled velocity via GetLeaveGroundVelocity. + // then LeaveGround() applies the scaled velocity via get_leave_ground_velocity. float? outJumpExtent = null; Vector3? outJumpVelocity = null; @@ -773,12 +991,6 @@ public sealed class PlayerMovementController { _jumpCharging = true; _jumpExtent = 0f; - // R3-W6 (map R1): retail's charge_jump fires at charge - // START (SmartBox/input boundary 0x0056afac) — the ONLY - // place StandingLongJump arms (grounded + Ready + no - // sidestep/turn). Never called by production code before - // this line despite the W3 port. - _motion.ChargeJump(); } _jumpExtent = MathF.Min(_jumpExtent + dt * JumpChargeRate, 1.0f); } @@ -788,21 +1000,63 @@ public sealed class PlayerMovementController var jumpResult = _motion.jump(_jumpExtent); if (jumpResult == WeenieError.None) { - // R3-W4 (J7): the manual LeaveGround call is DELETED — - // jump() clears OnWalkable, and the SAME frame's - // grounded→airborne edge (section 5's transition detection) - // fires _motion.LeaveGround() exactly where retail's - // transition sweep does. Capture jump_v_z NOW: the edge's - // LeaveGround resets JumpExtent to 0 later this frame. - float jumpVz = _motion.GetJumpVZ(); + // Capture jump_v_z BEFORE LeaveGround() — that call resets + // JumpExtent back to 0 (faithful to retail's FUN_00529710), + // after which get_jump_v_z() returns 0 because the extent + // gate at the top of the function fires. + float jumpVz = _motion.get_jump_v_z(); + _motion.LeaveGround(); outJumpExtent = _jumpExtent; - // D6.2: get_state_velocity() is now correct for all directions - // (apply_raw_movement normalized backward/strafe above), so the - // jump-launch velocity is get_state_velocity() + the vertical jump - // component. Retires the duplicated hand-mirrored formulas that - // existed only until adjust_motion was ported (register TS-22). - var jumpVel = _motion.get_state_velocity(); - outJumpVelocity = new Vector3(jumpVel.X, jumpVel.Y, jumpVz); + // BODY-LOCAL jump-launch velocity, computed directly from input. + // + // Why not read _body.Velocity? Because _motion.LeaveGround() + // routes through get_leave_ground_velocity → get_state_velocity, + // which is a faithful port of retail's FUN_00528960. Retail's + // version only handles WalkForward (0x45000005) / RunForward + // (0x44000007) / SideStepRight (0x6500000F); WalkBackwards + // and SideStepLeft return zero. Retail papers over this in + // adjust_motion (FUN_00528010) by translating + // WalkBackwards → WalkForward + speed × -0.65 + // SideStepLeft → SideStepRight + speed × -1 + // before they reach InterpretedState — but we don't yet port + // adjust_motion, so InterpretedState holds the un-translated + // command and get_state_velocity returns (0,0,0) for it. + // LeaveGround then writes (0,0,jumpZ) to the body, wiping the + // correct strafe/backward velocity the controller had just set + // a few lines up. Result: backward/strafe jumps go straight up. + // + // Until adjust_motion is ported, we mirror the grounded-velocity + // computation from the block above and stuff the result into + // outJumpVelocity directly. Local frame: +Y forward, +X right, + // +Z up — matches retail's body-frame convention. Server + // rotates body→world on receive, so observers see the jump + // in the correct world direction. + float jumpRunMul = 1.0f; + if (input.Run && _weenie.InqRunRate(out float jvrr)) + jumpRunMul = jvrr; + + // Forward uses get_state_velocity (which knows Walk vs Run vs + // animation-cycle pacing). Backward / Strafe use the same + // hardcoded scaled formulas the grounded-velocity block above + // uses (lines 397-408). + float localY = 0f; + if (input.Forward) + { + var stateVel = _motion.get_state_velocity(); + localY = stateVel.Y; + } + else if (input.Backward) + { + localY = -(MotionInterpreter.WalkAnimSpeed * 0.65f * jumpRunMul); + } + + float localX = 0f; + if (input.StrafeRight) + localX = MotionInterpreter.SidestepAnimSpeed * jumpRunMul; + else if (input.StrafeLeft) + localX = -MotionInterpreter.SidestepAnimSpeed * jumpRunMul; + + outJumpVelocity = new Vector3(localX, localY, jumpVz); // Local-prediction fix: LeaveGround above wrote (0, 0, jumpZ) // to the body for backward/strafe-left (same get_state_velocity @@ -811,7 +1065,7 @@ public sealed class PlayerMovementController // client renders the jump in the same world direction the // server is broadcasting to observers. Same vector we just // sent in JumpAction — local + remote stay in sync. - _body.set_local_velocity(outJumpVelocity.Value, autonomous: true); + _body.set_local_velocity(outJumpVelocity.Value); } _jumpCharging = false; _jumpExtent = 0f; @@ -842,29 +1096,6 @@ public sealed class PlayerMovementController // Integrate accumulated dt, clamped to MaxQuantum so a long // pause doesn't produce one giant integration step. float tickDt = MathF.Min(_physicsAccum, PhysicsBody.MaxQuantum); - - // R5-V3 (#171): retail UpdatePositionInternal (0x00512c30) — - // PositionManager::adjust_offset (@0x00512d0e) composes the - // sticky steer into this quantum's motion BEFORE - // UpdatePhysicsInternal, so the transition sweep below resolves - // it like any other movement (preIntegratePos was captured - // above). The delta's Origin is mover-LOCAL (rotate out by the - // body orientation); the heading is RELATIVE and writes Yaw — - // the authoritative facing the body quaternion is re-derived - // from every frame (a quaternion-only write would be clobbered). - // No-op while nothing is stuck (untouched delta frame). - if (PositionManager is { } ppm) - { - var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame(); - ppm.AdjustOffset(pmDelta, tickDt); - if (pmDelta.Origin != Vector3.Zero) - _body.Position += Vector3.Transform(pmDelta.Origin, _body.Orientation); - if (!pmDelta.Orientation.IsIdentity) - Yaw = AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading( - AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(Yaw) - + pmDelta.GetHeading()); - } - _body.calc_acceleration(); _body.UpdatePhysicsInternal(tickDt); _physicsAccum -= tickDt; @@ -882,17 +1113,8 @@ public sealed class PlayerMovementController // Falls back to simple Z-snap if transition fails. var resolveResult = _physics.ResolveWithTransition( preIntegratePos, postIntegratePos, CellId, - sphereRadius: 0.48f, // human Setup 0x02000001 sphere radius (dat: 0.480) - // #137 window climb (2026-07-06): sphereHeight is the CAPSULE TOP - // (InitPath places the head sphere center at height − radius). The - // dat human Setup 0x02000001 has Spheres[1].Origin.Z = 1.350 - // (top 1.830) and Height = 1.835 — retail collides with that - // sphere list verbatim (CPhysicsObj::transition 0x00512dc0 → - // init_sphere(GetNumSphere, GetSphere, scale)). The old 1.2f put - // the head sphere center at 0.72 — the top 0.63 m of the - // character had NO collision, letting the player climb into a - // 1.3 m window alcove head-through-lintel. Register TS-46. - sphereHeight: 1.835f, + sphereRadius: 0.48f, // human player radius from Setup + sphereHeight: 1.2f, // human player height from Setup stepUpHeight: StepUpHeight, stepDownHeight: StepDownHeight, // L.2.3a: from Setup.StepDownHeight isOnGround: _body.OnWalkable, @@ -934,16 +1156,6 @@ public sealed class PlayerMovementController { _prevPhysicsPos = oldTickEndPos; _currPhysicsPos = _body.Position; - - // R5-V3 (#171): retail UpdateObjectInternal TAIL — PositionManager:: - // UseTime (@0x005159b3) runs AFTER the quantum's integration - // (whose head hosts adjust_offset) and only on frames where a - // physics quantum executed (retail's update_object MinQuantum - // gate skips the whole UpdateObjectInternal). Diff-review find: - // a head-of-frame placement tore an expiring stick down BEFORE - // the crossing quantum's final steer/turn — retail applies that - // last adjust_offset, THEN the tail watchdog tears down. - PositionManager?.UseTime(); } // L.3a (2026-04-30): retail wall-bounce / velocity reflection. @@ -1070,13 +1282,7 @@ public sealed class PlayerMovementController if (wasAirborne) { - // R4-V5 → R5-V5: retail order — minterp first, then - // moveto (MovementManager::HitGround 0x00524300, decomp - // §2d — now the literal facade relay); re-arms a moveto - // suspended by the airborne UseTime contact gate. - // LeaveGround has NO moveto side (COMDAT no-op, §2e) — - // do not add one. - Movement.HitGround(); + _motion.HitGround(); justLanded = true; } } @@ -1095,16 +1301,6 @@ public sealed class PlayerMovementController } - // R3-W4 (J7/J8): the grounded→airborne EDGE fires retail's - // LeaveGround (0x00528b00) — jump launches (jump() cleared - // OnWalkable earlier this frame) AND walk-off-a-ledge both route - // here, replacing the old manual jump-block call (and giving - // ledge departures the momentum fallback + link strip they never - // had). Edge = grounded last frame, airborne now; the landing - // branch above already fires HitGround on the opposite edge. - if (!_body.OnWalkable && !_wasAirborneLastFrame) - _motion.LeaveGround(); - _wasAirborneLastFrame = !_body.OnWalkable; UpdateCellId(resolveResult.CellId, "resolver"); @@ -1116,33 +1312,37 @@ public sealed class PlayerMovementController uint? outTurnCmd = null; float? outTurnSpeed = null; - // Retail-faithful wire commands — the wire carries the RAW motion state: + // Retail-faithful wire commands. ACE's MovementData constructor only + // computes interpState.ForwardSpeed for WalkForward / WalkBackwards + // (Network/Motion/MovementData.cs:104-119) — for any other command + // the else-branch passes through without setting speed, so observers + // dead-reckon at speed=0. The wire therefore must be: // - Forward (walk): WalkForward @ 1.0 - // - Forward (run): WalkForward @ 1.0 + HoldKey.Run + // - Forward (run): WalkForward @ run_rate + HoldKey.Run + // (ACE auto-upgrades to RunForward for observers) // - Backward: WalkBackward @ 1.0 - // D6.2b (echo-test 2026-07-01): ACE RECOMPUTES the broadcast run speed - // from the character's run skill and auto-upgrades WalkForward+HoldKey.Run - // → RunForward for observers. Sending the raw forward_speed=1.0 (omitted by - // default-difference packing) still broadcasts RunForward @ runRate — a - // retail observer saw +Acdream run at full pace. - // R3-W6: the LOCAL animation no longer needs a separate - // LocalAnimationCommand — the walk→run promotion happens inside the - // ported machinery (apply_raw_movement → apply_run_to_command - // promotes WalkForward+HoldKey.Run → RunForward @ my_run_rate on the - // interpreted side, which the DefaultSink dispatch plays). - // NOTE (R7 scope): the wire values below stay derived from input — - // the L.2b-verified byte stream is untouched; sourcing them from - // _motion.RawState needs the cdb CommandInterpreter-boundary capture - // the W6 map's §2b TODO flags, and R7 owns outbound anyway. + // Our own local animation still wants the actual RunForward cycle + // though — that's carried separately in LocalAnimationCommand below. + uint? localAnimCmd = null; if (input.Forward) { - outForwardCmd = MotionCommand.WalkForward; - outForwardSpeed = 1.0f; // RAW — ACE recomputes the broadcast speed + outForwardCmd = MotionCommand.WalkForward; + if (input.Run && _weenie.InqRunRate(out float runRate)) + { + outForwardSpeed = runRate; + localAnimCmd = MotionCommand.RunForward; // local cycle is RunForward + } + else + { + outForwardSpeed = 1.0f; + localAnimCmd = MotionCommand.WalkForward; + } } else if (input.Backward) { outForwardCmd = MotionCommand.WalkBackward; outForwardSpeed = 1.0f; + localAnimCmd = MotionCommand.WalkBackward; } // Strafe: retail uses speed=1.0 for SideStep (see holtburger @@ -1177,32 +1377,33 @@ public sealed class PlayerMovementController } // ── 7. Detect motion state change ───────────────────────────────────── - // ForwardCommand can stay WalkForward while only the run-hold bit changes - // (walk W held, then Shift → run). Since D6.2b the wire forward_speed is - // always the RAW 1.0 (ACE recomputes the broadcast run speed), so the - // walk↔run toggle is detected via the HoldKey (runHold) and the - // LocalAnimationCommand change (Walk↔Run cycle), NOT via forward_speed. A - // fresh MoveToState on that toggle lets ACE's BroadcastMovement re-pick - // WalkForward vs RunForward (via HoldKey) and recompute the run speed for - // observers. The forward_speed comparison below is retained (harmless — it - // never fires now that forward_speed is constant) for defensiveness. + // Bug fix: ForwardCommand can stay the same (WalkForward) while ONLY + // ForwardSpeed or the run-hold bit changes. If the user is already + // walking (W held), then presses Shift, the outbound wire still has + // ForwardCommand=WalkForward but outForwardSpeed jumps from 1.0 to + // runRate. Without also tracking speed + hold-key here, no new + // MoveToState is sent — the server keeps thinking the player walks, + // and retail observers render walking animation despite the local + // player's RunForward cycle. + // + // Similarly LocalAnimationCommand change (Walk→Run on local cycle) + // must force a fresh outbound so ACE's BroadcastMovement re-runs + // MovementData(this, moveToState) which only reads ForwardCommand + + // ForwardSpeed + HoldKey to pick between WalkForward vs RunForward + // for remote observers. bool runHold = input.Run; - // R3-W6: the localAnimCmd leg is deleted with the synthesis layer; - // motionEdgeFired (any DoMotion/StopMotion/set_hold_run edge this - // frame) is OR-ed in — by construction an edge IS a state change - // (retail dispatches only on edges), keeping the wire cadence - // identical to the old output-comparison. bool changed = outForwardCmd != _prevForwardCmd || outSidestepCmd != _prevSidestepCmd || outTurnCmd != _prevTurnCmd || !FloatsEqual(outForwardSpeed, _prevForwardSpeed) || runHold != _prevRunHold - || motionEdgeFired; + || localAnimCmd != _prevLocalAnimCmd; _prevForwardCmd = outForwardCmd; _prevSidestepCmd = outSidestepCmd; _prevTurnCmd = outTurnCmd; _prevForwardSpeed = outForwardSpeed; _prevRunHold = runHold; + _prevLocalAnimCmd = localAnimCmd; static bool FloatsEqual(float? a, float? b) { @@ -1262,19 +1463,51 @@ public sealed class PlayerMovementController HeartbeatDue = groundedOnWalkable && sendThisFrame; - // R3-W6: the K-fix5 LocalAnimationSpeed synthesis is DELETED — the - // run pacing now comes from the ported machinery itself - // (apply_run_to_command scales the interpreted speed by my_run_rate; - // the DefaultSink dispatch plays the cycle at that speed — the same - // source remotes use). + // K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate + // should match the actual movement speed. For Forward+Run this is + // already runRate (it equals ForwardSpeed). For Backward+Run and + // Strafe+Run it must be runRate too even though the wire keeps + // those at 1.0. Picking runMul (already computed above) keeps the + // math in one place. bool anyDirectional = input.Forward || input.Backward || input.StrafeLeft || input.StrafeRight; + float localAnimSpeed = (input.Run && anyDirectional) + ? (_weenie.InqRunRate(out float vrrAnim) ? vrrAnim : 1f) + : 1f; - // R4-V5: the #69 auto-walk turn-cycle edge synthesizer is DELETED — - // the MoveToManager's own aux-turn steering and TurnToHeading nodes - // dispatch TurnRight/TurnLeft through _DoMotion (the retail - // mechanism), so turn cycles during a moveto come from the same - // pipeline as everything else. + // 2026-05-16 (issue #75) — server-initiated auto-walk drives + // the local animation cycle directly: + // - moving forward → WalkForward / RunForward (legs animate) + // - turn-first phase → TurnLeft / TurnRight (issue #69 fix) + // - aligned but pre-step / arrival → no override, falls to + // the user-input section's default (idle) + // UpdatePlayerAnimation reads LocalAnimationCommand + + // LocalAnimationSpeed; without these overrides the body + // translates/rotates without leg/arm animation. The motion + // cycle commands here flow into the animation sequencer + // ONLY — the wire-layer guard at GameWindow.cs:6419 prevents + // them from leaking to a user-MoveToState packet during + // auto-walk. + if (_autoWalkMovingForwardThisFrame) + { + if (_autoWalkInitiallyRunning && _weenie.InqRunRate(out float autoWalkRunRate)) + { + localAnimCmd = MotionCommand.RunForward; + localAnimSpeed = autoWalkRunRate; + } + else + { + localAnimCmd = MotionCommand.WalkForward; + localAnimSpeed = 1f; + } + } + else if (_autoWalkTurnDirectionThisFrame != 0) + { + localAnimCmd = _autoWalkTurnDirectionThisFrame > 0 + ? MotionCommand.TurnLeft + : MotionCommand.TurnRight; + localAnimSpeed = 1f; + } return new MovementResult( Position: Position, @@ -1296,6 +1529,8 @@ public sealed class PlayerMovementController // who then animated walk + dead-reckoned at walk speed while the // server position moved at run speed — visible as observer lag. IsRunning: input.Run && anyDirectional, + LocalAnimationCommand: localAnimCmd, + LocalAnimationSpeed: localAnimSpeed, JustLanded: justLanded, JumpExtent: outJumpExtent, JumpVelocity: outJumpVelocity); diff --git a/src/AcDream.App/Plugins/AppPluginHost.cs b/src/AcDream.App/Plugins/AppPluginHost.cs index 5b06e67e..2916724e 100644 --- a/src/AcDream.App/Plugins/AppPluginHost.cs +++ b/src/AcDream.App/Plugins/AppPluginHost.cs @@ -4,16 +4,14 @@ namespace AcDream.App.Plugins; public sealed class AppPluginHost : IPluginHost { - public AppPluginHost(IPluginLogger log, IGameState state, IEvents events, IUiRegistry ui) + public AppPluginHost(IPluginLogger log, IGameState state, IEvents events) { Log = log; State = state; Events = events; - Ui = ui; } public IPluginLogger Log { get; } public IGameState State { get; } public IEvents Events { get; } - public IUiRegistry Ui { get; } } diff --git a/src/AcDream.App/Plugins/BufferedUiRegistry.cs b/src/AcDream.App/Plugins/BufferedUiRegistry.cs deleted file mode 100644 index bcab04fb..00000000 --- a/src/AcDream.App/Plugins/BufferedUiRegistry.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Collections.Generic; -using AcDream.Plugin.Abstractions; - -namespace AcDream.App.Plugins; - -/// -/// Buffers plugin calls (which run in -/// Program.cs before the GL window opens) until GameWindow drains them into the -/// UiHost tree after construction. -/// -public sealed class BufferedUiRegistry : IUiRegistry -{ - public readonly record struct Pending(string MarkupPath, object Binding); - - private readonly List _pending = new(); - - public void AddMarkupPanel(string markupPath, object binding) - => _pending.Add(new Pending(markupPath, binding)); - - /// Return + clear all buffered registrations. - public IReadOnlyList Drain() - { - var copy = _pending.ToArray(); - _pending.Clear(); - return copy; - } -} diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs index 9e48adbb..bc43997b 100644 --- a/src/AcDream.App/Program.cs +++ b/src/AcDream.App/Program.cs @@ -4,14 +4,6 @@ using AcDream.App.Rendering; using AcDream.Core.Plugins; using Serilog; -if (args.Length >= 1 && args[0] == "ui-studio") -{ - var so = AcDream.App.Studio.StudioOptions.Parse(args[1..]); - using var sw = new AcDream.App.Studio.StudioWindow(so); - sw.Run(); - return 0; -} - Log.Logger = new LoggerConfiguration() .MinimumLevel.Debug() .WriteTo.Console() @@ -31,8 +23,7 @@ var runtimeOptions = RuntimeOptions.FromEnvironment(datDir); var worldGameState = new AcDream.Core.Plugins.WorldGameState(); var worldEvents = new AcDream.Core.Plugins.WorldEvents(); -var uiRegistry = new AcDream.App.Plugins.BufferedUiRegistry(); -var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents, uiRegistry); +var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents); var pluginsDir = Path.Combine(AppContext.BaseDirectory, "plugins"); Log.Information("scanning plugins in {PluginsDir}", pluginsDir); @@ -65,7 +56,7 @@ try catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); } } - using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents, uiRegistry); + using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents); window.Run(); } finally diff --git a/src/AcDream.App/Rendering/DollCamera.cs b/src/AcDream.App/Rendering/DollCamera.cs deleted file mode 100644 index 53a164ea..00000000 --- a/src/AcDream.App/Rendering/DollCamera.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Numerics; - -namespace AcDream.App.Rendering; - -/// -/// Fixed camera for the paperdoll mini-scene — retail-exact, ported from the gmPaperDollUI viewport -/// setup (decomp 0x004a5a39–0x004a5a69). The viewport (element 0x100001d5) is configured by -/// UIElement_Viewport::SetCamera(position, direction) with: -/// -/// position = (0.12, −2.4, 0.88) [hex 0x3df5c28f, 0xc019999a, 0x3f6147ae] -/// direction = (0, 0, 0) ⇒ CreatureMode::SetCameraDirection resets the view frame to -/// IDENTITY (euler_set_rotate(0,0,0) then rotate(0,0,0)), so the camera looks -/// straight down +Y with +Z up — NO yaw, NO pitch. -/// -/// The doll is framed purely by camera POSITION + FOV, NOT by aiming at the body: at distance 2.4 with -/// a π/4 (45°) vertical FOV the visible vertical band is z≈[−0.11, 1.87], which covers the whole ~1.6 m -/// figure even though the model origin sits at the FEET (z=0). FOV π/4 is CreatureMode's default -/// m_fFOVRadians (ctor 0x004543cf, hex 0x3f490fdb); default ambient is (0.3,0.3,0.3); the -/// paperdoll uses UseSharpMode (not SmartboxFOV) so Render::SetFOVRad(m_fFOVRadians) applies. -/// -/// An earlier hand-tune aimed the camera at mid-body to "fit the figure", which introduced a -/// spurious yaw (Target.x ≠ Eye.x) that rotated the view and turned the doll's face away from the -/// viewer. This restores retail's zero-yaw frame, where full-body framing comes from the eye height + -/// FOV, not from aiming. -/// -/// Uses the same / -/// convention as so the doll's triangle winding + back-face culling match the -/// world render pass. AC up-axis = +Z. -/// -public sealed class DollCamera : ICamera -{ - // Retail paperdoll camera origin (decomp 0x004a5a51–0x004a5a61). - private static readonly Vector3 Eye = new(0.12f, -2.4f, 0.88f); - // Identity view orientation ⇒ look straight down +Y (no yaw/pitch). Target = Eye + (0,1,0). - private static readonly Vector3 Target = new(0.12f, -1.4f, 0.88f); - private static readonly Vector3 Up = Vector3.UnitZ; // AC up-axis = +Z, same as ChaseCamera - - /// Vertical field of view — retail CreatureMode default m_fFOVRadians = π/4 (45°). - public float FovRadians { get; set; } = MathF.PI / 4f; - - public float Near { get; set; } = 0.1f; // same near plane as ChaseCamera / retail znear - public float Far { get; set; } = 50f; // doll scene is small; 50 m is ample - public float Aspect { get; set; } = 1f; - - /// - public Matrix4x4 View => - Matrix4x4.CreateLookAt(Eye, Target, Up); - - /// - public Matrix4x4 Projection => - Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0f ? 1f : Aspect, Near, Far); -} diff --git a/src/AcDream.App/Rendering/DollEntityBuilder.cs b/src/AcDream.App/Rendering/DollEntityBuilder.cs deleted file mode 100644 index 2b344120..00000000 --- a/src/AcDream.App/Rendering/DollEntityBuilder.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.World; - -namespace AcDream.App.Rendering; - -/// -/// Builds the dedicated paperdoll WorldEntity — retail's makeObject(player) -/// clone: the player's Setup id + current ObjDesc (base palette + subpalette overlays -/// + part overrides), posed at the scene origin facing the viewer. -/// -/// -/// The palette / part-override mapping mirrors the inline construction in -/// GameWindow.cs around lines 3390–3431. Extracted here so it is -/// unit-testable without dats and so the paperdoll renderer owns a clean -/// seam: it calls with fresh player state each time the -/// ObjDesc changes, and the renderer only has to swap the entity into its -/// dedicated mini-scene. -/// -/// -/// -/// The is a reserved synthetic guid that is: -/// (a) non-zero (so EntitySpawnAdapter.OnCreate's ServerGuid != 0 -/// guard accepts it), and (b) high enough that it never collides with a real -/// server-assigned guid. -/// -/// -public static class DollEntityBuilder -{ - /// - /// Reserved synthetic guid for the paperdoll clone. High, deliberately - /// outside the server's assignable range, and non-zero. - /// - public const uint DollServerGuid = 0xDA11_D011u; - - /// - /// Reserved render-local entity id for the doll. FIXED (the doll is a singleton) - /// and high enough never to collide with _liveEntityIdCounter ids. The - /// renderer passes this in animatedEntityIds so the dispatcher treats the - /// doll as animated — which BYPASSES the Tier-1 classification cache - /// (WbDrawDispatcher.cs:1142). That matters because a re-dress builds a NEW - /// WorldEntity with this SAME id; without the cache bypass the dispatcher would - /// serve the previous doll's cached batches and the new gear wouldn't appear. - /// - public const uint DollRenderId = 0xDA11_D012u; - - // retail RedressCreature: CPhysicsObj::set_heading(191.367905°) (decomp 0x004a3c0a). Frame::set_heading(h) - // (decomp 0x00535e40) builds the facing vector as (sin h°, cos h°, 0): at h=0 the creature faces +Y (AC - // North), the heading increasing CLOCKWISE toward +X. We rotate the doll's default +Y-forward body about - // +Z; System.Numerics rotates +Y → (−sin θ, cos θ), so to land on retail's (sin h, cos h) the angle is - // NEGATED (θ = −h). Using +h mirrors the X-lean (~22° off → the face reads as turned away from the viewer). - private const float _headingDegrees = 191.367905f; - private static readonly float _headingRad = -_headingDegrees * (MathF.PI / 180f); - private static readonly Quaternion _dollRotation = - Quaternion.CreateFromAxisAngle(new Vector3(0f, 0f, 1f), _headingRad); - - /// - /// Builds a synthetic for the paperdoll mini-scene. - /// - /// The player's Setup dat id (0x02xxxxxx). - /// Pre-resolved mesh refs (may be empty; caller fills them in). - /// - /// ObjDesc base palette id (0x04xxxxxx). Passed only when subpalettes are - /// also present — mirrors GameWindow which only builds a PaletteOverride - /// when SubPalettes.Count > 0. - /// - /// - /// Subpalette overlays from the server ObjDesc. Each tuple carries the - /// subpalette dat id, byte offset into the base palette, and byte length. - /// Null or empty → PaletteOverride on the returned entity is null. - /// - /// - /// AnimPartChange swaps from the server ObjDesc. Each tuple is a - /// (PartIndex, replacement GfxObj id) pair. Null or empty → empty array. - /// - public static WorldEntity Build( - uint setupId, - IReadOnlyList meshRefs, - uint? basePaletteId = null, - IReadOnlyList<(uint SubPaletteId, byte Offset, byte Length)>? subPalettes = null, - IReadOnlyList<(byte PartIndex, uint GfxObjId)>? partOverrides = null) - { - // --- palette override (mirrors GameWindow:3395-3405) --- - // Only build when there are sub-palette overlays — same gate as GameWindow. - PaletteOverride? paletteOverride = null; - if (subPalettes is { Count: > 0 } spList) - { - var ranges = new PaletteOverride.SubPaletteRange[spList.Count]; - for (int i = 0; i < spList.Count; i++) - ranges[i] = new PaletteOverride.SubPaletteRange( - spList[i].SubPaletteId, - spList[i].Offset, - spList[i].Length); - paletteOverride = new PaletteOverride( - BasePaletteId: basePaletteId ?? 0u, - SubPalettes: ranges); - } - - // --- part overrides (mirrors GameWindow:3407-3418) --- - PartOverride[] entityPartOverrides; - if (partOverrides is null or { Count: 0 }) - { - entityPartOverrides = Array.Empty(); - } - else - { - entityPartOverrides = new PartOverride[partOverrides.Count]; - for (int i = 0; i < partOverrides.Count; i++) - entityPartOverrides[i] = new PartOverride( - partOverrides[i].PartIndex, - partOverrides[i].GfxObjId); - } - - // --- assemble entity (mirrors GameWindow:3420-3431) --- - // Id=0: the paperdoll renderer assigns its own render-local id when it - // registers this entity with the mini-scene GpuWorldState. We set zero - // here so the builder stays pure (no static counter). The caller may - // replace it before registration. - return new WorldEntity - { - Id = DollRenderId, - ServerGuid = DollServerGuid, - SourceGfxObjOrSetupId = setupId, - Position = Vector3.Zero, - Rotation = _dollRotation, - MeshRefs = meshRefs, - PaletteOverride = paletteOverride, - PartOverrides = entityPartOverrides, - ParentCellId = null, // paperdoll mini-scene has no parent cell - }; - } -} diff --git a/src/AcDream.App/Rendering/EntityPhysicsHost.cs b/src/AcDream.App/Rendering/EntityPhysicsHost.cs deleted file mode 100644 index 151cd0a8..00000000 --- a/src/AcDream.App/Rendering/EntityPhysicsHost.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; - -namespace AcDream.App.Rendering; - -/// -/// R5-V2 — the App-side per entity: acdream's -/// stand-in for retail's CPhysicsObj as the movement managers see it. -/// One is built per entity (a remote RemoteMotion or the local player) -/// in GameWindow.EnsureRemoteMotionBindings / EnterPlayerModeNow -/// and registered in GameWindow._physicsHosts (guid → host), so -/// can resolve OTHER entities' hosts — the -/// cross-entity delivery path the voyeur system -/// needs. -/// -/// Owns a (retail -/// CPhysicsObj::target_manager). Its set_target/clear_target/ -/// add_voyeur/remove_voyeur/receive_target_update seams -/// forward to it exactly as retail's CPhysicsObj does; the movement managers' -/// target seams are repointed here, replacing the AP-79 poll adapter. The -/// per-entity accessors (position/velocity/radius/contact/clocks) and the -/// fan-out are injected by GameWindow so this -/// class stays free of GameWindow's internals (code-structure rule #1). -/// -/// R5-V3: owns a too (retail -/// CPhysicsObj::position_manager — retail creates it lazily via -/// get_position_manager; acdream constructs it eagerly, which is -/// behaviorally identical because the empty facade no-ops until its first -/// StickTo/ConstrainTo). fans -/// deliveries to the injected MoveToManager fan FIRST, then the -/// PositionManager — retail CPhysicsObj::HandleUpdateTarget order -/// (0x00512bc0: MovementManager @0x00512bf0, PositionManager -/// @0x00512c1a). -/// -public sealed class EntityPhysicsHost : IPhysicsObjHost -{ - private readonly Func _getPosition; - private readonly Func _getVelocity; - private readonly Func _getRadius; - private readonly Func _inContact; - private readonly Func _minterpMaxSpeed; - private readonly Func _curTime; - private readonly Func _physicsTimerTime; - private readonly Func _getObjectA; - private readonly Action _handleUpdateTarget; - private readonly Action _interruptCurrentMovement; - private readonly TargetManager _targetManager; - - public EntityPhysicsHost( - uint id, - Func getPosition, - Func getVelocity, - Func getRadius, - Func inContact, - Func minterpMaxSpeed, - Func curTime, - Func physicsTimerTime, - Func getObjectA, - Action handleUpdateTarget, - Action interruptCurrentMovement) - { - Id = id; - _getPosition = getPosition ?? throw new ArgumentNullException(nameof(getPosition)); - _getVelocity = getVelocity ?? throw new ArgumentNullException(nameof(getVelocity)); - _getRadius = getRadius ?? throw new ArgumentNullException(nameof(getRadius)); - _inContact = inContact ?? throw new ArgumentNullException(nameof(inContact)); - _minterpMaxSpeed = minterpMaxSpeed ?? throw new ArgumentNullException(nameof(minterpMaxSpeed)); - _curTime = curTime ?? throw new ArgumentNullException(nameof(curTime)); - _physicsTimerTime = physicsTimerTime ?? throw new ArgumentNullException(nameof(physicsTimerTime)); - _getObjectA = getObjectA ?? throw new ArgumentNullException(nameof(getObjectA)); - _handleUpdateTarget = handleUpdateTarget ?? throw new ArgumentNullException(nameof(handleUpdateTarget)); - _interruptCurrentMovement = interruptCurrentMovement - ?? throw new ArgumentNullException(nameof(interruptCurrentMovement)); - _targetManager = new TargetManager(this); - PositionManager = new PositionManager(this); - } - - // ── IPhysicsObjHost accessors ────────────────────────────────────────── - public uint Id { get; } - public Position Position => _getPosition(); - public Vector3 Velocity => _getVelocity(); - public float Radius => _getRadius(); - public bool InContact => _inContact(); - public float? MinterpMaxSpeed => _minterpMaxSpeed(); - public double CurTime => _curTime(); - public double PhysicsTimerTime => _physicsTimerTime(); - - /// The owned voyeur manager (retail - /// CPhysicsObj::target_manager). - public TargetManager TargetManager => _targetManager; - - /// R5-V3 — the owned facade (retail - /// CPhysicsObj::position_manager): sticky follow + (unarmed) - /// constraint leash. Seam targets: MoveToManager.StickTo/Unstick, - /// MotionInterpreter.UnstickFromObject, the per-tick - /// AdjustOffset/UseTime drivers. - public PositionManager PositionManager { get; } - - // ── IPhysicsObjHost fan-out / target-tracking seams ──────────────────── - public IPhysicsObjHost? GetObjectA(uint id) => _getObjectA(id); - - public void HandleUpdateTarget(TargetInfo info) - { - // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan order: - // MovementManager (the injected MoveToManager fan) first, then - // PositionManager (@0x00512c1a — the R5-V3 sticky consumer). - _handleUpdateTarget(info); - PositionManager.HandleUpdateTarget(info); - } - public void InterruptCurrentMovement() => _interruptCurrentMovement(); - - public void SetTarget(uint contextId, uint objectId, float radius, double quantum) - => _targetManager.SetTarget(contextId, objectId, radius, quantum); - - public void ClearTarget() => _targetManager.ClearTarget(); - public void ReceiveTargetUpdate(TargetInfo info) => _targetManager.ReceiveUpdate(info); - public void AddVoyeur(uint watcherId, float radius, double quantum) - => _targetManager.AddVoyeur(watcherId, radius, quantum); - public void RemoveVoyeur(uint watcherId) => _targetManager.RemoveVoyeur(watcherId); - - // ── per-tick driver + lifecycle (called by GameWindow) ───────────────── - - /// Retail TargetManager::HandleTargetting — the per-tick - /// voyeur sweep (self-throttled to 0.5 s). Retail runs it unconditionally - /// for every entity in UpdateObjectInternal, BEFORE the movement - /// managers' UseTime. - public void HandleTargetting() => _targetManager.HandleTargetting(); - - /// Retail CPhysicsObj::exit_world's - /// TargetManager::NotifyVoyeurOfEvent(ExitWorld) — tell every - /// watcher of this entity that it left the world (they drop the - /// stick/moveto). Called on despawn before the host is removed from the - /// registry. - public void NotifyExitWorld() => _targetManager.NotifyVoyeurOfEvent(TargetStatus.ExitWorld); - - /// R5-V3 (#171): retail CPhysicsObj::teleport_hook's tail - /// (0x00514ed0 @0x00514f1b-0x00514f28) — TargetManager::ClearTarget - /// (drop this entity's OWN subscription) then - /// NotifyVoyeurOfEvent(Teleported) (every entity watching THIS one - /// drops its stick/moveto — StickyManager::HandleUpdateTarget's - /// non-Ok teardown path). Called after a teleport placement. - public void NotifyTeleported() - { - _targetManager.ClearTarget(); - _targetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported); - } -} diff --git a/src/AcDream.App/Rendering/FadeOverlay.cs b/src/AcDream.App/Rendering/FadeOverlay.cs deleted file mode 100644 index caca95fd..00000000 --- a/src/AcDream.App/Rendering/FadeOverlay.cs +++ /dev/null @@ -1,121 +0,0 @@ -using System; -using Silk.NET.OpenGL; - -namespace AcDream.App.Rendering; - -/// -/// Fullscreen black quad at a given alpha — the teleport fade cover (retail-teleport -/// spec §C, 2026-06-22). Drawn LAST in the frame (over the world + UI) so it covers -/// everything; the drives the alpha -/// (opaque-black through the transit, ramping the world back in on WorldFadeIn). The -/// authentic 3D portal-swirl is deferred — this black cover replaces it for now. -/// -/// Draws in NDC, so it needs no view/projection. Self-contained GL state -/// (feedback_render_self_contained_gl_state): sets blend + disables depth, restores the -/// frame-global convention (depth test/write on) on exit. -/// -public sealed class FadeOverlay : IDisposable -{ - private const string VertSrc = @"#version 430 core -layout(location = 0) in vec2 aPos; -void main() { gl_Position = vec4(aPos, 0.0, 1.0); }"; - - private const string FragSrc = @"#version 430 core -uniform float uAlpha; -out vec4 FragColor; -void main() { FragColor = vec4(0.0, 0.0, 0.0, uAlpha); }"; - - private readonly GL _gl; - private readonly uint _program; - private readonly uint _vao; - private readonly uint _vbo; - private readonly int _locAlpha; - - // Two triangles covering the whole NDC viewport. - private static readonly float[] QuadVerts = - { - -1f, -1f, 1f, -1f, 1f, 1f, - -1f, -1f, 1f, 1f, -1f, 1f, - }; - - public FadeOverlay(GL gl) - { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); - - uint vs = Compile(ShaderType.VertexShader, VertSrc); - uint fs = Compile(ShaderType.FragmentShader, FragSrc); - _program = _gl.CreateProgram(); - _gl.AttachShader(_program, vs); - _gl.AttachShader(_program, fs); - _gl.LinkProgram(_program); - _gl.GetProgram(_program, ProgramPropertyARB.LinkStatus, out int linked); - if (linked == 0) - throw new InvalidOperationException($"FadeOverlay link failed: {_gl.GetProgramInfoLog(_program)}"); - _gl.DeleteShader(vs); - _gl.DeleteShader(fs); - - _locAlpha = _gl.GetUniformLocation(_program, "uAlpha"); - - _vao = _gl.GenVertexArray(); - _vbo = _gl.GenBuffer(); - _gl.BindVertexArray(_vao); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo); - unsafe - { - fixed (float* v = QuadVerts) - _gl.BufferData(BufferTargetARB.ArrayBuffer, - (nuint)(QuadVerts.Length * sizeof(float)), v, BufferUsageARB.StaticDraw); - _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), (void*)0); - } - _gl.EnableVertexAttribArray(0); - _gl.BindVertexArray(0); - _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); - } - - private uint Compile(ShaderType type, string src) - { - uint s = _gl.CreateShader(type); - _gl.ShaderSource(s, src); - _gl.CompileShader(s); - _gl.GetShader(s, ShaderParameterName.CompileStatus, out int ok); - if (ok == 0) - throw new InvalidOperationException($"FadeOverlay {type} compile failed: {_gl.GetShaderInfoLog(s)}"); - return s; - } - - /// - /// Draw the fullscreen black cover at (0 = clear → no-op, - /// 1 = opaque). Must be called LAST in the frame, after the world and UI, so it covers - /// everything on screen. - /// - public void Draw(float alpha) - { - if (alpha <= 0f) return; - alpha = Math.Clamp(alpha, 0f, 1f); - - // ---- set state (everything this draw depends on) ---- - _gl.Disable(EnableCap.DepthTest); - _gl.DepthMask(false); - _gl.Disable(EnableCap.CullFace); - _gl.Enable(EnableCap.Blend); - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - - _gl.UseProgram(_program); - _gl.Uniform1(_locAlpha, alpha); - _gl.BindVertexArray(_vao); - _gl.DrawArrays(PrimitiveType.Triangles, 0, 6); - _gl.BindVertexArray(0); - _gl.UseProgram(0); - - // ---- restore the frame-global convention ---- - _gl.DepthMask(true); - _gl.Enable(EnableCap.DepthTest); - } - - public void Dispose() - { - _gl.DeleteProgram(_program); - _gl.DeleteVertexArray(_vao); - _gl.DeleteBuffer(_vbo); - } -} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 8f2fc8c4..2365ca14 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -81,52 +81,6 @@ public sealed class GameWindow : IDisposable private int _terrainCpuSampleCursor; private long _terrainLastDiagTick; - // [FRAME-DIAG] (ACDREAM_WB_DIAG=1): per-frame cost split for the FPS deep-dive. - // The crux this measures: terrain APPLY runs in OnUpdate (under _datLock — - // dat reads + physics/ShadowObjects/BSP registration + the terrain GPU - // upload), while the DRAW + the title-bar ms run in OnRender, so a heavy - // apply stalls the loop turn yet is INVISIBLE to the render-only ms. We - // accumulate apply cost across each OnUpdate and flush it next to the - // [TERRAIN-DIAG] draw cost. Samples are µs×100 over a rolling 256-ring, - // reusing the [TERRAIN-DIAG] median/p95 helpers. Removable (mirrors the - // probes stripped in 92e95be). Gated once at construction — zero cost off. - private readonly bool _frameDiag = string.Equals( - System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", System.StringComparison.Ordinal); - private long _applyAccumTicks; // apply CPU accumulated across this OnUpdate - private long _applyUploadAccumTicks; // terrain glBufferSubData sub-span this OnUpdate - private int _appliesThisUpdate; // # landblock LOADS applied this OnUpdate - private int _frameDiagMaxAppliesPerUpdate; // burst size over the 5s window - private readonly long[] _applyCpuSamples = new long[256]; // apply µs×100 / Update - private int _applyCpuSampleCursor; - private readonly long[] _applyUploadSamples = new long[256]; // terrain-upload µs×100 / Update - private int _applyUploadSampleCursor; - private readonly long[] _entityUploadSamples = new long[256]; // _wbMeshAdapter.Tick µs×100 / Render - private int _entityUploadSampleCursor; - // Apply CPU split into three sub-spans (to name WHICH part of the 40-110ms - // apply dominates): cell-build (EnvCell dat reads + physics surfaces + - // buildings), gfxobj-BSP (per-entity physics BSP cache), ShadowObjects+lights - // registration. Accumulated per OnUpdate via checkpoints in ApplyLoadedTerrainLocked. - private long _applyCellAccumTicks; - private long _applyBspAccumTicks; - private long _applyShadowAccumTicks; - private readonly long[] _applyCellSamples = new long[256]; - private int _applyCellSampleCursor; - private readonly long[] _applyBspSamples = new long[256]; - private int _applyBspSampleCursor; - private readonly long[] _applyShadowSamples = new long[256]; - private int _applyShadowSampleCursor; - // Direct measure of the _datLock acquisition wait (the streaming worker holds - // the lock for its full per-landblock build, stalling this apply). Confirms the - // apply≫sub-span gap is lock contention, not in-lock work. - private long _applyLockWaitAccumTicks; - private readonly long[] _applyLockWaitSamples = new long[256]; - private int _applyLockWaitSampleCursor; - - // MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call - // per OnRender + three stage scopes. All logic lives in - // AcDream.App.Diagnostics.FrameProfiler (structure rule 1). - private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new(); - // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; private AcDream.App.Streaming.GpuWorldState _worldState = new(); @@ -217,7 +171,6 @@ public sealed class GameWindow : IDisposable // each frame on an indoor root (null on the outdoor root). private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; - private AcDream.App.Rendering.FadeOverlay? _fadeOverlay; // teleport fade cover (spec C) private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; // Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain @@ -252,13 +205,6 @@ public sealed class GameWindow : IDisposable /// private readonly Dictionary _animatedEntities = new(); - // MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys. - // WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically - // (`animatedEntityIds is null || animatedEntityIds.Count == 0`), so this can - // always be a live (possibly empty) HashSet instead of `new`ing one every - // frame or passing null. - private readonly HashSet _animatedIdsScratch = new(); - /// /// Tier 1 cache (#53): per-entity classification results for static /// entities (those NOT in ). Conceptually @@ -289,24 +235,6 @@ public sealed class GameWindow : IDisposable public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary? SurfaceOverrides)> PartTemplate; public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame] public AcDream.Core.Physics.AnimationSequencer? Sequencer; - - /// - /// MP-Alloc (2026-07-05): reusable per-entity MeshRefs buffer. Every - /// animated entity (including idle NPCs on a breathe cycle) used to - /// get a fresh `List<MeshRef>(partCount)` reassigned to - /// every tick — - /// the old list became garbage every frame. This buffer is cleared - /// and refilled in place instead; Entity.MeshRefs is set to - /// this SAME list reference once it's populated (assigning the - /// unchanged reference back is a no-op cost-wise). Safe because - /// TickAnimations runs single-threaded to completion before any - /// draw-side consumer reads MeshRefs, and the only cross-frame - /// consumer (RefreshPaperdollDoll) takes an explicit - /// `new List<MeshRef>(pe.MeshRefs)` VALUE copy — MeshRef is a - /// readonly record struct, so that copy is unaffected by later - /// in-place mutation of this list. - /// - public readonly List MeshRefsScratch = new(); } private AcDream.Core.Physics.DatCollectionLoader? _animLoader; @@ -412,16 +340,6 @@ public sealed class GameWindow : IDisposable /// private readonly Dictionary _remoteDeadReckon = new(); - /// - /// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates, - /// keyed by server guid. Retail keeps these stamps on CPhysicsObj - /// (update_times); seeded from CreateObject's PhysicsDesc timestamp - /// block in , consulted at the - /// top of , dropped with the entity in - /// . - /// - private readonly Dictionary _motionSequenceGates = new(); - /// /// Per-remote-entity physics + motion stack — verbatim application of /// retail's client-side motion pipeline to every remote. Mirrors @@ -438,26 +356,10 @@ public sealed class GameWindow : IDisposable /// remote gets the same treatment as the local player. /// /// - internal sealed class RemoteMotion // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it + private sealed class RemoteMotion { public AcDream.Core.Physics.PhysicsBody Body; - - /// R5-V5: retail CPhysicsObj::movement_manager — the - /// ONE per-entity owner of the interp + moveto pair (acclient.h - /// /* 3463 */). Constructed here with the interp; the - /// MoveToManager side arrives via MoveToFactory + - /// MakeMoveToManager() in EnsureRemoteMotionBindings. - /// / below are views of its - /// children (retail get_minterp-style access), kept so the - /// dozens of existing call sites read unchanged. - public AcDream.Core.Physics.Motion.MovementManager Movement; - - public AcDream.Core.Physics.MotionInterpreter Motion => Movement.Minterp; - /// R3-W4: the persistent per-remote dispatch sink (created - /// once by EnsureRemoteMotionBindings; also bound as - /// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch - /// cycles through the motion-table backend). - public AcDream.Core.Physics.Motion.MotionTableDispatchSink? Sink; + public AcDream.Core.Physics.MotionInterpreter Motion; /// Last UpdatePosition timestamp — drives body.update_object sub-stepping. public double LastServerPosTime; /// Last known server position — kept for diagnostics / HUD. @@ -470,19 +372,6 @@ public sealed class GameWindow : IDisposable /// public System.Numerics.Vector3 ServerVelocity; public bool HasServerVelocity; - - /// R4-V4: the entity's verbatim retail MoveToManager - /// (server-directed movement), constructed + seam-bound by - /// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. R5-V5: - /// owned by ; this is the child view. - public AcDream.Core.Physics.Motion.MoveToManager? MoveTo => Movement.MoveTo; - - // R5-V2: the entity's CPhysicsObj stand-in — owns the TargetManager - // voyeur system (retail CPhysicsObj::target_manager). Replaces the - // AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/ - // clear_target/quantum seams route here, HandleTargetting ticks per - // frame, and OTHER entities resolve this one via GameWindow._physicsHosts. - public EntityPhysicsHost? Host; /// /// True while a server MoveToObject/MoveToPosition packet is the /// active locomotion source. Retail runs these through MoveToManager @@ -587,8 +476,8 @@ public sealed class GameWindow : IDisposable /// queue catch-up REPLACES anim when active; anim stands when queue /// is idle. /// - public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } = - new AcDream.Core.Physics.RemoteMotionCombiner(); + public AcDream.Core.Physics.PositionManager Position { get; } = + new AcDream.Core.Physics.PositionManager(); /// /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the @@ -629,6 +518,19 @@ public sealed class GameWindow : IDisposable /// public float MaxSeqSpeedSinceLastUP; + /// + /// Seconds-since-epoch timestamp of the most recent UpdateMotion (UM) + /// for this remote. Used by the player-remote velocity-fallback cycle + /// refinement to skip refinement while a fresh UM is authoritative — + /// retail's outbound MoveToState gives us direction-explicit cycles + /// on direction-key changes (W press, W release, W↔S flip), and we + /// only want UP-derived velocity to refine the speed bucket within + /// a direction when no UM has arrived recently. Defaults to 0 + /// (epoch) so the first UP after spawn is allowed to refine + /// immediately if velocity already differs from the spawn cycle. + /// + public double LastUMTime; + public RemoteMotion() { Body = new AcDream.Core.Physics.PhysicsBody @@ -642,27 +544,8 @@ public sealed class GameWindow : IDisposable TransientState = AcDream.Core.Physics.TransientStateFlags.Contact | AcDream.Core.Physics.TransientStateFlags.OnWalkable | AcDream.Core.Physics.TransientStateFlags.Active, - // R4-V5 door-swing fix: a RemoteMotion exists only for a - // WORLD entity — retail's physics_obj->cell is non-null the - // moment the object is placed. Without this, the interp's - // detached-object guard stripped every dispatched - // transition link (see PhysicsBody.InWorld). - InWorld = true, }; - // R5-V5: the interp is owned by the MovementManager facade - // (retail CPhysicsObj::movement_manager -> motion_interpreter). - // acdream constructs it eagerly here — retail's lazy-create - // window is never observable (see MovementManager's class doc). - Movement = new AcDream.Core.Physics.Motion.MovementManager( - new AcDream.Core.Physics.MotionInterpreter(Body) - { - // R4-V5 #160 fix: retail remotes carry a weenie whose - // InqRunRate FAILS, landing apply_run_to_command on - // my_run_rate (the M13 wire feed). A null weenie took the - // degenerate 1.0 branch — observer-side run movetos played - // and moved in slow motion. See RemoteWeenie. - WeenieObj = new AcDream.Core.Physics.RemoteWeenie(), - }); + Motion = new AcDream.Core.Physics.MotionInterpreter(Body); } } @@ -712,10 +595,7 @@ public sealed class GameWindow : IDisposable // SpellTable.Empty if the file is missing (e.g. tooling contexts). public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable(); public readonly AcDream.Core.Spells.Spellbook SpellBook = null!; - public readonly AcDream.Core.Items.ClientObjectTable Objects = new(); - /// Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source). - public IReadOnlyList Shortcuts { get; private set; } - = System.Array.Empty(); + public readonly AcDream.Core.Items.ItemRepository Items = new(); // Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from // PlayerDescription so the Vitals HUD can render those bars. // Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment @@ -732,24 +612,6 @@ public sealed class GameWindow : IDisposable // when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator; private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm; - // Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1. - private AcDream.App.UI.UiHost? _uiHost; - // Phase D.5.1 — toolbar controller (kept for lifetime clarity; mirrors _chatWindowController pattern). - private AcDream.App.UI.Layout.ToolbarController? _toolbarController; - // Phase D.5.3a — selected-object strip controller (name, overlay state, health meter). - private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController; - // Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter). - private AcDream.App.UI.Layout.InventoryController? _inventoryController; - // Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler). - private AcDream.App.UI.Layout.PaperdollController? _paperdollController; - // Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport - // widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625). - private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer; - private AcDream.App.UI.UiViewport? _paperdollViewportWidget; - private AcDream.App.UI.UiNineSlicePanel? _inventoryFrame; - private bool _paperdollDollDirty = true; - // Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad. - private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry; // Phase I.2: ImGui debug panel ViewModel. Lives for as long as // _panelHost does. Self-subscribes to CombatState in its ctor, so // disposing isn't required (panel host holds the only ref). @@ -812,6 +674,8 @@ public sealed class GameWindow : IDisposable private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera; private bool _playerMode; private uint _playerServerGuid; + private uint? _playerCurrentAnimCommand; + private float _playerCurrentAnimSpeed = 1f; private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character private MovementTruthOutbound? _lastMovementTruthOutbound; @@ -927,17 +791,6 @@ public sealed class GameWindow : IDisposable private AcDream.Core.Net.WorldSession? _liveSession; private int _liveCenterX; private int _liveCenterY; - // Set by the login-spawn recenter (network thread) when the spawn landblock - // differs from the startup streaming center; consumed on the render thread - // in OnUpdateFrame to trigger StreamingController.ForceReloadWindow(). A far - // login-spawn moves the render origin just like an outdoor teleport, so the - // streaming region — bootstrapped around the stale startup center — must be - // rebuilt fresh around the spawn. Without this, RecenterTo treats the - // old/new window overlap as already-resident (MarkResidentFromBootstrap marks - // residence before the async loads land), leaving a permanent hole of - // resident-but-never-loaded landblocks where the player spawns — the - // cold-spawn "invisible player / world not loading" bug (2026-07-03). - private volatile bool _pendingForceReloadWindow; private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id // K-fix1 (2026-04-26): cached at startup so per-frame branches are @@ -975,6 +828,7 @@ public sealed class GameWindow : IDisposable /// keys the render list; this parallel dictionary keys by server guid. /// private readonly Dictionary _entitiesByServerGuid = new(); + private readonly Dictionary _liveEntityInfoByGuid = new(); /// /// Latest for each /// guid. Captured at the end of so @@ -984,21 +838,6 @@ public sealed class GameWindow : IDisposable private readonly Dictionary _lastSpawnByGuid = new(); // Current selection: written by Q-cycle (combat) and LMB click (interact); cleared on entity despawn. private uint? _selectedGuid; - /// Fires when the selected world object changes (retail gmToolbarUI selection-change event, - /// acclient_2013_pseudo_c.txt:198635). Private: only the internal SelectedObjectController subscribes. - private event Action? SelectionChanged; - /// Currently-selected world object guid. The setter fires only on - /// an actual change (dedup), so all writes go through here; reads may use the field directly. - private uint? SelectedGuid - { - get => _selectedGuid; - set - { - if (_selectedGuid == value) return; - _selectedGuid = value; - SelectionChanged?.Invoke(value); - } - } // B.6/B.7 (2026-05-16): pending close-range action that will be fired // once the local auto-walk overlay reports arrival (body has finished @@ -1006,20 +845,9 @@ public sealed class GameWindow : IDisposable // far-range sends fire the wire packet immediately at SendUse/SendPickUp // time. Cleared before the deferred send fires — single-fire, no retry. private (uint Guid, bool IsPickup)? _pendingPostArrivalAction; - - // R5-V2: the local player's CPhysicsObj stand-in — owns the player's - // TargetManager voyeur system, registered in _physicsHosts so remote - // entities chasing the player resolve it. Replaces the AP-79 - // _playerMoveToTarget* poll fields. - private EntityPhysicsHost? _playerHost; - - // R5-V2: guid → per-entity IPhysicsObjHost registry (retail's - // CObjectMaint::GetObjectA lookup). Backs every host's GetObjectA seam, - // giving the TargetManager voyeur round-trip its cross-entity delivery - // path. Populated in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow - // (player); pruned in RemoveLiveEntityByServerGuid. - private readonly Dictionary _physicsHosts = new(); - + private readonly record struct LiveEntityInfo( + string? Name, + AcDream.Core.Items.ItemType ItemType); private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; private const double ServerControlledVelocityStaleSeconds = 0.60; private int _liveSpawnReceived; // diagnostics @@ -1034,14 +862,12 @@ public sealed class GameWindow : IDisposable private int _liveAnimRejectSingleFrame; private int _liveAnimRejectPartFrames; - public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents, - AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null) + public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents) { _options = options ?? throw new System.ArgumentNullException(nameof(options)); _datDir = options.DatDir; _worldGameState = worldGameState; _worldEvents = worldEvents; - _uiRegistry = uiRegistry; SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); } @@ -1084,6 +910,7 @@ public sealed class GameWindow : IDisposable var startupDisplay = startupStore.LoadDisplay(); var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); + var options = WindowOptions.Default with { Size = new Vector2D(1280, 720), @@ -1145,10 +972,8 @@ public sealed class GameWindow : IDisposable _kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb); _mouseSource = new AcDream.App.Input.SilkMouseSource( firstMouse, - wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) - || (_uiHost?.Root.WantsMouse ?? false), - wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard) - || (_uiHost?.Root.WantsKeyboard ?? false)); + wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse, + wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); _mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers; _inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher( _kbSource, _mouseSource, _keyBindings); @@ -1182,36 +1007,21 @@ public sealed class GameWindow : IDisposable // integrates gravity against an empty world and free-falls // the player into the void (retail loads cells synchronously; // this is the async-streaming equivalent of that invariant). - isSpawnGroundReady: () => - { - if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false; - - // #107 / #135: spawn-ground readiness is spawn-claim aware. For an - // INDOOR claim (sealed dungeon / building interior) the ground the - // player lands on is the EnvCell FLOOR (its BSP), so gate on the - // cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap. - // A dungeon's cells sit in their landblock at an arbitrary (often - // negative) offset, so the spawn's WORLD position can fall in a - // NEIGHBOUR terrain landblock that the #135 dungeon collapse - // deliberately does not load; requiring terrain there hangs login - // forever (cellReady true, SampleTerrainZ null). Retail loads the - // cell synchronously and places the player on the cell floor — - // cellReady is the faithful indoor equivalent (#106/#107, AD-2). - // (Before #135 this only passed by accident: the 25×25 window - // happened to stream the neighbour terrain.) - if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp) - && sp.Position is { } spawnClaim - && spawnClaim.LandblockId != 0 - && (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u - && !IsSpawnClaimUnhydratable(spawnClaim.LandblockId)) - return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId); - - // Outdoor spawn, OR an unhydratable indoor claim that will demote to - // an outdoor position: hold until the terrain under the spawn streams - // (the original #106 gate — entering against an empty world free-falls - // the player into the void). - return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null; - }, + isSpawnGroundReady: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) + && _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null + // #107 gate-2 extension (2026-06-10): an INDOOR spawn claim + // additionally waits for the claimed cell's hydration so the + // entry snap's AdjustPosition validation can act (retail loads + // the cell synchronously before SetPosition; this is the + // async-streaming equivalent). Claims that can never hydrate + // (id outside the landblock's NumCells range per the dat) + // don't hold the gate — the Resolve-head safety net demotes + // them loudly. + && (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp) + || sp.Position is not { } spawnClaim + || spawnClaim.LandblockId == 0 + || _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId) + || IsSpawnClaimUnhydratable(spawnClaim.LandblockId)), enterPlayerMode: EnterPlayerModeFromAutoEntry); } @@ -1229,8 +1039,7 @@ public sealed class GameWindow : IDisposable // K.1b §E: explicit WantCaptureMouse defense-in-depth on the // surviving direct-mouse handler. Suppresses RMB orbit / // FlyCamera look while ImGui has the mouse focus. - if ((DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) - || (_uiHost?.Root.WantsMouse ?? false)) + if (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse) { _lastMouseX = pos.X; _lastMouseY = pos.Y; @@ -1464,7 +1273,7 @@ public sealed class GameWindow : IDisposable // live state from this GameWindow instance every frame: // - selected guid → _selectedGuid (set by PickAndStoreSelection) // - entity resolver → position from _entitiesByServerGuid + - // itemType from ClientObjectTable (Objects) + last spawn + // itemType / PWD bits from cached LiveEntityInfo + last spawn // - camera → _cameraController.Active or (zero) when not // yet ready, in which case the panel bails on viewport==0. _targetIndicator = new AcDream.App.UI.TargetIndicatorPanel( @@ -1473,7 +1282,9 @@ public sealed class GameWindow : IDisposable { if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return null; - uint rawItemType = (uint)LiveItemType(guid); + uint rawItemType = 0; + if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)) + rawItemType = (uint)info.ItemType; uint pwdBits = 0; uint? useability = null; if (_lastSpawnByGuid.TryGetValue(guid, out var spawn)) @@ -1918,503 +1729,6 @@ public sealed class GameWindow : IDisposable // references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132. _samplerCache = new SamplerCache(_gl); - // Phase D.2b — retail-look UI (ACDREAM_RETAIL_UI=1). Wires the existing - // UiHost retained-mode tree (dormant until now) + a first vitals panel. - // Render-only: UiHost input is NOT yet bridged to the InputDispatcher - // (next sub-phase), so the close button + window drag are inert. Coexists - // with the ImGui devtools path (ACDREAM_DEVTOOLS=1), which is unchanged. - if (_options.RetailUi) - { - _vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); - _uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont); - - // Feed Silk input to the UiRoot tree so windows drag / close / select. - // UiRoot consumes UI events; the game InputDispatcher (subscribed to the - // same devices) is gated off via WantCaptureMouse/Keyboard above when the - // pointer is over a widget — no double-handling. - foreach (var m in _input!.Mice) _uiHost.WireMouse(m); - foreach (var kb in _input!.Keyboards) _uiHost.WireKeyboard(kb); - - var cache = _textureCache!; - (uint, int, int) ResolveChrome(uint id) - { - uint t = cache.GetOrUploadRenderSurface(id, out int w, out int h); - return (t, w, h); - } - - // Phase D.5.1 — icon composer for the toolbar shortcut slots. - // Constructed once here so the closure below can capture it; needs - // the same cache reference that ResolveChrome uses above. - var iconComposer = new AcDream.App.UI.IconComposer(_dats!, cache); - - // Phase D.2b — optional retail stylesheet. controls.ini lives under - // the AC install (ACDREAM_AC_DIR); absent → source-verified fallback. - var controls = _options.AcDir is { } acDir - ? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini")) - : AcDream.App.UI.ControlsIni.Parse(string.Empty); - // Phase D.2b — retail dat-font for the vitals numbers (Font 0x40000000, - // Latin-1, 16px, outline atlas). Passed into the importer so the meter - // number overlay renders through the dat-font two-pass blit; falls back to - // the debug font only if it fails to load. Under _datLock like other reads. - AcDream.App.UI.UiDatFont? vitalsDatFont; - lock (_datLock) - vitalsDatFont = AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!); - Console.WriteLine(vitalsDatFont is not null - ? "[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay." - : "[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font."); - - // Phase D.2b — the vitals window is data-driven from the dat LayoutDesc - // (0x2100006C) via the LayoutImporter. The former hand-authored vitals.xml - // markup path was retired after the importer proved pixel-identical at the - // 2026-06-15 A/B gate. MarkupDocument stays for plugin/custom panels. - AcDream.App.UI.Layout.ImportedLayout? imported; - lock (_datLock) - imported = AcDream.App.UI.Layout.LayoutImporter.Import( - _dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont); - if (imported is not null) - { - AcDream.App.UI.Layout.VitalsController.Bind(imported, - healthPct: () => _vitalsVm!.HealthPercent, - staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f, - manaPct: () => _vitalsVm!.ManaPercent ?? 0f, - healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "", - staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "", - manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : ""); - // Top-level retail window: user-positioned (Anchors.None so the per-frame - // anchor pass doesn't reset it), movable, and horizontally resizable like - // retail. On a width change the dat edge-anchors reflow the pieces - // (UIElement::UpdateForParentSizeChange @0x00462640): top/bottom edges + - // the three bars stretch, corners stay 5px, the right edge/corners track - // the right side. Vertical resize is off (the layout has no vertical stretch). - var vitalsRoot = imported.Root; - vitalsRoot.Left = 10; vitalsRoot.Top = 30; - vitalsRoot.ClickThrough = false; - vitalsRoot.Anchors = AcDream.App.UI.AnchorEdges.None; - vitalsRoot.Draggable = true; - vitalsRoot.Resizable = true; - vitalsRoot.ResizeX = true; - vitalsRoot.ResizeY = false; - vitalsRoot.MinWidth = 40f; - _uiHost.Root.AddChild(vitalsRoot); - Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C)."); - } - else - { - Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable."); - } - - // Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI), - // the same importer path as vitals. ChatWindowController binds the transcript, - // input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter. - var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200); - AcDream.App.UI.Layout.ElementInfo? chatRootInfo; - AcDream.App.UI.Layout.ImportedLayout? chatLayout; - lock (_datLock) - { - chatRootInfo = AcDream.App.UI.Layout.LayoutImporter.ImportInfos( - _dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId); - chatLayout = chatRootInfo is null ? null - : AcDream.App.UI.Layout.LayoutImporter.Build(chatRootInfo, ResolveChrome, vitalsDatFont); - } - if (chatRootInfo is not null && chatLayout is not null) - { - var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind( - chatRootInfo, chatLayout, retailChatVm, - () => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance, - vitalsDatFont, _debugFont, ResolveChrome); - if (chatController is not null) - { - // Ctrl+C / Ctrl+A on the transcript + Ctrl+C/X/V/A on the input need the - // keyboard for clipboard + modifier (Ctrl/Shift) state. _uiHost.Keyboard - // is set by WireKeyboard above — it is non-null here. - chatController.Transcript.Keyboard = _uiHost.Keyboard; - chatController.Input.Keyboard = _uiHost.Keyboard; - // Wrap the dat content in the universal 8-piece beveled window chrome — - // the SAME UiNineSlicePanel the vitals window uses. The chat's own dat - // layout only carries flat background sprites, so without this the window - // has no retail-style border (the user asked for the vitals border). The - // nine-slice IS the movable/resizable window; the dat content fills its - // interior, inset by the border. The gmMainChatUI content is authored 490 - // wide (its transcript/input panels) — KEEP that width + the dat-authored - // HEIGHT so the content's child anchors (input-bar-at-bottom, transcript- - // fills) capture correct margins on first layout; resizing the frame reflows - // them correctly from there. - const int chatBorder = AcDream.App.UI.RetailChromeSprites.Border; - var chatRoot = chatController.Root; - float contentW = 490f, contentH = chatRoot.Height; // dat-authored height - var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) - { - Left = 10, Top = 440, - Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder, - MinWidth = 200f, MinHeight = 90f, - // Retail chat is translucent — fade the window's backgrounds/chrome - // (text stays opaque). Configurable opacity is a later step; 0.75 reads - // as see-through-but-readable. (retail SetDefaultOpacity ~0.5 / active 1.0) - Opacity = 0.75f, - }; - chatRoot.Left = chatBorder; chatRoot.Top = chatBorder; - chatRoot.Width = contentW; chatRoot.Height = contentH; - chatRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top - | AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom; - chatRoot.Draggable = false; chatRoot.Resizable = false; - chatFrame.AddChild(chatRoot); - _uiHost.Root.AddChild(chatFrame); - // Tab / Enter enters "write mode" by focusing this input (retail's chat - // activation); a focused input suppresses character movement (see the - // WantsKeyboard gate in the movement poll). - _uiHost.Root.DefaultTextInput = chatController.Input; - Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006)."); - } - else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006."); - } - else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found."); - - // Phase D.5.1 — toolbar window, data-driven from LayoutDesc 0x21000016 - // (gmToolbarUI). Mirrors the vitals/chat import+bind+mount pattern above. - - // Read the shortcut-slot digit sprite DID arrays from LayoutDesc 0x21000037 - // (the UIItem cell template): element 0x1000034A under composite 0x10000346. - // Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465); - // gmToolbarUI::RecvNotice_SetCombatMode (196610-196621) re-stamps per stance. - // Occupancy branch (decomp 229481): - // occupied → StateDesc.Properties[0x10000042] (peace) / [0x10000043] (war) - // empty → StateDesc.Properties[0x1000005e] (background digit, stance-independent) - uint[]? toolbarPeaceDigits = null; - uint[]? toolbarWarDigits = null; - uint[]? toolbarEmptyDigits = null; - lock (_datLock) - { - var uiItemLd = _dats!.Get(0x21000037u); - if (uiItemLd is not null - && uiItemLd.Elements.TryGetValue(0x10000346u, out var composite) - && composite.Children.TryGetValue(0x1000034Au, out var shortcutNumElem) - && shortcutNumElem.StateDesc is { } sd - && sd.Properties is { } props) - { - // Mirror LayoutImporter.ReadState: Properties[key] is ArrayBaseProperty - // containing DataIdBaseProperty entries. Each DataIdBaseProperty.Value is - // the RenderSurface DID for that digit. - // Peace: property 0x10000042; War: property 0x10000043. - if (props.TryGetValue(0x10000042u, out var rawPeace) - && rawPeace is DatReaderWriter.Types.ArrayBaseProperty arrPeace) - { - toolbarPeaceDigits = new uint[arrPeace.Value.Count]; - for (int i = 0; i < arrPeace.Value.Count; i++) - if (arrPeace.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d) - toolbarPeaceDigits[i] = d.Value; - } - if (props.TryGetValue(0x10000043u, out var rawWar) - && rawWar is DatReaderWriter.Types.ArrayBaseProperty arrWar) - { - toolbarWarDigits = new uint[arrWar.Value.Count]; - for (int i = 0; i < arrWar.Value.Count; i++) - if (arrWar.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d) - toolbarWarDigits[i] = d.Value; - } - } - else - { - Console.WriteLine("[D.5.1] digit arrays: element 0x1000034A/0x10000346 not found in LayoutDesc 0x21000037 — falling back to cited constants."); - } - - // Empty-slot BACKGROUND digit lives under a DIFFERENT cell composite: - // composite 0x10000341 (the UIElement_UIItem-typed variant) carries property - // 0x1000005e (plainer digits 0x060010FA..0x06001102 for 1-9, 0x060074CF for the - // bottom row); composite 0x10000346 (peace/war, read above) does NOT carry it. - // Confirmed by a live dat property dump. Retail: UIElement_UIItem::SetShortcutNum - // (decomp 229481/229493) — empty branch (m_elem_Icon->m_state == 0x1000001c) reads - // 0x1000005e, stance-independent. No fallback constants (safe: no digit if absent). - if (uiItemLd is not null - && uiItemLd.Elements.TryGetValue(0x10000341u, out var emptyComposite) - && emptyComposite.Children.TryGetValue(0x1000034Au, out var emptyScn) - && emptyScn.StateDesc is { } emptySd - && emptySd.Properties is { } emptyProps - && emptyProps.TryGetValue(0x1000005Eu, out var rawEmpty) - && rawEmpty is DatReaderWriter.Types.ArrayBaseProperty arrEmpty) - { - toolbarEmptyDigits = new uint[arrEmpty.Value.Count]; - for (int i = 0; i < arrEmpty.Value.Count; i++) - if (arrEmpty.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d) - toolbarEmptyDigits[i] = d.Value; - } - Console.WriteLine($"[D.5.1] empty digit array (0x10000341/0x1000005e): {toolbarEmptyDigits?.Length ?? 0} entries."); - } - - // Cited-constant fallback (UIElement_UIItem::SetShortcutNum, decomp 229465 + dat probe). - // Used when the dat navigation above fails (e.g. missing LayoutDesc in older dat). - if (toolbarPeaceDigits is null) - toolbarPeaceDigits = new uint[] - { 0x0600109Eu, 0x0600109Fu, 0x060010A0u, 0x060010A1u, 0x060010A2u, - 0x060010A3u, 0x060010A4u, 0x060010A5u, 0x060010A6u }; - if (toolbarWarDigits is null) - toolbarWarDigits = new uint[] - { 0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u, - 0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u }; - // Report the arrays actually used (after any fallback substitution). - Console.WriteLine($"[D.5.1] toolbar digit arrays ready: peace={toolbarPeaceDigits.Length}, war={toolbarWarDigits.Length}, empty={toolbarEmptyDigits?.Length ?? 0} entries."); - - AcDream.App.UI.Layout.ImportedLayout? toolbarLayout; - lock (_datLock) - toolbarLayout = AcDream.App.UI.Layout.LayoutImporter.Import( - _dats!, 0x21000016u, ResolveChrome, vitalsDatFont); - if (toolbarLayout is not null) - { - _toolbarController = AcDream.App.UI.Layout.ToolbarController.Bind( - toolbarLayout, Objects, - () => Shortcuts, - iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), - useItem: guid => UseItemByGuid(guid), - combatState: Combat, - peaceDigits: toolbarPeaceDigits, - warDigits: toolbarWarDigits, - emptyDigits: toolbarEmptyDigits, - sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g), - sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i)); - - // Phase D.5.3a — selected-object strip (name, overlay state, health meter). - // Analogue of retail gmToolbarUI::HandleSelectionChanged - // (acclient_2013_pseudo_c.txt:198635). - _selectedObjectController = AcDream.App.UI.Layout.SelectedObjectController.Bind( - toolbarLayout, - subscribeSelectionChanged: h => SelectionChanged += h, - subscribeHealthChanged: h => Combat.HealthChanged += h, - isHealthTarget: IsHealthBarTarget, - name: g => Objects.Get(g)?.Name, - healthPercent: g => Combat.GetHealthPercent(g), - hasHealth: g => Combat.HasHealth(g), - stackSize: g => (uint)(Objects.Get(g)?.StackSize ?? 0), - sendQueryHealth: g => _liveSession?.SendQueryHealth(g), - datFont: vitalsDatFont); - - var toolbarRoot = toolbarLayout.Root; - // Wrap the dat content in the universal 8-piece beveled window chrome — - // the SAME UiNineSlicePanel used by the vitals and chat windows. The - // toolbar LayoutDesc (0x21000016) is 300×122; the frame adds one border - // thickness on every side, giving an outer window of 310×132. - const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border; - float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height; - var toolbarFrame = new AcDream.App.UI.UiCollapsibleFrame(ResolveChrome) - { - Left = 10, Top = 300, - Width = toolbarContentW + 2 * toolbarBorder, - Height = toolbarContentH + 2 * toolbarBorder, - // The toolbar is fully opaque (not translucent like the chat window). - Opacity = 1.0f, - }; - // Content is offset by the border so it sits inside the chrome. - toolbarRoot.Left = toolbarBorder; - toolbarRoot.Top = toolbarBorder; - toolbarRoot.Width = toolbarContentW; - toolbarRoot.Height = toolbarContentH; - // Anchor content to Left|Top|Right only — drop Bottom so the dat content - // keeps its full height when the frame collapses; row 2 hides via Visible, - // not reflow. (Width is fixed so the horizontal anchors are inert but harmless.) - toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top - | AcDream.App.UI.AnchorEdges.Right; - // The frame is the draggable window; the content itself is not. ClickThrough so the - // content panel never CLAIMS a hit — its behavioral children (slots, indicators) are - // hit children-first, and its empty areas fall through to the frame (move). Critically, - // when collapsed the row-2 band is hidden, so below the collapsed frame the content has - // no visible/hit children and ClickThrough lets clicks fall through (no phantom - // window-drag from where row 2 used to be). Same pattern as the chat content panel. - toolbarRoot.ClickThrough = true; - toolbarRoot.Draggable = false; - toolbarRoot.Resizable = false; - toolbarFrame.AddChild(toolbarRoot); - - // Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden) - // and two-row height. CollapsedHeight is computed from the layout (just above row 2), - // so there's no magic constant. Bottom-edge only; default expanded. - // The full row-2 band (all at content-y 90, toolbar dump 0x21000016): a 6px left - // edge-piece (0x100006B6) + the 9 slots (0x100006B7..BF) + an 8px right edge-piece - // (0x100006C0). Hide ALL 11 when collapsed — hiding only the 9 slots leaves the two - // edge-pieces drawing as black pillars below the bar. - uint[] row2Ids = - { - 0x100006B6u, - 0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu, - 0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu, - 0x100006C0u, - }; - var toolbarRow2 = new System.Collections.Generic.List(); - float minRow2Top = float.MaxValue; - foreach (var id in row2Ids) - if (toolbarLayout.FindElement(id) is { } e2) - { - toolbarRow2.Add(e2); - if (e2.Top < minRow2Top) minRow2Top = e2.Top; - } - if (toolbarRow2.Count > 0) - { - float expandedH = toolbarContentH + 2 * toolbarBorder; // today's full height - float collapsedH = minRow2Top + 2 * toolbarBorder; // just above row 2 - toolbarFrame.CollapsedHeight = collapsedH; - toolbarFrame.ExpandedHeight = expandedH; - toolbarFrame.SecondRow = toolbarRow2; - toolbarFrame.Resizable = true; - toolbarFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; // bottom edge only - toolbarFrame.MinHeight = collapsedH; - toolbarFrame.MaxHeight = expandedH; - // Height stays expandedH (already set at construction) = default expanded. - Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0})."); - } - - _uiHost.Root.AddChild(toolbarFrame); - - Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016)."); - } - else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found."); - - // Drain plugin-registered markup panels (buffered before the GL - // window opened) into the same UiRoot tree. A faulty plugin markup - // file is isolated — logged + skipped, never crashes the client. - if (_uiRegistry is not null) - { - foreach (var p in _uiRegistry.Drain()) - { - try - { - string pluginXml = System.IO.File.ReadAllText(p.MarkupPath); - var pluginPanel = AcDream.App.UI.MarkupDocument.Build( - pluginXml, p.Binding, ResolveChrome, controls); - _uiHost.Root.AddChild(pluginPanel); - Console.WriteLine($"[D.2b] plugin UI panel loaded: {p.MarkupPath}"); - } - catch (Exception ex) - { - Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}"); - } - } - } - - // Phase D.2b-B — the real inventory window from LayoutDesc 0x21000023 (gmInventoryUI), - // via the LayoutImporter. The sub-window mount pulls in the nested paperdoll/backpack/ - // 3D-items panels (Type-0 leaves inheriting BaseLayoutId 0x21000024/22/21). Starts - // HIDDEN; F12 toggles it via the window manager. Position is the dat's own (X=500,Y=138). - AcDream.App.UI.Layout.ImportedLayout? invLayout; - lock (_datLock) - invLayout = AcDream.App.UI.Layout.LayoutImporter.Import( - _dats!, 0x21000023u, ResolveChrome, vitalsDatFont); - if (invLayout is not null) - { - var inventoryRoot = invLayout.Root; - // Wrap the dat content in the universal 8-piece beveled window chrome — the - // SAME UiNineSlicePanel the vitals/chat/toolbar windows use. The gmInventoryUI - // LayoutDesc (0x21000023) is 300×362; the frame adds one border thickness on - // every side. The frame is the draggable window; the dat content sits inside it. - const int invBorder = AcDream.App.UI.RetailChromeSprites.Border; - float invContentW = inventoryRoot.Width, invContentH = inventoryRoot.Height; - var inventoryFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome) - { - Left = inventoryRoot.Left, Top = inventoryRoot.Top, // keep the dat window position - Width = invContentW + 2 * invBorder, - Height = invContentH + 2 * invBorder, - Opacity = 1.0f, - Visible = false, // F12 toggles it - Anchors = AcDream.App.UI.AnchorEdges.None, // user-positioned - Draggable = true, - }; - // Content offset by the border so it sits inside the chrome. - inventoryRoot.Left = invBorder; inventoryRoot.Top = invBorder; - inventoryRoot.Width = invContentW; inventoryRoot.Height = invContentH; - // Content fills the frame vertically (Left|Top|Bottom = fixed width + height tracks - // the frame) so a vertical resize grows the inner content. - inventoryRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top - | AcDream.App.UI.AnchorEdges.Bottom; - inventoryRoot.Draggable = false; - inventoryFrame.AddChild(inventoryRoot); - - // Vertical-only resize: drag the BOTTOM edge to expand and see more of the pack. - // ResizeX=false + ResizableEdges=Bottom blocks horizontal resize (user request). - // MinHeight = the dat default (no shrink below it); MaxHeight = toward full screen. - inventoryFrame.Resizable = true; - inventoryFrame.ResizeX = false; - inventoryFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; - inventoryFrame.MinHeight = invContentH + 2 * invBorder; - inventoryFrame.MaxHeight = 560f; - - // Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid - // + scrollbar + the full-window backdrop grow vertically (Left|Top|Bottom); the - // paperdoll + side-bag column stay pinned at the top (Left|Top). The grid's ViewHeight - // grows → more rows + the scrollbar thumb ratio (view/content) reflects it. - void StretchV(uint id) - { - if (invLayout!.FindElement(id) is { } el) - el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top - | AcDream.App.UI.AnchorEdges.Bottom; - } - void PinTopLeft(uint id) - { - if (invLayout!.FindElement(id) is { } el) - el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top; - } - StretchV(0x100001D0u); // full-window backdrop - StretchV(0x100001CFu); // gm3DItemsUI sub-window (contents-grid container) - StretchV(0x100001C6u); // contents grid (UiItemList) - StretchV(0x100001C7u); // contents scrollbar - PinTopLeft(0x100001CDu); // paperdoll (fixed at top) - PinTopLeft(0x100001CEu); // side-bag column (fixed at top) - - _uiHost.Root.AddChild(inventoryFrame); - _uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryFrame); - - // Phase D.2b-B — populate the inventory from ClientObjectTable: the - // "Contents of Backpack" grid + the pack-selector strip + the burden meter + - // captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit. - - // Resolve each inventory list's empty-slot art from the dat cell template - // (retail UIElement_ItemList::InternalCreateItem 0x004e3570: attr 0x1000000e -> - // catalog 0x21000037 prototype's ItemSlot_Empty). The contents grid lives in - // gm3DItemsUI (0x21000021); the side-bag + main-pack in gmBackpackUI (0x21000022). - uint contentsEmpty, sideBagEmpty, mainPackEmpty; - lock (_datLock) - { - contentsEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000021u, 0x100001C6u); - sideBagEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001CAu); - mainPackEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001C9u); - } - Console.WriteLine($"[D.2b empty-slot] contents=0x{contentsEmpty:X8} sideBag=0x{sideBagEmpty:X8} mainPack=0x{mainPackEmpty:X8}"); - - _inventoryController = AcDream.App.UI.Layout.InventoryController.Bind( - invLayout, Objects, - playerGuid: () => _playerServerGuid, - iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), - strength: () => LocalPlayer.GetAttribute( - AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa - ? (int?)sa.Current : null, - datFont: vitalsDatFont, - contentsEmptySprite: contentsEmpty, - sideBagEmptySprite: sideBagEmpty, - mainPackEmptySprite: mainPackEmpty, - sendUse: g => _liveSession?.SendUse(g), - sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g), - sendPutItemInContainer: (item, container, placement) => - _liveSession?.SendPutItemInContainer(item, container, placement)); - - // Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear + - // wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid). - _paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind( - invLayout, Objects, - playerGuid: () => _playerServerGuid, - iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects), - sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask), - // Empty equip slots show a visible frame (same square as the inventory grid) so every - // slot position is seen + usable; the live 3D character (the doll) is Slice 2. - emptySlotSprite: contentsEmpty, - datFont: vitalsDatFont); // Slice 2: caption the "Slots" toggle button - - // Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory - // frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only - // when the window is open and in doll-view. The renderer itself is built after the - // WbDrawDispatcher exists (further down this method). - _paperdollViewportWidget = invLayout.FindElement(0x100001D5u) as AcDream.App.UI.UiViewport; - _inventoryFrame = inventoryFrame; - - Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023)."); - } - else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found."); - } - // Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is // mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher // always construct. @@ -2519,16 +1833,6 @@ public sealed class GameWindow : IDisposable // A.5 T22.5: apply A2C gate from quality preset. _wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage; - // Slice 2: now that the dispatcher exists, build the paperdoll doll RTT renderer and hand it to - // the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only - // blits the texture; the pre-UI hook drives the 3-D pass (see OnRender). - if (_paperdollViewportWidget is not null && _sceneLightingUbo is not null) - { - _paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer( - _gl, _wbDrawDispatcher, _sceneLightingUbo); - _paperdollViewportWidget.Renderer = _paperdollViewportRenderer; - } - // Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag}) // with the dispatcher — both consume the same global mesh buffer (VAO/IBO) // from ObjectMeshManager.GlobalBuffer. @@ -2544,7 +1848,6 @@ public sealed class GameWindow : IDisposable // T1: invisible portal depth writes (seal/punch) — retail // DrawPortalPolyInternal (Ghidra 0x0059bc90). _portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl); - _fadeOverlay = new AcDream.App.Rendering.FadeOverlay(_gl); } // Phase G.1 sky renderer — its own shader (sky.vert / sky.frag) @@ -2611,7 +1914,6 @@ public sealed class GameWindow : IDisposable state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, - clearPendingLoads: _streamer.ClearPendingLoads, removeTerrain: id => { // Phase G.2: release any LightSources attached to entities @@ -2629,11 +1931,7 @@ public sealed class GameWindow : IDisposable _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); _buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28) _envCellRenderer?.RemoveLandblock(id); // Phase A8 - }, - // #138: restore retained server objects when a landblock reloads - // (dungeon-exit expand or Far→Near promote). ACE won't re-send the - // objects it thinks we still know, so we re-project them ourselves. - onLandblockLoaded: RehydrateServerEntitiesForLandblock); + }); // A.5 T22.5: apply max-completions from resolved quality. _streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame; @@ -2686,7 +1984,6 @@ public sealed class GameWindow : IDisposable _vitalsVm?.SetLocalPlayerGuid(chosen.Id); Chat.SetLocalPlayerGuid(chosen.Id); _worldState.MarkPersistent(chosen.Id); - AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = chosen.Id; // TEMP #138-B probe Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); _liveSession.EnterWorld(user, characterIndex: 0); @@ -2722,10 +2019,6 @@ public sealed class GameWindow : IDisposable private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session) { _liveSession = session; - // D.5.4: ingest CreateObject into the object table (upsert) and wire Delete + - // UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so - // the table is populated before the render handler runs. - AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid); _liveSession.EntitySpawned += OnLiveEntitySpawned; _liveSession.EntityDeleted += OnLiveEntityDeleted; _liveSession.MotionUpdated += OnLiveMotionUpdated; @@ -2780,7 +2073,7 @@ public sealed class GameWindow : IDisposable var skillTable = _dats?.Get(0x0E000004u); AcDream.Core.Net.GameEventWiring.WireAll( - _liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer, + _liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer, TurbineChat, resolveSkillFormulaBonus: (skillId, attrCurrents) => { @@ -2822,9 +2115,7 @@ public sealed class GameWindow : IDisposable _lastSeenRunSkill, _lastSeenJumpSkill); Console.WriteLine($"player: applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}"); } - }, - onShortcuts: list => Shortcuts = list, - playerGuid: () => _playerServerGuid); + }); // Phase I.7: subscribe to CombatState events and emit // retail-faithful "You hit X for Y damage" chat lines into @@ -3001,7 +2292,6 @@ public sealed class GameWindow : IDisposable LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current); _liveSession.VitalCurrentUpdated += v => LocalPlayer.OnVitalCurrent(v.VitalId, v.Current); - } /// @@ -3021,77 +2311,6 @@ public sealed class GameWindow : IDisposable } } - /// - /// #138: re-hydrate retained server objects (doors, NPCs, chests, portals) - /// into a landblock that just (re)loaded. Fired by - /// after - /// AddLandblock / AddEntitiesToExistingLandblock. - /// - /// - /// The dungeon collapse (and Near→Far demote) drops a landblock's render - /// entities for FPS but keeps the parsed spawns in - /// (our weenie_object_table for world objects). ACE never - /// re-broadcasts objects it believes we still know — its per-player - /// KnownObjects set is not cleared on a normal teleport (verified - /// against references/ACE ObjectMaint; a real client keeps its - /// table and re-renders from it, per references/holtburger). So on - /// reload the render side would stay empty. We re-project the objects from - /// our own retained table instead — independent of any server re-send. - /// - /// - /// - /// Idempotent and cheap when nothing is missing: the selection skips guids - /// already present in (initial login, or objects - /// ACE did re-send), the player (owned by the persistent-entity rescue - /// path), and spawns with no world mesh. The replay's own - /// de-dup scrubs the state the - /// collapse orphaned — the entity lingers in - /// after RemoveLandblock even though its render entity is gone. - /// - /// - private void RehydrateServerEntitiesForLandblock(uint loadedLandblockId) - { - if (_lastSpawnByGuid.Count == 0) return; - - // Server guids that already have a live render entity. The gate keys on - // GpuWorldState (the render projection), NOT _entitiesByServerGuid, which - // still holds collapse-orphaned entries whose render entity is gone. - var present = new HashSet(); - foreach (var e in _worldState.Entities) - if (e.ServerGuid != 0) present.Add(e.ServerGuid); - - // Snapshot the retained spawns — the replay mutates _lastSpawnByGuid - // (remove then re-add), so we must not iterate it live. - var retained = new List( - _lastSpawnByGuid.Count); - foreach (var kv in _lastSpawnByGuid) - { - var sp = kv.Value; - bool hasWorldMesh = sp.Position is not null && sp.SetupTableId is not null; - uint spawnLb = sp.Position is { } p ? p.LandblockId : 0u; - retained.Add(new AcDream.App.Streaming.LandblockEntityRehydrator.RetainedSpawn( - kv.Key, spawnLb, hasWorldMesh)); - } - - var guids = AcDream.App.Streaming.LandblockEntityRehydrator.SelectGuidsToRehydrate( - loadedLandblockId, retained, present, _playerServerGuid); - if (guids.Count == 0) return; - - // Replay through the normal live-spawn build under the dat lock (it reads - // Setup/GfxObj/Surface dats). Each replay rebuilds the render entity into - // the now-loaded landblock via AppendLiveEntity's hot path. - lock (_datLock) - { - foreach (var guid in guids) - if (_lastSpawnByGuid.TryGetValue(guid, out var spawn)) - OnLiveEntitySpawnedLocked(spawn); - } - - if (_options.DumpLiveSpawns) - Console.WriteLine( - $"live: re-hydrated {guids.Count} server object(s) into landblock 0x{loadedLandblockId:X8}"); - } - /// /// Door detection by server-sent name. Doors fail the generic /// multi-frame-idle gate at line 2692 (no idle cycle), so we register @@ -3108,18 +2327,6 @@ public sealed class GameWindow : IDisposable { _liveSpawnReceived++; - // L.2g S1 (DEV-6): seed the movement-event staleness gate from the - // CreateObject PhysicsDesc timestamp block, as retail seeds - // update_times at object creation. Seed() adopts wholesale on first - // sight and advance-only afterward, so the #138 rehydrate replay of - // a RETAINED spawn through this handler cannot regress live stamps. - if (!_motionSequenceGates.TryGetValue(spawn.Guid, out var seqGate)) - { - seqGate = new AcDream.Core.Physics.MotionSequenceGate(); - _motionSequenceGates[spawn.Guid] = seqGate; - } - seqGate.Seed(spawn.InstanceSequence, spawn.MovementSequence, spawn.ServerControlSequence); - // De-dup: the server re-sends CreateObject for the same guid in // several situations (visibility refresh, landblock crossing, // appearance update). Without cleanup the OLD copy remains in @@ -3131,7 +2338,7 @@ public sealed class GameWindow : IDisposable // For a respawn, drop the previous rendering state here before we // build the new one. `_entitiesByServerGuid` is the canonical map, // its value is the live WorldEntity we need to dispose. - RemoveLiveEntityByServerGuid(spawn.Guid); + RemoveLiveEntityByServerGuid(spawn.Guid, logDelete: false); // When requested, log every spawn that arrives so we can inventory what the server // sends (including the ones we can't render yet). The Name field @@ -3155,6 +2362,12 @@ public sealed class GameWindow : IDisposable $"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}"); } + _liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo( + spawn.Name, + spawn.ItemType is { } rawItemType + ? (AcDream.Core.Items.ItemType)rawItemType + : AcDream.Core.Items.ItemType.None); + // Target the statue specifically for full diagnostic dump: Name match // is cheap and gives us exactly one entity's worth of log regardless // of arrival order. @@ -3223,64 +2436,6 @@ public sealed class GameWindow : IDisposable // landblock; each neighbor landblock is offset by 192 units per step. int lbX = (int)((p.LandblockId >> 24) & 0xFFu); int lbY = (int)((p.LandblockId >> 16) & 0xFFu); - - // G.3 (#133): recenter streaming onto the player's spawn landblock at - // login. The streaming center (_liveCenterX/_liveCenterY) is pinned to - // the startup default (Holtburg, 0xA9B4) and is otherwise only moved by - // the teleport-arrival path (OnLivePositionUpdated, ~line 4901). A - // character saved INSIDE a far dungeon spawns with that dungeon's - // landblock id, but the center never followed it, so the dungeon (tens - // of km away in world space) never streamed and the #107 auto-entry - // gate's SampleTerrainZ(pe.Position) waited forever — the player hung - // frozen at login. Mirror the teleport-arrival recenter HERE, for the - // PLAYER's spawn only, BEFORE the world-space translation below: when - // the spawn landblock differs from the current center, move the center - // onto it so the spawn maps to (PositionX, PositionY, PositionZ) in the - // new center frame (identical to the teleport path's - // `newWorldPos = new Vector3(p.PositionX, p.PositionY, p.PositionZ)`), - // and the next StreamingController.Tick observes the new center and - // streams the spawn landblock. - // - // No-op for a normal Holtburg login: the saved spawn landblock equals - // the default center, so the guard is false and origin/worldPos are - // byte-identical to the pre-fix path. Gated on the player guid so NPC / - // object spawns never move the center. Idempotent + thrash-free: a - // re-sent CreateObject for the same spawn landblock leaves the center - // already-equal, so the guard is false on every repeat. - if (spawn.Guid == _playerServerGuid - && (lbX != _liveCenterX || lbY != _liveCenterY)) - { - Console.WriteLine( - $"live: login spawn — recentering streaming from ({_liveCenterX},{_liveCenterY}) " + - $"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}"); - _liveCenterX = lbX; - _liveCenterY = lbY; - // The origin jumped — the streaming region is still bootstrapped - // around the stale startup center with residence marked before its - // async loads landed. Flag a full window rebuild (consumed on the - // render thread in OnUpdateFrame) so the region re-bootstraps fresh - // around the spawn, re-loading the landblocks RecenterTo would - // otherwise skip as already-resident — the cold-spawn streaming hole. - _pendingForceReloadWindow = true; - } - - // #135: the instant we know the player spawned into a SEALED dungeon, - // pre-collapse streaming to that single landblock — BEFORE the first - // StreamingController.Tick bootstraps the 25×25 ocean-grid window. The - // player isn't placed yet (physics CurrCell is null), so the per-frame - // insideDungeon gate stays false for the entire hydration window and - // NormalTick would otherwise load ~24 neighbor dungeons then unload them - // (the login FPS ramp the user reported — 10 fps slowly climbing). Sealed- - // dungeon only: a cottage/inn interior (SeenOutside) keeps its outdoor - // surround. We hold _datLock here, and IsSealedDungeonCell re-takes it - // (reentrant); the controller call is render-thread-safe (Channel writes). - if (spawn.Guid == _playerServerGuid - && _streamingController is not null - && IsSealedDungeonCell(p.LandblockId)) - { - _streamingController.PreCollapseToDungeon(lbX, lbY); - } - var origin = new System.Numerics.Vector3( (lbX - _liveCenterX) * 192f, (lbY - _liveCenterY) * 192f, @@ -3691,36 +2846,6 @@ public sealed class GameWindow : IDisposable if (liveBounds.TryGet(out var liveBMin, out var liveBMax)) entity.SetLocalBounds(liveBMin, liveBMax); - // A7 indoor lighting: server-spawned weenie fixtures (lanterns, - // braziers, glowing items) carry their light in Setup.Lights exactly - // like dat-baked statics, but the static registration in - // ApplyLoadedTerrainLocked never sees CreateObject entities — so an - // interior's lanterns cast no light and the room reads dark. Register - // them here, mirroring that path (GameWindow.cs ~6742). Owned by - // entity.Id, so RemoveLiveEntityByServerGuid's UnregisterOwner tears - // them down on despawn/respawn. Retail registers object-borne lights - // regardless of static-vs-dynamic origin (insert_light 0x0054d1b0). - // The light is placed at the spawn frame and does NOT follow a moving - // light-bearer yet (register row AP-44) — fine for stationary fixtures, - // which is the common case. _dats is non-null here (checked above). - if (_lightingSink is not null - && (entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) - { - var liteSetup = _dats.Get(entity.SourceGfxObjOrSetupId); - if (liteSetup is not null && liteSetup.Lights.Count > 0) - { - var loaded = AcDream.Core.Lighting.LightInfoLoader.Load( - liteSetup, - ownerId: entity.Id, - entityPosition: entity.Position, - entityRotation: entity.Rotation, - isDynamic: true, // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5) - cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility - foreach (var ls in loaded) - _lightingSink.RegisterOwnedLight(ls); - } - } - var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( Id: entity.Id, SourceId: entity.SourceGfxObjOrSetupId, @@ -3817,17 +2942,47 @@ public sealed class GameWindow : IDisposable seqMotion = AcDream.Core.Physics.MotionCommand.Ready; } - // R2-Q5: retail's enter-world sequence — initialize_ - // state installs the table default (so the entity is - // NEVER without a cycle: the L.1c "torso on the - // ground" hazard is structurally gone), then the - // wire's initial motion dispatches through the - // verbatim GetObjectSequence (a missing cycle leaves - // the default playing — retail's own miss behavior; - // the Run→Walk→Ready fallback chain is deleted with - // RemoteMotionSink). - sequencer.InitializeState(); - sequencer.SetCycle(seqStyle, seqMotion); + // Phase L.1c followup (2026-04-28): apply the same + // missing-cycle fallback the OnLiveMotionUpdated path + // uses. Without this, a monster spawned in combat + // stance with the wire's seqMotion absent from its + // MotionTable hits ClearCyclicTail() with no + // replacement enqueue, every body part snaps to its + // setup-default offset, and the visual collapses to + // "torso on the ground" — visible to acdream + // observers when another client is in combat with a + // monster, until the first OnLiveMotionUpdated UM + // applies the same fallback there. + uint spawnCycle = seqMotion; + if (!sequencer.HasCycle(seqStyle, spawnCycle)) + { + uint origCycle = spawnCycle; + // RunForward → WalkForward → Ready + if ((spawnCycle & 0xFFu) == 0x07 + && sequencer.HasCycle(seqStyle, 0x45000005u)) + { + spawnCycle = 0x45000005u; + } + else if (sequencer.HasCycle(seqStyle, 0x41000003u)) + { + spawnCycle = 0x41000003u; + } + else + { + spawnCycle = 0; + } + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + { + Console.WriteLine( + $"spawn cycle missing for guid=0x{spawn.Guid:X8} mtable=0x{mtableId:X8} " + + $"style=0x{seqStyle:X8} requested=0x{origCycle:X8} " + + $"→ fallback=0x{spawnCycle:X8}"); + } + } + + if (spawnCycle != 0) + sequencer.SetCycle(seqStyle, spawnCycle); } } } @@ -3900,11 +3055,8 @@ public sealed class GameWindow : IDisposable : NonCombatStyle; uint spawnState = spawn.PhysicsState ?? 0u; uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff; - // R2-Q5: initialize_state guarantees a playing default; - // the On/Off dispatch no-ops harmlessly if the door's - // table lacks the cycle (HasCycle guard deleted). - sequencer.InitializeState(); - sequencer.SetCycle(initialStyle, initialCycle); + if (sequencer.HasCycle(initialStyle, initialCycle)) + sequencer.SetCycle(initialStyle, initialCycle); var template = new (uint, IReadOnlyDictionary?)[meshRefs.Count]; for (int i = 0; i < meshRefs.Count; i++) @@ -3973,12 +3125,7 @@ public sealed class GameWindow : IDisposable private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete) { - // L.2g S1: drop the staleness gate with the entity — a subsequent - // re-create adopts fresh stamps from its CreateObject (retail's - // update_times die with the CPhysicsObj). - _motionSequenceGates.Remove(delete.Guid); - - if (RemoveLiveEntityByServerGuid(delete.Guid) + if (RemoveLiveEntityByServerGuid(delete.Guid, logDelete: true) && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") { Console.WriteLine( @@ -4018,121 +3165,6 @@ public sealed class GameWindow : IDisposable BasePaletteId = md.BasePaletteId, }; OnLiveEntitySpawned(newSpawn); - - // Slice 2: a player appearance change (equip / unequip) rebuilt _entitiesByServerGuid[player] - // above; flag the paperdoll doll to re-clone from it on the next doll pass (the C# analog of - // RedressCreature). Cheap flag — the rebuild is deferred to the pre-UI hook when visible. - if (update.Guid == _playerServerGuid) - _paperdollDollDirty = true; - } - - /// - /// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) + - /// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette / - /// part overrides into a dedicated entity posed at the scene origin - /// facing the viewer. The MeshRefs are COPIED so the doll holds a stable pose independent of the - /// player's live in-world animation. Returns false (leaving the dirty flag set to retry) when the - /// player entity isn't available yet. - /// - private bool RefreshPaperdollDoll() - { - if (_paperdollViewportRenderer is null) return false; - if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) || pe.MeshRefs.Count == 0) - { - _paperdollViewportRenderer.SetDoll(null); - return false; // player not ready — retry next frame - } - - uint? basePal = null; - List<(uint, byte, byte)>? subs = null; - if (pe.PaletteOverride is { } po) - { - basePal = po.BasePaletteId; - subs = new List<(uint, byte, byte)>(); - foreach (var r in po.SubPalettes) subs.Add((r.SubPaletteId, r.Offset, r.Length)); - } - - List<(byte, uint)>? parts = null; - if (pe.PartOverrides.Count > 0) - { - parts = new List<(byte, uint)>(pe.PartOverrides.Count); - foreach (var p in pe.PartOverrides) parts.Add((p.PartIndex, p.GfxObjId)); - } - - var meshRefsCopy = new List(pe.MeshRefs); // dressed parts (player's live frame) - var doll = AcDream.App.Rendering.DollEntityBuilder.Build( - pe.SourceGfxObjOrSetupId, meshRefsCopy, basePal, subs, parts); - ApplyPaperdollPose(doll, pe.SourceGfxObjOrSetupId); // re-pose into the paperdoll "posing" stance - _paperdollViewportRenderer.SetDoll(doll); - return true; - } - - /// - /// Resolve the paperdoll "posing" stance DID (the model pose — left leg back) exactly as retail: - /// gmPaperDollUI ctor (decomp 174243) sets m_didAnimation = DBObj::GetDIDByEnum(0x10000005, 7), - /// which is DBCache::GetDIDFromEnumStatic (decomp 20380) = master[MasterMapId][0x10000005] - /// → submap 0x25000009 → key 7. (Icon effects use keys 1-6 of the same submap; key 7 is the - /// paperdoll pose.) Per-race UpdateForRace override deferred — the ctor default applies to all - /// body-types for now. Returns 0 if the chain can't resolve. - /// - private uint ResolvePaperdollPoseDid() - { - var dats = _dats; - if (dats is null) return 0u; - uint masterDid = (uint)dats.Portal.Header.MasterMapId; - if (masterDid == 0) return 0u; - if (!dats.Portal.TryGet(masterDid, out var master)) return 0u; - // DBCache::GetDIDFromEnum (decomp 20380) resolves master[arg4] → submap, then submap[arg3]. - // GetDIDFromEnumStatic(0x10000005, 7) ⇒ arg3=0x10000005, arg4=7 ⇒ master[7] → submap, submap[0x10000005]. - if (!master.ClientEnumToID.TryGetValue(7u, out var subDid)) return 0u; // master-level key = 7 - if (!dats.Portal.TryGet(subDid, out var sub)) return 0u; - return sub.ClientEnumToID.TryGetValue(0x10000005u, out var did) ? did : 0u; // submap index = 0x10000005 - } - - /// - /// Re-pose the doll's already-dressed parts into the paperdoll "posing" stance — the C# analog of - /// retail set_sequence_animation(doll, m_didAnimation, …). Keeps each cloned part's GfxObjId + - /// surface overrides (the dressed appearance), but replaces its transform with the pose animation's - /// frame-0 per-part transform, using the same Scale·Rotate·Translate composition as the per-frame - /// animation tick (, GameWindow.cs ~9999). Static (the pose is fixed). If the - /// DID does not resolve to an Animation, the doll keeps its cloned pose (no regression) — the log - /// line surfaces what resolved so the pose is verified, not guessed. - /// - private void ApplyPaperdollPose(AcDream.Core.World.WorldEntity doll, uint setupId) - { - var dats = _dats; - if (dats is null) return; - // Retail's paperdoll doll is STATIC — it holds the pose animation's frame, it does NOT loop. - _animatedEntities.Remove(doll.Id); - // poseDID is cdb-CONFIRMED = 0x030003C0 (== retail gmPaperDollUI m_didAnimation for Horan; UpdateForRace - // did not override). Crash guard: only load a 0x03xxxxxx (Animation) DID (a non-animation id parses a - // garbage frame count → OOM). - uint poseDid = ResolvePaperdollPoseDid(); - if ((poseDid >> 24) != 0x03u) return; - var anim = dats.Get(poseDid); - var setup = dats.Get(setupId); - if (anim is null || setup is null || anim.PartFrames.Count == 0) return; - - // Retail plays the pose animation once and HOLDS the last frame (set_sequence_animation framerate=0, - // RedressCreature decomp 0x004a3c22). For 0x030003C0 the animation has only two distinct keyframes — - // frame 0 (a transitional, raised/bent arm) and frames 1..N (all byte-identical: the settled stance, - // arms down + one leg back) — verified by dumping the dat. The held pose is therefore the last frame. - int frameIdx = anim.PartFrames.Count - 1; - var frame = anim.PartFrames[frameIdx]; - var src = doll.MeshRefs; - var reposed = new List(src.Count); - for (int i = 0; i < src.Count; i++) - { - var scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : System.Numerics.Vector3.One; - System.Numerics.Vector3 origin = System.Numerics.Vector3.Zero; - System.Numerics.Quaternion orient = System.Numerics.Quaternion.Identity; - if (i < frame.Frames.Count) { origin = frame.Frames[i].Origin; orient = frame.Frames[i].Orientation; } - var transform = System.Numerics.Matrix4x4.CreateScale(scale) - * System.Numerics.Matrix4x4.CreateFromQuaternion(orient) - * System.Numerics.Matrix4x4.CreateTranslation(origin); - reposed.Add(new AcDream.Core.World.MeshRef(src[i].GfxObjId, transform) { SurfaceOverrides = src[i].SurfaceOverrides }); - } - doll.MeshRefs = reposed; } /// @@ -4169,18 +3201,9 @@ public sealed class GameWindow : IDisposable bool hasSphere = setup.Spheres.Count > 0; bool hasRadius = setup.Radius > 0.0001f; - // NOTE: We intentionally do NOT gate here on `!hasCyl && !hasSphere && !hasRadius`. - // That premature check was wrong: ShadowShapeBuilder.FromSetup also emits a BSP shape - // for any Part whose GfxObj has a non-null PhysicsBSP (e.g. a candle holder or floor - // candelabra with only a BSP collision mesh, no CylSphere/Sphere/Radius). Gating here - // would silently drop those BSP-only entities before the builder runs, making them - // invisible walls or fully passable. The correct final gate is at shapes.Count==0 - // below (after the builder + Radius fallback have run), which correctly handles ALL - // cases: BSP-only -> builder emits BSP shape -> registered; truly shapeless -> builder - // empty, no Radius fallback -> shapes.Count==0 -> return. Retail eligibility is - // "has physics_bsp OR cylsphere OR sphere" per CPhysicsObj::FindObjCollisions - // (acclient_2013_pseudo_c.txt:276917 context: the gate fires on the MOVER, not the - // target; no equivalent target-side gate skips BSP-only objects). + // Retail-faithful phantom skip (acclient_2013_pseudo_c.txt:276917). + if (!hasCyl && !hasSphere && !hasRadius) + return; float entScale = spawn.ObjScale ?? 1.0f; @@ -4194,22 +3217,9 @@ public sealed class GameWindow : IDisposable // span the doorway gap, so the player could walk through. With // this change the door also registers the part-0 BSP slab // (1.9 × 0.26 × 2.5 m) that retail uses for the real block. - // #175 (2026-07-05): BSP part shapes must pose at the motion table's - // DEFAULT-STATE frame (the closed pose — what the sequencer renders - // for an idle entity and what retail's live CPhysicsPart pose is), - // not the Setup's placement frame. The Facility Hub double door - // (Setup 0x02000C9D) places its panels AJAR in the placement frame - // (yaw −150°/−30°, −0.44 m behind the doorway) while rendering - // closed — the user embedded into the visual door on one side and - // hit a phantom slab on the other. Null (no motion table / no - // cycle / part-count mismatch) falls back to placement frames. - var closedPose = MotionTableDefaultPose( - spawn.MotionTableId ?? 0u, setup.Parts.Count); - var raw = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup( setup, entScale, - id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null, - partPoseOverride: closedPose); + id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null); // Substitute the real bounding-sphere radius for BSP shapes — // the pure builder's 2.0 placeholder works for typical doors @@ -4249,8 +3259,7 @@ public sealed class GameWindow : IDisposable CylHeight: (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale)); } - if (shapes.Count == 0) - return; + if (shapes.Count == 0) return; // Decode PvP / Player / Impenetrable from PWD._bitfield. // IsCreature comes from the spawn's ItemType (server-known type). @@ -4298,366 +3307,13 @@ public sealed class GameWindow : IDisposable } } - /// - /// #175: the motion table's default-state pose (the closed pose for - /// doors) — the derivation lives in - /// (Core, - /// dat-conformance-tested; the first cut here used a bare-style cycle - /// key, always missed, and silently no-oped — the "175 is not fixed" - /// report). Returns null → placement-frame fallback. - /// - private IReadOnlyList? MotionTableDefaultPose( - uint motionTableId, int partCount) - { - if (motionTableId == 0u || partCount == 0 || _dats is null) return null; - - var mt = _dats.Get(motionTableId); - if (mt is null) return null; - - var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames( - mt, id => _dats.Get(id)); - - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - { - string desc = pose is null ? "null->placement-fallback" - : System.FormattableString.Invariant( - $"part0=({pose[0].Origin.X:F2},{pose[0].Origin.Y:F2},{pose[0].Origin.Z:F2})"); - Console.WriteLine(System.FormattableString.Invariant( - $"[shape-pose] mt=0x{motionTableId:X8} parts={partCount} {desc}")); - } - - return pose; - } - - /// - /// R3-W4: one-time per-remote wiring of the animation-dispatch stack — - /// the persistent (ObservedOmega turn - /// callbacks), Motion.DefaultSink (so - /// apply_current_movement's interpreted branch dispatches cycles - /// — the retail mechanism behind the deleted K-fix18 forced-Falling), - /// and the RemoveLinkAnimations/InitializeMotionTables - /// seams (retail CPhysicsObj::RemoveLinkAnimations 0x0050fe20 / - /// InitializeMotionTables). Idempotent; safe from both the UM path and - /// the VectorUpdate path regardless of arrival order. - /// - private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings( - RemoteMotion rm, AnimatedEntity ae, uint serverGuid) - { - if (ae.Sequencer is null) - return rm.Sink; - if (rm.Sink is not null) - return rm.Sink; - - var rmForSink = rm; - rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(ae.Sequencer) - { - TurnApplied = (turnMotion, turnSpeed) => - { - float signed = (turnMotion & 0xFFu) == 0x0E - ? -System.MathF.Abs(turnSpeed) - : turnSpeed; - rmForSink.ObservedOmega = new System.Numerics.Vector3( - 0f, 0f, -(System.MathF.PI / 2f) * signed); - }, - TurnStopped = () => - rmForSink.ObservedOmega = System.Numerics.Vector3.Zero, - }; - rm.Motion.DefaultSink = rm.Sink; - // #174 (2026-07-05): the RemoveLinkAnimations seam is retail - // CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to - // CPartArray::HandleEnterWorld 0x00517d70 → - // MotionTableManager::HandleEnterWorld 0x0051bdd0: strip the - // sequence's link animations AND drain pending_animations - // completely (each pop fires MotionDone → CMotionInterp pops its - // pending_motions node in lockstep). The previous binding was the - // bare sequence strip — every LeaveGround (jump) stripped the - // animations that queued manager nodes were counting down on, - // orphaning them (NumAnims>0, anims gone) and permanently damming - // BOTH queues: MotionsPending() never drained again, so every - // armed moveto (ACE's walk-to-door mt-6, the close-range use turn) - // starved — the "door only works on a fresh session" bug. - rm.Motion.RemoveLinkAnimations = () => ae.Sequencer.Manager.HandleEnterWorld(); - rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState(); - // R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions - // 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions). - rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions; - - // R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness - // wiring (the conformance-tested reference). Positions are WORLD - // space on both sides (getPosition + the MovementStruct positions - // the UM router builds) — one consistent space, so the manager's - // distance/heading math matches the harness exactly. - // R5-V5: the construction is now the MovementManager facade's - // MoveToFactory (the acdream stand-in for the physics_obj/weenie_obj - // backpointers retail's MakeMoveToManager 0x00524000 constructs - // from); MakeMoveToManager() below invokes it once, preserving the - // pre-facade eager timing. - var rmT = rm; - var mtBody = rm.Body; - // R5-V3 (#171): real setup cylsphere radii — retail CPartArray:: - // GetRadius/GetHeight (0x005180a0/0x005180b0). Lazy per-call resolve - // (a dictionary hit) so the read never races the spawn path's - // CacheSetup. Feeds GetCurrentDistance's UseSpheres cylinder math - // (own side) and StickyManager::adjust_offset's own-radius gap term. - // Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with - // R5-V3"). - var selfEntity = ae.Entity; - // R5-V2: forward-declared so the MoveToManager's target seams route - // into the entity's TargetManager (retail CPhysicsObj::set_target → - // TargetManager::SetTarget). Assigned right after the manager is built. - EntityPhysicsHost host = null!; - double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - rm.Movement.MoveToFactory = () => - { - var mtm = new AcDream.Core.Physics.Motion.MoveToManager( - rm.Motion, - stopCompletely: () => rmT.Motion.StopCompletely(), - getPosition: () => new AcDream.Core.Physics.Position( - rmT.CellId, mtBody.Position, mtBody.Orientation), - getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading( - mtBody.Orientation), - setHeading: (h, _) => mtBody.Orientation = - AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h), - getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, - getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height, - contact: () => mtBody.OnWalkable, - isInterpolating: () => rmT.Interp.IsActive, - getVelocity: () => mtBody.Velocity, - getSelfId: () => serverGuid, - // R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum - // → the entity's TargetManager (replaces the AP-79 TrackedTarget* - // poll). The manager passes (0, top_level_id, 0.5, 0.0). - setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q), - clearTarget: () => host.ClearTarget(), - getTargetQuantum: () => host.TargetManager.GetTargetQuantum(), - setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q), - // R4-V5: real clock (same epoch-seconds base the per-remote tick uses). - curTime: NowSeconds); - // R5-V3 (#171, retires TS-39): bind the sticky seams to the host's - // PositionManager (host is constructed before MakeMoveToManager - // invokes this factory) — - // * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo - // (retail MoveToManager::BeginNextNode @0x00529d3a); - // * PerformMovement's head unstick: unstick_from_object → - // PositionManager::UnStick (MoveToManager.PerformMovement:414). - mtm.StickTo = (tlid, radius, height) => - host.PositionManager.StickTo(tlid, radius, height); - mtm.Unstick = host.PositionManager.UnStick; - return mtm; - }; - // TS-36 (remote side): the interp's interrupt seam is retail's - // interrupt_current_movement → MovementManager::CancelMoveTo(0x36) - // chain (V2's reentrancy tests prove the wiring is inert-safe; - // R5-V5: the chain now lands on the literal facade relay 0x005241b0). - rm.Motion.InterruptCurrentMovement = - () => rmT.Movement.CancelMoveTo( - AcDream.Core.Physics.WeenieError.ActionCancelled); - - // R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position - // is the entity's WORLD position (WorldEntity.Position — exactly the - // source the AP-79 poll used for a tracked target, so the voyeur system - // delivers the identical position for a moveto's quantum-0 case). - // HandleTargetting ticks per frame (added in the per-remote loop); - // HandleUpdateTarget fans deliveries to this entity's MoveToManager - // and (R5-V3, inside the host) its PositionManager — retail's - // CPhysicsObj::HandleUpdateTarget order. - host = new EntityPhysicsHost( - serverGuid, - getPosition: () => new AcDream.Core.Physics.Position( - rmT.CellId, - _entitiesByServerGuid.TryGetValue(serverGuid, out var selfEnt) - ? selfEnt.Position : mtBody.Position, - mtBody.Orientation), - getVelocity: () => mtBody.Velocity, - getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius, - inContact: () => mtBody.OnWalkable, - minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(), - curTime: NowSeconds, - physicsTimerTime: NowSeconds, - getObjectA: ResolvePhysicsHost, - // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head: - // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade - // relay to the moveto side); the host chains the PositionManager - // leg after it. - handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info), - interruptCurrentMovement: () => rmT.Movement.CancelMoveTo( - AcDream.Core.Physics.WeenieError.ActionCancelled)); - rm.Host = host; - _physicsHosts[serverGuid] = host; - - // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the - // MoveToManager via the factory above (host now exists for the - // sticky binds inside it). The UM funnel head's unstick - // (CPhysicsObj::unstick_from_object 0x0050eaea, invoked at the mt-0 - // routing sites) binds on the interp beside it. - rm.Movement.MakeMoveToManager(); - rm.Motion.UnstickFromObject = host.PositionManager.UnStick; - return rm.Sink; - } - - /// - /// R5-V2 — retail CObjectMaint::GetObjectA(id): resolve any entity's - /// by guid, the - /// cross-entity seam every host's GetObjectA uses. If the entity has - /// a bound host (from or - /// EnterPlayerModeNow) return it; otherwise, for any entity that - /// EXISTS in the world, lazily create a minimal position-only host. Retail - /// gives every CPhysicsObj the capacity to host a - /// target_manager — a STATIC object (chest, corpse) must still answer - /// add_voyeur so a mover can moveto/stick to it (without this, - /// auto-walk to a never-animated object would arm the moveto but never - /// receive the immediate target snapshot, and never start). The minimal - /// host is registered so subsequent lookups reuse it; it is NOT ticked - /// (a static object never drifts, so the AddVoyeur immediate snapshot is - /// all a watcher needs; despawn still fires ExitWorld via the registry). - /// - private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id) - { - if (_physicsHosts.TryGetValue(id, out var existing)) - return existing; - if (!_entitiesByServerGuid.ContainsKey(id)) - return null; - - double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - var minimal = new EntityPhysicsHost( - id, - getPosition: () => new AcDream.Core.Physics.Position( - 0u, - _entitiesByServerGuid.TryGetValue(id, out var e) - ? e.Position : System.Numerics.Vector3.Zero, - System.Numerics.Quaternion.Identity), - getVelocity: () => System.Numerics.Vector3.Zero, // static target - getRadius: () => 0f, - inContact: () => true, - minterpMaxSpeed: () => null, - curTime: NowSeconds, - physicsTimerTime: NowSeconds, - getObjectA: ResolvePhysicsHost, - handleUpdateTarget: _ => { }, // not a watcher - interruptCurrentMovement: () => { }); - _physicsHosts[id] = minimal; - return minimal; - } - - /// - /// R5-V3 (#171): retail CPartArray::GetRadius/GetHeight - /// (0x005180a0/0x005180b0 — setup->radius/height × scale; ACE - /// PartArray.cs:189-207 reads the same Setup-level fields). Returns - /// (0, 0) when the entity has no resolvable Setup (bare-GfxObj props) — - /// ACE's PartArray != null ? … : 0 fallback shape - /// (PhysicsObj.cs:951-952). Live spawns cache their Setup in - /// _physicsDataCache at spawn time (CacheSetup, ~3250), so this is - /// a dictionary hit for every creature. - /// - private (float Radius, float Height) GetSetupCylinder( - uint serverGuid, AcDream.Core.World.WorldEntity entity) - { - var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); - if (setup is null) - return (0f, 0f); - // Live spawns bake ObjScale into MeshRefs and never populate - // WorldEntity.Scale (a scenery-pipeline field, default 1.0) — the - // authoritative per-entity scale is the SPAWN RECORD's ObjScale, the - // same source the collision registration's entScale uses (~4137). - // entity.Scale remains the fallback for non-spawn entities (scenery — - // never a chase/sticky participant). Diff-review find: without this, - // a server-scaled creature variant read unscaled radii and kept the - // #171 interpenetration exactly for scaled bodies. - float scale = - _lastSpawnByGuid.TryGetValue(serverGuid, out var sp) - && sp.ObjScale is { } objScale && objScale > 0f - ? objScale - : (entity.Scale > 0f ? entity.Scale : 1f); - return (setup.Radius * scale, setup.Height * scale); - } - - /// - /// R5-V3 (#171): apply a - /// written by PositionManager.AdjustOffset onto a body — acdream's - /// stand-in for retail's Frame::combine in - /// CPhysicsObj::UpdatePositionInternal (0x00512c30, combine - /// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes - /// globaltolocalvec output — 0x00555430), so combining = rotating it - /// out by the body orientation. The rotation carries a RELATIVE heading; - /// an untouched (identity) rotation means "no turn" — retail distinguishes - /// an unwritten offset rotation from set_heading(0) by the identity - /// VALUE, not the angle, and the P5 pin (identity quaternion = heading 0) - /// makes compass addition the exact frame-combine here. - /// - private static void ApplyPositionManagerDelta( - AcDream.Core.Physics.PhysicsBody body, - AcDream.Core.Physics.Motion.MotionDeltaFrame delta) - { - if (delta.Origin != System.Numerics.Vector3.Zero) - body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation); - if (!delta.Orientation.IsIdentity) - body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading( - body.Orientation, - AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation) - + delta.GetHeading()); - } - - /// - /// R5-V4: retail CPhysicsObj::stick_to_object (0x005127e0) — the - /// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the - /// motionFlags 0x1 trailer guid): resolve the target, read its PartArray - /// radius/height (0 when shapeless — retail's null-PartArray arm), then - /// PositionManager::StickTo. Unresolvable target → no stick at - /// all (retail's GetObjectA-null path). Retail resolves the target's - /// top-level PARENT first (parent ?? self); acdream's entity - /// table is flat (no client-side parenting), so the guid is used as-is — - /// the same convention as RouteServerMoveTo's TopLevelId. - /// - private void StickToObjectFromWire(EntityPhysicsHost? host, uint targetGuid) - { - if (host is null) - return; - if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var tgtEnt)) - return; - var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt); - host.PositionManager.StickTo(targetGuid, radius, height); - } - - private bool RemoveLiveEntityByServerGuid(uint serverGuid) + private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete) { if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity)) return false; _worldState.RemoveEntityByServerGuid(serverGuid); _worldGameState.RemoveById(existingEntity.Id); - // R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains - // independently (r3-port-plan §4): the manager's (each pending - // animation fires MotionDone(success:false) → the bound interp pops - // in step) THEN the interp's own (flushes any remainder — - // MovementManager::HandleExitWorld 0x00524350, the R5-V5 facade - // relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30). - if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone)) - aeGone.Sequencer?.Manager.HandleExitWorld(); - if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone)) - rmGone.Movement.HandleExitWorld(); - // R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this - // entity that it left the world (TargetManager::NotifyVoyeurOfEvent - // ExitWorld) — a watcher moving-to/stuck-to this entity drops the - // moveto/stick. This is the ONLY clean-up for a watcher whose target - // already sent an Ok (once status != Undefined the 10 s staleness - // timeout never fires), so it must run before the host is pruned. - if (_physicsHosts.TryGetValue(serverGuid, out var hostGone) - && hostGone is EntityPhysicsHost ephGone) - { - // R5-V3 (#171): retail exit_world (0x00514e60) order — the - // departing entity first tears down its OWN stick - // (PositionManager::UnStick @0x00514e88 — drops its voyeur - // subscription on whatever it was stuck to) and its own target - // subscription (TargetManager::ClearTarget @0x00514e97 — same - // for a plain mid-chase moveto), THEN NotifyVoyeurOfEvent - // (ExitWorld) tells the entities watching IT. Without the first - // two, a despawning attacker leaves a dead voyeur entry on its - // target until send-failure pruning. - ephGone.PositionManager.UnStick(); - ephGone.ClearTarget(); - ephGone.NotifyExitWorld(); - } - _physicsHosts.Remove(serverGuid); _animatedEntities.Remove(existingEntity.Id); _classificationCache.InvalidateEntity(existingEntity.Id); _physicsEngine.ShadowObjects.Deregister(existingEntity.Id); @@ -4666,161 +3322,18 @@ public sealed class GameWindow : IDisposable // clear using the same guid the next spawn/update would use. _remoteDeadReckon.Remove(serverGuid); _remoteLastMove.Remove(serverGuid); + _liveEntityInfoByGuid.Remove(serverGuid); _entitiesByServerGuid.Remove(serverGuid); _lastSpawnByGuid.Remove(serverGuid); if (_selectedGuid == serverGuid) - SelectedGuid = null; + _selectedGuid = null; - // A7 indoor lighting: release this entity's owned lights on EVERY - // removal, including the respawn-dedup path (former logDelete=false). - // A respawned weenie fixture would otherwise leak its old light set and - // double-register the new one. (Was gated on logDelete — harmless only - // while live weenies registered no lights, which is no longer true.) - _lightingSink?.UnregisterOwner(existingEntity.Id); + if (logDelete) + _lightingSink?.UnregisterOwner(existingEntity.Id); return true; } - /// - /// R4-V5: shared retail unpack_movement type-6..9 routing - /// (0x00524440 cases 6/7/8/9, decomp §2f) — one body for remotes - /// (R4-V4) and the local player (R4-V5). Writes the wire run rate to - /// the interp (my_run_rate, unpack @300603/@300660 — plan M13), - /// builds the MovementStruct (mt 6/8 resolve the target guid - /// against the entity table; unresolvable degrades to - /// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f — - /// NOT an error), and calls the R5-V5 facade's PerformMovement - /// (MovementManager::PerformMovement 0x005240d0 — types 6-9 - /// MakeMoveToManager + delegate; retail's cases call MakeMoveToManager - /// at each head, a no-op here since the bind sites create eagerly). - /// Returns true when the event was a type-6..9 moveto (consumed); false - /// for every other movement type (caller falls through to its funnel / - /// skip posture). - /// - private bool RouteServerMoveTo( - AcDream.Core.Physics.Motion.MovementManager movement, - uint cellId, - AcDream.Core.Net.WorldSession.EntityMotionUpdate update) - { - if (update.MotionState.IsServerControlledMoveTo - && update.MotionState.MoveToPath is { } path) - { - // my_run_rate write (unpack_movement @300603). - if (update.MotionState.MoveToRunRate is { } mtRunRate) - movement.Minterp.MyRunRate = mtRunRate; - - var destWorld = AcDream.Core.Physics.Motion.MoveToMath - .OriginToWorld( - path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ, - _liveCenterX, _liveCenterY); - var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire( - path.Bitfield, - path.DistanceToObject, - path.MinDistance, - path.FailDistance, - update.MotionState.MoveToSpeed ?? 1f, - path.WalkRunThreshold, - path.DesiredHeading); - - var ms = new AcDream.Core.Physics.MovementStruct - { - Params = mp, - }; - // mt 6 with a resolvable target → MoveToObject (the P4 tracker - // feeds position updates per tick); else degrade to - // MoveToPosition at the wire origin (§2f). - if (update.MotionState.MovementType == 6 - && path.TargetGuid is { } tgtGuid - && _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt)) - { - ms.Type = AcDream.Core.Physics.MovementType.MoveToObject; - ms.ObjectId = tgtGuid; - ms.TopLevelId = tgtGuid; - // R5-V3 (#171): retail resolves the TARGET object's PartArray - // radius/height at the MoveToObject call site - // (CPhysicsObj::MoveToObject 0x005128e9/0x00512903; ACE - // PhysicsObj.cs:951-952) — they become SoughtObjectRadius/ - // Height, feeding GetCurrentDistance's edge-to-edge arrival - // (UseSpheres) and the sticky-arrival handoff's target radius. - // Was unset (0): every attacker closed ~one body-radius deeper - // than retail before stopping — the #171 dogpile term. - (ms.Radius, ms.Height) = GetSetupCylinder(tgtGuid, tgtEnt); - ms.Pos = new AcDream.Core.Physics.Position( - cellId, tgtEnt.Position, - System.Numerics.Quaternion.Identity); - } - else - { - ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition; - ms.Pos = new AcDream.Core.Physics.Position( - cellId, destWorld, - System.Numerics.Quaternion.Identity); - } - movement.PerformMovement(ms); - return true; - } - - if (update.MotionState.IsServerControlledTurnTo - && update.MotionState.TurnToPath is { } turnPath) - { - var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo( - turnPath.Bitfield, - turnPath.Speed, - turnPath.DesiredHeading); - - var ms = new AcDream.Core.Physics.MovementStruct { Params = mp }; - if (update.MotionState.MovementType == 8 - && turnPath.TargetGuid is { } turnTgt - && _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt)) - { - ms.Type = AcDream.Core.Physics.MovementType.TurnToObject; - ms.ObjectId = turnTgt; - ms.TopLevelId = turnTgt; - ms.Pos = new AcDream.Core.Physics.Position( - cellId, turnEnt.Position, - System.Numerics.Quaternion.Identity); - } - else - { - ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading; - // Retail's mt-8 unresolvable-object fallback substitutes the - // STANDALONE wire heading into the params before degrading - // (decomp §2f case 8: `params.desired_heading = wire_heading`). - // Invisible against ACE (P6: both heading fields written from - // the same source) but required for the verbatim degrade — - // closes the V4 carry-over the adversarial review caught - // (TurnToPathData.WireHeading was parsed but never consumed). - if (update.MotionState.MovementType == 8 - && turnPath.WireHeading is { } wireHeading) - { - mp.DesiredHeading = wireHeading; - } - } - movement.PerformMovement(ms); - return true; - } - - return false; - } - - /// - /// R4-V5 / R5-V2: the per-tick - /// drive (retail MovementManager::UseTime 0x005242f0 — the moveto - /// side's steering, arrival, fail-distance; R5-V5 facade relay). The P4 - /// TargetTracker POLL is gone (R5-V2): target-position delivery now flows - /// through the - /// voyeur system — the target's own HandleTargetting pushes updates - /// into this entity's HandleUpdateTarget, which is called - /// unconditionally per remote in the per-tick loop BEFORE this drive - /// (retail UpdateObjectInternal order). Safe for any remote: a - /// manager with no armed moveto no-ops, airborne bodies held by - /// UseTime's contact gate. - /// - private void TickRemoteMoveTo(RemoteMotion rm) - { - rm.Movement.UseTime(); - } - /// /// Phase 6.6: the server says an entity's motion has changed. Look up /// the AnimatedEntity for that guid, re-resolve the idle cycle with the @@ -4835,53 +3348,18 @@ public sealed class GameWindow : IDisposable if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return; - // L.2g S1 (DEV-6): retail staleness gate — BEFORE any state mutation. - // Retail drops stale/duplicate/superseded movement events at - // DispatchSmartBoxEvent (INSTANCE_TS, pseudo-C:357214) + - // CPhysics::SetObjectMovement (MOVEMENT_TS strictly-newer + - // SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered - // straggler re-applies an old gait or un-stops a stop. - if (!_motionSequenceGates.TryGetValue(update.Guid, out var seqGate)) + // #39 (2026-05-06): stamp the per-remote LastUMTime so the + // UP-velocity fallback path in ApplyServerControlledVelocityCycle + // can skip refinement while a UM is fresh. UMs are authoritative + // for direction-key changes (W press / release / W↔S flip); + // velocity refinement only helps for HoldKey-only changes (Shift + // toggle while a direction key is held — retail does NOT broadcast + // a fresh MoveToState in that case). + if (_remoteDeadReckon.TryGetValue(update.Guid, out var rmStateForUm)) { - // UM for an entity whose CreateObject we never parsed (rare — - // the entity lookup above implies a spawn). Adopt-on-first-seed - // keeps the gate correct from this event onward. - seqGate = new AcDream.Core.Physics.MotionSequenceGate(); - _motionSequenceGates[update.Guid] = seqGate; - seqGate.Seed(update.InstanceSequence, update.MovementSequence, update.ServerControlSequence); + rmStateForUm.LastUMTime = + (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; } - else if (!seqGate.TryAcceptMovementEvent( - update.InstanceSequence, update.MovementSequence, update.ServerControlSequence)) - { - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1" - || Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") - { - Console.WriteLine( - $"[UM_STALE] guid={update.Guid:X8} inst={update.InstanceSequence} " - + $"mov={update.MovementSequence} sc={update.ServerControlSequence} dropped"); - } - return; - } - - // R4-V5 (pin P1): retail CPhysics::SetObjectMovement's autonomous - // gate (0x00509690 @0050972e, raw 271370-271431) — a movement event - // whose wire autonomous byte is set is DROPPED ENTIRELY (no state - // application, no interrupt) when the addressed object IsThePlayer. - // ACE reflects the client's own outbound MoveToState back to the - // sender with IsAutonomous=1 hardcoded (MovementData.cs:162 + - // Player_Networking.cs:365) and retail never lets that echo reach - // unpack_movement — which is what makes the unconditional - // unpack-head interrupt in the player branch below safe against - // ACE. Order matches retail: the sequence gates above run FIRST. - // last_move_was_autonomous is NOT stored for dropped events (stored - // only on the unpack path). This retires the row-less "don't cancel - // on non-MoveTo UM" adaptation that lived here pre-V5 (its causal - // story was stale — V0-pins.md P1). Run-rate sync is re-anchored to - // retail's own feeds: PlayerDescription skills (SetCharacterSkills, - // K-fix7) + the mt-6/7 my_run_rate wire write below (M13) — the - // former ApplyServerRunRate echo tap is deleted, not gated. - if (update.Guid == _playerServerGuid && update.IsAutonomous) - return; // Re-resolve using the new stance/command. Keep the setup and // motion-table we already know about — the server's motion @@ -4936,12 +3414,23 @@ public sealed class GameWindow : IDisposable // Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled - && IsDoorName(LiveName(update.Guid))) + && _liveEntityInfoByGuid.TryGetValue(update.Guid, out var doorInfo) + && IsDoorName(doorInfo.Name)) { Console.WriteLine(System.FormattableString.Invariant( $"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}")); } + // Wire server-echoed RunRate first — used for the player's own + // locomotion tuning regardless of whether a cycle resolves. + if (_playerController is not null + && update.Guid == _playerServerGuid + && update.MotionState.ForwardSpeed.HasValue + && update.MotionState.ForwardSpeed.Value > 0f) + { + _playerController.ApplyServerRunRate(update.MotionState.ForwardSpeed.Value); + } + // ── Sequencer path (preferred) ────────────────────────────────── // Call SetCycle directly. The sequencer already handles: // - left→right / backward→forward remapping via adjust_motion @@ -4971,13 +3460,24 @@ public sealed class GameWindow : IDisposable // otherwise → resolve class byte and use full cmd float speedMod = update.MotionState.ForwardSpeed ?? 1f; uint fullMotion; - // R4-V4: the PlanMoveToStart seed is DELETED — MoveTo UMs no - // longer flow through the interpreted funnel at all (retail - // unpack_movement routes types 6-9 to MoveToManager; only type - // 0 does the interpreted-state copy). The manager's own - // BeginMoveForward -> get_command -> _DoMotion produces the - // cycle through the same sink every other motion uses. - if (!command.HasValue || command.Value == 0) + if ((!command.HasValue || command.Value == 0) + && update.MotionState.IsServerControlledMoveTo) + { + // Retail MoveToManager::BeginMoveForward calls + // MovementParameters::get_command (0x0052AA00), then + // _DoMotion -> adjust_motion. With CanRun and enough + // distance, WalkForward + HoldKey_Run becomes RunForward, + // and CMotionInterp::apply_run_to_command (0x00527BE0) + // multiplies speed by the packet's runRate. + var seed = AcDream.Core.Physics.ServerControlledLocomotion + .PlanMoveToStart( + update.MotionState.MoveToSpeed ?? 1f, + update.MotionState.MoveToRunRate ?? 1f, + update.MotionState.MoveToCanRun); + fullMotion = seed.Motion; + speedMod = seed.SpeedMod; + } + else if (!command.HasValue || command.Value == 0) { fullMotion = 0x41000003u; } @@ -5036,8 +3536,9 @@ public sealed class GameWindow : IDisposable // the paths above, but don't stomp the animation sequencer. // B.6 slice 1 (2026-05-14): trace inbound motion for the - // local player. One line per inbound UM, gated on - // ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5). + // local player so we can characterize what ACE sends during + // a server-initiated auto-walk. One line per inbound UM, + // gated on ACDREAM_PROBE_AUTOWALK=1. if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) { string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null"; @@ -5057,157 +3558,133 @@ public sealed class GameWindow : IDisposable $"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}")); } - // R4-V5: retail unpack_movement dispatch for the local - // player — the SAME shape the remote branch uses below. - // Head (@300566): interrupt + unstick fire for EVERY - // movement event that reached unpack (the P1 gate above - // already dropped the autonomous echoes that would have - // made this unsafe against ACE); then types 6-9 route to - // the player's MoveToManager. mt-0 falls through to the - // existing skip-sequencer posture (the interpreted-state - // copy + LoseControlToServer autonomy handoff is - // R5/MovementManager scope — V0-pins.md P1 adjacent seam). + // B.6 slice 2 (2026-05-14): drive the local player's body + // through a server-initiated auto-walk when ACE sends + // MoveToObject (movement type 6) — retail-faithful per + // MovementManager::PerformMovement 0x00524440 case 6. When + // the inbound motion is NOT a MoveTo, cancel any active + // auto-walk (server intent changed). if (_playerController is not null) { - // P1 tail (00509730): the unpack path stores the wire - // autonomous byte BEFORE unpack_movement — always false - // here (the gate above dropped autonomous events). This - // is what routes the controller's per-tick pump (A3 - // dual dispatch) to the INTERPRETED branch during a - // server moveto. LOCAL PLAYER ONLY for now: remotes' - // interps have no WeenieObj, which A3 treats as - // IsThePlayer — storing a remote player's autonomous - // byte would flip their per-tick apply onto the raw - // branch and clobber their funnel state; the remote - // store lands with real remote weenies (R5+). - _playerController.SetLastMoveWasAutonomous(update.IsAutonomous); - - _playerController.Motion.InterruptCurrentMovement?.Invoke(); - _playerController.Motion.UnstickFromObject?.Invoke(); - - // R5-V4a: unpack_movement HEAD style-on-change - // (0x00524440 @00524502-0052452c): wire style index → - // command word (command_ids[]; style-class indices map to - // 0x80000000|index — the funnel's S0-verified conversion) - // and `if (current style != wire style) DoMotion(style, - // ctor defaults)` BEFORE the movement-type switch — for - // EVERY type. acdream previously applied style only via - // the mt-0 funnel copy, so a chase/turn UM (mt 6-9) - // carrying a stance change started the move in the OLD - // stance (the RetailObserverTraceConformanceTests "S3 - // wires the unpack-level style-on-change" exclusion — - // this is that wiring). - uint wireStylePlayer = stance != 0 - ? (0x80000000u | (uint)stance) : 0x8000003Du; - if (_playerController.Motion.InterpretedState.CurrentStyle != wireStylePlayer) - _playerController.Motion.DoMotion(wireStylePlayer, - new AcDream.Core.Physics.Motion.MovementParameters()); - - if (_playerController.MoveTo is not null - && RouteServerMoveTo(_playerController.Movement, - _playerController.CellId, update)) + if (update.MotionState.IsServerControlledMoveTo + && update.MotionState.MoveToPath is { } pathData) { + // Translate landblock-local origin → world space. + var destWorld = AcDream.Core.Physics.RemoteMoveToDriver + .OriginToWorld( + pathData.OriginCellId, + pathData.OriginX, + pathData.OriginY, + pathData.OriginZ, + _liveCenterX, + _liveCenterY); + bool canCharge = update.MotionState.CanCharge; + _playerController.BeginServerAutoWalk( + destWorld, + pathData.MinDistance, + pathData.DistanceToObject, + update.MotionState.MoveTowards, + canCharge); if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) { Console.WriteLine(System.FormattableString.Invariant( - $"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo.MovementTypeState}")); + $"[autowalk-begin] dest=({destWorld.X:F2},{destWorld.Y:F2},{destWorld.Z:F2}) minDist={pathData.MinDistance:F2} objDist={pathData.DistanceToObject:F2} canCharge={canCharge} towards={update.MotionState.MoveTowards}")); } - return; - } - - // R5-V4: retail unpack_movement case-0 TAIL for the local - // player (@00524583-0052458e) — stick_to_object when the - // motionFlags 0x1 trailer carried a guid, then - // standing_longjump ← motionFlags 0x2 (UNCONDITIONAL — - // an absent flag clears it). The interpreted-state COPY - // stays skipped for the local player (the - // sequencer-authority posture above); these two writes - // are the only case-0 effects that apply here. - if (update.MotionState.MovementType == 0) - { - if (update.MotionState.StickyObjectGuid is { } playerSticky - && playerSticky != 0) - StickToObjectFromWire(_playerHost, playerSticky); - _playerController.Motion.StandingLongJump = - update.MotionState.StandingLongJump; } + // Note: do NOT cancel auto-walk on a non-MoveTo motion + // arriving. The trace (2026-05-14, launch-slice2.log) + // shows ACE follows every mt=0x06 MoveToObject + // immediately with an mt=0x00 InterpretedMotionState + // (cmd=0x0007 RunForward, fwdSpd=2.86) — the + // companion locomotion echo, NOT a cancel. The two + // travel as separate packets but both belong to the + // same auto-walk. Cancelling on the InterpretedMotionState + // killed the auto-walk on frame 1. Arrival detection + // (inside ApplyAutoWalkOverlay) and user-input + // cancellation (same) are the two natural end paths; + // a fresh MoveToObject re-targets via BeginServerAutoWalk + // overwrite. } } else { - // ── L.2g S2b (2026-07-02): remote entities flow through the - // verbatim CMotionInterp funnel. The wire state becomes an - // InboundInterpretedState (retail UnPack defaults for absent - // fields — a flags=0 UM is a wholesale stop, S0-verified), - // MotionInterpreter.MoveToInterpretedState applies it with - // retail's exact dispatch order + the 15-bit action-stamp - // gate (conformance: RetailObserverTraceConformanceTests, - // 183/183 vs the live retail-observer cdb trace), and the - // gate-passed dispatches go straight into the motion-table - // stack via MotionTableDispatchSink -> PerformMovement - // (R2-Q5; GetObjectSequence + is_allowed decide — no sink- - // side pick; airborne handling is the funnel's - // contact_allows_move gate, the retail mechanism behind the - // old K-fix17 guard). + var forwardRoute = AcDream.Core.Physics.AnimationCommandRouter.Classify(fullMotion); + bool forwardIsOverlay = forwardRoute is AcDream.Core.Physics.AnimationCommandRouteKind.Action + or AcDream.Core.Physics.AnimationCommandRouteKind.Modifier + or AcDream.Core.Physics.AnimationCommandRouteKind.ChatEmote; + bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck) + && rmCheck.Airborne; + + // Retail MotionTable::GetObjectSequence routes action-class + // ForwardCommand values (creature attacks, chat-emotes) through + // the Action branch, where the swing is appended before the + // current cyclic tail and currState.Substate remains Ready. + // Treating 0x10000051/52/53 as SetCycle commands made the + // immediate follow-up Ready packet abort the swing. + // Phase L.1c followup (2026-04-28): the next two state-update + // blocks are LIFTED out of the substate-only `else` branch so + // they run for BOTH overlay (Action/Modifier/ChatEmote) and + // substate (Walk/Run/Ready/etc) packets. Two separate research + // agents converged on the same root cause for the user- + // observed "creature just runs instead of attacking" symptom: // - // R4-V4: retail unpack_movement dispatch (0x00524440) — the - // head-interrupt fires for EVERY movement type, then types - // 6/7/8/9 route to MoveToManager.PerformMovement (which - // cancels again itself — retail does both) while ONLY type 0 - // flows through the interpreted-state funnel below. + // 1. Attack swings arrive as mt=0 with + // ForwardCommand=AttackHigh1 (Action class). Retail's + // CMotionInterp::move_to_interpreted_state + // (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies + // forward_command from the wire into the body's + // InterpretedState UNCONDITIONALLY. With + // forward_command=AttackHigh1, get_state_velocity + // returns 0 because its gate is RunForward||WalkForward + // — body stops moving forward. // - // R4-V5 door fix (2026-07-03 user report: doors stopped - // animating): entities that never receive an UpdatePosition - // (doors, levers — static animated objects) never got a - // RemoteMotion (created only in the UP handler), so since - // the L.2g S2b funnel cutover NOTHING applied their UM - // forward-command (the old direct SetCycle became the - // rm-only funnel) — a used door flipped ETHEREAL but never - // played its On/Off cycle. Retail runs the SAME unpack → - // CMotionInterp pipeline for every entity class; create the - // rm on first UM exactly like the UP handler does. - if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) + // 2. The acdream overlay branch was routing through + // PlayAction (animation overlay) but skipping ALL of: + // - ServerMoveToActive flag update + // - MoveToPath capture + // - InterpretedState.ForwardCommand assignment + // So during a swing UM, the body's InterpretedState + // stayed at RunForward from the prior MoveTo packet, + // ServerMoveToActive stayed true, and the per-tick + // remote driver kept steering + applying RunForward + // velocity through every frame. + // + // Note: we bypass DoInterpretedMotion / ApplyMotionToInterpretedState + // here because the latter is a heuristic that ONLY handles + // WalkForward / RunForward / WalkBackward / SideStep / Turn + // / Ready (MotionInterpreter.cs:941-970). For an Action + // command (e.g. AttackHigh1 = 0x10000062) the switch falls + // through and InterpretedState is silently NOT updated — + // exactly the bug we are fixing. Direct field assignment + // matches retail's copy_movement_from bulk-copy + // (acclient_2013_pseudo_c.txt:293301-293311). + if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) { - remoteMot = new RemoteMotion(); - remoteMot.Body.Orientation = entity.Rotation; - remoteMot.Body.Position = entity.Position; - _remoteDeadReckon[update.Guid] = remoteMot; - } - { - var sink = EnsureRemoteMotionBindings(remoteMot, ae, update.Guid); + remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; - // unpack_movement head (@300566): interrupt + unstick. - remoteMot.Motion.InterruptCurrentMovement?.Invoke(); - remoteMot.Motion.UnstickFromObject?.Invoke(); - - // R5-V4a: unpack_movement head style-on-change — see the - // player-side comment (@00524502-0052452c). Runs BEFORE - // the type routing for every movement type; the mt-0 - // funnel below still performs its own full style - // adoption (retail has BOTH — the head fires on CHANGE - // only, so an unchanged stance is a no-op here). - uint wireStyleRemote = stance != 0 - ? (0x80000000u | (uint)stance) : 0x8000003Du; - if (remoteMot.Motion.InterpretedState.CurrentStyle != wireStyleRemote) - remoteMot.Motion.DoMotion(wireStyleRemote, - new AcDream.Core.Physics.Motion.MovementParameters()); - - // R4-V5: the type-6..9 routing body is shared with the - // local player (RouteServerMoveTo) — behavior identical - // to the R4-V4 inline blocks it was extracted from. - // (MoveTo null = EnsureRemoteMotionBindings early-outed - // on a sequencer-less entity — same skip as pre-V5.) - if (remoteMot.MoveTo is not null - && RouteServerMoveTo(remoteMot.Movement, - remoteMot.CellId, update)) - { - return; - } - - // [FWD_WIRE] + observed-velocity history invalidation on - // a forward-command change (pre-S2 behavior, unchanged: - // the per-tick scaling must not reuse a stale ratio - // derived from the OLD motion). + // Bulk-copy the wire's resolved ForwardCommand + speed + // into InterpretedState UNCONDITIONALLY (overlay, + // substate, AND MoveTo packets). Matches retail's + // copy_movement_from semantics + // (acclient_2013_pseudo_c.txt:293301-293311) which does + // not filter by MovementType. + // + // For MoveTo packets, fullMotion is the RunForward seed + // from PlanMoveToStart, so this populates + // ForwardCommand=RunForward + ForwardSpeed=speed*runRate + // — what the OLD substate-only DoInterpretedMotion call + // (commit f794832 removed) used to set. Without it, + // apply_current_movement reads the default + // ForwardCommand=Ready and produces zero velocity, so + // chasing creatures only translate via UpdatePosition + // hard-snaps and at spawn appear posed at default + // (visible as "torso on the ground" until the first UP + // snap hits). + // + // For overlay (Action) packets this sets ForwardCommand + // to the Attack/Twitch/etc command, and + // get_state_velocity returns 0 because the gate is + // RunForward||WalkForward — body stops moving forward. if (remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion) { if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") @@ -5218,86 +3695,285 @@ public sealed class GameWindow : IDisposable + $"newCmd=0x{fullMotion:X8} " + $"newLow=0x{fullMotion & 0xFFu:X2} speed={speedMod:F3}"); } + // Motion command changed — invalidate observed-velocity + // history so the per-tick scaling in TickAnimations + // doesn't reuse a stale ratio derived from the OLD + // motion (e.g. carrying run-pace serverSpeed into the + // first walk frame, which would briefly accelerate + // walk to run pace before settling). remoteMot.PrevServerPosTime = 0.0; } + remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion; + // Pass speedMod through verbatim — preserve sign so retail's + // adjust_motion'd backward walk (cmd=WalkForward, spd<0) + // produces backward velocity in get_state_velocity, NOT + // forward. Pre-fix used `<=0 ? 1 : speedMod` which clamped + // negative to 1.0 and made the dead-reckoned body translate + // forward despite the reverse-playback animation — visually + // "still walking forward" from the observer's POV. + remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod; - // Build the funnel input. fullMotion/speedMod already - // encode retail's absent-field forward defaults (null/0 - // → Ready; mt 6-9 never reach here post-V4). Style: - // retail's InterpretedMotionState::UnPack (0x0051f400) - // defaults an absent stance to NonCombat 0x8000003D — - // NOT keep-current (S0 trace: every empty UM applied - // 0x8000003d on the retail observer). - var ims = new AcDream.Core.Physics.InboundInterpretedState + if (update.MotionState.IsServerControlledMoveTo + && update.MotionState.MoveToPath is { } path) { - CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du, - ForwardCommand = fullMotion, - ForwardSpeed = speedMod, - SideStepCommand = 0u, - SideStepSpeed = update.MotionState.SideStepSpeed ?? 1f, - TurnCommand = 0u, - TurnSpeed = update.MotionState.TurnSpeed ?? 1f, - }; + remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver + .OriginToWorld( + path.OriginCellId, + path.OriginX, + path.OriginY, + path.OriginZ, + _liveCenterX, + _liveCenterY); + remoteMot.MoveToMinDistance = path.MinDistance; + remoteMot.MoveToDistanceToObject = path.DistanceToObject; + remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards; + remoteMot.HasMoveToDestination = true; + remoteMot.LastMoveToPacketTime = + (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + } + else if (!update.MotionState.IsServerControlledMoveTo) + { + // Off MoveTo — clear stale destination so the per-tick + // driver doesn't keep steering. + remoteMot.HasMoveToDestination = false; + } + } + + if (forwardIsOverlay) + { + if (!remoteIsAirborne) + { + AcDream.Core.Physics.AnimationCommandRouter.RouteFullCommand( + ae.Sequencer, + fullStyle, + fullMotion, + speedMod <= 0f ? 1f : speedMod); + } + } + else + { + // Pick which cycle to play on the sequencer. Priority: + // 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk. + // 2. Else sidestep cmd if active — legs strafe. + // 3. Else turn cmd if active — legs pivot. + // 4. Else Ready — idle. + // + // For forward+sidestep or forward+turn, the forward cycle + // wins at the anim layer; the sidestep/turn contribute via + // MotionInterpreter velocity/omega writes. + uint animCycle = fullMotion; + float animSpeed = speedMod; + uint fwdLow = fullMotion & 0xFFu; + bool fwdIsRunWalk = fwdLow == 0x05 /* Walk */ || fwdLow == 0x06 /* WalkBack */ + || fwdLow == 0x07 /* Run */; + if (!fwdIsRunWalk) + { + // Forward is Ready (or absent). Prefer sidestep cycle if present, + // else turn cycle, else Ready. + if (update.MotionState.SideStepCommand is { } sideForAnim && sideForAnim != 0) + { + uint sideFullForAnim = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(sideForAnim); + if (sideFullForAnim == 0) sideFullForAnim = 0x65000000u | sideForAnim; + animCycle = sideFullForAnim; + animSpeed = update.MotionState.SideStepSpeed ?? 1f; + } + else if (update.MotionState.TurnCommand is { } turnForAnim && turnForAnim != 0) + { + uint turnFullForAnim = AcDream.Core.Physics.MotionCommandResolver + .ReconstructFullCommand(turnForAnim); + if (turnFullForAnim == 0) turnFullForAnim = 0x65000000u | turnForAnim; + animCycle = turnFullForAnim; + // SIGNED — do NOT MathF.Abs. ACE encodes TurnLeft on the + // wire as (TurnCommand=TurnRight, TurnSpeed=NEGATIVE), + // mirroring retail's adjust_motion convention. The + // sequencer's negative-speed path (EnqueueMotionData + // multiplies MotionData.Omega by speedMod, the + // synthesize-omega fallback flips zomega via + // -(pi/2)*adjustedSpeed) only produces the correct + // CCW rotation when the sign is preserved here. + // Confirmed by live wire trace 2026-05-03: TurnLeft + // input arrives as turnCmd16=0x000D, speed=-1.500. + animSpeed = update.MotionState.TurnSpeed ?? 1f; + } + } + // K-fix17 (2026-04-26): preserve the Falling cycle while + // the remote is airborne. ACE broadcasts UpdateMotion + // mid-arc when the player turns / runs — the previous + // SetCycle here swapped Falling → RunForward, breaking + // the visible jump animation. The arc still played out + // physics-wise (body went up/down), but the legs ran + // instead of folded. Skip the cycle swap when airborne; + // the InterpretedState updates below still fire so the + // body's velocity matches the new motion command, AND + // the post-resolve landing path restores the cycle to + // whatever the interpreted state says when the body + // lands. + if (!remoteIsAirborne) + { + // Fallback chain for missing cycles in the MotionTable. + // SetCycle unconditionally calls ClearCyclicTail() before + // looking up the cycle; if the cycle is absent, the body + // ends up with no cyclic tail at all and every part snaps + // to its setup-default offset — visible as "torso on the + // ground" because most creatures' setup-default puts all + // limbs at the torso origin. + // + // This is specifically a regression from commit 186a584 + // (Phase L.1c port): pre-fix, MoveTo packets fell through + // to fullMotion=Ready (which always exists in every + // MotionTable). Post-fix, MoveTo packets seed + // fullMotion=RunForward, but some creatures (especially + // when stance=HandCombat) lack a (combat, RunForward) + // cycle. Fall through RunForward → WalkForward → Ready + // until we find one the table actually contains. + // + // Note: this fallback is for the SEQUENCER (visible + // animation) only. InterpretedState.ForwardCommand still + // gets the wire's (or seeded) ForwardCommand verbatim + // so apply_current_movement produces correct velocity. + uint cycleToPlay = animCycle; + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && (animCycle & 0xFFu) is 0x05u or 0x07u) + { + bool hc = ae.Sequencer.HasCycle(fullStyle, cycleToPlay); + System.Console.WriteLine( + $"[HASCYCLE] guid={update.Guid:X8} style=0x{fullStyle:X8} " + + $"requestedCycle=0x{cycleToPlay:X8} HasCycle={hc}"); + } + if (!ae.Sequencer.HasCycle(fullStyle, cycleToPlay)) + { + uint requested = cycleToPlay; + // RunForward (0x44000007) → WalkForward (0x45000005) + if ((cycleToPlay & 0xFFu) == 0x07 + && ae.Sequencer.HasCycle(fullStyle, 0x45000005u)) + { + cycleToPlay = 0x45000005u; + } + // WalkForward → Ready (0x41000003) + else if (ae.Sequencer.HasCycle(fullStyle, 0x41000003u)) + { + cycleToPlay = 0x41000003u; + } + // Ready missing too — leave the existing cycle alone + // by not calling SetCycle at all (avoids the + // ClearCyclicTail wipe). + else + { + cycleToPlay = 0; + } + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") + { + Console.WriteLine( + $"UM cycle missing for guid=0x{update.Guid:X8} " + + $"style=0x{fullStyle:X8} requested=0x{requested:X8} " + + $"→ fallback=0x{cycleToPlay:X8}"); + } + } + if (cycleToPlay != 0) + { + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" + && (ae.Sequencer.CurrentMotion != cycleToPlay + || MathF.Abs(ae.Sequencer.CurrentSpeedMod - animSpeed) > 1e-3f)) + { + System.Console.WriteLine( + $"[SETCYCLE] guid={update.Guid:X8} " + + $"old=(motion=0x{ae.Sequencer.CurrentMotion:X8} speed={ae.Sequencer.CurrentSpeedMod:F3}) " + + $"new=(motion=0x{cycleToPlay:X8} speed={animSpeed:F3})"); + } + ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed); + } + } + + // Retail runs the full MotionInterp state machine on every + // remote. Route each wire command (forward, sidestep, turn) + // through DoInterpretedMotion so apply_current_movement → + // get_state_velocity → PhysicsBody.set_local_velocity fires + // on a subsequent tick exactly as retail's FUN_00529210 + // (apply_current_movement) does. + // + // Decompile refs: + // FUN_00529930 DoMotion + // FUN_00528f70 DoInterpretedMotion + // FUN_00528960 get_state_velocity + // FUN_00529210 apply_current_movement + // ServerMoveToActive flag, MoveToPath capture, and the + // InterpretedState.ForwardCommand bulk-copy are already + // handled by the LIFTED block above (so overlay-class swings + // also clear stale MoveTo state and update the body's + // forward command). This branch only handles sidestep / + // turn axes plus the ObservedOmega seed — none of which + // appear on overlay packets, so the existing logic is + // correct unchanged. (`remoteMot` is the same dictionary + // entry obtained at the top of the lifted block.) + if (remoteMot is not null) + { + // Sidestep axis. if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0) { uint sideFull = AcDream.Core.Physics.MotionCommandResolver .ReconstructFullCommand(sideCmd16); - ims.SideStepCommand = sideFull != 0 ? sideFull : (0x65000000u | sideCmd16); + if (sideFull == 0) sideFull = 0x65000000u | sideCmd16; + float sideSpd = update.MotionState.SideStepSpeed ?? 1f; + remoteMot.Motion.DoInterpretedMotion( + sideFull, sideSpd, modifyInterpretedState: true); } + else + { + // No sidestep — clear any leftover strafing motion. + remoteMot.Motion.StopInterpretedMotion( + AcDream.Core.Physics.MotionCommand.SideStepRight, modifyInterpretedState: true); + remoteMot.Motion.StopInterpretedMotion( + AcDream.Core.Physics.MotionCommand.SideStepLeft, modifyInterpretedState: true); + } + + // Turn axis — and use as the on/off switch for ObservedOmega. + // On turn start: seed ObservedOmega from the formula + // (π/2 × turnSpeed) so rotation begins THIS tick without + // waiting for the next UP to observe a delta. + // On turn end: zero ObservedOmega so rotation stops + // immediately instead of coasting at the last observed + // rate until the next UP shows zero delta. + // UpdatePosition still REFINES the rate from actual + // server deltas (more accurate than the formula), but + // this ensures instant on/off response. if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0) { uint turnFull = AcDream.Core.Physics.MotionCommandResolver .ReconstructFullCommand(turnCmd16); - ims.TurnCommand = turnFull != 0 ? turnFull : (0x65000000u | turnCmd16); + if (turnFull == 0) turnFull = 0x65000000u | turnCmd16; + float turnSpd = update.MotionState.TurnSpeed ?? 1f; if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { System.Console.WriteLine( $"[TURN_WIRE] guid={update.Guid:X8} turnCmd16=0x{turnCmd16:X4} " - + $"turnFull=0x{ims.TurnCommand:X8} speed={ims.TurnSpeed:F3}"); + + $"turnFull=0x{turnFull:X8} low=0x{turnFull & 0xFFu:X2} " + + $"({(((turnFull & 0xFFu) == 0x0D) ? "TurnRight" : ((turnFull & 0xFFu) == 0x0E) ? "TurnLeft" : "OTHER")}) " + + $"speed={turnSpd:F3}"); } + remoteMot.Motion.DoInterpretedMotion( + turnFull, turnSpd, modifyInterpretedState: true); + // Seed ObservedOmega with formula so rotation starts + // immediately; UP deltas will refine the rate. + uint turnLow = turnFull & 0xFFu; + if (turnLow == 0x0D /* TurnRight */) + remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, -(MathF.PI / 2f) * turnSpd); + else if (turnLow == 0x0E /* TurnLeft */) + remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, (MathF.PI / 2f) * turnSpd); } - - // Command list → the funnel's action list. Retail applies - // EVERY entry via DoInterpretedMotion under the 15-bit - // server_action_stamp gate — the gate is what makes ACE's - // re-bundled stale entries (e.g. the Ready bundled into a - // RunForward UM, 2026-05-03 finding) inert, replacing the - // old skip-SubState-class workaround. - if (update.MotionState.Commands is { Count: > 0 } cmdList) + else { - var actionList = new System.Collections.Generic.List(cmdList.Count); - foreach (var item in cmdList) - { - uint full = AcDream.Core.Physics.MotionCommandResolver - .ReconstructFullCommand(item.Command); - if (full == 0) full = 0x10000000u | item.Command; - actionList.Add(new AcDream.Core.Physics.InboundMotionAction( - full, - Stamp: item.PackedSequence & 0x7FFF, - Autonomous: (item.PackedSequence & 0x8000) != 0, - Speed: item.Speed)); - } - ims.Actions = actionList; + remoteMot.Motion.StopInterpretedMotion( + AcDream.Core.Physics.MotionCommand.TurnRight, modifyInterpretedState: true); + remoteMot.Motion.StopInterpretedMotion( + AcDream.Core.Physics.MotionCommand.TurnLeft, modifyInterpretedState: true); + // Zero ObservedOmega immediately — don't coast. + remoteMot.ObservedOmega = System.Numerics.Vector3.Zero; } - - // R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the - // motion-table stack (GetObjectSequence + is_allowed - // decide) — mt-0 only post-V4 (types 6-9 returned above). - remoteMot.Motion.MoveToInterpretedState(ims, sink); - - // R5-V4: retail unpack_movement case-0 TAIL order - // (@00524583-0052458e): move_to_interpreted_state FIRST - // (above), THEN stick_to_object when the motionFlags 0x1 - // trailer carried a guid, THEN standing_longjump ← - // motionFlags 0x2 (UNCONDITIONAL — an absent flag - // CLEARS it). - if (update.MotionState.StickyObjectGuid is { } stickyGuid - && stickyGuid != 0) - StickToObjectFromWire(remoteMot.Host, stickyGuid); - remoteMot.Motion.StandingLongJump = - update.MotionState.StandingLongJump; } } + } // CRITICAL: when we enter a locomotion cycle (Walk/Run/etc), // stamp the _remoteLastMove timestamp to "now". Without this, @@ -5349,11 +4025,7 @@ public sealed class GameWindow : IDisposable // a second SetCycle call wiped the first within the same UM // packet processing. Only Actions/Modifiers/ChatEmotes (overlays // that interleave with the cycle) belong in the list iteration. - if (update.Guid == _playerServerGuid // L.2g S2b: LOCAL ONLY — remote command - // lists flow through the funnel's - // action-stamp gate (retail's actual - // mechanism for bundled stale entries) - && update.MotionState.Commands is { Count: > 0 } cmds) + if (update.MotionState.Commands is { Count: > 0 } cmds) { if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { @@ -5379,7 +4051,7 @@ public sealed class GameWindow : IDisposable if (itemRoute == AcDream.Core.Physics.AnimationCommandRouteKind.SubState) continue; AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand( - ae.Sequencer!, // guarded by the enclosing `if (ae.Sequencer is not null)` + ae.Sequencer, fullStyle, item.Command, item.Speed); @@ -5460,24 +4132,26 @@ public sealed class GameWindow : IDisposable | AcDream.Core.Physics.TransientStateFlags.OnWalkable); rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity; - // R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism. - // The remote's ground departure fires LeaveGround (0x00528b00): - // strips pending transition links (the RemoveLinkAnimations - // seam) + re-applies movement through DefaultSink, whose - // contact-gated funnel dispatch engages Falling — no forced - // SetCycle, no skip flag. The wire velocity/omega are re-applied - // AFTER so they stay authoritative over LeaveGround's - // state-derived velocity write (adaptation note: retail's - // equivalence comes from the per-tick transition-sweep order — - // R6 scope). + // K-fix10 (2026-04-26): force the Falling animation cycle on + // the remote so the legs match the arc. Mirrors the local + // player's UpdatePlayerAnimation path which sets + // animCommand = Falling whenever !IsOnGround. + // + // K-fix18 (2026-04-26): pass skipTransitionLink:true so the + // RunForward→Falling transition frames don't play first. + // Without that flag the remote stood still for ~100 ms at + // the start of the jump while the link drained, then + // folded into Falling. Skipping the link makes the pose + // engage immediately on jump start. if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent) && _animatedEntities.TryGetValue(ent.Id, out var ae) && ae.Sequencer is not null) { - EnsureRemoteMotionBindings(rm, ae, update.Guid); - rm.Motion.LeaveGround(); - rm.Body.Velocity = update.Velocity; - rm.Body.Omega = update.Omega; + uint style = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; // NonCombat default + ae.Sequencer.SetCycle(style, AcDream.Core.Physics.MotionCommand.Falling, 1.0f, + skipTransitionLink: true); } } @@ -5522,6 +4196,35 @@ public sealed class GameWindow : IDisposable return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10; } + /// + /// Grace window in seconds after a UM arrives during which UP-derived + /// velocity refinement is suppressed for a player remote. UMs are + /// authoritative; the velocity fallback only fills the gap when retail + /// does not send a fresh MoveToState (Shift toggle while direction key + /// held). 200 ms covers the worst-case UM/UP race — UMs arrive on + /// direction-key events and UPs at 5–10 Hz, so the first UP after a + /// fresh UM lands ~100–200 ms behind. Tightened from 500 ms (commit + /// 8fa04af original) per user observation that the Shift-toggle + /// transition was visibly slower than retail; with 0.2 s the worst-case + /// added latency is just the UP cadence below it. + /// + private const double UmGraceSeconds = 0.2; + + /// + /// Speed (m/s) above which a player-remote currently in WalkForward + /// is promoted to RunForward by velocity refinement. Tuned to player + /// speeds: walk ≈ 3.12 m/s (WalkAnimSpeed × 1.0), run ≈ 8–12 m/s + /// (RunAnimSpeed × runRate ≈ 4.0 × 2.0–3.0). Hysteresis with + /// avoids thrashing at the boundary. + /// + private const float PlayerRunPromoteSpeed = 5.5f; + + /// + /// Speed (m/s) below which a player-remote currently in RunForward + /// is demoted to WalkForward by velocity refinement. + /// + private const float PlayerRunDemoteSpeed = 4.5f; + private void ApplyServerControlledVelocityCycle( uint serverGuid, AnimatedEntity ae, @@ -5530,31 +4233,35 @@ public sealed class GameWindow : IDisposable { if (rm.Airborne) return; if (ae.Sequencer is null) return; - // R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's - // get_command dispatch). Velocity-estimated cycle planning would - // fight it — same rationale as the pre-V4 ServerMoveToActive guard. - if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return; + // MoveTo packets already seeded the retail speed/runRate cycle. + // Keep UpdatePosition-derived velocity for render position only; + // using it to choose the cycle reverts fast chases back to slow + // velocity-estimated animation. + if (rm.ServerMoveToActive) return; if (IsPlayerGuid(serverGuid)) { - // L.2g S5 (2026-07-02, DEV-2 DELETED): player remotes get NO - // pace-derived cycle refinement — retail has no such mechanism - // anywhere in its inbound pipeline (deviation map DEV-2, two - // independent decomp dives + ACE cross-check). The #39-era - // premise ("retail's outbound goes silent on Shift toggle") was - // refuted at all three oracles + the S0 live capture: retail - // sends a fresh MoveToState on HoldRun toggle while moving - // (CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE - // rebroadcasts every MoveToState (GameActionMoveToState.cs:36), - // and the wire shows explicit 0x0005↔0x0007 UMs on each toggle - // (launch-s0-wireprobe.log). The refinement layer's re-promote - // after legitimate flags=0 Ready UMs was itself the observed - // Ready↔Run thrash. Cycle changes for player remotes come from - // UpdateMotion ONLY, exactly like retail; position error is the - // InterpolationManager chase's job. + // #39 (2026-05-06): player-remote forward-direction speed-bucket + // refinement. The bug case: actor toggles Shift while holding W + // (or releases Shift). Retail's outbound apparently does NOT + // broadcast a fresh MoveToState for HoldKey-only changes + // (verified via static analysis of CommandInterpreter::SendMovementEvent + // call sites; needs cdb confirmation). ACE has nothing to + // broadcast → no UM arrives at the observer → cycle stays at + // whichever direction-bucket was last set. Velocity DOES change + // (UP carries new pace), so this code path uses UP-derived + // velocity to refine the speed bucket within the same direction. // - // NPC/monster remotes below keep PlanFromVelocity until S6 - // unifies all entity classes onto the CMotionInterp funnel. + // Conservative scope: + // - Forward direction only (low byte 0x05 or 0x07). Sidestep + // and backward HoldKey toggles are deferred until the TTD + // trace described in + // docs/research/2026-05-06-locomotion-cycle-transitions/ + // confirms retail's exact algorithm. + // - Hysteresis (4.5 m/s demote / 5.5 m/s promote) prevents + // thrashing at the boundary. + // - 500 ms UM grace window — a fresh UM is always authoritative. + ApplyPlayerLocomotionRefinement(serverGuid, ae, rm, velocity); return; } @@ -5579,25 +4286,173 @@ public sealed class GameWindow : IDisposable + $"|v|={velocity.Length():F2} " + $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} " + $"prev=0x{currentMotion:X8} " - + $"airborne={rm.Airborne} moveTo={rm.MoveTo?.MovementTypeState ?? AcDream.Core.Physics.MovementType.Invalid}"); + + $"airborne={rm.Airborne} moveTo={rm.ServerMoveToActive}"); } ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod); } + private void ApplyPlayerLocomotionRefinement( + uint serverGuid, + AnimatedEntity ae, + RemoteMotion rm, + System.Numerics.Vector3 velocity) + { + // UM grace: a fresh UM is authoritative. + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + double sinceUm = nowSec - rm.LastUMTime; + if (sinceUm < UmGraceSeconds) return; + + uint currentMotion = ae.Sequencer!.CurrentMotion; + uint lowByte = currentMotion & 0xFFu; + float currentSign = MathF.Sign(ae.Sequencer.CurrentSpeedMod); + if (currentSign == 0f) currentSign = 1f; + + // Recognised locomotion directions: + // 0x05 (WalkForward) — also encodes WalkBackward via negative speed + // (ACE convention: SidestepCommand= cancel, ForwardCommand= + // WalkForward, ForwardSpeed *= -0.65) + // 0x07 (RunForward) + // 0x0F (SideStepRight) + // 0x10 (SideStepLeft) + // Other motions (Ready, Turn, emotes, attacks) are left to UM-driven SetCycle. + const uint LowWalkForward = 0x05u; + const uint LowRunForward = 0x07u; + const uint LowSideStepRight = 0x0Fu; + const uint LowSideStepLeft = 0x10u; + bool isForwardClass = lowByte == LowWalkForward || lowByte == LowRunForward; + bool isSidestep = lowByte == LowSideStepRight || lowByte == LowSideStepLeft; + if (!isForwardClass && !isSidestep) return; + + float horizSpeed = MathF.Sqrt(velocity.X * velocity.X + velocity.Y * velocity.Y); + + // Hysteresis: stay in current bucket unless we cross the appropriate + // threshold. Below StopSpeed → don't refine (let UM Ready stop signal + // handle the stop transition; we don't want UP momentary 0-velocity + // to drop the cycle to Ready while the actor is mid-stride). + if (horizSpeed < AcDream.Core.Physics.ServerControlledLocomotion.StopSpeed) + return; + + uint targetMotion; + float speedMod; + + if (isSidestep) + { + // Sidestep: motion ID stays the same (SideStepLeft / SideStepRight). + // Retail's wire encoding for sidestep speed buckets uses the same + // motion ID with different SidestepSpeed (slow ≈ 1.25 multiplier, + // fast ≈ 3.0 clamp per ACE MovementData.cs:124-131). On Shift + // toggle while a strafe key is held, retail does NOT broadcast a + // fresh MoveToState (same wire-silence rule as the forward case), + // so observer-side cycle refinement must come from UP-derived + // velocity here. Preserve the sign — SideStepLeft is sometimes + // emitted with negative speedMod by the adjust_motion path. + // + // Magnitude: horizSpeed / SidestepAnimSpeed maps the observed + // speed back to a SideStepSpeed the sequencer can apply as a + // framerate multiplier. Retail's get_state_velocity for + // sidestep cycles is `velocity.X = SidestepAnimSpeed * + // SideStepSpeed` (MotionInterpreter.cs:592 — constant 1.25 + // m/s). Dividing by WalkAnimSpeed (3.12) here was wrong by + // 2.5× and made slow strafe play visibly slower than retail. + float sideMag = horizSpeed / AcDream.Core.Physics.MotionInterpreter.SidestepAnimSpeed; + sideMag = MathF.Min(MathF.Max( + sideMag, + AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), + AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); + targetMotion = currentMotion; + speedMod = sideMag * currentSign; + } + else if (currentSign < 0f) + { + // BACKWARD walk: ACE encodes WalkBackward as `WalkForward` motion + // with NEGATIVE speedMod (MovementData.cs:115 `interpState.ForwardSpeed *= -0.65f`). + // No "RunBackward" motion exists — Shift toggle on backward + // changes only the magnitude of speedMod (slow back ≈ -0.65, + // fast back ≈ -1.91 = -runRate × 0.65). Keep WalkForward motion, + // refine magnitude, preserve negative sign. + // + // Without this branch (the original fix #1), backward refinement + // computed a positive speedMod from horizSpeed and overwrote the + // negative sign, making the legs play forward-walk while the body + // continued moving backward (the rubber-banding the user reported). + float backMag = horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed; + backMag = MathF.Min(MathF.Max( + backMag, + AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), + AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); + targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward; + speedMod = -backMag; + } + else if (lowByte == LowRunForward) + { + if (horizSpeed < PlayerRunDemoteSpeed) + { + targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward; + speedMod = MathF.Min(MathF.Max( + horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed, + AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), + AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); + } + else + { + targetMotion = AcDream.Core.Physics.MotionCommand.RunForward; + speedMod = MathF.Min(MathF.Max( + horizSpeed / AcDream.Core.Physics.MotionInterpreter.RunAnimSpeed, + AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), + AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); + } + } + else + { + // currently WalkForward (0x05) with positive speedMod = walking forward. + if (horizSpeed > PlayerRunPromoteSpeed) + { + targetMotion = AcDream.Core.Physics.MotionCommand.RunForward; + speedMod = MathF.Min(MathF.Max( + horizSpeed / AcDream.Core.Physics.MotionInterpreter.RunAnimSpeed, + AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), + AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); + } + else + { + targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward; + speedMod = MathF.Min(MathF.Max( + horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed, + AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), + AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); + } + } + + // Skip the SetCycle if neither motion nor speedMod changed + // meaningfully — avoids replaying transition links every UP. + bool motionChanged = currentMotion != targetMotion + && (currentMotion & 0xFFu) != (targetMotion & 0xFFu); + bool speedChanged = MathF.Abs(ae.Sequencer.CurrentSpeedMod - speedMod) > 0.05f; + if (!motionChanged && !speedChanged) + return; + + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + System.Console.WriteLine( + $"[UPCYCLE_PLAYER] guid={serverGuid:X8} " + + $"|v|={horizSpeed:F2} cur=0x{currentMotion:X8} " + + $"-> motion=0x{targetMotion:X8} speedMod={speedMod:F2} " + + $"sinceUM={sinceUm:F2}s " + + $"motionChg={motionChanged} speedChg={speedChanged}"); + } + + uint style = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; + ae.Sequencer.SetCycle(style, targetMotion, speedMod); + } + private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { - // Phase A.1 / #135: track the PLAYER's last server-known landblock so the - // streaming controller can follow the player in the fly-camera / pre-player-mode - // (login hold) views. Filtered to our OWN character guid — resolving the original - // Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock - // must NOT move the streaming observer: during a dungeon-login hold (player not - // yet placed, so _playerController is null and the PortalSpace observer branch - // can't apply) that would drift the observer off the pre-collapsed dungeon - // landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window - // the pre-collapse just suppressed. _playerServerGuid is set from CharacterList - // (~line 1984) before world entry, so it is valid by the time updates arrive. - if (update.Guid == _playerServerGuid) - _lastLivePlayerLandblockId = update.Position.LandblockId; + // Phase A.1: track the most recently updated entity's landblock so the + // streaming controller can follow the player. TODO: filter by our own + // character guid once we reliably know it from CharacterList. + _lastLivePlayerLandblockId = update.Position.LandblockId; if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; @@ -5781,30 +4636,27 @@ public sealed class GameWindow : IDisposable { rmState.Airborne = false; rmState.Body.Velocity = System.Numerics.Vector3.Zero; + rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact | AcDream.Core.Physics.TransientStateFlags.OnWalkable; rmState.Interp.Clear(); rmState.Body.Position = worldPos; - // #161: retail landing = MovementManager::HitGround - // (minterp → moveto, 0x00524300 — the R5-V5 facade - // relay) with the Gravity state bit STILL SET - // (CMotionInterp::HitGround gates on state&0x400). The - // re-apply dispatches the PRESERVED pre-fall forward - // command → landing link → cycle. This replaces the - // forced SetCycle, which read the then-clobbered - // ForwardCommand (Falling) and re-set the pose it meant - // to clear. See the twin block in TickAnimations - // (VU.land). + // Reset the sequencer out of Falling — see matching block in + // TickAnimations Step 5 (env-var path) for rationale. if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand) && aeForLand.Sequencer is not null) { - EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid); + uint style = aeForLand.Sequencer.CurrentStyle != 0 + ? aeForLand.Sequencer.CurrentStyle + : 0x8000003Du; + uint landingCmd = rmState.Motion.InterpretedState.ForwardCommand; + if (landingCmd == 0) + landingCmd = AcDream.Core.Physics.MotionCommand.Ready; + float landingSpeed = rmState.Motion.InterpretedState.ForwardSpeed; + if (landingSpeed <= 0f) landingSpeed = 1f; + aeForLand.Sequencer.SetCycle(style, landingCmd, landingSpeed); } - rmState.Movement.HitGround(); - // DR bookkeeping only (partner of the jump-start - // `State |= Gravity`). - rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; return; } @@ -5835,14 +4687,24 @@ public sealed class GameWindow : IDisposable isMovingTo: false, currentBodyPosition: rmState.Body.Position); } - // Track the UP-derived synth velocity for diagnostics - // ([VEL_DIAG] pace comparison). L.2g S5 (2026-07-02): the - // #39-era cycle-refinement call that used to live here is - // DELETED — retail never adapts a remote's animation from - // observed pace (deviation map DEV-2; premise refuted at - // decomp + ACE source + the S0 live capture, which shows - // explicit 0x0005↔0x0007 UMs on every Shift toggle). - // Player-remote cycles are UM-driven only. + // #39 fix-3 (2026-05-06): velocity-fallback cycle refinement + // for player remotes. Wire-level evidence (`launch-39-diag2.log`): + // when retail's actor toggles Shift while a direction key + // is held, retail's outbound MoveToState logic does NOT + // emit a fresh packet (only Ready ↔ Run UMs visible in + // `[FWD_WIRE]`, despite a clear walk-pace ↔ run-pace + // velocity transition in `[VEL_DIAG]`). ACE has nothing + // to broadcast → no UM arrives at the observer → cycle + // sticks at whatever the last UM set. Compute the + // synth-velocity here in the player-remote path AND + // call into ApplyServerControlledVelocityCycle, which + // routes through the direction-preserving + UM-grace + // ApplyPlayerLocomotionRefinement helper (added in + // commit 8fa04af). + // + // The legacy non-player block below (3759+) covers NPCs + // and is gated `!IsPlayerGuid`; this block fills the + // matching gap for players. if (rmState.PrevServerPosTime > 0.0) { double nowSecVel = rmState.LastServerPosTime; @@ -5852,6 +4714,21 @@ public sealed class GameWindow : IDisposable var synthVel = (worldPos - rmState.PrevServerPos) / (float)dtPos; rmState.ServerVelocity = synthVel; rmState.HasServerVelocity = true; + + if (_animatedEntities.TryGetValue(entity.Id, out var aeForVel) + && aeForVel.Sequencer is not null) + { + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + System.Console.WriteLine( + $"[UPCYCLE_SRC] guid={update.Guid:X8} src=synth-player"); + } + ApplyServerControlledVelocityCycle( + update.Guid, + aeForVel, + rmState, + synthVel); + } } } @@ -5885,34 +4762,7 @@ public sealed class GameWindow : IDisposable rmState.ServerVelocity = System.Numerics.Vector3.Zero; rmState.HasServerVelocity = false; } - // R5-V3 #171 residual (2026-07-04 gate: "flashing/flapping", - // stale facing, pushed-into-player): while an entity is STUCK, - // the sticky steer owns its frame — retail's UP corrections flow - // through the InterpolationManager into the SAME adjust_offset - // chain where StickyManager OVERWRITES them while armed - // (PositionManager::adjust_offset 0x00555190 order; sticky - // assigns m_fOrigin 0x00555430), so a server correction can - // never fight an armed stick frame-by-frame. This legacy NPC - // path hard-snaps OUTSIDE that chain, producing a visible - // snap-out/steer-back oscillation at UP cadence (position) and - // a stale-facing stomp (orientation). Faithful translation to - // the snap architecture: suppress the position/orientation/ - // velocity snaps while stuck. LastServerPos/Time bookkeeping - // still records below — server truth reasserts on the first UP - // after unstick (bounded by the 1 s sticky lease). Register - // row with TS-41/TS-44. - bool snapSuppressedByStick = - (rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u; - if (snapSuppressedByStick - && AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled) - { - float snapDist = System.Numerics.Vector3.Distance( - worldPos, rmState.Body.Position); - Console.WriteLine(FormattableString.Invariant( - $"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})")); - } - if (!snapSuppressedByStick) - rmState.Body.Position = worldPos; + rmState.Body.Position = worldPos; // K-fix15 (2026-04-26): DON'T auto-clear airborne on UP. // ACE broadcasts UPs during the arc (peak / mid-fall / land) // at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on @@ -5948,13 +4798,7 @@ public sealed class GameWindow : IDisposable // turn starts incorporates the pre-turn interval and produces // a halved "observed" rate → visible slow-start. Formula-only // is stable and simple; hard-snap fixes any drift. - // R5-V3 #171 residual: gated on the stick (see the position-snap - // comment above) — ACE's server-side facing lags the strafing - // target; stomping it over the sticky's per-tick face-tracking - // was the "monster attacking while facing a different direction" - // gate report. - if (!snapSuppressedByStick) - rmState.Body.Orientation = rot; + rmState.Body.Orientation = rot; rmState.LastServerPos = worldPos; rmState.LastServerPosTime = nowSec; // Align the body's physics clock with our clock so update_object @@ -5981,6 +4825,7 @@ public sealed class GameWindow : IDisposable // carries no stop information for our ACE. if (svel.LengthSquared() < 0.04f) { + rmState.ServerMoveToActive = false; rmState.Motion.StopCompletely(); if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop) && aeForStop.Sequencer is not null) @@ -5993,26 +4838,25 @@ public sealed class GameWindow : IDisposable } } } - else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity - && !snapSuppressedByStick) + else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity) { rmState.Body.Velocity = rmState.ServerVelocity; } if (rmState.HasServerVelocity - && !snapSuppressedByStick && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity)) { - // NPC/monster remotes: PlanFromVelocity cycle selection from - // UP-derived velocity (ACE broadcasts NPC motion patterns the - // UM stream alone doesn't cover). Player remotes return early - // inside — their cycles are UM-driven only per retail (L.2g - // S5; DEV-2 deleted). Unification of NPCs onto the - // CMotionInterp funnel is S6. + // #39 (2026-05-06): un-gated for player remotes — the + // function itself routes player remotes into the dedicated + // ApplyPlayerLocomotionRefinement path (forward-direction + // speed bucket only, with UM grace + hysteresis). Non-player + // remotes use the existing PlanFromVelocity path. // // D2 (Commit A 2026-05-03): tag whether the velocity feeding - // ApplyServerControlledVelocityCycle is wire-explicit or - // synthesized from position deltas (the common case). + // ApplyServerControlledVelocityCycle is wire-explicit (rare for + // player remotes — ACE almost never sets HasVelocity on player + // UPs) or synthesized from position deltas (the common case). + // Pairs with the [UPCYCLE]/[UPCYCLE_PLAYER] line printed inside. if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { string velSrc = update.Velocity is null ? "synth" : "wire"; @@ -6030,7 +4874,7 @@ public sealed class GameWindow : IDisposable entity.Rotation = rmState.Body.Orientation; } - // Phase B.3 / G.3a (#133): portal-space arrival detection. + // Phase B.3: portal-space arrival detection. // Only runs for our own player character while in PortalSpace. if (_playerController is not null && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace @@ -6044,218 +4888,90 @@ public sealed class GameWindow : IDisposable bool differentLandblock = (lbX != oldLbX || lbY != oldLbY); - Console.WriteLine( - $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + - $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); - - System.Numerics.Vector3 newWorldPos; - if (differentLandblock) + // #107 (2026-06-10): ANY player position update while in PortalSpace + // IS the teleport arrival. Retail/holtburger exit portal space on the + // next position event unconditionally (holtburger messages.rs + // PlayerTeleport handler: log + LoginComplete; the destination applies + // through the normal position flow — no distance test). The old + // `differentLandblock || farAway(>100m)` arrival gate was an + // invention: ACE's same-landblock short-hop position corrections + // (e.g. right after an indoor login) matched neither condition, so + // PortalSpace never exited and movement input stayed frozen for the + // whole session (the #107 "input ignored" wedge shape — + // flood-fix-gate2.log: `teleport started (seq=1)` with no arrival). { - // #145: drop the stale SOURCE center landblock from the physics engine - // BEFORE recentering. The destination loads at world-offset (0,0) (the new - // center), but the prior center was ALSO loaded at offset (0,0) and its - // offset is never re-based — so the two overlap, and the Z-agnostic outdoor - // cell-snap (AdjustPosition / Resolve, iterating _landblocks) resolves the - // player into the STALE source landblock (a dungeon's frame). That happens - // for the arrival snap AND every per-frame resolve until the source finally - // unloads, so outbound movement is sent in the dungeon's frame and the server - // rejects every move as a failed transition (#145 / #138). Removing the stale - // center here makes both resolves fall through to the server-authoritative - // position (Resolve's NO-LANDBLOCK verbatim branch, :605) until the - // destination streams in. Only the offset-(0,0) center can collide with the - // destination-local position, so removing it alone is sufficient. - uint staleCenterId = AcDream.App.Streaming.StreamingRegion.EncodeLandblockId( - _liveCenterX, _liveCenterY); - _physicsEngine.RemoveLandblock(staleCenterId); + Console.WriteLine( + $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + + $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); - // Recenter the streaming controller on the new landblock NOW (kick - // off the dungeon load). After recentering, the destination is - // (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin. - _liveCenterX = lbX; - _liveCenterY = lbY; - newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); + System.Numerics.Vector3 newWorldPos; + if (differentLandblock) + { + // 1. Recenter the streaming controller on the new landblock. + _liveCenterX = lbX; + _liveCenterY = lbY; - // #135: pre-collapse on teleport into a sealed dungeon too — same - // race as login. The destination isn't placed until it hydrates, so - // without this NormalTick loads the full neighbor window during the - // arrival hold. The PortalSpace observer branch (OnUpdate) keeps the - // observer pinned to _liveCenterX/Y while held, so the stale frozen - // player position can't drift the observer off the dungeon and re-expand. - if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId)) - _streamingController.PreCollapseToDungeon(lbX, lbY); + // Recompute worldPos with new center (it becomes local-to-center). + // After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ) + // relative to the new origin — which maps to world-space (0,0,0) + local offset. + // The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically. + newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); + // (after recentering, origin is (0,0,0) since lb == center) + } else - // Outdoor teleport: the render origin (_liveCenter) just moved. Drop ALL - // resident terrain so overlapping blocks on a NEARBY jump re-bake at the - // new origin instead of rendering shifted — the confirmed "terrain in the - // sky" arcs (stale slots offset by exactly deltaLB×192). 2026-06-22. - _streamingController?.ForceReloadWindow(); - } - else - { - newWorldPos = worldPos; - } + { + // Same landblock: worldPos is already in the current center frame. + newWorldPos = worldPos; + } - // Retail "pink-bubble" transit: do NOT snap here. Record the destination and - // PRIORITIZE its landblock in streaming so it applies ahead of the per-frame - // budget (residency in ~hundreds of ms, not 10-14s). The TAS — ticked per frame - // in OnUpdate — holds the player in PortalSpace behind the fade until the - // destination terrain is resident (TeleportWorldReady), then fires Place. While - // held, no movement resolve runs, so the outbound cell frame can't corrupt. - _pendingTeleportRot = rot; - _pendingTeleportPos = newWorldPos; - _pendingTeleportCell = p.LandblockId; - _teleportHoldSeconds = 0f; - _teleportForced = false; - if (_streamingController is not null) - { - _streamingController.PriorityLandblockId = - AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY); - // Eager-apply the destination's IMMEDIATE SURROUNDINGS (the near ring), not - // just the one landblock the player stands on — so they arrive in a loaded, - // collidable world instead of a single landblock in the void. - _streamingController.PriorityRadius = TeleportNearRingRadius; + // 2. Resolve through physics for the correct ground Z. + uint newCellId = p.LandblockId; + var resolved = _physicsEngine.Resolve( + newWorldPos, newCellId, + System.Numerics.Vector3.Zero, _playerController.StepUpHeight); + var snappedPos = new System.Numerics.Vector3( + resolved.Position.X, resolved.Position.Y, resolved.Position.Z); + + // 3. Snap player entity + controller. + entity.SetPosition(snappedPos); + entity.ParentCellId = resolved.CellId; + entity.Rotation = rot; + _playerController.SetPosition(snappedPos, resolved.CellId); + + // 4. Recenter chase camera on the new position. + _chaseCamera?.Update(snappedPos, _playerController.Yaw); + _retailChaseCamera?.Update(snappedPos, _playerController.Yaw, + playerVelocity: System.Numerics.Vector3.Zero, + isOnGround: true, + contactPlaneNormal: System.Numerics.Vector3.UnitZ, + dt: 1f / 60f); + + // 5. Return to InWorld. + _playerController.State = AcDream.App.Input.PlayerState.InWorld; + Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); + + // 5. Send LoginComplete to tell the server the client finished loading. + // Per holtburger's PlayerTeleport handler (client/messages.rs:434-440), + // retail clients call send_login_complete() after each portal transition. + // ResetLoginComplete() clears the latch so the 0xF746 PlayerCreate path + // doesn't also send one. We send directly here instead. + _liveSession?.SendGameAction( + AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); } - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( - "AIM", p.LandblockId, - $"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}"); } } - // Retail teleport transit: the dormant 7-state TAS drives the fade cover, holds the - // player in PortalSpace until the destination is resident (TeleportWorldReady), then - // fires Place (materialize) and FireLoginComplete (regain control + ack the server). - // Replaces the old TeleportArrivalController hold/place machine. - private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new(); - private bool _teleportInProgress; - private System.Numerics.Vector3 _pendingTeleportPos; - private uint _pendingTeleportCell; - private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout) - private bool _teleportForced; // true when the safety-net timeout force-places - private float _teleportFadeAlpha; // 0 = clear, 1 = full black; consumed by the fade overlay - private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity; - - // Loud safety net for a destination that never streams (worker crash / corrupt dat / - // OOB coords). WALL-CLOCK, not a frame count: during a dungeon-exit hold the source is - // unloaded and the destination not yet loaded → empty world → ~1000 fps, so a 600-FRAME - // ceiling fired in ~0.6s and force-placed into the skybox before the expand finished. - // Now rarely fires because priority-apply makes residency fast. - private const float TeleportMaxHoldSeconds = 10f; - - // 2026-06-22: how many landblocks of the player's IMMEDIATE SURROUNDINGS to eager-apply - // (and to hold the fade for) on a teleport. radius 1 = the 3×3 around the destination — - // the player's own landblock + its 8 neighbours, ~576 m across — enough that they arrive - // standing on loaded ground with wall collision and a non-empty immediate view. The far - // ring (out to the streaming window) still drains at the per-frame budget after the fade - // lifts. Bigger = a more complete arrival but a longer (still hidden) loading fade. - private const int TeleportNearRingRadius = 1; - - // #145: the LANDBLOCK-relative (cell-local) position used to SEED the player - // body's cell-relative CellPosition. This is the ONE place the streaming center - // (_liveCenter) is allowed to touch the physics frame — at the placement seam, - // converting the render-frame world position into the wire's (cell, local). After - // seeding, physics carries (cell, local) forward without ever reading _liveCenter. - private System.Numerics.Vector3 CellLocalForSeed(System.Numerics.Vector3 worldPos, uint cellId) - { - int lbX = (int)((cellId >> 24) & 0xFFu); - int lbY = (int)((cellId >> 16) & 0xFFu); - var origin = new System.Numerics.Vector3( - (lbX - _liveCenterX) * 192f, - (lbY - _liveCenterY) * 192f, - 0f); - return worldPos - origin; - } - /// - /// worldReady for the TAS transit: is the player's teleport destination resident so we - /// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell - /// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around - /// the player, ) being registered — not just the - /// single destination landblock — so the fade only lifts onto a loaded, collidable world - /// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no - /// "only one landblock loaded"). The streaming controller priority-applies that same ring, - /// so it flips fast. An impossible claim (indoor cell id outside the dat's NumCells) - /// returns true so the TAS stops holding and the forced placement surfaces the failure - /// loudly rather than holding forever. - /// - private bool TeleportWorldReady(uint destCell) - { - if (IsSpawnClaimUnhydratable(destCell)) return true; - bool indoor = (destCell & 0xFFFFu) >= 0x0100u; - return indoor - ? _physicsEngine.IsSpawnCellReady(destCell) - : _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius); - } - - // The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only - // once the destination is ready (or force-run on impossible/timeout, logged loud). - private void PlaceTeleportArrival( - System.Numerics.Vector3 destPos, uint destCell, bool forced) - { - var resolved = _physicsEngine.Resolve( - destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight); - var snappedPos = new System.Numerics.Vector3( - resolved.Position.X, resolved.Position.Y, resolved.Position.Z); - - if (forced) - Console.WriteLine( - $"live: teleport HOLD gave up (impossible/timeout) — force-snapping " + - $"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}"); - - if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) - { - pe.SetPosition(snappedPos); - pe.ParentCellId = resolved.CellId; - pe.Rotation = _pendingTeleportRot; - } - _playerController.SetPosition(snappedPos, resolved.CellId, - CellLocalForSeed(snappedPos, resolved.CellId)); - // R5-V3 (#171, diff-review find): retail teleport_hook's TAIL - // (0x00514ed0 @0x00514f1b-0x00514f28) — after the manager teardown - // SetPosition just ran (moveto cancel + own UnStick), the teleporting - // object clears its OWN target subscription and tells every entity - // WATCHING IT that it teleported (NotifyVoyeurOfEvent(Teleported)). - // That notify is what tears down the mobs' sticks/target-tracking ON - // the player — without it, a melee pack stuck to the player keeps - // steering toward the post-teleport position at the 5× sticky follow - // speed for up to the 1 s lease (non-retail lurch on every recall). - _playerHost?.NotifyTeleported(); - // Face the server-specified destination heading (retail drops you facing a fixed - // direction). The render entity already got _pendingTeleportRot above; sync the - // controller yaw so the camera + movement frame match it instead of the stale - // pre-teleport facing. - _playerController.Yaw = AcQuaternionToYaw(_pendingTeleportRot); - - _chaseCamera?.Update(snappedPos, _playerController.Yaw); - _retailChaseCamera?.Update(snappedPos, _playerController.Yaw, - playerVelocity: System.Numerics.Vector3.Zero, - isOnGround: true, - contactPlaneNormal: System.Numerics.Vector3.UnitZ, - dt: 1f / 60f); - - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( - "PLACED", resolved.CellId, $"forced={forced}"); - // Do NOT flip to InWorld or send LoginComplete here — the player materializes BEHIND - // the fade and stays input-frozen (PortalSpace) until the TAS fades the world back in - // and fires FireLoginComplete (which regains control + acks the server). This is the - // retail "pop out the other side" sequence. - Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); - } - - /// - /// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). Freeze movement - /// input (PortalSpace) and begin the retail fade transit. The per-frame TAS tick holds - /// the player behind the fade until the destination is resident, then materializes them - /// (Place) and, after the world fades back in, regains control + acks the server - /// (FireLoginComplete). + /// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). + /// Freeze movement input by setting the player controller to PortalSpace. + /// The controller's Update() will return a zero-movement result until the + /// destination UpdatePosition arrives and OnLivePositionUpdated resets the + /// state to InWorld. /// private void OnTeleportStarted(uint sequence) { if (_playerController is not null) _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; - _teleportInProgress = true; - _teleportHoldSeconds = 0f; - _teleportForced = false; - _teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal); Console.WriteLine($"live: teleport started (seq={sequence})"); } @@ -6488,25 +5204,6 @@ public sealed class GameWindow : IDisposable // case), which blocks the render thread for at most that duration. // This is the minimum correct behavior; a future pass can reduce // contention by pre-building render-thread work on the worker. - // tp-probe (2026-06-22, REMOVABLE): measure lock-WAIT (the _datLock - // contention signal — large only when the render thread is hammering the - // lock during a CreateObject flood) AND lock-HOLD (the intrinsic build - // cost). Identical work in both branches; the probe branch only adds the - // stopwatch + log. No behavior change when ProbeTeleportEnabled is false. - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled) - { - var sw = System.Diagnostics.Stopwatch.StartNew(); - lock (_datLock) - { - long waitedMs = sw.ElapsedMilliseconds; - sw.Restart(); - var built = BuildLandblockForStreamingLocked(landblockId, kind); - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport( - "BUILD", landblockId, - $"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={kind}"); - return built; - } - } lock (_datLock) { return BuildLandblockForStreamingLocked(landblockId, kind); @@ -6531,8 +5228,7 @@ public sealed class GameWindow : IDisposable return new AcDream.Core.World.LoadedLandblock( landblockId, heightmapOnly, - System.Array.Empty(), - AcDream.Core.World.PhysicsDatBundle.Empty); // far tier: no cells/buildings/entities + System.Array.Empty()); } var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId); @@ -6611,91 +5307,10 @@ public sealed class GameWindow : IDisposable merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY)); merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY)); - // datLock fix: pre-read (under the worker's _datLock) every dat the apply - // consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER - // `merged` so entity GfxObj/Setup ids are known. - var physicsDats = BuildPhysicsDatBundle(landblockId, merged); return new AcDream.Core.World.LoadedLandblock( baseLoaded.LandblockId, baseLoaded.Heightmap, - merged, - physicsDats); - } - - /// - /// Pre-reads (under the worker's _datLock) every dat object - /// consumes, so the apply makes zero - /// DatCollection calls and the update thread never blocks on the - /// worker's lock. MIRRORS the apply's six read sites — gather and apply must - /// enumerate the same ids. Reads only; no build / registration. - /// - private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle( - uint landblockId, - System.Collections.Generic.IReadOnlyList entities) - { - var envCells = new System.Collections.Generic.Dictionary(); - var environments = new System.Collections.Generic.Dictionary(); - var setups = new System.Collections.Generic.Dictionary(); - var gfxObjs = new System.Collections.Generic.Dictionary(); - - // (1) LandBlockInfo - var lbInfo = _dats!.Get( - (landblockId & 0xFFFF0000u) | 0xFFFEu); - - if (lbInfo is not null) - { - // (2)+(3) EnvCell + Environment, per cell - if (lbInfo.NumCells > 0) - { - uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u; - for (uint offset = 0; offset < lbInfo.NumCells; offset++) - { - uint envCellId = firstCellId + offset; - var envCell = _dats.Get(envCellId); - if (envCell is null) continue; - envCells[envCellId] = envCell; - if (envCell.EnvironmentId == 0) continue; - uint envId = 0x0D000000u | envCell.EnvironmentId; - if (!environments.ContainsKey(envId)) - { - var environment = _dats.Get(envId); - if (environment is not null) environments[envId] = environment; - } - } - } - - // (4) building shell Setup, per building - foreach (var building in lbInfo.Buildings) - { - uint modelId = building.ModelId; - if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId)) - { - var bldSetup = _dats.Get(modelId); - if (bldSetup is not null) setups[modelId] = bldSetup; - } - } - } - - // (5)+(6) entity GfxObj (BSP cache) + entity Setup (ShadowObjects parts/lights) - foreach (var entity in entities) - { - uint src = entity.SourceGfxObjOrSetupId; - if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src)) - { - var s = _dats.Get(src); - if (s is not null) setups[src] = s; - } - foreach (var meshRef in entity.MeshRefs) - { - uint gid = meshRef.GfxObjId; - if ((gid & 0xFF000000u) != 0x01000000u) continue; - if (gfxObjs.ContainsKey(gid)) continue; - var gfx = _dats.Get(gid); - if (gfx is not null) gfxObjs[gid] = gfx; - } - } - - return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs); + merged); } /// @@ -6815,20 +5430,9 @@ public sealed class GameWindow : IDisposable // fall back to the local bilinear sample. var worldPx = localX + lbOffset.X; var worldPy = localY + lbOffset.Y; - // FIX (trees-in-sky, 2026-06-22): scenery ground-Z comes from THIS - // landblock's OWN heightmap — the same triangle-aware Z the player walks on - // (TerrainSurface.SampleZFromHeightmap, lock-step with physics per #48), - // scoped to the landblock being built. The former global - // _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at - // build time this landblock is NOT registered in physics yet, so that query - // could only return null (→ this same own-heightmap) or a STALE neighbor's - // height — the previous location's terrain, still registered after a teleport - // recenter (which drops only the single stale CENTER landblock, GameWindow - // :5444) until streaming unloads it — planting scenery at the old location's - // altitude (trees-in-sky, deltaZ up to +500m; confirmed via the - // [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every - // case, so the global query is removed (also drops its per-spawn cost). - float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY); + float? maybePhysicsZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy); + float groundZ = maybePhysicsZ + ?? SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY); float finalZ = groundZ + spawn.LocalPosition.Z; // Issue #48 diagnostic. One log line per (spawn, rendered-mesh) @@ -6838,9 +5442,7 @@ public sealed class GameWindow : IDisposable // line by world coords + gfx id, the data picks the hypothesis. if (_options.DumpSceneryZ) { - // groundZ now always comes from THIS landblock's own heightmap (the - // global physics query was removed — see the trees-in-sky fix above). - string source = "heightmap"; + string source = maybePhysicsZ.HasValue ? "physics" : "bilinear"; foreach (var mr in meshRefs) { var dgfx = _dats.Get(mr.GfxObjId); @@ -6996,56 +5598,26 @@ public sealed class GameWindow : IDisposable // Static objects inside the cell continue to flow through the dispatcher // as WorldEntity records below — they have real GfxObj MeshRefs that work // fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list. - // Transforms — needed by the portal-visibility cell (unlifted) AND the - // render/physics path. Computed for EVERY cell with a valid cellStruct, - // not just drawable ones. Keep the small render lift out of physics; retail - // BSP contact planes use the EnvCell origin verbatim. The lift constant is - // shared with every draw-space consumer of portal polygons (OutsideView - // gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130). - var physicsCellOrigin = envCell.Position.Origin + lbOffset; - var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( - 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); - var cellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); - var physicsCellTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * - System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); - - // PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless - // of whether CellMesh.Build produced drawable sub-meshes. A portals-only - // pass-through connector (a ramp / stair / cellar mouth) yields 0 render - // sub-meshes but MUST be in the visibility graph so the flood can traverse it - // to the cells beyond — otherwise the flood lookup-misses the unregistered - // neighbour and the grey clear shows through the opening (#133: ramp - // neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier - // at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace] - // skip=lookup-miss). Retail keeps the whole landblock cell array resident - // before the flood runs; BuildLoadedCell reads the cellStruct portals, NOT - // the render sub-meshes. The +0.02 m render lift is a DRAW concern only and - // is intentionally NOT fed into the visibility transform (#119-residual: the - // lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells). - BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); - - // PHYSICS cell graph: cache EVERY cell with a valid cellStruct, regardless of - // drawable sub-meshes. The camera-collision sweep (SmartBox::update_viewer → - // sphere_path.curr_cell, pc:92870) and the player cell-transit must be able to - // TRANSIT THROUGH a portals-only connector — otherwise the viewer/curr cell can - // never reach it and lags one cell behind the eye (#133 residual: the camera sat - // 1.32 m past the ramp portal's plane while the viewer cell stalled in - // 0x00070103 — the sweep transited every cached neighbour but NEVER the - // un-cached connector 0x014D — so the side test culled the on-screen connector - // portal and the grey clear showed through). Retail keeps the whole landblock - // cell array resident for the sweep; a portals-only connector has an empty - // collision BSP but its portals drive the transit. CacheCellStruct reads the - // cellStruct directly, not the render sub-meshes. - _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); - var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); if (cellSubMeshes.Count > 0) { _pendingCellMeshes[envCellId] = cellSubMeshes; + // Keep the small render lift out of physics; retail BSP + // contact planes use the EnvCell origin verbatim. The lift + // constant is shared with every draw-space consumer of + // portal polygons (OutsideView gate, seal/punch fans) — + // see PortalVisibilityBuilder.ShellDrawLiftZ (#130). + var physicsCellOrigin = envCell.Position.Origin + lbOffset; + var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3( + 0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ); + var cellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); + var physicsCellTransform = + System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * + System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin); + // Phase A8: register the cell with EnvCellRenderer for rendering. // staticObjects is empty — cell stabs continue as separate WorldEntity // records via the dispatcher (see lines below for the unchanged stab path). @@ -7058,6 +5630,25 @@ public sealed class GameWindow : IDisposable cellWorldPosition: cellOrigin, cellRotation: envCell.Position.Orientation, staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>()); + + // Step 4: build LoadedCell for portal visibility — with the + // PHYSICS (unlifted) transform. The +0.02 m render lift above + // is a DRAW concern (shell z-fighting vs terrain); feeding it + // into the visibility graph shifted every HORIZONTAL portal + // plane 2 cm up, putting an eye standing on a deck/landing + // 10–20 mm BELOW the lifted plane — outside the side test's + // ±10 mm in-plane window — so the cell behind the portal was + // side-culled: the tower-top staircase vanish + roof flap + // (#119-residual; captured live at eye z=126.803 vs the + // 010A→0107 plane at 126.80, reproduced ONLY with the lift in + // TowerAscentReplayTests.CapturedTopOfStairs_*). Vertical + // doorways were immune (the lift slides their planes along + // themselves), which is why this hit exactly stairs, decks, + // and cellar mouths. + BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform); + + // Cache CellStruct physics BSP for indoor collision (UNCHANGED). + _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform); } } } @@ -7074,17 +5665,6 @@ public sealed class GameWindow : IDisposable .DumpEntitySourceIds.Contains(stab.Id); int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0; - // #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to - // nothing (GfxObj id 0) at any runtime distance, so retail's distance-based - // degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the - // WorldBuilder editor shows it at the origin. acdream's render path came from - // WB (no distance LOD), so without this skip it draws the marker forever (the - // red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs - // skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0). - if ((stab.Id & 0xFF000000u) == 0x01000000u - && AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id)) - continue; - var meshRefs = new List(); var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); if ((stab.Id & 0xFF000000u) == 0x01000000u) @@ -7118,12 +5698,6 @@ public sealed class GameWindow : IDisposable } foreach (var mr in flat) { - // #136: skip an editor-only marker PART (retail hides it at runtime - // distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole - // part GfxObj 0x010028CA is such a marker — skipping it empties - // meshRefs and the whole stab drops below. - if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId)) - continue; var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) { @@ -7203,27 +5777,15 @@ public sealed class GameWindow : IDisposable AcDream.Core.Terrain.LandblockMeshData meshData) { if (_terrain is null || _dats is null) return; - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("APPLY", lb.LandblockId); - // datLock fix (2026-06-23): ApplyLoadedTerrainLocked now makes ZERO - // DatCollection calls — every dat it needs was pre-read by the streaming - // worker into lb.PhysicsDats. Its remaining mutations are update-thread- - // only (physics engine, ShadowObjects, renderer, _worldState) or already - // concurrent-safe (PhysicsDataCache is ConcurrentDictionary). _datLock's - // only cross-thread job was serializing DatCollection, so with no Get call - // here the lock is unnecessary — removing it eliminates the measured - // 24ms-median / 88ms-p95 lockwait stall (the FPS 30↔200 swing). The method - // keeps its historical "Locked" name; the worker still serializes its OWN - // dat reads on _datLock — only the apply stops contending for it. - // [FRAME-DIAG]: lockwait now measures ~0 — the before/after proof. - long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; - if (_frameDiag) - _applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; - ApplyLoadedTerrainLocked(lb, meshData); - if (_frameDiag) + // Phase A.1 hotfix: render-thread path also takes the dat lock so it + // doesn't race with BuildLandblockForStreaming on the worker thread. + // Hold the lock across the entity hydration below (GfxObj sub-mesh + // builds). The terrain mesh is pre-built by the worker (T12) and passed + // in via meshData, so LandblockMesh.Build no longer runs under this lock. + lock (_datLock) { - _applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0; - _appliesThisUpdate++; + ApplyLoadedTerrainLocked(lb, meshData); } } @@ -7383,11 +5945,6 @@ public sealed class GameWindow : IDisposable // _heightTable still needed for physics TerrainSurface below. if (_terrain is null || _dats is null || _heightTable is null) return; - // datLock fix: every dat the apply needs was pre-read by the worker into - // lb.PhysicsDats. Read from the bundle — NO _dats.Get here, so this method - // (and its caller) need no _datLock. - var datBundle = lb.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty; - uint lbXu = (lb.LandblockId >> 24) & 0xFFu; uint lbYu = (lb.LandblockId >> 16) & 0xFFu; int lbX = (int)lbXu; @@ -7400,16 +5957,7 @@ public sealed class GameWindow : IDisposable // Phase A.5 T15/T16: route through AddLandblockWithMesh — the named // two-tier entry point. Delegates to AddLandblock internally; both // paths share one GPU upload path. - // [FRAME-DIAG]: bracket the terrain glBufferSubData sub-span so we can tell - // the (tiny ~17KB) GPU upload apart from the dat-read/registration tail. - long fdU0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; _terrain.AddLandblockWithMesh(lb.LandblockId, meshData, origin); - if (_frameDiag) - _applyUploadAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdU0; - - // [FRAME-DIAG]: split the post-upload apply CPU into three sub-spans via - // method-scope checkpoints — cell-build → gfxobj-BSP → ShadowObjects+lights. - long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; // Step 4: drain pending LoadedCells from the worker thread. // Also collect into a local dict for the BuildingLoader stamping pass below. @@ -7462,18 +6010,21 @@ public sealed class GameWindow : IDisposable var cellSurfaces = new List(); var portalPlanes = new List(); - var lbInfo = datBundle.Info; + var lbInfo = _dats.Get( + (lb.LandblockId & 0xFFFF0000u) | 0xFFFEu); if (lbInfo is not null && lbInfo.NumCells > 0) { uint firstCellId = (lb.LandblockId & 0xFFFF0000u) | 0x0100u; for (uint offset = 0; offset < lbInfo.NumCells; offset++) { uint envCellId = firstCellId + offset; - if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell)) continue; + var envCell = _dats.Get(envCellId); + if (envCell is null) continue; if (envCell.EnvironmentId == 0) continue; - if (!datBundle.Environments.TryGetValue( - 0x0D000000u | envCell.EnvironmentId, out var environment)) continue; + var environment = _dats.Get( + 0x0D000000u | envCell.EnvironmentId); + if (environment is null) continue; if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) continue; // Transform CellStruct vertices from cell-local to world space. @@ -7537,33 +6088,10 @@ public sealed class GameWindow : IDisposable // formula (gridX * 8 + gridY + 1) within the 192m × 192m landblock. if (lbInfo is not null && lbInfo.Buildings.Count > 0) { - // #146 (2026-06-24): clear this landblock's prior building cache so - // the loop re-bases each building's STREAMING-RELATIVE WorldTransform - // with the CURRENT _liveCenter (origin, recomputed above per apply). - // CacheBuilding's per-cell first-wins then applies fresh within this - // pass — mirrors terrain's per-apply WorldOffset re-base (AddLandblock). - // Without it, CacheBuilding's idempotent guard locks the transform at - // the frame it was first cached with, so a teleport recenter strands - // the shell BSP at a stale world offset and house walls stop colliding - // (login at Arwic → portal to Holtburg → walls clip; bldOrigin ~5.5km off). - _physicsDataCache.RemoveBuildingsForLandblock(lb.LandblockId); - uint lbPrefix = lb.LandblockId & 0xFFFF0000u; foreach (var building in lbInfo.Buildings) { - // #147 (2026-06-24): do NOT skip portal-less buildings. A town's - // PERIMETER WALL is a building with NO portals (you don't enter a - // wall segment through a door) — but it still needs its shell BSP - // registered for COLLISION. The original skip existed for the - // transit/entry feature (CellTransit.CheckBuildingTransit); dropping - // the whole building also dropped its collision shell, so a far - // town's city walls had ZERO collision and the player walked through - // them (Arwic: 16 of 30 buildings are portal-less wall segments - // ringing the town at 24 m intervals). Retail collides with a - // building's shell regardless of portals — find_building_collisions - // (0x006b5300) tests parts[0], independent of the portal list. So a - // portal-less building is now cached with an EMPTY portal list (no - // transit) but its collision shell (ModelId) intact. + if (building.Portals.Count == 0) continue; var bldPortals = new System.Collections.Generic.List( building.Portals.Count); @@ -7609,7 +6137,7 @@ public sealed class GameWindow : IDisposable // _dats is non-null on every streaming-drain path // (the GfxObj physics cache loop below dereferences // it unconditionally). - datBundle.Setups.TryGetValue(building.ModelId, out var bldSetup); + var bldSetup = _dats!.Get(building.ModelId); shellPart0 = bldSetup is not null && bldSetup.Parts.Count > 0 ? bldSetup.Parts[0] : 0u; @@ -7678,14 +6206,13 @@ public sealed class GameWindow : IDisposable // (and _pendingCellMeshes drain) are retired with InstancedMeshRenderer. // Cache GfxObj physics data (BSP trees) for the physics engine — this // loop is physics-only, not renderer-side. - // [FRAME-DIAG] checkpoint: end cell-build, begin gfxobj-BSP. - if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyCellAccumTicks += t - fdCheck; fdCheck = t; } foreach (var entity in lb.Entities) { foreach (var meshRef in entity.MeshRefs) { if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue; - if (!datBundle.GfxObjs.TryGetValue(meshRef.GfxObjId, out var gfx)) continue; + var gfx = _dats.Get(meshRef.GfxObjId); + if (gfx is null) continue; _physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx); } } @@ -7693,8 +6220,6 @@ public sealed class GameWindow : IDisposable // The data is no longer consumed (WB handles EnvCell geometry through // its own pipeline), but the worker thread still populates this dict. _pendingCellMeshes.Clear(); - // [FRAME-DIAG] checkpoint: end gfxobj-BSP, begin ShadowObjects+lights. - if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyBspAccumTicks += t - fdCheck; fdCheck = t; } // Task 7: register static entities into the ShadowObjectRegistry so the // Transition system can find and collide against them during movement. @@ -7706,7 +6231,7 @@ public sealed class GameWindow : IDisposable // 2. Setup: use Setup.Radius (the capsule radius) if available. // 3. Fallback: 1.0m (conservative default for trees / small objects). int lbBspCount = 0, lbCylCount = 0, lbNoneCount = 0; - int scTried = 0; + int scTried = 0, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 0; foreach (var entity in lb.Entities) { // Phase G.2: if the entity's Setup has baked-in LightInfos, @@ -7721,15 +6246,14 @@ public sealed class GameWindow : IDisposable uint src = entity.SourceGfxObjOrSetupId; if ((src & 0xFF000000u) == 0x02000000u) { - datBundle.Setups.TryGetValue(src, out var datSetup); + var datSetup = _dats.Get(src); if (datSetup is not null && datSetup.Lights.Count > 0) { var loaded = AcDream.Core.Lighting.LightInfoLoader.Load( datSetup, ownerId: entity.Id, entityPosition: entity.Position, - entityRotation: entity.Rotation, - cellId: entity.ParentCellId ?? 0u); // A7 #176/#177: scope to the owning cell's visibility + entityRotation: entity.Rotation); foreach (var ls in loaded) _lightingSink.RegisterOwnedLight(ls); } @@ -7851,28 +6375,17 @@ public sealed class GameWindow : IDisposable // clobber entries via Deregister. { var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); - // Retail binary collision dispatch (CPhysicsObj::FindObjCollisions - // @0x0050f050, see feedback_retail_binary_dispatch): an object uses - // its physics BSP if it HAS one (HAS_PHYSICS_BSP_PS 0x10000), ELSE - // its CSetup CylSpheres/Spheres — never both. The per-part BSP loop - // above already registered every BSP-bearing part (entityBsp counts - // them). So register the Setup's CylSpheres/Spheres ONLY when no BSP - // claimed this object (entityBsp == 0). - // - // This supersedes the ISSUES #83 / A1.6 (2026-05-21) gate that - // skipped Setup cyl/sphere for ALL landblock stabs to stop a - // BSP+cylinder doubled-collision. That gate over-broadened: it also - // killed collision for BSP-LESS stabs whose ONLY shape is a Setup - // CylSphere — e.g. the Holtburg town torch (stab Setup 0x020005D8, - // part 0x01001774 HasPhysics=False/BSP=null, cylSphere r=0.2 h=2.2). - // Retail collides with it via that cylsphere (live cdb 2026-06-25: - // FindObjCollisions target ncyl=1, cyl h=2.2 at world (105.99,17.17)); - // acdream walked straight through. Gating on entityBsp==0 keeps #83's - // anti-doubling (stab WITH a BSP → BSP-only) while restoring cyl - // collision for BSP-less stabs. Other landblock entities on this path - // (scenery — tree-trunk cylspheres etc.) are unaffected: with no BSP - // they still register cyl/sphere exactly as before. - if (setup is not null && entityBsp == 0) + // ISSUES #83 / Phase A1.6 (2026-05-21): landblock stabs use BSP + // collision exclusively (retail CBuildingObj). Skip Setup-derived + // CylSphere/Sphere/Radius shadow registrations for stabs to + // prevent the same doubled-collision bug A1 fixed for the + // mesh-AABB-fallback — but on the Setup path instead. Without + // this gate, a stab whose source is 0x02xxxxxx (Setup) with + // defined CylSpheres registers BOTH per-part BSP shadows AND a + // CylSphere shadow with id = entity.Id, producing an invisible + // collision cylinder at the stab origin alongside the correct + // BSP walls. + if (setup is not null && !_isLandblockStab) { float entScale = entity.Scale > 0f ? entity.Scale : 1f; uint shapeIndex = 0; @@ -7970,6 +6483,207 @@ public sealed class GameWindow : IDisposable } } + // L-fix3 (2026-04-28): retail "decorative / phantom" detection. + // A Setup is phantom (no collision) when it has NO CylSpheres, + // NO Spheres, AND zero overall Radius. Small plants, grass + // tufts, flowers, ground-cover bushes all match — retail + // ships them with empty collision arrays so the player walks + // through them. Without this gate the mesh-bounds fallback + // below assigns every plant a 0.3 m+ collision cylinder + // (line ~4409 clamp) and they block the player. + // + // The gate is layered AFTER the Setup CylSphere / BSP + // registrations above (which are no-ops for phantom Setups + // anyway), so non-phantom scenery (trees with real + // CylSpheres or canopy-only BSPs) still gets the + // mesh-bounds fallback. The check is on entity.SourceGfxObjOrSetupId + // to look up the cached Setup; if it's a raw GfxObj + // (0x010xxxxx, no Setup metadata) we keep the old fallback + // behaviour because GfxObjs don't expose phantom intent. + bool isPhantomSetup = false; + if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) + { + var setupInfoForPhantom = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); + if (setupInfoForPhantom is not null + && setupInfoForPhantom.CylSpheres.Count == 0 + && setupInfoForPhantom.Spheres.Count == 0 + && setupInfoForPhantom.Radius <= 0.0001f) + { + isPhantomSetup = true; + } + } + + // Issue #101 (2026-05-25): retail-faithful phantom check for + // GfxObj-only entity sources. Retail's CPartArray::InitParts + // emits NO collision shapes when the source GfxObj has + // HasPhysics=False / null PhysicsBSP.Root. Our mesh-aabb-fallback + // below previously synthesized a clamped [0.30, 1.50]m cylinder + // from the visual mesh AABB — that's the source of the 10 + // phantom 0.80m cyls that block the Holtburg upper-floor + // staircase (issue #101). Suppress synthesis for these. + // Spec: docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md. + bool isPhantomGfxObj = _physicsDataCache.IsPhantomGfxObjSource(entity.SourceGfxObjOrSetupId); + + // VISUAL mesh-bounds collision: for SCENERY entities (IDs with + // 0x80000000 bit set, indicating procedurally-placed scenery), + // ALWAYS compute a cylinder from the world-space mesh AABB. + // This catches trees whose BSP is only on the canopy (player + // walks under) AND corrects CylSphere positioning issues caused + // by mesh files having vertices offset from the mesh origin. + // + // For stabs (low IDs) and live entities, keep the existing Setup + // CylSphere / BSP registrations — those are placed with precise + // frame data and don't have the scenery offset issue. + // + // L-fix3: skip entirely when the Setup is phantom — retail + // decorative meshes have no collision data on purpose. + if (!isPhantomSetup + && !isPhantomGfxObj + && !_isLandblockStab + && (_isOutdoorMesh || (entityBsp == 0 && entityCyl == 0)) + && entity.MeshRefs.Count > 0) + { + float entScale = entity.Scale > 0f ? entity.Scale : 1f; + bool haveBounds = false; + var worldMin = new System.Numerics.Vector3(float.MaxValue); + var worldMax = new System.Numerics.Vector3(float.MinValue); + + var entRoot = + System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) * + System.Numerics.Matrix4x4.CreateTranslation(entity.Position); + + // First pass: compute overall vertical extent in world Z. + float overallMinZ = float.MaxValue; + float overallMaxZ = float.MinValue; + foreach (var mr in entity.MeshRefs) + { + var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId); + if (vb is null || vb.Radius <= 0f) continue; + var partWorld = mr.PartTransform * entRoot; + for (int bi = 0; bi < 8; bi++) + { + var corner = new System.Numerics.Vector3( + (bi & 1) != 0 ? vb.Max.X : vb.Min.X, + (bi & 2) != 0 ? vb.Max.Y : vb.Min.Y, + (bi & 4) != 0 ? vb.Max.Z : vb.Min.Z); + var w = System.Numerics.Vector3.Transform(corner, partWorld); + if (w.Z < overallMinZ) overallMinZ = w.Z; + if (w.Z > overallMaxZ) overallMaxZ = w.Z; + } + } + + // Second pass: use TRUNK HEIGHT ONLY (bottom 25% of the mesh + // or first 2.5m, whichever is smaller) for horizontal radius. + // This gives us the trunk thickness — not the canopy spread. + // The Z extent still uses the full mesh (so tall trees have + // tall collision cylinders). + float trunkHeight = MathF.Min(2.5f, (overallMaxZ - overallMinZ) * 0.25f); + if (trunkHeight < 0.5f) trunkHeight = 0.5f; + float trunkTopZ = overallMinZ + trunkHeight; + + foreach (var mr in entity.MeshRefs) + { + var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId); + if (vb is null || vb.Radius <= 0f) continue; + + var partWorld = mr.PartTransform * entRoot; + // Only accumulate horizontal extents from corners within the + // trunk height range. Pass the full vertical extent through. + for (int bi = 0; bi < 8; bi++) + { + var corner = new System.Numerics.Vector3( + (bi & 1) != 0 ? vb.Max.X : vb.Min.X, + (bi & 2) != 0 ? vb.Max.Y : vb.Min.Y, + (bi & 4) != 0 ? vb.Max.Z : vb.Min.Z); + var w = System.Numerics.Vector3.Transform(corner, partWorld); + + // Always track vertical extent + if (w.Z < worldMin.Z) worldMin.Z = w.Z; + if (w.Z > worldMax.Z) worldMax.Z = w.Z; + + // Only track horizontal extent for TRUNK-level vertices + if (w.Z <= trunkTopZ) + { + if (w.X < worldMin.X) worldMin.X = w.X; + if (w.Y < worldMin.Y) worldMin.Y = w.Y; + if (w.X > worldMax.X) worldMax.X = w.X; + if (w.Y > worldMax.Y) worldMax.Y = w.Y; + haveBounds = true; + } + } + } + + if (haveBounds) + { + if (_isScenery) scHaveBounds++; + + // RADIUS: prefer the Setup's CylSphere radius (the retail + // trunk radius — thin, matches tree trunks). Fall back to + // Setup.Radius or mesh AABB if CylSphere is unavailable. + // Always scale by entity.Scale. + float entScaleLocal = entity.Scale > 0f ? entity.Scale : 1f; + float cylRadius = -1f; + float cylHeight; + + var setupInfo = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); + if (setupInfo is not null) + { + if (setupInfo.CylSpheres.Count > 0 && setupInfo.CylSpheres[0].Radius > 0f) + { + // Retail CylSphere — the definitive trunk collision. + cylRadius = setupInfo.CylSpheres[0].Radius * entScaleLocal; + } + else if (setupInfo.Radius > 0f) + { + // Setup.Radius — the overall bounding radius. For + // thin trunks this might be the full tree radius + // (canopy included) but often it's the trunk. + cylRadius = setupInfo.Radius * entScaleLocal; + } + } + + // Fall back to mesh AABB trunk-level radius if no Setup data. + if (cylRadius < 0f) + { + float halfX = (worldMax.X - worldMin.X) * 0.5f; + float halfY = (worldMax.Y - worldMin.Y) * 0.5f; + cylRadius = MathF.Max(halfX, halfY); + } + + // Clamp: retail AC trunks are 0.3-1.0m. Bigger radii (from + // the AABB fallback for canopy-heavy meshes) are clearly + // wrong; clamp to a reasonable tree-trunk maximum. + if (cylRadius < 0.3f) cylRadius = 0.3f; + if (cylRadius > 1.5f) cylRadius = 1.5f; + + // HEIGHT: use Setup.Height scaled, or mesh AABB vertical extent. + if (setupInfo is not null && setupInfo.Height > 0f) + cylHeight = setupInfo.Height * entScaleLocal; + else + cylHeight = MathF.Max(worldMax.Z - entity.Position.Z, cylRadius); + + // CENTER: entity.Position (the rendered root). + var baseCenter = new System.Numerics.Vector3( + entity.Position.X, entity.Position.Y, entity.Position.Z); + + _physicsEngine.ShadowObjects.Register( + entity.Id, + entity.SourceGfxObjOrSetupId, + baseCenter, entity.Rotation, cylRadius, + origin.X, origin.Y, lb.LandblockId, + AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight, + seedCellId: entity.ParentCellId ?? 0u); + // L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg]. + // state/flags literals: landblock-baked scenery; no server PhysicsState. + if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled) + Console.WriteLine(System.FormattableString.Invariant( + $"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=mesh-aabb-fallback state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}")); + entityCyl++; + if (_isScenery) scRegistered++; + } + else if (_isScenery) scNoBounds++; + } + // Tally per-entity collision presence (debug counter — optional). if (entityBsp > 0) lbBspCount++; if (entityCyl > 0) lbCylCount++; @@ -7985,7 +6699,8 @@ public sealed class GameWindow : IDisposable } if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && scTried > 0) Console.WriteLine( - $"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} (outdoorNone={lbNoneCount})"); + $"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} registered={scRegistered} " + + $"noBounds={scNoBounds} tooThin={scTooThin} (outdoorNone={lbNoneCount})"); // Find scenery WITHOUT any cached visual bounds at all int sceneryNoCache = 0; @@ -8034,39 +6749,10 @@ public sealed class GameWindow : IDisposable _worldGameState.Add(snapshot); _worldEvents.FireEntitySpawned(snapshot); } - // [FRAME-DIAG] checkpoint: end ShadowObjects+lights registration (apply tail). - if (_frameDiag) _applyShadowAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdCheck; } private void OnUpdate(double dt) { - using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update); - - // [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick; - // flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame - // boundary, independent of where in OnUpdate the applies landed) and reset. - if (_frameDiag) - { - if (_appliesThisUpdate > 0) - { - FrameDiagPush(_applyCpuSamples, ref _applyCpuSampleCursor, _applyAccumTicks); - FrameDiagPush(_applyUploadSamples, ref _applyUploadSampleCursor, _applyUploadAccumTicks); - FrameDiagPush(_applyCellSamples, ref _applyCellSampleCursor, _applyCellAccumTicks); - FrameDiagPush(_applyBspSamples, ref _applyBspSampleCursor, _applyBspAccumTicks); - FrameDiagPush(_applyShadowSamples, ref _applyShadowSampleCursor, _applyShadowAccumTicks); - FrameDiagPush(_applyLockWaitSamples, ref _applyLockWaitSampleCursor, _applyLockWaitAccumTicks); - if (_appliesThisUpdate > _frameDiagMaxAppliesPerUpdate) - _frameDiagMaxAppliesPerUpdate = _appliesThisUpdate; - } - _applyAccumTicks = 0; - _applyUploadAccumTicks = 0; - _applyCellAccumTicks = 0; - _applyBspAccumTicks = 0; - _applyShadowAccumTicks = 0; - _applyLockWaitAccumTicks = 0; - _appliesThisUpdate = 0; - } - // Phase A.1: advance the streaming controller FIRST so the initial // landblocks are loaded into GpuWorldState before live-session // CreateObject events drain. The earlier order (live tick first, @@ -8100,27 +6786,7 @@ public sealed class GameWindow : IDisposable int observerCx = _liveCenterX; int observerCy = _liveCenterY; - if (_playerMode && _playerController is not null - && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace) - { - // Teleport hold (#135): the local player position is frozen at the - // PRE-teleport spot, expressed in the OLD center frame, but - // _liveCenterX/_liveCenterY were already recentered onto the - // destination landblock (OnLivePositionUpdated). Follow the - // destination directly — the stale position-derived offset - // (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off - // the dungeon and trip ExitDungeonExpand, re-streaming the very - // neighbor window the pre-collapse just suppressed. Correct for an - // outdoor teleport too: pre-load the destination during the hold. - // - // NOTE: these assignments equal the observerCx/Cy defaults initialized - // above — the LOAD-BEARING effect of this branch is INHIBITING the - // position-derived offset in the else-if below while the player position - // is frozen, not the (redundant) assignment. Kept explicit for clarity. - observerCx = _liveCenterX; - observerCy = _liveCenterY; - } - else if (_playerMode && _playerController is not null) + if (_playerMode && _playerController is not null) { // Player mode: follow the physics-resolved player position. // The player walks via the local physics engine; the server @@ -8132,28 +6798,12 @@ public sealed class GameWindow : IDisposable observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); } else if (_liveSession is not null - && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld) + && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld + && _lastLivePlayerLandblockId is { } lid) { - // Live, not yet in player mode: the login auto-entry hold, or a live - // fly-camera spectator. Follow the PLAYER's server-known landblock; if it - // hasn't arrived yet, KEEP the _liveCenterX/_liveCenterY default — which is - // the spawn/teleport recenter (the dungeon landblock at a dungeon login). - // - // #135 regression fix (2026-06-14): this MUST NOT fall through to the - // fly-camera projection below. During a dungeon-login hold the streaming is - // pre-collapsed onto the spawn landblock; a camera-derived observer far from - // it trips ExitDungeonExpand and unloads the dungeon before it can hydrate — - // the player is never placed and login hangs with no dungeon. Previously - // _lastLivePlayerLandblockId was set by ANY entity, so a dungeon-local NPC - // kept this branch on the dungeon; once it was filtered to the player guid - // (line ~4507), a not-yet-arrived player UP dropped to the camera branch. - // The fly camera is the OFFLINE observer only. - if (_lastLivePlayerLandblockId is { } lid) - { - observerCx = (int)((lid >> 24) & 0xFFu); - observerCy = (int)((lid >> 16) & 0xFFu); - } - // else: keep the _liveCenterX/_liveCenterY default (the spawn recenter). + // Live mode (fly camera): follow the server's last-known player position. + observerCx = (int)((lid >> 24) & 0xFFu); + observerCy = (int)((lid >> 16) & 0xFFu); } else { @@ -8167,56 +6817,7 @@ public sealed class GameWindow : IDisposable observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); } - // Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell - // (indoor cell that doesn't see outside — the same predicate that kills - // the sun/sky, playerInsideCell below), collapse streaming to the single - // dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25 - // window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building - // interiors (cottage/inn) have SeenOutside cells, so they are NOT gated - // and keep their surrounding terrain. - // True only for a sealed indoor cell. Read the physics CurrCell's own - // SeenOutside (ObjCell.SeenOutside, set from the EnvCell dat flags) rather - // than the render registry: the registry lookup only succeeds AFTER the - // landblock FINALIZES (~tens of seconds for a 205-cell dungeon), which - // delayed the collapse and let the full 25×25 neighbor window churn in - // first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the - // moment the player is placed, so the collapse now engages at the snap. - // AP-36 / #145: during a teleport HOLD the player is unplaced and CurrCell is - // the frozen SOURCE cell. Suppress the source-cell gate so a teleport OUT of a - // dungeon follows the destination (the PortalSpace observer pin above) instead - // of staying pinned to the source dungeon — see DungeonStreamingGate. - bool isTeleportHold = - _playerController is { State: AcDream.App.Input.PlayerState.PortalSpace }; - var currCell = _physicsEngine.DataCache?.CellGraph.CurrCell; - bool currSealedDungeon = - currCell is AcDream.Core.World.Cells.EnvCell pcEnv && !pcEnv.SeenOutside; - var gate = AcDream.App.Streaming.DungeonStreamingGate.Compute( - isTeleportHold, currSealedDungeon, currCell?.Id ?? 0u); - bool insideDungeon = gate.InsideDungeon; - if (gate.ObserverLandblockKey is { } cellLb) - { - // Pin the collapse to the cell's OWN landblock (cell id high 16 bits), - // NOT the position-derived observer landblock. A dungeon's EnvCells sit - // at arbitrary world coords (the "ocean" placement) with negative local - // offsets, so floor(pp.Y/192) lands one landblock off — which collapses - // onto the WRONG landblock and unloads the real dungeon, nulling CurrCell - // and breaking the render (the Bug-A coordinate class). The cell id is the - // authoritative landblock. - observerCx = (int)((cellLb >> 8) & 0xFFu); - observerCy = (int)(cellLb & 0xFFu); - } - // Consume the login-spawn far-recenter flag (network thread → render - // thread): drop the stale startup window + null the region so this - // Tick re-bootstraps the whole window fresh around the spawn origin. - // Same mechanism the outdoor-teleport path uses (line ~5725). Fixes - // the cold-spawn streaming hole (resident-but-never-loaded landblocks - // the RecenterTo diff skipped as already-resident). - if (_pendingForceReloadWindow) - { - _pendingForceReloadWindow = false; - _streamingController.ForceReloadWindow(); - } - _streamingController.Tick(observerCx, observerCy, insideDungeon); + _streamingController.Tick(observerCx, observerCy); // Re-inject persistent entities rescued from unloaded landblocks // into the current center landblock (the one the observer is in). @@ -8236,69 +6837,11 @@ public sealed class GameWindow : IDisposable // Step 2: routed through the controller; functionally identical. _liveSessionController?.Tick(); - // Retail teleport transit. Runs AFTER streaming (which priority-applies the - // destination landblock this frame) and the live-session drain, so a destination - // that became resident this frame materializes the same frame. The TAS holds at - // Tunnel until worldReady, fires Place (materialize, hidden), then after the min- - // continue + fades fires FireLoginComplete (regain control + ack). The fade overlay - // is opaque black during the tunnel states (we render a fade, not the 3D swirl) and - // ramps the world back in on WorldFadeIn. - if (_teleportInProgress) - { - bool haveDest = _pendingTeleportCell != 0u; - bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell); - if (haveDest && !ready) - { - _teleportHoldSeconds += (float)dt; - if (_teleportHoldSeconds >= TeleportMaxHoldSeconds) - { - ready = true; - _teleportForced = true; - } - } - - var (snap, evts) = _teleportAnim.Tick((float)dt, ready); - _teleportFadeAlpha = snap.ShowTunnel ? 1f : snap.FadeAlpha; - - foreach (var e in evts) - { - switch (e) - { - case AcDream.Core.World.TeleportAnimEvent.Place: - PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced); - if (_streamingController is not null) - { - _streamingController.PriorityLandblockId = 0u; - _streamingController.PriorityRadius = 0; - } - break; - case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete: - if (_playerController is not null) - _playerController.State = AcDream.App.Input.PlayerState.InWorld; - // holtburger client/messages.rs:434 — re-send LoginComplete after - // each portal transition. - _liveSession?.SendGameAction( - AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); - _teleportInProgress = false; - _pendingTeleportCell = 0u; - _teleportFadeAlpha = 0f; - break; - default: - // PlayEnterSound / EnterTunnel / PlayExitSound — audio polish deferred. - break; - } - } - } - // Phase K.1a — tick the input dispatcher so Hold-type bindings // re-fire while their chord is held. K.1b adds the subscribers // that actually consume the events. _inputDispatcher?.Tick(); - // Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse - // on selection, then revert). No-op when nothing is flashing. - _selectedObjectController?.Tick(dt); - // Phase K.2 — re-evaluate WantCaptureMouse for the MMB // mouse-look state machine. Detect rising/falling edges so the // state suspends correctly when ImGui claims the cursor while @@ -8332,11 +6875,6 @@ public sealed class GameWindow : IDisposable // this guard adds defense-in-depth for the per-frame IsActionHeld // movement poll below (typing "walk" into a chat field shouldn't // walk). - // ImGui dev-tools text fields fully pause game input (incl. autorun) — fine, it's a - // debug overlay. The RETAIL chat "write mode" does NOT early-return here: the block - // below still runs so AUTORUN keeps driving the character while you type. Held WASD - // is silenced at the source instead — InputDispatcher.IsActionHeld returns false - // while WantCaptureKeyboard (which includes a focused chat input) is set. bool suppressGameInput = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard; if (suppressGameInput) return; @@ -8422,15 +6960,6 @@ public sealed class GameWindow : IDisposable ? localEnt.Id : 0u; - // R5-V2: tick the player's TargetManager BEFORE Update ticks - // MoveToManager.UseTime (retail UpdateObjectInternal order). This is - // the load-bearing call for creature-chase: the player, as a watched - // target, pushes its position to its voyeurs (the NPCs moving-to it) - // here — that push lands in each NPC's HandleUpdateTarget the same - // frame, ahead of the NPCs' own UseTime in the per-remote loop - // (which runs after this block). Replaces the AP-79 player poll. - _playerHost?.HandleTargetting(); - var result = _playerController.Update((float)dt, input); // Update the player entity's position + rotation so it renders at @@ -8446,39 +6975,11 @@ public sealed class GameWindow : IDisposable // so it doesn't get frustum-culled when the player walks away from // the spawn landblock. Without this, the entity stays in the spawn // landblock's entity list and disappears when that landblock is culled. - uint currentLb; - if (result.CellId != 0 && (result.CellId & 0xFFFFu) >= 0x0100u) - { - // Indoor cell (dungeon/building EnvCell): the entity's landblock is - // the CELL's landblock. Dungeon EnvCells sit at arbitrary "ocean" - // world coords with negative local-Y, so floor(pp.Y/192) lands one - // landblock off (the Bug-A class) — relocating the player into the - // landblock the dungeon collapse unloaded, making the avatar - // invisible. The cell id is authoritative. - currentLb = (result.CellId & 0xFFFF0000u) | 0xFFFFu; - } - else - { - var pp = _playerController.Position; - int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); - int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); - currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); - } - // #138-B (2026-06-24): do NOT relocate the avatar while a teleport - // is in transit (PortalSpace). The controller's cell is still the - // FROZEN SOURCE until PlaceTeleportArrival materializes the - // destination, so relocating here drags the avatar — which the - // teleport's rescue/re-inject (GpuWorldState.DrainRescued) already - // placed at the destination center — back into the now-UNLOADED - // SOURCE landblock's pending bucket, where nothing recovers it - // (RelocateEntity only scans _loaded). Net: the avatar vanishes - // after teleporting out. The teleport machinery owns the avatar's - // landblock during transit; per-frame relocation resumes at - // FireLoginComplete (InWorld). Probe-confirmed: launch4 line 561 - // APPEND guid=player lb=0x0007FFFF(source dungeon) -> PENDING -> - // DRAWSET ABSENT, never recovered. - if (_playerController.State != AcDream.App.Input.PlayerState.PortalSpace) - _worldState.RelocateEntity(pe, currentLb); + var pp = _playerController.Position; + int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); + int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); + uint currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); + _worldState.RelocateEntity(pe, currentLb); } // Update chase camera(s). The CameraController exposes whichever @@ -8542,64 +7043,47 @@ public sealed class GameWindow : IDisposable var wireRot = YawToAcQuaternion(_playerController.Yaw); byte contactByte = result.IsOnGround ? (byte)1 : (byte)0; - // 2026-05-16 (issue #75) / R4-V5: user-MoveToState packets - // are ONLY for user-initiated motion intent — retail's - // architectural split between user-input motion and - // server-driven motion. Post-V5 the split holds BY - // CONSTRUCTION: MotionStateChanged derives exclusively from - // input edges (the MoveToManager's dispatches never touch - // the controller's edge detector), so a manager-driven - // moveto produces no outbound MoveToState; the moment the - // user presses a key, the edge's CancelMoveTo chain kills - // the moveto and THAT state change legitimately goes on - // the wire ("user took control" — which is now true). The - // former !IsServerAutoWalking guard is redundant and gone - // with B.6. - if (result.MotionStateChanged) + // 2026-05-16 (issue #75): wire-layer semantic gate — + // user-MoveToState packets are ONLY for user-initiated + // motion intent. During server-controlled auto-walk + // (inbound MoveToObject), motion-state transitions + // come from the auto-walk's animation override, not + // from user input. Sending a MoveToState in that case + // would tell ACE "user took control" and cancel its + // own MoveToChain. This is NOT a band-aid like the + // earlier grace-period — it's the wire-layer's + // expression of retail's architectural split between + // user-input motion and server-driven motion: they + // share the local motion-state machine but only + // user-input flows back to the wire. Without the + // refactor (issue #75) this guard masked a synthesis + // leak; with the refactor it expresses the proper + // semantic. + if (result.MotionStateChanged && !_playerController.IsServerAutoWalking) { - // HoldKey axis values — retail enum (acclient.h enum - // HoldKey): Invalid = 0, None = 1, Run = 2. + // HoldKey axis values — retail enum (holtburger types.rs HoldKey): + // Invalid = 0, None = 1, Run = 2 // Retail always sends CURRENT_HOLD_KEY (and uses the same // value for every active per-axis hold key — see // holtburger's build_motion_state_raw_motion_state). - // When the player is running forward, Run; otherwise None. - var axisHoldKey = result.IsRunning - ? AcDream.Core.Physics.HoldKey.Run - : AcDream.Core.Physics.HoldKey.None; - - // D1/L.2b (2026-06-30): build the COMPLETE RawMotionState - // snapshot (matching retail's CPhysicsObj::InqRawMotionState()) - // and let RawMotionStatePacker's default-difference comparison - // decide what actually goes on the wire — see - // RawMotionStatePacker (ports RawMotionState::Pack, 0x0051ed10). - // This slice changes ONLY the packing; the state values - // below are exactly what the pre-slice code passed in. - var rawMotionState = new AcDream.Core.Physics.RawMotionState - { - CurrentHoldKey = axisHoldKey, - // CurrentStyle: not tracked by acdream today — leave at - // the retail default (0x8000003D) so the packer omits it. - ForwardCommand = result.ForwardCommand ?? AcDream.Core.Physics.RawMotionState.Default.ForwardCommand, - ForwardHoldKey = result.ForwardCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid, - // D6.2b: the wire carries the RAW forward_speed (1.0 for run/walk; - // the controller now sets outForwardSpeed=1.0). ACE recomputes the - // broadcast run speed from run skill (echo-confirmed 2026-07-01), so - // this is retail-faithful; 1.0 is omitted by default-difference packing. - ForwardSpeed = result.ForwardSpeed ?? AcDream.Core.Physics.RawMotionState.Default.ForwardSpeed, - SidestepCommand = result.SidestepCommand ?? AcDream.Core.Physics.RawMotionState.Default.SidestepCommand, - SidestepHoldKey = result.SidestepCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid, - SidestepSpeed = result.SidestepSpeed ?? AcDream.Core.Physics.RawMotionState.Default.SidestepSpeed, - TurnCommand = result.TurnCommand ?? AcDream.Core.Physics.RawMotionState.Default.TurnCommand, - TurnHoldKey = result.TurnCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid, - TurnSpeed = result.TurnSpeed ?? AcDream.Core.Physics.RawMotionState.Default.TurnSpeed, - // Actions: acdream does not yet emit discrete motion - // events on this path (D2, deferred to Phase 2+). - }; + // When the player is running forward, 2=Run; otherwise 1=None. + const uint HoldKeyNone = 1u; + const uint HoldKeyRun = 2u; + uint axisHoldKey = result.IsRunning ? HoldKeyRun : HoldKeyNone; var seq = _liveSession.NextGameActionSequence(); var body = AcDream.Core.Net.Messages.MoveToState.Build( gameActionSequence: seq, - rawMotionState: rawMotionState, + forwardCommand: result.ForwardCommand, + forwardSpeed: result.ForwardSpeed, + sidestepCommand: result.SidestepCommand, + sidestepSpeed: result.SidestepSpeed, + turnCommand: result.TurnCommand, + turnSpeed: result.TurnSpeed, + holdKey: axisHoldKey, // always present + forwardHoldKey: result.ForwardCommand.HasValue ? axisHoldKey : (uint?)null, + sidestepHoldKey: result.SidestepCommand.HasValue ? axisHoldKey : (uint?)null, + turnHoldKey: result.TurnCommand.HasValue ? axisHoldKey : (uint?)null, cellId: wireCellId, position: wirePos, rotation: wireRot, @@ -8607,25 +7091,15 @@ public sealed class GameWindow : IDisposable serverControlSequence: _liveSession.ServerControlSequence, teleportSequence: _liveSession.TeleportSequence, forcePositionSequence: _liveSession.ForcePositionSequence, - contact: contactByte != 0, - // Standing longjump is not implemented yet — honest - // current value, not a guess (D3). - standingLongjump: false); + contactLongJump: contactByte); DumpMovementTruthOutbound( "MTS", seq, result, wirePos, wireCellId, contactByte); _liveSession.SendGameAction(body); - // D5 audit (2026-06-30, this slice): retail's - // SendMovementEvent (acclient_2013_pseudo_c.txt, 0x006b4680) - // stamps ONLY last_sent_position_time after an MTS send — it - // does NOT update last_sent_position or - // last_sent_contact_plane (confirmed via Ghidra - // decompile-by-address during this slice). Only - // SendPositionEvent (the AP path, below) stamps all three. - // acdream's NotePositionSent call here stamps all three on - // BOTH paths — this is a real, audit-confirmed divergence - // from retail. Per this slice's scope (D5 = audit only, - // change only with explicit sign-off), left UNCHANGED; - // reported to the lead engineer instead of fixed here. + // B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so + // HeartbeatDue resets its interval from THIS send — mirrors retail's + // SendMovementEvent (acclient_2013_pseudo_c.txt:0x006b4680) writing + // last_sent_position_time + last_sent_position + contact_plane + // after each MTS send. _playerController.NotePositionSent( worldPos: _playerController.Position, cellId: _playerController.CellId, @@ -8651,13 +7125,9 @@ public sealed class GameWindow : IDisposable _liveSession.SendGameAction(body); // B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so // HeartbeatDue resets its interval from THIS send — mirrors retail's - // SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348, - // decomp address 0x006b4770, re-confirmed via the Ghidra - // decompile-by-address bridge during the L.2b slice 2026-06-30) + // SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348) // writing last_sent_position_time + last_sent_position + - // last_sent_contact_plane after each AP. Unlike the MTS path - // above (SendMovementEvent, 0x006b4680), which retail stamps - // ONLY last_sent_position_time, this AP path is correct. + // last_sent_contact_plane after each AP. _playerController.NotePositionSent( worldPos: _playerController.Position, cellId: _playerController.CellId, @@ -8667,17 +7137,11 @@ public sealed class GameWindow : IDisposable if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue) { - // D4/L.2b (2026-06-30): JumpPack::Pack (0x00516d10) packs the - // full Position, not an objectGuid/spellId — pass the same - // wireCellId/wirePos/wireRot the MoveToState send above uses. var seq = _liveSession.NextGameActionSequence(); var jumpBody = AcDream.Core.Net.Messages.JumpAction.Build( gameActionSequence: seq, extent: result.JumpExtent.Value, velocity: result.JumpVelocity.Value, - cellId: wireCellId, - position: wirePos, - rotation: wireRot, instanceSequence: _liveSession.InstanceSequence, serverControlSequence: _liveSession.ServerControlSequence, teleportSequence: _liveSession.TeleportSequence, @@ -8687,6 +7151,7 @@ public sealed class GameWindow : IDisposable } // Update the player entity's animation cycle to match current motion. + UpdatePlayerAnimation(result); } } @@ -8802,22 +7267,6 @@ public sealed class GameWindow : IDisposable 1f - 2f * (q.Y * q.Y + q.Z * q.Z)); } - /// - /// Exact inverse of : AC wire orientation → our internal - /// yaw (radians, 0=+X East). NOT the same as , which - /// returns the raw quaternion Z-angle (AC's theta = 450 - heading) — that carries a - /// 270° offset vs our yaw. Inverting the documented chain: theta = 450 - heading and - /// heading = 180 - yaw ⇒ yaw = thetaDeg - 270. Used to face the player the server-specified - /// way on a teleport arrival (retail drops you facing the portal's destination heading). - /// - private static float AcQuaternionToYaw(System.Numerics.Quaternion q) - { - float thetaDeg = ExtractYawFromQuaternion(q) * (180f / MathF.PI); - float yawDeg = thetaDeg - 270f; // 180 - (450 - thetaDeg) - float yaw = yawDeg * (MathF.PI / 180f); - return MathF.Atan2(MathF.Sin(yaw), MathF.Cos(yaw)); // normalize to (-PI, PI] - } - private void OnCameraModeChanged(bool _modeBool) { if (_input is null) return; @@ -8846,8 +7295,6 @@ public sealed class GameWindow : IDisposable private void OnRender(double deltaSeconds) { - _frameProfiler.FrameBoundary(_gl!); - // Phase G.1: set the clear color from the current sky's fog // tint so the horizon band continues naturally past the // rendered geometry. Fog blends to this color at max distance @@ -8900,16 +7347,7 @@ public sealed class GameWindow : IDisposable // GL thread queue). Must happen before any draw work so that // resources uploaded this frame are available immediately. // No-op when ACDREAM_USE_WB_FOUNDATION is off (_wbMeshAdapter is null). - // [FRAME-DIAG]: this OnRender drain is where staged GfxObj/entity meshes hit - // the GPU (uncapped) — the H2 entity-upload suspect, separate from the apply. - long fdE0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L; - using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload)) - { - _wbMeshAdapter?.Tick(); - } - if (_frameDiag) - FrameDiagPush(_entityUploadSamples, ref _entityUploadSampleCursor, - System.Diagnostics.Stopwatch.GetTimestamp() - fdE0); + _wbMeshAdapter?.Tick(); // Phase D.2a — begin ImGui frame. Paired with the Render() call // after the scene draws (below). ImGuiController.Update() @@ -9089,24 +7527,7 @@ public sealed class GameWindow : IDisposable // consumes binding=1 reads the same data for the rest of the // frame — terrain, static mesh, instanced mesh, sky. UpdateSunFromSky(kf, playerInsideCell); - // A7 indoor lighting: position retail's viewer fill light at the player - // (SmartBox::set_viewer 0x00452c40) before the snapshot is built. It is - // the primary interior fill (no sun indoors) and is indoor-only via the - // AP-43 gate. playerViewPos is the player render position (or camPos when - // there is no player), matching retail's player/viewer branch. - Lighting.UpdateViewerLight(playerViewPos); Lighting.Tick(camPos); - - // Fix B (A7 #3): build this frame's point-light snapshot and hand it to - // the entity dispatcher for per-OBJECT light selection - // (minimize_object_lighting). Replaces the single global nearest-8-to- - // camera UBO set for point/spot lights so a wall's torches stay tied to - // the wall as the camera moves. The SUN + ambient still flow through the - // SceneLighting UBO built below (binding=1) — terrain/sky read those. - Lighting.BuildPointLightSnapshot(camPos); - _wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot); - _envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2) - var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( Lighting, in atmo, camPos, (float)WorldTime.DayFraction); @@ -9133,25 +7554,6 @@ public sealed class GameWindow : IDisposable _sceneLightingUbo?.Upload(ubo); - // #133 A7 (2026-06-13): objective dungeon-lighting probe. One - // rate-limited [light] line — insideCell / ambient / sun / - // registered-point-lights / active-slot-count / player cell — so - // the dungeon-dim question is self-verifiable from launch.log - // without a screenshot. RegisteredCount is point/spot lights only - // (the sun lives in LightManager.Sun, never in the _all list); - // ubo.CellAmbient.W is the shader active-slot count, which counts - // the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1. - AcDream.Core.Rendering.RenderingDiagnostics.EmitLight( - insideCell: playerInsideCell, - ambientR: Lighting.CurrentAmbient.AmbientColor.X, - ambientG: Lighting.CurrentAmbient.AmbientColor.Y, - ambientB: Lighting.CurrentAmbient.AmbientColor.Z, - sunIntensity: Lighting.Sun?.Intensity ?? 0f, - registeredLights: Lighting.RegisteredCount, - activeLights: (int)ubo.CellAmbient.W, - playerCellId: playerRoot?.CellId ?? 0u, - lights: Lighting); - // Never cull the landblock the player is currently on. uint? playerLb = null; if (_playerMode && _playerController is not null) @@ -9170,15 +7572,13 @@ public sealed class GameWindow : IDisposable var envCellViewProj = camera.View * camera.Projection; _envCellFrustum?.Update(envCellViewProj); - // MP-Alloc: reuse _animatedIdsScratch instead of `new`ing a - // HashSet every frame. Downstream consumers (WbDrawDispatcher. - // WalkEntitiesInto) treat null and an empty set identically, so an - // always-non-null (possibly empty) set is behaviorally the same as - // the old null-when-nothing-animated local. - _animatedIdsScratch.Clear(); - foreach (var k in _animatedEntities.Keys) - _animatedIdsScratch.Add(k); - HashSet? animatedIds = _animatedIdsScratch; + HashSet? animatedIds = null; + if (_animatedEntities.Count > 0) + { + animatedIds = new HashSet(_animatedEntities.Count); + foreach (var k in _animatedEntities.Keys) + animatedIds.Add(k); + } // Phase G.1: sky renderer — draws the far-plane-infinity // celestial meshes FIRST so the rest of the scene z-tests @@ -9409,11 +7809,6 @@ public sealed class GameWindow : IDisposable CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, Camera = camera, CameraWorldPosition = camPos, - // A7 #176/#177: once DrawInside has resolved the visible-cell set, - // rebuild the point-light pool from ONLY those cells' lights (retail's - // per-frame add_*_lights over visible_cell_table). The renderers hold a - // reference to the same PointSnapshot list, rebuilt in place here. - RebuildScopedLights = visible => Lighting.BuildPointLightSnapshot(camPos, visible), Frustum = frustum, PlayerLandblockId = playerLb, AnimatedEntityIds = animatedIds, @@ -9813,35 +8208,6 @@ public sealed class GameWindow : IDisposable SkipWorldGeometry: ; } - // Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the - // viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window - // is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle). - // The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state. - if (_options.RetailUi && _paperdollViewportRenderer is not null - && _paperdollViewportWidget is { Visible: true } dollWidget - && _inventoryFrame is { Visible: true }) - { - if (_paperdollDollDirty && RefreshPaperdollDoll()) - _paperdollDollDirty = false; - dollWidget.TextureHandle = _paperdollViewportRenderer.Render( - (int)dollWidget.Width, (int)dollWidget.Height); - } - - // Phase D.2b — retail-look UI tree (render-only; input integration deferred). - // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own - // blend/depth state and restores. Drawn before ImGui so the devtools - // overlay composites on top during development. - if (_options.RetailUi && _uiHost is not null) - { - _uiHost.Tick(deltaSeconds); - _uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); - } - - // Teleport fade cover (retail-teleport spec C). Drawn AFTER the world + retail UI so - // it covers them during a transit; the ImGui devtools below composite on top so they - // stay visible for debugging. Alpha is 0 outside a teleport → Draw is a no-op. - _fadeOverlay?.Draw(_teleportFadeAlpha); - // Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws // so ImGui composites on top. ImGuiController save/restores the // GL state it touches (blend, scissor, VAO, shader, texture); any @@ -9921,10 +8287,7 @@ public sealed class GameWindow : IDisposable // panels but still over the 3D scene. Cheap when no // selection — internal early-return on null guid. _targetIndicator?.Render(); - using (var _imguiStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui)) - { - _imguiBootstrap.Render(); - } + _imguiBootstrap.Render(); } // Update the window title with performance stats every ~0.5s. @@ -9996,18 +8359,14 @@ public sealed class GameWindow : IDisposable { var ae = kv.Value; - // The server guid is carried on the entity itself - // (WorldEntity.ServerGuid, set == the _entitiesByServerGuid key at - // construction — see OnLiveEntitySpawnedLocked: `ServerGuid = spawn.Guid` - // and `_entitiesByServerGuid[spawn.Guid] = entity`). Read it directly — - // O(1) — instead of the former reverse ReferenceEquals scan over ALL of - // _entitiesByServerGuid, which made this loop O(animated × all-entities). - // That super-linear cost is the FPS-drops-per-teleport sink: world - // entities accumulate without bound (pruned only on DeleteObject, and ACE - // does not re-send / clear KnownObjects across a teleport), so N climbs - // every hop. ServerGuid defaults to 0 for entities not server-tracked, - // exactly matching the old scan's miss case. - uint serverGuid = ae.Entity.ServerGuid; + // Locate the server guid for this entity once per tick — needed + // for dead-reckoning. O(N) reverse lookup; for player populations + // < 100 the cost is negligible. + uint serverGuid = 0; + foreach (var esg in _entitiesByServerGuid) + { + if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; } + } // ── Dead-reckoning: smooth position between UpdatePosition bursts. // The server broadcasts UpdatePosition at ~5-10Hz for distant @@ -10025,30 +8384,8 @@ public sealed class GameWindow : IDisposable if (ae.Sequencer is not null && serverGuid != 0 && serverGuid != _playerServerGuid - && _remoteDeadReckon.TryGetValue(serverGuid, out var rm) - // R4-V5 door fix companion: rm entries now also exist for - // static animated objects (doors/levers — created on first - // UM so the funnel can play their On/Off cycles). Those - // must NOT enter this dead-reckoning block: it force- - // asserts ground contact, zeroes/integrates velocity, and - // ground-resolves the body — position machinery for - // entities the server MOVES. "Has ever received an - // UpdatePosition" is exactly that distinction (there is - // nothing to dead-reckon between UPs that don't exist); - // their animation still plays via the sequencer's own - // TickAnimations advance. - && rm.LastServerPosTime > 0) + && _remoteDeadReckon.TryGetValue(serverGuid, out var rm)) { - // R5-V2: retail UpdateObjectInternal ticks TargetManager:: - // HandleTargetting UNCONDITIONALLY per entity, BEFORE the - // movement managers' UseTime. This is where this entity, as a - // watched target, pushes its position to its voyeurs (any entity - // moving-to it), and where its own target-info staleness times - // out. Runs for every remote regardless of the grounded/airborne - // branch below (which drive MoveToManager.UseTime via - // TickRemoteMoveTo). No-op for entities with no target + no voyeurs. - rm.Host?.HandleTargetting(); - if (IsPlayerGuid(serverGuid) && !rm.Airborne) { // ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ── @@ -10105,21 +8442,6 @@ public sealed class GameWindow : IDisposable rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active; } - // R4-V5 glide fix (2026-07-03 user report: a retail - // player using an object GLIDES to it — position moves - // via the UP queue but no walk/run legs): remote - // PLAYERS' MoveToManagers were armed by mt-6 but never - // TICKED — the V4 UseTime slot lived only in the - // NPC/legacy branch below, gated !IsPlayerGuid - // (inherited from the deleted RemoteMoveToDriver's - // NPC-only scope). Retail ticks every entity's - // MovementManager (UpdateObjectInternal has no entity- - // class fork). The manager's dispatches produce the - // locomotion cycle through the funnel sink (the LEGS); - // position stays queue-chased per the L.3 M2 spec, so - // the two compose exactly like an NPC's tick. - TickRemoteMoveTo(rm); - // Step 2 (M3): queue + anim translation via PositionManager. // ComputeOffset returns: // - Vector3.Zero when queue is empty AND seqVel is zero @@ -10154,29 +8476,7 @@ public sealed class GameWindow : IDisposable interp: rm.Interp, maxSpeed: maxSpeed, terrainNormal: terrainNormal); - // R5-V3 (#171): retail chains Interpolation → Sticky over - // ONE shared delta frame (PositionManager::adjust_offset - // 0x00555190). The combiner's catch-up IS acdream's - // interpolation stage, so its offset SEEDS the frame - // (converted to mover-local) and StickyManager:: - // adjust_offset OVERWRITES it when armed+initialized - // (0x00555430 assigns m_fOrigin rather than accumulating). - // With no stick armed the frame comes back untouched and - // this reduces to the pre-V3 `Position += offset`. - if (rm.Host is { } plHost) - { - var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame - { - Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec( - rm.Body.Orientation, offset), - }; - plHost.PositionManager.AdjustOffset(pmDelta, dt); - ApplyPositionManagerDelta(rm.Body, pmDelta); - } - else - { - rm.Body.Position += offset; - } + rm.Body.Position += offset; // Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1. // Prints per-tick body Z trajectory + queue state + projected @@ -10332,47 +8632,7 @@ public sealed class GameWindow : IDisposable rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact | AcDream.Core.Physics.TransientStateFlags.OnWalkable | AcDream.Core.Physics.TransientStateFlags.Active; - // #170 residual fix (2026-07-04): an ARMED moveto always - // takes the MOVETO branch. The old arbitration routed the - // tick to SERVERVEL whenever UPs flowed (position-delta - // synthesis sets HasServerVelocity=true for any moving - // NPC), which SKIPPED MoveToManager.UseTime for exactly - // the duration of the server-side chase — the armed - // manager only ever ticked in UP-silent gaps (creature - // stopped server-side), turned toward a stale heading, - // and was interrupted by the next UM before reaching - // BeginMoveForward. Live funnel (launch-drainq.log, - // corrected per-guid attribution): 16 arms → 11 turns - // dispatched → 1 run install; [npc-tick] shows - // branch=SERVERVEL (skips UseTime) mtState=MoveToObject - // for chasing scamps. The legs stayed in Ready while the - // body glided on synthesized velocity — the #170 slide. - // Retail runs MovementManager::UseTime UNCONDITIONALLY - // per tick (CPhysicsObj::UpdateObjectInternal 0x005156b0, - // call at 0x00515998) and has NO wire-velocity leg-driver; - // between UPs a moveto-driven body translates from the - // motion state (get_state_velocity) with UP hard-snaps - // correcting drift. The SERVERVEL leg remains ONLY as the - // legacy fallback for entities WITHOUT an armed moveto - // (scripted-path NPCs / missiles carrying wire velocity). - // Full-stack conformance: RemoteChaseEndToEndHarnessTests - // (turn→run→drain sustained when the manager is ticked). - bool moveToArmed = rm.MoveTo is - { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }; - // R5-V3 (#171): a STUCK entity is also client-side- - // driven — after the sticky arrival the moveto is - // cleaned (Invalid) but StickyManager::adjust_offset - // owns the between-snap translation exactly like an - // armed moveto. Routing it to SERVERVEL would glide - // the body on synthesized wire velocity AGAINST the - // sticky steer (the same starvation class as the #170 - // SERVERVEL fix — register TS-41). Retail has no - // SERVERVEL leg at all; it stays the fallback for - // entities with NO client-side movement driver. - bool stickyArmed = - (rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u; - if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity - && !moveToArmed && !stickyArmed) + if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity) { double velocityAge = nowSec - rm.LastServerPosTime; if (velocityAge > ServerControlledVelocityStaleSeconds) @@ -10391,49 +8651,94 @@ public sealed class GameWindow : IDisposable rm.Body.Velocity = rm.ServerVelocity; } } + else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive + && rm.HasMoveToDestination) + { + // Phase L.1c port of retail MoveToManager per-tick + // steering (HandleMoveToPosition @ 0x00529d80). + // Steer body orientation toward the latest + // server-supplied destination, then let + // apply_current_movement set Velocity from the + // RunForward cycle through the now-correct heading. + + // Stale-destination guard (2026-04-28): if no + // MoveTo packet has refreshed the destination + // recently, the entity has likely left our + // streaming view or the server cancelled the + // move without us seeing the cancel UM. Continuing + // to steer toward a stale point produces the + // "monster runs in place after popping back into + // view" symptom. Clear and stand down. + double moveToAge = nowSec - rm.LastMoveToPacketTime; + if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds) + { + rm.HasMoveToDestination = false; + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + else + { + var driveResult = AcDream.Core.Physics.RemoteMoveToDriver + .Drive( + rm.Body.Position, + rm.Body.Orientation, + rm.MoveToDestinationWorld, + rm.MoveToMinDistance, + rm.MoveToDistanceToObject, + (float)dt, + rm.MoveToMoveTowards, + out var steeredOrientation); + rm.Body.Orientation = steeredOrientation; + + if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver + .DriveResult.Arrived) + { + // Within arrival window — zero velocity until the + // next MoveTo packet refreshes the destination + // (or the server explicitly stops us with an + // interpreted-motion UM cmd=Ready). + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } + else + { + // Steering active — apply_current_movement reads + // InterpretedState.ForwardCommand=RunForward (set + // when the MoveTo packet arrived) and emits + // velocity along +Y in body local space. Our + // updated orientation rotates that into the right + // world direction toward the target. + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + + // Clamp horizontal velocity so we don't overshoot + // the arrival threshold during the final tick of + // approach. Without this, a 4 m/s body advances + // ~6 cm/tick and visibly runs slightly through + // the target before the swing UM lands. + float arrivalThreshold = rm.MoveToMoveTowards + ? rm.MoveToDistanceToObject + : rm.MoveToMinDistance; + rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver + .ClampApproachVelocity( + rm.Body.Position, + rm.Body.Velocity, + rm.MoveToDestinationWorld, + arrivalThreshold, + (float)dt, + rm.MoveToMoveTowards); + } + } + } + else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) + { + // MoveTo flag set but we haven't seen a path payload + // yet (e.g. truncated packet, or a brand-new entity + // whose first cycle UM is still in flight). Hold + // velocity at zero — same conservative stance as the + // 882a07c stabilizer for incomplete state. + rm.Body.Velocity = System.Numerics.Vector3.Zero; + } else { - // R4-V4: the retail MoveToManager drives - // server-directed movement per tick — UseTime runs - // HandleMoveToPosition/HandleTurnToHeading (steering - // + arrival + fail-distance), dispatching its OWN - // per-node locomotion (turn / RunForward) through the - // sink. That per-node dispatch is the only motion the - // per-tick update should issue for a remote. - TickRemoteMoveTo(rm); - - // #170 ROOT FIX (2026-07-04): the per-frame - // rm.Motion.apply_current_movement(...) call that used - // to be here is DELETED. For a remote (which has a - // DefaultSink) it re-ran ApplyInterpretedMovement EVERY - // FRAME, re-dispatching the whole interpreted state — - // stance (0x8000003C), forward=attack, sidestep/turn - // stops — and each successful DoInterpretedMotion - // appends a CMotionInterp.pending_motions node. Those - // nodes barely drain for a remote, so pending_motions - // EXPLODED to ~1.3M entries (live capture: add=1.37M vs - // done=5.7K; ~671K of them the unchanged stance). - // MotionsPending() then stayed permanently true, so - // MoveToManager.BeginTurnToHeading (0x00529b90, - // `if (motions_pending) return`) could never start the - // chase turn → the chase never reached BeginMoveForward - // → RunForward, so the creature SLID in an idle+attack - // pose instead of running. Retail dispatches per MOTION - // EVENT (per UM), never per frame — a live cdb drain - // trace showed retail add_to_queue == MotionDone (queue - // stays shallow). The motion is already dispatched per - // UM by the funnel (MoveToInterpretedState) and by the - // MoveToManager per node above; the only thing the - // deleted call still provided was body velocity, which - // the get_state_velocity refresh below performs - // directly. get_state_velocity (0x00527d50, verbatim) - // returns WalkForward→3.12×spd, RunForward→4.0×spd, 0 - // otherwise; grounded-only (this branch already asserted - // Contact+OnWalkable), airborne velocity owns the jump. - if (rm.Body.OnWalkable) - rm.Body.set_local_velocity( - rm.Motion.get_state_velocity(), - rm.Body.LastMoveWasAutonomous); + rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); } } else @@ -10486,20 +8791,6 @@ public sealed class GameWindow : IDisposable // here to prevent UpdatePhysicsInternal's own omega pass from // double-integrating. var preIntegratePos = rm.Body.Position; - // R5-V3 (#171): the sticky steer — retail - // UpdatePositionInternal's PositionManager::adjust_offset - // slot (0x00512c30, call @0x00512d0e): the delta composes - // into this tick's motion BEFORE UpdatePhysicsInternal + - // the transition sweep below, so collision resolves the - // sticky movement like any other motion (preIntegratePos - // is captured first — the sweep covers the steer). No-op - // when nothing is stuck (untouched delta frame). - if (rm.Host is { } npcHost) - { - var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame(); - npcHost.PositionManager.AdjustOffset(pmDelta, dt); - ApplyPositionManagerDelta(rm.Body, pmDelta); - } rm.Body.calc_acceleration(); rm.Body.UpdatePhysicsInternal(dt); var postIntegratePos = rm.Body.Position; @@ -10525,16 +8816,14 @@ public sealed class GameWindow : IDisposable if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0) { // Sphere dims match local-player defaults (human Setup - // 0x02000001: sphere radius 0.480, capsule top 1.835 = - // Setup.Height; see the #137 TS-46 note at the - // PlayerMovementController call). Good enough for + // bounds — ~0.48m radius, ~1.2m height). Good enough for // grounded humanoid remotes; can be setup-derived later // if creatures of wildly different sizes need different // collision profiles. var resolveResult = _physicsEngine.ResolveWithTransition( preIntegratePos, postIntegratePos, rm.CellId, sphereRadius: 0.48f, - sphereHeight: 1.835f, + sphereHeight: 1.2f, stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f // K-fix9 (2026-04-26): mirror the K-fix7 gate — @@ -10562,55 +8851,6 @@ public sealed class GameWindow : IDisposable if (resolveResult.CellId != 0) rm.CellId = resolveResult.CellId; - // #173 (2026-07-05): retail CPhysicsObj::handle_all_collisions - // (pc:282699-282715) runs after EVERY SetPositionInternal — - // remote objects included; a VectorUpdate-launched jump arc - // is ordinary object physics in retail. acdream ported the - // velocity reflection for the LOCAL player only (L.3a, - // PlayerMovementController ~:940), so a remote jumping into - // a dungeon ceiling had its POSITION pinned by the sweep - // while its +Z velocity kept integrating — the char hovered - // at the roof until gravity burned the arc off, landing - // late (user report, 0x0007 dungeon). Mirror the local - // site exactly: - // v_new = v − (1 + elasticity)·dot(v, n)·n - // with the AD-25 suppression (bounce only when airborne - // before AND after — corridor slides and landings don't - // reflect; the landing snap below keeps its - // `Velocity.Z <= 0` gate intact). Inelastic movers - // (missiles, later) zero out instead. - if (resolveResult.CollisionNormalValid) - { - bool prevOnWalkable = rm.Body.OnWalkable; - bool nowOnWalkable = resolveResult.IsOnGround; - bool applyBounce = rm.Body.State.HasFlag( - AcDream.Core.Physics.PhysicsStateFlags.Sledding) - ? !(prevOnWalkable && nowOnWalkable) - : (!prevOnWalkable && !nowOnWalkable); - if (applyBounce) - { - if (rm.Body.State.HasFlag( - AcDream.Core.Physics.PhysicsStateFlags.Inelastic)) - { - rm.Body.Velocity = System.Numerics.Vector3.Zero; - } - else - { - var vRem = rm.Body.Velocity; - var nRem = resolveResult.CollisionNormal; - float dotVN = System.Numerics.Vector3.Dot(vRem, nRem); - if (dotVN < 0f) - { - rm.Body.Velocity = - vRem + nRem * (-(dotVN * (rm.Body.Elasticity + 1f))); - if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - Console.WriteLine( - $"VU.bounce guid=0x{serverGuid:X8} n=({nRem.X:F2},{nRem.Y:F2},{nRem.Z:F2}) vZ {vRem.Z:F2}->{rm.Body.Velocity.Z:F2}"); - } - } - } - } - // K-fix15 (2026-04-26): post-resolve landing // detection for airborne remotes. Mirrors // PlayerMovementController's local-player landing @@ -10632,35 +8872,38 @@ public sealed class GameWindow : IDisposable rm.Airborne = false; rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact | AcDream.Core.Physics.TransientStateFlags.OnWalkable; + rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; rm.Body.Velocity = new System.Numerics.Vector3( rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); - // #161: HitGround MUST run with the Gravity state - // bit still set — CMotionInterp::HitGround - // (0x00528ac0) gates on state&0x400 (retail never - // clears GRAVITY on landing; it's a persistent - // object property). Clearing it first made this - // re-apply a silent no-op, which is why the - // falling pose never exited. The re-apply - // dispatches the PRESERVED pre-fall forward - // command through the funnel → the motion table - // plays the Falling→X landing link. (The old - // K-fix17 forced SetCycle is deleted: it read the - // then-clobbered InterpretedState.ForwardCommand - // — 0x40000015 — and re-set the very Falling - // cycle it meant to clear.) - // R4-V5 (closes the V4 wiring-contract gap the - // adversarial review caught): retail order — - // minterp first, then moveto (MovementManager:: - // HitGround 0x00524300, §2d — the R5-V5 facade - // relay). Re-arms a moveto suspended by the - // airborne UseTime contact gate; without it a - // chasing NPC that lands stalls until ACE's - // ~1 Hz re-emit. - rm.Movement.HitGround(); - // DR bookkeeping only (partner of the jump-start - // `State |= Gravity`): stops the per-tick gravity - // integration for the grounded body. - rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; + rm.Motion.HitGround(); + + // K-fix17 (2026-04-26): reset the sequencer cycle + // from Falling back to whatever the interpreted + // motion state says they should be doing now. + // Without this, the remote stays in the Falling + // pose forever (legs folded) until the next + // server-sent UpdateMotion arrives. Use the + // sequencer's current style (preserved across + // jump) and pick the cycle from + // InterpretedState.ForwardCommand: Ready + // (idle), WalkForward, RunForward, WalkBackward. + // SideStep / Turn aren't strict locomotion + // priorities — the next UM the server sends will + // refine the cycle if the player is mid-strafe + // when they land; this just gets the legs out + // of Falling immediately. + if (ae.Sequencer is not null) + { + uint style = ae.Sequencer.CurrentStyle != 0 + ? ae.Sequencer.CurrentStyle + : 0x8000003Du; + uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; + if (landingCmd == 0) + landingCmd = AcDream.Core.Physics.MotionCommand.Ready; + float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; + if (landingSpeed <= 0f) landingSpeed = 1f; + ae.Sequencer.SetCycle(style, landingCmd, landingSpeed); + } if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); @@ -10672,15 +8915,6 @@ public sealed class GameWindow : IDisposable ae.Entity.ParentCellId = rm.CellId; ae.Entity.Rotation = rm.Body.Orientation; } - - // R5-V3 (#171): retail UpdateObjectInternal tail — - // PositionManager::UseTime (0x005156b0, call @0x005159b3, - // right after CPartArray::HandleMovement, UNCONDITIONAL for - // every entity in both grounded and airborne branches): the - // sticky 1 s lease watchdog (StickyManager::UseTime - // 0x00555610 — a stick not re-issued by a fresh server arm - // within 1 s tears itself down). No-op while nothing is stuck. - rm.Host?.PositionManager.UseTime(); } // ── Get per-part (origin, orientation) from either sequencer or legacy ── @@ -10729,53 +8963,10 @@ public sealed class GameWindow : IDisposable } seqFrames = ae.Sequencer.Advance(dt); - // R3-W2: bind the MotionDone seam once per sequencer to the - // entity's REAL consumer — the MotionInterpreter's - // pending_motions pop (retail CPhysicsObj::MotionDone - // 0x0050fdb0 → MovementManager → CMotionInterp::MotionDone - // 0x00527ec0; r3-port-plan.md §4). Remotes bind their - // RemoteMotion interp; the local player binds the - // controller's; interp-less entities (doors, statics) keep - // the diagnostic recorder only. - if (ae.Sequencer.MotionDoneTarget is null) - { - uint mdGuid = serverGuid; - // Resolve the interp AT FIRE TIME, not bind time: a - // remote's RemoteMotion is created on its first UM/UP, - // which can arrive AFTER the first anim tick — an - // eagerly-captured null would silently drop completions - // forever. MotionDone fires per completed motion (rare), - // so the dictionary lookup is negligible. - ae.Sequencer.MotionDoneTarget = (m, ok) => - { - AcDream.Core.Physics.MotionInterpreter? interp = null; - if (mdGuid != 0 && mdGuid == _playerServerGuid) - interp = _playerController?.Motion; - else if (mdGuid != 0 - && _remoteDeadReckon.TryGetValue(mdGuid, out var rmFire)) - interp = rmFire.Motion; - - interp?.MotionDone(m, ok); - if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - System.Console.WriteLine( - $"[MOTIONDONE] guid={mdGuid:X8} motion=0x{m:X8} success={ok} " - + $"pending={(interp?.MotionsPending() ?? false)}"); - }; - } - // Phase E.1: drain animation hooks (footstep sounds, attack // damage frames, particle spawns, part swaps, etc.) and fan // them out to registered subsystems via the hook router. // Mirrors ACE's PhysicsObj.add_anim_hook dispatch path. - // - // R2-Q4 queue-drain wiring (r2-port-plan.md §4, the G6 seam; - // per-tick PLACEMENT provisional until R6 installs retail's - // UpdateObjectInternal order): each drained AnimDone hook - // advances the entity's MotionTableManager countdown - // (retail AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone - // 0x0050fda0 → CPartArray::AnimationDone(1)), and UseTime - // runs once per tick to sweep zero-tick queue entries - // (retail call sites 0x00517d57/0x00517d67). var hooks = ae.Sequencer.ConsumePendingHooks(); if (hooks.Count > 0) { @@ -10785,11 +8976,8 @@ public sealed class GameWindow : IDisposable var hook = hooks[hi]; if (hook is null) continue; _hookRouter.OnHook(ae.Entity.Id, worldPos, hook); - if (hook is DatReaderWriter.Types.AnimationDoneHook) - ae.Sequencer.Manager.AnimationDone(success: true); } } - ae.Sequencer.Manager.UseTime(); } else { @@ -10862,13 +9050,7 @@ public sealed class GameWindow : IDisposable } } - // MP-Alloc (2026-07-05): reuse the entity's cached MeshRefs list - // instead of allocating a fresh List(partCount) every - // tick (see AnimatedEntity.MeshRefsScratch for the safety - // argument — single-threaded tick-before-draw ordering, no - // consumer caches a stale reference or diffs list identity). - var newMeshRefs = ae.MeshRefsScratch; - newMeshRefs.Clear(); + var newMeshRefs = new List(partCount); var scaleMat = ae.Scale == 1.0f ? System.Numerics.Matrix4x4.Identity : System.Numerics.Matrix4x4.CreateScale(ae.Scale); @@ -10947,21 +9129,197 @@ public sealed class GameWindow : IDisposable }); } - // Re-assigning the SAME list reference every tick is cheap (a - // property store) and keeps this line's shape identical to the - // pre-MP-Alloc code for anyone grepping the history. ae.Entity.MeshRefs = newMeshRefs; } } - // R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is - // driven through the SAME MotionTableDispatchSink/DefaultSink funnel - // remotes use (edge-driven DoMotion/StopMotion/set_hold_run in - // PlayerMovementController; airborne-Falling falls out of - // contact_allows_move + apply_current_movement). The #45 sidestep - // 1.248x factor + ACDREAM_ANIM_SPEED_SCALE died with it — - // EXPECTED-DIFF: local sidestep pacing now matches how remotes have - // always played (w6-cutover-map.md R3). + /// + /// Phase B.2: switch the locally-controlled player entity's animation cycle + /// to match the current motion command. Only re-resolves when the command + /// actually changes (forward → run, idle → walk, etc.) to avoid re-building + /// the animation entry every frame. + /// + /// + /// Action motions (Jump, FallDown, emotes, attacks) are routed through + /// — they + /// live in the motion table's Modifiers dict, not the Cycles dict, and + /// are inserted into the queue on top of the current cycle instead of + /// replacing it. + /// + /// + private void UpdatePlayerAnimation(AcDream.App.Input.MovementResult result) + { + if (_dats is null) return; + + // ── Airborne SubState (Falling) ──────────────────────────────────── + // + // Retail models the jump-animation as a SubState swap to + // MotionCommand.Falling (0x40000015) while airborne, NOT as an + // Action overlay. Empirically verified: Links[(NonCombat,RunForward)] + // has 3 transitions including 0x40000015 Falling. The SubState cycle + // for Falling lives in Cycles[(style, Falling)] and loops while + // airborne. On land, we transition back to whatever SubState the + // motion input implies (Ready / WalkForward / RunForward). + // + // Implementation: force animCommand = Falling when airborne; the + // existing SetCycle pathway resolves the link + cycle correctly and + // the transition back happens naturally when airborne becomes false. + + // Determine the animation command: airborne takes priority (Falling + // SubState), then forward, sidestep, turn, then idle (Ready 0x41000003). + // + // Airborne → Falling (retail behavior; see airborne note above). + // Otherwise: LocalAnimationCommand (RunForward when running) preferred, + // falling back to wire ForwardCommand (WalkForward / WalkBackward). + uint animCommand; + if (!result.IsOnGround) + animCommand = AcDream.Core.Physics.MotionCommand.Falling; + else if (result.LocalAnimationCommand is { } localCmd) + animCommand = localCmd; + else if (result.ForwardCommand is { } fwd) + animCommand = fwd; + else if (result.SidestepCommand is { } ss) + animCommand = ss; + else if (result.TurnCommand is { } tc) + animCommand = tc; + else + animCommand = 0x41000003u; // Ready (idle) + + // Fast path: no command change AND speed delta is negligible. If + // command is unchanged but speed changed, we must still propagate + // so the sequencer can MultiplyCyclicFramerate — keeping the run + // loop smooth without restart. + // K-fix5 (2026-04-26): use LocalAnimationSpeed (cycle pace) NOT + // ForwardSpeed (wire field) — backward+run + strafe+run keep + // ForwardSpeed/SidestepSpeed at 1.0 for ACE compatibility but + // need the local cycle to play at runRate × so the animation + // matches the actual movement velocity. + float newSpeed = result.LocalAnimationSpeed; + bool sameCmd = animCommand == _playerCurrentAnimCommand; + bool sameSpeed = MathF.Abs(newSpeed - _playerCurrentAnimSpeed) < 1e-3f; + if (sameCmd && sameSpeed) return; + _playerCurrentAnimCommand = animCommand; + _playerCurrentAnimSpeed = newSpeed; + + if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return; + + // The player entity may not be in _animatedEntities if a post-spawn + // UpdateMotion removed it (Phase 6.8 pattern). In that case, load + // the Setup and re-register. This is the player's own character so + // we always want it animated in player mode. + if (!_animatedEntities.TryGetValue(pe.Id, out var ae)) + { + // A.5 T10: lock around _dats.Get — worker thread may be + // building a landblock mesh concurrently. DatBinReader's + // shared buffer position would corrupt without serialization. + DatReaderWriter.DBObjs.Setup? setup; + lock (_datLock) { setup = _dats.Get(pe.SourceGfxObjOrSetupId); } + if (setup is null) return; + _physicsDataCache.CacheSetup(pe.SourceGfxObjOrSetupId, setup); + + // Build a minimal part template from the entity's current MeshRefs. + var template = new (uint, IReadOnlyDictionary?)[pe.MeshRefs.Count]; + for (int i = 0; i < pe.MeshRefs.Count; i++) + template[i] = (pe.MeshRefs[i].GfxObjId, pe.MeshRefs[i].SurfaceOverrides); + + ae = new AnimatedEntity + { + Entity = pe, + Setup = setup, + Animation = null!, // filled below + LowFrame = 0, + HighFrame = 0, + Framerate = 30f, + Scale = 1f, + PartTemplate = template, + CurrFrame = 0f, + }; + _animatedEntities[pe.Id] = ae; + } + + // The motion table cycle key is (style << 16) | (command & 0xFFFFFF). + // Without a stance override, the resolver uses the table default + // (which always maps to the idle/Ready cycle regardless of command). + // Pass the NonCombat stance (0x003D) so the resolver builds the + // correct cycle key for walk/run/turn commands. + ushort cmdOverride = (ushort)(animCommand & 0xFFFFu); + const ushort NonCombatStance = 0x003D; + var cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( + ae.Setup, _dats, + motionTableIdOverride: _playerMotionTableId, + stanceOverride: NonCombatStance, + commandOverride: cmdOverride); + + // Sequencer path: SetCycle handles adjust_motion internally + // (TurnLeft→TurnRight with negative speed, etc.) + // + // Speed scaling: K-fix5 (2026-04-26) — use LocalAnimationSpeed + // (the PlayerMovementController-computed cycle pace) instead of + // the wire ForwardSpeed. Forward+Run = runRate; Backward+Run = + // runRate (where ForwardSpeed is the ACE-compatible 1.0); + // Strafe+Run = runRate (where SidestepSpeed is 1.0). Anything + // not in run = 1.0. The animation cycle now visually matches + // the movement velocity in every direction. + if (ae.Sequencer is not null) + { + uint fullStyle = 0x80000000u | (uint)NonCombatStance; + float animSpeed = result.LocalAnimationSpeed > 0f + ? result.LocalAnimationSpeed + : 1f; + // ACDREAM_ANIM_SPEED_SCALE: optional visual-pacing knob. Retail's + // animation framerate scales linearly with speedMod (r03 §8.3), + // and our speedMod = runRate. If the visual feel doesn't match + // retail, override via env var (default 1.0 = no change). + float animScale = 1.0f; + if (float.TryParse( + Environment.GetEnvironmentVariable("ACDREAM_ANIM_SPEED_SCALE"), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, + out var s) && s > 0f) + { + animScale = s; + } + // K-fix18 (2026-04-26): when transitioning into Falling + // (jump start), skip the link so the legs engage Falling + // immediately. Without this the local player visibly + // stood still for ~100 ms at the start of every jump + // while the RunForward→Falling transition link drained. + // For everything else (Walk → Run, Run → Ready, etc.) we + // keep the link so transitions stay smooth. + bool skipLink = animCommand == AcDream.Core.Physics.MotionCommand.Falling; + + // #45 (2026-05-06): scale sidestep speedMod to match ACE's + // wire formula. PlayerMovementController hands us a raw + // localAnimSpeed (1.0 slow / runRate fast), but ACE's + // BroadcastMovement converts SidestepSpeed via + // `speed × 3.12 / 1.25 × 0.5` + // (Network/Motion/MovementData.cs:124-131). Without the + // matching multiplier here, the local sidestep cycle plays + // at speedMod = 1.0 while the observer-side cycle plays at + // ~1.248 — local strafe visibly slower than retail (user + // report at #45 close-out of #39). + // Factor = WalkAnimSpeed / SidestepAnimSpeed × 0.5 + // = 3.12 / 1.25 × 0.5 = 1.248. + uint cmdLow = animCommand & 0xFFu; + if (cmdLow == 0x0Fu /* SideStepRight */ || cmdLow == 0x10u /* SideStepLeft */) + { + animSpeed *= AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed + / AcDream.Core.Physics.MotionInterpreter.SidestepAnimSpeed + * 0.5f; + } + + ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * animScale, + skipTransitionLink: skipLink); + } + + // Legacy path: update the manual slerp fields (for entities without sequencer) + if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return; + ae.Animation = cycle.Animation; + ae.LowFrame = Math.Max(0, cycle.LowFrame); + ae.HighFrame = Math.Min(cycle.HighFrame, cycle.Animation.PartFrames.Count - 1); + ae.Framerate = cycle.Framerate; + ae.CurrFrame = ae.LowFrame; + } /// /// Phase 3a — re-roll the active DayGroup whenever the current @@ -12141,7 +10499,6 @@ public sealed class GameWindow : IDisposable state: _worldState, nearRadius: _nearRadius, farRadius: _farRadius, - clearPendingLoads: _streamer.ClearPendingLoads, removeTerrain: id => { if (_lightingSink is not null && @@ -12382,13 +10739,6 @@ public sealed class GameWindow : IDisposable switch (action) { - case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel: - // Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it - // does not fire while the chat input holds focus. Null _uiHost = - // retail UI off (ACDREAM_RETAIL_UI unset) → no-op. - _uiHost?.ToggleWindow(AcDream.App.UI.WindowNames.Inventory); - break; - case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel: foreach (var panel in EnumerateDebugPanel()) { @@ -12502,6 +10852,7 @@ public sealed class GameWindow : IDisposable _playerController = null; _chaseCamera = null; _retailChaseCamera = null; + _playerCurrentAnimCommand = null; } else _window!.Close(); @@ -12630,7 +10981,7 @@ public sealed class GameWindow : IDisposable if (picked is uint guid) { - SelectedGuid = guid; + _selectedGuid = guid; string label = DescribeLiveEntity(guid); Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); // B.7 (2026-05-15): one-shot per-pick diagnostic so we can @@ -12638,7 +10989,9 @@ public sealed class GameWindow : IDisposable // RadarBlipColor are produced for the just-picked entity. // Helps verify whether a "green NPC" really is flagged as // Vendor server-side or whether our lookup is wrong. - uint rawItemType = (uint)LiveItemType(guid); + uint rawItemType = 0; + if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)) + rawItemType = (uint)info.ItemType; uint pwdBits = 0; uint? pickUseability = null; float? pickUseRadius = null; @@ -12695,7 +11048,8 @@ public sealed class GameWindow : IDisposable // Retail string at acclient_2013_pseudo_c.txt:1033115 // (data_7e2a70): "The %s cannot be used". - bool isCreature = (LiveItemType(sel) & AcDream.Core.Items.ItemType.Creature) != 0; + bool isCreature = _liveEntityInfoByGuid.TryGetValue(sel, out var info) + && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0; if (isCreature) { @@ -12740,16 +11094,15 @@ public sealed class GameWindow : IDisposable return; } - // B.6/R4-V5: install a speculative local TurnToObject/MoveToObject - // through the player's MoveToManager so close-range Use rotates the - // body to face before the action fires. For FAR targets, ACE's - // CreateMoveToChain (Player_Move.cs:37-179) takes over via inbound - // MovementType=6, whose PerformMovement re-targets with ACE's - // wire-supplied radius. + // B.6 (2026-05-15): install speculative local auto-walk against + // the target so close-range Use rotates the body to face before + // the action fires. For FAR targets, ACE's CreateMoveToChain + // (Player_Move.cs:37-179) takes over via inbound MovementType=6 + // and our overlay is overwritten by ACE's wire-supplied radius. // - // Close-range deferral fires the wire packet ONCE on - // MoveToComplete(None) (turn-first done), not a retry of an - // earlier failed send. No re-send path. + // 2026-05-16: simplified — close-range deferral now fires the + // wire packet ONCE on AutoWalkArrived (turn-first done), not a + // retry of an earlier failed send. No re-send path. bool closeRange = IsCloseRangeTarget(guid); InstallSpeculativeTurnToTarget(guid); @@ -12782,20 +11135,6 @@ public sealed class GameWindow : IDisposable } } - // Phase D.5.1 — direct use-by-guid for toolbar shortcut clicks. - // Mirrors the B.4b far-range send path; no proximity / auto-walk needed - // for items already in the player's inventory. - private void UseItemByGuid(uint guid) - { - if (_liveSession is null - || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld) - return; - var seq = _liveSession.NextGameActionSequence(); - var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid); - _liveSession.SendGameAction(body); - Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={seq}"); - } - private void SendPickUp(uint itemGuid) { if (_liveSession is null @@ -12866,14 +11205,13 @@ public sealed class GameWindow : IDisposable } /// - /// 2026-05-16 / R4-V5. Fires the deferred close-range Use/PickUp action - /// once the player's moveto completes naturally (the MoveToManager's - /// MoveToComplete(None) client seam — the body has finished - /// rotating to face / walking to the target; a user-input cancel never - /// fires it). This is a FIRST send — not a retry of an earlier failed - /// send. Far-range Use/PickUp paths fire the wire packet immediately at - /// / time and never touch - /// _pendingPostArrivalAction. + /// 2026-05-16. Fires the deferred close-range Use/PickUp action + /// once the local auto-walk overlay reports arrival (i.e. the body + /// has finished rotating to face the target). Unlike the old + /// OnAutoWalkArrivedReSendAction, this is a FIRST send — not a + /// retry of an earlier failed send. Far-range Use/PickUp paths + /// fire the wire packet immediately at / time + /// and never touch _pendingPostArrivalAction. /// private void OnAutoWalkArrivedSendDeferredAction() { @@ -12938,7 +11276,8 @@ public sealed class GameWindow : IDisposable // Mirror InstallSpeculativeTurnToTarget's per-type radius heuristic. float useRadius = 0.6f; - if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0) + if (_liveEntityInfoByGuid.TryGetValue(targetGuid, out var info) + && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0) { useRadius = 3.0f; } @@ -12958,16 +11297,15 @@ public sealed class GameWindow : IDisposable private void InstallSpeculativeTurnToTarget(uint targetGuid) { - if (_playerController is not { } pc || pc.MoveTo is null) return; + if (_playerController is null) return; if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity)) return; // Per-type use radius — same heuristic as the picker's - // radiusForGuid callback (register AP-23, re-anchored here by - // R4-V5; survives because ACE's close-branch broadcasts nothing - // actionable). + // radiusForGuid callback. float useRadius = 0.6f; - if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0) + if (_liveEntityInfoByGuid.TryGetValue(targetGuid, out var info) + && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0) { useRadius = 3.0f; } @@ -12980,14 +11318,13 @@ public sealed class GameWindow : IDisposable } // Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit - // from local distance so the speculative moveto uses the + // from local distance so the speculative auto-walk uses the // same walk/run as the wire-triggered overwrite that arrives // moments later. ACE's Creature.SetWalkRunThreshold sets // CanCharge when player→target distance >= WalkRunThreshold / // 2 = 7.5 m (the 15 m wire default halved). Match exactly so // the speculative install doesn't flip walk↔run when ACE's - // MoveToObject broadcast overwrites it (PerformMovement - // cancels + restarts — retail-consistent re-target). + // MoveToObject broadcast overwrites it. const float AceCanChargeDistance = 7.5f; var bodyPos = _playerController.Position; float ddx = entity.Position.X - bodyPos.X; @@ -12995,52 +11332,12 @@ public sealed class GameWindow : IDisposable float distToTarget = MathF.Sqrt(ddx * ddx + ddy * ddy); bool speculativeCanCharge = distToTarget >= AceCanChargeDistance; - // R4-V5: retail's client-initiated use flow issues TurnToObject / - // MoveToObject through the SAME manager the wire path uses (decomp - // §9a/§9b callers) — in-range targets get a pure turn-to-face; - // out-of-range targets get the local moveto that ACE's mt-6 - // broadcast will re-target moments later. MovementParameters ctor - // defaults (0x1EE0F: MoveTowards, UseSpheres, threshold 15, - // MinDistance 0) match the old BeginServerAutoWalk install's - // semantics; only the AP-23 radius + the #77 CanCharge prediction - // are non-default. - var p = new AcDream.Core.Physics.Motion.MovementParameters - { - DistanceToObject = useRadius, - CanCharge = speculativeCanCharge, - }; - var ms = new AcDream.Core.Physics.MovementStruct - { - ObjectId = targetGuid, - TopLevelId = targetGuid, - Pos = new AcDream.Core.Physics.Position( - _playerController.CellId, entity.Position, - System.Numerics.Quaternion.Identity), - Params = p, - Type = IsCloseRangeTarget(targetGuid) - ? AcDream.Core.Physics.MovementType.TurnToObject - : AcDream.Core.Physics.MovementType.MoveToObject, - }; - // R5-V3 (#171, diff-review find): retail resolves the TARGET's - // PartArray radius/height at EVERY MoveToObject call site — the - // client-initiated use flow included (CPhysicsObj::MoveToObject - // 0x005128e9/0x00512903), not just the wire mt-6 route. Without - // this, the player's now-real own radius made the UseSpheres - // arrival hybrid (centerDist − playerRadius ≤ useRadius) — the - // auto-walk stopped ~one player-radius farther out than the AP-23 - // constants intended, and the two MoveToObject sites disagreed. - (ms.Radius, ms.Height) = GetSetupCylinder(targetGuid, entity); - // Part of this install's AP-23 adaptation: store the autonomy flag - // exactly as the wire mt-6 this install anticipates would (the P1 - // unpack store, IsAutonomous=false) — without it the per-tick - // pump's A3 dispatch stays on the raw branch (the user's last input - // set it autonomous) and clobbers this moveto's dispatched motions - // with the idle raw state. - _playerController.SetLastMoveWasAutonomous(false); - // R5-V5: through the facade (MovementManager::PerformMovement - // 0x005240d0) — retail's client-initiated use flow reaches the same - // manager the wire path uses. - pc.Movement.PerformMovement(ms); + _playerController.BeginServerAutoWalk( + destinationWorld: entity.Position, + minDistance: 0f, + distanceToObject: useRadius, + moveTowards: true, + canCharge: speculativeCanCharge); } private uint? SelectClosestCombatTarget(bool showToast) @@ -13065,7 +11362,7 @@ public sealed class GameWindow : IDisposable bestGuid = guid; } - SelectedGuid = bestGuid; + _selectedGuid = bestGuid; if (bestGuid is { } selected) { string label = DescribeLiveEntity(selected); @@ -13089,42 +11386,10 @@ public sealed class GameWindow : IDisposable return false; if (!_entitiesByServerGuid.ContainsKey(guid)) return false; - - return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0; - } - - // PublicWeenieDesc _bitfield flags (acclient.h:6431-6463) — same bitfield RadarBlipColors reads. - private const uint BfPlayer = 0x8u; // BF_PLAYER (acclient.h:6434) - private const uint BfAttackable = 0x10u; // BF_ATTACKABLE (acclient.h:6437) - - /// - /// True if the selected-object strip should show a Health meter for . - /// Approximates retail's IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable() - /// gate (gmToolbarUI::HandleSelectionChanged :198754) using the server-provided PWD flags: - /// the BF_ATTACKABLE bit (monsters) or the BF_PLAYER bit (other players). - /// A friendly NPC (e.g. a vendor) has neither bit set → name-only, matching retail. - /// The full PK/faction logic of ObjectIsAttackable + the pet case are not ported (divergence AP-46). - /// - private bool IsHealthBarTarget(uint guid) - { - if (guid == _playerServerGuid) - return false; - if (!_entitiesByServerGuid.ContainsKey(guid)) + if (!_liveEntityInfoByGuid.TryGetValue(guid, out var info)) return false; - uint pwd = _lastSpawnByGuid.TryGetValue(guid, out var spawn) - && spawn.ObjectDescriptionFlags is { } odf ? odf : 0u; - - // Another player → health bar (retail IsPlayer branch). - if ((pwd & BfPlayer) != 0) - return true; - - // Attackable branch: retail ObjectIsAttackable requires the object to be a CREATURE - // first (InqType() & 0x10, acclient_2013_pseudo_c.txt:375406), THEN attackable. A Door - // carries the BF_ATTACKABLE bit but is ItemType Misc, so it is never a health-bar target — - // require the Creature flag here too (matches retail; excludes attackable doors/objects). - bool isCreature = (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0; - return isCreature && (pwd & BfAttackable) != 0; + return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0; } @@ -13291,7 +11556,8 @@ public sealed class GameWindow : IDisposable // `ItemUseable = null`; without the fallback the M1 "click NPC" // flow regresses. The diagnostic line below lets us measure // how often this branch fires in real play. - if ((LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0) + if (_liveEntityInfoByGuid.TryGetValue(guid, out var info) + && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0) { if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled) Console.WriteLine(System.FormattableString.Invariant( @@ -13399,15 +11665,11 @@ public sealed class GameWindow : IDisposable return (it & SmallItemMask) != 0u; } - private AcDream.Core.Items.ItemType LiveItemType(uint guid) => - Objects.Get(guid)?.Type ?? AcDream.Core.Items.ItemType.None; - - private string? LiveName(uint guid) => Objects.Get(guid)?.Name; - private string DescribeLiveEntity(uint guid) { - var name = LiveName(guid); - if (!string.IsNullOrWhiteSpace(name)) return name!; + if (_liveEntityInfoByGuid.TryGetValue(guid, out var info) + && !string.IsNullOrWhiteSpace(info.Name)) + return info.Name!; return $"0x{guid:X8}"; } @@ -13444,6 +11706,7 @@ public sealed class GameWindow : IDisposable _playerController = null; _chaseCamera = null; _retailChaseCamera = null; + _playerCurrentAnimCommand = null; _playerMouseDeltaX = 0f; } } @@ -13484,35 +11747,6 @@ public sealed class GameWindow : IDisposable return unhydratable; } - // #135: is this server-sent cell id a SEALED dungeon EnvCell — an indoor cell - // (low 16 bits >= 0x0100) whose EnvCell dat flags lack SeenOutside? Distinguishes - // a real dungeon (collapse streaming to its single landblock) from a building - // interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from - // an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as - // the hydration path (BuildLoadedCell, ~line 5999) and as the physics - // CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse - // decision matches the eventual gate decision exactly. Returns false when the dat - // lacks the cell (out-of-range index / missing record) so we never collapse on a - // guess. The dat read is reentrant-safe under _datLock (Monitor) — callers may - // already hold it (the login spawn handler does). - private bool IsSealedDungeonCell(uint cellId) - { - // Not an EnvCell: the sub-0x0100 outdoor sub-cells AND the 0xFFFE/0xFFFF - // structural shell ids (LandBlockInfo / LandBlock heightmap). A naive - // `< 0x0100` test MISSES 0xFFFF (65535 is not < 256), and Get on - // 0xXXYYFFFF would then type-confuse the LandBlock record living at that id as - // an EnvCell (its bytes unpack to a bogus Flags value). A real spawn/teleport - // position never carries a shell id, but exclude them so the read is sound. - uint low = cellId & 0xFFFFu; - if (low < 0x0100u || low >= 0xFFFEu) return false; - if (_dats is null) return false; - DatReaderWriter.DBObjs.EnvCell? envCell; - lock (_datLock) - envCell = _dats.Get(cellId); - return envCell is not null - && !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside); - } - private void EnterPlayerModeFromAutoEntry() { _playerMode = true; @@ -13586,138 +11820,10 @@ public sealed class GameWindow : IDisposable _playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine); - // R4-V5: the local player's verbatim MoveToManager — same seam - // wiring shape as EnsureRemoteMotionBindings, with three - // player-specific differences: (a) heading reads/writes go through - // the controller's Yaw (the authoritative facing the body - // quaternion is re-derived from every Update; a quaternion-only - // set_heading would be overwritten next frame) via the P5-pinned - // yaw↔heading bridge; (b) the contact seam reads the REAL Contact - // transient bit (retail UseTime gates transient_state & 1 — - // remotes force-assert Contact+OnWalkable every grounded tick, so - // OnWalkable was equivalent there); (c) isInterpolating is false — - // the local player has no InterpolationManager. Own radius/height - // are the real setup cylsphere values (R5-V3 — lazy reads because - // this method caches the player Setup a few paragraphs further - // down). setHeading's - // `send` flag is currently UNCONSUMED (register TS-33): the AP - // heartbeat diffs position/plane/cell but not orientation (retail's - // Frame::is_equal compares the full frame), so a stationary heading - // snap doesn't reach the wire — masked against ACE, which rotates - // server-side on its own turn paths; the full-frame diff lands with - // the R7 outbound-cadence port. - var pcMoveTo = _playerController; - // R5-V2: forward-declared so the player MoveToManager's target seams - // route into the player's TargetManager (retail CPhysicsObj::set_target). - EntityPhysicsHost playerHost = null!; - // R5-V5: the construction is the player MovementManager's - // MoveToFactory (same facade shape as EnsureRemoteMotionBindings); - // MakeMoveToManager() below invokes it once, after playerHost exists - // for the sticky binds. - _playerController.Movement.MoveToFactory = () => - { - var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager( - pcMoveTo.Motion, - stopCompletely: () => pcMoveTo.Motion.StopCompletely(), - getPosition: () => new AcDream.Core.Physics.Position( - pcMoveTo.CellId, pcMoveTo.Position, pcMoveTo.BodyOrientation), - getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw), - setHeading: (h, _) => pcMoveTo.Yaw = - AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h), - getOwnRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, - getOwnHeight: () => GetSetupCylinder(_playerServerGuid, playerEntity).Height, - contact: () => pcMoveTo.BodyInContact, - isInterpolating: () => false, - getVelocity: () => pcMoveTo.BodyVelocity, - getSelfId: () => _playerServerGuid, - // R5-V2: retail CPhysicsObj::set_target/clear_target/quantum → the - // player's TargetManager (replaces the AP-79 _playerMoveToTarget* poll). - setTarget: (ctx, tlid, radius, q) => playerHost.SetTarget(ctx, tlid, radius, q), - clearTarget: () => playerHost.ClearTarget(), - getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(), - setTargetQuantum: q => playerHost.TargetManager.SetTargetQuantum(q), - curTime: () => pcMoveTo.SimTimeSeconds); - - // AD-27 re-anchored (was the deleted AutoWalkArrived event): fire - // the deferred close-range Use/PickUp action when the moveto - // completes NATURALLY (MoveToComplete is the documented client - // seam; it never fires on CancelMoveTo, so a user-input cancel - // doesn't send the action — same contract the old "arrived"-only - // event had). - playerMoveTo.MoveToComplete = err => - { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled) - Console.WriteLine($"[autowalk-end] reason=complete err={err}"); - if (err == AcDream.Core.Physics.WeenieError.None) - OnAutoWalkArrivedSendDeferredAction(); - }; - - // R5-V3 (#171, retires TS-39 — player side): bind the sticky - // seams to the player host's PositionManager (same trio as the - // remote bind: BeginNextNode arrival StickTo @0x00529d3a, - // PerformMovement-head Unstick). - playerMoveTo.StickTo = (tlid, radius, height) => - playerHost.PositionManager.StickTo(tlid, radius, height); - playerMoveTo.Unstick = playerHost.PositionManager.UnStick; - return playerMoveTo; - }; - - // R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is - // the player's WORLD position (WorldEntity.Position — what the AP-79 - // poll used), so an NPC watching the player receives the identical - // position. Registered in _physicsHosts so those NPCs' GetObjectA - // resolves the player; HandleTargetting is ticked in the player - // pre-Update block. - playerHost = new EntityPhysicsHost( - _playerServerGuid, - getPosition: () => new AcDream.Core.Physics.Position( - pcMoveTo.CellId, - _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pSelf) - ? pSelf.Position : pcMoveTo.Position, - pcMoveTo.BodyOrientation), - getVelocity: () => pcMoveTo.BodyVelocity, - getRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius, - inContact: () => pcMoveTo.BodyInContact, - minterpMaxSpeed: () => pcMoveTo.Motion.GetMaxSpeed(), - curTime: () => pcMoveTo.SimTimeSeconds, - physicsTimerTime: () => pcMoveTo.SimTimeSeconds, - getObjectA: ResolvePhysicsHost, - // Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head: - // MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade - // relay); the host chains the PositionManager leg after it. - handleUpdateTarget: info => _playerController?.Movement.HandleUpdateTarget(info), - interruptCurrentMovement: () => _playerController?.Movement.CancelMoveTo( - AcDream.Core.Physics.WeenieError.ActionCancelled)); - _playerHost = playerHost; - _physicsHosts[_playerServerGuid] = playerHost; - - // R5-V5: retail MakeMoveToManager (0x00524000) — constructs the - // player MoveToManager via the factory above (playerHost now exists - // for the sticky binds inside it). The UM-funnel-head unstick - // (CPhysicsObj::unstick_from_object 0x0050eaea) binds on the interp - // beside it, and the controller takes the PositionManager handoff it - // drives at the retail UpdatePositionInternal/UpdateObjectInternal - // points (AdjustOffset/UseTime). - var playerMovement = _playerController.Movement; - playerMovement.MakeMoveToManager(); - _playerController.Motion.UnstickFromObject = playerHost.PositionManager.UnStick; - _playerController.PositionManager = playerHost.PositionManager; - - // TS-36 RETIRED: the interp's interrupt seam is retail's - // interrupt_current_movement → MovementManager::CancelMoveTo(0x36) - // chain (raw 278189-278200) — since R5-V5 the literal facade relay - // (0x005241b0). Every DoMotion/StopMotion/StopCompletely/jump/ - // set_hold_run cancel site now genuinely cancels a running moveto - // (V2's reentrancy tests prove the chain is inert-safe). Captures - // THIS controller's facade (not the _playerController field) so a - // Tab-toggle's stale interp keeps cancelling its own manager. - _playerController.Motion.InterruptCurrentMovement = () => - { - if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled - && playerMovement.IsMovingTo()) - Console.WriteLine("[autowalk-end] reason=interrupt"); - playerMovement.CancelMoveTo(AcDream.Core.Physics.WeenieError.ActionCancelled); - }; + // B.6/B.7 (2026-05-16): fire the deferred close-range Use/PickUp + // action (first send, not a retry) when the local auto-walk overlay + // reports arrival (body finished rotating to face the target). + _playerController.AutoWalkArrived += OnAutoWalkArrivedSendDeferredAction; // K-fix7 (2026-04-26): if PlayerDescription already arrived, the // server's Run / Jump skill values are cached here — push them @@ -13798,47 +11904,10 @@ public sealed class GameWindow : IDisposable int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f); pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u; } - // R4-V5 moveto-stall fix #2 (2026-07-03, the [autowalk-gate] probe's - // one-immortal-node finding): the sequencer/sink bind block MUST run - // BEFORE the initial SetPosition. SetPosition → StopCompletely - // enqueues the A9 pending_motions node whose completable partner is - // the DefaultSink's type-5 motion-table entry — with the sink still - // null at login, the node was ORPHANED, and pending_motions never - // reached empty again (head-pop-any just relabels a backlog), so - // the MoveToManager's wait-for-anims gate never opened: every - // server MoveTo armed but the body never moved. - if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE) - && playerAE.Sequencer is { } playerSeq) - { - _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); - // R3-W4: bind the player interp's retail seams to the player - // sequencer — LeaveGround/HitGround strip transition links here - // (the K-fix18 replacement). - // #174 (2026-07-05): the seam is HandleEnterWorld (strip + FULL - // queue drain), not the bare sequence strip — see the remote - // bind site's comment (retail 0x0050fe20 → 0x00517d70 → - // 0x0051bdd0). The bare strip orphaned every pending manager - // node on each jump's LeaveGround; the local player's - // pending_motions then never drained and every armed moveto - // starved (#174: doors unusable after the first jump/run). - _playerController.Motion.RemoveLinkAnimations = () => playerSeq.Manager.HandleEnterWorld(); - _playerController.Motion.InitializeMotionTables = - () => playerSeq.Manager.InitializeState(); - _playerController.Motion.CheckForCompletedMotions = - playerSeq.Manager.CheckForCompletedMotions; - // R3-W6: the player's cycles are now driven through the SAME - // dispatch sink remotes use (TurnApplied/TurnStopped omitted — - // ObservedOmega is a remote-DR-only concept; local rotation is - // the controller's Yaw integration). - _playerController.Motion.DefaultSink = - new AcDream.Core.Physics.Motion.MotionTableDispatchSink(playerSeq); - } - var initResult = _physicsEngine.Resolve( playerEntity.Position, pinitCellId, System.Numerics.Vector3.Zero, 100f); - _playerController.SetPosition(initResult.Position, initResult.CellId, - CellLocalForSeed(initResult.Position, initResult.CellId)); + _playerController.SetPosition(initResult.Position, initResult.CellId); // #111 (2026-06-10): snap the ENTITY too — parity with the // teleport-arrival path (entity.SetPosition + ParentCellId at // GameWindow.cs:4914). Without this, the renderer keeps drawing the @@ -13848,6 +11917,12 @@ public sealed class GameWindow : IDisposable playerEntity.SetPosition(initResult.Position); playerEntity.ParentCellId = initResult.CellId; + if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE) + && playerAE.Sequencer is { } playerSeq) + { + _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); + } + var q = playerEntity.Rotation; float rawYaw = MathF.Atan2( 2f * (q.W * q.Z + q.X * q.Y), @@ -14054,45 +12129,6 @@ public sealed class GameWindow : IDisposable $"visible={_terrain?.VisibleSlots ?? 0} " + $"loaded={_terrain?.LoadedSlots ?? 0} " + $"capacity={_terrain?.CapacitySlots ?? 0}"); - - // [FRAME-DIAG]: the FPS-deep-dive per-frame cost split + leak/churn counters. - // apply_us = terrain-apply CPU in OnUpdate (dat reads + physics/ShadowObjects - // registration + terrain upload) — INVISIBLE to the title-bar ms (H1). - // upl_us = the terrain glBufferSubData sub-span of that apply (expected tiny). - // entUpl_us= the OnRender _wbMeshAdapter.Tick entity-mesh GPU drain (H2). - // For entity-DRAW CPU/GPU see the [WB-DIAG] line; walked = resident-N proxy (H3). - double applyMedUs = TerrainDiagMedianMicros(_applyCpuSamples) / 100.0; - double applyP95Us = TerrainDiagPercentile95Micros(_applyCpuSamples) / 100.0; - double uplMedUs = TerrainDiagMedianMicros(_applyUploadSamples) / 100.0; - double uplP95Us = TerrainDiagPercentile95Micros(_applyUploadSamples) / 100.0; - double entUplMedUs = TerrainDiagMedianMicros(_entityUploadSamples) / 100.0; - double entUplP95Us = TerrainDiagPercentile95Micros(_entityUploadSamples) / 100.0; - // apply CPU split: cell-build / gfxobj-BSP / ShadowObjects+lights — names - // WHICH part of the apply dominates (decides the fix shape). - double cellMedUs = TerrainDiagMedianMicros(_applyCellSamples) / 100.0; - double cellP95Us = TerrainDiagPercentile95Micros(_applyCellSamples) / 100.0; - double bspMedUs = TerrainDiagMedianMicros(_applyBspSamples) / 100.0; - double bspP95Us = TerrainDiagPercentile95Micros(_applyBspSamples) / 100.0; - double shadMedUs = TerrainDiagMedianMicros(_applyShadowSamples) / 100.0; - double shadP95Us = TerrainDiagPercentile95Micros(_applyShadowSamples) / 100.0; - double lockMedUs = TerrainDiagMedianMicros(_applyLockWaitSamples) / 100.0; - double lockP95Us = TerrainDiagPercentile95Micros(_applyLockWaitSamples) / 100.0; - int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0; - Console.WriteLine( - $"[FRAME-DIAG] apply_us={applyMedUs:F1}m/{applyP95Us:F1}p95 " + - $"lockwait={lockMedUs:F1}m/{lockP95Us:F1}p95 " + - $"[cell={cellMedUs:F1}m/{cellP95Us:F1}p95 bsp={bspMedUs:F1}m/{bspP95Us:F1}p95 " + - $"shadow={shadMedUs:F1}m/{shadP95Us:F1}p95] " + - $"(upl={uplMedUs:F1}m/{uplP95Us:F1}p95) " + - $"entUpl_us={entUplMedUs:F1}m/{entUplP95Us:F1}p95 " + - $"applies_max/upd={_frameDiagMaxAppliesPerUpdate} " + - $"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " + - $"forceReload={_streamingController?.ForceReloadCount ?? 0}" + - $"(drop={_streamingController?.LastForceReloadDropCount ?? 0}) " + - $"esg={_entitiesByServerGuid.Count} spawn={_lastSpawnByGuid.Count} " + - $"resident={_worldState.Entities.Count} walked={walked}"); - _frameDiagMaxAppliesPerUpdate = 0; // reset the per-window burst tracker - _terrainLastDiagTick = now; } @@ -14124,17 +12160,6 @@ public sealed class GameWindow : IDisposable return copy[copy.Length - 1 - offset]; } - /// [FRAME-DIAG] helper: convert a - /// timestamp-tick delta to the microseconds×100 fixed-point used by the rolling - /// sample rings and store it (zeros are ignored by the median/p95 helpers, so an - /// idle frame's 0-sample doesn't dilute the non-zero cost distribution). - private static void FrameDiagPush(long[] samples, ref int cursor, long ticks) - { - // µs×100 = ticks × 1e8 / Stopwatch.Frequency. - samples[cursor] = (long)(ticks * 100_000_000.0 / System.Diagnostics.Stopwatch.Frequency); - cursor = (cursor + 1) % samples.Length; - } - /// A.5 T22: parse a float environment variable, returning /// when the variable is absent or unparseable. private static float ParseEnvFloat(string name, float defaultValue) @@ -14161,7 +12186,6 @@ public sealed class GameWindow : IDisposable _wbDrawDispatcher?.Dispose(); _envCellRenderer?.Dispose(); // Phase A8 _portalDepthMask?.Dispose(); // T1 - _fadeOverlay?.Dispose(); _clipFrame?.Dispose(); // Phase U.3 _skyRenderer?.Dispose(); // depends on sampler cache; dispose first _samplerCache?.Dispose(); @@ -14172,13 +12196,10 @@ public sealed class GameWindow : IDisposable _terrain?.Dispose(); _terrainModernShader?.Dispose(); _sceneLightingUbo?.Dispose(); - _paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures _particleRenderer?.Dispose(); _debugLines?.Dispose(); - _uiHost?.Dispose(); _textRenderer?.Dispose(); _debugFont?.Dispose(); - _frameProfiler.Dispose(); // MP0: releases the GpuFrameTimer query ring _dats?.Dispose(); _input?.Dispose(); _gl?.Dispose(); diff --git a/src/AcDream.App/Rendering/InteriorEntityPartition.cs b/src/AcDream.App/Rendering/InteriorEntityPartition.cs index 150345da..9ef43dda 100644 --- a/src/AcDream.App/Rendering/InteriorEntityPartition.cs +++ b/src/AcDream.App/Rendering/InteriorEntityPartition.cs @@ -35,55 +35,8 @@ public static class InteriorEntityPartition public Dictionary> ByCell { get; } = new(); public List OutdoorStatic { get; } = new(); public List Dynamics { get; } = new(); - - // MP-Alloc: scratch for PruneEmptyCellBuckets — reused across frames - // so pruning itself doesn't allocate. - private readonly List _emptyCellScratch = new(); - - /// - /// MP-Alloc (2026-07-05): clear every collection in place for reuse - /// by . - /// The per-cell lists inside are cleared and - /// KEPT (not removed) so a steady-state frame with the same visible - /// cell set reuses the same List<WorldEntity> instances instead - /// of reallocating one per cell every frame. - /// - internal void ClearForReuse() - { - foreach (var list in ByCell.Values) - list.Clear(); - OutdoorStatic.Clear(); - Dynamics.Clear(); - } - - /// - /// MP-Alloc: drop any cell bucket that ended this frame with zero - /// entries (either newly emptied, or a leftover key from a previous - /// frame's visible-cell set that this frame never touched). Keeps - /// ByCell.Count / .Keys bit-identical to the old always-fresh- - /// Dictionary behavior — callers that inspect key presence/count - /// directly (not just TryGetValue) must see exactly the cells that - /// actually received at least one static this frame. - /// - internal void PruneEmptyCellBuckets() - { - _emptyCellScratch.Clear(); - foreach (var (cellId, list) in ByCell) - { - if (list.Count == 0) - _emptyCellScratch.Add(cellId); - } - foreach (var cellId in _emptyCellScratch) - ByCell.Remove(cellId); - } } - /// - /// Allocating overload — always returns a brand-new . - /// Kept for tests and any one-shot caller; the per-frame render path - /// uses the - /// reuse overload instead (see ). - /// public static Result Partition( HashSet visibleCells, IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, @@ -91,28 +44,6 @@ public static class InteriorEntityPartition IReadOnlyDictionary? AnimatedById)> landblockEntries) { var result = new Result(); - Partition(result, visibleCells, landblockEntries); - return result; - } - - /// - /// MP-Alloc (2026-07-05): reuse overload. Clears - /// in place (see ) and refills it, - /// reusing each cell's existing List<WorldEntity> when the - /// cell key survives from the previous frame instead of allocating a new - /// one — the per-cell dictionary entries persist across frames (cleared, - /// never removed) since the visible-cell set is usually stable frame to - /// frame. Identical partitioning output to the allocating overload; only - /// the backing storage is reused. - /// - public static void Partition( - Result result, - HashSet visibleCells, - IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, - IReadOnlyList Entities, - IReadOnlyDictionary? AnimatedById)> landblockEntries) - { - result.ClearForReuse(); foreach (var entry in landblockEntries) { foreach (var e in entry.Entities) @@ -139,8 +70,7 @@ public static class InteriorEntityPartition } } } - - result.PruneEmptyCellBuckets(); + return result; } /// Shared indoor classification — keep DrawDynamicsLast, the diff --git a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs b/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs deleted file mode 100644 index 0a3f83bd..00000000 --- a/src/AcDream.App/Rendering/PaperdollViewportRenderer.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.Rendering.Wb; -using AcDream.App.UI; -using AcDream.Core.Lighting; -using AcDream.Core.World; -using Silk.NET.OpenGL; - -namespace AcDream.App.Rendering; - -/// -/// Render-to-texture renderer for the paperdoll 3-D doll (the C# analog of retail -/// CreatureMode::Render). Draws ONE re-dressed player clone (a , -/// built by + wired by GameWindow) into a private off-screen -/// framebuffer with a fixed + one distant light, then hands the color -/// texture to the widget which blits it as a normal 2-D sprite. -/// -/// The whole 3-D pass is sealed in a so it can't disturb the -/// surrounding world / UI GL state ([[feedback_render_self_contained_gl_state]]). It runs in the -/// per-frame pre-UI hook (GameWindow), gated on inventory-open ∧ doll-view — NOT from the widget's -/// 2-D OnDraw. -/// -/// Reuses the existing + the player's already-resolved, -/// already-GPU-resident MeshRefs (the doll clones them), so no separate mesh/texture upload is -/// needed. With frustum: null the doll's synthetic landblock is always "visible" and the -/// entity is walked from entry.Entities and drawn from its current MeshRefs — a STATIC pose -/// for now; idle animation (TickAnimations rebuilding the doll's MeshRefs) is a later slice. -/// -public sealed unsafe class PaperdollViewportRenderer : IUiViewportRenderer, IDisposable -{ - private readonly GL _gl; - private readonly WbDrawDispatcher _dispatcher; - private readonly SceneLightingUboBinding _lightUbo; - private readonly DollCamera _camera = new(); - - // Off-screen target (lazily (re)created on size change). - private uint _fbo; - private uint _colorTex; - private uint _depthRbo; - private int _fbW; - private int _fbH; - - // The doll WorldEntity (held by reference: when GameWindow's TickAnimations later rebuilds its - // MeshRefs each frame, this renderer sees the updated pose automatically). null = nothing to draw. - private WorldEntity? _doll; - - // Synthetic landblock for the doll's one-entry Draw tuple. With frustum:null + this as - // neverCullLandblockId the entity is never culled. 0 is unused by live streaming on the doll path. - private const uint DollLandblockId = 0u; - - // The doll's render id, passed as "animated" so the dispatcher bypasses the Tier-1 classification - // cache (WbDrawDispatcher.cs:1142) and re-classifies from the current MeshRefs every frame. This is - // what makes a re-dress (a new WorldEntity built with the same DollRenderId) actually take effect, - // AND what lets the idle animation (later slice, TickAnimations rebuilds MeshRefs) show. - private static readonly HashSet _dollAnimatedIds = new() { DollEntityBuilder.DollRenderId }; - - public PaperdollViewportRenderer(GL gl, WbDrawDispatcher dispatcher, SceneLightingUboBinding lightUbo) - { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); - _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); - _lightUbo = lightUbo ?? throw new ArgumentNullException(nameof(lightUbo)); - } - - /// Set (or clear) the doll entity. GameWindow builds/re-dresses it from the live player. - public void SetDoll(WorldEntity? doll) => _doll = doll; - - /// - public uint Render(int width, int height) - { - var doll = _doll; - if (doll is null || doll.MeshRefs.Count == 0 || width <= 0 || height <= 0) - return 0u; - - EnsureFramebuffer(width, height); - if (_fbo == 0) return 0u; - _camera.Aspect = width / (float)height; - - // Seal the entire 3-D pass — GLStateScope restores viewport/scissor/depth/cull/blend/program/ - // FBO bindings on dispose, so the surrounding world + 2-D UI passes are untouched. - using var scope = new GLStateScope(_gl); - - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo); - _gl.Viewport(0, 0, (uint)width, (uint)height); - _gl.Disable(EnableCap.ScissorTest); - _gl.ClearColor(0f, 0f, 0f, 0f); // transparent — the window backdrop shows through behind the doll - _gl.ClearDepth(1.0); - _gl.DepthMask(true); // depth clears honor glDepthMask - _gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); - - _gl.Enable(EnableCap.DepthTest); - _gl.DepthFunc(DepthFunction.Less); - _gl.Enable(EnableCap.CullFace); - _gl.CullFace(TriangleFace.Back); - _gl.FrontFace(FrontFaceDirection.Ccw); // matches the world object pass; if the doll renders inside-out at the gate, flip to Cw - _gl.Disable(EnableCap.Blend); - - UploadDollLight(); - - // One synthetic landblock entry holding just the doll. frustum:null ⇒ always "visible" ⇒ - // walked from entry.Entities and drawn from doll.MeshRefs (WbDrawDispatcher.cs:692,1190). - var entities = new WorldEntity[] { doll }; - var entries = new (uint, Vector3, Vector3, IReadOnlyList, IReadOnlyDictionary?)[] - { - (DollLandblockId, new Vector3(-4f, -4f, -4f), new Vector3(4f, 4f, 4f), entities, null), - }; - - _dispatcher.Draw( - _camera, - entries, - frustum: null, - neverCullLandblockId: DollLandblockId, - visibleCellIds: null, - animatedEntityIds: _dollAnimatedIds); // doll treated as animated ⇒ cache-bypass (re-dress correctness) - - return _colorTex; - } - - /// Overwrite the shared scene-lighting UBO (binding=1) with the doll's single distant - /// light. Safe: nothing else draws 3-D after the doll this frame, and GameWindow rebuilds the - /// world UBO at the start of the next frame. Retail values: DISTANT_LIGHT dir (0.3,1.9,0.65) - /// intensity 2.0 (gmPaperDollUI::PostInit decomp 175529-175533). - private void UploadDollLight() - { - var dir = Vector3.Normalize(new Vector3(0.3f, 1.9f, 0.65f)); - var ubo = new SceneLightingUbo - { - Light0 = new UboLight - { - PosAndKind = new Vector4(0f, 0f, 0f, 0f), // kind 0 = directional - DirAndRange = new Vector4(dir, 1e9f), // huge range = no distance cutoff - ColorAndIntensity = new Vector4(1f, 1f, 1f, 2.0f), // white @ retail intensity 2.0 - ConeAngleEtc = Vector4.Zero, - }, - CellAmbient = new Vector4(0.30f, 0.30f, 0.30f, 1f), // retail CreatureMode default ambient (ctor 0x004543cf = 0.3,0.3,0.3) - FogParams = new Vector4(1e9f, 1e9f, 0f, 0f), // fog pushed to infinity = no fog on the doll - FogColor = Vector4.Zero, - CameraAndTime = new Vector4(0.12f, -2.4f, 0.88f, 0f), // camera world pos (matches DollCamera eye) - }; - _lightUbo.Upload(ubo); - } - - /// (Re)create the FBO + color texture + depth-stencil renderbuffer when the requested - /// size changes. Color is RGBA8 with linear filtering + clamp; depth is Depth24Stencil8. - private void EnsureFramebuffer(int width, int height) - { - if (_fbo != 0 && width == _fbW && height == _fbH) return; - DeleteFramebuffer(); - - _fbW = width; - _fbH = height; - - _colorTex = _gl.GenTexture(); - _gl.BindTexture(TextureTarget.Texture2D, _colorTex); - _gl.TexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba8, (uint)width, (uint)height, 0, - PixelFormat.Rgba, PixelType.UnsignedByte, (void*)0); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.ClampToEdge); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.ClampToEdge); - _gl.BindTexture(TextureTarget.Texture2D, 0); - - _depthRbo = _gl.GenRenderbuffer(); - _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo); - _gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer, InternalFormat.Depth24Stencil8, (uint)width, (uint)height); - _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0); - - _fbo = _gl.GenFramebuffer(); - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo); - _gl.FramebufferTexture2D(FramebufferTarget.Framebuffer, FramebufferAttachment.ColorAttachment0, - TextureTarget.Texture2D, _colorTex, 0); - _gl.FramebufferRenderbuffer(FramebufferTarget.Framebuffer, FramebufferAttachment.DepthStencilAttachment, - RenderbufferTarget.Renderbuffer, _depthRbo); - - var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer); - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); - if (status != GLEnum.FramebufferComplete) - { - Console.WriteLine($"[paperdoll] framebuffer incomplete: {status} ({width}x{height})"); - DeleteFramebuffer(); - } - } - - private void DeleteFramebuffer() - { - if (_fbo != 0) { _gl.DeleteFramebuffer(_fbo); _fbo = 0; } - if (_colorTex != 0) { _gl.DeleteTexture(_colorTex); _colorTex = 0; } - if (_depthRbo != 0) { _gl.DeleteRenderbuffer(_depthRbo); _depthRbo = 0; } - _fbW = _fbH = 0; - } - - public void Dispose() => DeleteFramebuffer(); -} diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index d078e0c6..e47fc338 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -48,19 +48,6 @@ public sealed unsafe class ParticleRenderer : IDisposable private float[] _instanceScratch = new float[256 * 16]; - // MP-Alloc (2026-07-05): Draw() is called up to ~11 times per frame - // (sky pre/post, scene, per-visible-cell, dynamics, unattached passes), - // each previously `new`ing a List (BuildDrawList) and a - // List (the per-batch `run` list) that became garbage - // as soon as the call returned. All Draw() calls happen sequentially on - // the render thread (verified: every call site in GameWindow.cs is a - // plain synchronous invocation from the single-threaded OnRender chain, - // none dispatched via Task.Run/Parallel) and each call fully drains its - // lists before returning, so a single pair of reused fields is safe - - // no call overlaps another's use of these buffers. - private readonly List _drawListScratch = new(64); - private readonly List _runScratch = new(64); - public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); @@ -146,9 +133,6 @@ public sealed unsafe class ParticleRenderer : IDisposable if (draws.Count == 0) return; draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq)); - // draws IS _drawListScratch (see BuildDrawList) - sorting it in place - // is fine, nothing else reads it between BuildDrawList's return and - // this call. _shader.Use(); _shader.SetMatrix4("uViewProjection", camera.View * camera.Projection); @@ -160,7 +144,7 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.Disable(EnableCap.CullFace); _gl.ActiveTexture(TextureUnit.Texture0); - var run = _runScratch; + var run = new List(64); for (int i = 0; i < draws.Count;) { var key = draws[i].Key; @@ -194,8 +178,7 @@ public sealed unsafe class ParticleRenderer : IDisposable Vector3 cameraUp, Func? emitterFilter) { - var draws = _drawListScratch; - draws.Clear(); + var draws = new List(Math.Max(64, particles.ActiveParticleCount)); foreach (var (em, idx) in particles.EnumerateLive()) { if (em.RenderPass != renderPass) diff --git a/src/AcDream.App/Rendering/RenderBootstrap.cs b/src/AcDream.App/Rendering/RenderBootstrap.cs deleted file mode 100644 index b7ee7015..00000000 --- a/src/AcDream.App/Rendering/RenderBootstrap.cs +++ /dev/null @@ -1,231 +0,0 @@ -using System.Collections.Concurrent; -using DatReaderWriter; -using Microsoft.Extensions.Logging.Abstractions; -using Silk.NET.OpenGL; - -namespace AcDream.App.Rendering; - -/// -/// The subset of the production render stack that the UI Studio needs: -/// GL + dats + UiHost + the WB mesh pipeline. Constructed from the same -/// classes and the same order as , minus -/// terrain / sky / physics / streaming. -/// -public sealed record RenderStack( - GL Gl, - DatReaderWriter.DatCollection Dats, - string ShaderDir, - Wb.BindlessSupport Bindless, - TextureCache TextureCache, - Shader MeshShader, - Wb.WbMeshAdapter MeshAdapter, - Wb.EntitySpawnAdapter EntitySpawnAdapter, - Wb.WbDrawDispatcher DrawDispatcher, - SceneLightingUboBinding LightingUbo, - AcDream.App.UI.UiHost UiHost, - AcDream.App.UI.UiDatFont? VitalsDatFont, - AcDream.App.UI.UiDatFont? LargeDatFont) : System.IDisposable -{ - /// Dispose the GL pieces this stack OWNS (everything created in - /// ). + are caller-owned - /// and NOT disposed here. Called once at studio teardown. - public void Dispose() - { - DrawDispatcher.Dispose(); - MeshAdapter.Dispose(); - TextureCache.Dispose(); - MeshShader.Dispose(); - LightingUbo.Dispose(); - UiHost.Dispose(); - } - - /// - /// Resolves a sprite id (0x06xxxxxx) to a (GL handle, width, height) triple. - /// Copied verbatim from GameWindow's ResolveChrome closure — it calls - /// TextureCache.GetOrUploadRenderSurface(id, out w, out h). - /// - public (uint handle, int width, int height) ResolveChrome(uint spriteId) - { - uint t = TextureCache.GetOrUploadRenderSurface(spriteId, out int w, out int h); - return (t, w, h); - } - - // ── Font cache (per-stack, keyed by FontDid) ───────────────────────────── - - /// - /// Cache of loaded dat fonts keyed by FontDid (0x40000000-range). - /// Populated lazily by . Thread-safe for - /// concurrent reads from the studio render loop; writes happen only - /// during the first load of each distinct FontDid. - /// - private readonly ConcurrentDictionary _fontCache = new(); - - /// - /// Lazily load and cache a dat font by its FontDid. Returns null (and - /// caches null) when the Font DBObj is absent or has no foreground surface — - /// callers fall back to the global font in that case. - /// - /// Pre-seeds (0x40000000) and - /// (0x40000001) from the already-loaded instances - /// to avoid a redundant upload on those two ids. - /// - public AcDream.App.UI.UiDatFont? ResolveDatFont(uint fontDid) - { - return _fontCache.GetOrAdd(fontDid, id => - AcDream.App.UI.UiDatFont.Load(Dats, TextureCache, id)); - } - - /// - /// Pre-seeds the font cache from the two already-loaded font instances - /// (VitalsDatFont = 0x40000000, LargeDatFont = 0x40000001) so that - /// returns them without a redundant GL upload. - /// Called once by after the stack is - /// fully constructed. - /// - internal void SeedFontCache() - { - if (VitalsDatFont is not null) - _fontCache.TryAdd(AcDream.App.UI.UiDatFont.DefaultFontId, VitalsDatFont); - if (LargeDatFont is not null) - _fontCache.TryAdd(0x40000001u, LargeDatFont); - } -} - -/// Options for . -public sealed record RenderBootstrapOptions( - AcDream.UI.Abstractions.Settings.QualitySettings Quality); - -/// -/// Constructs the UI Studio's render stack from the production classes, -/// in the same order as . -/// -public static class RenderBootstrap -{ - /// - /// Build the studio's render stack. Throws - /// (same message as GameWindow) if GL_ARB_bindless_texture or - /// GL_ARB_shader_draw_parameters are absent — the modern path is mandatory. - /// - public static RenderStack Create( - GL gl, - DatReaderWriter.DatCollection dats, - RenderBootstrapOptions opts) - { - // --- Bindless detection (GameWindow ~1701-1723) --- - if (!Wb.BindlessSupport.TryCreate(gl, out var bindless) - || bindless is null - || !bindless.HasShaderDrawParameters(gl)) - { - throw new NotSupportedException( - "acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " + - "(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " + - "If this is unexpected, please file a bug report with your GPU vendor + driver version."); - } - - // --- Shared infra (GameWindow ~1198, ~1211) --- - string shaderDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); - var lightingUbo = new SceneLightingUboBinding(gl); - - // --- Mesh shader (GameWindow ~1769-1771) --- - var meshShader = new Shader(gl, - Path.Combine(shaderDir, "mesh_modern.vert"), - Path.Combine(shaderDir, "mesh_modern.frag")); - - // --- TextureCache (GameWindow ~1774) --- - var textureCache = new TextureCache(gl, dats, bindless); - - // --- AnimLoader (GameWindow ~1240) --- - var animLoader = new AcDream.Core.Physics.DatCollectionLoader(dats); - - // --- WbMeshAdapter (GameWindow ~2286-2287) --- - var wbLogger = NullLogger.Instance; - var meshAdapter = new Wb.WbMeshAdapter(gl, dats, wbLogger); - - // --- SequencerFactory (GameWindow ~2306-2334) --- - var capturedDats = dats; - var capturedAnimLoader = animLoader; - AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e) - { - if (capturedDats is not null && capturedAnimLoader is not null) - { - var setup = capturedDats.Get(e.SourceGfxObjOrSetupId); - if (setup is not null) - { - uint mtableId = (uint)setup.DefaultMotionTable; - if (mtableId != 0) - { - var mtable = capturedDats.Get(mtableId); - if (mtable is not null) - return new AcDream.Core.Physics.AnimationSequencer( - setup, mtable, capturedAnimLoader); - } - // Setup exists but no motion table — no-op sequencer. - return new AcDream.Core.Physics.AnimationSequencer( - setup, - new DatReaderWriter.DBObjs.MotionTable(), - capturedAnimLoader); - } - } - // Complete fallback: empty setup + empty motion table + null loader. - return new AcDream.Core.Physics.AnimationSequencer( - new DatReaderWriter.DBObjs.Setup(), - new DatReaderWriter.DBObjs.MotionTable(), - new NullAnimLoader()); - } - - // --- EntitySpawnAdapter (GameWindow ~2335-2336) --- - var entitySpawnAdapter = new Wb.EntitySpawnAdapter( - textureCache, SequencerFactory, meshAdapter); - - // --- EntityClassificationCache (GameWindow ~217 — field initializer, new()) --- - var classificationCache = new Wb.EntityClassificationCache(); - - // --- WbDrawDispatcher (GameWindow ~2377-2381) --- - var drawDispatcher = new Wb.WbDrawDispatcher( - gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter, - bindless, classificationCache); - drawDispatcher.AlphaToCoverage = opts.Quality.AlphaToCoverage; - - // --- Vitals dat font (GameWindow ~1820-1822) --- - var vitalsDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache); - - // --- Larger retail font (0x40000001, MaxCharHeight=18) for attribute row text. - // The default font (0x40000000, 16px) renders the row names too small; the 18px - // variant (confirmed in client_portal.dat 2026-06-26) matches the retail character - // window list more closely (≈ icon height ≈ 24px target, 18px is best available). - var largeDatFont = AcDream.App.UI.UiDatFont.Load(dats, textureCache, 0x40000001u); - - // --- UiHost (GameWindow ~1790); pass null for debugFont (only used as - // a fallback BitmapFont for the world-space HUD — not needed for the - // UI Studio, and BitmapFont requires a system font byte array) --- - var uiHost = new AcDream.App.UI.UiHost(gl, shaderDir, defaultFont: null); - - var stack = new RenderStack( - Gl: gl, - Dats: dats, - ShaderDir: shaderDir, - Bindless: bindless, - TextureCache: textureCache, - MeshShader: meshShader, - MeshAdapter: meshAdapter, - EntitySpawnAdapter: entitySpawnAdapter, - DrawDispatcher: drawDispatcher, - LightingUbo: lightingUbo, - UiHost: uiHost, - VitalsDatFont: vitalsDatFont, - LargeDatFont: largeDatFont); - - // Pre-seed the font cache with the two already-uploaded atlas instances - // so ResolveDatFont(0x40000000) and ResolveDatFont(0x40000001) hit the cache - // rather than re-uploading the same GL texture a second time. - stack.SeedFontCache(); - - return stack; - } - - // NullAnimLoader mirrors GameWindow's private NullAnimLoader (GameWindow ~13327-13330). - private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader - { - public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null; - } -} diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index 634db0e4..4993f5c1 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -23,11 +23,6 @@ public sealed class RetailPViewRenderer new(0, new Vector4(-1f, -1f, 1f, 1f), Array.Empty()); private readonly HashSet _oneCell = new(1); - // Shell-batch scratch: all of a pass's cells collected for ONE batched - // opaque Render call (instead of one heavy Render per cell). Reused across - // frames + across look-in buildings. Spec: - // docs/superpowers/specs/2026-06-23-envcell-shell-batching-design.md - private readonly HashSet _shellBatch = new(); // R-A2: per-building flood grouping, reused across frames (inner lists cleared each frame). private readonly Dictionary> _buildingGroups = new(); @@ -42,21 +37,6 @@ public sealed class RetailPViewRenderer // (statics + outside-stage dynamics passing the slice cone). private readonly List _lateParticleOwnerScratch = new(); - // MP-Alloc (2026-07-05): the frame's entity partition (ByCell/OutdoorStatic/ - // Dynamics), reused across frames instead of `new`ing a Result (a Dictionary - // + 2 Lists, plus one List per visible cell) every DrawInside - // call. See InteriorEntityPartition.Partition(Result, ...) — clears in - // place and reuses each cell's list across frames when the cell stays - // visible. - private readonly InteriorEntityPartition.Result _partitionResult = new(); - - // MP-Alloc (2026-07-05): DrawInside's drawable-cell set, reused across - // frames instead of `new HashSet(pvFrame.OrderedVisibleCells)` every - // call. Every consumer (DrawEntityBucket, DrawExitPortalMasks, - // DrawCellObjectLists, RetailPViewFrameResult.DrawableCells) reads it - // synchronously within the same frame it was built. - private readonly HashSet _drawableCellsScratch = new(); - // T2 (BR-4): retail has NO distance constant on the flood-admission chain // (DrawBuilding → portal walk → ConstructView: viewconeCheck + side test + // GetClip + GetVisible only). The old 48 m seed cap is replaced by the @@ -125,9 +105,7 @@ public sealed class RetailPViewRenderer // so every visible cell's shell has a prepared batch and seals — killing the grey // (the old clipAssembly.CellIdToSlot.Keys filter silently dropped slot-less cells). // Per-slice trim still applies in DrawEnvCellShells (Task 4 makes it self-contained). - _drawableCellsScratch.Clear(); - _drawableCellsScratch.UnionWith(pvFrame.OrderedVisibleCells); - var drawableCells = _drawableCellsScratch; + var drawableCells = new HashSet(pvFrame.OrderedVisibleCells); UseIndoorMembershipOnlyRouting(); // #124: look-in cells need prepared shell batches + their statics routed @@ -146,12 +124,6 @@ public sealed class RetailPViewRenderer prepareCells = _lookInPrepareScratch; } - // A7 #176/#177: scope this frame's point-light pool to the cells actually being - // drawn, NOW that the flood has resolved the visible set (retail collects lights - // per-frame over visible_cell_table). Must run before the cell/entity draws below - // that select from LightManager.PointSnapshot. - ctx.RebuildScopedLights?.Invoke(prepareCells); - _envCells.PrepareRenderBatches( ctx.ViewProjection, ctx.CameraWorldPosition, @@ -160,8 +132,7 @@ public sealed class RetailPViewRenderer centerLbY: ctx.RenderCenterLbY, renderRadius: ctx.RenderRadius); - InteriorEntityPartition.Partition(_partitionResult, prepareCells, ctx.LandblockEntries); - var partition = _partitionResult; + var partition = InteriorEntityPartition.Partition(prepareCells, ctx.LandblockEntries); var result = new RetailPViewFrameResult { PortalFrame = pvFrame, @@ -374,26 +345,13 @@ public sealed class RetailPViewRenderer // Pass 2: shells + statics, far→near. UseIndoorMembershipOnlyRouting(); - - // Opaque shells batched per building into ONE Render (this building's - // aperture punches above already ran; z-buffer handles order and - // lighting is per-instance CellId-keyed) — was one heavy per-frame - // Render per cell. Per-cell entity/particle work stays in the loop. - _shellBatch.Clear(); - foreach (uint cid in frame.OrderedVisibleCells) - _shellBatch.Add(cid); - if (_shellBatch.Count > 0) - _envCells.Render(WbRenderPass.Opaque, _shellBatch); - for (int i = frame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = frame.OrderedVisibleCells[i]; _oneCell.Clear(); _oneCell.Add(cellId); - // Opaque shell batched above. Transparent stays per-cell (far→near) - // for correct compositing; skipped for opaque-only cells. - if (_envCells.CellHasTransparent(cellId)) - _envCells.Render(WbRenderPass.Transparent, _oneCell); + _envCells.Render(WbRenderPass.Opaque, _oneCell); + _envCells.Render(WbRenderPass.Transparent, _oneCell); _cellStaticScratch.Clear(); if (partition.ByCell.TryGetValue(cellId, out var bucket)) @@ -698,28 +656,11 @@ public sealed class RetailPViewRenderer // Per-cell opaque+transparent keeps the far→near transparent // compositing the per-cell loop already provided. UseIndoorMembershipOnlyRouting(); - - // Opaque: ONE batched Render for all shell cells (was one heavy per-frame - // Render call PER cell — the dense-town FPS sink, ~94 calls/24.75ms at - // Arwic). Opaque needs no draw order (z-buffer), and lighting is - // per-instance (CellId-keyed light SSBO in EnvCellRenderer.RenderModernMDI- - // Internal), so cross-cell batching is visually identical. The filtered - // Render path already groups all cells' instances into one MDI. - _shellBatch.Clear(); - foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) - _shellBatch.Add(entry.CellId); - if (_shellBatch.Count > 0) - _envCells.Render(WbRenderPass.Opaque, _shellBatch); - - // Transparent: far→near order matters for compositing, so keep these - // per-cell in ShellPass order — but skip cells with no transparent - // geometry (most are opaque-only walls/floors), removing the bulk of the - // per-cell transparent Render calls. foreach (var entry in IndoorDrawPlan.ShellPass(pvFrame)) { - if (!_envCells.CellHasTransparent(entry.CellId)) continue; _oneCell.Clear(); _oneCell.Add(entry.CellId); + _envCells.Render(WbRenderPass.Opaque, _oneCell); _envCells.Render(WbRenderPass.Transparent, _oneCell); } } @@ -750,10 +691,6 @@ public sealed class RetailPViewRenderer { EntitySphere(e, out var c, out float r); bool indoor = InteriorEntityPartition.IsIndoorCellId(e.ParentCellId); - // TEMP (#138-B): trace the avatar's survival through this cull. - bool isProbePlayer = AcDream.App.Streaming.EntityVanishProbe.Enabled - && AcDream.App.Streaming.EntityVanishProbe.PlayerGuid != 0 - && e.ServerGuid == AcDream.App.Streaming.EntityVanishProbe.PlayerGuid; // #118: under an interior root, outdoor-classified dynamics drew in // the outside stage (pre-clear, seal-protected) — retail draws them // via LScape::draw's per-landcell DrawSortCell, never in the @@ -762,18 +699,10 @@ public sealed class RetailPViewRenderer // seal. Indoor dynamics (incl. exit-portal straddlers, which drew // in BOTH stages) stay — this pass is retail's loop C. if (!rootIsOutdoor && !indoor) - { - if (isProbePlayer) - AcDream.App.Streaming.EntityVanishProbe.LogPlayerDynOnChange( - $"cell=0x{(e.ParentCellId ?? 0):X8} indoor=False rootOutdoor={rootIsOutdoor} -> CULLED(outside-stage)"); continue; - } bool visible = indoor ? viewcone.SphereVisibleInCell(e.ParentCellId!.Value, c, r) : viewcone.SphereVisibleOutside(c, r); - if (isProbePlayer) - AcDream.App.Streaming.EntityVanishProbe.LogPlayerDynOnChange( - $"cell=0x{(e.ParentCellId ?? 0):X8} indoor={indoor} rootOutdoor={rootIsOutdoor} viewcone={visible} -> {(visible ? "DRAWN" : "CULLED(viewcone)")}"); if (visible) _dynamicsScratch.Add(e); } @@ -821,23 +750,6 @@ public sealed class RetailPViewRenderer // statics-through-walls fix: a static whose sphere is outside every // view of its cell no longer paints through the wall (the cottage // phantom staircase's draw path). - // Dense-town FPS iteration-1 (spec 2026-06-23-cellobject-draw-batching): - // the per-cell DrawEntityBucket calls below were the top CPU sink at Arwic - // (cellobjects ~3.5 ms/frame; each WbDrawDispatcher.Draw orphans 6 SSBOs + - // full state setup). Collapse them into ONE cross-cell batched draw — the - // shipped cells-shell batching pattern applied to cell OBJECTS. Two loops - // preserve the statics-before-particles depth order: loop 1 culls + - // accumulates every cell's survivors and draws them once; loop 2 runs the - // per-cell particle passes AFTER the statics own the depth buffer (particles - // depth-test but write no depth). The dispatcher sorts opaque front-to-back - // and transparent back-to-front by group distance (WbDrawDispatcher.cs: - // 1469-1470), so cross-cell batching composites correctly — equal-or-better - // than the old per-cell-bucketed order. visibleCellIds = the union of cells, - // so the dispatcher admits exactly the same survivor set. - - // Loop 1: per-cell viewcone cull → accumulate survivors + the union of cells. - _allCellStatics.Clear(); - _cellObjCells.Clear(); for (int i = pvFrame.OrderedVisibleCells.Count - 1; i >= 0; i--) { uint cellId = pvFrame.OrderedVisibleCells[i]; @@ -847,50 +759,34 @@ public sealed class RetailPViewRenderer if (!partition.ByCell.TryGetValue(cellId, out var bucket) || bucket.Count == 0) continue; - int survivorsBefore = _allCellStatics.Count; + _cellStaticScratch.Clear(); foreach (var e in bucket) { EntitySphere(e, out var c, out float r); if (viewcone.SphereVisibleInCell(cellId, c, r)) - _allCellStatics.Add(e); + _cellStaticScratch.Add(e); } - int survivors = _allCellStatics.Count - survivorsBefore; - if (survivors > 0) - _cellObjCells.Add(cellId); // BR-2 phantom-site probe (T3-updated): post-viewcone survivors. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePhantomEnabled) - EmitPhantomObjsProbe(cellId, survivors); - } + EmitPhantomObjsProbe(cellId, _cellStaticScratch.Count); - // ONE batched static-object draw for every visible cell (was N per-cell - // WbDrawDispatcher.Draw calls). T1: per-cell STATIC lists only — dynamics - // draw in DrawDynamicsLast. T3 (BR-5): each static was sphere-tested against - // ITS cell's views above (the statics-through-walls fix is preserved by the - // cull; only the draw is batched). - if (_allCellStatics.Count > 0) - { - UseIndoorMembershipOnlyRouting(); - DrawEntityBucket(ctx, _allCellStatics, _cellObjCells); - } + if (_cellStaticScratch.Count > 0) + { + _oneCell.Clear(); + _oneCell.Add(cellId); + UseIndoorMembershipOnlyRouting(); + DrawEntityBucket(ctx, _cellStaticScratch, _oneCell); + } - // Cell-particle pass — consolidated across ALL visible cells into ONE - // draw. Was per-cell, and each call re-walked the ENTIRE live particle set - // (DrawRetailPViewCellParticles → ParticleRenderer.Draw enumerates every - // live emitter), i.e. O(cells × particles) — the dense-town cellobjects - // sink (~5 ms at Arwic). Static owners are disjoint per cell, so the UNION - // (= _allCellStatics, already accumulated above for the batched draw) draws - // EXACTLY the same emitters: the callback gates on owner id (the cone- - // surviving set), the renderer sorts globally back-to-front, and the per- - // cell slice was never used for clipping (the scissor gate was deleted in - // T3 — DrawRetailPViewCellParticles disables clip distances). Runs after - // the batched static draw so emitters depth-test against the statics now in - // the buffer (the statics-before-particles order). cellId/slice are unused - // by the particle pass — pass NoClipSlice + the union owner list. This also - // drops the per-cell BuildDrawList allocations (N → 1). - if (_allCellStatics.Count > 0) - ctx.DrawCellParticles?.Invoke( - new RetailPViewCellSliceContext(0u, NoClipSlice, _allCellStatics)); + // T3 (BR-5): particles gate through the SAME viewcone as their + // owners — the callback receives the cone-surviving entity set, so + // an emitter attached to a culled static no longer draws through + // the wall (the candle-flames-through-walls fix). Consumed + // synchronously within this iteration (scratch list reuse). + foreach (var slice in GetCellSlicesOrNoClip(clipAssembly, cellId)) + ctx.DrawCellParticles?.Invoke(new RetailPViewCellSliceContext(cellId, slice, _cellStaticScratch)); + } } // T3 scratch lists (render thread only; cleared per use). @@ -903,12 +799,6 @@ public sealed class RetailPViewRenderer // #121: cone-surviving dynamics whose emitters draw in the dynamics // particle pass (survivors minus outside-stage). Cleared per use. private readonly List _dynamicsParticleScratch = new(); - // Dense-town FPS iteration-1 (cellobject batching): all visible cells' - // viewcone-surviving statics accumulated for ONE batched DrawEntityBucket, - // plus the union of their cell ids for the dispatcher's visibleCellIds gate. - // Cleared at the top of DrawCellObjectLists. - private readonly List _allCellStatics = new(); - private readonly HashSet _cellObjCells = new(); /// /// #118 stage assignment for a dynamic under an INTERIOR root: does it draw @@ -1108,16 +998,6 @@ public sealed class RetailPViewDrawContext : IRetailPViewCellDrawContext public Action? DrawUnattachedSceneParticles { get; init; } public Action>? DrawDynamicsParticles { get; init; } public Action? EmitDiagnostics { get; init; } - - /// A7 #176/#177: rebuild the point-light snapshot scoped to the cells - /// this frame actually draws — invoked AFTER the portal flood resolves the visible - /// set and BEFORE any cell/entity draw (the faithful port of retail's per-frame - /// light collection: CObjCell::add_*_to_global_lights walked over - /// CEnvCell::visible_cell_table). The argument is every cell drawn this frame - /// (main flood + interior-root look-ins). A cell-less light (viewer fill) is kept - /// regardless. Null-safe: outdoor/no-flood callers leave it unset and keep the - /// legacy full-pool snapshot. - public Action>? RebuildScopedLights { get; init; } } public sealed class RetailPViewFrameResult diff --git a/src/AcDream.App/Rendering/Shaders/mesh.frag b/src/AcDream.App/Rendering/Shaders/mesh.frag index f2e879ae..7765a46a 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh.frag @@ -46,12 +46,10 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -// Retail per-vertex point-light ramp (calc_point_light 0x0059c8b0): the -// contribution scales by (1 - dist/falloff_eff) — a LINEAR fade to exactly -// 0 at the edge, NOT a hard-cutoff bubble. (The prior "no attenuation inside -// Range / crisp boundaries" note was a misread; it is the literal cause of -// the #133 "spotlight" look. falloff_eff = Falloff * static_light_factor 1.3 -// is folded into Range by LightInfoLoader.) Spots add a binary cos-cone test. +// Retail hard-cutoff lighting equation (r13 §10.2). No distance +// attenuation inside Range; hard edge at Range; spotlights use a +// binary cos-cone test. This is deliberate — the retail "bubble of +// light" look relies on crisp boundaries. vec3 accumulateLights(vec3 N, vec3 worldPos) { vec3 lit = uCellAmbient.xyz; int activeLights = int(uCellAmbient.w); @@ -75,19 +73,14 @@ vec3 accumulateLights(vec3 N, vec3 worldPos) { if (d < range && range > 1e-3) { vec3 Ldir = toL / max(d, 1e-4); float ndl = max(0.0, dot(N, Ldir)); - // calc_point_light (1 - dist/falloff_eff) linear ramp; Range already - // carries falloff_eff (Falloff * 1.3), so it fades to 0 at the cutoff. - float atten = clamp(1.0 - d / max(range, 1e-3), 0.0, 1.0); + float atten = 1.0; // retail: no attenuation inside Range if (kind == 2) { // Spotlight: hard-edged cos-cone test. float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); atten *= (cos_l > cos_edge) ? 1.0 : 0.0; } - // Retail per-channel "no-blowout" cap (calc_point_light 0x0059c8b0): a single - // point/spot light can't push a channel past its own colour, regardless of - // intensity (~100) — kills the close-torch overblow (#93). See mesh_modern.frag. - lit += min(Lcol * ndl * atten, uLights[i].colorAndIntensity.xyz); + lit += Lcol * ndl * atten; } } } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag index 4f344369..bbcc9584 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.frag +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.frag @@ -4,7 +4,6 @@ in vec3 vNormal; in vec2 vTexCoord; in vec3 vWorldPos; -in vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights), from mesh_modern.vert in flat uvec2 vTextureHandle; in flat uint vTextureLayer; @@ -32,11 +31,36 @@ layout(std140, binding = 1) uniform SceneLighting { vec4 uCameraAndTime; }; -// A7 (2026-06-15): per-vertex lighting moved to mesh_modern.vert (Gouraud) to match -// retail's fixed-function per-vertex T&L — a per-pixel evaluation made a hard "spotlight" -// pool. The SceneLighting UBO above is still declared here for fog (uFogParams/uFogColor/ -// uCameraAndTime) + the lightning-flash bump; its uLights[]/uCellAmbient are now consumed -// in the vertex shader. The std140 layout must stay identical to the vert + the CPU upload. +vec3 accumulateLights(vec3 N, vec3 worldPos) { + vec3 lit = uCellAmbient.xyz; + int activeLights = int(uCellAmbient.w); + for (int i = 0; i < 8; ++i) { + if (i >= activeLights) break; + int kind = int(uLights[i].posAndKind.w); + vec3 Lcol = uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w; + if (kind == 0) { + vec3 Ldir = -uLights[i].dirAndRange.xyz; + float ndl = max(0.0, dot(N, Ldir)); + lit += Lcol * ndl; + } else { + vec3 toL = uLights[i].posAndKind.xyz - worldPos; + float d = length(toL); + float range = uLights[i].dirAndRange.w; + if (d < range && range > 1e-3) { + vec3 Ldir = toL / max(d, 1e-4); + float ndl = max(0.0, dot(N, Ldir)); + float atten = 1.0; + if (kind == 2) { + float cos_edge = cos(uLights[i].coneAngleEtc.x * 0.5); + float cos_l = dot(-Ldir, uLights[i].dirAndRange.xyz); + atten *= (cos_l > cos_edge) ? 1.0 : 0.0; + } + lit += Lcol * ndl * atten; + } + } + } + return lit; +} vec3 applyFog(vec3 lit, vec3 worldPos) { int mode = int(uFogParams.w); @@ -82,8 +106,8 @@ void main() { if (color.a < 0.05) discard; } - // Per-vertex Gouraud lighting from the vertex shader (ambient + capped lights). - vec3 lit = vLit; + vec3 N = normalize(vNormal); + vec3 lit = accumulateLights(N, vWorldPos); // Lightning flash — additive scene bump (matches mesh_instanced.frag). lit += uFogParams.z * vec3(0.6, 0.6, 0.75); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index dd8f7f4a..ce4378ac 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -69,42 +69,6 @@ layout(std430, binding = 3) readonly buffer ClipSlotBuf { uint instanceClipSlot[]; }; -// === Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ===== -// retail picks up-to-8 point/spot lights PER OBJECT by the object's own position -// (minimize_object_lighting 0x0054d480), so a torch always lights the wall it -// sits on, camera-INDEPENDENTLY. The previous single global nearest-8-to-CAMERA -// UBO set (LightManager.Tick) made a wall brighten as the camera approached -// (its torches swapping into the global top-8). Two SSBOs replace that for -// point/spot lights (the SUN + ambient still come from the SceneLighting UBO): -// -// binding=4 — GLOBAL point/spot light array, uploaded once per frame from -// LightManager.PointSnapshot. The index of a light here is stable for the frame. -// binding=5 — per-instance light SET: MaxLightsPerObject(8) int indices per -// instance INTO gLights[] (-1 = unused slot), parallel to the binding=0 -// instance buffer and indexed by the SAME instanceIndex. WbDrawDispatcher fills -// it once per entity (the set is constant across the entity's parts/tuples). -struct GlobalLight { - vec4 posAndKind; - vec4 dirAndRange; - vec4 colorAndIntensity; - vec4 coneAngleEtc; -}; -layout(std430, binding = 4) readonly buffer GlobalLightBuf { - GlobalLight gLights[]; -}; -layout(std430, binding = 5) readonly buffer InstanceLightSetBuf { - int instanceLightIdx[]; // 8 per instance; -1 = unused -}; - -// #142: per-instance "indoor" flag, 1 per instance, parallel to the binding=0 -// instance buffer (same instanceIndex). 1 = object parented to an EnvCell (skip the -// sun — retail's useSunlight==0 interior stage); 0 = outdoor object (gets the sun). -// Read ONLY inside the uniform `uLightingMode == 0` branch below, so the mode-1 -// (EnvCell shell) path provably never touches it — EnvCellRenderer need not bind it. -layout(std430, binding = 6) readonly buffer InstanceIndoorBuf { - uint instanceIndoor[]; -}; - // Core profile: redeclare gl_PerVertex so writing gl_ClipDistance[] is legal // alongside gl_Position. The array is sized 8 to match the CellClip plane budget // and the GL guarantee (GL_MAX_CLIP_DISTANCES >= 8). The host enables @@ -131,132 +95,10 @@ uniform mat4 uViewProjection; // _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's // uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845. uniform int uDrawIDOffset; -uniform int uLightingMode; // A7 Fix D: 0 = OBJECT (plain Lambert + sun), 1 = ENVCELL (half-Lambert wrap, no sun) - -// SceneLighting UBO — binding=1 in the UBO namespace (GL keeps the SSBO and UBO -// binding tables separate, so this coexists with the binding=1 BatchBuffer SSBO -// above). IDENTICAL std140 layout to mesh_modern.frag. -// -// A7 (2026-06-15): lighting moved from the FRAGMENT shader to HERE (per-VERTEX) so -// torch/point lights Gouraud-interpolate across each triangle the way retail's -// fixed-function T&L does (D3D DrawEnvCell vertex bake + minimize_object_lighting for -// objects). A per-PIXEL evaluation made a tight bright "spotlight" pool on flat walls; -// per-vertex spreads it into a soft, broad gradient with no hard edge. -struct Light { - vec4 posAndKind; - vec4 dirAndRange; - vec4 colorAndIntensity; - vec4 coneAngleEtc; -}; -layout(std140, binding = 1) uniform SceneLighting { - Light uLights[8]; - vec4 uCellAmbient; - vec4 uFogParams; - vec4 uFogColor; - vec4 uCameraAndTime; -}; - -// Faithful calc_point_light (0x0059c8b0) contribution from ONE point/spot light — -// the wrap + norm shape, factored out so the per-object SSBO loop shares it. D = -// light − vertex, used UN-normalised (length = dist); N is the unit vertex normal. -// Returns the RGB to ADD, already per-channel capped to the light's own colour. -vec3 pointContribution(vec3 N, vec3 worldPos, GlobalLight L) { - int kind = int(L.posAndKind.w); - vec3 toL = L.posAndKind.xyz - worldPos; // D (un-normalised) - float distsq = dot(toL, toL); - float d = sqrt(distsq); - float range = L.dirAndRange.w; // falloff_eff = Falloff × 1.3 (static) / × 1.5 (dynamic) - if (d >= range || range <= 1e-4) return vec3(0.0); - float intensity = L.colorAndIntensity.w; - vec3 baseCol = L.colorAndIntensity.xyz; - - // #143: DYNAMIC lights (viewer fill, portal, server-object lights — flagged by - // coneAngleEtc.y==1 from GlobalLightPacker) use retail's D3D hardware attenuation - // (config_hardware_light 0x0059ad30): a POINT light is given Attenuation1=1 ⇒ - // att = 1/d (inverse-LINEAR), plain Lambert N·L, hard range cutoff. That spreads - // softly across the room (the portal tint, the viewer fill) instead of the static - // bake's 1/d³ distance-cube, which makes a tight concentrated pool. No per-light - // cap — D3D accumulates then saturates, which accumulateLights does via min(pointAcc,1). - if (L.coneAngleEtc.y > 0.5) { - vec3 Ldir = toL / max(d, 1e-4); - float ndl = max(0.0, dot(N, Ldir)); - if (ndl <= 0.0) return vec3(0.0); - if (kind == 2) { // dynamic spot: hard cos-cone gate - if (dot(-Ldir, L.dirAndRange.xyz) <= cos(L.coneAngleEtc.x * 0.5)) return vec3(0.0); - } - return (intensity * ndl / max(d, 1e-3)) * baseCol; // att = 1/d - } - - // ── STATIC dat-baked lights: retail's per-vertex bake (calc_point_light 0x0059c8b0) ── - // A7 Fix D D-3: angular term by lighting path. ENVCELL bake (mode 1) keeps the - // half-Lambert wrap (lights surfaces angled away, retail calc_point_light); OBJECT - // mode (0) uses plain Lambert max(0,N·L) so a torch BEHIND a character contributes - // nothing (retail's hardware path). toL is un-normalised (length d). - float angular = (uLightingMode == 1) - ? (1.0 / 1.5) * (dot(N, toL) + 0.5 * d) // half-Lambert wrap (EnvCell bake) - : max(0.0, dot(N, toL)); // plain Lambert (object/hardware) - if (angular <= 0.0) return vec3(0.0); - // NORM branch (distance-cube): >1 m → distsq·d ≈ inverse-square soft far halo; - // <1 m → just d (dodge the near singularity). "Punchy near, soft far." - float norm = (distsq > 1.0) ? (distsq * d) : d; - float scale = (1.0 - d / range) * intensity * (angular / norm); - if (kind == 2) { - // Spotlight: hard-edged cos-cone gate layered on the point ramp. - vec3 Ldir = toL / max(d, 1e-4); - float cos_edge = cos(L.coneAngleEtc.x * 0.5); - float cos_l = dot(-Ldir, L.dirAndRange.xyz); - if (cos_l <= cos_edge) scale = 0.0; - } - // Per-channel no-blowout cap to the light's OWN colour (un-intensity-scaled): - // a single light can't push a channel past its colour. Summed lit clamped in frag. - return min(scale * baseCol, baseCol); -} - -vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { - vec3 lit = uCellAmbient.xyz; - - // SUN / directional — OBJECT path only (mode 0). retail's EnvCell path - // (minimize_envcell_lighting) enables only dynamic lights, NEVER the sun, so - // EnvCell walls (mode 1) get no directional sun wash (A7 Fix D D-4). - // #142: within mode 0, also skip the sun for indoor objects (ParentCellId is an - // EnvCell). This mirrors retail's per-draw-stage useSunlight toggle: the interior - // stage runs useSunlightSet(0) (PView::DrawCells 0x005a49f3), so indoor objects - // get no sun even in windowed buildings where the player's frame is not sun-killed. - if (uLightingMode == 0) { - if (instanceIndoor[instanceIndex] == 0u) { // #142: outdoor objects only get the sun - int activeLights = int(uCellAmbient.w); - for (int i = 0; i < 8; ++i) { - if (i >= activeLights) break; - if (int(uLights[i].posAndKind.w) != 0) continue; // directional only - vec3 Ldir = -uLights[i].dirAndRange.xyz; - float ndl = max(0.0, dot(N, Ldir)); - lit += uLights[i].colorAndIntensity.xyz * uLights[i].colorAndIntensity.w * ndl; - } - } - } - - // POINT / SPOT torches: their OWN accumulator (A7 Fix D, D-1). Retail's - // SetStaticLightingVertexColors sums the static point lights from BLACK and - // clamps the SUM to [0,1] before anything else (a baked emissive term), so a - // few warm intensity-100 torches can't push the whole pixel to white the way - // folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor - // (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged. - vec3 pointAcc = vec3(0.0); - int base = instanceIndex * 8; - for (int k = 0; k < 8; ++k) { - int gi = instanceLightIdx[base + k]; - if (gi < 0) continue; - pointAcc += pointContribution(N, worldPos, gLights[gi]); - } - lit += min(pointAcc, vec3(1.0)); // clamp the torch sum on its own (retail baked emissive) - - return lit; // frag still does the final min(lit, 1.0) -} out vec3 vNormal; out vec2 vTexCoord; out vec3 vWorldPos; -out vec3 vLit; // A7: per-vertex Gouraud lighting (ambient + capped lights) out flat uvec2 vTextureHandle; out flat uint vTextureLayer; @@ -281,7 +123,6 @@ void main() { vWorldPos = worldPos.xyz; vNormal = normalize(mat3(model) * aNormal); - vLit = accumulateLights(vNormal, vWorldPos, instanceIndex); // A7: per-vertex Gouraud (per-object lights) vTexCoord = aTexCoord; BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB]; diff --git a/src/AcDream.App/Rendering/Shaders/ui_text.frag b/src/AcDream.App/Rendering/Shaders/ui_text.frag index 75c9cd3d..7740ea11 100644 --- a/src/AcDream.App/Rendering/Shaders/ui_text.frag +++ b/src/AcDream.App/Rendering/Shaders/ui_text.frag @@ -7,13 +7,10 @@ uniform sampler2D uTex; uniform int uUseTexture; void main() { - if (uUseTexture == 1) { + if (uUseTexture != 0) { // Font atlas is a single-channel R8 texture; red = coverage alpha. float coverage = texture(uTex, vUv).r; FragColor = vec4(vColor.rgb, vColor.a * coverage); - } else if (uUseTexture == 2) { - // RGBA dat sprite (decoded to RGBA8); modulate by tint/alpha. - FragColor = texture(uTex, vUv) * vColor; } else { FragColor = vColor; } diff --git a/src/AcDream.App/Rendering/TextRenderer.cs b/src/AcDream.App/Rendering/TextRenderer.cs index 88592057..ad04da1a 100644 --- a/src/AcDream.App/Rendering/TextRenderer.cs +++ b/src/AcDream.App/Rendering/TextRenderer.cs @@ -25,39 +25,14 @@ public sealed unsafe class TextRenderer : IDisposable private readonly Shader _shader; private readonly uint _vao; private readonly uint _vbo; - private readonly uint _whiteTex; // 1×1 white, for solid fills routed through the sprite bucket private int _vboCapacityBytes; private readonly List _textBuf = new(8192); private readonly List _rectBuf = new(1024); - // Submission-ordered sprite segments: consecutive DrawSprite calls with the - // SAME texture batch into one segment; a texture change starts a new segment. - // Drawing segments in submission order preserves painter z-order for - // sprite-on-sprite UI. (The old per-texture dictionary drew a REUSED texture - // at its FIRST-insertion point, so later bar sprites covered glyphs emitted - // earlier via the shared dat-font atlas — the stamina/mana numbers vanished.) - private sealed class SpriteSeg { public uint Texture; public readonly List Verts = new(256); } - private readonly List _spriteSegs = new(); - private int _segUsed; private int _textVerts; private int _rectVerts; private Vector2 _screenSize; - // Overlay layer — a parallel set of buckets drawn AFTER the normal sprite/rect/text - // buckets, so open popups/menus composite on top of EVERYTHING, including translucent - // rect panel backgrounds (which otherwise always win because rects flush after - // sprites). Routed by OverlayMode; the UI root sets it for the popup traversal. - private readonly List _overlayTextBuf = new(1024); - private readonly List _overlayRectBuf = new(256); - private readonly List _overlaySpriteSegs = new(); - private int _overlaySegUsed; - private int _overlayTextVerts; - private int _overlayRectVerts; - - /// When true, Draw* calls route to the overlay layer (flushed last, on top - /// of all normal-layer geometry). Set by the UI root around the popup/overlay pass. - public bool OverlayMode { get; set; } - public TextRenderer(GL gl, string shaderDir) { _gl = gl; @@ -81,20 +56,6 @@ public sealed unsafe class TextRenderer : IDisposable _gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0); _gl.BindVertexArray(0); - - // 1×1 white texture so DrawFill can route solid-colour quads through the SPRITE - // bucket (the shader multiplies texel×color → white×color = color). Lets a panel - // background draw UNDER its text in painter order, which DrawRect's separate - // bucket cannot (it always composites after all sprites). - _whiteTex = _gl.GenTexture(); - _gl.BindTexture(TextureTarget.Texture2D, _whiteTex); - Span whitePixel = stackalloc byte[] { 255, 255, 255, 255 }; - fixed (byte* wp = whitePixel) - _gl.TexImage2D(TextureTarget.Texture2D, 0, (int)InternalFormat.Rgba8, 1, 1, 0, - PixelFormat.Rgba, PixelType.UnsignedByte, wp); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Nearest); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMinFilter.Nearest); - _gl.BindTexture(TextureTarget.Texture2D, 0); } /// Begin a HUD pass. Call once per frame before any Draw* calls. @@ -103,32 +64,17 @@ public sealed unsafe class TextRenderer : IDisposable _screenSize = screenSize; _textBuf.Clear(); _rectBuf.Clear(); - _segUsed = 0; // pool the SpriteSeg objects across frames _textVerts = 0; _rectVerts = 0; - _overlayTextBuf.Clear(); - _overlayRectBuf.Clear(); - _overlaySegUsed = 0; - _overlayTextVerts = 0; - _overlayRectVerts = 0; - OverlayMode = false; } /// Draw a filled rectangle in screen pixel space. public void DrawRect(float x, float y, float w, float h, Vector4 color) { - if (OverlayMode) { AppendQuad(_overlayRectBuf, x, y, w, h, 0, 0, 0, 0, color); _overlayRectVerts += 6; } - else { AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); _rectVerts += 6; } + AppendQuad(_rectBuf, x, y, w, h, 0, 0, 0, 0, color); + _rectVerts += 6; } - /// Draw a solid-colour quad through the SPRITE bucket (and the overlay layer - /// when active), so it composites in painter order with sprites + dat-font text. Use - /// this — not — for a panel BACKGROUND that text draws on top of: - /// DrawRect's bucket always flushes after all sprites, so a rect background would cover - /// the text instead. - public void DrawFill(float x, float y, float w, float h, Vector4 color) - => DrawSprite(_whiteTex, x, y, w, h, 0f, 0f, 1f, 1f, color); - /// Draw a 1-pixel-thick outline rect. public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) { @@ -173,47 +119,16 @@ public sealed unsafe class TextRenderer : IDisposable if (gw > 0 && gh > 0) { - if (OverlayMode) { AppendQuad(_overlayTextBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _overlayTextVerts += 6; } - else { AppendQuad(_textBuf, gx, gy, gw, gh, g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, color); _textVerts += 6; } + AppendQuad(_textBuf, + gx, gy, gw, gh, + g.UvMinX, g.UvMinY, g.UvMaxX, g.UvMaxY, + color); + _textVerts += 6; } cursorX += g.Advance; } } - /// - /// Draw a textured sprite quad in screen pixel space with an explicit - /// source-UV rectangle (for 9-slice / atlas sub-regions). Batched per - /// GL texture handle; flushed with uUseTexture=2 (RGBA modulate). - /// - public void DrawSprite(uint texture, float x, float y, float w, float h, - float u0, float v0, float u1, float v1, Vector4 tint) - { - SpriteSeg seg = OverlayMode - ? NextSpriteSeg(_overlaySpriteSegs, ref _overlaySegUsed, texture) - : NextSpriteSeg(_spriteSegs, ref _segUsed, texture); - AppendQuad(seg.Verts, x, y, w, h, u0, v0, u1, v1, tint); - } - - /// Pick the sprite segment for : extend the current - /// same-texture run, else reuse a pooled segment, else allocate. Submission order is - /// preserved (painter z-order for sprite-on-sprite UI). - private static SpriteSeg NextSpriteSeg(List segs, ref int used, uint texture) - { - if (used > 0 && segs[used - 1].Texture == texture) - return segs[used - 1]; - if (used < segs.Count) - { - var s = segs[used++]; - s.Texture = texture; - s.Verts.Clear(); - return s; - } - var ns = new SpriteSeg { Texture = texture }; - segs.Add(ns); - used++; - return ns; - } - private static void AppendQuad(List buf, float x, float y, float w, float h, float u0, float v0, float u1, float v1, Vector4 color) @@ -244,9 +159,7 @@ public sealed unsafe class TextRenderer : IDisposable /// Upload + draw accumulated rects + text. font may be null if only DrawRect was used. public void Flush(BitmapFont? font) { - bool anyNormal = _segUsed > 0 || _textVerts > 0 || _rectVerts > 0; - bool anyOverlay = _overlaySegUsed > 0 || _overlayTextVerts > 0 || _overlayRectVerts > 0; - if (!anyNormal && !anyOverlay) return; + if (_textVerts == 0 && _rectVerts == 0) return; _shader.Use(); _shader.SetVec2("uScreenSize", _screenSize); @@ -258,85 +171,36 @@ public sealed unsafe class TextRenderer : IDisposable bool wasDepth = _gl.IsEnabled(EnableCap.DepthTest); bool wasBlend = _gl.IsEnabled(EnableCap.Blend); bool wasCull = _gl.IsEnabled(EnableCap.CullFace); - // The world pass leaves alpha-to-coverage + multisample enabled (WbDrawDispatcher, - // QualitySettings MSAA). If they bleed into the UI pass, each glyph's soft alpha - // EDGE is converted to dithered MSAA coverage instead of a clean alpha blend — - // the "text not sharp / fuzzy" artifact. The UI composites with straight alpha - // blending and must own this state (feedback_render_self_contained_gl_state). - bool wasA2C = _gl.IsEnabled(EnableCap.SampleAlphaToCoverage); - bool wasMsaa = _gl.IsEnabled(EnableCap.Multisample); - _gl.Disable(EnableCap.SampleAlphaToCoverage); - _gl.Disable(EnableCap.Multisample); _gl.Disable(EnableCap.DepthTest); _gl.Disable(EnableCap.CullFace); - _gl.DepthMask(false); _gl.Enable(EnableCap.Blend); _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - // LAYERED compositing for the UI (background → fill → text): - // 1. RGBA dat sprites — window chrome / panel backgrounds (behind) - // 2. Untextured rects — widget fills (e.g. vital bars) on the chrome - // 3. Text glyphs — on top - // Bucket 1 (sprites) draws in SUBMISSION (painter) order via _spriteSegs, - // so sprite-on-sprite z is preserved. Buckets 2 (rects) + 3 (debug text) - // composite on top, in that order. The OVERLAY layer repeats all three - // AFTER the normal layer, so open popups beat even the rect backgrounds. - DrawLayer(_spriteSegs, _segUsed, _rectBuf, _rectVerts, _textBuf, _textVerts, font); - DrawLayer(_overlaySpriteSegs, _overlaySegUsed, _overlayRectBuf, _overlayRectVerts, _overlayTextBuf, _overlayTextVerts, font); - - // Restore GL state. - _gl.DepthMask(true); - if (!wasBlend) _gl.Disable(EnableCap.Blend); - if (wasCull) _gl.Enable(EnableCap.CullFace); - if (wasDepth) _gl.Enable(EnableCap.DepthTest); - if (wasA2C) _gl.Enable(EnableCap.SampleAlphaToCoverage); - if (wasMsaa) _gl.Enable(EnableCap.Multisample); - - _gl.BindVertexArray(0); - } - - /// Draw one compositing layer: sprites (submission order, one call per - /// texture) → untextured rects → debug-font text. Shared by the normal and overlay - /// layers; GL state + shader are set up by . - private void DrawLayer( - List spriteSegs, int segUsed, - List rectBuf, int rectVerts, - List textBuf, int textVerts, BitmapFont? font) - { - // 1. RGBA dat sprites — one draw call per distinct GL texture. - if (segUsed > 0) - { - _shader.SetInt("uUseTexture", 2); - _gl.ActiveTexture(TextureUnit.Texture0); - _shader.SetInt("uTex", 0); - for (int i = 0; i < segUsed; i++) - { - var seg = spriteSegs[i]; - if (seg.Verts.Count == 0) continue; - _gl.BindTexture(TextureTarget.Texture2D, seg.Texture); - UploadBuffer(seg.Verts); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)(seg.Verts.Count / FloatsPerVertex)); - } - } - - // 2. Untextured rects — widget fills on top of the chrome. - if (rectVerts > 0) + // Untextured rects first — they form panel backgrounds. + if (_rectVerts > 0) { _shader.SetInt("uUseTexture", 0); - UploadBuffer(rectBuf); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)rectVerts); + UploadBuffer(_rectBuf); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_rectVerts); } - // 3. Textured debug-font text glyphs on top. - if (textVerts > 0 && font is not null) + // Textured text glyphs. + if (_textVerts > 0 && font is not null) { _shader.SetInt("uUseTexture", 1); _gl.ActiveTexture(TextureUnit.Texture0); _gl.BindTexture(TextureTarget.Texture2D, font.TextureId); _shader.SetInt("uTex", 0); - UploadBuffer(textBuf); - _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)textVerts); + UploadBuffer(_textBuf); + _gl.DrawArrays(PrimitiveType.Triangles, 0, (uint)_textVerts); } + + // Restore GL state. + if (!wasBlend) _gl.Disable(EnableCap.Blend); + if (wasCull) _gl.Enable(EnableCap.CullFace); + if (wasDepth) _gl.Enable(EnableCap.DepthTest); + + _gl.BindVertexArray(0); } private void UploadBuffer(List buf) @@ -359,7 +223,6 @@ public sealed unsafe class TextRenderer : IDisposable public void Dispose() { - _gl.DeleteTexture(_whiteTex); _gl.DeleteBuffer(_vbo); _gl.DeleteVertexArray(_vao); _shader.Dispose(); diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index bbc7d4b5..056ec01f 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -14,7 +14,6 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab private readonly GL _gl; private readonly DatCollection _dats; private readonly Dictionary _handlesBySurfaceId = new(); - private readonly Dictionary _sizeBySurfaceId = new(); /// /// Composite cache for surface-with-override-origtex entries (Phase 5 /// TextureChanges). Key = (baseSurfaceId, overrideOrigTextureId), @@ -31,18 +30,6 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab private readonly Dictionary<(uint surfaceId, uint origTexOverride, ulong paletteHash), uint> _handlesByPalette = new(); private uint _magentaHandle; - // Direct-RenderSurface caches for UI sprites: 0x06xxxxxx RenderSurface ids - // decoded directly (Portal/HighRes → DecodeRenderSurface), bypassing the - // Surface→SurfaceTexture chain that GetOrUpload uses for world materials. - private readonly Dictionary _handlesByRenderSurfaceId = new(); - private readonly Dictionary _rsSizeById = new(); - - // Ad-hoc handles produced by the public UploadRgba8(byte[],int,int,bool) wrapper - // (used by IconComposer for composited item icons). These are NOT stored in any - // of the keyed caches above, so Dispose must sweep this list to avoid leaking - // GL texture objects until process exit. - private readonly List _adhocHandles = new(); - private readonly Wb.BindlessSupport? _bindless; // Bindless / Texture2DArray parallel caches. Keys mirror the legacy three @@ -93,74 +80,6 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab return h; } - /// - /// Like but also returns the decoded - /// pixel dimensions. UI 9-slice geometry needs the source size to - /// compute slice UVs. Cached alongside the handle. - /// - public uint GetOrUpload(uint surfaceId, out int width, out int height) - { - if (_handlesBySurfaceId.TryGetValue(surfaceId, out var existing) - && _sizeBySurfaceId.TryGetValue(surfaceId, out var sz)) - { - width = sz.w; height = sz.h; - return existing; - } - - var decoded = DecodeFromDats(surfaceId, origTextureOverride: null, paletteOverride: null); - uint h = UploadRgba8(decoded); - _handlesBySurfaceId[surfaceId] = h; - _sizeBySurfaceId[surfaceId] = (decoded.Width, decoded.Height); - width = decoded.Width; height = decoded.Height; - return h; - } - - /// - /// Upload a UI sprite by its RenderSurface DataId (0x06xxxxxx), decoded - /// DIRECTLY (Portal/HighRes → DecodeRenderSurface) rather than through the - /// Surface→SurfaceTexture chain that uses - /// for world-geometry materials. This is the correct path for retail UI - /// chrome + font glyph sheets, which reference RenderSurface directly. - /// Paletted (PFID_P8 / PFID_INDEX16) UI sprites — e.g. the selected-object - /// health-bar track 0x0600193E — are decoded against the RenderSurface's own - /// DefaultPaletteId (same starting palette - /// uses); non-paletted formats have DefaultPaletteId==0 → palette null. Returns - /// a 1x1 magenta handle on miss. - /// - public uint GetOrUploadRenderSurface(uint renderSurfaceId, out int width, out int height, bool nearest = false) - { - if (_handlesByRenderSurfaceId.TryGetValue(renderSurfaceId, out var existing) - && _rsSizeById.TryGetValue(renderSurfaceId, out var sz)) - { - width = sz.w; height = sz.h; - return existing; - } - - DecodedTexture decoded; - if (_dats.Portal.TryGet(renderSurfaceId, out var rs) - || _dats.HighRes.TryGet(renderSurfaceId, out rs)) - { - // Resolve the surface's own default palette so paletted UI sprites decode - // correctly instead of the magenta fallback (the back-track 0x0600193E behind - // the selected-object health bar is PFID_P8/INDEX16). Non-paletted formats - // (DefaultPaletteId==0) keep the previous null-palette behaviour unchanged. - Palette? palette = rs.DefaultPaletteId != 0 - ? _dats.Get(rs.DefaultPaletteId) - : null; - decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette); - } - else - { - decoded = DecodedTexture.Magenta; - } - - uint h = UploadRgba8(decoded, nearest); - _handlesByRenderSurfaceId[renderSurfaceId] = h; - _rsSizeById[renderSurfaceId] = (decoded.Width, decoded.Height); - width = decoded.Width; height = decoded.Height; - return h; - } - /// /// Alpha-channel histogram for one decoded texture. Used to diagnose /// "why are clouds not transparent" — if cloud textures come out with @@ -557,19 +476,7 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab return composed; } - /// Uploads a raw RGBA8 byte array as a Texture2D. Used by - /// to upload CPU-composited icon layers. - /// The returned handle is tracked in and deleted by - /// . Callers must NOT also store the handle in any of the - /// keyed caches — that would cause a double-delete on Dispose. - public uint UploadRgba8(byte[] rgba, int width, int height, bool nearest = false) - { - uint h = UploadRgba8(new DecodedTexture(rgba, width, height), nearest); - _adhocHandles.Add(h); - return h; - } - - private uint UploadRgba8(DecodedTexture decoded, bool nearest = false) + private uint UploadRgba8(DecodedTexture decoded) { uint tex = _gl.GenTexture(); _gl.BindTexture(TextureTarget.Texture2D, tex); @@ -586,11 +493,8 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab PixelType.UnsignedByte, p); - // Point (nearest) sampling for pixel-exact UI text — bilinear softens the dat - // font's small glyphs. Other surfaces use bilinear. - int filter = nearest ? (int)TextureMinFilter.Nearest : (int)TextureMinFilter.Linear; - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, filter); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, filter); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat); _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat); @@ -678,17 +582,5 @@ public sealed unsafe class TextureCache : Wb.ITextureCachePerInstance, IDisposab _gl.DeleteTexture(_magentaHandle); _magentaHandle = 0; } - - // RenderSurface (UI sprite) handles — pre-existing gap: this dict was populated - // by GetOrUploadRenderSurface but was not swept here before this fix. - foreach (var h in _handlesByRenderSurfaceId.Values) - _gl.DeleteTexture(h); - _handlesByRenderSurfaceId.Clear(); - - // Ad-hoc handles from the public UploadRgba8(byte[],int,int,bool) wrapper - // (IconComposer composited icons). Not stored in any keyed cache. - foreach (var h in _adhocHandles) - _gl.DeleteTexture(h); - _adhocHandles.Clear(); } } diff --git a/src/AcDream.Content/DatCollectionAdapter.cs b/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs similarity index 66% rename from src/AcDream.Content/DatCollectionAdapter.cs rename to src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs index 6c8b3aa5..60ed18c7 100644 --- a/src/AcDream.Content/DatCollectionAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/DatCollectionAdapter.cs @@ -7,32 +7,19 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; -namespace AcDream.Content; +namespace AcDream.App.Rendering.Wb; /// -/// THE adapter, -/// shared by all three consumers: ObjectMeshManager (AcDream.App, via -/// WbMeshAdapter), acdream-bake (AcDream.Bake.BakeRunner), and the pak -/// equivalence suite (AcDream.Content.Tests). MP1b's review found the -/// original App-internal copy had silently forked into three near-identical -/// versions — drift between them is exactly what the live-vs-pak equivalence -/// suite CANNOT detect (both sides would drift together only if they share -/// one implementation), so unification here is load-bearing, not cosmetic. -/// GL-free (DatReaderWriter only), hence Content is the right home -/// (established in MP1a alongside ). +/// Adapts acdream's to WB's interface. /// -/// History: O-D7 originally introduced this adapter (App-internal) -/// because ObjectMeshManager had 26 _dats.X call sites, letting it -/// stay byte-identical to the WB original while routing all DAT I/O through -/// the single DatCollection. -/// -/// Iteration properties return the REAL dat iterations (the App copy -/// hardcoded 0 — a stub nothing read; the bake tool stamps iterations into -/// the pak header from DatCollection directly, and any future consumer of -/// these properties should get truth, so the unification intentionally -/// keeps the real values). +/// O-D7 fallback path: taken because ObjectMeshManager has 26 _dats.X call sites (threshold is 20), +/// making a full refactor to DatCollection larger than spec permits in a single task. +/// This adapter lets ObjectMeshManager stay byte-identical to the WB original while +/// routing all DAT I/O through our single DatCollection. The adapter is dropped in T7 +/// when the WorldBuilder project reference is removed entirely. /// -public sealed class DatCollectionAdapter : IDatReaderWriter { +internal sealed class DatCollectionAdapter : IDatReaderWriter +{ private readonly DatCollection _dats; private readonly DatDatabaseWrapper _portal; private readonly DatDatabaseWrapper _cell; @@ -40,7 +27,8 @@ public sealed class DatCollectionAdapter : IDatReaderWriter { private readonly DatDatabaseWrapper _language; private readonly ReadOnlyDictionary _cellRegions; - public DatCollectionAdapter(DatCollection dats) { + public DatCollectionAdapter(DatCollection dats) + { ArgumentNullException.ThrowIfNull(dats); _dats = dats; _portal = new DatDatabaseWrapper(dats.Portal); @@ -62,16 +50,18 @@ public sealed class DatCollectionAdapter : IDatReaderWriter { public IDatDatabase HighRes => _highRes; public IDatDatabase Language => _language; - // RegionFileMap is used by some WB internals but not by any acdream consumer. + // RegionFileMap is used by some WB internals but not by ObjectMeshManager. public ReadOnlyDictionary RegionFileMap => new ReadOnlyDictionary(new Dictionary()); - public int PortalIteration => _portal.Iteration; - public int CellIteration => _cell.Iteration; - public int HighResIteration => _highRes.Iteration; - public int LanguageIteration => _language.Iteration; + // Iteration properties — not used by ObjectMeshManager, so delegate to 0. + public int PortalIteration => 0; + public int CellIteration => 0; + public int HighResIteration => 0; + public int LanguageIteration => 0; - public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) { + public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) + { // Route to cell db (the only region we expose) return _dats.Cell.TryGetFileBytes(fileId, ref bytes, out bytesRead); } @@ -81,12 +71,15 @@ public sealed class DatCollectionAdapter : IDatReaderWriter { /// Mirrors DefaultDatReaderWriter.ResolveId — checks each underlying DatDatabase /// via DatDatabase.TypeFromId (which reads the type range tables). /// - public IEnumerable ResolveId(uint id) { + public IEnumerable ResolveId(uint id) + { var results = new List(); - void CheckDb(DatDatabaseWrapper wrapper) { + void CheckDb(DatDatabaseWrapper wrapper) + { var rawDb = wrapper.RawDatabase; - if (rawDb.Tree.TryGetFile(id, out _)) { + if (rawDb.Tree.TryGetFile(id, out _)) + { var type = rawDb.TypeFromId(id); if (type != DBObjType.Unknown) results.Add(new IDatReaderWriter.IdResolution(wrapper, type)); @@ -108,7 +101,8 @@ public sealed class DatCollectionAdapter : IDatReaderWriter { public bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj => throw new NotSupportedException("DatCollectionAdapter is read-only."); - public void Dispose() { + public void Dispose() + { // The underlying DatCollection is owned by the caller — do not dispose it here. // Individual wrapper objects hold no unmanaged resources. } @@ -116,16 +110,17 @@ public sealed class DatCollectionAdapter : IDatReaderWriter { /// /// Wraps a as . -/// Mirrors WorldBuilder.Shared.Services.DefaultDatDatabase (taken into our -/// tree in Phase O; moved here from AcDream.App in the MP1b adapter -/// unification). +/// Mirrors WorldBuilder.Shared.Services.DefaultDatDatabase but lives in our namespace +/// so the WorldBuilder project reference can be dropped in T7. /// -public sealed class DatDatabaseWrapper : IDatDatabase { +internal sealed class DatDatabaseWrapper : IDatDatabase +{ private readonly DatDatabase _db; private readonly ConcurrentDictionary<(Type, uint), IDBObj> _cache = new(); private readonly object _lock = new(); - public DatDatabaseWrapper(DatDatabase db) { + public DatDatabaseWrapper(DatDatabase db) + { ArgumentNullException.ThrowIfNull(db); _db = db; } @@ -139,14 +134,18 @@ public sealed class DatDatabaseWrapper : IDatDatabase { public IEnumerable GetAllIdsOfType() where T : IDBObj => _db.GetAllIdsOfType(); - public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj { - if (_cache.TryGetValue((typeof(T), fileId), out var cached)) { + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj + { + if (_cache.TryGetValue((typeof(T), fileId), out var cached)) + { value = (T)cached; return true; } - lock (_lock) { - if (_db.TryGet(fileId, out value)) { + lock (_lock) + { + if (_db.TryGet(fileId, out value)) + { _cache.TryAdd((typeof(T), fileId), value); return true; } @@ -155,9 +154,8 @@ public sealed class DatDatabaseWrapper : IDatDatabase { // a miss for an id whose BTree entry EXISTS is always an anomaly — // either Unpack returned false or the lookup flickered transiently. // Legit not-found probes (e.g. Portal→HighRes fallback) stay silent. - // Kept through the MP1b unification — the tripwire now covers the - // bake tool and the equivalence suite too, not just the client. - if (_db.Tree.TryGetFile(fileId, out _)) { + if (_db.Tree.TryGetFile(fileId, out _)) + { Console.WriteLine( $"[dat-miss] {typeof(T).Name} 0x{fileId:X8} entry EXISTS but TryGet failed " + $"(thread={Environment.CurrentManagedThreadId})"); @@ -167,14 +165,18 @@ public sealed class DatDatabaseWrapper : IDatDatabase { return false; } - public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) { - lock (_lock) { + public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) + { + lock (_lock) + { return _db.TryGetFileBytes(fileId, out value); } } - public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) { - lock (_lock) { + public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) + { + lock (_lock) + { return _db.TryGetFileBytes(fileId, ref bytes, out bytesRead); } } @@ -182,7 +184,8 @@ public sealed class DatDatabaseWrapper : IDatDatabase { public bool TrySave(T obj, int iteration = 0) where T : IDBObj => throw new NotSupportedException("DatDatabaseWrapper is read-only."); - public void Dispose() { + public void Dispose() + { // The underlying DatDatabase is owned by DatCollection — do not dispose here. } } diff --git a/src/AcDream.Content/EdgeLineBuilder.cs b/src/AcDream.App/Rendering/Wb/EdgeLineBuilder.cs similarity index 96% rename from src/AcDream.Content/EdgeLineBuilder.cs rename to src/AcDream.App/Rendering/Wb/EdgeLineBuilder.cs index 028dd821..cec23146 100644 --- a/src/AcDream.Content/EdgeLineBuilder.cs +++ b/src/AcDream.App/Rendering/Wb/EdgeLineBuilder.cs @@ -1,11 +1,7 @@ using System.Numerics; using DatReaderWriter.Types; -// MP1a (2026-07-05, Task 4): moved to AcDream.Content, namespace only — -// consumed by MeshExtractor.PrepareCellStructEdgeLineData, which must be -// GL-free. - -namespace AcDream.Content { +namespace AcDream.App.Rendering.Wb { public static class EdgeLineBuilder { public static List BuildEdgeLines(CellStruct cellStruct) { var edgeMap = new Dictionary>(); diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 0a65655b..2fe1a37a 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -88,17 +88,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable private uint _clipSlotBuffer; private uint[] _clipSlotData = Array.Empty(); - // A7 Fix D (D-2): this renderer owns its lighting (self-contained GL state, - // like uViewProjection) instead of reading the SSBO 4/5 WbDrawDispatcher last - // left bound. binding=4 = global point-light snapshot (same data/indices as the - // dispatcher, via GlobalLightPacker); binding=5 = 8 int indices per instance. - private uint _globalLightsSsbo; // binding=4 - private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16]; - private uint _instLightSetSsbo; // binding=5 - private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; - private System.Collections.Generic.IReadOnlyList? _pointSnapshot; - private readonly System.Collections.Generic.Dictionary _cellLightSetCache = new(); - // Phase U.3: SHARED per-cell clip-region SSBO (binding=2) handed in via // SetClipRegionSsbo (the GameWindow-level ClipFrame buffer). When 0, we bind // our own one-slot no-clip fallback so the shader never reads an unbound SSBO. @@ -242,18 +231,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable _gl.BufferData(GLEnum.ShaderStorageBuffer, (nuint)(_modernInstanceCapacity * sizeof(uint)), null, GLEnum.DynamicDraw); - // A7 Fix D (D-2): binding=4 global lights + binding=5 per-instance light set. - _gl.GenBuffers(1, out _globalLightsSsbo); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_globalLightData.Length * sizeof(float)), null, GLEnum.DynamicDraw); - - _gl.GenBuffers(1, out _instLightSetSsbo); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(_modernInstanceCapacity * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)), - null, GLEnum.DynamicDraw); - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, 0); _gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0); } @@ -285,17 +262,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable public void SetClipRouting(IReadOnlyDictionary? cellIdToSlot) => _cellIdToSlot = cellIdToSlot; - /// - /// A7 Fix D (D-2): hand the renderer this frame's point-light snapshot - /// (LightManager.PointSnapshot). Call once per frame BEFORE Render, alongside - /// the WbDrawDispatcher snapshot wire-in. Indices in the per-cell light sets - /// reference this snapshot, which is also uploaded to binding=4 here, so the - /// pass is self-contained. Null/empty -> shells receive no point lights. - /// - public void SetPointSnapshot( - System.Collections.Generic.IReadOnlyList? snapshot) - => _pointSnapshot = snapshot; - // --------------------------------------------------------------------------- // GetEnvCellGeomId // Verbatim copy of WB EnvCellRenderManager.cs:94-103. @@ -877,7 +843,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable // WB EnvCellRenderManager.cs:406-409: uniform state setup. _shader.SetInt("uRenderPass", (int)renderPass); _shader.SetInt("uFilterByCell", 0); - _shader.SetInt("uLightingMode", 1); // A7 Fix D D-3/D-4: EnvCell bake (wrap points, no sun) // Phase U.4 ROOT-CAUSE FIX (cell-shell flicker / "transparent walls when // moving"): upload uViewProjection HERE rather than inheriting it from @@ -1032,65 +997,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable } } - /// - /// True if the cell's prepared snapshot has any transparent render batch. - /// The pview shell pass uses this to skip the (heavy per-frame) transparent - /// call for opaque-only cells — most cell geometry is - /// opaque walls/floors/ceilings, so this removes the bulk of the per-cell - /// transparent draws. Read-only; mirrors the [shell] probe's batch scan. - /// - public bool CellHasTransparent(uint cellId) - { - var snapshot = _activeSnapshot; - if (snapshot is null || !snapshot.BatchedByCell.TryGetValue(cellId, out var gfxDict)) - return false; - foreach (var (gfxObjId, transforms) in gfxDict) - { - if (transforms.Count == 0) continue; - var rd = _meshManager.TryGetRenderData(gfxObjId); - if (rd is null) continue; - foreach (var b in rd.Batches) - if (b.IsTransparent) return true; - } - return false; - } - - // --------------------------------------------------------------------------- - // GetCellLightSet (A7 Fix D D-2 helper) - // Per-cell up-to-8 point lights, cached per frame. Camera-independent, like - // WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds. - // --------------------------------------------------------------------------- - - // A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world - // bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet). - // Cached per frame; unused slots are -1 (shader adds no point light there). - private int[] GetCellLightSet(uint cellId) - { - if (_cellLightSetCache.TryGetValue(cellId, out var cached)) return cached; - - var set = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; - System.Array.Fill(set, -1); - - var snap = _pointSnapshot; - // Landblocks are keyed by the streaming landblock id 0xXXYYFFFF - // (GameWindow: (x<<24)|(y<<16)|0xFFFF), NOT 0xXXYY0000 — so the landblock - // key is (cellId & 0xFFFF0000) | 0xFFFF. The old `cellId & 0xFFFF0000` key - // (0xXXYY0000) NEVER matched a registered landblock, so this lookup always - // missed: SelectForObject never ran and every EnvCell wall received ZERO - // point lights (the entire "indoor torches/lanterns don't light the room" - // bug — confirmed by the [cell-light] probe: inBounds=False for every cell). - if (snap is { Count: > 0 } && - _landblocks.TryGetValue((cellId & 0xFFFF0000u) | 0xFFFFu, out var lb) && - lb.EnvCellBounds.TryGetValue(cellId, out var b)) - { - Vector3 center = (b.Min + b.Max) * 0.5f; - float radius = (b.Max - b.Min).Length() * 0.5f; - AcDream.Core.Lighting.LightManager.SelectForObject(snap, center, radius, set); - } - _cellLightSetCache[cellId] = set; - return set; - } - // --------------------------------------------------------------------------- // RenderModernMDIInternal // Extracted from WB BaseObjectRenderManager.cs:709-848 (single-slot variant). @@ -1110,15 +1016,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable int passIdx = (int)renderPass; if (passIdx < 0 || passIdx > 2) return; - // A7 Fix D (D-2): per-frame per-cell light-set cache (built lazily in - // GetCellLightSet below). Clear once here so each cell gets a fresh lookup - // using this frame's _pointSnapshot. Called for EVERY pass (opaque AND - // transparent); the cache entries are stable within a frame since PointSnapshot - // doesn't change between Render calls, so clearing once (at the opaque pass) - // and leaving stale entries for the transparent pass would also be correct, but - // clearing both is safe and matches WbDrawDispatcher's per-call ComputeEntityLightSet. - _cellLightSetCache.Clear(); - // §4 outdoor full-world flap (2026-06-10): hoisted from below the SSBO uploads. // Without the global VAO nothing can draw, and returning AFTER the pass state // was established leaked it (same early-out shape as the totalDraws==0 leak — @@ -1316,35 +1213,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable (nuint)(uniqueInstanceCount * sizeof(uint)), ptr); } - // A7 Fix D (D-2): per-instance 8-int light set, parallel to the transforms, - // keyed on the cell each shell instance belongs to (mirrors _clipSlotData). - int lightStride = AcDream.Core.Lighting.LightManager.MaxLightsPerObject; - if (_lightSetData.Length < uniqueInstanceCount * lightStride) - _lightSetData = new int[System.Math.Max(_lightSetData.Length * 2, uniqueInstanceCount * lightStride)]; - for (int i = 0; i < uniqueInstanceCount; i++) - { - int[] cellSet = GetCellLightSet(allInstances[i].CellId); - System.Array.Copy(cellSet, 0, _lightSetData, i * lightStride, lightStride); - } - - // A7 Fix D (D-2): upload binding=4 (global lights) + binding=5 (per-instance set). - int lightCount = AcDream.Core.Lighting.GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); - int glUploadCount = lightCount > 0 ? lightCount : 1; - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _globalLightsSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), - null, GLEnum.DynamicDraw); - fixed (float* gp = _globalLightData) - _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, - (nuint)(glUploadCount * AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * sizeof(float)), gp); - - _gl.BindBuffer(GLEnum.ShaderStorageBuffer, _instLightSetSsbo); - _gl.BufferData(GLEnum.ShaderStorageBuffer, - (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), null, GLEnum.DynamicDraw); - fixed (int* lp = _lightSetData) - _gl.BufferSubData(GLEnum.ShaderStorageBuffer, 0, - (nuint)(uniqueInstanceCount * lightStride * sizeof(int)), lp); - // WB BaseObjectRenderManager.cs:807-818: bind VAO + SSBOs + barrier. // (globalVao validated at the top of the method — a return here would leak the // pass state established above.) @@ -1360,8 +1228,6 @@ public sealed unsafe class EnvCellRenderer : IDisposable // (binding=2, via the GameWindow ClipFrame or our no-clip fallback). _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 3, _clipSlotBuffer); BindClipRegionBinding2(); - _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 4, _globalLightsSsbo); // A7 Fix D (D-2) - _gl.BindBufferBase(GLEnum.ShaderStorageBuffer, 5, _instLightSetSsbo); // A7 Fix D (D-2) _gl.BindBuffer(GLEnum.DrawIndirectBuffer, _mdiCommandBuffer); _gl.MemoryBarrier(MemoryBarrierMask.ShaderStorageBarrierBit | MemoryBarrierMask.CommandBarrierBit); @@ -1577,7 +1443,5 @@ public sealed unsafe class EnvCellRenderer : IDisposable if (_modernBatchBuffer != 0) { _gl.DeleteBuffer(_modernBatchBuffer); _modernBatchBuffer = 0; } if (_clipSlotBuffer != 0) { _gl.DeleteBuffer(_clipSlotBuffer); _clipSlotBuffer = 0; } // Phase U.3 if (_fallbackClipRegionSsbo != 0) { _gl.DeleteBuffer(_fallbackClipRegionSsbo); _fallbackClipRegionSsbo = 0; } // Phase U.3 - if (_globalLightsSsbo != 0) { _gl.DeleteBuffer(_globalLightsSsbo); _globalLightsSsbo = 0; } // A7 Fix D (D-2) - if (_instLightSetSsbo != 0) { _gl.DeleteBuffer(_instLightSetSsbo); _instLightSetSsbo = 0; } // A7 Fix D (D-2) } } diff --git a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs index 3e2c78e2..3441c66a 100644 --- a/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs +++ b/src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs @@ -1,4 +1,3 @@ -using AcDream.Content; using Chorizite.Core.Render.Enums; using Silk.NET.OpenGL; using System; diff --git a/src/AcDream.Content/IDatReaderWriter.cs b/src/AcDream.App/Rendering/Wb/IDatReaderWriter.cs similarity index 86% rename from src/AcDream.Content/IDatReaderWriter.cs rename to src/AcDream.App/Rendering/Wb/IDatReaderWriter.cs index 2ab4afda..d6c7d9e3 100644 --- a/src/AcDream.Content/IDatReaderWriter.cs +++ b/src/AcDream.App/Rendering/Wb/IDatReaderWriter.cs @@ -5,17 +5,12 @@ using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; // Phase O-T7: verbatim copy of WorldBuilder.Shared.Services.IDatReaderWriter + -// IDatDatabase, taken into our tree so the WorldBuilder.Shared project -// reference could be dropped. -// -// MP1a (2026-07-05, Task 4): moved to AcDream.Content, namespace only — -// MeshExtractor's constructor takes IDatReaderWriter and must be GL-free. -// MP1b review (finding 7): the concrete DatCollection-backed implementation -// (DatCollectionAdapter, this assembly) now lives HERE too — one shared -// adapter for ObjectMeshManager (App), acdream-bake, and the equivalence -// suite, so the three consumers can never drift apart. +// IDatDatabase into the AcDream.App.Rendering.Wb namespace so the +// WorldBuilder.Shared project reference can be dropped. +// The only consumer of IDatReaderWriter in acdream is DatCollectionAdapter + +// ObjectMeshManager, both already in this namespace. -namespace AcDream.Content; +namespace AcDream.App.Rendering.Wb; /// /// Interface for the dat reader/writer diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index 748d6f35..b9261ad1 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -15,12 +15,128 @@ using System.Numerics; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; -using AcDream.Content; using AcDream.Core.Rendering.Wb; using PixelFormat = Silk.NET.OpenGL.PixelFormat; using BoundingBox = Chorizite.Core.Lib.BoundingBox; +using BCnEncoder.Decoder; +using BCnEncoder.Shared; +using BCnEncoder.ImageSharp; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; namespace AcDream.App.Rendering.Wb { + /// + /// Vertex format for scenery mesh rendering: position, normal, UV. + /// + [StructLayout(LayoutKind.Sequential)] + public struct VertexPositionNormalTexture { + public Vector3 Position; + public Vector3 Normal; + public Vector2 UV; + + public static int Size => 8 * sizeof(float); // 3+3+2 = 8 floats = 32 bytes + + public VertexPositionNormalTexture(Vector3 position, Vector3 normal, Vector2 uv) { + Position = position; + Normal = normal; + UV = uv; + } + } + + /// + /// Staged data for a particle emitter to be created on the GL thread. + /// + public struct StagedEmitter { + public ParticleEmitter Emitter; + public uint PartIndex; + public Matrix4x4 Offset; + } + + /// + /// CPU-side mesh data prepared on a background thread. + /// Contains vertex data and per-batch index/texture info, but NO GPU resources. + /// + public class ObjectMeshData { + public ulong ObjectId { get; set; } + public bool IsSetup { get; set; } + public VertexPositionNormalTexture[] Vertices { get; set; } = Array.Empty(); + public List Batches { get; set; } = new(); + + /// + /// #125 (2026-06-12): GL upload-retry counter. A failed + /// (returns null from its + /// catch) used to be dropped permanently — the staged item was consumed, + /// no render data was produced, and the prepared data lingered in the CPU + /// cache where PrepareMeshDataAsync's cache-hit short-circuit + /// returned it without ever re-staging it for upload (session-sticky + /// invisible mesh, one [wb-error] line). The drain loop now re-stages a + /// failed upload for the NEXT frame up to times. The counter lives on the mesh-data object so + /// it resets to 0 naturally whenever the id is re-prepared (fresh object), + /// and bounds a deterministic GL failure to a few loud lines instead of a + /// silent permanent drop OR an unbounded per-frame retry storm. Retail + /// loads content synchronously and has no such failure mode — this + /// converges our async pipeline toward that guarantee. + /// + public int UploadAttempts; + + /// For EnvCell: the geometry of the cell itself. + public ObjectMeshData? EnvCellGeometry { get; set; } + + /// For Setup objects: parts with their local transforms. + public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new(); + + /// Particle emitters from physics scripts. + public List ParticleEmitters { get; set; } = new(); + + /// Per-format texture atlas data (to be uploaded to GPU on main thread). + public Dictionary<(int Width, int Height, TextureFormat Format), List> TextureBatches { get; set; } = new(); + + /// Local bounding box. + public BoundingBox BoundingBox { get; set; } + + /// Approximate center point used for depth sorting / transparency ordering. + public Vector3 SortCenter { get; set; } + + /// DataID of a simpler GfxObj to use at long distance / low quality, or GfxObjDegradeInfo. + public uint DIDDegrade { get; set; } + + /// Sphere used for mouse selection. + public Sphere? SelectionSphere { get; set; } + + /// Edge line vertices for Environment wireframe rendering. + public Vector3[] EdgeLines { get; set; } = Array.Empty(); + } + + /// + /// CPU-side data for a single rendering batch (indices + texture reference). + /// + public class MeshBatchData { + public ushort[] Indices { get; set; } = Array.Empty(); + public (int Width, int Height, TextureFormat Format) TextureFormat { get; set; } + public TextureAtlasManager.TextureKey TextureKey { get; set; } + public int TextureIndex { get; set; } + public byte[] TextureData { get; set; } = Array.Empty(); + public PixelFormat? UploadPixelFormat { get; set; } + public PixelType? UploadPixelType { get; set; } + public DatReaderWriter.Enums.CullMode CullMode { get; set; } + } + + /// + /// CPU-side texture info for deduplication during background preparation. + /// + public class TextureBatchData { + public TextureAtlasManager.TextureKey Key { get; set; } + public byte[] TextureData { get; set; } = Array.Empty(); + public PixelFormat? UploadPixelFormat { get; set; } + public PixelType? UploadPixelType { get; set; } + public List Indices { get; set; } = new(); + public DatReaderWriter.Enums.CullMode CullMode { get; set; } + public bool IsTransparent { get; set; } + public bool IsAdditive { get; set; } + public bool HasWrappingUVs { get; set; } + } + /// /// GPU-side render data created on the main thread. /// @@ -71,7 +187,7 @@ namespace AcDream.App.Rendering.Wb { public (int Width, int Height) TextureSize { get; set; } public TextureFormat TextureFormat { get; set; } public uint SurfaceId { get; set; } - public TextureKey Key { get; set; } + public TextureAtlasManager.TextureKey Key { get; set; } public DatReaderWriter.Enums.CullMode CullMode { get; set; } public bool IsTransparent { get; set; } public bool IsAdditive { get; set; } @@ -93,14 +209,6 @@ namespace AcDream.App.Rendering.Wb { private readonly IDatReaderWriter _dats; private readonly ILogger _logger; - /// - /// MP1a (2026-07-05): the GL-free CPU extraction half, verbatim-moved to - /// AcDream.Content so the MP1b bake tool can run it without a GL context. - /// Owns the dat read → mesh build → inline texture decode pipeline; this - /// class keeps the queue/worker lifecycle and all GL upload. - /// - private readonly MeshExtractor _extractor; - internal IDatReaderWriter Dats => _dats; public bool IsDisposed { get; private set; } @@ -152,6 +260,12 @@ namespace AcDream.App.Rendering.Wb { return false; } + // Cache for decoded textures to avoid redundant BCn decoding + private readonly ConcurrentQueue _decodedTextureLru = new(); + private readonly ConcurrentDictionary _decodedTextureCache = new(); + private const int MaxDecodedTextures = 128; + private readonly ThreadLocal _bcDecoder = new(() => new BcDecoder()); + public GlobalMeshBuffer? GlobalBuffer { get; } private readonly bool _useModernRendering; @@ -162,12 +276,6 @@ namespace AcDream.App.Rendering.Wb { _graphicsDevice = graphicsDevice; _dats = dats; _logger = logger; - // Side-stage sink: particle-preload meshes staged mid-extraction go - // straight onto the staged-upload queue, exactly as the pre-MP1a code - // did — immediate enqueue, surviving a later throw in the same - // Prepare* call. ConcurrentQueue.Enqueue is thread-safe for the - // extractor's up-to-4 concurrent decode workers. - _extractor = new MeshExtractor(_dats, _logger, data => _stagedMeshData.Enqueue(data)); _useModernRendering = _graphicsDevice.HasOpenGL43 && _graphicsDevice.HasBindless; if (_useModernRendering) { GlobalBuffer = new GlobalMeshBuffer(_graphicsDevice.GL); @@ -452,7 +560,7 @@ namespace AcDream.App.Rendering.Wb { uint envId = 0x0D000000u | req.EnvironmentId; if (_dats.Portal.TryGet(envId, out var environment)) { if (environment.Cells.TryGetValue(req.CellStructure, out var cellStruct)) { - data = _extractor.PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, CancellationToken.None); + data = PrepareCellStructMeshData(id, cellStruct, req.Surfaces, Matrix4x4.Identity, CancellationToken.None); // TEMP diagnostic #105 (strip with fix): a null prep here means // this deduplicated cell geometry will NEVER render anywhere. if (data == null) @@ -473,7 +581,7 @@ namespace AcDream.App.Rendering.Wb { if ((id & 0x2_0000_0000UL) != 0) Console.WriteLine($"[geom-misroute] envcell geom 0x{id:X10} had no pending request — generic path will null it"); // If it's a direct setup or gfxobj, make sure background loads don't abort half-way - data = _extractor.PrepareMeshData(id, isSetup, CancellationToken.None); + data = PrepareMeshData(id, isSetup, CancellationToken.None); } if (data != null) { lock (_cpuMeshCache) { @@ -512,12 +620,62 @@ namespace AcDream.App.Rendering.Wb { /// Phase 1 (Background Thread): Prepare CPU-side mesh data from DAT. /// This loads vertices, indices, and texture data but creates NO GPU resources. /// Thread-safe: only reads from DAT files. - /// - /// MP1a (2026-07-05): delegates to , - /// the verbatim-moved GL-free extraction dispatcher. See . /// public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default) { - return _extractor.PrepareMeshData(id, isSetup, ct); + try { + // Use the low 32 bits as the DAT file ID + var datId = (uint)(id & 0xFFFFFFFFu); + var resolutions = _dats.ResolveId(datId).ToList(); + var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); + if (selectedResolution == null) return null; + + var type = selectedResolution.Type; + var db = selectedResolution.Database; + + if (type == DBObjType.Setup) { + if (!db.TryGet(datId, out var setup)) return null; + return PrepareSetupMeshData(id, setup, ct); + } + else if (type == DBObjType.GfxObj) { + if (!db.TryGet(datId, out var gfxObj)) return null; + return PrepareGfxObjMeshData(id, gfxObj, Vector3.One, ct); + } + else if (type == DBObjType.EnvCell) { + if (!db.TryGet(datId, out var envCell)) return null; + + // If bit 32 is set, this is a request for the cell's synthetic geometry only + if ((id & 0x1_0000_0000UL) != 0) { + uint envId = 0x0D000000u | envCell.EnvironmentId; + if (_dats.Portal.TryGet(envId, out var environment)) { + if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { + return PrepareCellStructMeshData(id, cellStruct, envCell.Surfaces, Matrix4x4.Identity, ct); + } + } + return null; + } + + return PrepareEnvCellMeshData(id, envCell, ct); + } + else if (type == DBObjType.Environment) { + if (!db.TryGet(datId, out var environment)) return null; + + // For Environment objects, create wireframe-only edge geometry + if (environment.Cells.Count > 0) { + var result = PrepareCellStructEdgeLineData(id, environment.Cells, Matrix4x4.Identity, ct); + return result; + } + return null; + } + return null; + } + catch (OperationCanceledException) { + // Ignore + return null; + } + catch (Exception ex) { + _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id); + return null; + } } /// @@ -660,7 +818,7 @@ namespace AcDream.App.Rendering.Wb { bool hasBounds = false; var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); - _extractor.CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); + CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); result = hasBounds ? (min, max) : null; } else if (type == DBObjType.EnvCell) { @@ -687,13 +845,13 @@ namespace AcDream.App.Rendering.Wb { bool hasBounds = false; var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); - _extractor.CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); + CollectParts(datId, Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, CancellationToken.None); result = hasBounds ? (min, max) : null; } } else { if (!db.TryGet(datId, out var gfxObj)) return null; - result = _extractor.ComputeBounds(gfxObj, Vector3.One); + result = ComputeBounds(gfxObj, Vector3.One); } _boundsCache[id] = result; return result; @@ -706,6 +864,181 @@ namespace AcDream.App.Rendering.Wb { #region Private: Background Preparation + private ObjectMeshData? PrepareSetupMeshData(ulong id, Setup setup, CancellationToken ct) { + var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + bool hasBounds = false; + + CollectParts((uint)(id & 0xFFFFFFFFu), Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, ct); + + var emitters = new List(); + var processedScripts = new HashSet(); + if (setup.DefaultScript.DataId != 0) { + if (processedScripts.Add(setup.DefaultScript.DataId)) { + CollectEmittersFromScript(setup.DefaultScript.DataId, emitters, ct); + } + } + + return new ObjectMeshData { + ObjectId = id, + IsSetup = true, + SetupParts = parts, + ParticleEmitters = emitters, + BoundingBox = hasBounds ? new BoundingBox(min, max) : default, + SelectionSphere = setup.SelectionSphere + }; + } + + private void CollectEmittersFromScript(uint scriptId, List emitters, CancellationToken ct) { + if (_dats.Portal.TryGet(scriptId, out var script)) { + foreach (var hook in script.ScriptData) { + if (hook.Hook.HookType == AnimationHookType.CreateParticle && hook.Hook is CreateParticleHook particleHook) { + if (_dats.Portal.TryGet(particleHook.EmitterInfoId.DataId, out var emitter)) { + emitters.Add(new StagedEmitter { + Emitter = emitter, + PartIndex = particleHook.PartIndex, + Offset = Matrix4x4.CreateFromQuaternion(particleHook.Offset.Orientation) * Matrix4x4.CreateTranslation(particleHook.Offset.Origin) + }); + + // Pre-load and stage the particle's GfxObjs + if (emitter.HwGfxObjId.DataId != 0) { + var meshData = PrepareMeshData(emitter.HwGfxObjId.DataId, false, ct); + if (meshData != null) { + _stagedMeshData.Enqueue(meshData); + } + } + if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) { + var meshData = PrepareMeshData(emitter.GfxObjId.DataId, false, ct); + if (meshData != null) { + _stagedMeshData.Enqueue(meshData); + } + } + } + } + } + } + } + + private void CollectParts(uint id, Matrix4x4 currentTransform, List<(ulong GfxObjId, Matrix4x4 Transform)> parts, ref Vector3 min, ref Vector3 max, ref bool hasBounds, CancellationToken ct, int depth = 0) { + if (depth > 50) { + _logger.LogWarning("Max recursion depth reached while collecting parts for 0x{Id:X8}. Possible circular dependency.", id); + return; + } + ct.ThrowIfCancellationRequested(); + + var resolutions = _dats.ResolveId(id).ToList(); + var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); + if (selectedResolution == null) return; + + var type = selectedResolution.Type; + var db = selectedResolution.Database; + + if (type == DBObjType.Setup) { + if (!db.TryGet(id, out var setup)) return; + + // Use Resting placement first, then default + if (!setup.PlacementFrames.TryGetValue(Placement.Resting, out var placementFrame)) { + if (!setup.PlacementFrames.TryGetValue(Placement.Default, out placementFrame)) { + placementFrame = setup.PlacementFrames.Values.FirstOrDefault(); + } + } + if (placementFrame == null) return; + + for (int i = 0; i < setup.Parts.Count; i++) { + var partId = setup.Parts[i]; + var transform = Matrix4x4.Identity; + + if (setup.Flags.HasFlag(SetupFlags.HasDefaultScale) && setup.DefaultScale.Count > i) { + transform *= Matrix4x4.CreateScale(setup.DefaultScale[i]); + } + + if (placementFrame.Frames != null && i < placementFrame.Frames.Count) { + var orientation = new System.Numerics.Quaternion( + (float)placementFrame.Frames[i].Orientation.X, + (float)placementFrame.Frames[i].Orientation.Y, + (float)placementFrame.Frames[i].Orientation.Z, + (float)placementFrame.Frames[i].Orientation.W + ); + transform *= Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin); + } + + CollectParts(partId, transform * currentTransform, parts, ref min, ref max, ref hasBounds, ct, depth + 1); + } + } + else if (type == DBObjType.EnvCell) { + if (!db.TryGet(id, out var envCell)) return; + + // Calculate the inverse transform of the cell to localize its contents + var cellOrientation = new System.Numerics.Quaternion( + (float)envCell.Position.Orientation.X, + (float)envCell.Position.Orientation.Y, + (float)envCell.Position.Orientation.Z, + (float)envCell.Position.Orientation.W + ); + var cellTransform = Matrix4x4.CreateFromQuaternion(cellOrientation) * + Matrix4x4.CreateTranslation(envCell.Position.Origin); + if (!Matrix4x4.Invert(cellTransform, out var invertCellTransform)) { + invertCellTransform = Matrix4x4.Identity; + } + + // Include cell geometry + uint envId = 0x0D000000u | envCell.EnvironmentId; + if (_dats.Portal.TryGet(envId, out var environment)) { + if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { + foreach (var vert in cellStruct.VertexArray.Vertices.Values) { + var transformed = Vector3.Transform(vert.Origin, currentTransform); + min = Vector3.Min(min, transformed); + max = Vector3.Max(max, transformed); + } + hasBounds = true; + + // Add synthetic geometry ID to parts list + parts.Add(((ulong)id | 0x1_0000_0000UL, currentTransform)); + } + } + + foreach (var stab in envCell.StaticObjects) { + var orientation = new System.Numerics.Quaternion( + (float)stab.Frame.Orientation.X, + (float)stab.Frame.Orientation.Y, + (float)stab.Frame.Orientation.Z, + (float)stab.Frame.Orientation.W + ); + var transform = Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(stab.Frame.Origin); + // Localize static object transform relative to the cell + var localizedTransform = transform * invertCellTransform; + + CollectParts(stab.Id, localizedTransform * currentTransform, parts, ref min, ref max, ref hasBounds, ct, depth + 1); + } + } + else if (type == DBObjType.GfxObj) { + parts.Add((id, currentTransform)); + + if (db.TryGet(id, out var partGfx)) { + var (partMin, partMax) = ComputeBounds(partGfx, Vector3.One); + var corners = new Vector3[8]; + corners[0] = new Vector3(partMin.X, partMin.Y, partMin.Z); + corners[1] = new Vector3(partMin.X, partMin.Y, partMax.Z); + corners[2] = new Vector3(partMin.X, partMax.Y, partMin.Z); + corners[3] = new Vector3(partMin.X, partMax.Y, partMax.Z); + corners[4] = new Vector3(partMax.X, partMin.Y, partMin.Z); + corners[5] = new Vector3(partMax.X, partMin.Y, partMax.Z); + corners[6] = new Vector3(partMax.X, partMax.Y, partMin.Z); + corners[7] = new Vector3(partMax.X, partMax.Y, partMax.Z); + + foreach (var corner in corners) { + var transformed = Vector3.Transform(corner, currentTransform); + min = Vector3.Min(min, transformed); + max = Vector3.Max(max, transformed); + } + hasBounds = true; + } + } + } + /// /// #113: the set of polygon ids referenced by the GfxObj's drawing BSP — /// the polys retail actually renders (D3DPolyRender traverses the BSP; @@ -727,6 +1060,769 @@ namespace AcDream.App.Rendering.Wb { if (node.NegNode is not null) CollectDrawingBspPolygonIds(node.NegNode, ids); } + private ObjectMeshData? PrepareGfxObjMeshData(ulong id, GfxObj gfxObj, Vector3 scale, CancellationToken ct) { + var vertices = new List(); + var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>(); + var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List>(); + + var (min, max) = ComputeBounds(gfxObj, scale); + var boundingBox = new BoundingBox(min, max); + + // #113 (2026-06-11): retail draws a GfxObj by TRAVERSING its drawing + // BSP — a polygon present in the Polygons dictionary but referenced by + // no DrawingBSP node is never rendered (physics/no-draw geometry). + // The Holtburg meeting hall (0x010014C3) keeps its walkable exterior + // stair-ramp as dictionary polys {0,1}: in the PhysicsBSP (NPCs walk + // it) but absent from every DrawingBSP node — retail shows a plain + // wall; iterating the dictionary draws the "phantom staircase" + // (invisible-but-walkable in retail, visible in acdream). The hill + // cottage (0x01000827) carries 8 such orphans. + // + // ⚠️ FILTER NOT APPLIED (e46d3d9 un-applied same day): naively + // filtering to CollectDrawingBspPolygonIds(gfxObj) made DOORS + // disappear across Holtburg (user gate 2026-06-11) — the naive + // PosNode/NegNode walk evidently misses polys some models reference + // another way (portal-type nodes? leaf indexing? DatReaderWriter + // parse gap?). Diagnose with the histogram fact in + // Issue113PhantomStairsDumpTests on a door GfxObj BEFORE re-landing. + // The full retail draw is BSP-TRAVERSAL ORDER drawing, not a + // dictionary iteration with a filter — see the holistic port handoff + // docs/research/2026-06-11-building-render-holistic-port-handoff.md. + foreach (var polyEntry in gfxObj.Polygons) { + ct.ThrowIfCancellationRequested(); + var poly = polyEntry.Value; + if (poly.VertexIds.Count < 3) continue; + + // Handle Positive Surface + if (!poly.Stippling.HasFlag(StipplingType.NoPos)) { + AddSurfaceToBatch(poly, poly.PosSurface, false); + } + + // Handle Negative Surface + // Some objects use Clockwise CullMode to indicate negative surface data is present + bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) || + poly.Stippling.HasFlag(StipplingType.Both) || + (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise); + + if (hasNeg) { + AddSurfaceToBatch(poly, poly.NegSurface, true); + } + + void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool isNeg) { + if (surfaceIdx < 0 || surfaceIdx >= gfxObj.Surfaces.Count) return; + + var surfaceId = gfxObj.Surfaces[surfaceIdx]; + if (!_dats.Portal.TryGet(surfaceId, out var surface)) { + // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) + Console.WriteLine($"[tex-skip] gfxobj Surface 0x{surfaceId:X8} miss -> poly batch dropped (obj 0x{gfxObj.Id:X8})"); + return; + } + + int texWidth, texHeight; + byte[] textureData; + TextureFormat textureFormat; + PixelFormat? uploadPixelFormat = null; + PixelType? uploadPixelType = null; + bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); + bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); + uint paletteId = 0; + bool isDxt3or5 = false; + DatReaderWriter.Enums.PixelFormat? sourceFormat = null; + var isAdditive = false; + var isTransparent = false; + + if (isSolid) { + texWidth = texHeight = 32; + textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + } + else if (_dats.Portal.TryGet(surface.OrigTextureId, out var surfaceTexture)) { + var renderSurfaceId = surfaceTexture.Textures.First(); + if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { + // check highres + if (!_dats.HighRes.TryGet(renderSurfaceId, out var hrRenderSurface)) { + throw new Exception($"Unable to load RenderSurface: 0x{renderSurfaceId:X8}"); + } + + renderSurface = hrRenderSurface; + } + + texWidth = renderSurface.Width; + texHeight = renderSurface.Height; + paletteId = renderSurface.DefaultPaletteId; + sourceFormat = renderSurface.Format; + + if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { + isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + + if (_decodedTextureCache.TryGetValue(renderSurfaceId, out textureData!)) { + // use cached data + } + else { + textureData = new byte[texWidth * texHeight * 4]; + + CompressionFormat compressionFormat = renderSurface.Format switch { + DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1, + DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2, + DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3, + _ => throw new NotSupportedException($"Unsupported compressed format: {renderSurface.Format}") + }; + + using (var image = _bcDecoder.Value!.DecodeRawToImageRgba32(renderSurface.SourceData, texWidth, texHeight, compressionFormat)) { + image.CopyPixelDataTo(textureData); + } + _decodedTextureCache.TryAdd(renderSurfaceId, textureData); + } + + if (isClipMap && textureData != null) { + // If we got this from the cache, we need to clone it so we don't scale the cached raw data + if (_decodedTextureCache.ContainsKey(renderSurfaceId)) { + var clonedData = new byte[textureData.Length]; + System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); + textureData = clonedData; + } + + for (int i = 0; i < textureData.Length; i += 4) { + if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) { + textureData[i + 3] = 0; + } + } + } + } + else { + textureFormat = TextureFormat.RGBA8; + textureData = renderSurface.SourceData; + switch (renderSurface.Format) { + case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16: + if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var paletteData)) + throw new Exception($"Unable to load Palette: 0x{renderSurface.DefaultPaletteId:X8}"); + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_P8: + if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var p8PaletteData)) + throw new Exception($"Unable to load Palette: 0x{renderSurface.DefaultPaletteId:X8}"); + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A8: + case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA: + textureData = new byte[texWidth * texHeight * 4]; + if (surface.Type.HasFlag(SurfaceType.Additive)) { + TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + } + else { + TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + } + uploadPixelFormat = PixelFormat.Rgba; + break; + default: + throw new NotSupportedException($"Unsupported surface format: {renderSurface.Format}"); + } + } + + if (surface.Translucency > 0.0f && textureData != null) { + // If we got this from the cache, we need to clone it so we don't scale the cached raw data + if (sourceFormat.HasValue && TextureHelpers.IsCompressedFormat(sourceFormat.Value) && _decodedTextureCache.ContainsKey(renderSurfaceId)) { + var clonedData = new byte[textureData.Length]; + System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); + textureData = clonedData; + } + + float alphaScale = 1.0f - surface.Translucency; + for (int i = 3; i < textureData.Length; i += 4) { + textureData[i] = (byte)(textureData[i] * alphaScale); + } + } + + isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive); + isTransparent = isSolid ? surface.ColorValue.Alpha < 255 : + (surface.Type.HasFlag(SurfaceType.Translucent) || + surface.Type.HasFlag(SurfaceType.Base1ClipMap) || + ((uint)surface.Type & 0x100) != 0 || // Alpha + ((uint)surface.Type & 0x200) != 0 || // InvAlpha + isAdditive || + (surface.Translucency > 0.0f && surface.Translucency < 1.0f) || + textureFormat == TextureFormat.A8 || + textureFormat == TextureFormat.Rgba32f || + isDxt3or5 || + (sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5))); + } + else { + // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) + Console.WriteLine($"[tex-skip] gfxobj SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> poly batch dropped (surface 0x{surfaceId:X8})"); + return; + } + + var format = (texWidth, texHeight, textureFormat); + var key = new TextureAtlasManager.TextureKey { + SurfaceId = surfaceId, + PaletteId = paletteId, + Stippling = poly.Stippling, + IsSolid = isSolid + }; + + if (!batchesByFormat.TryGetValue(format, out var batches)) { + batches = new List(); + batchesByFormat[format] = batches; + } + + var batch = batches.FirstOrDefault(b => b.Key.Equals(key) && b.CullMode == poly.SidesType); + if (batch == null) { + batch = new TextureBatchData { + Key = key, + CullMode = poly.SidesType, + TextureData = textureData!, + UploadPixelFormat = uploadPixelFormat, + UploadPixelType = uploadPixelType, + IsTransparent = isTransparent, + IsAdditive = isAdditive + }; + batches.Add(batch); + } + + bool batchHasWrappingUVs = batch.HasWrappingUVs; + BuildPolygonIndices(poly, gfxObj, scale, UVLookup, vertices, batch.Indices, isNeg, ref batchHasWrappingUVs); + batch.HasWrappingUVs = batchHasWrappingUVs; + } + } + + return new ObjectMeshData { + ObjectId = id, + IsSetup = false, + Vertices = vertices.ToArray(), + TextureBatches = batchesByFormat, + BoundingBox = boundingBox, + SortCenter = gfxObj?.SortCenter ?? Vector3.Zero, + DIDDegrade = gfxObj != null && gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) ? gfxObj.DIDDegrade : 0, + SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f } + }; + } + + private ObjectMeshData? PrepareEnvCellMeshData(ulong id, EnvCell envCell, CancellationToken ct) { + var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + bool hasBounds = false; + + // Calculate the inverse transform of the cell to localize its contents + var cellOrientation = new System.Numerics.Quaternion( + (float)envCell.Position.Orientation.X, + (float)envCell.Position.Orientation.Y, + (float)envCell.Position.Orientation.Z, + (float)envCell.Position.Orientation.W + ); + var cellTransform = Matrix4x4.CreateFromQuaternion(cellOrientation) * + Matrix4x4.CreateTranslation(envCell.Position.Origin); + if (!Matrix4x4.Invert(cellTransform, out var invertCellTransform)) { + invertCellTransform = Matrix4x4.Identity; + } + + // Add static objects + var emitters = new List(); + foreach (var stab in envCell.StaticObjects) { + var orientation = new System.Numerics.Quaternion( + (float)stab.Frame.Orientation.X, + (float)stab.Frame.Orientation.Y, + (float)stab.Frame.Orientation.Z, + (float)stab.Frame.Orientation.W + ); + var transform = Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(stab.Frame.Origin); + + // Localize static object transform relative to the cell + var localizedTransform = transform * invertCellTransform; + + CollectParts(stab.Id, localizedTransform, parts, ref min, ref max, ref hasBounds, ct); + + // For EnvCell static objects, we need to manually collect emitters if they are Setups. + // Bugfix 2026-05-19 (acdream): pre-check the Setup-prefix (0x02xxxxxx) before calling + // TryGet. Without this, calling TryGet on a GfxObj-prefixed id + // (0x01xxxxxx) throws ArgumentOutOfRangeException as DatReaderWriter tries to parse + // GfxObj bytes as a Setup record. The exception bubbles up through PrepareMeshData's + // outer catch and the entire cell fails to upload — manifesting as missing floors + // in any building whose StaticObjects include a GfxObj-typed stab (very common). + // Confirmed via acdream's Phase 2 indoor-cell-rendering diagnostic probes; see + // docs/research/2026-05-19-indoor-cell-rendering-cause.md in the acdream repo. + if ((stab.Id & 0xFF000000u) == 0x02000000u + && _dats.Portal.TryGet(stab.Id, out var stabSetup)) { + var stabEmitters = new List(); + var processedScripts = new HashSet(); + if (stabSetup.DefaultScript.DataId != 0) { + if (processedScripts.Add(stabSetup.DefaultScript.DataId)) { + CollectEmittersFromScript(stabSetup.DefaultScript.DataId, stabEmitters, ct); + } + } + + foreach (var emitter in stabEmitters) { + emitters.Add(new StagedEmitter { + Emitter = emitter.Emitter, + PartIndex = emitter.PartIndex, + Offset = emitter.Offset * localizedTransform + }); + } + } + } + + // Load environment and cell structure geometry + uint envId = 0x0D000000u | envCell.EnvironmentId; + ObjectMeshData? cellGeometry = null; + if (_dats.Portal.TryGet(envId, out var environment)) { + if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { + var cellGeomId = id | 0x1_0000_0000UL; + cellGeometry = PrepareCellStructMeshData(cellGeomId, cellStruct, envCell.Surfaces, Matrix4x4.Identity, ct); + if (cellGeometry != null) { + parts.Add((cellGeomId, Matrix4x4.Identity)); + min = Vector3.Min(min, cellGeometry.BoundingBox.Min); + max = Vector3.Max(max, cellGeometry.BoundingBox.Max); + hasBounds = true; + } + } + } + + return new ObjectMeshData { + ObjectId = id, + IsSetup = true, + SetupParts = parts, + ParticleEmitters = emitters, + EnvCellGeometry = cellGeometry, + BoundingBox = hasBounds ? new BoundingBox(min, max) : default, + SelectionSphere = new Sphere { Origin = hasBounds ? (min + max) / 2f : Vector3.Zero, Radius = hasBounds ? Vector3.Distance(max, min) / 2.0f : 0f } + }; + } + + private ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, List surfaceOverrides, Matrix4x4 transform, CancellationToken ct) { + var vertices = new List(); + var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>(); + var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List>(); + + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var vert in cellStruct.VertexArray.Vertices.Values) { + var localizedPos = Vector3.Transform(vert.Origin, transform); + min = Vector3.Min(min, localizedPos); + max = Vector3.Max(max, localizedPos); + } + var boundingBox = new BoundingBox(min, max); + + foreach (var poly in cellStruct.Polygons.Values) { + ct.ThrowIfCancellationRequested(); + if (poly.VertexIds.Count < 3) continue; + + // Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this + // DatReaderWriter "CullMode" as CPolygon::sides_type, not as a + // GL cull enum: 0 = pos, 1 = pos twice with reversed winding, + // 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still + // suppress hidden portal/cap faces before they reach our mesh. + bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos); + bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg); + + if (hasPos) + AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false); + if (hasPos && poly.SidesType == CullMode.None) { + AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true); + } + else if (hasNeg && poly.SidesType == CullMode.Clockwise) { + AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false); + } + + void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) { + if (surfaceIdx < 0) return; + + uint surfaceId; + if (surfaceIdx < surfaceOverrides.Count) { + surfaceId = 0x08000000u | surfaceOverrides[surfaceIdx]; + } + else { + _logger.LogWarning($"Failed to find surface override for index {surfaceIdx} in CellStruct 0x{cellStruct:X4}"); + return; + } + + if (!_dats.Portal.TryGet(surfaceId, out var surface)) { + // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) + Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> WALL poly batch dropped (cellstruct 0x{cellStruct:X4})"); + return; + } + + int texWidth, texHeight; + byte[] textureData; + TextureFormat textureFormat; + PixelFormat? uploadPixelFormat = null; + PixelType? uploadPixelType = null; + bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); + bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); + uint paletteId = 0; + bool isDxt3or5 = false; + DatReaderWriter.Enums.PixelFormat? sourceFormat = null; + var isAdditive = false; + var isTransparent = false; + + if (isSolid) { + texWidth = texHeight = 32; + textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + } + else if (_dats.Portal.TryGet(surface.OrigTextureId, out var surfaceTexture)) { + var renderSurfaceId = surfaceTexture.Textures.First(); + if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { + if (!_dats.HighRes.TryGet(renderSurfaceId, out var hrRenderSurface)) { + // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) + Console.WriteLine($"[tex-skip] cellstruct RenderSurface 0x{renderSurfaceId:X8} miss (portal+highres) -> WALL poly batch dropped"); + return; + } + renderSurface = hrRenderSurface; + } + + texWidth = renderSurface.Width; + texHeight = renderSurface.Height; + paletteId = renderSurface.DefaultPaletteId; + sourceFormat = renderSurface.Format; + + if (_decodedTextureCache.TryGetValue(renderSurfaceId, out var cachedData)) { + textureData = cachedData; + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + } + else { + if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { + isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; + textureFormat = TextureFormat.RGBA8; + uploadPixelFormat = PixelFormat.Rgba; + + textureData = new byte[texWidth * texHeight * 4]; + CompressionFormat compressionFormat = renderSurface.Format switch { + DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1, + DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2, + DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3, + _ => throw new NotSupportedException($"Unsupported compressed format: {renderSurface.Format}") + }; + + using (var image = _bcDecoder.Value!.DecodeRawToImageRgba32(renderSurface.SourceData, texWidth, texHeight, compressionFormat)) { + image.CopyPixelDataTo(textureData); + } + } + else { + textureFormat = TextureFormat.RGBA8; + textureData = renderSurface.SourceData; + switch (renderSurface.Format) { + case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16: + if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var paletteData)) return; + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_P8: + if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var p8PaletteData)) return; + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: + textureData = new byte[texWidth * texHeight * 4]; + TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + uploadPixelFormat = PixelFormat.Rgba; + break; + case DatReaderWriter.Enums.PixelFormat.PFID_A8: + case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA: + textureData = new byte[texWidth * texHeight * 4]; + if (surface.Type.HasFlag(SurfaceType.Additive)) { + TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + } + else { + TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); + } + uploadPixelFormat = PixelFormat.Rgba; + break; + default: return; + } + } + + // Add to cache with LRU logic + if (textureData != null && _decodedTextureCache.TryAdd(renderSurfaceId, textureData)) { + _decodedTextureLru.Enqueue(renderSurfaceId); + if (_decodedTextureCache.Count > MaxDecodedTextures) { + if (_decodedTextureLru.TryDequeue(out var evictedId)) { + _decodedTextureCache.TryRemove(evictedId, out _); + } + } + } + } + + if (isClipMap && textureData != null) { + // If we got this from the cache, we need to clone it so we don't scale the cached raw data + var clonedData = new byte[textureData.Length]; + System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); + textureData = clonedData; + + for (int i = 0; i < textureData.Length; i += 4) { + if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) { + textureData[i + 3] = 0; + } + } + } + } + else { + // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) + Console.WriteLine($"[tex-skip] cellstruct SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> WALL poly batch dropped (surface 0x{surfaceId:X8})"); + return; + } + + isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive); + isTransparent = isSolid ? surface.ColorValue.Alpha < 255 : + (surface.Type.HasFlag(SurfaceType.Translucent) || + surface.Type.HasFlag(SurfaceType.Base1ClipMap) || + ((uint)surface.Type & 0x100) != 0 || // Alpha + ((uint)surface.Type & 0x200) != 0 || // InvAlpha + isAdditive || + (surface.Translucency > 0.0f && surface.Translucency < 1.0f) || + textureFormat == TextureFormat.A8 || + textureFormat == TextureFormat.Rgba32f || + isDxt3or5 || + (sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || + sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5))); + + var format = (texWidth, texHeight, textureFormat); + var key = new TextureAtlasManager.TextureKey { + SurfaceId = surfaceId, + PaletteId = paletteId, + Stippling = poly.Stippling, + IsSolid = isSolid + }; + + if (!batchesByFormat.TryGetValue(format, out var batches)) { + batches = new List(); + batchesByFormat[format] = batches; + } + + var batch = batches.FirstOrDefault(b => b.Key.Equals(key) && b.CullMode == poly.SidesType); + if (batch == null) { + batch = new TextureBatchData { + Key = key, + CullMode = poly.SidesType, + TextureData = textureData!, + UploadPixelFormat = uploadPixelFormat, + UploadPixelType = uploadPixelType, + IsTransparent = isTransparent, + IsAdditive = isAdditive + }; + batches.Add(batch); + } + + // Helper for CellStruct vertices + bool batchHasWrappingUVs = batch.HasWrappingUVs; + BuildCellStructPolygonIndices( + poly, + cellStruct, + UVLookup, + vertices, + batch.Indices, + useNegUv, + invertNormal, + reverseWinding, + transform, + ref batchHasWrappingUVs); + batch.HasWrappingUVs = batchHasWrappingUVs; + } + } + + return new ObjectMeshData { + ObjectId = id, + IsSetup = false, + Vertices = vertices.ToArray(), + TextureBatches = batchesByFormat, + BoundingBox = boundingBox, + SortCenter = Vector3.Zero, + SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f } + }; + } + + private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct, + Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup, + List vertices, List indices, + bool useNegUv, bool invertNormal, bool reverseWinding, + Matrix4x4 transform, ref bool hasWrappingUVs) { + + var polyIndices = new List(); + + for (int i = 0; i < poly.VertexIds.Count; i++) { + ushort vertId = (ushort)poly.VertexIds[i]; + ushort uvIdx = 0; + + if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) + uvIdx = poly.NegUVIndices[i]; + else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count) + uvIdx = poly.PosUVIndices[i]; + + if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue; + + if (uvIdx >= vertex.UVs.Count) { + uvIdx = 0; + } + + var key = (vertId, uvIdx, invertNormal); + + if (!hasWrappingUVs) { + var uvCheck = vertex.UVs.Count > 0 + ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) + : Vector2.Zero; + if (uvCheck.X < 0f || uvCheck.X > 1f || uvCheck.Y < 0f || uvCheck.Y > 1f) { + hasWrappingUVs = true; + } + } + + if (!UVLookup.TryGetValue(key, out var idx)) { + var uv = vertex.UVs.Count > 0 + ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) + : Vector2.Zero; + + var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform)); + if (invertNormal) { + normal = -normal; + } + + idx = (ushort)vertices.Count; + vertices.Add(new VertexPositionNormalTexture( + Vector3.Transform(vertex.Origin, transform), + normal, + uv + )); + UVLookup[key] = idx; + } + polyIndices.Add(idx); + } + + if (reverseWinding) { + for (int i = 2; i < polyIndices.Count; i++) { + indices.Add(polyIndices[i]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[0]); + } + } + else { + for (int i = 2; i < polyIndices.Count; i++) { + indices.Add(polyIndices[0]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[i]); + } + } + } + + private void BuildPolygonIndices(Polygon poly, GfxObj gfxObj, Vector3 scale, + Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup, + List vertices, List indices, bool useNegSurface, ref bool hasWrappingUVs) { + + var polyIndices = new List(); + + for (int i = 0; i < poly.VertexIds.Count; i++) { + ushort vertId = (ushort)poly.VertexIds[i]; + ushort uvIdx = 0; + + if (useNegSurface && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) + uvIdx = poly.NegUVIndices[i]; + else if (!useNegSurface && poly.PosUVIndices != null && i < poly.PosUVIndices.Count) + uvIdx = poly.PosUVIndices[i]; + + if (!gfxObj.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue; + + if (uvIdx >= vertex.UVs.Count) { + uvIdx = 0; + } + + var key = (vertId, uvIdx, useNegSurface); + + if (!hasWrappingUVs) { + var uvCheck = vertex.UVs.Count > 0 + ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) + : Vector2.Zero; + if (uvCheck.X < 0f || uvCheck.X > 1f || uvCheck.Y < 0f || uvCheck.Y > 1f) { + hasWrappingUVs = true; + } + } + + if (!UVLookup.TryGetValue(key, out var idx)) { + var uv = vertex.UVs.Count > 0 + ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) + : Vector2.Zero; + + var normal = Vector3.Normalize(vertex.Normal); + if (useNegSurface) { + normal = -normal; + } + + idx = (ushort)vertices.Count; + vertices.Add(new VertexPositionNormalTexture( + vertex.Origin * scale, + normal, + uv + )); + UVLookup[key] = idx; + } + polyIndices.Add(idx); + } + + if (useNegSurface) { + // Reverse winding for negative surface so it's visible from the other side + for (int i = 2; i < polyIndices.Count; i++) { + indices.Add(polyIndices[0]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[i]); + } + } + else { + for (int i = 2; i < polyIndices.Count; i++) { + indices.Add(polyIndices[i]); + indices.Add(polyIndices[i - 1]); + indices.Add(polyIndices[0]); + } + } + } + #endregion #region Private: GPU Upload @@ -801,13 +1897,7 @@ namespace AcDream.App.Rendering.Wb { atlasList.Add(atlasManager); } - // MP1a: AcDream.Content is Silk.NET-free — the extraction records - // carry Content-owned UploadPixelFormat/UploadPixelType enums whose - // underlying values are the GL ABI constants (numerically identical - // to Silk.NET.OpenGL.PixelFormat/PixelType), so this lifted nullable - // cast is value- and null-preserving. - textureIndex = atlasManager.AddTexture(batch.Key, batch.TextureData, - (PixelFormat?)batch.UploadPixelFormat, (PixelType?)batch.UploadPixelType); + textureIndex = atlasManager.AddTexture(batch.Key, batch.TextureData, batch.UploadPixelFormat, batch.UploadPixelType); if (_useModernRendering) { ibo = GlobalBuffer!.IBO; @@ -953,6 +2043,17 @@ namespace AcDream.App.Rendering.Wb { #endregion + private (Vector3 Min, Vector3 Max) ComputeBounds(GfxObj gfxObj, Vector3 scale) { + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (var vert in gfxObj.VertexArray.Vertices.Values) { + var p = vert.Origin * scale; + min = Vector3.Min(min, p); + max = Vector3.Max(max, p); + } + return (min, max); + } + private void UnloadObject(ulong key) { if (!_renderData.TryGetValue(key, out var data)) return; @@ -1076,5 +2177,102 @@ namespace AcDream.App.Rendering.Wb { } }); } + + private ObjectMeshData? PrepareCellStructEdgeLineData(ulong id, Dictionary cellStructs, Matrix4x4 transform, CancellationToken ct) { + var cellStructList = cellStructs.ToList(); + if (cellStructList.Count == 0) { + return null; + } + + // Calculate bounding box from ALL vertices in all cell structures + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + var allEdgeLines = new List(); + + // Process each CellStruct and collect all edge lines + foreach (var cellStructKvp in cellStructList) { + var cellStruct = cellStructKvp.Value; + + // Build edge lines for this CellStruct + var edgeLines = EdgeLineBuilder.BuildEdgeLines(cellStruct); + + // Transform edge lines to world space and add to collection + foreach (var edgeLine in edgeLines) { + allEdgeLines.Add(Vector3.Transform(edgeLine, transform)); + } + + // Update bounding box with vertices from this CellStruct + foreach (var vert in cellStruct.VertexArray.Vertices.Values) { + var localizedPos = Vector3.Transform(vert.Origin, transform); + min = Vector3.Min(min, localizedPos); + max = Vector3.Max(max, localizedPos); + } + } + + if (allEdgeLines.Count == 0) { + return null; + } + + var boundingBox = new BoundingBox(min, max); + + // Create minimal mesh data for edge line rendering + // We still need some vertices for rendering system to work, but they'll be transparent + var vertices = new List { + new VertexPositionNormalTexture { Position = Vector3.Zero, Normal = Vector3.UnitZ, UV = Vector2.Zero } + }; + var indices = new List { 0, 0, 0 }; // Dummy triangle + + // Create a transparent texture for base triangles (so only edge lines are visible) + var transparentTexture = TextureHelpers.CreateSolidColorTexture(new ColorARGB { Alpha = 0, Red = 255, Green = 255, Blue = 255 }, 1, 1); + + var result = new ObjectMeshData { + ObjectId = id, + IsSetup = false, + Vertices = vertices.ToArray(), + Batches = new List { + new MeshBatchData { + Indices = indices.ToArray(), + TextureFormat = (1, 1, TextureFormat.RGBA8), + TextureKey = new TextureAtlasManager.TextureKey { + SurfaceId = 0xFFFFFFFF, // Dummy surface ID + PaletteId = 0, + Stippling = StipplingType.NoPos, + IsSolid = true + }, + TextureIndex = 0, + TextureData = transparentTexture, + UploadPixelFormat = PixelFormat.Rgba, + UploadPixelType = PixelType.UnsignedByte, + CullMode = CullMode.None + } + }, + // Also populate TextureBatches for GPU upload + TextureBatches = new Dictionary<(int Width, int Height, TextureFormat Format), List> { + [(1, 1, TextureFormat.RGBA8)] = new List { + new TextureBatchData { + Indices = indices.ToList(), + Key = new TextureAtlasManager.TextureKey { + SurfaceId = 0xFFFFFFFF, // Dummy surface ID + PaletteId = 0, + Stippling = StipplingType.NoPos, + IsSolid = true + }, + TextureData = transparentTexture, + UploadPixelFormat = PixelFormat.Rgba, + UploadPixelType = PixelType.UnsignedByte, + CullMode = CullMode.None, + IsTransparent = false // Render in opaque pass but transparent + } + } + }, + BoundingBox = boundingBox, + SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f } + }; + + // Store all edge lines in mesh data for later use in UploadMeshData + result.EdgeLines = allEdgeLines.ToArray(); + + return result; + } } } diff --git a/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs b/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs index 8751c689..8ce969d1 100644 --- a/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs +++ b/src/AcDream.App/Rendering/Wb/TextureAtlasManager.cs @@ -1,4 +1,3 @@ -using AcDream.Content; using Chorizite.Core.Render; using Chorizite.Core.Render.Enums; using DatReaderWriter.Enums; @@ -95,5 +94,27 @@ namespace AcDream.App.Rendering.Wb { _refCounts.Clear(); _freeSlots.Clear(); } + + public struct TextureKey : IEquatable { + public uint SurfaceId; + public uint PaletteId; + public StipplingType Stippling; + public bool IsSolid; + + public bool Equals(TextureKey other) { + return SurfaceId == other.SurfaceId && + PaletteId == other.PaletteId && + Stippling == other.Stippling && + IsSolid == other.IsSolid; + } + + public override bool Equals(object? obj) { + return obj is TextureKey other && Equals(other); + } + + public override int GetHashCode() { + return HashCode.Combine(SurfaceId, PaletteId, Stippling, IsSolid); + } + } } } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index d2375a3e..e266be8c 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.Numerics; using System.Runtime.InteropServices; -using AcDream.Core.Lighting; using AcDream.Core.Meshing; using AcDream.Core.Rendering; using AcDream.Core.Terrain; @@ -133,37 +132,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable private uint _clipSlotSsbo; private uint[] _clipSlotData = new uint[256]; - // Fix B (A7 #3): per-OBJECT light selection (minimize_object_lighting). Two - // SSBOs replace the single global nearest-8-to-CAMERA UBO set for point/spot - // lights — see mesh_modern.vert binding=4/5. _globalLightsSsbo (binding=4) - // holds the per-frame point-light snapshot (LightManager.PointSnapshot); - // _instLightSetSsbo (binding=5) holds MaxLightsPerObject int indices per - // instance INTO it (-1 = unused), laid out parallel to _instanceSsbo. - private uint _globalLightsSsbo; - private uint _instLightSetSsbo; - private int[] _lightSetData = new int[256 * LightManager.MaxLightsPerObject]; - private float[] _globalLightData = new float[GlobalLightPacker.FloatsPerLight * 16]; // 16 floats (4 vec4) per GlobalLight - - // #142: per-instance "indoor" flag (binding=6), one uint per instance, parallel - // to _instanceSsbo. 1 = object parented to an EnvCell (skip the sun in the - // shader's uLightingMode==0 branch); 0 = outdoor object (gets the sun). - // Mechanically a clone of _clipSlotData / _clipSlotSsbo. - private uint _instIndoorSsbo; - private uint[] _indoorData = new uint[256]; - // This frame's point-light snapshot, handed in by GameWindow before Draw via - // SetSceneLights. Null/empty ⇒ only ambient + sun render (all instance sets -1). - private IReadOnlyList? _pointSnapshot; - // This entity's selected point/spot light set — computed ONCE per entity at - // the isNewEntity site (constant across the entity's parts/tuples), exactly - // like _currentEntitySlot. -1 = unused slot. - private readonly int[] _currentEntityLightSet = new int[LightManager.MaxLightsPerObject]; - - // #142: per-entity "indoor" flag — set once per entity in ComputeEntityLightSet, - // parallel to _currentEntityLightSet. True when IndoorObjectReceivesTorches fires - // (ParentCellId is an EnvCell). Appended to InstanceGroup.IndoorFlags in - // AppendCurrentLightSet; uploaded as binding=6 instanceIndoor[]. - private bool _currentEntityIndoor; - // Phase U.3: the SHARED per-cell clip-region SSBO (binding=2), owned by the // GameWindow-level ClipFrame and handed to us via SetClipRegionSsbo. When 0 // (not yet wired), we bind our OWN fallback no-clip region buffer below so the @@ -361,22 +329,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _batchSsbo = _gl.GenBuffer(); _indirectBuffer = _gl.GenBuffer(); _clipSlotSsbo = _gl.GenBuffer(); // Phase U.3 binding=3 - _globalLightsSsbo = _gl.GenBuffer(); // Fix B binding=4 - _instLightSetSsbo = _gl.GenBuffer(); // Fix B binding=5 - _instIndoorSsbo = _gl.GenBuffer(); // #142 binding=6 } - /// - /// Fix B (A7 #3): hand the dispatcher this frame's GLOBAL point-light snapshot - /// (). Call once per frame BEFORE - /// . The dispatcher uploads it to binding=4 and selects each - /// object's up-to-8 lights from it () - /// by the object's bounding sphere — camera-independent. Pass null/empty to - /// disable per-object point lights (only ambient + sun render). - /// - public void SetSceneLights(IReadOnlyList? pointSnapshot) - => _pointSnapshot = pointSnapshot; - /// /// Phase U.3: hand the dispatcher the SHARED per-cell clip-region SSBO /// (binding=2) that created. The @@ -907,9 +861,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _indoorProbeFrameCounter++; var vp = camera.View * camera.Projection; _shader.SetMatrix4("uViewProjection", vp); - // A7 Fix D D-3/D-4: object path — plain Lambert points + sun. MUST set - // explicitly (shared GL uniform; EnvCellRenderer sets it to 1). - _shader.SetInt("uLightingMode", 0); // #128 self-heal: fresh re-request dedup per Draw pass. _missRequested.Clear(); @@ -937,7 +888,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable camPos = invView.Translation; // ── Phase 1: clear groups, walk entities, build groups ────────────── - foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); grp.LightSets.Clear(); grp.IndoorFlags.Clear(); } + foreach (var grp in _groups.Values) { grp.Matrices.Clear(); grp.Slots.Clear(); } var metaTable = _meshAdapter.MetadataTable; uint anyVao = 0; @@ -1102,11 +1053,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_currentEntityCulled) probeCulledEntities++; - // Fix B: select this entity's up-to-8 point/spot lights ONCE (the set - // is constant across the entity's parts/tuples), by the entity's - // bounding sphere — camera-INDEPENDENT (minimize_object_lighting). - ComputeEntityLightSet(entity); - // #119 decisive probe: one-shot dump (+ change re-emission) for // ACDREAM_DUMP_ENTITY-targeted entities. Before the culled-continue // so a routed-out entity still reports its state. @@ -1404,18 +1350,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable if (_clipSlotData.Length < totalInstances) _clipSlotData = new uint[totalInstances + 256]; - // Fix B: per-instance light-set buffer, MaxLightsPerObject ints per - // instance, laid out in the SAME group order / cursor as _instanceData - // so instanceLightIdx[instanceIndex*8 + k] (binding=5) tracks - // Instances[instanceIndex] (binding=0). - if (_lightSetData.Length < totalInstances * LightManager.MaxLightsPerObject) - _lightSetData = new int[(totalInstances + 256) * LightManager.MaxLightsPerObject]; - - // #142: per-instance indoor flag buffer, one uint per instance, parallel to - // _clipSlotData / _instanceData. Grown on demand like the others. - if (_indoorData.Length < totalInstances) - _indoorData = new uint[totalInstances + 256]; - _opaqueDraws.Clear(); _translucentDraws.Clear(); @@ -1441,16 +1375,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // Slots[] is parallel to Matrices[] within the group; write the // slot at the same cursor so binding=3 stays aligned with binding=0. _clipSlotData[cursor] = grp.Slots[i]; - // Fix B: LightSets[] holds 8 ints per instance, parallel to - // Matrices[]; copy this instance's block to the same cursor so - // binding=5 stays aligned with binding=0. - int lsDst = cursor * LightManager.MaxLightsPerObject; - int lsSrc = i * LightManager.MaxLightsPerObject; - for (int k = 0; k < LightManager.MaxLightsPerObject; k++) - _lightSetData[lsDst + k] = grp.LightSets[lsSrc + k]; - // #142: IndoorFlags[] is parallel to Matrices[]; write at the same - // cursor so binding=6 instanceIndoor[] tracks binding=0 instances. - _indoorData[cursor] = grp.IndoorFlags[i]; cursor++; } @@ -1536,21 +1460,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable fixed (uint* sp = _clipSlotData) UploadSsbo(_clipSlotSsbo, 3, sp, totalInstances * sizeof(uint)); - // #142: per-instance indoor flag buffer (binding=6), one uint per instance, - // laid out parallel to _instanceData in Phase 3. Only [0..totalInstances) - // is uploaded — stale tail never read (same guarantee as clip-slot above). - fixed (uint* dp = _indoorData) - UploadSsbo(_instIndoorSsbo, 6, dp, totalInstances * sizeof(uint)); - - // Fix B: global point-light buffer (binding=4) + per-instance light-set - // buffer (binding=5). The global buffer is this frame's PointSnapshot; the - // per-instance buffer holds 8 int indices into it per instance, laid out - // parallel to _instanceData in Phase 3. Both bound with ≥1 element so the - // shader never reads an unbound SSBO on a no-lights frame. - UploadGlobalLights(); - fixed (int* lp = _lightSetData) - UploadSsbo(_instLightSetSsbo, 5, lp, totalInstances * LightManager.MaxLightsPerObject * sizeof(int)); - fixed (DrawElementsIndirectCommand* cp = _indirectCommands) { _gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer); @@ -1834,23 +1743,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _gl.BindBufferBase(BufferTargetARB.ShaderStorageBuffer, binding, ssbo); } - /// - /// Fix B: pack into the binding=4 global light - /// buffer (one GlobalLight = 4 vec4 = 16 floats, std430 stride 64 bytes, - /// matching mesh_modern.vert's GlobalLight). Always uploads ≥1 element - /// so the shader never reads an unbound SSBO — on a no-lights frame index 0 is - /// a zeroed dummy that no instance set references (all sets are -1). - /// - private unsafe void UploadGlobalLights() - { - int n = GlobalLightPacker.Pack(_pointSnapshot, ref _globalLightData); - int count = n > 0 ? n : 1; // never zero-size - // Pack guarantees _globalLightData holds at least max(n,1) * FloatsPerLight floats. - fixed (float* gp = _globalLightData) - UploadSsbo(_globalLightsSsbo, 4, gp, - count * GlobalLightPacker.FloatsPerLight * sizeof(float)); - } - /// /// Phase U.3: bind the per-cell clip-region SSBO to binding=2. Prefers the /// shared buffer (set via ); @@ -2044,83 +1936,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } grp.Matrices.Add(model); grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices - AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices - } - - /// - /// Fix B: choose the up-to-8 point/spot lights for THIS entity (the result - /// reused by every part/instance of it), by the entity's world bounding - /// sphere. Camera-independent (), so - /// a static building's torches stay constant as the viewer moves. Fills - /// ; unused slots are -1. On the no-lights - /// path (no snapshot handed in) every slot is -1 ⇒ shader adds no point light. - /// - /// - /// A7 Fix D round 2 (2026-06-19): retail lights OUTDOOR objects with the SUN + - /// ambient ONLY — never the static wall torches. The per-object torch step - /// (minimize_object_lighting, 0x0054d480) runs ONLY in the indoor stage: - /// RenderDeviceD3D::DrawMeshInternal (0x0059f398) calls it under - /// if (Render::useSunlight == 0), and the outdoor landscape stage runs - /// Render::useSunlightSet(1) (PView::DrawCells 0x005a485a, right - /// before LScape::draw which draws buildings/scenery). So a building - /// EXTERIOR shell (, - /// = null) and all outdoor scenery / - /// creatures get the sun, not torches. We mirror that: only objects parented to - /// an EnvCell (indoor) select torches; outdoor objects keep the all-(-1) set so - /// the sun path alone lights them. This is what made the Holtburg meeting-hall - /// facade wash out warm — the dat's intensity-100 wall torches (range - /// Falloff×1.3) were flooding the exterior shell that retail never torch-lights. - /// The indoor "no sun" half is already handled by the global sun kill when the - /// player is inside a cell (UpdateSunFromSky). See the divergence register - /// (AP-43) and docs/research/2026-06-19-lighting-a7-fixD-round2-*. - /// - /// - private void ComputeEntityLightSet(WorldEntity entity) - { - // #142: set the indoor flag first so it's available even when the early-return - // fires below. Both the torch selection and the sun gate use the same predicate, - // so they can't disagree — one call, one truth. - _currentEntityIndoor = IndoorObjectReceivesTorches(entity.ParentCellId); - - Array.Fill(_currentEntityLightSet, -1); - var snap = _pointSnapshot; - if (snap is null || snap.Count == 0) return; - - // Retail useSunlight gate: outdoor objects receive no per-object torches. - if (!_currentEntityIndoor) return; // #142: reuse the cached flag (was: IndoorObjectReceivesTorches(...)) - - if (entity.AabbDirty) entity.RefreshAabb(); - Vector3 center = (entity.AabbMin + entity.AabbMax) * 0.5f; - float radius = (entity.AabbMax - entity.AabbMin).Length() * 0.5f; - LightManager.SelectForObject(snap, center, radius, _currentEntityLightSet); - } - - /// - /// Retail's useSunlight gate for per-object torch lighting, as a pure - /// predicate. An object receives the static wall torches (the indoor - /// minimize_object_lighting pass) ONLY when it is parented to an EnvCell - /// — an interior cell, by the AC convention (cellId & 0xFFFF) >= 0x0100. - /// Outdoor objects (building shells with null , - /// outdoor scenery in a land sub-cell 0x0001..0x00FF, outdoor creatures) - /// are sun-lit only and return false. Mirrors - /// RenderDeviceD3D::DrawMeshInternal (0x0059f398): torches enabled iff - /// Render::useSunlight == 0, which is true only in the indoor draw stage. - /// - internal static bool IndoorObjectReceivesTorches(uint? parentCellId) - => parentCellId.HasValue - && (parentCellId.Value & 0xFFFFu) >= 0x0100u - && (parentCellId.Value & 0xFFFFu) != 0xFFFFu; // 0xFFFF = landblock marker, not an EnvCell → outdoor - - /// - /// Fix B: append the current entity's 8-slot light set to a group's - /// , parallel to its Matrices (one - /// 8-int block per instance), mirroring grp.Slots.Add. - /// - private void AppendCurrentLightSet(InstanceGroup grp) - { - for (int k = 0; k < LightManager.MaxLightsPerObject; k++) - grp.LightSets.Add(_currentEntityLightSet[k]); - grp.IndoorFlags.Add(_currentEntityIndoor ? 1u : 0u); // #142, parallel to the light block } private void ClassifyBatches( @@ -2178,7 +1993,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable } grp.Matrices.Add(model); grp.Slots.Add(_currentEntitySlot); // Phase U.4 — parallel to Matrices - AppendCurrentLightSet(grp); // Fix B — 8 ints per instance, parallel to Matrices collector?.Add(new CachedBatch(key, texHandle, restPose)); } } @@ -2258,9 +2072,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable _gl.DeleteBuffer(_indirectBuffer); if (_clipSlotSsbo != 0) _gl.DeleteBuffer(_clipSlotSsbo); // Phase U.3 if (_fallbackClipRegionSsbo != 0) _gl.DeleteBuffer(_fallbackClipRegionSsbo); // Phase U.3 - if (_globalLightsSsbo != 0) _gl.DeleteBuffer(_globalLightsSsbo); // Fix B binding=4 - if (_instLightSetSsbo != 0) _gl.DeleteBuffer(_instLightSetSsbo); // Fix B binding=5 - if (_instIndoorSsbo != 0) _gl.DeleteBuffer(_instIndoorSsbo); // #142 binding=6 if (_gpuQueriesInitialized) { for (int i = 0; i < GpuQueryRingDepth; i++) @@ -2446,19 +2257,5 @@ public sealed unsafe class WbDrawDispatcher : IDisposable // _clipSlotData at the same cursor it writes Matrices[i] into _instanceData, // so the binding=3 instanceClipSlot[] tracks the binding=0 instance. public readonly List Slots = new(); - - // Fix B (A7 #3): per-instance light SET, MaxLightsPerObject(8) ints per - // instance, parallel to Matrices (LightSets[i*8 .. i*8+8) is the selected - // light index block for the instance whose matrix is Matrices[i]). At - // layout time the dispatcher copies each block into _lightSetData at the - // same cursor, so the binding=5 instanceLightIdx[] tracks the binding=0 - // instance. -1 = unused slot. - public readonly List LightSets = new(); - - // #142: per-instance "indoor" flag, parallel to Matrices. IndoorFlags[i] is - // 1 when the instance's entity is parented to an EnvCell (skip the sun); 0 - // for outdoor objects (gets the sun). Written into _indoorData at the same - // cursor as Matrices, so binding=6 instanceIndoor[] tracks binding=0. - public readonly List IndoorFlags = new(); } } diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 9440ba6c..8bbdd6bd 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using AcDream.Content; using AcDream.Core.Meshing; using AcDream.Core.Rendering; using DatReaderWriter; diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs index 9be7601d..a1ceb4db 100644 --- a/src/AcDream.App/RuntimeOptions.cs +++ b/src/AcDream.App/RuntimeOptions.cs @@ -39,9 +39,7 @@ public sealed record RuntimeOptions( bool RetailCloseDegrades, bool DumpSceneryZ, bool DumpLiveSpawns, - int? LegacyStreamRadius, - bool RetailUi, - string? AcDir) + int? LegacyStreamRadius) { /// /// Build options from the process environment. Used by @@ -83,9 +81,7 @@ public sealed record RuntimeOptions( DumpLiveSpawns: IsExactlyOne(env("ACDREAM_DUMP_LIVE_SPAWNS")), // Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on // top of the quality preset's radii. Null when unset or invalid. - LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")), - RetailUi: IsExactlyOne(env("ACDREAM_RETAIL_UI")), - AcDir: NullIfEmpty(env("ACDREAM_AC_DIR"))); + LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS"))); } /// True iff live-mode credentials are present and valid for connecting. diff --git a/src/AcDream.App/Streaming/DungeonStreamingGate.cs b/src/AcDream.App/Streaming/DungeonStreamingGate.cs deleted file mode 100644 index 86aaeb5d..00000000 --- a/src/AcDream.App/Streaming/DungeonStreamingGate.cs +++ /dev/null @@ -1,51 +0,0 @@ -namespace AcDream.App.Streaming; - -/// Result of the per-frame dungeon streaming-gate decision. -/// Passed to — collapse -/// streaming to the single dungeon landblock. -/// When non-null, override the streaming observer to this -/// landblock key (the cell id's high 16 bits, 0xXXYY). Null leaves the caller's observer as-is. -public readonly record struct DungeonGateResult(bool InsideDungeon, uint? ObserverLandblockKey); - -/// -/// AP-36: the dungeon streaming gate (#133 FPS). When the player stands in a SEALED -/// EnvCell (an indoor cell that doesn't see outside), streaming collapses to the single -/// dungeon landblock — AC dungeons have no adjacent landblocks, so the normal 25×25 -/// window would pull in ~129 unrelated ocean-grid dungeons. The trigger is the player's -/// CURRENT cell (CellGraph.CurrCell, set the moment the player is placed), and the -/// observer is pinned to that cell's OWN landblock (the cell id high 16 bits) because a -/// dungeon's EnvCells sit at arbitrary ocean-grid world coords with negative local offsets. -/// -/// Extracted from GameWindow.OnUpdate as a pure function so the -/// teleport-hold rule (below) is unit-testable without the GL/dat/network stack. -/// -public static class DungeonStreamingGate -{ - /// - /// Decide the streaming gate from the player's current cell. - /// - /// True while a teleport arrival is held (the controller is in - /// PortalSpace): the player is NOT yet placed, so is the frozen - /// SOURCE cell, not where the player is going. - /// CurrCell is EnvCell && !SeenOutside. - /// The current cell id (0xXXYYNNNN). - public static DungeonGateResult Compute( - bool isTeleportHold, bool currCellIsSealedDungeon, uint currCellId) - { - // #145/#138: during a teleport hold the player is NOT yet placed, so CurrCell is - // the frozen SOURCE cell — where the player IS, not where they're going. Streaming - // must follow the DESTINATION, which the PortalSpace observer pin already does, so - // the source-cell gate is suppressed. Otherwise a teleport OUT of a dungeon keeps - // streaming collapsed on the source dungeon (CurrCell still sealed) → the outdoor - // destination never hydrates → the TAS transit holds 600 frames → force-snap to - // ocean. A teleport INTO a dungeon is handled explicitly upstream by - // StreamingController.PreCollapseToDungeon (and the controller's _collapsed latch - // holds it through the hold), so suppressing the gate here doesn't regress it. - if (isTeleportHold) - return new DungeonGateResult(false, null); - - if (currCellIsSealedDungeon) - return new DungeonGateResult(true, currCellId >> 16); - return new DungeonGateResult(false, null); - } -} diff --git a/src/AcDream.App/Streaming/EntityVanishProbe.cs b/src/AcDream.App/Streaming/EntityVanishProbe.cs deleted file mode 100644 index a991e261..00000000 --- a/src/AcDream.App/Streaming/EntityVanishProbe.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; - -namespace AcDream.App.Streaming; - -/// -/// TEMP diagnostic (#138-B avatar-vanish). Env-gated (ACDREAM_PROBE_ENT=1) -/// trace of the persistent player entity across teleport streaming churn: -/// whether it is present in the render draw-DATA ( -/// flat view) and whether it survives the dynamics CULL -/// (RetailPViewRenderer.DrawDynamicsLast). -/// -/// The two halves answer the decisive question: is the avatar -/// "missing for a moment" because it is absent from the draw set -/// (re-injected into the pending bucket while its landblock re-streams), or -/// because it is present but culled (in the dynamics list, dropped by -/// the outside-stage / viewcone gate)? -/// -/// STRIP once #138-B is root-caused (throwaway, like the dense-town FPS -/// apparatus). Observation-only — emits no behavior change. -/// -internal static class EntityVanishProbe -{ - public static readonly bool Enabled = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENT") == "1"; - - /// Player server guid, set once at world entry so the draw-side - /// [dyn] line can single out the avatar without plumbing the guid - /// through the render stack. - public static uint PlayerGuid; - - public static void Log(string msg) - { - if (Enabled) Console.WriteLine(msg); - } - - private static string _lastPlayerDyn = ""; - - /// Emit a [dyn] player line only when the outcome string - /// changes — a stationary, drawn avatar must not spam one line per frame - /// (that console+Tee I/O would itself depress FPS and bury the signal). - /// Fires on cell change or DRAWN↔CULLED transition. - public static void LogPlayerDynOnChange(string status) - { - if (!Enabled) return; - if (status == _lastPlayerDyn) return; - _lastPlayerDyn = status; - Console.WriteLine("[dyn] player " + status); - } -} diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 2e2c142d..f5fa2307 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -255,43 +255,7 @@ public sealed class GpuWorldState { if (entity.ServerGuid == 0) return; - uint canonical = (newCanonicalLb & 0xFFFF0000u) | 0xFFFFu; - - // Fast path: already drawn in the correct loaded bucket → nothing to do - // (avoids per-frame list churn for a settled, stationary entity). - if (_loaded.TryGetValue(canonical, out var target)) - { - foreach (var e in target.Entities) - if (ReferenceEquals(e, entity)) return; - } - - // Remove the entity from wherever it currently lives — a loaded bucket - // OR a pending bucket — then re-append to its current landblock. - // - // Scanning _pendingByLandblock is the 2026-07-03 fix for the cold-spawn / - // run-out "invisible player" bug: a persistent (server-spawned) entity - // that spawned into a not-yet-loaded landblock sits in _pendingByLandblock, - // and the old code scanned ONLY _loaded — so it silently no-op'd and left - // the player stranded, hidden, even after its landblock finished loading - // (the AddLandblock pending-drain had already run empty before the churn - // re-parked the player, and the player is excluded from the server-object - // re-hydrate — so RelocateEntity was the ONLY path that could recover it, - // and it couldn't reach a pending entity). Re-appending routes the entity - // to _loaded (drawn) when its landblock is loaded, or back to pending to - // await AddLandblock otherwise. - RemoveEntityFromAllBuckets(entity); - AppendLiveEntity(canonical, entity); - } - - /// - /// Remove (by reference) from whichever - /// or bucket it - /// currently occupies. At most one bucket holds a given entity, so this - /// stops after the first hit. Called by before - /// re-appending, so a stranded pending entity can be promoted. - /// - private void RemoveEntityFromAllBuckets(WorldEntity entity) - { + // Remove from current landblock (find it by scanning) foreach (var kvp in _loaded) { var entities = kvp.Value.Entities; @@ -299,21 +263,23 @@ public sealed class GpuWorldState { if (ReferenceEquals(entities[i], entity)) { + if (kvp.Key == newCanonicalLb) return; // already in the right place + + // Remove from old var newList = new List(entities.Count - 1); for (int j = 0; j < entities.Count; j++) if (j != i) newList.Add(entities[j]); _loaded[kvp.Key] = new LoadedLandblock( - kvp.Value.LandblockId, kvp.Value.Heightmap, newList); + kvp.Value.LandblockId, + kvp.Value.Heightmap, + newList); + + // Add to new (via AppendLiveEntity which handles pending) + AppendLiveEntity(newCanonicalLb, entity); return; } } } - - foreach (var kvp in _pendingByLandblock) - { - if (kvp.Value.Remove(entity)) - return; - } } public void RemoveLandblock(uint landblockId) @@ -332,7 +298,6 @@ public sealed class GpuWorldState if (entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid)) { _persistentRescued.Add(entity); - EntityVanishProbe.Log($"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from=loaded lb=0x{landblockId:X8}"); } } @@ -350,29 +315,6 @@ public sealed class GpuWorldState } } - // #138 (secondary): rescue persistent entities sitting in the PENDING - // bucket too, not just the loaded list. The player is re-injected via - // AppendLiveEntity into its current landblock every frame - // (GameWindow's DrainRescued loop); right after a teleport that - // landblock often hasn't streamed in yet, so the player lands in - // _pendingByLandblock. If that same landblock is then unloaded (a - // streaming churn / re-teleport before it finishes loading), the - // pending entry was silently dropped here — violating the - // "persistent ⇒ survives unload" contract and making the avatar - // vanish after a couple round-trips. Rescue them so DrainRescued - // re-parks them at the next valid landblock. - if (_pendingByLandblock.TryGetValue(landblockId, out var pendingForLb)) - { - foreach (var entity in pendingForLb) - { - if (entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid)) - { - _persistentRescued.Add(entity); - EntityVanishProbe.Log($"[ent] RESCUE guid=0x{entity.ServerGuid:X8} from=pending lb=0x{landblockId:X8}"); - } - } - } - _pendingByLandblock.Remove(landblockId); _aabbs.Remove(landblockId); @@ -477,8 +419,6 @@ public sealed class GpuWorldState _entityScriptActivator?.OnCreate(entity); uint canonicalLandblockId = (landblockId & 0xFFFF0000u) | 0xFFFFu; - bool probePersistent = EntityVanishProbe.Enabled - && entity.ServerGuid != 0 && _persistentGuids.Contains(entity.ServerGuid); if (_loaded.TryGetValue(canonicalLandblockId, out var lb)) { @@ -491,8 +431,6 @@ public sealed class GpuWorldState lb.LandblockId, lb.Heightmap, newEntities); - if (probePersistent) - EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> LOADED(drawn)"); RebuildFlatView(); return; } @@ -506,8 +444,6 @@ public sealed class GpuWorldState _pendingByLandblock[canonicalLandblockId] = bucket; } bucket.Add(entity); - if (probePersistent) - EntityVanishProbe.Log($"[ent] APPEND guid=0x{entity.ServerGuid:X8} lb=0x{canonicalLandblockId:X8} -> PENDING(hidden)"); } /// @@ -614,29 +550,5 @@ public sealed class GpuWorldState private void RebuildFlatView() { _flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray(); - if (EntityVanishProbe.Enabled) ProbeFlatViewTransitions(); - } - - // TEMP (#138-B): persistent guids currently present in the drawn flat - // view, for EntityVanishProbe transition logging. Strip with the probe. - private readonly HashSet _persistentInFlatProbe = new(); - - // TEMP (#138-B): log when a persistent (player) entity enters/leaves the - // drawn flat view. Transition-gated → low volume (fires at teleport - // boundaries, not every rebuild). Strip with EntityVanishProbe. - private void ProbeFlatViewTransitions() - { - var now = new HashSet(); - foreach (var e in _flatEntities) - if (e.ServerGuid != 0 && _persistentGuids.Contains(e.ServerGuid)) - now.Add(e.ServerGuid); - foreach (var g in now) - if (!_persistentInFlatProbe.Contains(g)) - EntityVanishProbe.Log($"[ent] DRAWSET guid=0x{g:X8} -> PRESENT (flatCount={_flatEntities.Count})"); - foreach (var g in _persistentInFlatProbe) - if (!now.Contains(g)) - EntityVanishProbe.Log($"[ent] DRAWSET guid=0x{g:X8} -> ABSENT (flatCount={_flatEntities.Count})"); - _persistentInFlatProbe.Clear(); - foreach (var g in now) _persistentInFlatProbe.Add(g); } } diff --git a/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs b/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs deleted file mode 100644 index ef473633..00000000 --- a/src/AcDream.App/Streaming/LandblockEntityRehydrator.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Collections.Generic; - -namespace AcDream.App.Streaming; - -/// -/// Decides which retained server-object spawns to re-hydrate (rebuild render -/// entities for) when a landblock (re)loads. The pure selection half of the -/// #138 fix; GameWindow owns the replay half (it alone can rebuild a -/// mesh from a CreateObject). -/// -/// -/// Why this exists (retail/ACE model). A real AC client keeps its -/// weenie_object_table across a teleport and re-projects its rendered -/// world from that table; the server does NOT re-broadcast objects it believes -/// the client already knows (ACE's per-player KnownObjects set is never -/// cleared on a normal teleport — ACE relies on the client retaining the -/// table, cross-checked against references/holtburger + -/// references/ACE/Source/ACE.Server/Physics/Common/ObjectMaint.cs). -/// -/// -/// -/// acdream's dungeon-collapse (and Near→Far demote) drops a landblock's -/// RENDER entities for FPS but retains the parsed spawns -/// (GameWindow._lastSpawnByGuid — the table is only pruned by an -/// explicit server DeleteObject or a spawn de-dup). On reload, ACE will -/// not re-send the objects it still thinks we have, so the render side stays -/// empty — the #138 symptom. This selects the retained spawns whose render -/// entity is currently absent so GameWindow can replay them, making -/// re-delivery independent of the server. -/// -/// -public static class LandblockEntityRehydrator -{ - /// - /// A retained spawn reduced to just the fields the selection needs. - /// is true only when the spawn carries both - /// a world position AND a Setup id — i.e. it would actually build a visible - /// render entity (inventory items and setup-less spawns produce none and - /// are skipped, matching OnLiveEntitySpawnedLocked's own guard). - /// - public readonly record struct RetainedSpawn(uint Guid, uint SpawnLandblockId, bool HasWorldMesh); - - /// - /// Canonical landblock id (low 16 bits forced to 0xFFFF) — the same - /// keying uses, so a - /// cell-resolved spawn id (0xAAAA00CC) and a streamed landblock id - /// (0xAAAAFFFF) compare equal when they name the same landblock. - /// - public static uint Canonicalize(uint landblockId) => (landblockId & 0xFFFF0000u) | 0xFFFFu; - - /// - /// Select the guids whose retained spawn should be replayed for the - /// just-loaded . - /// - /// The landblock that just (re)loaded. - /// Snapshot of the retained world-object spawns. - /// - /// Server guids that already have a live render entity in - /// . A guid here was either never dropped or was - /// already re-delivered by the server, so replaying it would be redundant. - /// - /// - /// The local player's guid — never re-hydrated here; it is owned by the - /// persistent-entity rescue path ( - /// + DrainRescued), which preserves the player's live pose/position - /// rather than resetting it to the spawn snapshot. - /// - public static List SelectGuidsToRehydrate( - uint loadedLandblockId, - IReadOnlyCollection retainedSpawns, - IReadOnlySet presentServerGuids, - uint playerServerGuid) - { - uint loadedCanonical = Canonicalize(loadedLandblockId); - var result = new List(); - foreach (var s in retainedSpawns) - { - if (!s.HasWorldMesh) continue; // no visible entity to rebuild - if (s.Guid == playerServerGuid) continue; // player: persistent-rescue path owns it - if (Canonicalize(s.SpawnLandblockId) != loadedCanonical) continue; // different landblock - if (presentServerGuids.Contains(s.Guid)) continue; // already rendered - result.Add(s.Guid); - } - return result; - } -} diff --git a/src/AcDream.App/Streaming/LandblockStreamJob.cs b/src/AcDream.App/Streaming/LandblockStreamJob.cs index 050c1265..c5e36815 100644 --- a/src/AcDream.App/Streaming/LandblockStreamJob.cs +++ b/src/AcDream.App/Streaming/LandblockStreamJob.cs @@ -14,16 +14,6 @@ public abstract record LandblockStreamJob(uint LandblockId) { public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId); public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId); - - /// - /// Control job: drop every queued (not-yet-started) Load from the worker's - /// priority queues, keeping Unloads. Posted by - /// when the player enters a - /// dungeon and the in-flight outdoor/neighbor window load must be cancelled - /// (#133 FPS — dungeons have no adjacent landblocks). LandblockId is 0 by - /// convention; readers pattern-match on the type. - /// - public sealed record ClearLoads() : LandblockStreamJob(0); } /// diff --git a/src/AcDream.App/Streaming/LandblockStreamer.cs b/src/AcDream.App/Streaming/LandblockStreamer.cs index f5602b12..19b2a94b 100644 --- a/src/AcDream.App/Streaming/LandblockStreamer.cs +++ b/src/AcDream.App/Streaming/LandblockStreamer.cs @@ -127,7 +127,6 @@ public sealed class LandblockStreamer : IDisposable { if (System.Threading.Volatile.Read(ref _disposed) != 0) throw new ObjectDisposedException(nameof(LandblockStreamer)); - AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("ENQ", landblockId, $"kind={kind}"); _inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind)); } @@ -142,22 +141,6 @@ public sealed class LandblockStreamer : IDisposable _inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId)); } - /// - /// Cancel every queued-but-not-started Load. Posts a - /// control job which the worker - /// honours at read time, dropping all pending Loads from both priority - /// queues (Unloads survive). Used on the dungeon-entry edge to abort the - /// in-flight 25×25 neighbor window so the ~129 ocean-grid dungeons never - /// finish loading (#133 FPS). Loads the worker has ALREADY dequeued still - /// complete; the StreamingController's collapsed-sweep unloads those few. - /// - public void ClearPendingLoads() - { - if (System.Threading.Volatile.Read(ref _disposed) != 0) - throw new ObjectDisposedException(nameof(LandblockStreamer)); - _inbox.Writer.TryWrite(new LandblockStreamJob.ClearLoads()); - } - /// /// Drain up to completed results. /// Non-blocking. Call from the render thread once per OnUpdate. @@ -197,18 +180,7 @@ public sealed class LandblockStreamer : IDisposable } while (_inbox.Reader.TryRead(out var job)) - { - if (job is LandblockStreamJob.ClearLoads) - { - // Dungeon-entry cancellation: drop every queued Load, - // keep Unloads. Handled at read time so it supersedes - // Loads sitting in the priority queues ahead of it. - DropLoadJobs(highPriority); - DropLoadJobs(lowPriority); - continue; - } EnqueuePrioritized(job, highPriority, lowPriority); - } if (highPriority.Count == 0 && lowPriority.Count == 0) continue; @@ -261,22 +233,6 @@ public sealed class LandblockStreamer : IDisposable lowPriority.Enqueue(job); } - /// - /// Drop every from a priority queue, - /// preserving Unloads (and any other control jobs). Rotates the queue once - /// in place. Used by the path. - /// - private static void DropLoadJobs(Queue queue) - { - int count = queue.Count; - for (int i = 0; i < count; i++) - { - var job = queue.Dequeue(); - if (job is not LandblockStreamJob.Load) - queue.Enqueue(job); - } - } - private static void RemoveLowPriorityJobsForLandblock( Queue queue, uint landblockId, diff --git a/src/AcDream.App/Streaming/StreamingController.cs b/src/AcDream.App/Streaming/StreamingController.cs index 2e1f8682..f0bc0955 100644 --- a/src/AcDream.App/Streaming/StreamingController.cs +++ b/src/AcDream.App/Streaming/StreamingController.cs @@ -22,35 +22,9 @@ public sealed class StreamingController private readonly Func> _drainCompletions; private readonly Action _applyTerrain; private readonly Action? _removeTerrain; - private readonly Action? _clearPendingLoads; - - /// - /// #138: fired after a landblock's entity layer (re)loads — both the full - /// path (initial / dungeon-exit - /// expand) and the path - /// (Far→Near). GameWindow wires it to re-hydrate server objects whose - /// render entities were dropped by the collapse/demote but whose parsed - /// spawns it still retains. Receives the canonical landblock id. Null in - /// tests that don't exercise re-hydration. - /// - private readonly Action? _onLandblockLoaded; private readonly GpuWorldState _state; private StreamingRegion? _region; - // True while streaming is collapsed to the single dungeon landblock the - // player stands in (the dungeon gate, #133 FPS). AC dungeons have NO - // adjacent landblocks — neighbors are unrelated ocean-grid dungeons that - // are never visible, so we stop loading the 25×25 window entirely. - private bool _collapsed; - - // The dungeon landblock id we collapsed onto. Once collapsed we key the - // gate on this STABLE landblock, not the per-frame insideDungeon signal: - // CurrCell can momentarily resolve to null/outdoor mid-frame, and gating - // expand on that flicker thrashes collapse↔expand (reload storms + a light - // leak). We only expand when the observer actually moves to a different - // landblock (teleport/portal out). - private uint _collapsedCenter; - /// /// Near-tier radius (LBs from observer that load full detail: terrain + /// scenery + entities). Set at construction; readable thereafter. @@ -89,53 +63,6 @@ public sealed class StreamingController /// public int MaxCompletionsPerFrame { get; set; } = 4; - /// - /// #138: the teleport destination landblock id. When non-zero, - /// applies this landblock's - /// or - /// completion immediately - /// — even if it sits past the - /// position in the outbox — so the player can materialise at the - /// destination without waiting for every earlier-queued landblock to - /// drain first. Non-priority completions drained past it are buffered - /// in and applied over subsequent frames, - /// so no completions are lost and there is no GPU spike. - /// - /// Set by GameWindow when a teleport starts; reset to 0 - /// once the destination landblock has been applied (or when the - /// teleport destination changes). - /// - public uint PriorityLandblockId { get; set; } - - /// - /// 2026-06-22: the radius (in landblocks, Chebyshev) around - /// that eager-applies - /// ahead of the per-frame budget. 0 (default) = only the single center landblock — the - /// original priority behaviour. A teleport sets this to the near ring so the player's - /// IMMEDIATE SURROUNDINGS (terrain + collision + scenery) are resident on arrival, not - /// just the one landblock they stand on. Without it, only the destination landblock - /// applies immediately and everything around it drains at , - /// so the player arrives to a near-empty world (the "Fort Tethana only one landblock - /// loaded" symptom) and their cell-walk can't root into neighbour cells yet (the - /// transient walk-through-walls). The far ring still drains at the budget. The eager - /// apply runs during the teleport hold (behind the fade), so the GPU spike is hidden. - /// - public int PriorityRadius { get; set; } - - // [FRAME-DIAG] (read by GameWindow's ACDREAM_WB_DIAG rollup): the standing - // deferred-LOAD backlog. A non-zero value during/after a teleport is the - // GPU-upload tail draining at MaxCompletionsPerFrame. Removable probe surface. - public int DeferredApplyBacklog => _deferredApply.Count; - - // [FRAME-DIAG]: how many times ForceReloadWindow has fired (each one drops + - // re-uploads the WHOLE window) and the landblock count it dropped last time. - public int ForceReloadCount { get; private set; } - public int LastForceReloadDropCount { get; private set; } - - // Completions that were drained past a priority item get buffered here - // so they still apply over subsequent frames without loss. - private readonly List _deferredApply = new(); - public StreamingController( Action enqueueLoad, Action enqueueUnload, @@ -144,17 +71,13 @@ public sealed class StreamingController GpuWorldState state, int nearRadius, int farRadius, - Action? removeTerrain = null, - Action? clearPendingLoads = null, - Action? onLandblockLoaded = null) + Action? removeTerrain = null) { _enqueueLoad = enqueueLoad; _enqueueUnload = enqueueUnload; _drainCompletions = drainCompletions; _applyTerrain = applyTerrain; _removeTerrain = removeTerrain; - _clearPendingLoads = clearPendingLoads; - _onLandblockLoaded = onLandblockLoaded; _state = state; NearRadius = nearRadius; FarRadius = farRadius; @@ -174,76 +97,7 @@ public sealed class StreamingController /// → enqueue full unload /// /// - public void Tick(int observerCx, int observerCy, bool insideDungeon = false) - { - uint centerId = StreamingRegion.EncodeLandblockId(observerCx, observerCy); - - if (_collapsed) - { - // Hysteresis. Cases: - // - Still in the SAME dungeon landblock → hold (sweep stragglers). - // - In a DIFFERENT dungeon cell (multi-landblock dungeon / new dungeon) - // → re-collapse onto it. - // - CurrCell flickered null but the player hasn't gone anywhere: the - // observer landblock reverts to the position-derived value, which for a - // dungeon is only ever the ADJACENT off-by-one landblock (negative cell- - // local Y). Hold — never expand on an adjacent flicker. - // - Genuinely left to a DISTANT landblock (portal/teleport out, always far - // from the ocean-grid dungeon block) → expand. - if (insideDungeon && centerId != _collapsedCenter) - EnterDungeonCollapse(observerCx, observerCy, centerId); - else if (!insideDungeon && ChebyshevLandblocks(centerId, _collapsedCenter) > 1) - ExitDungeonExpand(observerCx, observerCy); - else - SweepCollapsed(); - } - else if (insideDungeon) - { - EnterDungeonCollapse(observerCx, observerCy, centerId); - } - else - { - NormalTick(observerCx, observerCy); - } - - DrainAndApply(); - } - - /// - /// #135: collapse to a single dungeon landblock IMMEDIATELY, before the first - /// has a chance to bootstrap the full 25×25 window. Called - /// from the login / teleport spawn path the instant the streaming center is - /// recentered onto a SEALED dungeon landblock. - /// - /// The per-frame insideDungeon gate keys on the physics - /// CurrCell, which is only set once the player is PLACED — and placement - /// waits for the dungeon landblock to hydrate. So for the whole hydration window - /// (tens of seconds for a ~200-cell dungeon) the gate reads false and - /// would enqueue the ~24 unrelated ocean-grid neighbor - /// dungeons (+ ~19k entities each); the collapse then only mops them up after - /// placement. That mop-up is the 10→high FPS ramp users see at a dungeon login. - /// - /// Pre-collapsing means the EXPENSIVE dungeon-neighbour window is never - /// enqueued. On teleport nothing is enqueued at all (this fires before the next - /// Tick recenters). On login a brief Holtburg outdoor window may be enqueued by the - /// frame-1 NormalTick (before the player's spawn arrives) and is immediately - /// cancelled by _clearPendingLoads here — cheap outdoor terrain, not the - /// ocean-grid dungeons, and a handful of already-dequeued loads get swept next - /// frame. Idempotent: a no-op when already collapsed onto this same landblock, so a - /// re-sent spawn or a same-frame double call costs nothing. Render-thread only, - /// same as . - /// - public void PreCollapseToDungeon(int cx, int cy) - { - uint centerId = StreamingRegion.EncodeLandblockId(cx, cy); - if (_collapsed && _collapsedCenter == centerId) return; - EnterDungeonCollapse(cx, cy, centerId); - } - - /// - /// Outdoor / building-interior streaming — the original two-tier model. - /// - private void NormalTick(int observerCx, int observerCy) + public void Tick(int observerCx, int observerCy) { if (_region is null) { @@ -262,213 +116,35 @@ public sealed class StreamingController foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id); foreach (var id in diff.ToUnload) _enqueueUnload(id); } - } - /// - /// Dungeon-entry edge: cancel the in-flight window load, unload every - /// resident neighbor, and pin streaming to the player's single dungeon - /// landblock. Retail-faithful — AC dungeons have no adjacent landblocks - /// (ACE LandblockManager.GetAdjacentIDs returns empty for a dungeon); - /// the 25×25 window was pulling in ~129 unrelated ocean-grid dungeons and - /// their thousands of emitters (#133 FPS). Unloading them also tears down - /// their lights, shrinking the static-light set toward retail's ≤40. - /// - private void EnterDungeonCollapse(int cx, int cy, uint centerId) - { - if (!_collapsed || _collapsedCenter != centerId) - Console.WriteLine($"streaming: dungeon collapse -> 0x{centerId:X8}"); - _collapsed = true; - _collapsedCenter = centerId; - _clearPendingLoads?.Invoke(); - - foreach (var id in _state.LoadedLandblockIds) - if (id != centerId) _enqueueUnload(id); - - // Pin a radius-0 region so RecenterTo never re-expands while inside, - // and so the post-exit rebuild starts from a clean, consistent state. - _region = new StreamingRegion(cx, cy, 0, 0); - _region.MarkResidentFromBootstrap(); - - // The dungeon landblock itself must be (or become) loaded. If a prior - // ClearPendingLoads cancelled its queued load, re-enqueue it. - if (!_state.IsLoaded(centerId)) - _enqueueLoad(centerId, LandblockStreamJobKind.LoadNear); - } - - /// - /// While collapsed, unload any landblock that finished loading after the - /// collapse edge — a Load the worker had already dequeued before the - /// control job took - /// effect. At steady state only the dungeon landblock is resident, so this - /// is a no-op. - /// - private void SweepCollapsed() - { - // Always preserve the true dungeon landblock (_collapsedCenter), never the - // per-frame observer landblock — a CurrCell flicker must not unload the dungeon. - foreach (var id in _state.LoadedLandblockIds) - if (id != _collapsedCenter) _enqueueUnload(id); - } - - /// Chebyshev distance in landblock cells between two landblock ids. - private static int ChebyshevLandblocks(uint a, uint b) - { - int ax = (int)((a >> 24) & 0xFFu), ay = (int)((a >> 16) & 0xFFu); - int bx = (int)((b >> 24) & 0xFFu), by = (int)((b >> 16) & 0xFFu); - return Math.Max(Math.Abs(ax - bx), Math.Abs(ay - by)); - } - - /// - /// True when is the priority center or within - /// landblocks of it (Chebyshev) — i.e. inside the teleport - /// near ring that eager-applies. With the default radius 0 - /// this reduces to an exact match on (the original - /// single-landblock priority behaviour). - /// - private bool IsWithinPriorityRing(uint id) - => PriorityLandblockId != 0u - && ChebyshevLandblocks(id, PriorityLandblockId) <= PriorityRadius; - - /// - /// Dungeon-exit edge (portal to outdoors / teleport): rebuild the full - /// two-tier window at the new center and unload anything resident from the - /// collapsed state that falls outside it. - /// - private void ExitDungeonExpand(int observerCx, int observerCy) - { - Console.WriteLine( - $"streaming: dungeon EXIT-expand -> ({observerCx},{observerCy}) " + - $"(was collapsed on 0x{_collapsedCenter:X8})"); - _collapsed = false; - var rebuilt = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius); - - foreach (var id in _state.LoadedLandblockIds) - if (!rebuilt.Resident.Contains(id)) _enqueueUnload(id); - - var boot = rebuilt.ComputeFirstTickDiff(); - foreach (var id in boot.ToLoadNear) - if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadNear); - foreach (var id in boot.ToLoadFar) - if (!_state.IsLoaded(id)) _enqueueLoad(id, LandblockStreamJobKind.LoadFar); - rebuilt.MarkResidentFromBootstrap(); - _region = rebuilt; - } - - /// - /// 2026-06-22: an OUTDOOR teleport moved the render origin (_liveCenter). Every - /// resident terrain block was baked relative to the OLD origin, so any block that survives - /// an INCREMENTAL recenter — which happens on a NEARBY jump where the old and new windows - /// overlap, e.g. (170,168)→(169,180) — renders shifted by the jump distance: the confirmed - /// "terrain in the sky" arcs (the stale slots were all offset by exactly deltaLB×192). - /// SYNCHRONOUSLY drop every resident landblock (render slot + physics + state) so none - /// survives into the next frame stale, and no async unload can race a re-bake, then null - /// the region so the next re-bootstraps the WHOLE window fresh at - /// the new origin (the near ring is priority-applied behind the fade; the rest streams in). - /// A sealed-dungeon destination uses instead. - /// - public void ForceReloadWindow() - { - _collapsed = false; - // Snapshot — RemoveLandblock mutates the loaded set we're iterating. - var ids = new List(_state.LoadedLandblockIds); - ForceReloadCount++; // [FRAME-DIAG] churn counter - LastForceReloadDropCount = ids.Count; // = upcoming re-upload volume - foreach (var id in ids) + // Drain up to N completions per frame so a big diff doesn't spike + // GPU upload time. Remaining completions wait for the next frame. + var drained = _drainCompletions(MaxCompletionsPerFrame); + foreach (var result in drained) { - _state.RemoveLandblock(id); - _removeTerrain?.Invoke(id); // frees the render slot + physics + cell registries - } - _region = null; // NormalTick re-creates + bootstraps the full window next frame - } - - /// - /// Apply streamed completions for this frame. LOADS (terrain mesh GPU uploads) are the - /// expensive part, so they are metered at to avoid a - /// GPU-upload spike; the overflow buffers in and drains over - /// subsequent frames. UNLOADS are cheap (they free GPU buffers — no upload) and are applied - /// IMMEDIATELY, never throttled: a teleport produces a whole window of unloads (~600), and - /// metering them at the load rate left the previous location's terrain resident for seconds - /// (rendering at its old world position as "floating terrain at the horizon"), and rapid - /// hops accumulated them faster than they cleared — a runaway resident count (951 observed - /// vs a 625 window) that also dragged FPS. Loads inside the teleport near ring - /// (, applied behind the fade) likewise bypass the budget so the - /// player materialises in a loaded world. - /// - private void DrainAndApply() - { - // --- Step 1: drain the outbox in bounded chunks. Apply unloads + priority near-ring - // loads immediately; defer every other (budget-metered) load. Draining the whole - // outbox each frame (bounded by MaxDrainIterations) is what lets unloads flush - // promptly regardless of the load backlog — the throttle is on GPU UPLOADS, not on - // freeing them. The drain cap must NOT be gated behind the per-frame load budget - // (the prior version returned once the budget hit 0, stranding the outbox). - const int MaxDrainIterations = 64; // cap at 64 * MaxCompletionsPerFrame drained/frame - int iter = 0; - while (iter++ < MaxDrainIterations) - { - var chunk = _drainCompletions(MaxCompletionsPerFrame); - if (chunk.Count == 0) break; - foreach (var result in chunk) + switch (result) { - if (result is LandblockStreamResult.Unloaded - || IsWithinPriorityRing(ResultLandblockId(result))) - ApplyResult(result); // free (unload) or behind-the-fade near ring - else - _deferredApply.Add(result); // a GPU-upload load — meter it in step 2 + case LandblockStreamResult.Loaded loaded: + _applyTerrain(loaded.Landblock, loaded.MeshData); + _state.AddLandblock(loaded.Landblock); + break; + case LandblockStreamResult.Promoted promoted: + _applyTerrain(promoted.Landblock, promoted.MeshData); + _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); + break; + case LandblockStreamResult.Unloaded unloaded: + _state.RemoveLandblock(unloaded.LandblockId); + _removeTerrain?.Invoke(unloaded.LandblockId); + break; + case LandblockStreamResult.Failed failed: + Console.WriteLine( + $"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}"); + break; + case LandblockStreamResult.WorkerCrashed crashed: + Console.WriteLine( + $"streaming: worker CRASHED: {crashed.Error}"); + break; } } - - // --- Step 2: apply the deferred LOAD backlog at the per-frame budget (FIFO, so - // earlier-queued landblocks win). Caps GPU upload per frame so a big diff doesn't - // spike. _deferredApply now only ever holds loads — unloads were applied in step 1. - int budget = MaxCompletionsPerFrame; - int i = 0; - while (i < budget && i < _deferredApply.Count) { ApplyResult(_deferredApply[i]); i++; } - if (i > 0) _deferredApply.RemoveRange(0, i); } - - /// - /// Apply a single with the full side- - /// effects: terrain upload, GPU state, and the re-hydration callback. - /// Extracted from the inline switch in the original DrainAndApply - /// so both the priority-hunt path and the normal drain path share it. - /// - private void ApplyResult(LandblockStreamResult result) - { - switch (result) - { - case LandblockStreamResult.Loaded loaded: - _applyTerrain(loaded.Landblock, loaded.MeshData); - _state.AddLandblock(loaded.Landblock); - // #138: after the landblock is in _loaded (so AppendLiveEntity - // hot-paths), restore any retained server objects ACE won't - // re-send. Fired AFTER AddLandblock, never before. - _onLandblockLoaded?.Invoke(loaded.Landblock.LandblockId); - break; - case LandblockStreamResult.Promoted promoted: - _applyTerrain(promoted.Landblock, promoted.MeshData); - _state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities); - _onLandblockLoaded?.Invoke(promoted.LandblockId); - break; - case LandblockStreamResult.Unloaded unloaded: - _state.RemoveLandblock(unloaded.LandblockId); - _removeTerrain?.Invoke(unloaded.LandblockId); - break; - case LandblockStreamResult.Failed failed: - Console.WriteLine( - $"streaming: load failed for 0x{failed.LandblockId:X8}: {failed.Error}"); - break; - case LandblockStreamResult.WorkerCrashed crashed: - Console.WriteLine( - $"streaming: worker CRASHED: {crashed.Error}"); - break; - } - } - - /// - /// Returns the landblock id associated with . - /// For this is 0 by - /// convention (not tied to a specific landblock). - /// - private static uint ResultLandblockId(LandblockStreamResult result) => result.LandblockId; } diff --git a/src/AcDream.App/Studio/DumpLayout.cs b/src/AcDream.App/Studio/DumpLayout.cs deleted file mode 100644 index 1f90f724..00000000 --- a/src/AcDream.App/Studio/DumpLayout.cs +++ /dev/null @@ -1,235 +0,0 @@ -using System.Numerics; -using AcDream.App.UI; - -namespace AcDream.App.Studio; - -// ───────────────────────────────────────────────────────────────────────────── -// DumpLayout — load a panel from the retail UI layout dump -// -// The dump stores every node's rect in ABSOLUTE screen coordinates (the -// panel's design position in the retail UI, not relative to its parent). -// Evidence: for the "inventory" panel, the root node is at x=500,y=138 and -// its direct children are also at x=500,y=161 — the child y=161 is only -// 23 pixels below the parent y=138, which makes sense as a child offset -// (the header row), not as the raw rect. If the rects were parent-relative, -// (500,161) would place the child way off the window. -// -// DumpLayout converts absolute → parent-relative by computing: -// child.Left = child.Rect.X - parent.Rect.X -// child.Top = child.Rect.Y - parent.Rect.Y -// -// The root node (ParentTraversalIndex == null) is placed at (0,0) so the -// whole tree sits at the UiHost origin rather than at the panel's retail -// screen position. -// ───────────────────────────────────────────────────────────────────────────── - -/// -/// Builds a static tree from the retail UI layout dump -/// JSON. The tree is a hierarchy of (draws its -/// sprite) or plain containers (Group nodes), with each -/// node's set to the dump's element_id and -/// set to the widget_kind string. -/// -/// This source is STATIC — no controllers, no FixtureProvider, no live -/// game data. It is a build reference for the UI Studio showing any of the 26 -/// retail windows without needing the production panel wired up. -/// -public static class DumpLayout -{ - /// - /// Parse the dump at , find the panel whose slug - /// matches , and build a tree. - /// - /// - /// maps a RenderSurface id (0x06xxxxxx) to a - /// (GL texture handle, native width, native height) triple — pass - /// RenderStack.ResolveChrome from the studio, or a stub returning - /// (1,1,1) for tests. - /// - /// - /// Returns null and sets on failure. - /// - public static UiElement? Load( - string dumpPath, - string slug, - Func resolve, - out string? error) - { - // ── 1. Parse the dump JSON ──────────────────────────────────────── - var dump = UiDumpModel.Parse(dumpPath); - if (dump is null) - { - error = $"[dump] Failed to parse '{dumpPath}'."; - return null; - } - - // ── 2. Find the requested panel ─────────────────────────────────── - var panel = dump.Panels.FirstOrDefault( - p => string.Equals(p.Slug, slug, StringComparison.OrdinalIgnoreCase)); - if (panel is null) - { - error = $"[dump] Panel slug '{slug}' not found. " + - $"Available: {string.Join(", ", dump.Panels.Select(p => p.Slug))}"; - return null; - } - - if (panel.Nodes.Count == 0) - { - error = $"[dump] Panel '{slug}' has no nodes."; - return null; - } - - // ── 3. Build a traversal-index → node lookup ────────────────────── - var byIndex = new Dictionary(panel.Nodes.Count); - foreach (var n in panel.Nodes) - byIndex[n.TraversalIndex] = n; - - // ── 4. Create UiElement objects for every node ──────────────────── - var elements = new Dictionary(panel.Nodes.Count); - foreach (var node in panel.Nodes) - { - var el = BuildElement(node, resolve); - elements[node.TraversalIndex] = el; - } - - // ── 5. Wire parent–child relationships + set parent-relative coords ─ - UiElement? root = null; - foreach (var node in panel.Nodes) - { - var el = elements[node.TraversalIndex]; - - if (node.ParentTraversalIndex is null) - { - // Root node — place at (0,0) so the tree sits at the UiHost origin. - // The panel's absolute rect offset is discarded here (it was the - // retail design position inside the retail screen, which we don't need). - el.Left = 0f; - el.Top = 0f; - root = el; - } - else - { - // Non-root: convert absolute → parent-relative by subtracting parent rect. - // child.Left = child.Rect.X - parent.Rect.X - // child.Top = child.Rect.Y - parent.Rect.Y - // This preserves the visual layout inside each group without placing the - // entire panel at its retail screen origin. - var parentNode = byIndex[node.ParentTraversalIndex.Value]; - el.Left = node.Rect.X - parentNode.Rect.X; - el.Top = node.Rect.Y - parentNode.Rect.Y; - - var parentEl = elements[node.ParentTraversalIndex.Value]; - parentEl.AddChild(el); - } - } - - if (root is null) - { - error = $"[dump] Panel '{slug}': no root node found (all nodes have a parent)."; - return null; - } - - // Give the root the full panel dimensions (from the dump's width/height record). - root.Width = panel.Width; - root.Height = panel.Height; - - error = null; - return root; - } - - // ── Private helpers ─────────────────────────────────────────────────────── - - private static UiElement BuildElement( - DumpNode node, - Func resolve) - { - uint imageId = UiDumpModel.PickImageId(node); - var kind = node.WidgetKind ?? "Group"; - - UiElement el; - if (imageId != 0 && !string.Equals(kind, "Group", StringComparison.OrdinalIgnoreCase)) - { - // Sprite/Button/Scrollbar/Slider — create a sprite-drawing element. - el = new DumpSpriteElement(imageId, resolve) - { - Name = kind, - ClickThrough = true, // static mockup; no behavior - Anchors = AnchorEdges.None, - }; - } - else - { - // Group (or sprite without an image) — plain container, no own draw. - el = new DumpGroupElement() - { - Name = kind, - ClickThrough = true, - Anchors = AnchorEdges.None, - }; - } - - // EventId is set from the dump's element_id (cast to uint — the decimal - // values in the JSON represent the same dat handle used at runtime). - el.EventId = (uint)node.ElementId; - el.Left = node.Rect.X; // overwritten by caller per root/child logic - el.Top = node.Rect.Y; - el.Width = node.Rect.Width; - el.Height = node.Rect.Height; - - return el; - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// DumpSpriteElement — minimal element that draws a single sprite -// ───────────────────────────────────────────────────────────────────────────── - -/// -/// Draws a single sprite at its native size tiled to fill -/// × . Used for Sprite/Button/Scrollbar/Slider nodes from -/// the retail UI dump. -/// -/// We do NOT reuse here because -/// that class requires an ElementInfo with a populated StateMedia -/// dictionary — the dat-import plumbing — which is not needed for a static dump -/// preview. A minimal subclass keeps the code simpler and the dependency surface -/// smaller. -/// -internal sealed class DumpSpriteElement : UiElement -{ - private readonly uint _imageId; - private readonly Func _resolve; - - public DumpSpriteElement(uint imageId, Func resolve) - { - _imageId = imageId; - _resolve = resolve; - } - - protected override void OnDraw(UiRenderContext ctx) - { - if (_imageId == 0) return; - - var (tex, tw, th) = _resolve(_imageId); - if (tex == 0 || tw == 0 || th == 0) return; - - // Tile at native resolution (same as UiDatElement.OnDraw — UV-repeat on both - // axes via GL_REPEAT, Width/tw and Height/th tile the texture). - ctx.DrawSprite(tex, 0, 0, Width, Height, - 0, 0, Width / tw, Height / th, Vector4.One); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// DumpGroupElement — pure container (Group nodes from the dump) -// ───────────────────────────────────────────────────────────────────────────── - -/// -/// Container element for dump Group nodes — no own draw, just hosts children. -/// Extending UiElement directly (no OnDraw override) gives transparent groups, -/// which matches Group nodes in the retail layout that have no background sprite. -/// -internal sealed class DumpGroupElement : UiElement -{ - // No OnDraw — completely transparent container. -} diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs deleted file mode 100644 index 75956370..00000000 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ /dev/null @@ -1,159 +0,0 @@ -using AcDream.App.Rendering; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using AcDream.Core.Items; -using AcDream.Core.Net.Messages; -using DatReaderWriter; - -namespace AcDream.App.Studio; - -/// -/// Populates a loaded panel with sample data by calling the production -/// controller Bind methods against . -/// -/// -/// The studio is intentionally thin — there is no live game session, no -/// server connection, and no network. FixtureProvider bridges that gap by -/// feeding static fixtures (vitals percentages, a fake inventory, empty -/// shortcut lists) so the bound widgets show plausible state instead of -/// empty zeroes. -/// -/// -/// -/// IconIds approach: raw-resolve stub — resolve the base iconId -/// via and return the GL handle -/// directly. This is intentionally simpler than GameWindow's full -/// (5-layer composite). The raw icon is enough -/// to confirm the grid cells draw something in the studio; the full -/// compositor is the live-game concern, not the layout preview concern. -/// -/// -public static class FixtureProvider -{ - /// - /// Populate with sample data appropriate for - /// . Calls the production controller Bind - /// methods so the panel's widgets drive off the same code path as the - /// full game. - /// - /// The LayoutDesc dat id used to import the layout. - /// The imported layout whose widgets to populate. - /// The live render stack (for - /// and ). - /// A already seeded by - /// . - /// The live DAT collection used to resolve per-list empty-slot sprites - /// (same lookup GameWindow.OnLoad performs for the production binding). - public static void Populate( - uint layoutId, - ImportedLayout layout, - RenderStack stack, - ClientObjectTable objects, - DatCollection dats) - { - switch (layoutId) - { - case 0x2100006Cu: // vitals - VitalsController.Bind(layout, - healthPct: () => SampleData.HealthPct, - staminaPct: () => SampleData.StaminaPct, - manaPct: () => SampleData.ManaPct, - healthText: () => "80/100", - staminaText: () => "60/100", - manaText: () => "90/100"); - break; - - case 0x21000016u: // toolbar - ToolbarController.Bind( - layout, - objects, - shortcuts: () => System.Array.Empty(), - iconIds: MakeIconIds(stack), - useItem: _ => { }, - combatState: null, - peaceDigits: null, - warDigits: null, - emptyDigits: null, - sendAddShortcut: null, - sendRemoveShortcut: null); - break; - - case 0x21000023u: // inventory + paperdoll - { - // Resolve the per-list empty-slot art from the dat cell template, matching the - // exact lookup GameWindow.OnLoad performs (UIElement_ItemList::InternalCreateItem - // 0x004e3570 → attr 0x1000000e → catalog 0x21000037 → ItemSlot_Empty). - uint contentsEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000021u, 0x100001C6u); - uint sideBagEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001CAu); - uint mainPackEmpty = ItemListCellTemplate.ResolveEmptySprite(dats, 0x21000022u, 0x100001C9u); - - var iconIds = MakeIconIds(stack); - - InventoryController.Bind( - layout, - objects, - playerGuid: () => SampleData.PlayerGuid, - iconIds: iconIds, - strength: () => 100, - datFont: stack.VitalsDatFont, - contentsEmptySprite: contentsEmpty, - sideBagEmptySprite: sideBagEmpty, - mainPackEmptySprite: mainPackEmpty); - - // Bind the paperdoll equip slots (same imported subtree as the inventory). - // Mirrors GameWindow:2257-2265: PaperdollController.Bind with contentsEmpty as - // emptySlotSprite (each slot shows the same square-frame placeholder as the grid). - PaperdollController.Bind( - layout, - objects, - playerGuid: () => SampleData.PlayerGuid, - iconIds: iconIds, - sendWield: null, // no live session in the studio - emptySlotSprite: contentsEmpty, - datFont: stack.VitalsDatFont); - break; - } - - case 0x2100002Eu: // gmStatManagementUI — Attributes/Skills/Titles window (LayoutDesc 0x2100002E) - // Bind the REAL importer-mounted header + list elements (name/heritage/PK/level/ - // total-XP/XP-meter + the 9-row attribute list + footer State-A). NOT the text-report - // sub-panel (that is gmCharacterInfoUI 0x2100001A → CharacterController). - // LargeDatFont (0x40000001, MaxCharHeight=18) is used for the attribute row text; - // fallback to VitalsDatFont (0x40000000, 16px) if unavailable. - CharacterStatController.Bind( - layout, - data: SampleData.SampleCharacter, - datFont: stack.VitalsDatFont, - rowDatFont: stack.LargeDatFont ?? stack.VitalsDatFont, - spriteResolve: stack.ResolveChrome); - break; - - default: - // Unknown layout — no-op; the panel renders structurally. - break; - } - } - - // ── Helpers ───────────────────────────────────────────────────────────── - - /// - /// Build the iconIds delegate for toolbar / inventory controllers. - /// - /// - /// Raw-resolve stub: resolve the base (arg 2) - /// via and return its GL handle. - /// The remaining args (type, underlayId, overlayId, effects) are ignored - /// for the studio — a single-layer icon is sufficient for layout preview. - /// - /// - /// This is what the task spec calls "v1 raw-resolve stub". - /// - private static Func MakeIconIds(RenderStack stack) - => (_, iconId, _, _, _) => - { - if (iconId == 0u) return 0u; - var (handle, _, _) = stack.ResolveChrome(iconId); - return handle; - }; - -} diff --git a/src/AcDream.App/Studio/LayoutSource.cs b/src/AcDream.App/Studio/LayoutSource.cs deleted file mode 100644 index 9aa2517d..00000000 --- a/src/AcDream.App/Studio/LayoutSource.cs +++ /dev/null @@ -1,123 +0,0 @@ -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using DatReaderWriter; - -namespace AcDream.App.Studio; - -/// Which kind of source the studio is currently previewing. -public enum LayoutSourceKind { DatLayout, Markup } - -/// -/// Wraps the two ways the UI Studio can load a panel to preview: -/// a LayoutDesc dat id, or a KSML markup file path (Task 6 — unsupported now). -/// -/// Call with the current to -/// import the layout and get the root . The result is also -/// cached in so can re-run the same -/// source without re-reading the options. -/// -public sealed class LayoutSource -{ - private readonly DatCollection _dats; - private readonly Func _resolve; - private readonly UiDatFont? _datFont; - private readonly Func? _fontResolve; - - public LayoutSourceKind Kind { get; private set; } - public uint? LayoutId { get; private set; } - public string? MarkupPath { get; private set; } - public string? LastError { get; private set; } - public ImportedLayout? CurrentLayout { get; private set; } - - /// - /// Create a LayoutSource. - /// - /// Optional per-element font resolver: FontDid → - /// (null when the font isn't in the dats). When supplied, - /// elements with a non-zero FontDid receive their own dat font at build time - /// instead of the shared global. Controllers that - /// explicitly set after - /// still override the build-time value. - /// Pass null (default) for the original single-font behavior — the live - /// path passes null so it is provably unchanged. - public LayoutSource( - DatCollection dats, - Func resolve, - UiDatFont? datFont, - Func? fontResolve = null) - { - _dats = dats ?? throw new ArgumentNullException(nameof(dats)); - _resolve = resolve ?? throw new ArgumentNullException(nameof(resolve)); - _datFont = datFont; - _fontResolve = fontResolve; - } - - /// - /// Load the layout described by . For a dat layout - /// ( is non-null) calls - /// . For a markup path sets - /// and returns null (Task 6, not yet implemented). - /// - /// Returns the root on success, or null on failure - /// (check ). - /// - public UiElement? Load(StudioOptions opts) - { - LastError = null; - CurrentLayout = null; - - if (opts.MarkupPath is not null) - { - Kind = LayoutSourceKind.Markup; - MarkupPath = opts.MarkupPath; - LastError = "markup unsupported (Task 6)"; - return null; - } - - if (opts.LayoutId is null) - { - LastError = "ui-studio: no layout id or markup path specified."; - return null; - } - - Kind = LayoutSourceKind.DatLayout; - LayoutId = opts.LayoutId; - return LoadDat(opts.LayoutId.Value); - } - - /// Re-run the most-recently-configured source without re-reading options. - public UiElement? Reload() - { - LastError = null; - CurrentLayout = null; - - if (Kind == LayoutSourceKind.Markup) - { - LastError = "markup unsupported (Task 6)"; - return null; - } - - if (LayoutId is null) - { - LastError = "ui-studio: no layout id to reload."; - return null; - } - - return LoadDat(LayoutId.Value); - } - - // ── Private ────────────────────────────────────────────────────────────────── - - private UiElement? LoadDat(uint layoutId) - { - var imported = LayoutImporter.Import(_dats, layoutId, _resolve, _datFont, _fontResolve); - if (imported is null) - { - LastError = $"ui-studio: LayoutDesc 0x{layoutId:X8} not found in dats."; - return null; - } - - CurrentLayout = imported; - return imported.Root; - } -} diff --git a/src/AcDream.App/Studio/PanelFbo.cs b/src/AcDream.App/Studio/PanelFbo.cs deleted file mode 100644 index 4f61ec55..00000000 --- a/src/AcDream.App/Studio/PanelFbo.cs +++ /dev/null @@ -1,156 +0,0 @@ -using System; -using System.Numerics; -using AcDream.App.Rendering.Wb; -using AcDream.App.UI; -using Silk.NET.OpenGL; - -namespace AcDream.App.Studio; - -/// -/// Renders a into an off-screen FBO each frame and -/// returns the color texture handle for display in ImGui. -/// -/// Pattern lifted verbatim from : -/// RGBA8 color texture + Depth24Stencil8 renderbuffer, lazily (re)created on -/// size change. The entire 2-D UI pass is sealed in a -/// so it cannot disturb the surrounding ImGui GL state. -/// -/// FBO origin is bottom-left (GL convention). The caller must flip V when -/// displaying the texture in ImGui (pass uv0=(0,1), uv1=(1,0) to ImGui.Image) -/// so the image appears right-side-up in ImGui's top-left coordinate system. -/// -public sealed unsafe class PanelFbo : IDisposable -{ - private readonly GL _gl; - - // Off-screen target — lazily (re)created when the requested size changes. - private uint _fbo; - private uint _colorTex; - private uint _depthRbo; - private int _fbW; - private int _fbH; - - public PanelFbo(GL gl) - { - _gl = gl ?? throw new ArgumentNullException(nameof(gl)); - } - - /// - /// Render (a full draw pass) into a - /// private FBO at × pixels. - /// Returns the GL color texture handle (0 on failure). The texture is valid until - /// the next call to with a different size, or until . - /// - public uint Render(int width, int height, UiHost host) - { - if (width <= 0 || height <= 0 || host is null) return 0u; - - EnsureFramebuffer(width, height); - if (_fbo == 0) return 0u; - - // Seal the entire pass: GLStateScope saves + restores every GL state the - // UI draw touches (viewport, blend, FBO binding, etc.) so ImGui's own state - // — set up by BeginFrame and expected intact by Render — is untouched. - using var scope = new GLStateScope(_gl); - - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo); - _gl.Viewport(0, 0, (uint)width, (uint)height); - _gl.Disable(EnableCap.ScissorTest); - _gl.ClearColor(0.18f, 0.18f, 0.18f, 1f); // opaque dark-grey canvas background (the FBO IS the canvas) - _gl.ClearDepth(1.0); - _gl.DepthMask(true); - _gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); - - host.Draw(new Vector2(width, height)); - - // FBO stays bound here; GLStateScope.Dispose() restores the previous binding. - return _colorTex; - } - - /// - /// Read the FBO color attachment back to CPU as a flat RGBA8 byte array. - /// Must be called AFTER for the same and - /// (so the FBO exists and is the right size). - /// - /// FBO origin is bottom-left (GL convention). The caller is responsible for - /// flipping rows vertically before saving as a top-left-origin image format (PNG). - /// - /// Returns an empty array when the FBO is not ready. - /// - public unsafe byte[] ReadColorRgba(int width, int height) - { - if (_fbo == 0 || width <= 0 || height <= 0) return Array.Empty(); - - int byteCount = width * height * 4; - var buf = new byte[byteCount]; - - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo); - fixed (byte* p = buf) - { - _gl.ReadPixels(0, 0, (uint)width, (uint)height, - PixelFormat.Rgba, PixelType.UnsignedByte, p); - } - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); - - return buf; - } - - // ── FBO lifecycle (mirrors PaperdollViewportRenderer.EnsureFramebuffer) ────── - - private void EnsureFramebuffer(int width, int height) - { - if (_fbo != 0 && width == _fbW && height == _fbH) return; - DeleteFramebuffer(); - - _fbW = width; - _fbH = height; - - _colorTex = _gl.GenTexture(); - _gl.BindTexture(TextureTarget.Texture2D, _colorTex); - _gl.TexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba8, - (uint)width, (uint)height, 0, - PixelFormat.Rgba, PixelType.UnsignedByte, (void*)0); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter, - (int)TextureMinFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter, - (int)TextureMinFilter.Linear); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, - (int)TextureWrapMode.ClampToEdge); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, - (int)TextureWrapMode.ClampToEdge); - _gl.BindTexture(TextureTarget.Texture2D, 0); - - _depthRbo = _gl.GenRenderbuffer(); - _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo); - _gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer, - InternalFormat.Depth24Stencil8, (uint)width, (uint)height); - _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0); - - _fbo = _gl.GenFramebuffer(); - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo); - _gl.FramebufferTexture2D(FramebufferTarget.Framebuffer, - FramebufferAttachment.ColorAttachment0, - TextureTarget.Texture2D, _colorTex, 0); - _gl.FramebufferRenderbuffer(FramebufferTarget.Framebuffer, - FramebufferAttachment.DepthStencilAttachment, - RenderbufferTarget.Renderbuffer, _depthRbo); - - var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer); - _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); - if (status != GLEnum.FramebufferComplete) - { - Console.WriteLine($"[studio] PanelFbo incomplete: {status} ({width}x{height})"); - DeleteFramebuffer(); - } - } - - private void DeleteFramebuffer() - { - if (_fbo != 0) { _gl.DeleteFramebuffer(_fbo); _fbo = 0; } - if (_colorTex != 0) { _gl.DeleteTexture(_colorTex); _colorTex = 0; } - if (_depthRbo != 0) { _gl.DeleteRenderbuffer(_depthRbo); _depthRbo = 0; } - _fbW = _fbH = 0; - } - - public void Dispose() => DeleteFramebuffer(); -} diff --git a/src/AcDream.App/Studio/SampleData.cs b/src/AcDream.App/Studio/SampleData.cs deleted file mode 100644 index da094467..00000000 --- a/src/AcDream.App/Studio/SampleData.cs +++ /dev/null @@ -1,227 +0,0 @@ -using AcDream.App.UI.Layout; -using AcDream.Core.Items; - -namespace AcDream.App.Studio; - -/// -/// Static sample data for the UI Studio fixture provider. -/// Provides a pre-built populated with a -/// representative player, their main-pack items, side bags, and equipped gear, -/// so the 2-D inventory / paperdoll panels render populated when previewed in -/// the studio without a live game session. -/// -/// Icon ids used (all real 0x06xxxxxx RenderSurface ids confirmed in the dats): -/// Inventory empty-slot sprite : 0x06004D20 -/// Generic "misc" item : 0x060011D4 (icon_misc_underlay / fallback) -/// Iron Sword (melee weapon) : 0x060011CBu (acclient default weapon underlay) -/// Leather Breastplate (armor) : 0x060011CFu (acclient default armor underlay) -/// Leather Gloves : 0x060011F3u (acclient default clothing underlay) -/// Steel Ring : 0x060011D5u (acclient default jewelry underlay) -/// Healing Kit : 0x060011D4u (generic misc fallback) -/// Spell components : 0x060011D4u (generic misc fallback) -/// Side-bag 1 (Container) : 0x06004D20u -/// Side-bag 2 (Container) : 0x06004D20u -/// Equipped helm (HeadWear) : 0x060011F3u -/// Equipped chest armor : 0x060011CFu -/// Equipped melee weapon : 0x060011CBu -/// -/// These are the icon *base* RenderSurface ids — the same ids GameWindow passes -/// as `iconId` into the iconIds lambda. FixtureProvider resolves them via -/// and returns the raw GL handle. -/// -public static class SampleData -{ - // ── Guids ──────────────────────────────────────────────────────────────── - - /// Fake server guid for the studio's synthetic player. - public const uint PlayerGuid = 0x50000001u; - - // Items in main pack (slots 0–5). - private const uint SwordGuid = 0x50000010u; - private const uint ChestGuid = 0x50000011u; - private const uint GlovesGuid = 0x50000012u; - private const uint RingGuid = 0x50000013u; - private const uint HealKitGuid = 0x50000014u; - private const uint CompGuid = 0x50000015u; - - // Side bags (also in main pack, ContainerId = PlayerGuid; slots 6 & 7). - private const uint Bag1Guid = 0x50000020u; - private const uint Bag2Guid = 0x50000021u; - - // Equipped items (ContainerId = PlayerGuid, CurrentlyEquippedLocation set). - private const uint HelmGuid = 0x50000030u; - private const uint ChestEqGuid = 0x50000031u; - private const uint WeaponEqGuid = 0x50000032u; - - // ── Icon ids (0x06xxxxxx RenderSurface dat ids) ─────────────────────────── - - // These are the same underlay/fallback icon ids the IconComposer tests pin. - private const uint IconWeapon = 0x060011CBu; // weapon underlay - private const uint IconArmor = 0x060011CFu; // armor underlay - private const uint IconClothing = 0x060011F3u; // clothing underlay - private const uint IconJewelry = 0x060011D5u; // jewelry underlay - private const uint IconMisc = 0x060011D4u; // misc / fallback underlay - - // ── Public API ────────────────────────────────────────────────────────── - - /// - /// Build a fresh populated with the - /// studio's sample player + a realistic inventory snapshot. - /// The table is owned by the caller and should be kept alive for the - /// window's lifetime. - /// - public static ClientObjectTable BuildObjectTable() - { - var t = new ClientObjectTable(); - - // ── Player object ───────────────────────────────────────────────── - t.AddOrUpdate(new ClientObject - { - ObjectId = PlayerGuid, - Name = "Studio Player", - Type = ItemType.Creature, - ItemsCapacity = 102, - ContainersCapacity = 7, - }); - - // ── Loose items in main pack (slots 0–5) ────────────────────────── - - AddItem(t, SwordGuid, ItemType.MeleeWeapon, IconWeapon, "Iron Sword", PlayerGuid, 0, burden: 60); - AddItem(t, ChestGuid, ItemType.Armor, IconArmor, "Leather Breastplate", PlayerGuid, 1, burden: 200); - AddItem(t, GlovesGuid, ItemType.Clothing, IconClothing, "Leather Gloves", PlayerGuid, 2, burden: 50); - AddItem(t, RingGuid, ItemType.Jewelry, IconJewelry, "Steel Ring", PlayerGuid, 3, burden: 10); - AddItem(t, HealKitGuid, ItemType.Misc, IconMisc, "Healing Kit", PlayerGuid, 4, burden: 30); - AddItem(t, CompGuid, ItemType.SpellComponents, IconMisc, "Spell Comps", PlayerGuid, 5, burden: 25, stackSize: 50, stackMax: 100); - - // ── Side bags (Container items in main pack, slots 6 & 7) ───────── - - AddItem(t, Bag1Guid, ItemType.Container, IconMisc, "Small Pack 1", - containerId: PlayerGuid, slot: 6, burden: 20, itemsCapacity: 24); - AddItem(t, Bag2Guid, ItemType.Container, IconMisc, "Small Pack 2", - containerId: PlayerGuid, slot: 7, burden: 20, itemsCapacity: 24); - - // ── Equipped items (ContainerId = PlayerGuid, CurrentlyEquippedLocation set) ── - - AddEquipped(t, HelmGuid, ItemType.Armor, IconClothing, "Tin Helm", EquipMask.HeadWear); - AddEquipped(t, ChestEqGuid, ItemType.Armor, IconArmor, "Chain Coat", EquipMask.ChestArmor); - AddEquipped(t, WeaponEqGuid, ItemType.MeleeWeapon, IconWeapon, "Wooden Sword", EquipMask.MeleeWeapon); - - return t; - } - - // ── Sample vital constants (used by FixtureProvider) ──────────────────── - - public const float HealthPct = 0.8f; - public const float StaminaPct = 0.6f; - public const float ManaPct = 0.9f; - - // ── Sample character sheet (used by CharacterController in the Studio) ─── - - /// - /// Returns a representative for the studio's - /// synthetic character. Values are plausible retail-scale numbers so the - /// report renders with well-proportioned text in all sections. - /// - public static CharacterSheet SampleCharacter() => new() - { - Name = "Studio Player", - Level = 126, - Race = "Aluvian", - Heritage = "Aluvian Heritage", - Title = "the Adventurer", - BirthDate = "January 5, 2001", - PlayTime = "2 years, 114 days, 4 hours", - Deaths = 42, - - PkStatus = "Non-Player Killer", - TotalXp = 1_250_000_000, - XpToNextLevel = 42_000_000, - XpFraction = 0.63f, - - // Vitals: retail screenshot spec (Pass 1 acceptance criteria §Goal). - HealthCurrent = 5, HealthMax = 5, - StaminaCurrent = 10, StaminaMax = 10, - ManaCurrent = 10, ManaMax = 10, - - // Attributes: Strength + Quickness = 200; all others = 10 (retail screenshot spec §Goal). - Strength = 200, - Endurance = 10, - Quickness = 200, - Coordination = 10, - Focus = 10, - Self = 10, - - UnspentSkillCredits = 12, - SpecializedSkillCredits = 4, - - // Available skill credits (retail InqInt(0x18)); shown in Attributes tab footer State-A. - SkillCredits = 96, - - // Unassigned (banked) XP (retail InqInt64(2)); footer State-A line-2 value. - UnassignedXp = 87_757_321_741L, - - // Raise costs in retail display order (Strength, Endurance, Coordination, Quickness, - // Focus, Self, Health, Stamina, Mana). - // Str@200 = maxed → 0 (disabled). Quickness@200 = maxed → 0. Others @10 → affordable. - // Focus@10 → 110 matches the authoritative retail screenshot (spec §4). - // Formula bracket at value=10: ExperienceToAttributeLevel(11) − ExperienceToAttributeLevel(10). - AttributeRaiseCosts = new long[] { 0L, 95L, 100L, 0L, 110L, 105L, 90L, 88L, 112L }, - - AugmentationName = "Swords", - - BurdenCurrent = 1200, - BurdenMax = 4500, - }; - - // ── Helpers ───────────────────────────────────────────────────────────── - - private static void AddItem( - ClientObjectTable t, - uint guid, - ItemType type, - uint iconId, - string name, - uint containerId, - int slot, - int burden = 0, - int stackSize = 1, - int stackMax = 1, - int itemsCapacity = 0) - { - t.AddOrUpdate(new ClientObject - { - ObjectId = guid, - Name = name, - Type = type, - IconId = iconId, - Burden = burden, - StackSize = stackSize, - StackSizeMax = stackMax, - ItemsCapacity = itemsCapacity, - }); - t.MoveItem(guid, containerId, slot); - } - - private static void AddEquipped( - ClientObjectTable t, - uint guid, - ItemType type, - uint iconId, - string name, - EquipMask equipMask) - { - t.AddOrUpdate(new ClientObject - { - ObjectId = guid, - Name = name, - Type = type, - IconId = iconId, - ValidLocations = equipMask, - CurrentlyEquippedLocation = equipMask, - ContainerId = PlayerGuid, - }); - // Update the equip location on the object via MoveItem (sets ContainerId + - // CurrentlyEquippedLocation via the equip overload). - t.MoveItem(guid, PlayerGuid, newSlot: -1, newEquipLocation: equipMask); - } -} diff --git a/src/AcDream.App/Studio/StudioInspector.cs b/src/AcDream.App/Studio/StudioInspector.cs deleted file mode 100644 index 26e6dc0d..00000000 --- a/src/AcDream.App/Studio/StudioInspector.cs +++ /dev/null @@ -1,295 +0,0 @@ -using System.Numerics; -using AcDream.App.UI; -using ImGuiNET; - -namespace AcDream.App.Studio; - -/// -/// All canvas mouse events gathered by in one frame. -/// All coordinates are already mapped to panel-local pixels (origin top-left, same as UiRoot). -/// -public readonly struct CanvasInputEvent -{ - /// Mouse is currently hovering the canvas image. When false all other fields are 0 / false. - public readonly bool IsHovered; - /// Panel-local pixel coordinate of the mouse this frame (valid when ). - public readonly int MouseX; - /// Panel-local pixel coordinate of the mouse this frame (valid when ). - public readonly int MouseY; - /// Left-button went down this frame. - public readonly bool LeftDown; - /// Left-button came up this frame. - public readonly bool LeftUp; - /// Mouse-wheel scroll delta (lines, positive = up). Zero when no scroll. - public readonly int ScrollDelta; - - public CanvasInputEvent(bool hovered, int mx, int my, bool ld, bool lu, int scroll) - { - IsHovered = hovered; - MouseX = mx; - MouseY = my; - LeftDown = ld; - LeftUp = lu; - ScrollDelta = scroll; - } -} - -/// -/// Four-pane ImGui IDE for the acdream UI Studio: -/// -/// Toolbar — panel picker (slug combo) across the top. -/// Canvas — shows the panel FBO texture; in Interact mode mouse events -/// are forwarded to the panel UiHost (buttons/tabs respond); in Inspect mode a -/// left-click hit-tests and selects the element under the cursor. -/// Tree — recursive ImGui tree of the element hierarchy; clicking a node -/// sets . -/// Properties — shows the element's geometry, -/// anchors, and z-order. -/// -/// -/// Coordinate mapping for the canvas: -/// The FBO is rendered at the full window size and displayed 1:1 inside the Canvas ImGui -/// sub-window. After ImGui.Image we call ImGui.GetItemRectMin() to get the -/// screen-space top-left of the drawn image (accounting for the sub-window's title bar, -/// padding, and any scrolling). Subtracting that from the raw mouse screen position gives -/// panel-local pixels directly — no additional scale factor is needed because the image is -/// drawn 1:1. -/// -/// V-flip — no extra Y inversion needed: -/// The FBO origin is bottom-left (GL convention), so we pass uv0=(0,1), uv1=(1,0) to -/// ImGui.Image to flip V. After this flip, displayed row 0 (top of the image on -/// screen) corresponds to panel Y=0 (the top of the UI panel), matching UiRoot's -/// top-left origin. Therefore the panel-local Y computed above maps directly into UiRoot -/// without further inversion — do NOT flip Y again. -/// -/// Layout: the four panes call SetNextWindowPos + SetNextWindowSize -/// with ImGuiCond.FirstUseEver so they start docked but can be freely dragged. -/// -public sealed class StudioInspector -{ - /// Currently selected element (set by tree-click or canvas-click in Inspect mode). - public UiElement? Selected { get; set; } - - /// - /// When true (default) canvas mouse events are forwarded to the panel UiHost so elements - /// respond to clicks. When false a canvas click hit-tests and selects an element in the - /// inspector tree instead. Toggle via the "Interact / Inspect" checkbox in the toolbar. - /// - public bool InteractMode { get; set; } = true; - - // ── Toolbar ─────────────────────────────────────────────────────────────────── - - /// - /// Draw the "Studio" toolbar window (top strip) containing a slug combo-box and - /// the Interact / Inspect mode toggle. Returns the newly-selected slug when the - /// user picks a different panel, or null when unchanged. - /// The mode toggle sets : checked = Interact (panel - /// elements respond to clicks), unchecked = Inspect (clicks select elements in the - /// tree). - /// - /// All available panel slugs (from UiDumpModel.ListSlugs). - /// The slug of the panel currently loaded. - /// Studio window width (pixels). - public string? DrawToolbar(IReadOnlyList slugs, string? current, int windowW) - { - ImGui.SetNextWindowPos(new Vector2(0f, 0f), ImGuiCond.FirstUseEver); - ImGui.SetNextWindowSize(new Vector2(windowW, 40f), ImGuiCond.FirstUseEver); - ImGui.Begin("Studio", - ImGuiWindowFlags.NoScrollbar | ImGuiWindowFlags.NoScrollWithMouse); - - ImGui.SetNextItemWidth(300f); - string? result = null; - string preview = current ?? "(none)"; - if (ImGui.BeginCombo("Panel", preview)) - { - foreach (var slug in slugs) - { - bool selected = string.Equals(slug, current, StringComparison.OrdinalIgnoreCase); - if (ImGui.Selectable(slug, selected) && !selected) - result = slug; - if (selected) - ImGui.SetItemDefaultFocus(); - } - ImGui.EndCombo(); - } - - ImGui.SameLine(); - bool interact = InteractMode; - if (ImGui.Checkbox("Interact", ref interact)) - InteractMode = interact; - if (ImGui.IsItemHovered()) - ImGui.SetTooltip("Interact: canvas clicks reach the panel (buttons/tabs respond).\nUncheck to Inspect: clicks select elements in the tree."); - - ImGui.End(); - return result; - } - - // ── Canvas ──────────────────────────────────────────────────────────────────── - - /// - /// Draw the "Canvas" ImGui window containing the panel FBO texture and return all - /// canvas mouse events for this frame as a . - /// - /// Coordinate mapping: After ImGui.Image, GetItemRectMin() - /// returns the actual screen-space top-left of the drawn image (accounting for the - /// sub-window title bar, padding, and scrolling). Subtracting that from the raw ImGui - /// mouse position gives panel-local pixels directly — no scale factor because the - /// image is drawn 1:1. - /// - /// V-flip — no extra Y inversion: we pass uv0=(0,1) / uv1=(1,0) so the - /// GL bottom-left origin is flipped to top-left on screen. After the flip, screen - /// row 0 = panel Y 0 (top of the UI), so the computed Y already matches UiRoot's - /// top-left origin — do NOT flip Y again. - /// - /// If is non-null a bright-green 2-pixel outline is - /// drawn over it using the window draw list. - /// - public CanvasInputEvent DrawCanvas(nint panelTex, int width, int height, - int windowX, int windowW, int windowY, int windowH) - { - ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver); - ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver); - ImGui.Begin("Canvas"); - - var imageSize = new Vector2(width, height); - // V-flip: FBO origin is bottom-left; ImGui images expect top-left. - // uv0 = bottom-left of texture = top of the panel in screen space. - // uv1 = top-right of texture = bottom of the panel in screen space. - var uv0 = new Vector2(0f, 1f); - var uv1 = new Vector2(1f, 0f); - ImGui.Image(panelTex, imageSize, uv0, uv1); - - // rectMin: screen-space top-left of the image AFTER ImGui.Image + any chrome offset. - // This is what lets us translate raw mouse screen coords into panel-local pixels. - var rectMin = ImGui.GetItemRectMin(); - - // ── Selection highlight ─────────────────────────────────────────────── - var el = Selected; - if (el is not null && el.Width > 0f && el.Height > 0f) - { - var sp = el.ScreenPosition; - var p0 = new Vector2(rectMin.X + sp.X, rectMin.Y + sp.Y); - var p1 = new Vector2(p0.X + el.Width, p0.Y + el.Height); - var dl = ImGui.GetWindowDrawList(); - dl.AddRect(p0, p1, - ImGui.GetColorU32(new Vector4(0.2f, 1f, 0.4f, 1f)), - 0f, ImDrawFlags.None, 2f); - } - - // ── Gather canvas mouse events ──────────────────────────────────────── - // IsItemHovered is true when the mouse is over the Image item (not just the window). - bool hovered = ImGui.IsItemHovered(); - int mx = 0, my = 0; - bool leftDown = false, leftUp = false; - int scroll = 0; - - if (hovered) - { - var mousePos = ImGui.GetMousePos(); - // Panel-local pixel = mouse offset from the image's screen-space top-left. - // Scale is 1:1 (image drawn at full FBO size). Y needs no extra flip — see summary. - int ix = (int)(mousePos.X - rectMin.X); - int iy = (int)(mousePos.Y - rectMin.Y); - // Clamp to image bounds (mouse can be on the image edge pixel). - if (ix >= 0 && ix < width && iy >= 0 && iy < height) - { - mx = ix; - my = iy; - leftDown = ImGui.IsMouseClicked(ImGuiMouseButton.Left); - leftUp = ImGui.IsMouseReleased(ImGuiMouseButton.Left); - float wheelY = ImGui.GetIO().MouseWheel; - scroll = (int)wheelY; // positive = scroll up - } - else - { - // Mouse is over ImGui chrome (title bar, padding) adjacent to image — not over the panel. - hovered = false; - } - } - - ImGui.End(); - return new CanvasInputEvent(hovered, mx, my, leftDown, leftUp, scroll); - } - - // ── Tree ────────────────────────────────────────────────────────────────────── - - /// Draw the "Tree" ImGui window. Clicking a node sets . - public void DrawTree(UiElement root, int windowX, int windowY, int windowW, int windowH) - { - ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver); - ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver); - ImGui.Begin("Tree"); - DrawTreeNode(root); - ImGui.End(); - } - - private void DrawTreeNode(UiElement el) - { - // Label: EventId (hex) + C# type name, e.g. "0x10000001 [UiDatElement]" - string label = $"0x{el.EventId:X8} [{el.GetType().Name}]"; - - bool isSelected = ReferenceEquals(el, Selected); - bool hasChildren = el.Children.Count > 0; - - ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags.OpenOnArrow - | ImGuiTreeNodeFlags.SpanAvailWidth; - if (!hasChildren) - flags |= ImGuiTreeNodeFlags.Leaf; - if (isSelected) - flags |= ImGuiTreeNodeFlags.Selected; - - bool open = ImGui.TreeNodeEx(label, flags); - - // Click on the node label (not the arrow) selects it. - if (ImGui.IsItemClicked(ImGuiMouseButton.Left)) - Selected = el; - - if (open) - { - foreach (var child in el.Children) - DrawTreeNode(child); - ImGui.TreePop(); - } - } - - // ── Properties ─────────────────────────────────────────────────────────────── - - /// Draw the "Properties" ImGui window for . - public void DrawProperties(int windowX, int windowY, int windowW, int windowH) - { - ImGui.SetNextWindowPos(new Vector2(windowX, windowY), ImGuiCond.FirstUseEver); - ImGui.SetNextWindowSize(new Vector2(windowW, windowH), ImGuiCond.FirstUseEver); - ImGui.Begin("Properties"); - - var el = Selected; - if (el is null) - { - ImGui.TextUnformatted("(nothing selected)"); - ImGui.End(); - return; - } - - ImGui.TextUnformatted($"Id (EventId): 0x{el.EventId:X8}"); - ImGui.TextUnformatted($"Type: {el.GetType().Name}"); - ImGui.TextUnformatted($"Name: {el.Name ?? "(null)"}"); - ImGui.Separator(); - ImGui.TextUnformatted($"Rect: ({el.Left}, {el.Top}, {el.Width} x {el.Height})"); - ImGui.TextUnformatted($"Anchors: {el.Anchors}"); - ImGui.TextUnformatted($"ZOrder: {el.ZOrder}"); - ImGui.Separator(); - ImGui.TextUnformatted($"Visible: {el.Visible}"); - ImGui.TextUnformatted($"Enabled: {el.Enabled}"); - ImGui.TextUnformatted($"ClickThrough: {el.ClickThrough}"); - ImGui.TextUnformatted($"Draggable: {el.Draggable}"); - ImGui.TextUnformatted($"Resizable: {el.Resizable}"); - ImGui.TextUnformatted($"IsDragSource: {el.IsDragSource}"); - ImGui.TextUnformatted($"HandlesClick: {el.HandlesClick}"); - ImGui.TextUnformatted($"Opacity: {el.Opacity:F2}"); - ImGui.Separator(); - var sp = el.ScreenPosition; - ImGui.TextUnformatted($"ScreenPos: ({sp.X:F1}, {sp.Y:F1})"); - ImGui.TextUnformatted($"Children: {el.Children.Count}"); - - ImGui.End(); - } -} diff --git a/src/AcDream.App/Studio/StudioOptions.cs b/src/AcDream.App/Studio/StudioOptions.cs deleted file mode 100644 index 2a715b01..00000000 --- a/src/AcDream.App/Studio/StudioOptions.cs +++ /dev/null @@ -1,116 +0,0 @@ -namespace AcDream.App.Studio; - -/// -/// Parsed options for the acdream UI Studio standalone tool. -/// Constructed by from the command-line tokens that follow -/// the ui-studio dispatch token. -/// -public sealed record StudioOptions( - string DatDir, - uint? LayoutId, - string? MarkupPath, - string? DumpSlug = null, - string? DumpFile = null, - string? ScreenshotPath = null) -{ - /// - /// Parse studio options from the args that come AFTER the ui-studio token. - /// - /// Positional (first non-flag arg): dat directory. Falls back to - /// ACDREAM_DAT_DIR when omitted. - /// --layout 0xNNNN: hex LayoutDesc dat id to preview. - /// --markup <path>: path to a KSML markup file (Task 6, unsupported for now). - /// --dump <slug>: load a panel from the retail UI dump JSON by slug - /// (e.g. inventory, radar, toolbar). Static mockup — no controllers. - /// --dump-file <path>: override the default dump file path - /// (docs/research/2026-06-25-retail-ui-layout-dump.json from the solution root). - /// Only meaningful when --dump is also given. - /// --screenshot <path>: headless mode — render the loaded panel to a PNG - /// at and exit without showing an interactive window. - /// Combines with --dump or --layout. - /// When neither --layout, --markup, nor --dump is given the - /// default layout 0x2100006C (vitals) is used. - /// - public static StudioOptions Parse(string[] args) - { - string? datDir = null; - uint? layoutId = null; - string? markupPath = null; - string? dumpSlug = null; - string? dumpFile = null; - string? screenshotPath = null; - - for (int i = 0; i < args.Length; i++) - { - if (args[i] == "--layout" && i + 1 < args.Length) - { - var raw = args[++i]; - // Accept 0xNNNN or plain hex. - if (raw.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) - raw = raw[2..]; - if (uint.TryParse(raw, System.Globalization.NumberStyles.HexNumber, null, out var id)) - layoutId = id; - } - else if (args[i] == "--markup" && i + 1 < args.Length) - { - markupPath = args[++i]; - } - else if (args[i] == "--dump" && i + 1 < args.Length) - { - dumpSlug = args[++i]; - } - else if (args[i] == "--dump-file" && i + 1 < args.Length) - { - dumpFile = args[++i]; - } - else if (args[i] == "--screenshot" && i + 1 < args.Length) - { - screenshotPath = args[++i]; - } - else if (!args[i].StartsWith('-')) - { - datDir ??= args[i]; - } - } - - // Fall back to ACDREAM_DAT_DIR when no positional dat dir was given. - datDir ??= Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - - if (string.IsNullOrWhiteSpace(datDir)) - throw new InvalidOperationException( - "ui-studio: dat directory required — pass as first arg or set ACDREAM_DAT_DIR."); - - // Default layout: vitals (0x2100006C), unless a dump slug or markup is requested. - if (layoutId is null && markupPath is null && dumpSlug is null) - layoutId = 0x2100006Cu; - - return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile, screenshotPath); - } - - /// - /// Resolve the dump file path for this session: - /// - /// if explicitly set. - /// Otherwise <solutionRoot>/docs/research/2026-06-25-retail-ui-layout-dump.json. - /// - /// Returns null when neither the override nor the default file exists. - /// - public string? ResolveDumpFile() - { - if (!string.IsNullOrEmpty(DumpFile)) - return DumpFile; - - // Walk up from the App binary to the solution root (same approach as - // ConformanceDats.SolutionRoot in the test project). - var dir = AppContext.BaseDirectory; - while (!string.IsNullOrEmpty(dir)) - { - var candidate = Path.Combine(dir, "docs", "research", - "2026-06-25-retail-ui-layout-dump.json"); - if (File.Exists(candidate)) - return candidate; - dir = Path.GetDirectoryName(dir); - } - return null; - } -} diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs deleted file mode 100644 index 63191fe1..00000000 --- a/src/AcDream.App/Studio/StudioWindow.cs +++ /dev/null @@ -1,458 +0,0 @@ -using System.Numerics; -using AcDream.App.Rendering; -using AcDream.App.UI; -using DatReaderWriter; -using DatReaderWriter.Options; -using Silk.NET.Input; -using Silk.NET.Maths; -using Silk.NET.OpenGL; -using Silk.NET.Windowing; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; -using static Silk.NET.OpenGL.ClearBufferMask; - -namespace AcDream.App.Studio; - -/// -/// Standalone Silk.NET window that boots the production render stack -/// () and previews a single UI panel -/// identified by a . -/// -/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path] -/// -/// Task 3 adds an ImGui IDE on top of the panel FBO: -/// -/// Canvas pane — the panel rendered off-screen via . -/// Tree pane — the element hierarchy; click-to-select. -/// Properties pane — geometry/anchors/flags of the selected element. -/// Click-to-inspect — a left-click in the canvas selects the topmost -/// element under the cursor via . -/// -/// -/// The window is intentionally thin: no game world, no physics, no streaming — -/// just GL + UiHost + the layout under test, identical to how the panel -/// appears inside GameWindow. -/// -public sealed class StudioWindow : IDisposable -{ - private readonly StudioOptions _opts; - - // Created in OnLoad, released in OnClosing. - private IWindow? _window; - private DatCollection? _dats; - private RenderStack? _stack; - private LayoutSource? _source; - - // Task 3 additions. - private AcDream.UI.ImGui.ImGuiBootstrapper? _imgui; - private PanelFbo? _panelFbo; - private StudioInspector? _inspector; - private UiElement? _panelRoot; // top-level element added to UiRoot (for hit-test + tree) - - // UX-pass additions: panel picker + current-slug tracking. - private string? _currentSlug; // slug of the panel currently displayed (null = non-dump mode) - private string? _dumpFile; // resolved dump file path (once, in OnLoad) - private IReadOnlyList _dumpSlugs = Array.Empty(); // all slugs from the dump - - // Task 4: sample data table — built once in OnLoad and kept alive for the window's lifetime - // so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly. - private AcDream.Core.Items.ClientObjectTable? _objects; - - // Headless screenshot mode: set when --screenshot was passed. - // True after the first OnRender fires the screenshot (guard against repeat). - private bool _screenshotDone; - - public StudioWindow(StudioOptions opts) - { - _opts = opts ?? throw new ArgumentNullException(nameof(opts)); - } - - /// - /// Open the window and block until it is closed. - /// Mirrors GameWindow.Run(). - /// - public void Run() - { - // Resolve quality settings the same way GameWindow.Run() does - // (SettingsStore → QualitySettings.From → WithEnvOverrides). - var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( - AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - var startupDisplay = startupStore.LoadDisplay(); - var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality); - var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase); - - var options = WindowOptions.Default with - { - Size = new Vector2D(1280, 720), - Title = "acdream UI Studio", - API = new GraphicsAPI( - ContextAPI.OpenGL, - ContextProfile.Core, - ContextFlags.ForwardCompatible, - new APIVersion(4, 3)), - VSync = false, - // MSAA from quality preset — must be baked into the GL context at creation. - Samples = startupQuality.MsaaSamples, - PreferredStencilBufferBits = 8, - // Headless screenshot mode: hide the window so no desktop flash occurs. - // The GL context is still fully valid on a hidden window; FBO rendering - // is off-screen and independent of window visibility. - IsVisible = _opts.ScreenshotPath is null, - }; - - _window = Window.Create(options); - _window.Load += OnLoad; - _window.Update += OnUpdate; - _window.Render += OnRender; - _window.Closing += OnClosing; - _window.Run(); - } - - private void OnLoad() - { - var gl = GL.GetApi(_window!); - - _dats = new DatCollection(_opts.DatDir, DatAccessType.Read); - - // Build QualitySettings for RenderBootstrap (same as Run() above — re-read - // after the GL context is confirmed, mirroring GameWindow.OnLoad). - var store = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( - AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); - var display = store.LoadDisplay(); - var quality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides( - AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality)); - - _stack = RenderBootstrap.Create(gl, _dats, new RenderBootstrapOptions(quality)); - - // Load the panel described by options and add it to the UI tree. - // Task 4b: --dump uses DumpLayout (static retail mockup, no controllers). - // All other modes use LayoutSource + FixtureProvider (production path). - // - // Fix C: pass the per-element font resolver into LayoutSource so that elements - // with a non-zero FontDid get their own dat font at build time. This is wired - // ONLY in the studio path; GameWindow's Import calls continue to pass null so the - // live game path is provably unchanged (follow-up: GameWindow font-resolver wire-up). - _source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont, - fontResolve: _stack.ResolveDatFont); - - // Resolve the dump file once (used by OnLoad + by LoadDumpPanel at runtime). - _dumpFile = _opts.ResolveDumpFile(); - if (_dumpFile is not null) - _dumpSlugs = UiDumpModel.ListSlugs(_dumpFile) - .OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(); - - UiElement? root; - if (_opts.DumpSlug is not null) - { - if (_dumpFile is null) - { - Console.Error.WriteLine("[studio] --dump: retail UI dump file not found. " + - "Expected docs/research/2026-06-25-retail-ui-layout-dump.json in the source tree, " + - "or pass --dump-file ."); - root = null; - } - else - { - root = DumpLayout.Load(_dumpFile, _opts.DumpSlug, _stack.ResolveChrome, out var dumpErr); - if (root is null) - Console.Error.WriteLine($"[studio] dump load failed: {dumpErr}"); - else - _currentSlug = _opts.DumpSlug; - } - } - else - { - root = _source.Load(_opts); - if (root is null) - Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}"); - } - - _panelRoot = root; - if (root is not null) - { - _stack.UiHost.Root.AddChild(root); - - // Task 4: populate the panel with sample data via production controllers, - // so inventory / vitals / toolbar panels render with plausible content. - // Dump source is static — no FixtureProvider needed. - if (_opts.DumpSlug is null && _source.CurrentLayout is not null) - { - uint layoutId = _opts.LayoutId ?? 0x2100006Cu; - _objects = SampleData.BuildObjectTable(); - FixtureProvider.Populate(layoutId, _source.CurrentLayout, _stack, _objects, _dats); - } - } - - // Task 3: ImGui IDE — interactive mode only. - // Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped. - _panelFbo = new PanelFbo(gl); - if (_opts.ScreenshotPath is null) - { - var input = _window!.CreateInput(); - // Wire KEYBOARD input into UiHost (keyboard coords are not spatial, so no remapping needed). - // Do NOT wire mouse here — raw Silk window coords would be offset by the canvas sub-window's - // position (tree pane width + ImGui chrome) and land in the wrong panel-local location. - // Mouse is forwarded manually from DrawCanvas with correct panel-local mapping below. - foreach (var kb in input.Keyboards) - _stack.UiHost.WireKeyboard(kb); - _imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input); - _inspector = new StudioInspector(); - } - } - - private void OnUpdate(double dt) { } - - private void OnRender(double dt) - { - if (_stack is null || _panelFbo is null) return; - - // ── HEADLESS SCREENSHOT PATH ────────────────────────────────────────────── - if (_opts.ScreenshotPath is not null) - { - if (_screenshotDone) return; // fire exactly once - _screenshotDone = true; - - // Pick render size from the loaded root's bounds (clamped to sane limits). - // Fall back to 1280×720 when the root has no explicit size. - int w = 1280, h = 720; - if (_panelRoot is not null) - { - float rw = _panelRoot.Width; - float rh = _panelRoot.Height; - if (rw >= 1f && rh >= 1f) - { - w = Math.Clamp((int)rw, 256, 2048); - h = Math.Clamp((int)rh, 256, 2048); - } - } - - // Tick once so widget state is initialised (e.g. bar fills). - _stack.UiHost.Tick(dt); - - // Render the panel into the FBO. - _panelFbo.Render(w, h, _stack.UiHost); - - // Read back RGBA pixels (FBO origin = bottom-left). - byte[] pixels = _panelFbo.ReadColorRgba(w, h); - if (pixels.Length == 0) - { - Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels."); - _window?.Close(); - return; - } - - // Flip rows vertically: FBO bottom-left → PNG top-left. - int stride = w * 4; - byte[] flipped = new byte[pixels.Length]; - for (int row = 0; row < h; row++) - { - System.Buffer.BlockCopy(pixels, row * stride, flipped, (h - 1 - row) * stride, stride); - } - - // Build ImageSharp image and save as PNG. - var path = _opts.ScreenshotPath; - var dir = Path.GetDirectoryName(path); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - - using var img = Image.LoadPixelData(flipped, w, h); - img.SaveAsPng(path); - Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})"); - - _window?.Close(); - return; - } - - // ── INTERACTIVE PATH ────────────────────────────────────────────────────── - if (_imgui is null || _inspector is null) return; - - var gl = _stack.Gl; - int iw = _window!.Size.X; - int ih = _window!.Size.Y; - - // 1. Tick the UI widgets (OnRender's own dt — Update + Render fire with the same delta). - _stack.UiHost.Tick(dt); - - // 2. Render the panel into the off-screen FBO; get the color texture. - // The FBO is the same logical size as the window, so element rects map 1:1 to - // FBO pixels — no scale factor needed when displaying the canvas at full size. - uint panelTex = _panelFbo.Render(iw, ih, _stack.UiHost); - - // 3. Clear the window back-buffer (the dark ImGui background shows behind panes). - gl.ClearColor(0.1f, 0.1f, 0.1f, 1f); - gl.Clear(ColorBufferBit | DepthBufferBit); - - // 4. Begin the ImGui frame. - _imgui.BeginFrame((float)dt); - - // ── Layout constants (fixed pane arrangement, FirstUseEver) ────────────── - // MenuBar: always-on-top main menu bar (~22px) — panel picker lives here so it - // is never covered by the floating panes (replaces the old 40px toolbar). - // Tree: 280px wide on the left, below menu bar. - // Canvas: centre strip between tree and properties. - // Props: 340px wide on the right, below menu bar. - const int kMenuBarH = 22; // ImGui default main menu bar height - const int kTreeW = 280; - const int kPropsW = 340; - int canvasX = kTreeW; - int canvasW = Math.Max(1, iw - kTreeW - kPropsW); - int propsX = iw - kPropsW; - int paneY = kMenuBarH; - int paneH = Math.Max(1, ih - kMenuBarH); - - // 5. Main menu bar — panel picker combo pinned to the window top. - // BeginMainMenuBar returns true when the bar is visible (always is); the combo - // inside it is always-on-top and is never occluded by Tree/Canvas/Props panes. - string? pickedSlug = null; - if (ImGuiNET.ImGui.BeginMainMenuBar()) - { - ImGuiNET.ImGui.SetNextItemWidth(300f); - string preview = _currentSlug ?? "(none)"; - if (ImGuiNET.ImGui.BeginCombo("Panel", preview)) - { - foreach (var slug in _dumpSlugs) - { - bool selected = string.Equals(slug, _currentSlug, - System.StringComparison.OrdinalIgnoreCase); - if (ImGuiNET.ImGui.Selectable(slug, selected) && !selected) - pickedSlug = slug; - if (selected) - ImGuiNET.ImGui.SetItemDefaultFocus(); - } - ImGuiNET.ImGui.EndCombo(); - } - ImGuiNET.ImGui.EndMainMenuBar(); - } - if (pickedSlug is not null) - LoadDumpPanel(pickedSlug); - - // 6. Canvas pane — show the FBO texture; gather canvas mouse events. - var canvasEvt = default(CanvasInputEvent); - if (panelTex != 0) - canvasEvt = _inspector.DrawCanvas( - (nint)panelTex, iw, ih, - canvasX, canvasW, paneY, paneH); - - // 7. Forward canvas mouse events to the panel UiHost or the inspector tree. - // - // Coordinate mapping (see StudioInspector.DrawCanvas summary): - // panel-local pixel = raw_mouse - ImGui.GetItemRectMin() (1:1 scale, no Y flip) - // The image is drawn V-flipped (uv0.Y=1, uv1.Y=0) so screen top = panel Y=0. - // - // Interact mode (default): canvas mouse events go directly to UiRoot so elements respond. - // OnMouseMove + OnMouseDown/Up + OnScroll are all forwarded. - // A Console.WriteLine confirms each forwarded left-click for live verification. - // - // Inspect mode: left-click hit-tests and selects the element in the tree (old behavior). - // OnMouseMove is still forwarded so hover/tooltip state in the panel stays live. - if (canvasEvt.IsHovered) - { - int mx = canvasEvt.MouseX; - int my = canvasEvt.MouseY; - var root = _stack.UiHost.Root; - - // Always forward mouse-move so hover highlights / tooltips in the panel work. - root.OnMouseMove(mx, my); - - if (_inspector.InteractMode) - { - // ── Interact: live panel interaction ────────────────────────────── - if (canvasEvt.LeftDown) - { - Console.WriteLine($"[studio] canvas click → panel ({mx}, {my})"); - root.OnMouseDown(UiMouseButton.Left, mx, my); - } - if (canvasEvt.LeftUp) - root.OnMouseUp(UiMouseButton.Left, mx, my); - if (canvasEvt.ScrollDelta != 0) - root.OnScroll(canvasEvt.ScrollDelta); - } - else - { - // ── Inspect: click selects an element in the tree ───────────────── - if (canvasEvt.LeftDown) - { - var hit = root.Pick(mx, my); - if (hit is not null) - _inspector.Selected = hit; - } - } - } - - // 8. Element tree pane. - if (_panelRoot is not null) - _inspector.DrawTree(_panelRoot, 0, paneY, kTreeW, paneH); - - // 9. Properties pane. - _inspector.DrawProperties(propsX, paneY, kPropsW, paneH); - - // 9. Finalise ImGui and flush draw data to the window. - _imgui.Render(); - } - - /// - /// Load a different dump panel at runtime (no relaunch required). - /// Removes the current from the UI tree, - /// loads the named slug from the dump, and installs the new root. - /// Resets to null. - /// No-op when the dump file is not available or the slug fails to load. - /// - public void LoadDumpPanel(string slug) - { - if (_stack is null || _inspector is null) return; - if (_dumpFile is null) - { - Console.Error.WriteLine("[studio] LoadDumpPanel: dump file not available."); - return; - } - - // Remove the existing panel root from the tree. - if (_panelRoot is not null) - { - _stack.UiHost.Root.RemoveChild(_panelRoot); - _panelRoot = null; - } - - // Load the new panel. - var newRoot = DumpLayout.Load(_dumpFile, slug, _stack.ResolveChrome, out var err); - if (newRoot is null) - { - Console.Error.WriteLine($"[studio] LoadDumpPanel('{slug}') failed: {err}"); - _currentSlug = null; - return; - } - - _stack.UiHost.Root.AddChild(newRoot); - _panelRoot = newRoot; - _currentSlug = slug; - _inspector.Selected = null; - } - - private void OnClosing() - { - _imgui?.Dispose(); - _panelFbo?.Dispose(); - _imgui = null; - _panelFbo = null; - _stack?.Dispose(); // whole render stack: dispatcher/mesh/textures/shader/ubo/uihost - _dats?.Dispose(); - _dats = null; - _stack = null; - } - - public void Dispose() - { - _imgui?.Dispose(); - _panelFbo?.Dispose(); - _imgui = null; - _panelFbo = null; - _window?.Dispose(); - _window = null; - // If OnClosing wasn't called (e.g. an exception before Run() completed), dispose the FULL - // stack anyway — the review flagged that disposing only UiHost here leaked the rest. - _stack?.Dispose(); - _dats?.Dispose(); - _dats = null; - _stack = null; - } -} diff --git a/src/AcDream.App/Studio/UiDumpModel.cs b/src/AcDream.App/Studio/UiDumpModel.cs deleted file mode 100644 index c33e4b9e..00000000 --- a/src/AcDream.App/Studio/UiDumpModel.cs +++ /dev/null @@ -1,198 +0,0 @@ -using System.Text.Json; -using System.Text.Json.Serialization; - -namespace AcDream.App.Studio; - -// ───────────────────────────────────────────────────────────────────────────── -// UiDumpModel — POCOs for docs/research/2026-06-25-retail-ui-layout-dump.json -// -// Schema (v1): -// { "version":1, "panels":[ { "id":int, "slug":string, "title":string, -// "bucket":string, "parent_slug":string|null, -// "width":int, "height":int, -// "nodes":[ { "traversal_index":int, "element_id":int, -// "layout_id":int, "parent_layout_id":int|null, -// "parent_traversal_index":int|null, "base_layout_id":int, -// "rect":{x,y,width,height}, -// "widget_kind":"Group"|"Sprite"|"Button"|"Scrollbar"|"Slider", -// "state_set":{ "default_image":{image_id,alpha_image_id}|null, -// "states":[{state_id,image:{...}}] } -// } ] } ] } -// -// All ids in the dump are DECIMAL ints (e.g. element_id=268435925 = 0x100001D5, -// image_id=100693194 = 0x060074CA). Cast to uint before use in dat/GL APIs. -// -// Rect coordinates are ABSOLUTE (screen-space origin = panel's design position -// in retail layout, NOT relative to the parent). DumpLayout.Load converts them -// to parent-relative when building the UiElement tree. -// ───────────────────────────────────────────────────────────────────────────── - -/// Top-level container for the retail UI layout dump. -public sealed class UiDump -{ - [JsonPropertyName("version")] - public int Version { get; set; } - - [JsonPropertyName("panels")] - public List Panels { get; set; } = new(); -} - -/// One panel (window) exported from the retail UI. -public sealed class DumpPanel -{ - [JsonPropertyName("id")] - public long Id { get; set; } - - [JsonPropertyName("slug")] - public string Slug { get; set; } = ""; - - [JsonPropertyName("title")] - public string Title { get; set; } = ""; - - [JsonPropertyName("bucket")] - public string Bucket { get; set; } = ""; - - [JsonPropertyName("parent_slug")] - public string? ParentSlug { get; set; } - - [JsonPropertyName("width")] - public float Width { get; set; } - - [JsonPropertyName("height")] - public float Height { get; set; } - - [JsonPropertyName("nodes")] - public List Nodes { get; set; } = new(); -} - -/// One element node within a panel's traversal list. -public sealed class DumpNode -{ - [JsonPropertyName("traversal_index")] - public int TraversalIndex { get; set; } - - [JsonPropertyName("element_id")] - public long ElementId { get; set; } - - [JsonPropertyName("layout_id")] - public long LayoutId { get; set; } - - [JsonPropertyName("parent_layout_id")] - public long? ParentLayoutId { get; set; } - - [JsonPropertyName("parent_traversal_index")] - public int? ParentTraversalIndex { get; set; } - - [JsonPropertyName("base_layout_id")] - public long BaseLayoutId { get; set; } - - [JsonPropertyName("rect")] - public DumpRect Rect { get; set; } = new(); - - [JsonPropertyName("widget_kind")] - public string WidgetKind { get; set; } = "Group"; - - [JsonPropertyName("state_set")] - public DumpStateSet StateSet { get; set; } = new(); -} - -/// Absolute screen-space rect (see comment above — must subtract parent rect for UiElement). -public sealed class DumpRect -{ - [JsonPropertyName("x")] - public float X { get; set; } - - [JsonPropertyName("y")] - public float Y { get; set; } - - [JsonPropertyName("width")] - public float Width { get; set; } - - [JsonPropertyName("height")] - public float Height { get; set; } -} - -/// State set for a node — default image plus per-state overrides. -public sealed class DumpStateSet -{ - [JsonPropertyName("default_image")] - public DumpImage? DefaultImage { get; set; } - - [JsonPropertyName("states")] - public List States { get; set; } = new(); -} - -/// Image reference (RenderSurface dat id + optional separate alpha surface). -public sealed class DumpImage -{ - [JsonPropertyName("image_id")] - public long ImageId { get; set; } - - [JsonPropertyName("alpha_image_id")] - public long? AlphaImageId { get; set; } -} - -/// A named state override. -public sealed class DumpState -{ - [JsonPropertyName("state_id")] - public int StateId { get; set; } - - [JsonPropertyName("image")] - public DumpImage Image { get; set; } = new(); -} - -// ───────────────────────────────────────────────────────────────────────────── -// Helper statics -// ───────────────────────────────────────────────────────────────────────────── - -/// -/// Parsing helpers for the retail UI dump JSON. -/// -public static class UiDumpModel -{ - private static readonly JsonSerializerOptions _opts = new() - { - PropertyNameCaseInsensitive = true, - AllowTrailingCommas = true, - ReadCommentHandling = JsonCommentHandling.Skip, - }; - - /// Parse the full dump from a file path. Returns null on failure. - public static UiDump? Parse(string path) - { - try - { - using var stream = File.OpenRead(path); - return JsonSerializer.Deserialize(stream, _opts); - } - catch - { - return null; - } - } - - /// - /// Return the list of slugs in the dump (for smoke-testing every panel). - /// Returns an empty list if the file cannot be parsed. - /// - public static IReadOnlyList ListSlugs(string path) - { - var dump = Parse(path); - if (dump is null) return Array.Empty(); - return dump.Panels.Select(p => p.Slug).ToList(); - } - - /// - /// Pick the sprite id to use for a node: prefer default_image.image_id; - /// fall back to states[0].image.image_id; return 0 if neither exists. - /// - public static uint PickImageId(DumpNode node) - { - if (node.StateSet.DefaultImage is { ImageId: > 0 } di) - return (uint)di.ImageId; - if (node.StateSet.States.Count > 0 && node.StateSet.States[0].Image.ImageId > 0) - return (uint)node.StateSet.States[0].Image.ImageId; - return 0u; - } -} diff --git a/src/AcDream.App/UI/ControlsIni.cs b/src/AcDream.App/UI/ControlsIni.cs deleted file mode 100644 index 2812d696..00000000 --- a/src/AcDream.App/UI/ControlsIni.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Collections.Generic; -using System.Globalization; -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// Minimal reader for retail's controls.ini — a flat INI with one -/// [section] per element type. Colors are #AARRGGBB (alpha -/// first). Optional: a missing file yields an empty sheet (callers fall back -/// to hardcoded defaults). See the D.2b spec §7. -/// -public sealed class ControlsIni -{ - private readonly Dictionary> _sections; - - private ControlsIni(Dictionary> s) => _sections = s; - - /// Load from disk; returns an empty sheet if the file is absent. - public static ControlsIni Load(string path) - => System.IO.File.Exists(path) - ? Parse(System.IO.File.ReadAllText(path)) - : new ControlsIni(new()); - - public static ControlsIni Parse(string text) - { - var sections = new Dictionary>(System.StringComparer.OrdinalIgnoreCase); - Dictionary? cur = null; - foreach (var raw in text.Split('\n')) - { - var line = raw.Trim(); - if (line.Length == 0 || line[0] == ';' || line[0] == '#') continue; - if (line[0] == '[' && line[^1] == ']') - { - var name = line[1..^1].Trim(); - cur = new Dictionary(System.StringComparer.OrdinalIgnoreCase); - sections[name] = cur; - continue; - } - int eq = line.IndexOf('='); - if (eq <= 0 || cur is null) continue; - cur[line[..eq].Trim()] = line[(eq + 1)..].Trim(); - } - return new ControlsIni(sections); - } - - public string? Get(string section, string key) - => _sections.TryGetValue(section, out var s) && s.TryGetValue(key, out var v) ? v : null; - - /// Parse a #AARRGGBB token into an RGBA . - public bool TryColor(string section, string key, out Vector4 color) - { - color = default; - var v = Get(section, key); - if (v is null || v.Length != 9 || v[0] != '#') return false; - if (!uint.TryParse(v.AsSpan(1), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out uint argb)) - return false; - float a = ((argb >> 24) & 0xFF) / 255f; - float r = ((argb >> 16) & 0xFF) / 255f; - float g = ((argb >> 8) & 0xFF) / 255f; - float b = (argb & 0xFF) / 255f; - color = new Vector4(r, g, b, a); - return true; - } -} diff --git a/src/AcDream.App/UI/IItemListDragHandler.cs b/src/AcDream.App/UI/IItemListDragHandler.cs deleted file mode 100644 index 2c8f1f96..00000000 --- a/src/AcDream.App/UI/IItemListDragHandler.cs +++ /dev/null @@ -1,25 +0,0 @@ -namespace AcDream.App.UI; - -/// -/// A panel controller implements this and registers itself on each of its -/// s. Port of retail's m_dragHandler vtable -/// (RegisterItemListDragHandler, decomp 230461; confirmed acclient -/// 0x004a539e + the gmToolbarUI block 0x004bdd89). -/// decides the accept/reject OVERLAY only (advisory). -/// is authoritative — it performs the action, or -/// no-ops to reject. -/// -public interface IItemListDragHandler -{ - /// The drag STARTED from a cell in this list — retail's RecvNotice_ItemListBeginDrag - /// → RemoveShortcut (decomp 0x004bd930/0x004bd450): the handler removes the lifted item from its - /// model + wire so the source slot empties immediately. The item is "in hand" until - /// HandleDropRelease (place) or the drag ends off-target (stays removed). No restore on cancel. - void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload); - - /// True ⇒ the target cell shows the accept (green) frame; false ⇒ reject (red). - bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); - - /// Perform the drop (issue the per-panel wire action), or no-op to reject. - void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload); -} diff --git a/src/AcDream.App/UI/IUiViewportRenderer.cs b/src/AcDream.App/UI/IUiViewportRenderer.cs deleted file mode 100644 index 13023078..00000000 --- a/src/AcDream.App/UI/IUiViewportRenderer.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace AcDream.App.UI; - -/// Renders a 3-D mini-scene into an off-screen buffer and returns the GL color-texture -/// handle. Called by the per-frame pre-UI hook (GameWindow), NOT from UiViewport.OnDraw. Implemented -/// by PaperdollViewportRenderer in AcDream.App.Rendering. Intra-App decoupling so the UI widget -/// doesn't depend on WbDrawDispatcher/GameWindow. -public interface IUiViewportRenderer -{ - /// Render at (width,height); return the color-texture GL handle, or 0 if nothing rendered. - uint Render(int width, int height); -} diff --git a/src/AcDream.App/UI/IconComposer.cs b/src/AcDream.App/UI/IconComposer.cs deleted file mode 100644 index 2ff95019..00000000 --- a/src/AcDream.App/UI/IconComposer.cs +++ /dev/null @@ -1,271 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.Rendering; -using AcDream.Core.Items; -using AcDream.Core.Textures; -using DatReaderWriter; -using DatReaderWriter.DBObjs; - -namespace AcDream.App.UI; - -/// -/// Builds an item icon by alpha-compositing its RenderSurface layers into one 32x32 -/// texture, mirroring retail IconData::RenderIcons (decomp 407524) and -/// DBCache::GetDIDFromEnum (0x413940). Each layer is a 0x06 RenderSurface decoded -/// DIRECTLY (the D.2b RenderSurface-vs-Surface rule). -/// -/// Layer order (bottom → top), matching retail: -/// 1. type-default underlay (OPAQUE backing; resolved via EnumIDMap 0x10000004 from -/// the portal MasterMap) — -/// 2. item custom underlay (e.g. "magic" tint strip) -/// 3. base icon -/// 4. item custom overlay (e.g. "enchanted" sparkle) -/// -/// The type-default underlay is the key to non-transparent filled slots: because it -/// is fully opaque and is layer 0, sizes the output to it and -/// the alpha-over pass fills every pixel. The overlay ReplaceColor tint and the effect -/// overlay (RenderIcons 407546) remain out of scope (paperdoll phase). -/// -/// Composited textures are cached by their (typeUnderlay, underlay, base, overlay) tuple. -/// -public sealed class IconComposer -{ - private readonly DatCollection _dats; - private readonly TextureCache _cache; - private readonly Dictionary<(uint, uint, uint, uint, uint), uint> _byTuple = new(); - - // ── type-default underlay resolve (EnumIDMap 0x10000004) ───────────────── - // Portal MasterMap (0x25000000) maps enum 0x10000004 → submap DID (0x25000008). - // Submap maps index → 0x06 RenderSurface DID. index = LSB(itemType)+1, or 0x21. - // Refs: IconData::RenderIcons 0058d214–0058d22c; DBCache::GetDIDFromEnum 0x413940. - private EnumIDMap? _underlaySubMap; - private bool _underlayResolveTried; - private readonly Dictionary _underlayDidByIndex = new(); - - // ── effect overlay resolve (EnumIDMap 0x10000005) ──────────────────────── - // Portal MasterMap (0x25000000) maps enum 0x10000005 → submap DID (0x25000009). - // Submap maps index → 0x06 RenderSurface DID. index = LSB(effects)+1, fallback 0x21. - // Refs: IconData::RenderIcons 0x0058d180 (effect path); the effect tile is a - // ReplaceColor tint SOURCE, not a blit layer (see RESOLVED doc, divergence DR-1). - private EnumIDMap? _effectSubMap; - private bool _effectResolveTried; - private readonly Dictionary _effectDidByIndex = new(); - private readonly Dictionary _effectTileByDid = new(); - - public IconComposer(DatCollection dats, TextureCache cache) - { - _dats = dats; - _cache = cache; - } - - /// - /// Resolve the type-default underlay DID for via the - /// two-level EnumIDMap chain (retail: IconData::RenderIcons 0058d214–0058d22c + - /// DBCache::GetDIDFromEnum 0x413940). - /// - /// index = LowestSetBit(itemType) + 1, or 0x21 when itemType has no bits set. - /// - /// NOTE: retail RenderIcons (407546) has a special paperdoll IsThePlayer case - /// that uses GetDIDByEnum(0x10000004, 7) + TYPE_CONTAINER for the player doll — that - /// path is out of scope here (paperdoll phase). - /// - internal uint ResolveUnderlayDid(ItemType itemType) - { - uint raw = (uint)itemType; - int lsb = raw == 0 ? -1 : BitOperations.TrailingZeroCount(raw); - uint index = lsb < 0 ? 0x21u : (uint)(lsb + 1); - if (_underlayDidByIndex.TryGetValue(index, out var cached)) return cached; - EnsureUnderlaySubMap(); - uint did = 0; - if (_underlaySubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d; - _underlayDidByIndex[index] = did; - return did; - } - - private void EnsureUnderlaySubMap() - { - if (_underlayResolveTried) return; - _underlayResolveTried = true; - uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000 - if (masterDid == 0) return; - if (!_dats.Portal.TryGet(masterDid, out var master)) return; - if (!master.ClientEnumToID.TryGetValue(0x10000004u, out var subDid)) return; // → 0x25000008 - if (_dats.Portal.TryGet(subDid, out var sub)) _underlaySubMap = sub; - } - - /// - /// Resolve the effect-overlay DID for via the EnumIDMap - /// 0x10000005 chain. index = LowestSetBit(effects)+1; if the entry is missing/zero, - /// retail falls back to index 0x21 (the solid-black tile). NOTE: the effect path has - /// NO lsb==-1 pre-check (unlike the type underlay), so effects==0 → index 0 → miss → - /// fallback. (Retail IconData::RenderIcons 0x0058d180.) - /// - internal uint ResolveEffectDid(uint effects) - { - int lsb = effects == 0 ? -1 : BitOperations.TrailingZeroCount(effects); - uint index = (uint)(lsb + 1); - if (_effectDidByIndex.TryGetValue(index, out var cached)) return cached; - EnsureEffectSubMap(); - uint did = 0; - if (_effectSubMap is { } sub && sub.ClientEnumToID.TryGetValue(index, out var d)) did = d; - if (did == 0 && _effectSubMap is { } sub2 && sub2.ClientEnumToID.TryGetValue(0x21u, out var fb)) - did = fb; - _effectDidByIndex[index] = did; - return did; - } - - private void EnsureEffectSubMap() - { - if (_effectResolveTried) return; - _effectResolveTried = true; - uint masterDid = (uint)_dats.Portal.Header.MasterMapId; // = 0x25000000 - if (masterDid == 0) return; - if (!_dats.Portal.TryGet(masterDid, out var master)) return; - if (!master.ClientEnumToID.TryGetValue(0x10000005u, out var subDid)) return; // → 0x25000009 - if (_dats.Portal.TryGet(subDid, out var sub)) _effectSubMap = sub; - } - - /// - /// Retail SurfaceWindow::ReplaceColor SURFACE overload (0x004415b0): for every - /// pixel in that equals pure-white-opaque (RGBAColor(1,1,1,1) → - /// 0xFFFFFFFF), copy the SAME (x,y) pixel from the source effect tile. This preserves - /// the effect tile's texture/gradient (NOT a flat color). Retail requires the source to - /// cover the dest (it does — both are 32x32); out-of-range pixels are left unchanged. - /// Mutates in place. - /// - internal static void ReplaceWhiteFromSurface(byte[] dst, int dw, int dh, byte[] src, int sw, int sh) - { - for (int y = 0; y < dh; y++) - for (int x = 0; x < dw; x++) - { - int di = (y * dw + x) * 4; - if (dst[di] == 255 && dst[di + 1] == 255 && dst[di + 2] == 255 && dst[di + 3] == 255 - && x < sw && y < sh) - { - int si = (y * sw + x) * 4; - dst[di] = src[si]; dst[di + 1] = src[si + 1]; - dst[di + 2] = src[si + 2]; dst[di + 3] = src[si + 3]; - } - } - } - - /// - /// The decoded effect tile for (enum 0x10000005). The tile is - /// a 32x32 textured RenderSurface whose pixels ARE the per-effect coloring (blue=Magical, - /// green=Poisoned, …; the 0x21 fallback is solid black). Retail copies it per-pixel into - /// the icon's white pixels (gradient), so we need the whole tile, not a representative - /// color. Cached per DID. - /// - internal bool TryGetEffectTile(uint effects, out DecodedTexture tile) - { - tile = null!; - uint did = ResolveEffectDid(effects); - if (did == 0) return false; - if (_effectTileByDid.TryGetValue(did, out var cached)) { tile = cached; return true; } - if (!TryDecode(did, out var d)) return false; - _effectTileByDid[did] = d; - tile = d; - return true; - } - - private bool TryDecode(uint renderSurfaceId, out DecodedTexture decoded) - { - decoded = null!; - if (renderSurfaceId == 0) return false; - if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) && - !_dats.HighRes.TryGet(renderSurfaceId, out rs)) - return false; - decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); - return true; - } - - /// Pure alpha-over composite, bottom->top. Layers may differ in size; - /// the result is sized to the FIRST (bottom) layer and upper layers are sampled - /// top-left aligned (all icon layers are 32x32 in practice). - public static (byte[] rgba, int w, int h) Compose(IReadOnlyList<(byte[] rgba, int w, int h)> layers) - { - if (layers.Count == 0) return (Array.Empty(), 0, 0); - var (baseRgba, w, h) = layers[0]; - var outp = (byte[])baseRgba.Clone(); - for (int li = 1; li < layers.Count; li++) - { - var (src, sw, sh) = layers[li]; - int cw = Math.Min(w, sw), ch = Math.Min(h, sh); - for (int y = 0; y < ch; y++) - for (int x = 0; x < cw; x++) - { - int di = (y * w + x) * 4, si = (y * sw + x) * 4; - float sa = src[si + 3] / 255f; - if (sa <= 0f) continue; - float da = 1f - sa; - outp[di] = (byte)(src[si] * sa + outp[di] * da); - outp[di + 1] = (byte)(src[si + 1] * sa + outp[di + 1] * da); - outp[di + 2] = (byte)(src[si + 2] * sa + outp[di + 2] * da); - outp[di + 3] = (byte)Math.Min(255f, src[si + 3] + outp[di + 3] * da); - } - } - return (outp, w, h); - } - - /// - /// Resolve (and cache) the composited GL texture for an item's icon state. - /// Returns 0 if no base icon. Mirrors retail IconData::RenderIcons (0x0058d180): - /// a DRAG composite (base + custom overlay + effect recolor) blitted over the - /// type-default underlay + custom underlay. The effect tile (enum 0x10000005) is a - /// ReplaceColor tint SOURCE, not a blit layer (DR-1). The recolor runs for ALL items: - /// effects==0 resolves to the 0x21 solid-black fallback tile, so pure-white pixels become - /// black (matching retail); magical items take the per-effect hue instead. - /// - public uint GetIcon(ItemType itemType, uint iconId, uint underlayId, uint overlayId, uint effects) - { - if (iconId == 0) return 0; - uint typeUnderlayDid = ResolveUnderlayDid(itemType); - var key = (typeUnderlayDid, iconId, underlayId, overlayId, effects); - if (_byTuple.TryGetValue(key, out var tex)) return tex; - - // Stage 1 — retail m_pDragIcon: base + custom overlay, then the effect recolor. - var dragLayers = new List<(byte[] rgba, int w, int h)>(); - AddLayer(dragLayers, iconId); - AddLayer(dragLayers, overlayId); - (byte[] rgba, int w, int h)? drag = null; - if (dragLayers.Count > 0) - { - var composed = Compose(dragLayers); - // Effect recolor — ALWAYS, matching retail IconData::RenderIcons (0x0058d180): - // the effect tile (enum 0x10000005, lsb(effects)+1, fallback 0x21) is non-null - // even for effects==0 (the 0x21 SOLID-BLACK tile 0x060011C5). Retail's RenderIcons - // calls the SURFACE overload of SurfaceWindow::ReplaceColor (0x004415b0), copying - // the textured effect tile per-pixel into the icon's pure-white pixels — so - // magical items take the tile's GRADIENT hue and mundane items go solid black. - // (Visually confirmed against retail 2026-06-17: the Energy Crystal's blue is a - // gradient, not a flat tint, and the no-mana scroll's edges are black.) - if (TryGetEffectTile(effects, out var tile)) - ReplaceWhiteFromSurface(composed.rgba, composed.w, composed.h, - tile.Rgba8, tile.Width, tile.Height); - drag = composed; - } - - // Stage 2 — retail m_pIcon: type-default underlay (opaque) + custom underlay + drag. - var layers = new List<(byte[] rgba, int w, int h)>(); - AddLayer(layers, typeUnderlayDid); - AddLayer(layers, underlayId); - if (drag is { } d) layers.Add(d); - if (layers.Count == 0) return 0; - - var (rgba, w, h) = Compose(layers); - uint handle = _cache.UploadRgba8(rgba, w, h, nearest: true); - _byTuple[key] = handle; - return handle; - } - - private void AddLayer(List<(byte[], int, int)> layers, uint renderSurfaceId) - { - if (renderSurfaceId == 0) return; - if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) && - !_dats.HighRes.TryGet(renderSurfaceId, out rs)) - return; - var decoded = SurfaceDecoder.DecodeRenderSurface(rs, palette: null); - layers.Add((decoded.Rgba8, decoded.Width, decoded.Height)); - } -} diff --git a/src/AcDream.App/UI/ItemDragPayload.cs b/src/AcDream.App/UI/ItemDragPayload.cs deleted file mode 100644 index b06728d4..00000000 --- a/src/AcDream.App/UI/ItemDragPayload.cs +++ /dev/null @@ -1,24 +0,0 @@ -namespace AcDream.App.UI; - -/// -/// Where a dragged item came from — the retail InqDropIconInfo flag -/// distinction (flags & 0xE == 0 fresh-from-inventory vs -/// flags & 4 within-list reorder) expressed as a typed enum. The drop -/// handler maps SourceKind + target back to the fresh-vs-reorder decision. -/// Decomp anchors: gmToolbarUI 0x004bd162 / 0x004bd1af; InqDropIconInfo 230533. -/// -public enum ItemDragSource { Inventory, ShortcutBar, Equipment, Ground } - -/// -/// Snapshot of a drag-in-progress, taken at drag-begin (so a server move arriving -/// mid-drag can't mutate it under us). Port of retail's m_dragElement + -/// InqDropIconInfo out-params (objId/container/flags, decomp 230533). -/// SourceContainer is intentionally NOT stored: the handler resolves the -/// LIVE container via ClientObjectTable.Get(ObjId).ContainerId at drop — the -/// same container id retail reads off the dragged element, single source of truth. -/// -public sealed record ItemDragPayload( - uint ObjId, // dragged weenie guid (retail itemID, +0x5FC) - ItemDragSource SourceKind, // what kind of slot it left - int SourceSlot, // the source cell's SlotIndex (retail m_lastShortcutNumDragged) - UiItemSlot SourceCell); // back-ref: reorder-restore / clear source state / ghost diff --git a/src/AcDream.App/UI/Layout/CharacterController.cs b/src/AcDream.App/UI/Layout/CharacterController.cs deleted file mode 100644 index 709c8262..00000000 --- a/src/AcDream.App/UI/Layout/CharacterController.cs +++ /dev/null @@ -1,182 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.UI; - -namespace AcDream.App.UI.Layout; - -/// -/// Per-window controller for the Character window (LayoutDesc 0x2100002E, gmCharacterInfoUI). -/// -/// Retail fills a single scrollable UIElement_Text element (id 0x1000011d, -/// m_pMainText) by calling a sequence of Update* methods that each -/// AppendStringInfo into the same element. This controller replicates that report -/// structure using acdream's . -/// -/// Section order (ported from gmCharacterInfoUI::Update 0x004ba790): -/// -/// Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) -/// Vitals / endurance (UpdateEnduranceInfo 0x004b8eb0) -/// Innate attributes (UpdateInnateAttributeInfo 0x004b87e0): -/// InqAttribute 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self -/// Skills summary (UpdateFakeSkills 0x004b8930) -/// Augmentations (UpdateAugmentations 0x004b9000) -/// Burden / load (UpdateLoad 0x004b8a20) -/// -/// Each section is separated by a blank line (retail: AppendStringInfo(m_pMainText, &var_120) -/// with an empty StringInfo between sections). -/// -/// StringInfo resolution is NOT ported — the full dat string-table lookup is a -/// later refinement. For this pilot, labels are composed directly from the well-known -/// AC attribute / stat names confirmed in the decomp string literals and data identifiers. -/// -public static class CharacterController -{ - /// Dat element id for the main report text (m_pMainText). Confirmed in - /// gmCharacterInfoUI::PostInit 0x004b86f0: GetChildRecursive(this, 0x1000011d). - public const uint MainTextId = 0x1000011du; - - /// White body text — matches retail's default UIElement_Text foreground. - private static readonly Vector4 BodyColor = new(1f, 1f, 1f, 1f); - - /// Yellow section header — retail uses a different StringInfo color per section header. - private static readonly Vector4 HeaderColor = new(1f, 0.85f, 0.35f, 1f); - - /// - /// Bind the character report text element found in to - /// . The report is regenerated each frame via - /// so the Studio or a live session can push a fresh - /// without re-binding. - /// - /// The element must resolve as a (Type 12 in the dat). - /// If 0x1000011d is absent from the layout the bind is a no-op — partial layouts - /// (e.g. test fakes) don't cause errors. - /// - /// Imported 0x2100002E layout tree. - /// Provider returning the current . - /// Retail dat font forwarded from the render stack. May be null - /// (falls back to the BitmapFont debug path). - public static void Bind( - ImportedLayout layout, - Func data, - UiDatFont? datFont = null) - { - // Retail's gmCharacterInfoUI CREATES m_pMainText (0x1000011d) at RUNTIME — it is NOT a static - // element in any LayoutDesc (confirmed: acdream's dat-import of 0x2100002E AND 0x2100006E both - // lack it; only a runtime UI-tree capture has it). So replicate that: build the report text - // element ourselves and place it in the panel body, exactly as the gm*UI does at runtime. - var root = layout.Root; - if (root is null) return; - - var text = new UiText - { - EventId = MainTextId, - Name = "m_pMainText", - Left = 12f, - Top = 44f, // below the window header row - Width = System.Math.Max(40f, root.Width - 24f), - Height = System.Math.Max(40f, root.Height - 56f), - Anchors = AnchorEdges.None, - ZOrder = 1_000_000, // draw above the static chrome - DatFont = datFont, - ClickThrough = false, - }; - text.LinesProvider = () => BuildReport(data()); - root.AddChild(text); - } - - // ── Report builder ──────────────────────────────────────────────────────── - - /// - /// Build the complete character report as a line list. - /// Order mirrors gmCharacterInfoUI::Update (0x004ba790). - /// - private static IReadOnlyList BuildReport(CharacterSheet s) - { - var lines = new List(); - - // ── Section 1: Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ── - // Retail InqInt(0x62) = DateOfBirth (unix timestamp); InqInt(0x7d) = TotalPlayTime; - // InqInt(0x2b) = NumDeaths. - AppendHeader(lines, s.Name); - Append(lines, $"Level {s.Level}"); - if (s.Race is not null) - Append(lines, s.Race); - if (s.Heritage is not null) - Append(lines, s.Heritage); - if (s.Title is not null) - Append(lines, s.Title); - AppendBlank(lines); - - if (s.BirthDate is not null) - Append(lines, $"Birth: {s.BirthDate}"); - if (s.PlayTime is not null) - Append(lines, $"Age: {s.PlayTime}"); - Append(lines, $"Deaths: {s.Deaths}"); - AppendBlank(lines); - - // ── Section 2: Vitals / endurance (UpdateEnduranceInfo 0x004b8eb0) ── - // Retail InqAttribute(1) = Strength base, InqAttribute(2) = Endurance base. - // Computes max-stamina tier and max-health tier from (Str+End) and (End+2*End) sums. - AppendHeader(lines, "Vitals"); - Append(lines, $"Health: {s.HealthCurrent} / {s.HealthMax}"); - Append(lines, $"Stamina: {s.StaminaCurrent} / {s.StaminaMax}"); - Append(lines, $"Mana: {s.ManaCurrent} / {s.ManaMax}"); - AppendBlank(lines); - - // ── Section 3: Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ── - // InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, - // Focus, Self (confirmed from the AddVariable_Int sequence in the decomp). - AppendHeader(lines, "Attributes"); - Append(lines, $"Strength: {s.Strength}"); - Append(lines, $"Endurance: {s.Endurance}"); - Append(lines, $"Quickness: {s.Quickness}"); - Append(lines, $"Coordination: {s.Coordination}"); - Append(lines, $"Focus: {s.Focus}"); - Append(lines, $"Self: {s.Self}"); - AppendBlank(lines); - - // ── Section 4: Skills summary (UpdateFakeSkills 0x004b8930) ── - // Retail InqInt(0xb5) = SkillCredits remaining; InqInt(0xc0) = AvailableSkillCredits. - AppendHeader(lines, "Skills"); - Append(lines, $"Unspent skill credits: {s.UnspentSkillCredits}"); - if (s.SpecializedSkillCredits > 0) - Append(lines, $"Specialized credits: {s.SpecializedSkillCredits}"); - AppendBlank(lines); - - // ── Section 5: Augmentations (UpdateAugmentations 0x004b9000) ── - // Retail InqInt(0x162) = AugmentationStat; string-switch on value 1..0xb + Unknown. - AppendHeader(lines, "Augmentations"); - if (s.AugmentationName is not null) - Append(lines, s.AugmentationName); - else - Append(lines, "None"); - AppendBlank(lines); - - // ── Section 6: Burden / load (UpdateLoad 0x004b8a20) ── - // Retail InqLoad → EncumbranceCapacity(Strength, AugEncumbrance). - // When load >= 1.0 (overloaded): shows "X burden over capacity; Y% speed penalty". - // When aug > 0: shows "N augmentations (X.X% bonus)". - AppendHeader(lines, "Encumbrance"); - Append(lines, $"Burden: {s.BurdenCurrent}"); - Append(lines, $"Capacity: {s.BurdenMax}"); - if (s.BurdenMax > 0) - { - int pct = (int)Math.Round(100.0 * s.BurdenCurrent / s.BurdenMax); - Append(lines, $"Load: {pct}%"); - } - - return lines; - } - - // ── Line helpers ────────────────────────────────────────────────────────── - - private static void AppendHeader(List lines, string text) - => lines.Add(new UiText.Line(text, HeaderColor)); - - private static void Append(List lines, string text) - => lines.Add(new UiText.Line(text, BodyColor)); - - private static void AppendBlank(List lines) - => lines.Add(new UiText.Line(string.Empty, BodyColor)); -} diff --git a/src/AcDream.App/UI/Layout/CharacterSheet.cs b/src/AcDream.App/UI/Layout/CharacterSheet.cs deleted file mode 100644 index b64c64eb..00000000 --- a/src/AcDream.App/UI/Layout/CharacterSheet.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; - -namespace AcDream.App.UI.Layout; - -/// -/// Snapshot of all character data the Character window report uses. -/// Passed to as a provider delegate -/// so the window can be rebound to live data in GameWindow or a static -/// fixture in the UI Studio. -/// -/// Field names and retail property ids confirmed from -/// gmCharacterInfoUI::UpdatePlayerBirthAgeDeaths (0x004b8cb0), -/// UpdateEnduranceInfo (0x004b8eb0), -/// UpdateInnateAttributeInfo (0x004b87e0), -/// UpdateFakeSkills (0x004b8930), -/// UpdateAugmentations (0x004b9000), and -/// UpdateLoad (0x004b8a20). -/// -public sealed class CharacterSheet -{ - // ── Identity ────────────────────────────────────────────────────────────── - - /// Character name (first line of the report). - public string Name { get; init; } = string.Empty; - - /// Character level. - public int Level { get; init; } - - /// Race string, e.g. "Aluvian". Null = omit. - public string? Race { get; init; } - - /// Heritage string, e.g. "Aluvian Heritage". Null = omit. - public string? Heritage { get; init; } - - /// Title string, e.g. "the Adventurer". Null = omit. - public string? Title { get; init; } - - // ── Experience / PK (gmStatManagementUI::UpdateExperience 0x004f0a70, - // UpdatePKStatus 0x004f00a0) — the Attributes-tab header strip ────────── - - /// Total accrued experience (retail PropertyInt64 1). Header value - /// element 0x10000235 (m_pTotalXPText). - public long TotalXp { get; init; } - - /// Experience remaining to the next level. Header value element - /// 0x10000238 (m_pXPToLevelText). - public long XpToNextLevel { get; init; } - - /// XP-to-next-level meter fill, 0..1 (retail (cur−base)/(cap−base)). - /// Drives the header meter 0x10000236 (m_pXPToLevelMeter). - public float XpFraction { get; init; } - - /// PK status display string, e.g. "Non-Player Killer". Header element - /// 0x10000233 (m_pPKStatusText). Null = omit. - public string? PkStatus { get; init; } - - // ── Birth / age / deaths (UpdatePlayerBirthAgeDeaths 0x004b8cb0) ───────── - - /// Formatted birth date string (retail InqInt(0x62) → strftime). - /// Null = omit the birth line. - public string? BirthDate { get; init; } - - /// Formatted play-time duration (retail InqInt(0x7d) → QueryDuration). - /// Null = omit the age line. - public string? PlayTime { get; init; } - - /// Total deaths (retail InqInt(0x2b) = NumDeaths). - public int Deaths { get; init; } - - // ── Vitals (UpdateEnduranceInfo 0x004b8eb0) ───────────────────────────── - - public int HealthCurrent { get; init; } - public int HealthMax { get; init; } - public int StaminaCurrent { get; init; } - public int StaminaMax { get; init; } - public int ManaCurrent { get; init; } - public int ManaMax { get; init; } - - // ── Innate attributes (UpdateInnateAttributeInfo 0x004b87e0) ──────────── - // InqAttribute order: 1,2,4,3,5,6 = Strength, Endurance, Quickness, Coordination, Focus, Self. - - public int Strength { get; init; } - public int Endurance { get; init; } - public int Quickness { get; init; } - public int Coordination { get; init; } - public int Focus { get; init; } - public int Self { get; init; } - - // ── Skills (UpdateFakeSkills 0x004b8930) ──────────────────────────────── - // Retail InqInt(0xb5) = SkillCredits; InqInt(0xc0) = AvailableSkillCredits. - // InqInt(0x18) = available skill credits — footer 0x10000245 in the Attributes tab. - - public int UnspentSkillCredits { get; init; } - public int SpecializedSkillCredits { get; init; } - - /// - /// Available (unspent) skill credits shown in the Attributes tab footer State-A. - /// Retail InqInt(0x18) — gmStatManagementUI::DisplayDefaultFooter (0x0049cde0). - /// Element 0x10000243 (footer line-1 value in the studio's 3-line layout). - /// - public int SkillCredits { get; init; } - - /// - /// Unassigned (banked) experience points. - /// Retail InqInt64(2) — shown in footer line-2 in State-A display. - /// Element 0x10000245 (footer line-2 value). - /// - public long UnassignedXp { get; init; } - - // ── Attribute raise costs (ExperienceToAttributeLevel, gmAttributeUI::PostInit) ── - // Retail formula: ExperienceToAttributeLevel(value + 1) − ExperienceToAttributeLevel(value). - // We store pre-computed per-attribute costs for the fixture; cost == 0 → attribute is - // at max or not trainable (raise button ghosted/hidden). Ordered to match AttrRows - // (Strength, Endurance, Coordination, Quickness, Focus, Self, Health, Stamina, Mana). - // Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910) + CM_Train::Event_TrainAttribute. - - /// - /// XP cost to raise each of the 9 attributes/vitals by 1, in retail display order: - /// [0]=Strength, [1]=Endurance, [2]=Coordination, [3]=Quickness, [4]=Focus, [5]=Self, - /// [6]=Health, [7]=Stamina, [8]=Mana. - /// Cost 0 means the attribute is at max (raise button ghosted). - /// - public long[] AttributeRaiseCosts { get; init; } = Array.Empty(); - - // ── Augmentations (UpdateAugmentations 0x004b9000) ───────────────────── - // Retail InqInt(0x162) = AugmentationStat; string-switch 1..0xb. - - /// Augmentation name from the switch in UpdateAugmentations (0x004b9000), - /// e.g. "Swords", "Two Handed Weapons". Null = "None". - public string? AugmentationName { get; init; } - - // ── Burden / load (UpdateLoad 0x004b8a20) ─────────────────────────────── - // Retail InqLoad + EncumbranceCapacity(Strength, AugEncumbrance). - - public int BurdenCurrent { get; init; } - public int BurdenMax { get; init; } -} diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs deleted file mode 100644 index e686c481..00000000 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ /dev/null @@ -1,1081 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.UI; - -namespace AcDream.App.UI.Layout; - -/// -/// Controller for the Character window's Attributes tab — LayoutDesc 0x2100002E, -/// whose tab-content slot 0x1000022B mounts sub-layout 0x2100002C (gmAttributeUI, root -/// type 0x1000002A) which in turn chains into the gmStatManagementUI header content. -/// -/// Unlike (which targets the SEPARATE text-report -/// sub-panel 0x2100001A, gmCharacterInfoUI, by creating its runtime m_pMainText element), -/// this controller binds the real, statically-mounted header + list elements that the -/// importer already produces — every id below is confirmed present via -/// . -/// -/// Ported from gmStatManagementUI::UpdateCharacterInfo (0x004f0770) + -/// UpdateExperience (0x004f0a70) + UpdatePKStatus (0x004f00a0): name, heritage, -/// PK status, level, total XP and the XP-to-level meter. The attribute list is the -/// gmAttributeUI list box (0x1000023D), built as 9 manual-layout rows per -/// gmAttributeUI::PostInit (0x0049db70) + AttributeInfoRegion / Attribute2ndInfoRegion -/// (0x004f1910 / 0x004f19e0). Row icons loaded via sub-element 0x10000129 in the retail -/// dat template (each icon is a 0x06xxxxxx RenderSurface DataID from SubMap -/// 0x25000006 / 0x25000007, spec §2). -/// -/// Footer State A (nothing selected) bound from -/// DisplayDefaultFooter (0x0049cde0): title empty, line-1 value = -/// "Select an Attribute to Improve", line-2 value = available skill credits (InqInt(0x18)). -/// -/// Footer State B (row selected) bound from -/// DisplaySelectedAttribute (implicitly): title = "{AttrName}: {value}", -/// line-1 label = "Experience To Raise:", line-1 value = raise cost, -/// line-2 label = "Unassigned Experience:", line-2 value = UnassignedXp. -/// -/// Tab button states: Attributes = "Open", Skills = "Closed", Titles = "Closed". -/// Source: UIStateId.Open (0x0C) / UIStateId.Closed (0x0B) — set on the UiButton children -/// inside each tab group container. -/// -/// Raise buttons: 0x10000246 (×1) + 0x100005EB (×10). State "Normal" = affordable -/// (UIStateId.Normal, 0x01), state "Ghosted" = unaffordable or no selection -/// (UIStateId.Ghosted, 0x0D). Source: gmAttributeUI::AttributeInfoRegion::Update (0x004f1910). -/// -public static class CharacterStatController -{ - // ── gmStatManagementUI header element ids (sub-layout 0x2100002C content) ── - public const uint NameId = 0x10000231u; // m_pNameText - public const uint HeritageId = 0x10000232u; // m_pHeritageText - public const uint PkStatusId = 0x10000233u; // m_pPKStatusText - public const uint LevelCaptionId = 0x1000023Au; // "Character Level" caption ABOVE level value - public const uint LevelId = 0x1000023Bu; // m_pLevelText (right-side level area) - public const uint TotalXpLabelId = 0x10000234u; // "Total Experience (XP):" caption left of value - public const uint TotalXpId = 0x10000235u; // m_pTotalXPText - public const uint XpMeterId = 0x10000236u; // m_pXPToLevelMeter (UiMeter) - // Fix 5: 0x10000237 (XP-to-level label) and 0x10000238 (XP-to-level value) are now - // built by the LayoutImporter as UiText children of the XP meter (non-Type-3 children - // are explicitly built and registered in byId). FindElement DOES return them now. - // The controller binds their LinesProvider instead of injecting new runtime nodes. - public const uint XpNextLabelId = 0x10000237u; // "XP for next level:" label child of XP meter - public const uint XpNextValueId = 0x10000238u; // XP-to-next-level value child of XP meter - public const uint ListBoxId = 0x1000023Du; // m_pListBox container - - // ── Footer STATE-A container id ────────────────────────────────────────── - // 0x10000240 is the "nothing selected" footer group. Its children (0x1000024E label row, - // 0x10000242–0x10000245 labels+values) are the correct State-A versions with wider - // label widths (195px vs 145px in State B). _byId stores the LAST duplicate, which - // is the narrower State-B/C copy — so we walk the tree to 0x10000240 and bind from there. - public const uint FooterStateAId = 0x10000240u; // State-A footer container (nothing selected) - public const uint FooterStateBId = 0x10000241u; // State-B footer container (row selected) - public const uint FooterStateCId = 0x10000247u; // State-C footer container (hide inactive) - - // ── Tab bar element ids (LayoutDesc 0x2100002E root) ──────────────────── - // In the imported tree each tab group (0x10000228/229/538) resolves to a UiText with - // ConsumesDatChildren=true, so the three button children (left-cap, label, right-cap) - // are consumed at import time and not present in the widget tree. - // - // WHY this is NOT fixed in Fix 5 (importer series): - // The Fix 5 meter patch only builds NON-Type-3 children of UiMeter. The tab group children - // are children of UiText (Type 12), not UiMeter (Type 7). Flipping UiText.ConsumesDatChildren - // globally is not safe — the chat transcript UiText and other Type-12 elements also have - // children in some layouts (scroll decorators etc.), and building them as live UiText widgets - // would risk invisible nodes stealing focus/clicks (the exact problem ConsumesDatChildren was - // designed to prevent). Additionally, the page-visibility pass in Bind() depends on - // tab group Children.Count==0 to skip them — adding children to the tab groups would break - // that pass and hide ALL tab content. - // - // Therefore: the controller injects 3 sprite-drawing root children per tab (absolute coords), - // using the known RenderSurface ids from the retail UI layout dump (2026-06-25): - // Closed (inactive) state: left=0x06005D93, center=0x06005D95, right=0x06005D97 - // Open (active) state: left=0x06005D92, center=0x06005D94, right=0x06005D96 - // Source: state_id 11=Closed, 12=Open per gmTabUI::SetActive(bool); sprite ids confirmed - // from retail UI layout dump nodes 0x10000439, 0x100000E9, 0x10000215 in layout 0x2100002E. - public const uint TabAttribId = 0x10000228u; // Attributes tab group - public const uint TabSkillsId = 0x10000229u; // Skills tab group - public const uint TabTitlesId = 0x10000538u; // Titles tab group - - // Tab button piece widths and shared height (from dump node rects). - private const float TabLeftCapW = 17f; // left-cap button width - private const float TabCenterW = 58f; // center label button width - private const float TabRightCapW = 17f; // right-cap button width - private const float TabH = 25f; // button height - - // Tab sprite ids per state (Open = active, Closed = inactive). - // Source: retail UI layout dump nodes 0x10000439/0x100000E9/0x10000215 in 0x2100002E. - private const uint TabOpenLeft = 0x06005D92u; - private const uint TabOpenCenter = 0x06005D94u; - private const uint TabOpenRight = 0x06005D96u; - private const uint TabClosedLeft = 0x06005D93u; - private const uint TabClosedCenter = 0x06005D95u; - private const uint TabClosedRight = 0x06005D97u; - - // ── Footer element ids (gmStatManagementUI struct fields) ──────────────── - // Source: acclient.h / DisplayDefaultFooter (0x0049cde0) - public const uint FooterTitleId = 0x1000024eu; // GetFooterTitleLabel - public const uint FooterLine1Label = 0x10000242u; // GetFooterLineOneLabel - public const uint FooterLine1Value = 0x10000243u; // GetFooterLineOneValue - public const uint FooterLine2Label = 0x10000244u; // GetFooterLineTwoLabel - public const uint FooterLine2Value = 0x10000245u; // GetFooterLineTwoValue - - // ── Raise button element ids ────────────────────────────────────────────── - // Source: gmAttributeUI::PostInit (0x0049db70); CM_Train::Event_TrainAttribute. - // Button state "Normal" (UIStateId 0x01) = affordable (green/active); - // "Ghosted" (UIStateId 0x0D) = disabled. Hidden when nothing is selected. - public const uint RaiseOneId = 0x10000246u; // raise × 1 - public const uint RaiseTenId = 0x100005EBu; // raise × 10 - - // ── UIStateId string keys (DatReaderWriter UIStateId enum.ToString()) ──── - // State names as returned by UIStateId.ToString() — used as ActiveState keys on UiButton. - private const string StateOpen = "Open"; // UIStateId.Open = 0x0C — active tab - private const string StateClosed = "Closed"; // UIStateId.Closed = 0x0B — inactive tab - private const string StateNormal = "Normal"; // UIStateId.Normal = 0x01 — affordable / default - private const string StateGhosted = "Ghosted"; // UIStateId.Ghosted = 0x0D — disabled button - - private static readonly Vector4 Body = new(0.92f, 0.90f, 0.82f, 1f); // parchment-white body text - private static readonly Vector4 Gold = new(1f, 0.82f, 0.36f, 1f); // section / emphasis gold - - /// Row highlight color — semi-translucent gold, matches retail - /// UIStateId.Highlight (0x06) sprite 0x06001397 visual intent. - private static readonly Vector4 HighlightBg = new(1f, 0.75f, 0.2f, 0.25f); - - // ── Row layout constants ───────────────────────────────────────────────── - // RowHeight 22px + IconSize 16px: retail spec (2026-06-26) says icons ~icon-height - // and rows tighter. 16px icon fits inside 22px row with 3px vertical padding each side. - // The larger row font (0x40000001, MaxCharHeight=18) is clipped to the 22px height which - // gives a tight-but-readable line. Retail spec (2026-06-26 ref): "rows tighter, text ≈ icon height". - private const float RowHeight = 22f; - private const float IconSize = 16f; - private const float RowPadX = 4f; - private const float IconGap = 6f; - - // ── Attribute row descriptors — retail display order per spec §1 ───────── - private static readonly (string name, uint iconDid)[] AttrRows = new[] - { - ("Strength", 0x060002C8u), // enum 1 - ("Endurance", 0x060002C4u), // enum 2 - ("Coordination", 0x060002C9u), // enum 4 - ("Quickness", 0x060002C6u), // enum 3 - ("Focus", 0x060002C5u), // enum 5 - ("Self", 0x060002C7u), // enum 6 - }; - - private static readonly (string name, uint iconDid)[] VitalRows = new[] - { - ("Health", 0x06004C3Bu), // CurEnum 2 - ("Stamina", 0x06004C3Cu), // CurEnum 4 - ("Mana", 0x06004C3Du), // CurEnum 6 - }; - - /// - /// Bind the Attributes-tab header + 9-row list + footer elements, tab button states, - /// and raise buttons in to . - /// - /// - /// Interactive mode (Pass 2): each attribute/vital row is a - /// that fires selection logic on left-click. The - /// selected row index is held in a mutable int[] box (single element) so that - /// all closures share the same mutable slot without escaping the static method boundary. - /// - /// - /// - /// resolves a 0x06xxxxxx RenderSurface dat id to - /// a (GL tex handle, pixel width, pixel height) triple. Pass null in tests where - /// icon rendering is not asserted. - /// - /// - public static void Bind( - ImportedLayout layout, - Func data, - UiDatFont? datFont = null, - UiDatFont? rowDatFont = null, - Func? spriteResolve = null) - { - // rowDatFont: larger font for attribute row name/value text (18px vs 16px default). - // Falls back to datFont when null (tests, or dat missing). - rowDatFont ??= datFont; - - // Name (18px from dat FontDid), Heritage (14px), PkStatus (14px): - // Fix C: pass null → Label's null-guard keeps the build-time dat font. - // Controllers still own the text color and the LinesProvider. - // Name = WHITE (retail "Horan" is white — confirmed 2026-06-26). - Label(layout, NameId, null, Vector4.One, () => data().Name); - Label(layout, HeritageId, null, Body, () => data().Heritage ?? data().Race ?? string.Empty); - Label(layout, PkStatusId, null, Body, () => data().PkStatus ?? string.Empty); - - // ── Header captions (new — retail labels above/left of each number) ────── - // LevelCaption (0x1000023A, 16px from dat): pass null → keep build-time dat font. - LabelTwoLine(layout, LevelCaptionId, null, Body, "Character", "Level"); - - // Level number: retail renders this as large gold centered text in the 65×50 element. - // Fix C: the dat FontDid for the level element (0x1000023B) is now applied at build - // time when the font resolver is provided (studio path). We no longer force rowDatFont - // here for the level — the dat's own FontDid drives the font. The Gold color is still - // set via LinesProvider. SYNTHESIZED elements (the 9 attribute rows built in - // BuildAttributeRows) continue to use datFont directly since they have no dat origin. - // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo 0x004f0770. - // runtime color, dat carries none. - Label(layout, LevelId, null, Gold, () => data().Level.ToString()); - - // TotalXpLabel (16px from dat) + TotalXp (16px from dat): pass null → keep dat font. - LabelLeft(layout, TotalXpLabelId, null, Body, static () => "Total Experience (XP):"); - Label(layout, TotalXpId, null, Body, () => data().TotalXp.ToString("N0")); - - // XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70). - // Fix 5: child elements 0x10000237 (label) and 0x10000238 (value) are now built by - // the LayoutImporter as UiText children of the XP meter (non-Type-3 meter children - // are explicitly built and registered in byId — see LayoutImporter.BuildWidget). - // FindElement now returns them; the controller binds their LinesProvider. - // The importer builds them as UiText via DatWidgetFactory.BuildText, applying their - // dat-origin HJustify/VJustify/FontDid/FontColor at build time. The controller then - // overrides Padding=0 (to avoid scroll clip in the ~13px-tall bar), ClickThrough=true, - // and provides the LinesProvider for runtime content. - if (layout.FindElement(XpMeterId) is UiMeter meter) - { - meter.Fill = () => data().XpFraction; - - // Bind the dat-origin XP label (0x10000237) and value (0x10000238). - // These are now real UiText children of the meter (built by the importer). - // The retail layout places the caption + value ON TOP of the red bar - // (ref 2026-06-26: "value … with the red fill bar behind it"). - // Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1). - if (layout.FindElement(XpNextLabelId) is UiText xpLabel) - { - if (datFont is not null) xpLabel.DatFont = datFont; - xpLabel.ClickThrough = true; - xpLabel.Centered = false; // left-align (retail: aligns with Total XP label above) - xpLabel.RightAligned = false; - xpLabel.Padding = 0f; // avoid scroll clip — meter bar is ~13px tall - - // Item 1: align the XP-next label's left edge to match the TotalXpLabel's - // absolute left edge. The XP-next label is a child of the meter (local coords), - // so its Left = TotalXpLabel.Left − meter.Left. This accounts for the meter's - // horizontal offset within the panel (the meter starts to the right of the - // "Total Experience (XP):" caption row). Source: retail spec §State 1 (the - // "XP for next level:" caption left-aligns with "Total Experience (XP):" above). - if (layout.FindElement(TotalXpLabelId) is { } totalXpLbl) - { - float xpNextLeft = totalXpLbl.Left - meter.Left; - xpLabel.Left = xpNextLeft >= 0f ? xpNextLeft : 0f; - } - - xpLabel.LinesProvider = static () => new[] { new UiText.Line("XP for next level:", Body) }; - } - if (layout.FindElement(XpNextValueId) is UiText xpValue) - { - if (datFont is not null) xpValue.DatFont = datFont; - xpValue.ClickThrough = true; - xpValue.RightAligned = true; - xpValue.Padding = 0f; // avoid scroll clip - xpValue.LinesProvider = () => new[] { new UiText.Line(data().XpToNextLevel.ToString("N0"), Body) }; - } - } - - // ── Tab bar sprite children (added to PANEL ROOT, not to the tab groups) ─── - // The tab groups (0x10000228/229/538) are UiText (Type 12) with - // ConsumesDatChildren=true, so their 3 button children were consumed at import. - // We inject the visual equivalent — 3 sprite UiTexts per tab — as CHILDREN OF - // layout.Root at the tab's ABSOLUTE screen position, NOT as children of the tab - // groups. This matters because the page-visibility pass (below) iterates - // root.Children and hides any child that doesn't contain NameId; if we added - // sprites to the tab groups, those groups would have non-zero Children.Count - // and be eligible for the pass, then hidden (since NameId is in the content area). - // By adding to root instead, the tab groups keep Children.Count==0 and are skipped - // by "if (page.Children.Count == 0) continue" — they stay visible always. - // Source: retail dump tab rects confirmed (2026-06-25): - // Attributes = (0,0,92,25), Skills = (92,0,92,25), Titles = (184,0,92,25). - if (layout.Root is { } tabRoot && spriteResolve is not null) - { - // Hide the original tab group elements (0x10000228/229/538): they are UiText - // nodes that render their own state sprite at ZOrder=1/2/3. Our replacement - // sprites go to root at ZOrder=8 (above them), so the originals would paint - // underneath. Hiding them avoids the double-draw and any opaque overlap. - if (layout.FindElement(TabAttribId) is { } origAttr) origAttr.Visible = false; - if (layout.FindElement(TabSkillsId) is { } origSkills) origSkills.Visible = false; - if (layout.FindElement(TabTitlesId) is { } origTitles) origTitles.Visible = false; - - AddTabSpritesToRoot(tabRoot, spriteResolve, datFont, - tabX: 0f, label: "Attributes", isOpen: true); - AddTabSpritesToRoot(tabRoot, spriteResolve, datFont, - tabX: 92f, label: "Skills", isOpen: false); - AddTabSpritesToRoot(tabRoot, spriteResolve, datFont, - tabX: 184f, label: "Titles", isOpen: false); - } - - // ── Attribute list — 9 rows in list box 0x1000023D ──────────────────── - // Mutable selected-index box: -1 = nothing selected. - var sel = new int[] { -1 }; - - // Gather EVERY copy of the raise buttons in the tree. The raise button ids - // (0x10000246, 0x100005EB) appear once per tab page (Attributes/Skills/Titles) - // in the dat inheritance structure; ImportedLayout._byId keeps only the LAST - // mounted copy. We collect all copies so we can hide them all initially and - // show/hide the correct set when a row is selected. - // - // At bind time the tree includes all three tab pages (the page-visibility pass - // runs AFTER this). Collecting from the full tree is safe: once the page- - // visibility pass hides the inactive pages their raise buttons are invisible - // regardless of the Visible flag we set here — but the Attributes page's - // buttons (which are NOT hidden by the page pass) must be explicitly hidden. - var allRaise1 = new List(); - var allRaise10 = new List(); - if (layout.Root is { } r) - { - // Single-pass tree walk to collect all UiButton copies at the two ids. - // FindElement only returns the last-registered copy in _byId; we need ALL - // copies because duplicated sub-layout mounts each tab page independently. - CollectButtonsById(r, RaiseOneId, allRaise1, layout); - CollectButtonsById(r, RaiseTenId, allRaise10, layout); - } - // If tree-walk found nothing, fall back to _byId (covers fake/test layouts). - if (allRaise1.Count == 0 && layout.FindElement(RaiseOneId) is UiButton b1) allRaise1.Add(b1); - if (allRaise10.Count == 0 && layout.FindElement(RaiseTenId) is UiButton b10) allRaise10.Add(b10); - - // Initial state: raise buttons hidden until a row is selected. - foreach (var b in allRaise1) b.Visible = false; - foreach (var b in allRaise10) b.Visible = false; - - // ── Footer State B/C visibility ─────────────────────────────────────── - // There are THREE footer state groups (A=0x10000240, B=0x10000241, C=0x10000247) - // all stacked at the same position within the Attributes page. _byId stores only - // the LAST copy of each id; the others live in the VISIBLE Attributes page and must - // also be hidden. - // - // WHY this cannot be done in the importer (dat state-model audit 2026-06-26): - // All three group elements have DefaultState = StatManagement_Footer_Default - // (0x10000011) — the dat does NOT differentiate them by visibility. The parent - // element (0x1000022F) has a States map {Default, Text, Meter} with PassToChildren= - // true, but each child group also registers all three states (IncFlags=None, - // Media=0) — meaning the state-propagation produces no media change on any group. - // Retail's gmStatManagementUI uses hardcoded element-id dispatch - // (GetChildRecursive(this, 0x10000240) for Default, 0x10000241 for Text, 0x10000247 - // for Meter) to access the right group's children at runtime — the groups themselves - // are never hidden/shown via the dat state mechanism. The controller is the correct - // and only place for this visibility management. See retail decomp - // gmStatManagementUI::GetFooterTitleLabel @0x004f0170. - // - // Strategy: walk the Attributes page (the root child that contains the NameId anchor) - // and hide every UiDatElement whose ElementId matches B or C. The page-visibility - // pass hides everything in the Skills/Titles pages, so we only need to act on the - // Attributes page's copies. - if (layout.FindElement(NameId) is { } bAnchor && layout.Root is { } fbRoot) - { - foreach (var fbPage in fbRoot.Children) - { - if (fbPage.Children.Count == 0) continue; - if (!ContainsWidget(fbPage, bAnchor)) continue; - // This is the Attributes page. Hide all stateB and stateC copies in it. - HideAllById(fbPage, FooterStateBId); - HideAllById(fbPage, FooterStateCId); - break; - } - } - // Also hide the last-registered copies (in the _byId dict, which may be from a - // different page). Belt-and-suspenders. - if (layout.FindElement(FooterStateBId) is { } footerB) footerB.Visible = false; - if (layout.FindElement(FooterStateCId) is { } footerC) footerC.Visible = false; - - // ── Footer State A initial binding ──────────────────────────────────── - // Walk to the State-A container directly (rather than _byId which returns the - // last duplicate) so we get the wider-label copies (195px) for the unselected state. - BindFooterDynamic(layout, datFont, data, sel); - - List? rowPanels = null; - - if (layout.FindElement(ListBoxId) is { } list) - { - rowPanels = BuildAttributeRows(list, rowDatFont, spriteResolve, data, sel, - allRaise1, allRaise10); - } - - // ── Active-page selection (fixes the dark-overlay) ───────────────────── - // WHY this cannot be done in the importer (dat state-model audit 2026-06-26): - // The three tab-page content areas (0x1000022B Attributes, 0x1000022C Skills, - // 0x10000539 Titles) all have DefaultState = Undef (0) — the dat carries no - // visibility encoding for tabs. Tab visibility is managed at runtime by gmTabUI - // via SetVisible(bool) on the page containers. The controller is the correct - // and only place for initial tab-page selection. - if (layout.FindElement(NameId) is { } anchor && layout.Root is { } root) - { - foreach (var page in root.Children) - { - if (page.Children.Count == 0) continue; - page.Visible = ContainsWidget(page, anchor); - } - } - } - - // ── 9-row attribute list ───────────────────────────────────────────────── - - private static List BuildAttributeRows( - UiElement list, - UiDatFont? datFont, - Func? spriteResolve, - Func data, - int[] sel, - List allRaise1, - List allRaise10) - { - float listW = list.Width; - float y = 0f; - var rows = new List(); - - for (int i = 0; i < AttrRows.Length; i++) - { - var (rowName, iconDid) = AttrRows[i]; - int rowIndex = i; - - var row = AddRow(list, datFont, spriteResolve, - left: 0f, top: y, width: listW, height: RowHeight, - iconDid: iconDid, - nameText: rowName, - valueProvider: () => - { - var s = data(); - int v = rowIndex switch - { - 0 => s.Strength, - 1 => s.Endurance, - 2 => s.Coordination, - 3 => s.Quickness, - 4 => s.Focus, - 5 => s.Self, - _ => 0, - }; - return v.ToString(); - }); - - row.OnClick = () => HandleRowClick(rowIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10); - rows.Add(row); - y += RowHeight; - } - - for (int i = 0; i < VitalRows.Length; i++) - { - var (rowName, iconDid) = VitalRows[i]; - int rowIndex = i; - int absIndex = AttrRows.Length + i; - - var row = AddRow(list, datFont, spriteResolve, - left: 0f, top: y, width: listW, height: RowHeight, - iconDid: iconDid, - nameText: rowName, - valueProvider: () => - { - var s = data(); - return rowIndex switch - { - 0 => $"{s.HealthCurrent}/{s.HealthMax}", - 1 => $"{s.StaminaCurrent}/{s.StaminaMax}", - 2 => $"{s.ManaCurrent}/{s.ManaMax}", - _ => string.Empty, - }; - }); - - row.OnClick = () => HandleRowClick(absIndex, sel, rows, spriteResolve, data, allRaise1, allRaise10); - rows.Add(row); - y += RowHeight; - } - - return rows; - } - - /// - /// Handles a row click: toggle (same row → deselect), else select new row. - /// Updates highlight, footer providers, and raise-button state. - /// - private static void HandleRowClick( - int clickedIndex, - int[] sel, - List rows, - Func? spriteResolve, - Func data, - List allRaise1, - List allRaise10) - { - int newSel = (sel[0] == clickedIndex) ? -1 : clickedIndex; - sel[0] = newSel; - - // Log for live test confirmation (user tests selection in the studio). - string rowName = GetRowName(newSel); - Console.WriteLine($"[CharacterStat] Row click: index={clickedIndex} → selected={newSel} ({rowName})"); - - // Update highlight on all rows. - // Retail uses sprite 0x06001397 (Button state 6 — the dark horizontal bars) - // for the selected row background. When spriteResolve is available, apply the - // sprite; otherwise fall back to the translucent gold tint. - const uint HighlightSprite = 0x06001397u; - for (int i = 0; i < rows.Count; i++) - { - var row = rows[i]; - if (i == newSel) - { - if (spriteResolve is not null) - { - row.BackgroundColor = Vector4.Zero; - row.BackgroundSprite = HighlightSprite; - row.SpriteResolve = spriteResolve; - } - else - { - row.BackgroundColor = HighlightBg; - row.BackgroundSprite = 0u; - row.SpriteResolve = null; - } - } - else - { - row.BackgroundColor = Vector4.Zero; - row.BackgroundSprite = 0u; - row.SpriteResolve = null; - } - } - - // Update raise buttons. - RefreshRaiseButtons(newSel, data, allRaise1, allRaise10); - } - - /// Refresh raise button visibility + state based on current selection. - /// Applies to ALL collected raise button copies (one per tab page) so the - /// Attributes-page buttons are correctly shown/hidden regardless of which - /// copy happens to be in ImportedLayout._byId. - private static void RefreshRaiseButtons( - int selectedIndex, - Func data, - List allRaise1, - List allRaise10) - { - if (allRaise1.Count == 0 && allRaise10.Count == 0) return; - - if (selectedIndex < 0) - { - // Nothing selected: hide all raise buttons. - foreach (var b in allRaise1) b.Visible = false; - foreach (var b in allRaise10) b.Visible = false; - return; - } - - var sheet = data(); - long cost = GetRaiseCost(sheet, selectedIndex); - bool affordable = cost > 0 && sheet.UnassignedXp >= cost; - - // State "Normal" = affordable/green; "Ghosted" = too expensive or maxed. - string btnState = affordable ? StateNormal : StateGhosted; - - foreach (var b in allRaise1) { b.Visible = true; b.ActiveState = btnState; } - foreach (var b in allRaise10) { b.Visible = true; b.ActiveState = btnState; } - } - - /// Return the raise cost for row from the sheet. - /// Returns 0 if the cost array is shorter than expected. - internal static long GetRaiseCost(CharacterSheet sheet, int rowIndex) - { - if (sheet.AttributeRaiseCosts is null || rowIndex < 0 - || rowIndex >= sheet.AttributeRaiseCosts.Length) - return 0L; - return sheet.AttributeRaiseCosts[rowIndex]; - } - - /// Return the display name for the row at , - /// or an empty string if the index is out of range. - internal static string GetRowName(int index) - { - if (index < 0) return string.Empty; - if (index < AttrRows.Length) return AttrRows[index].name; - int vi = index - AttrRows.Length; - if (vi < VitalRows.Length) return VitalRows[vi].name; - return string.Empty; - } - - /// Return the numeric value for the row at . - internal static string GetRowValueString(CharacterSheet sheet, int index) - { - return index switch - { - 0 => sheet.Strength.ToString(), - 1 => sheet.Endurance.ToString(), - 2 => sheet.Coordination.ToString(), - 3 => sheet.Quickness.ToString(), - 4 => sheet.Focus.ToString(), - 5 => sheet.Self.ToString(), - 6 => $"{sheet.HealthCurrent}/{sheet.HealthMax}", - 7 => $"{sheet.StaminaCurrent}/{sheet.StaminaMax}", - 8 => $"{sheet.ManaCurrent}/{sheet.ManaMax}", - _ => string.Empty, - }; - } - - /// - /// Add a single attribute/vital row to as a - /// containing icon + name + value children. - /// Returns the panel so the caller can wire . - /// - private static UiClickablePanel AddRow( - UiElement list, - UiDatFont? datFont, - Func? spriteResolve, - float left, float top, float width, float height, - uint iconDid, - string nameText, - Func valueProvider) - { - var row = new UiClickablePanel - { - Left = left, - Top = top, - Width = width, - Height = height, - BackgroundColor = Vector4.Zero, // transparent until selected - BorderColor = Vector4.Zero, - UseSelectionBars = true, // Item 3: draw sprite as top+bottom bars, not full stretch - Anchors = AnchorEdges.Left | AnchorEdges.Top, - }; - - float iconY = (height - IconSize) * 0.5f; - - var iconEl = new UiText - { - Left = RowPadX, - Top = iconY, - Width = IconSize, - Height = IconSize, - ClickThrough = true, - DatFont = null, - BackgroundSprite = spriteResolve is not null ? iconDid : 0u, - SpriteResolve = spriteResolve is not null - ? id => { var (h, w, ht) = spriteResolve(id); return (h, w, ht); } - : null, - LinesProvider = static () => Array.Empty(), - Anchors = AnchorEdges.Left | AnchorEdges.Top, - }; - - float nameX = RowPadX + IconSize + IconGap; - float nameW = width * 0.60f; - float nameY = 0f; - - string capturedName = nameText; - var nameEl = new UiText - { - Left = nameX, - Top = nameY, - Width = nameW, - Height = height, - DatFont = datFont, - ClickThrough = true, - Centered = false, - RightAligned = false, - Padding = 1f, - Anchors = AnchorEdges.Left | AnchorEdges.Top, - }; - nameEl.LinesProvider = () => new[] { new UiText.Line(capturedName, Body) }; - - float valueW = width - nameX - nameW - RowPadX; - float valueX = nameX + nameW; - - var valueEl = new UiText - { - Left = valueX, - Top = nameY, - Width = valueW > 0f ? valueW : 40f, - Height = height, - DatFont = datFont, - ClickThrough = true, - RightAligned = true, - Anchors = AnchorEdges.Left | AnchorEdges.Top, - }; - var capturedProvider = valueProvider; - valueEl.LinesProvider = () => new[] { new UiText.Line(capturedProvider(), Body) }; - - row.AddChild(iconEl); - row.AddChild(nameEl); - row.AddChild(valueEl); - list.AddChild(row); - return row; - } - - // ── Footer — dynamic (State A + State B via sel[]) ──────────────────────── - - /// - /// Bind all 5 footer elements with providers that close over : - /// when sel[0] == -1 (nothing selected) they emit State-A content; - /// when a row is selected they emit State-B content. - /// - /// State A (DisplayDefaultFooter 0x0049cde0): - /// title = "Select an Attribute to Improve"; line-1 label = "Skill Credits Available:"; - /// line-1 value = SkillCredits; line-2 label = "Unassigned Experience:"; - /// line-2 value = UnassignedXp. - /// - /// State B (attribute selected): - /// title = "{AttrName}: {value}" (e.g. "Focus: 10"); line-1 label = "Experience To Raise:"; - /// line-1 value = raise cost; line-2 label = "Unassigned Experience:"; - /// line-2 value = UnassignedXp. - /// - /// Footer element ids appear in THREE footer-state groups (0x10000240 State A, - /// 0x10000241 State B, 0x10000247 State C). _byId stores the LAST duplicate, - /// which is the State B/C copy with narrower labels (145px vs 195px in State A). To get - /// the correct wide-label State A elements, we find the State A container directly and - /// walk to the child elements by id within that subtree. - /// - private static void BindFooterDynamic( - ImportedLayout layout, - UiDatFont? datFont, - Func data, - int[] sel) - { - // Walk the State A container (0x10000240) to find the wide-label copies of the - // footer child elements. The 5 children of 0x10000240 at their LOCAL coords: - // title (0x1000024E): Left=0, Top=0, W=300, H=55 - // line-1 label (0x10000242): Left=5, Top=20, W=195 (State A wide) vs 145 (B/C narrow) - // line-1 value (0x10000243): Left=200, Top=20, W=95 - // line-2 label (0x10000244): Left=5, Top=37, W=195 (State A wide) vs 145 (B/C narrow) - // line-2 value (0x10000245): Left=200, Top=37, W=95 - // The State B/C copies use narrower labels (145px) to accommodate raise buttons. - // - // IMPORTANT: The footer state id (0x10000240) appears once per tab-page sub-layout - // (Attributes / Skills / Titles). layout._byId stores only the LAST registered copy, - // which ends up in the LAST-imported tab page (Titles). The page-visibility pass - // hides the Titles page → the bound footer elements would be invisible. - // - // Fix: find stateA in the SAME subtree as the anchor element (NameId=0x10000231). - // The anchor is in the Attributes content area (the page the pass keeps Visible=True). - // We walk every root-level content area to find the one that contains the anchor, - // then walk that subtree for the footer container. - UiElement? stateA = null; - if (layout.FindElement(NameId) is { } anchor && layout.Root is { } visRoot) - { - // Find the root-level content page that contains the anchor. - foreach (var page in visRoot.Children) - { - if (page.Children.Count == 0) continue; - if (!ContainsWidget(page, anchor)) continue; - // This page will be Visible=True. Walk it for FooterStateAId. - stateA = FindInSubtree(page, static el => - el is UiDatElement d && d.ElementId == FooterStateAId); - break; - } - } - // Fallback: layout._byId (test layouts with a single page). - stateA ??= layout.FindElement(FooterStateAId); - - // Position-based lookup within stateA's children. - // If stateA is null or the element isn't found, falls back to layout._byId. - UiText? ByPos(float top, float left, uint fallbackId) - { - if (stateA is not null) - { - foreach (var c in stateA.Children) - if (c is UiText t - && Math.Abs(c.Top - top) < 1f - && Math.Abs(c.Left - left) < 1f) - return t; - } - return layout.FindElement(fallbackId) as UiText; - } - - // Title (Top=0, Left=0): State A = "Select an Attribute to Improve"; State B = "{name}: {value}" - // Clear BackgroundSprite on the title element: it is H=55 (full footer height) in the dat - // and its background sprite would cover the line-1/line-2 elements at local y=20/37. - // The controller owns the footer visual; the dat sprite is superfluous here. - var titleEl = ByPos(0f, 0f, FooterTitleId); - if (titleEl is not null) - { - // The dat title element is H=55 (the full footer box). The dat says VJustify=Center, so - // without an override the text would center vertically in the 55px box, overlapping - // line-1/line-2 below. We set VerticalJustify=Top explicitly so the text renders at the - // top of the 55px box (y≈Padding), keeping all three footer lines non-overlapping. - // The dat says HJustify=Center (Centered=true from BuildText) — the title is centered. - // BackgroundSprite cleared: its full-height sprite would cover line-1/line-2. - titleEl.BackgroundSprite = 0; - titleEl.VerticalJustify = VJustify.Top; // dat says Center; override to Top (see comment above) - } - // Title (FooterTitle 0x1000024E, 20px from dat): pass null → keep dat font. - // Fix C: the dat has a 20px font for the footer title. Let it drive. - if (titleEl is not null) - { - // DatFont: null → keep the build-time dat font (20px in studio, global fallback in live game). - // Centered=true comes from the dat (HJustify=Center) via BuildText — not overridden here. - // RightAligned stays false (BuildText default for a Center element). - titleEl.ClickThrough = true; - titleEl.LinesProvider = () => - { - if (sel[0] < 0) - return new[] { new UiText.Line("Select an Attribute to Improve", Body) }; - var s = data(); - string name = GetRowName(sel[0]); - string value = GetRowValueString(s, sel[0]); - // State B title is WHITE (retail confirmed). - return new[] { new UiText.Line($"{name}: {value}", Vector4.One) }; - }; - } - - // Footer lines (all dat-origin with their own font sizes): pass null → keep dat font. - var l1L = ByPos(20f, 5f, FooterLine1Label); - LabelProvider(l1L, null, Body, () => - sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:"); - - var l1V = ByPos(20f, 200f, FooterLine1Value); - LabelProvider(l1V, null, Body, () => - { - if (sel[0] < 0) return data().SkillCredits.ToString(); - long cost = GetRaiseCost(data(), sel[0]); - return cost > 0 ? cost.ToString("N0") : "Infinity!"; - }); - - // Line-2 elements: pass null → keep dat font. - var l2L = ByPos(37f, 5f, FooterLine2Label); - LabelProvider(l2L, null, Body, - static () => "Unassigned Experience:"); - - var l2V = ByPos(37f, 200f, FooterLine2Value); - LabelProvider(l2V, null, Body, - () => data().UnassignedXp.ToString("N0")); - } - - // ── Helpers ────────────────────────────────────────────────────────────── - - /// Depth-first walk of and its descendants. - /// Sets = false on every - /// whose equals . - private static void HideAllById(UiElement node, uint targetId) - { - if (node is UiDatElement d && d.ElementId == targetId) - node.Visible = false; - foreach (var child in node.Children) - HideAllById(child, targetId); - } - - /// Depth-first search of and its descendants. - /// Returns the first element for which returns true, - /// or null if none found. - private static UiElement? FindInSubtree(UiElement node, Func predicate) - { - if (predicate(node)) return node; - foreach (var child in node.Children) - { - var found = FindInSubtree(child, predicate); - if (found is not null) return found; - } - return null; - } - - private static bool ContainsWidget(UiElement node, UiElement target) - { - if (ReferenceEquals(node, target)) return true; - foreach (var c in node.Children) - if (ContainsWidget(c, target)) return true; - return false; - } - - private static void Label(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text) - { - if (layout.FindElement(id) is UiText t) - { - // Null = keep whatever the importer (dat FontDid resolver) set at build time. - // Non-null = controller explicit override. - if (datFont is not null) t.DatFont = datFont; - t.Centered = true; - t.ClickThrough = true; - t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; - } - } - - /// Two-line centered label. Provides TWO lines from LinesProvider so both - /// fit side-by-side in a narrow element without truncation. The scroll path in - /// renders multiple lines oldest-first (top-to-bottom), so - /// line 0 = (top) and line 1 = (bottom). - /// This replaces the single-line "Character Level" caption which truncated in the 65px element. - /// Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1 level caption). - private static void LabelTwoLine(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, - string line1, string line2) - { - if (layout.FindElement(id) is UiText t) - { - // Null = keep whatever the importer (dat FontDid resolver) set at build time. - if (datFont is not null) t.DatFont = datFont; - t.Centered = false; // non-Centered → scroll/multi-line path - t.RightAligned = false; - t.ClickThrough = true; - t.Padding = 1f; - t.LinesProvider = () => new[] - { - new UiText.Line(line1, color), - new UiText.Line(line2, color), - }; - } - } - - /// Left-justified label (for captions that should be left-aligned, not centered). - /// Padding=0 so a single dat-font line (≈12px) fits cleanly in a small element without - /// being clipped by the bottom-pin scroll math (top=Padding, bottom=H-Padding). - private static void LabelLeft(ImportedLayout layout, uint id, UiDatFont? datFont, Vector4 color, Func text) - { - if (layout.FindElement(id) is UiText t) - { - // Null = keep whatever the importer (dat FontDid resolver) set at build time. - if (datFont is not null) t.DatFont = datFont; - t.Centered = false; - t.RightAligned = false; - t.ClickThrough = true; - t.Padding = 0f; // avoid scroll clip in small-height header elements - t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; - } - } - - /// Bind a directly-located widget with a provider. - /// Used when the widget was found by subtree walk rather than FindElement. - /// Sets Padding = 0 to prevent the scroll-clip from hiding text in small - /// (H≈17–18px) footer elements: with the default Padding=4 and a dat font line-height - /// of ~12px the bottom-pinned baseY ends up above the top clip boundary → blank. - private static void LabelProvider(UiText? t, UiDatFont? datFont, Vector4 color, Func text) - { - if (t is null) return; - // Null = keep whatever the importer (dat FontDid resolver) set at build time. - if (datFont is not null) t.DatFont = datFont; - t.Centered = false; - t.RightAligned = false; - t.ClickThrough = true; - t.Padding = 0f; - t.LinesProvider = () => new[] { new UiText.Line(text(), color) }; - } - - /// - /// Add the three sprite pieces and the label for ONE tab to - /// as direct root children at absolute positions. Sprites go to root (not to the - /// tab group elements) so the tab groups keep Children.Count==0 and survive the - /// page-visibility pass unchanged. - /// - /// Sprite ids from the retail UI layout dump (2026-06-25), nodes - /// 0x10000439/0x100000E9/0x10000215 in layout 0x2100002E: - /// Open (active): 0x06005D92/0x06005D94/0x06005D96; - /// Closed (inactive): 0x06005D93/0x06005D95/0x06005D97. - /// - /// Tab geometry confirmed from dump: each tab = 92px wide, 25px tall. - /// Left-cap = 17px, center = 58px, right-cap = 17px (total 92px). - /// - private static void AddTabSpritesToRoot( - UiElement root, - Func spriteResolve, - UiDatFont? datFont, - float tabX, - string label, - bool isOpen) - { - uint leftId = isOpen ? TabOpenLeft : TabClosedLeft; - uint centerId = isOpen ? TabOpenCenter : TabClosedCenter; - uint rightId = isOpen ? TabOpenRight : TabClosedRight; - - // Left cap: 17×25 at (tabX, 0) - root.AddChild(MakeSpriteElement(spriteResolve, x: tabX, y: 0f, w: TabLeftCapW, h: TabH, spriteId: leftId)); - // Center: 58×25 at (tabX+17, 0) — carries the label text on top - var center = MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW, y: 0f, w: TabCenterW, h: TabH, spriteId: centerId); - root.AddChild(center); - // Right cap: 17×25 at (tabX+75, 0) - root.AddChild(MakeSpriteElement(spriteResolve, x: tabX + TabLeftCapW + TabCenterW, y: 0f, w: TabRightCapW, h: TabH, spriteId: rightId)); - - // Label text centered on the tab center piece. - // Active (Open) tab: gold; inactive (Closed) tabs: parchment body color. - // ZOrder=9 → draws on top of the sprite background (ZOrder=8) and original tab groups (ZOrder=1–3). - Vector4 labelColor = isOpen ? Gold : Body; - string capturedLabel = label; - var labelEl = new UiText - { - Left = tabX + TabLeftCapW, - Top = 0f, - Width = TabCenterW, - Height = TabH, - ZOrder = 9, - DatFont = datFont, - ClickThrough = true, - Centered = true, - Anchors = AnchorEdges.Left | AnchorEdges.Top, - }; - labelEl.LinesProvider = () => new[] { new UiText.Line(capturedLabel, labelColor) }; - root.AddChild(labelEl); - } - - /// Build a single sprite-drawing leaf at the given position. - /// ZOrder=8 places it above the original tab-group UiTexts (ZOrder=1–3) so it draws on top. - private static UiText MakeSpriteElement( - Func spriteResolve, - float x, float y, float w, float h, uint spriteId) - { - return new UiText - { - Left = x, - Top = y, - Width = w, - Height = h, - ZOrder = 8, - BackgroundSprite = spriteId, - SpriteResolve = id => { var (tex, tw, th) = spriteResolve(id); return (tex, tw, th); }, - LinesProvider = static () => System.Array.Empty(), - ClickThrough = true, - Anchors = AnchorEdges.Left | AnchorEdges.Top, - }; - } - - /// - /// Depth-first tree walk to collect every that was registered - /// in under the given dat element . - /// - /// - /// The standard returns only the LAST widget - /// registered for a given id; for elements duplicated across tab-page sub-layouts - /// (raise buttons, close buttons) we need ALL copies so that visibility changes are - /// reflected in every page — not just the last-mounted one. - /// - /// - /// - /// Implementation: walk the entire tree calling - /// is not enough (it uses a dict). Instead we - /// exploit the fact that identical element ids produce widgets that all share the SAME - /// instance in _byId for THEIR copy; but siblings from - /// different inheritance mounts are SEPARATE instances not in the same _byId slot. - /// We therefore walk the tree recursively and collect every whose - /// ActiveState reflects the dat default (before our code sets it), which is not a - /// reliable discriminator. Instead, we gather ALL instances from - /// the subtree at the known spatial position (bottom of the panel) — but positions can - /// overlap across pages. - /// - /// - /// - /// The correct approach: since _byId stores only one instance per id, we use the - /// for the canonical id, then do a FULL tree walk - /// to find ADDITIONAL instances that have identical Width×Height to - /// the known button. This works because the three page copies share the same dat template - /// and thus the same geometry. Collected via reference-equality guard to avoid duplicates. - /// - /// - private static void CollectButtonsById( - UiElement node, - uint targetId, - List result, - ImportedLayout layout) - { - // Find the canonical copy (last registered in _byId) as the geometry reference. - if (layout.FindElement(targetId) is not UiButton canonical) return; - - // Walk the tree and collect ALL UiButton instances matching the canonical geometry. - // The canonical copy itself will also be found — that's fine; use a HashSet to dedup. - var seen = new HashSet(ReferenceEqualityComparer.Instance); - CollectMatchingButtons(node, canonical, seen, result); - } - - private static void CollectMatchingButtons( - UiElement node, - UiButton canonical, - HashSet seen, - List result) - { - if (node is UiButton btn - && Math.Abs(btn.Width - canonical.Width) < 0.5f - && Math.Abs(btn.Height - canonical.Height) < 0.5f - && seen.Add(btn)) - { - result.Add(btn); - } - foreach (var child in node.Children) - CollectMatchingButtons(child, canonical, seen, result); - } - -} diff --git a/src/AcDream.App/UI/Layout/ChatWindowController.cs b/src/AcDream.App/UI/Layout/ChatWindowController.cs deleted file mode 100644 index 7726b96a..00000000 --- a/src/AcDream.App/UI/Layout/ChatWindowController.cs +++ /dev/null @@ -1,472 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.Rendering; -using AcDream.App.UI; -using AcDream.Core.Chat; -using AcDream.UI.Abstractions; -using AcDream.UI.Abstractions.Panels.Chat; - -namespace AcDream.App.UI.Layout; - -/// -/// Binds the imported chat LayoutDesc (0x21000006) to live behavior — the acdream -/// analogue of retail ChatInterface + gmMainChatUI::PostInit @0x4ce130. -/// -/// -/// The transcript (0x10000011) is Type-12 and is built as a -/// by the factory; this controller binds its live data provider in place. The input -/// (0x10000016) is also Type-12, so the factory builds it as an invisible -/// placeholder; this controller removes that placeholder and adds -/// a at the same rect. The scrollbar track (0x10000012) is -/// built directly as a by the factory (Type 11) and bound in -/// place. The channel menu (0x10000014) is built as (Type 6) -/// and bound in place. -/// -/// -public sealed class ChatWindowController -{ - public const uint LayoutId = 0x21000006u; - - // Element ids from chat LayoutDesc 0x21000006 (confirmed in Task D/G1). - private const uint RootId = 0x1000000Eu; - private const uint ResizeBarId = 0x1000000Fu; // dat top resize bar (800px — dropped; nine-slice grips replace it) - private const uint TranscriptPanelId = 0x10000010u; - private const uint TranscriptId = 0x10000011u; // Type-12 prototype — skipped by factory - private const uint TrackId = 0x10000012u; - private const uint InputBarId = 0x10000013u; - private const uint MenuId = 0x10000014u; - private const uint InputId = 0x10000016u; // Type-12 Text — factory builds UiText placeholder; Bind removes + replaces with UiField - private const uint SendId = 0x10000019u; - private const uint MaxMinId = 0x1000046Fu; - - // Scrollbar sprite ids from base layout 0x2100003E (confirmed in Task D). - private const uint TrackSprite = 0x06004C5Fu; - private const uint ThumbSprite = 0x06004C63u; // 3-slice middle tile - private const uint ThumbTopSprite = 0x06004C60u; // 3-slice top cap - private const uint ThumbBotSprite = 0x06004C66u; // 3-slice bottom cap - private const uint UpSprite = 0x06004C6Cu; // up arrow (top button) - private const uint DownSprite = 0x06004C69u; // down arrow (bottom button) - - // Chat input focused-field background (element 0x10000016 Normal_focussed state). - private const uint InputFocusField = 0x060011ABu; // gold "lit" field when in write mode - - // Channel menu sprite ids (confirmed in chat element dump). - private const uint MenuNormal = 0x06004D65u; // button face - private const uint MenuPressed = 0x06004D66u; // button pressed - private const uint MenuPopupBg = 0x0600124Cu; // popup panel fill (element 0x1000001C) - private const uint MenuItemRow = 0x0600124Eu; // item row bg (template 0x1000001E) - private const uint MenuItemSelected = 0x0600124Du; // active channel row - - // ── Public surface ───────────────────────────────────────────────────── - - /// Root element of the imported layout (the chat window chrome). - public UiElement Root { get; private set; } = null!; - - /// Live chat transcript widget. Null until succeeds. - public UiText Transcript { get; private set; } = null!; - - /// Editable chat input widget. Null until succeeds. - public UiField Input { get; private set; } = null!; - - /// Scrollbar widget, driven by 's scroll model. - public UiScrollbar Scrollbar { get; private set; } = null!; - - /// Channel-selector menu widget. - public UiMenu Menu { get; private set; } = null!; - - // ── Private state ────────────────────────────────────────────────────── - - private ChatChannelKind _activeChannel = ChatChannelKind.Say; - - // ── Channel knowledge (ported from old UiChannelMenu — gmMainChatUI::InitTalkFocusMenu @0x4cdc50) ── - - private static readonly (string Label, ChatChannelKind? Channel)[] ChannelItems = - { - ("Squelch (ignore)", null), - ("Tell to Selected", null), - ("Chat to All", ChatChannelKind.Say), - ("Tell to Fellows", ChatChannelKind.Fellowship), - ("Tell to General Chat", ChatChannelKind.General), - ("Tell to LFG Chat", ChatChannelKind.Lfg), - ("Tell to Society Chat", ChatChannelKind.Society), - ("Tell to Monarch", ChatChannelKind.Monarch), - ("Tell to Patron", ChatChannelKind.Patron), - ("Tell to Vassals", ChatChannelKind.Vassals), - ("Tell to Allegiance", ChatChannelKind.Allegiance), - ("Tell to Trade Chat", ChatChannelKind.Trade), - ("Tell to Roleplay Chat", ChatChannelKind.Roleplay), - ("Tell to Olthoi Chat", ChatChannelKind.Olthoi), - }; - - private static string ChannelButtonLabel(ChatChannelKind k) => k switch - { - ChatChannelKind.Say => "Chat", - ChatChannelKind.General => "General", - ChatChannelKind.Trade => "Trade", - ChatChannelKind.Lfg => "LFG", - ChatChannelKind.Fellowship => "Fellow", - ChatChannelKind.Allegiance => "Alleg", - ChatChannelKind.Patron => "Patron", - ChatChannelKind.Vassals => "Vassals", - ChatChannelKind.Monarch => "Monarch", - ChatChannelKind.Roleplay => "Roleplay", - ChatChannelKind.Society => "Society", - ChatChannelKind.Olthoi => "Olthoi", - _ => "Chat", - }; - - private static bool ChannelAvailable(ChatChannelKind k) - => k is ChatChannelKind.Say or ChatChannelKind.General or ChatChannelKind.Trade or ChatChannelKind.Lfg; - - /// Window height before maximize (stored to restore on un-maximize). - private float _normalHeight; - /// Window top before maximize. - private float _normalTop; - private bool _maximized; - - // ── Factory ──────────────────────────────────────────────────────────── - - /// - /// Bind an imported chat layout to live behavior. - /// - /// and must come from the - /// SAME pass (ImportInfos then Build) - /// so rects in the info tree match the widget geometry in the layout tree. - /// - /// Returns null if the essential transcript/input panels are missing from - /// the info tree or the widget tree (e.g. the layout dat is incomplete). - /// - /// Full tree from - /// . - /// Widget tree from . - /// Chat view-model (transcript data + command routing). - /// Factory that returns the live command bus at submit time. - /// Called on every chat submit so it resolves - /// even when the live session is established AFTER runs - /// (mirrors the ImGui ChatPanel which re-reads the bus each frame). - /// Retail dat font for transcript + input rendering. - /// Fallback debug bitmap font (used when - /// is null). - /// Dat RenderSurface id → (GL tex handle, px width, px height). - /// Forwarded to and . - public static ChatWindowController? Bind( - ElementInfo rootInfo, - ImportedLayout layout, - ChatVM vm, - Func busProvider, - UiDatFont? datFont, - BitmapFont? debugFont, - Func resolve) - { - // The transcript is built as a UiText by the factory (Type 12). - // The input node (0x10000016) is also Type-12 → UiText, but the controller replaces - // it with a UiField. Read its rect from the raw ElementInfo tree first. - var iInfo = FindInfo(rootInfo, InputId); - - // Their parent panels must exist as real widgets in the layout tree. - var transcriptPanel = layout.FindElement(TranscriptPanelId); - var inputBar = layout.FindElement(InputBarId); - - if (iInfo is null || transcriptPanel is null || inputBar is null) - { - Console.WriteLine( - $"[D.2b] ChatWindowController.Bind: missing required elements " + - $"(iInfo={iInfo is not null}, " + - $"panel={transcriptPanel is not null}, bar={inputBar is not null}) — " + - $"chat window will not be interactive."); - return null; - } - - // LayoutDesc 0x21000006 has SEVERAL top-level elements: the gmMainChatUI window - // (RootId 0x1000000E) PLUS stray auxiliary elements that are NOT part of the docked - // window — a separate Field+ListBox (0x1000001C/1D, the floaty scrollback), the - // talk-focus highlight strip (0x1000001E), and a scroll-button prototype (0x10000526). - // LayoutImporter.ImportInfos wraps all top-level elements in a synthetic Type-3 root, - // so using layout.Root would render the strays overlapping the real window (the - // red-striped garbage in the first live render). Use the gmMainChatUI window itself: - // GameWindow adds this to the host, which re-parents it out of the synthetic wrapper, - // orphaning the strays so they never draw. - var window = layout.FindElement(RootId) ?? layout.Root; - var c = new ChatWindowController { Root = window }; - - // Drop the dat top resize bar (0x1000000F): it is authored 800px wide and - // juts out of the content-width window. The host wraps this content in the - // universal nine-slice chrome, whose grips provide the resize affordance. - if (layout.FindElement(ResizeBarId) is { Parent: { } rbParent } resizeBar) - rbParent.RemoveChild(resizeBar); - - // Reclaim the 9px strip the dropped resize bar occupied (rows 0-8 of the root): - // grow the transcript panel up to the window top so its dark bg fills the strip. - // Otherwise the root element's brown bg shows through as a sliver along the top. - transcriptPanel.Top = 0f; - transcriptPanel.Height += 9f; // dat resize-bar height (0x1000000F H=9) - - // ── Transcript ─────────────────────────────────────────────────── - // The factory now builds the Type-12 transcript element (0x10000011) as a UiText. - // Find it in the widget tree and bind the live providers — no remove/add needed. - c.Transcript = layout.FindElement(TranscriptId) as UiText - ?? throw new InvalidOperationException("chat transcript 0x10000011 not built as UiText"); - c.Transcript.DatFont = datFont; - c.Transcript.Font = debugFont; - c.Transcript.BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f); // retail translucent transcript - c.Transcript.LinesProvider = () => BuildLines(vm, c.Transcript, datFont, debugFont); - - // ── Input ──────────────────────────────────────────────────────── - // The input element (0x10000016) resolves to Type-12 Text, so the factory built it - // as an unbound (invisible) UiText placeholder in the input bar. The editable entry - // is a controller-placed UiField at the same rect — drop the placeholder, add the field. - if (layout.FindElement(InputId) is { Parent: { } inParent } inputPlaceholder) - inParent.RemoveChild(inputPlaceholder); - c.Input = new UiField - { - Left = iInfo.X, - Top = iInfo.Y, - Width = iInfo.Width, - Height = iInfo.Height, - Anchors = ElementReader.ToAnchors(iInfo.Left, iInfo.Top, iInfo.Right, iInfo.Bottom), - DatFont = datFont, - Font = debugFont, - BackgroundColor = new Vector4(0f, 0f, 0f, 0.35f), // retail translucent unfocused field - SpriteResolve = resolve, - FocusFieldSprite = InputFocusField, - }; - inputBar.AddChild(c.Input); - c.Input.OnSubmit = text => ChatCommandRouter.Submit(text, vm, busProvider(), c._activeChannel); - - // ── Scrollbar — bind the factory-built Type-11 track element ──────── - // The factory now builds the Type-11 track element (0x10000012) as a UiScrollbar - // directly. Find it, bind it in place — no remove/add needed. - var track = layout.FindElement(TrackId); - if (track is UiScrollbar bar) - { - float oldTop = bar.Top; - bar.Top = 0f; // pull up to the panel top (resize-bar reclaim) - bar.Height = bar.Height + oldTop; - bar.Model = c.Transcript.Scroll; - bar.SpriteResolve = resolve; - bar.TrackSprite = TrackSprite; - bar.ThumbSprite = ThumbSprite; - bar.ThumbTopSprite = ThumbTopSprite; - bar.ThumbBotSprite = ThumbBotSprite; - bar.UpSprite = UpSprite; - bar.DownSprite = DownSprite; - c.Scrollbar = bar; - } - - // ── Channel menu — bind the factory-built Type-6 UiMenu ────────── - if (layout.FindElement(MenuId) is UiMenu menu) - { - menu.DatFont = datFont; menu.Font = debugFont; menu.SpriteResolve = resolve; - menu.NormalSprite = MenuNormal; menu.PressedSprite = MenuPressed; - menu.PopupBgSprite = MenuPopupBg; - menu.ItemNormalSprite = MenuItemRow; menu.ItemHighlightSprite = MenuItemSelected; - menu.Items = System.Array.ConvertAll(ChannelItems, - t => new UiMenu.MenuItem(t.Label, (object?)t.Channel)); - menu.Selected = (object?)c._activeChannel; - // Specials (Squelch / Tell-to-Selected, null payload) render WHITE/enabled like - // retail; only the talk-CHANNEL items grey when unavailable. - menu.EnabledProvider = p => p is not ChatChannelKind ch || ChannelAvailable(ch); - menu.ButtonLabelProvider = () => ChannelButtonLabel(c._activeChannel); - // The widget reports the pick; the controller owns Selected. Only a talk-channel - // payload updates the active channel + highlight — the null-payload specials are - // deferred no-ops (see the chat re-drive deferred list) and leave selection intact. - menu.OnSelect = p => - { - if (p is ChatChannelKind ch) { c._activeChannel = ch; menu.Selected = p; } - }; - c.Menu = menu; - } - - // ── Send button — Enter-alternate submit trigger ────────────────── - // Retail's gmMainChatUI wires the Send button to the same ProcessCommand path. - if (layout.FindElement(SendId) is UiButton sendEl) - { - sendEl.OnClick = () => c.Input.Submit(); - // The Send sprite is a blank gold button — retail draws the caption as text. - sendEl.Label = "Send"; - sendEl.LabelFont = datFont; - sendEl.LabelColor = new Vector4(1f, 0.92f, 0.72f, 1f); - } - - // ── Size the channel button to its label + reflow the input field ─ - // Retail's talk-focus button autosizes to the selected channel name; the input - // field then fills the gap from the button's right edge to the Send button. The - // dat authors the button at a fixed 46px (too narrow for "Chat" once the LED + - // arrow are accounted for), so widen it to its content and shift the input. - // Recompute on every channel change (the button grows/shrinks with the label). - if (c.Menu is not null) - { - float inputRight = c.Input.Left + c.Input.Width; // == Send button's left edge - void ReflowInputRow() - { - c.Menu.Width = System.MathF.Round(c.Menu.NaturalButtonWidth()); - c.Menu.ResetAnchorCapture(); - c.Input.Left = c.Menu.Left + c.Menu.Width; - c.Input.Width = System.MathF.Max(40f, inputRight - c.Input.Left); - c.Input.ResetAnchorCapture(); - } - var onSelect = c.Menu.OnSelect; - c.Menu.OnSelect = p => { onSelect?.Invoke(p); ReflowInputRow(); }; - ReflowInputRow(); - } - - // ── Max/min toggle — simplified gmMainChatUI::HandleMaximizeButton ── - if (layout.FindElement(MaxMinId) is UiButton maxMinEl) - { - // The dat puts max/min and the scrollbar up-button at the SAME X (both - // right-anchored), so at content width they overlap. Retail shows max/min - // just LEFT of the scrollbar column — shift it one button-width left. - if (track is not null) - maxMinEl.Left = track.Left - maxMinEl.Width; - maxMinEl.OnClick = c.ToggleMaximize; - } - - return c; - } - - // ── Max/min implementation ───────────────────────────────────────────── - - /// - /// Toggle between the normal chat window height and an expanded 320px height. - /// Simplified port of retail gmMainChatUI::HandleMaximizeButton @0x4cddb0: - /// retail stores the pre-maximize height and restores it on a second click. - /// The 320px expanded size is the approximate retail maximized chat height. - /// - private void ToggleMaximize() - { - if (!_maximized) - { - _normalHeight = Root.Height; - _normalTop = Root.Top; - // Expand upward: move the top edge up so the bottom stays anchored. - Root.Top = MathF.Max(0f, Root.Top + Root.Height - 320f); - Root.Height = 320f; - _maximized = true; - } - else - { - Root.Top = _normalTop; - Root.Height = _normalHeight; - _maximized = false; - } - } - - // ── Helpers ──────────────────────────────────────────────────────────── - - /// - /// Depth-first search for an node by id in the - /// raw info tree (which contains ALL elements, including the Type-12 skipped ones). - /// - private static ElementInfo? FindInfo(ElementInfo node, uint id) - { - if (node.Id == id) return node; - foreach (var child in node.Children) - { - var found = FindInfo(child, id); - if (found is not null) return found; - } - return null; - } - - /// - /// Convert the ChatVM's detailed lines to the transcript's - /// record format, applying retail-faithful - /// per- colors. - /// - private static IReadOnlyList BuildLines( - ChatVM vm, UiText view, UiDatFont? datFont, BitmapFont? debugFont) - { - var detailed = vm.RecentLinesDetailed(); - if (detailed.Count == 0) return Array.Empty(); - - // Word-wrap each message to the transcript's current pixel width (ports retail - // GlyphList::Recalculate @0x473800 — break at word boundaries when the line would - // exceed wrapWidth). Re-evaluated each frame so wrapping follows window resize. - float maxW = view.Width - 2f * view.Padding; - Func measure = - datFont is { } df ? s => df.MeasureWidth(s) - : debugFont is { } bf ? s => bf.MeasureWidth(s) - : s => s.Length * 7f; - - var result = new List(detailed.Count); - foreach (var d in detailed) - { - var color = RetailChatColor(d.Kind); - foreach (var frag in WrapText(d.Text, maxW, measure)) - result.Add(new UiText.Line(frag, color)); - } - return result; - } - - /// - /// Greedy word-wrap: split into fragments that each fit in - /// pixels (per ), breaking at spaces. - /// A word that is itself wider than the line is broken at CHARACTER boundaries (no - /// hyphen), packed onto the current line first — so a long unbroken token (e.g. a URL - /// or "wwwww…") wraps instead of overflowing, and a "You say," prefix stays on the same - /// row as the start of the message. Mirrors retail GlyphList::Recalculate's per-GlyphLine - /// emission (which breaks mid-glyph-run when a run exceeds the wrap width). - /// - public static IEnumerable WrapText(string text, float maxW, Func measure) - { - if (string.IsNullOrEmpty(text) || maxW <= 0f || measure(text) <= maxW) - { - yield return text ?? string.Empty; - yield break; - } - - var line = new System.Text.StringBuilder(); - foreach (var word in text.Split(' ')) - { - string sep = line.Length > 0 ? " " : string.Empty; - if (measure(line.ToString() + sep + word) <= maxW) - { - line.Append(sep).Append(word); // fits on the current line - continue; - } - if (line.Length > 0 && measure(word) <= maxW) - { - yield return line.ToString(); // word fits alone → push to a new line - line.Clear(); - line.Append(word); - continue; - } - // Word too long for any single line: char-wrap it, packing onto the current - // line's remaining space first (keeps the prefix with the message start). - if (line.Length > 0) line.Append(' '); - foreach (char ch in word) - { - if (line.Length > 0 && measure(line.ToString() + ch) > maxW) - { - yield return line.ToString(); - line.Clear(); - } - line.Append(ch); - } - } - if (line.Length > 0) yield return line.ToString(); - } - - /// - /// Per- text color — the EXACT retail RGBA values read from a - /// live retail client via cdb (the named RGBAColor constants at acclient - /// 0x81c4a8+, e.g. colorWhite/colorBrightPurple/colorLightBlue/ - /// colorGreen, used by ChatInterface::BuildChatColorLookupTable @0x4f31c0). - /// The four common kinds (speech/tell/channel/system) are confirmed by the named - /// symbols + universal AC convention; the rarer kinds map to the nearest named color. - /// - private static Vector4 RetailChatColor(ChatKind kind) => kind switch - { - ChatKind.LocalSpeech => new(1f, 1f, 1f, 1f), // colorWhite - ChatKind.RangedSpeech => new(1f, 1f, 1f, 1f), // colorWhite (shout) - ChatKind.Channel => new(0.247f, 0.749f, 1f, 1f), // colorLightBlue - ChatKind.Tell => new(1f, 0.498f, 1f, 1f), // colorBrightPurple - ChatKind.System => new(0.5f, 1f, 0.498f, 1f), // colorGreen - ChatKind.Popup => new(0.5f, 1f, 0.498f, 1f), // colorGreen (server broadcast) - ChatKind.Emote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey - ChatKind.SoulEmote => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey - ChatKind.Combat => new(0.96f, 0.459f, 0.447f, 1f), // colorLightRed - _ => new(0.824f, 0.824f, 0.784f, 1f), // colorGrey (fallback) - }; -} diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs deleted file mode 100644 index 973fba79..00000000 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ /dev/null @@ -1,314 +0,0 @@ -using System; -using System.Linq; -using AcDream.App.UI; - -namespace AcDream.App.UI.Layout; - -/// -/// Hybrid factory: behavioral element Types map to dedicated widgets (verbatim -/// algorithm ports); everything else (and unknown Types) falls back to -/// . -/// -/// -/// Type 12 = UIElement_Text — a scrollable colored-line text view. Every Type-12 -/// element is now built as a . Elements that carry their own -/// dat sprite media keep it as the . Pure -/// prototype elements (no state media, no controller binding) draw nothing because -/// defaults to transparent. -/// -/// -/// -/// The meter's back/front 3-slice sprite ids live on grandchild image elements, -/// NOT on the meter element itself (format doc §11). -/// walks two layers down to extract them: the two Type-3 container children -/// ordered by (back behind = lower, front -/// on top = higher), then within each container the image children that carry -/// a DirectState ("" key) sprite, ordered by their X position to obtain -/// left-cap / center-tile / right-cap. -/// -/// -/// -/// The expand-detail overlay present in the front container carries ONLY named -/// states ("HideDetail"/"ShowDetail") — no "" DirectState entry — so the -/// TryGetValue("") filter in excludes it -/// automatically. -/// -/// -public static class DatWidgetFactory -{ - /// - /// Creates the for , sets its - /// rect (Left/Top/Width/Height) and Anchors, and returns it. - /// - /// Resolved, merged element snapshot from the LayoutDesc importer. - /// RenderSurface id → (GL tex handle, pixel width, pixel height). - /// Returns (0,0,0) when the texture is not yet uploaded. - /// Retail UI font for the meter's "cur/max" number overlay. - /// May be null pre-load — the meter falls back to the debug bitmap font. - /// Optional font resolver: FontDid → - /// (or null when the font can't be loaded). When non-null, any element whose - /// is non-zero gets ITS OWN dat font applied instead of - /// the shared fallback. Null = original behavior (use - /// for every element). - /// The widget for this element. Never null — every type produces a widget. - public static UiElement? Create(ElementInfo info, - Func resolve, UiDatFont? datFont, - Func? fontResolve = null) - { - // Retail Type 3 = UIElement_Field (reg :126190), but in acdream's CURRENT layouts - // (vitals 0x2100006C / chat 0x21000006) Type-3 elements are sprite-bearing chrome + - // containers (the 8-piece bevel corners/edges, the transcript/input panels), NOT - // editable fields — retail draws those as inert media-bearing Fields, which our - // UiDatElement reproduces pixel-for-pixel (and without the spurious focus/edit - // affordance a UiField would add). The one true editable field, the chat input - // (0x10000016), resolves to Type 12 and is controller-placed as a UiField. So Type 3 - // stays on the generic fallback here; register it as UiField only when a window - // actually carries a factory-built editable Type-3 field (and UiField grows a - // background-media draw + an opt-in editable flag at that point). UiField (the widget) - // still ships — it just isn't wired into the factory switch yet. - // Resolve this element's own dat font if a resolver is provided and the element - // has a FontDid. Falls back to the shared datFont when not set (FontDid==0) or - // when the resolver returns null (font missing from dats). - UiDatFont? elementFont = datFont; - if (fontResolve is not null && info.FontDid != 0) - elementFont = fontResolve(info.FontDid) ?? datFont; - - UiElement e = info.Type switch - { - 1 => new UiButton(info, resolve), // UIElement_Button (reg :125828) - 6 => new UiMenu(), // UIElement_Menu (reg :120163) - 7 => BuildMeter(info, resolve, elementFont), // UIElement_Meter - 0xD => new UiViewport(), // UIElement_Viewport — 3-D mini-scene blit leaf - 11 => new UiScrollbar(), // UIElement_Scrollbar (reg :124137) - 12 => BuildText(info, resolve, elementFont), // UIElement_Text (reg :115655) - 0x10000031u => new UiItemList(resolve), // UIElement_ItemList — toolbar/inventory/paperdoll slots - _ => new UiDatElement(info, resolve), // generic fallback (incl. Type 3 chrome/containers) - }; - - // Propagate position + size (pixel-exact from the dat). - e.Left = info.X; - e.Top = info.Y; - e.Width = info.Width; - e.Height = info.Height; - - // Honor the dat's draw order. ZLevel is the primary layer (higher = further BACK — e.g. the - // gmInventoryUI full-window backdrop at ZLevel 100 sits behind the ZLevel-0 panels, #145); - // ReadOrder is the within-layer tiebreaker (higher = on top). K=10000 exceeds any window's - // element count so ZLevel always dominates. Vitals (all ZLevel 0) keep ZOrder == ReadOrder. - e.ZOrder = (int)info.ReadOrder - (int)info.ZLevel * 10000; - - // Map the four raw edge-anchor values to the AnchorEdges bit-flag that the - // UI layout engine uses for reflow. - e.Anchors = ElementReader.ToAnchors(info.Left, info.Top, info.Right, info.Bottom); - - return e; - } - - // ── Meter ──────────────────────────────────────────────────────────────── - - /// - /// Builds a and populates its sprite ids from the meter's - /// child/grandchild elements (format doc §11). Two shapes are handled: - /// - /// - /// 3-slice shape (vitals meters — 2 Type-3 containers, each with 3 image grandchildren): - /// - /// meter (Type 7) - /// ├── back-layer container (Type 3, lower ReadOrder — drawn first / behind) - /// │ ├── left-cap image (DirectState "" → File = back-left sprite) - /// │ ├── center image (DirectState "" → File = back-tile sprite) - /// │ └── right-cap image (DirectState "" → File = back-right sprite) - /// ├── front-layer container (Type 3, higher ReadOrder — drawn on top) - /// │ ├── left-cap image (→ front-left sprite) - /// │ ├── center image (→ front-tile sprite) - /// │ ├── right-cap image (→ front-right sprite) - /// │ └── expand overlay (named "ShowDetail"/"HideDetail" only — NO DirectState — IGNORED) - /// └── text label (Type 0) (IGNORED — Fill/Label providers bound by VitalsController) - /// - /// - /// - /// - /// Single-image shape (toolbar selected-object meters 0x100001A1/0x100001A2 — 1 Type-3 - /// child, no grandchildren): the back-track sprite is on the meter element's own DirectState; - /// the fill sprite is on the single Type-3 child's own DirectState. Both are placed in the - /// TILE slot (Back/FrontTile) with left/right caps 0, so tiles - /// them across the full bar geometry (DrawMode=Normal) and clips the fill to the fraction. - /// (retail: gmToolbarUI::HandleSelectionChanged :198635, UIElement_Meter::Initialize :123328) - /// - /// meter (Type 7) [DirectState "" → back-track sprite, e.g. 0x0600193E] - /// └── fill container (Type 3) [DirectState "" → fill sprite, e.g. 0x0600193F] - /// - /// - /// - /// - /// and are NOT set here. - /// They are bound to the live stat providers by the controller (VitalsController / - /// SelectedObjectController). - /// - /// - private static UiMeter BuildMeter(ElementInfo info, - Func resolve, UiDatFont? datFont) - { - var m = new UiMeter - { - SpriteResolve = resolve, - DatFont = datFont, - }; - - // The two 3-slice containers are Type-3 children of the meter element. - // ReadOrder determines draw order: the back track has a LOWER ReadOrder - // (drawn first, behind the fill), the front has a HIGHER ReadOrder (on top). - var containers = info.Children - .Where(c => c.Type == 3) - .OrderBy(c => c.ReadOrder) - .ToList(); - - if (containers.Count >= 2) - { - // Vitals 3-slice shape: two Type-3 containers each holding 3 grandchild images - // (left-cap / center-tile / right-cap). Back is the lower ReadOrder; front is higher. - var (bl, bt, br) = SliceIds(containers[0]); - m.BackLeft = bl; - m.BackTile = bt; - m.BackRight = br; - - var (fl, ft, fr) = SliceIds(containers[1]); - m.FrontLeft = fl; - m.FrontTile = ft; - m.FrontRight = fr; - } - else if (containers.Count == 1) - { - // Single-image shape used by the toolbar selected-object meters - // (health 0x100001A1, mana 0x100001A2). - // - The back-track sprite lives on the meter ELEMENT's own DirectState ("" key of - // info.StateMedia) — not on any grandchild image. e.g. health back = 0x0600193E. - // - The fill sprite lives on the single Type-3 child's own DirectState ("" key of - // containers[0].StateMedia). e.g. health fill = 0x0600193F. - // The fill child has NO image grandchildren, so SliceIds would return all-zero — - // read the container's StateMedia directly instead. - // - // These go in the TILE slot (not the left-cap slot): the sprites are DrawMode=Normal, - // which retail renders as "tile at native width to fill the full element geometry" - // (format doc §6; the generic UiDatElement.OnDraw Normal path; UIElement_Meter:: - // DrawChildren :123574 clips the child's FULL 140px geometry box to the fill fraction). - // With the sprite on BackLeft instead, UiMeter.DrawHBar would clamp the cap to the - // sprite's NATIVE width (capL = min(nativeW, 140)) — leaving a right-side gap and - // mapping the fill fraction to native width when nativeW < 140. The tile slot makes - // midW = full bar width, so the back tiles across all 140px and the front clips to - // 140*fraction correctly for any native sprite width (left/right caps unused = 0). - // (retail: gmToolbarUI::HandleSelectionChanged :198635 / UIElement_Meter::DrawChildren :123574) - m.BackLeft = 0; - m.BackTile = info.StateMedia.TryGetValue("", out var bm) ? bm.File : 0u; - m.BackRight = 0; - - m.FrontLeft = 0; - m.FrontTile = containers[0].StateMedia.TryGetValue("", out var fm) ? fm.File : 0u; - m.FrontRight = 0; - } - else - { - // Count == 0: no Type-3 containers at all — genuinely malformed meter dat. - Console.WriteLine($"[D.2b] meter 0x{info.Id:X8}: {containers.Count} Type-3 slice containers (expected 1 or 2) — bars may render as solid-color fallback."); - } - - return m; - } - - /// - /// Returns the (left, tile, right) sprite ids for a 3-slice container, - /// extracting them from the container's image children that carry a DirectState - /// ("" key) with a non-zero file id, ordered left-to-right by their X position. - /// - /// - /// Children that carry ONLY named states (e.g. the expand-detail overlay with - /// "ShowDetail"/"HideDetail" entries but no "" key) are excluded automatically - /// because for "" returns - /// false. - /// - /// - private static (uint left, uint tile, uint right) SliceIds(ElementInfo container) - { - // Only children that have a non-zero DirectState image are slice candidates. - // The expand-detail overlay has NO DirectState entry, so it's excluded here. - // Project the File during filtering to avoid a second TryGetValue lookup. - // Stable sort: on an X tie, original Children insertion order (dat key-sort order) wins. - var slices = container.Children - .Where(c => c.StateMedia.TryGetValue("", out var med) && med.File != 0) - .Select(c => (c.X, File: c.StateMedia[""].File)) - .OrderBy(t => t.X) - .ToList(); - - uint left = slices.Count > 0 ? slices[0].File : 0u; - uint tile = slices.Count > 1 ? slices[1].File : 0u; - uint right = slices.Count > 2 ? slices[2].File : 0u; - - return (left, tile, right); - } - - // ── Text ───────────────────────────────────────────────────────────────── - - /// Type-12 UIElement_Text: a scrollable colored-line text view. The element's - /// own Direct/Normal media (if any) becomes the background sprite, drawn under the text — - /// so a Type-12 element that previously rendered via UiDatElement keeps its sprite. Lines - /// are bound later by the controller (LinesProvider). An unbound UiText draws nothing - /// because defaults to transparent. - /// - /// - /// Justification from the dat ( / - /// ) is applied here at build time so that controllers - /// that subsequently call / - /// on dat-origin elements can be simplified. Controllers that explicitly set those - /// properties after still override the build-time - /// defaults — the build-time value is just the starting point, not a lock. - /// - /// - /// The font to seed on the widget. When a font resolver was - /// provided and the element's FontDid resolved successfully, this is that element-specific - /// font; otherwise it is the shared global fallback. Controllers that call - /// and set afterward - /// still override this — the build-time value is just the starting point. - private static UiText BuildText(ElementInfo info, Func resolve, - UiDatFont? elementFont = null) - { - uint bg = info.StateMedia.TryGetValue( - !string.IsNullOrEmpty(info.DefaultStateName) ? info.DefaultStateName - : info.StateMedia.ContainsKey("Normal") ? "Normal" : "", out var m) - ? m.File : 0u; - - // Apply horizontal + vertical justification from the dat at build time. - // Controllers that call FindElement and set Centered/RightAligned/VerticalJustify - // afterward will override these — this is only the dat-driven default. - bool centered = info.HJustify == HJustify.Center; - bool rightAligned = info.HJustify == HJustify.Right; - var vJustify = info.VJustify switch - { - VJustify.Top => VJustify.Top, - VJustify.Bottom => VJustify.Bottom, - _ => VJustify.Center, - }; - - var t = new UiText - { - BackgroundSprite = bg, - SpriteResolve = resolve, - Centered = centered, - RightAligned = rightAligned, - VerticalJustify = vJustify, - // Seed the dat-driven font. When a font resolver was supplied and the element - // carries a non-zero FontDid, elementFont is the element-specific dat font; otherwise - // it is the shared global fallback. Controllers that call FindElement and explicitly - // set DatFont afterward STILL override this (backward-compat guarantee). - DatFont = elementFont, - }; - - // Font color from dat property 0x1B (ColorBaseProperty). - // When present, seed DefaultColor so controllers that read it don't have to hard-code colors. - // Controllers that supply explicit per-line colors via LinesProvider still win — this is only - // the build-time default. - if (info.FontColor.HasValue) - t.DefaultColor = info.FontColor.Value; - - return t; - } -} diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs deleted file mode 100644 index cb4b1552..00000000 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ /dev/null @@ -1,223 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; - -namespace AcDream.App.UI.Layout; - -/// -/// Horizontal text justification read from dat property 0x14 (UIElement HorizontalJustification). -/// Values match the retail enum: 0=Left, 1=Center, 3/5=Right. -/// -public enum HJustify : byte { Left = 0, Center = 1, Right = 2 } - -/// -/// Vertical text justification read from dat property 0x15 (UIElement VerticalJustification). -/// Values: 2=Top, 4=Bottom; absent/other = Center. -/// -public enum VJustify : byte { Top = 0, Center = 1, Bottom = 2 } - -/// -/// GL-free, dat-free snapshot of a resolved layout element. -/// Populated by the LayoutDesc importer from DatReaderWriter.ElementDesc -/// after inheritance is applied. The pure transforms on -/// operate on this type so they can be unit-tested without the dats or OpenGL. -/// -/// IMPORTANT: Tasks 3–6 depend on this shape exactly. Do not add members without -/// updating the plan spec and downstream consumers. -/// -public sealed class ElementInfo -{ - /// Dat element id (e.g. 0x100000E6). - public uint Id; - - /// - /// Raw element class id as a uint. - /// Game-specific ids like 0x1000004D (gmVitalsUI root) and 0x10000009 - /// overflow int when treated as signed, so this stays uint. - /// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter, - /// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root. - /// - public uint Type; - - /// Position and size within the parent, in pixels (cast from dat uint fields). - public float X, Y, Width, Height; - - /// - /// Raw edge-anchor flag values from the dat (LeftEdge, TopEdge, - /// RightEdge, BottomEdge fields of ElementDesc). - /// Values 0–4; map to bit-flags via - /// . - /// - public uint Left, Top, Right, Bottom; - - /// Draw order within the parent (lower = drawn first / behind). - public uint ReadOrder; - - /// Layer level from the dat (ElementDesc.ZLevel). Higher = drawn further - /// BACK (a full-window backdrop at ZLevel 100 sits behind ZLevel-0 panels). The factory - /// folds it into so ZLevel dominates and ReadOrder is the - /// within-layer tiebreaker. Issue #145 (vitals are all ZLevel 0, so they're unaffected). - public uint ZLevel; - - /// - /// Font dat object id inherited from the base element's Properties[0x1A] - /// (ArrayBaseProperty → DataIdBaseProperty). 0 = none / not inherited. - /// - public uint FontDid; - - /// - /// Horizontal text justification from dat Properties[0x14] - /// (EnumBaseProperty: 0=Left, 1=Center, 3/5=Right). - /// Default is to preserve existing behavior where - /// controllers set Centered=true and no property was read. - /// - public HJustify HJustify = HJustify.Center; - - /// - /// Vertical text justification from dat Properties[0x15] - /// (EnumBaseProperty: 2=Top, 4=Bottom; absent/other = Center). - /// Default is to preserve existing behavior. - /// - public VJustify VJustify = VJustify.Center; - - /// - /// Font color from dat Properties[0x1B] (ColorBaseProperty, ARGB bytes). - /// Null when the dat carries no color for this element; the factory then leaves the - /// widget at its default white (). - /// Propagated in with the same "non-null derived wins" - /// rule used for and . - /// - public Vector4? FontColor; - - /// - /// Sprite per state: state name → (RenderSurface file id, DrawMode int). - /// The "" key represents the unnamed DirectState (ElementDesc.StateDesc). - /// Named states use the UIStateId.ToString() value as the key - /// (e.g. "HideDetail", "ShowDetail"). - /// - public Dictionary StateMedia = new(); - - /// - /// The element's initial active state name, taken from ElementDesc.DefaultState.ToString(). - /// Normalized to "" when the dat carries Undef/Undefined/0 (no default set). - /// Used by to pick which state's sprite to render initially. - /// Examples: "Normal" (Send button), "Minimized" (max/min button), "" (DirectState). - /// - public string DefaultStateName = ""; - - /// - /// Resolved child elements (populated by the importer in Task 5). - /// Children come from the derived element's own tree, not the base element's. - /// - public List Children = new(); -} - -/// -/// Pure, GL-free, dat-free transforms for the LayoutDesc importer. -/// All methods are static and operate on POCOs. -/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond -/// the bit-flag enum from AcDream.App.UI. -/// -public static class ElementReader -{ - /// Edge-anchor flags → AnchorEdges, per retail UIElement::UpdateForParentSizeChange - /// @0x00462640. The far-axis fields drive stretch: RightEdge==1 ⇒ the right edge tracks the - /// parent's right edge (stretch); LeftEdge==2 ⇒ a fixed-width element's left tracks the right - /// edge (it moves right). ==4 (not present in the vitals layout) = both-sides stretch; ==3 = - /// centered (no edge anchor → falls back to pin-top-left). This is the INVERSE of the earlier - /// format-doc §4 reading, which was wrong (it made every piece fixed-width). - /// LeftEdge dat field value (0–4). - /// TopEdge dat field value (0–4). - /// RightEdge dat field value (0–4). - /// BottomEdge dat field value (0–4). - public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom) - { - var a = AnchorEdges.None; - if (left == 1 || left == 4) a |= AnchorEdges.Left; - if (right == 1 || right == 4 || left == 2) a |= AnchorEdges.Right; - if (top == 1 || top == 4) a |= AnchorEdges.Top; - if (bottom == 1 || bottom == 4 || top == 2) a |= AnchorEdges.Bottom; - if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left - return a; - } - - /// - /// Merges a base element snapshot with a derived element snapshot, mirroring - /// the BaseElement / BaseLayoutId inheritance chain in the dat. - /// - /// - /// Rules: - /// - /// - /// Scalar fields (, , - /// , , - /// ): derived wins if non-zero; otherwise - /// inherited from base. - /// - /// - /// Position (, ) and - /// edge flags ( etc.) and - /// : always taken from the derived element - /// (derived placement, not the base prototype's geometry). - /// - /// - /// : base entries are the default; derived - /// entries override (or add) per state name key. - /// - /// - /// : come from the derived element's own tree only. - /// - /// - /// - /// - public static ElementInfo Merge(ElementInfo base_, ElementInfo derived) - { - var m = new ElementInfo - { - Id = derived.Id != 0 ? derived.Id : base_.Id, - // Type: derived wins if non-zero; Type 0 (text element per format §8) inherits the base's Type. - // For a text element whose base prototype is Type 12 (style prototype), this yields Type 12 — - // which DatWidgetFactory skips (returns null). That is intentional for Plan 1: vitals text - // numbers render via UiMeter.Label bound by VitalsController, not a dat text node. - // A Plan-2 standalone text element would need a type-preserving path (e.g. float? nullable - // Width/Height, or explicit handling of Type 0 before the merge). - Type = derived.Type != 0 ? derived.Type : base_.Type, - X = derived.X, - Y = derived.Y, - // NOTE: 0 is the "not set, inherit from base" sentinel for Width/Height. This - // diverges from the format doc §12 rule 2 ("derived W/H win even if zero") but is - // indistinguishable for Plan 1 (all base elements are zero-size Type-12 prototypes). - // If a real zero-size derived element ever needs to override a non-zero base in - // Plan 2, switch Width/Height to float? + null-coalescing (and update Tasks 3-5). - Width = derived.Width != 0 ? derived.Width : base_.Width, - Height = derived.Height != 0 ? derived.Height : base_.Height, - Left = derived.Left, - Top = derived.Top, - Right = derived.Right, - Bottom = derived.Bottom, - ReadOrder = derived.ReadOrder, - ZLevel = derived.ZLevel != 0 ? derived.ZLevel : base_.ZLevel, - FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid, - // HJustify/VJustify: derived wins when it carries an explicit non-Center value - // (the dat property was present and read); otherwise inherit the base prototype's value. - // Center is the default (= "not set by this element") so Center-derived never overrides - // a non-Center base — matching the FontDid "non-zero wins" convention. - HJustify = derived.HJustify != HJustify.Center ? derived.HJustify : base_.HJustify, - VJustify = derived.VJustify != VJustify.Center ? derived.VJustify : base_.VJustify, - // FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base. - // Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base. - FontColor = derived.FontColor ?? base_.FontColor, - // DefaultStateName: derived wins if set; otherwise inherit the base's default. - DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, - // Children come from the derived element's own tree, not the base prototype's. - // Defensive copy: prevent a later mutation of either the merged result or the input - // from corrupting the other. Safe for the Task-5 flow (derived.Children is fully - // populated by the recursive importer BEFORE Merge is called and never mutated after). - Children = new List(derived.Children), - }; - // Start with base StateMedia as defaults, then let derived entries override. - m.StateMedia = new Dictionary(base_.StateMedia); - foreach (var kv in derived.StateMedia) - m.StateMedia[kv.Key] = kv.Value; - return m; - } -} diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs deleted file mode 100644 index 89bf6b1c..00000000 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ /dev/null @@ -1,491 +0,0 @@ -using System; -using System.Numerics; -using AcDream.App.UI; -using AcDream.Core.Items; - -namespace AcDream.App.UI.Layout; - -/// -/// Binds the imported gmInventoryUI tree (LayoutDesc 0x21000023) and populates it from -/// . The acdream analogue of retail -/// gmInventoryUI/gmBackpackUI/gm3DItemsUI ::PostInit (named-retail decomp 176236/176596/176728). -/// Container-switching is live (click a side bag → Use 0x0036 → ViewContents 0x0196 full-replace); -/// drag-into-bag / wield-drop wire are later sub-phases. -/// -public sealed class InventoryController : IItemListDragHandler -{ - // Element ids — spec §1 (dat dump of 0x21000022 / 0x21000021 + the *::PostInit binds). - public const uint ContentsGridId = 0x100001C6u; // gm3DItemsUI m_itemList ("Contents of Backpack") - public const uint ContainerListId = 0x100001CAu; // gmBackpackUI m_containerList (side-bag selector) - public const uint TopContainerId = 0x100001C9u; // gmBackpackUI m_topContainer (main-pack cell) - public const uint BurdenMeterId = 0x100001D9u; // gmBackpackUI m_burdenMeter (vertical) - public const uint BurdenTextId = 0x100001D8u; // gmBackpackUI m_burdenText ("%d%%") - public const uint BurdenCaptionId = 0x100001D7u; // "Burden" - public const uint ContentsCaptionId = 0x100001C5u; // "Contents of Backpack" - public const uint ContentsScrollbarId = 0x100001C7u; // gm3DItemsUI gutter scrollbar (right of the grid) - - // Scrollbar chrome from base layout 0x2100003E (shared with the chat scrollbar; see - // ChatWindowController). Both inventory + chat scrollbars inherit this base. - private const uint ScrollTrackSprite = 0x06004C5Fu; - private const uint ScrollThumbSprite = 0x06004C63u; // 3-slice middle tile - private const uint ScrollThumbTop = 0x06004C60u; // 3-slice top cap - private const uint ScrollThumbBot = 0x06004C66u; // 3-slice bottom cap - private const uint ScrollUpSprite = 0x06004C6Cu; // up arrow (top button) - private const uint ScrollDownSprite = 0x06004C69u; // down arrow (bottom button) - - // 3D-items grid: 192x96 → 6 cols x 3 rows of the 32x32 UIItem cell (template 0x21000037). - private const int ContentsColumns = 6; - private const float ContentsCellPx = 32f; // gm3DItemsUI grid (192x96 = 6x3 of 32px) - private const float BackpackCellPx = 36f; // gmBackpackUI column cells (0x100001C9/CA = 36px) - private const int SideBagSlots = 7; // 0x100001CA is 36x252 = 7 slots - private const int MainPackSlots = 102; // retail main-pack capacity (Wiki: "up to 102 items") - private const int SidePackSlots = 24; // standard side-pack capacity (reference_retail_inventory_paperdoll) - - private readonly ClientObjectTable _objects; - private readonly Func _playerGuid; - private readonly Func _iconIds; - private readonly Func _strength; - - private readonly UiItemList? _contentsGrid; - private readonly UiItemList? _containerList; - private readonly UiItemList? _topContainer; - private readonly UiMeter? _burdenMeter; - - private float _burdenFill; - private int _burdenPercent; - private static readonly Vector4 CaptionColor = new(1f, 1f, 1f, 1f); - - private uint _openContainer; // 0 = the main pack (the player); else the open side bag's guid - private uint _selectedItem; // 0 = none; the panel-wide selected item (green square) - private readonly Action? _sendUse; - private readonly Action? _sendNoLongerViewing; - private readonly Action? _sendPutItemInContainer; // (item, container, placement) - - // ACE PropertyInt ids read by retail InqLoad (decomp 0x0058f130: InqInt(5)/InqInt(0xe6)). - private const uint EncumbranceValProperty = 5u; // total carried burden - private const uint EncumbranceAugProperty = 0xE6u; // carry-capacity augmentation - - private InventoryController( - ImportedLayout layout, - ClientObjectTable objects, - Func playerGuid, - Func iconIds, - Func strength, - UiDatFont? datFont, - uint contentsEmptySprite, - uint sideBagEmptySprite, - uint mainPackEmptySprite, - Action? sendUse, - Action? sendNoLongerViewing, - Action? sendPutItemInContainer) - { - _objects = objects; - _playerGuid = playerGuid; - _iconIds = iconIds; - _strength = strength; - _sendUse = sendUse; - _sendNoLongerViewing = sendNoLongerViewing; - _sendPutItemInContainer = sendPutItemInContainer; - - _contentsGrid = layout.FindElement(ContentsGridId) as UiItemList; - _containerList = layout.FindElement(ContainerListId) as UiItemList; - _topContainer = layout.FindElement(TopContainerId) as UiItemList; - - if (_contentsGrid is not null) - { - _contentsGrid.Columns = ContentsColumns; - _contentsGrid.CellWidth = ContentsCellPx; - _contentsGrid.CellHeight = ContentsCellPx; - } - - // Bind the gutter scrollbar to the contents grid's scroll model (the factory built - // 0x100001C7 as a bare Type-11 UiScrollbar; wire it like ChatWindowController does). - if (_contentsGrid is not null - && layout.FindElement(ContentsScrollbarId) is UiScrollbar bar) - { - bar.Model = _contentsGrid.Scroll; - bar.SpriteResolve = _contentsGrid.SpriteResolve; // chrome resolve from the factory ctor - bar.TrackSprite = ScrollTrackSprite; - bar.ThumbSprite = ScrollThumbSprite; - bar.ThumbTopSprite = ScrollThumbTop; - bar.ThumbBotSprite = ScrollThumbBot; - bar.UpSprite = ScrollUpSprite; - bar.DownSprite = ScrollDownSprite; - } - if (_containerList is not null) - { - _containerList.Columns = 1; - _containerList.CellWidth = BackpackCellPx; - _containerList.CellHeight = BackpackCellPx; - } - - // Per-list empty-slot art, resolved from the dat cell template (retail attr 0x1000000e - // -> catalog 0x21000037 prototype's ItemSlot_Empty). 0 = leave the UiItemSlot default. - if (_contentsGrid is not null) _contentsGrid.CellEmptySprite = contentsEmptySprite; - if (_containerList is not null) _containerList.CellEmptySprite = sideBagEmptySprite; - if (_topContainer is not null) _topContainer.CellEmptySprite = mainPackEmptySprite; - - // B-Drag: register this controller as the drag handler on all three lists. - _contentsGrid?.RegisterDragHandler(this); - _containerList?.RegisterDragHandler(this); - _topContainer?.RegisterDragHandler(this); - - // Burden meter: vertical 11×58 bar (gmBackpackUI m_burdenMeter, retail direction 4). - _burdenMeter = layout.FindElement(BurdenMeterId) as UiMeter; - if (_burdenMeter is not null) - { - _burdenMeter.Vertical = true; // 11x58 vertical bar - _burdenMeter.FillFromBottom = true; // retail direction 4 (visual-confirmed) - _burdenMeter.Fill = () => _burdenFill; - } - - // Captions: drive each host UiText directly with the known string (the caption - // elements resolve to UiText). "Contents of Backpack" + "%d%%" are procedural in retail - // (gm3DItemsUI/gmBackpackUI PostInit/SetLoadLevel); "Burden" is the dat label. (Spec §5.) - AttachCaption(layout.FindElement(BurdenCaptionId), () => "Burden", datFont); - AttachCaption(layout.FindElement(ContentsCaptionId), () => "Contents of " + OpenContainerName(), datFont); - AttachCaption(layout.FindElement(BurdenTextId), () => _burdenPercent + "%", datFont); - - // Rebuild on any change to the player's possessions. - _objects.ObjectAdded += OnObjectChanged; - _objects.ObjectMoved += OnObjectMoved; - _objects.ObjectRemoved += OnObjectChanged; - _objects.ObjectUpdated += OnObjectChanged; - - Populate(); - } - - public static InventoryController Bind( - ImportedLayout layout, - ClientObjectTable objects, - Func playerGuid, - Func iconIds, - Func strength, - UiDatFont? datFont, - uint contentsEmptySprite = 0u, - uint sideBagEmptySprite = 0u, - uint mainPackEmptySprite = 0u, - Action? sendUse = null, - Action? sendNoLongerViewing = null, - Action? sendPutItemInContainer = null) - => new InventoryController(layout, objects, playerGuid, iconIds, strength, datFont, - contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite, - sendUse, sendNoLongerViewing, sendPutItemInContainer); - - private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } - private void OnObjectMoved(ClientObject o, uint from, uint to) - { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } - - /// True if the object is in (or wielded by) the player — i.e. a rebuild is warranted. - private bool Concerns(ClientObject o) - { - uint p = _playerGuid(); - return o.ObjectId == p // the player object IS the burden source - || o.ContainerId == p || o.WielderId == p - || o.ContainerId == EffectiveOpen() // the OPEN container's contents drive the grid - || (o.ContainerId != 0 && _objects.Get(o.ContainerId)?.ContainerId == p); // 2-deep - } - - public void Populate() - { - uint p = _playerGuid(); - uint open = EffectiveOpen(); - - _contentsGrid?.Flush(); - _containerList?.Flush(); - - // Side-bag column: ALWAYS the player's bags (constant across container switches; only the - // open/selected indicators move). Equipped items never appear here. - foreach (var guid in _objects.GetContents(p)) - { - var item = _objects.Get(guid); - if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; - bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; - if (isBag) AddCell(_containerList, guid, isContainer: true); - } - - // Contents grid: the OPEN container's loose items. (Bags live in the column; a side bag has - // no sub-bags, so the isBag skip is a no-op when a bag is open.) - foreach (var guid in _objects.GetContents(open)) - { - var item = _objects.Get(guid); - if (item is null || item.CurrentlyEquippedLocation != EquipMask.None) continue; - bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; - if (!isBag) AddCell(_contentsGrid, guid, isContainer: false); - } - - // Pad the grid to the OPEN container's capacity (main pack 102 / side bag 24). - if (_contentsGrid is not null) - { - int cap = _objects.Get(open)?.ItemsCapacity ?? 0; - int slots = cap > 0 ? cap : (open == p ? MainPackSlots : SidePackSlots); - while (_contentsGrid.GetNumUIItems() < slots) AddEmptyCell(_contentsGrid); - } - - // Pad the side-bag column to capacity, clamped to the 7-slot column (AP-52). - if (_containerList is not null) - { - int capacity = _objects.Get(p)?.ContainersCapacity ?? 0; - int bags = _containerList.GetNumUIItems(); - int slots = capacity > 0 ? capacity : SideBagSlots; - slots = Math.Max(slots, bags); - slots = Math.Min(slots, SideBagSlots); - while (_containerList.GetNumUIItems() < slots) AddEmptyCell(_containerList); - } - - // Main-pack cell: the player's own container — clicking it opens/selects the main pack. - // Retail draws a CONSTANT backpack icon here, NOT the player's body icon: IconData::RenderIcons - // (0x0058d1ee) has an IsThePlayer() branch that draws a fixed backpack with m_itemType = - // TYPE_CONTAINER. Compose that backpack base over the Container type-underlay (the player - // object's own IconId is the character body, which would render wrong here). The backpack - // RenderSurface is 0x0600127E, VISUALLY CONFIRMED at the live gate 2026-06-22 (the earlier - // 0x060011F4 from a research dat-dump of GetDIDByEnum(0x10000004,7) was a green tile, not the - // pack — the index value was misreported). Retires AP-51. - if (_topContainer is not null) - { - const uint PlayerPackBaseIcon = 0x0600127Eu; // constant main-pack backpack (visual gate) - _topContainer.Flush(); - var main = new UiItemSlot { SpriteResolve = _topContainer.SpriteResolve }; - main.SetItem(p, _iconIds(ItemType.Container, PlayerPackBaseIcon, 0u, 0u, 0u)); - main.DragAcceptSprite = 0x060011F7u; main.DragRejectSprite = 0x060011F8u; - main.Clicked = () => OpenContainer(p); - SetCapacityBar(main, p); // main-pack fullness (items / ItemsCapacity) - _topContainer.AddItem(main); - } - - ApplyIndicators(); - RefreshBurden(); - } - - /// The effective open container — the explicit one, or the player (main pack) by default. - /// Resolved live (not cached at ctor) so a late-arriving player guid is handled. The default - /// sentinel is 0; once the main pack is explicitly opened, _openContainer holds the player - /// guid instead — both resolve here to the same main-pack container, so the paths are equivalent. - private uint EffectiveOpen() => _openContainer != 0 ? _openContainer : _playerGuid(); - - /// Add a populated cell wired to its click role: container cell → open+select, - /// item cell → select-only. - private void AddCell(UiItemList? list, uint guid, bool isContainer) - { - if (list is null) return; - var item = _objects.Get(guid); - uint tex = item is null ? 0u - : _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); - var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve }; - cell.SetItem(guid, tex); - cell.SlotIndex = list.GetNumUIItems(); // index it will occupy (== its slot in a packed list) - cell.DragAcceptSprite = 0x060011F7u; // green insert arrow (export-confirmed) - cell.DragRejectSprite = 0x060011F8u; // red circle - if (isContainer) { cell.Clicked = () => OpenContainer(guid); SetCapacityBar(cell, guid); } - else cell.Clicked = () => SelectItem(guid); - list.AddItem(cell); - } - - private static void AddEmptyCell(UiItemList list) - { - var cell = new UiItemSlot { SpriteResolve = list.SpriteResolve, SlotIndex = list.GetNumUIItems() }; - cell.DragAcceptSprite = 0x060011F7u; - cell.DragRejectSprite = 0x060011F8u; - list.AddItem(cell); - } - - /// Set the per-cell container capacity bar — retail UIElement_UIItem::UpdateCapacityDisplay - /// (0x004e16e0): visible only for a container with itemsCapacity > 0; fill = - /// GetNumContainedItems / itemsCapacity, clamped [0,1]. -1 hides the bar (non-container / unknown - /// capacity). For a CLOSED side bag the contents aren't indexed until it's opened (ViewContents), - /// so the bar reads empty until then — faithful to retail's known-children count. - private void SetCapacityBar(UiItemSlot cell, uint containerGuid) - { - int cap = _objects.Get(containerGuid)?.ItemsCapacity ?? 0; - if (cap <= 0) { cell.CapacityFill = -1f; return; } - int n = _objects.GetContents(containerGuid).Count; - cell.CapacityFill = Math.Clamp(n / (float)cap, 0f, 1f); - } - - // ── IItemListDragHandler (B-Drag) — drop an item to move it (optimistic + wire) ────────────── - /// Inventory items do NOT lift-remove (unlike the toolbar): the item stays in its slot - /// until the server confirms the move. Retail dims the source; we leave it + the floating ghost. - public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } - - /// Advisory accept/reject overlay (green insert-arrow / red circle). Grid drops are always - /// valid (reorder/insert); a side-bag/main-pack drop is valid unless that container is KNOWN-full. - public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - { - if (payload.ObjId == 0) return false; - if (targetList == _contentsGrid) return true; - if (targetList == _containerList || targetList == _topContainer) - { - if (targetCell.ItemId == 0 || targetCell.ItemId == payload.ObjId) return false; - return !IsContainerFull(targetCell.ItemId); - } - return false; - } - - /// Perform the move: resolve (container, placement) from the target, optimistically move - /// locally (instant), and send PutItemInContainer. Retail: HandleDropRelease + ItemList_InsertItem. - public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - { - uint item = payload.ObjId; - if (item == 0) return; - - uint container; int placement; - if (targetList == _contentsGrid) - { - container = EffectiveOpen(); - placement = targetCell.ItemId != 0 - ? targetCell.SlotIndex // insert-before = the target's GRID INDEX (gapless), not its raw ContainerSlot - : _objects.GetContents(container).Count; // first empty = append - } - else if (targetList == _containerList || targetList == _topContainer) - { - if (targetCell.ItemId == 0 || targetCell.ItemId == item) return; - container = targetCell.ItemId; // the bag / main pack - if (IsContainerFull(container)) return; // red already shown - placement = _objects.GetContents(container).Count; // append into it - } - else return; - - if (container == item) return; // never into itself - _objects.MoveItemOptimistic(item, container, placement); // instant local move - _sendPutItemInContainer?.Invoke(item, container, placement); - } - - /// True only when we KNOW the container is full (capacity known + contents indexed). A - /// closed bag (unknown count) returns false → advisory accept; the server is authoritative. - private bool IsContainerFull(uint container) - { - int cap = _objects.Get(container)?.ItemsCapacity ?? 0; - if (cap <= 0) return false; - return _objects.GetContents(container).Count >= cap; - } - - /// Select an item (panel-wide green square) without changing the open container or - /// touching the wire. Retail: UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0). - private void SelectItem(uint guid) - { - if (guid == 0) return; - _selectedItem = guid; - ApplyIndicators(); - } - - /// Open a container (side bag or main pack): it becomes both the selected item and the - /// open container. Sends Use to open a side bag server-side (ViewContents arrives async), and - /// NoLongerViewingContents when switching away from a previously-open side bag. Retail: - /// gmBackpackUI container-selection + CM_Physics::Event_Use. - private void OpenContainer(uint guid) - { - if (guid == 0) return; - _selectedItem = guid; // the container cell is also the selected item - uint open = EffectiveOpen(); - if (guid == open) { ApplyIndicators(); return; } // already open — just move the square - - uint p = _playerGuid(); - if (open != p && open != 0) - _sendNoLongerViewing?.Invoke(open); // close the previously-open side bag - _openContainer = guid; - if (guid != p) - _sendUse?.Invoke(guid); // open the side bag (ViewContents will land) - Populate(); // repaint the grid for the new open container - } - - /// Stamp the open-container triangle + selected-item square onto every cell per the - /// retail keying (item.itemID == openContainerId / selectedItemId). - private void ApplyIndicators() - { - SetIndicators(_contentsGrid); - SetIndicators(_containerList); - SetIndicators(_topContainer); - } - - private void SetIndicators(UiItemList? list) - { - if (list is null) return; - uint open = EffectiveOpen(); - for (int i = 0; i < list.GetNumUIItems(); i++) - { - var cell = list.GetItem(i); - if (cell is null) continue; - cell.Selected = cell.ItemId != 0 && cell.ItemId == _selectedItem; - cell.IsOpenContainer = cell.ItemId != 0 && cell.ItemId == open; - } - } - - /// The open container's display name for the contents caption. - private string OpenContainerName() - { - uint open = EffectiveOpen(); - return open == _playerGuid() ? "Backpack" : (_objects.Get(open)?.Name ?? "Backpack"); - } - - private void AttachCaption(UiElement? host, Func text, UiDatFont? datFont) - { - if (host is null) return; - - // The caption elements (0x100001D7 "Burden", 0x100001C5 "Contents of Backpack", - // 0x100001D8 "%") resolve to UiText (Type-0 inheriting a text base — confirmed live). - // Drive the host UiText DIRECTLY: it is already in the paint tree and renders, whereas - // a nested child UiText did not paint. Set it to a static centered single-line label. - if (host is UiText t) - { - t.Centered = true; - t.DatFont = datFont; - t.ClickThrough = true; - t.AcceptsFocus = false; - t.IsEditControl = false; - t.CapturesPointerDrag = false; - t.LinesProvider = () => - { - var s = text(); - return string.IsNullOrEmpty(s) - ? Array.Empty() - : new[] { new UiText.Line(s, CaptionColor) }; - }; - return; - } - - // Fallback (non-UiText host): attach a centered label child. - var label = new UiText - { - Left = 0f, Top = 0f, Width = host.Width, Height = host.Height, - Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, - Centered = true, DatFont = datFont, ClickThrough = true, - AcceptsFocus = false, IsEditControl = false, CapturesPointerDrag = false, - LinesProvider = () => - { - var s = text(); - return string.IsNullOrEmpty(s) - ? Array.Empty() - : new[] { new UiText.Line(s, CaptionColor) }; - }, - }; - host.AddChild(label); - } - - /// Recompute the burden fill + percent. Port of CACQualities::InqLoad - /// (decomp 0x0058f130) → gmBackpackUI::SetLoadLevel (0x004a6ea0). currentBurden: - /// player wire EncumbranceVal (PropertyInt 5) if present, else the carried-Burden sum. - private void RefreshBurden() - { - uint p = _playerGuid(); - int str = _strength() ?? 10; // InqAttribute default 0xa - int aug = _objects.Get(p)?.Properties.GetInt(EncumbranceAugProperty) ?? 0; - int capacity = BurdenMath.EncumbranceCapacity(str, aug); - - int? wire = _objects.Get(p)?.Properties.Ints.TryGetValue(EncumbranceValProperty, out var ev) == true - ? ev : (int?)null; - int burden = wire ?? _objects.SumCarriedBurden(p); - - float load = BurdenMath.LoadRatio(capacity, burden); - _burdenFill = BurdenMath.LoadToFill(load); - _burdenPercent = BurdenMath.LoadToPercent(load); - } - - /// Detach event handlers (idempotent). - public void Dispose() - { - _objects.ObjectAdded -= OnObjectChanged; - _objects.ObjectMoved -= OnObjectMoved; - _objects.ObjectRemoved -= OnObjectChanged; - _objects.ObjectUpdated -= OnObjectChanged; - } -} diff --git a/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs b/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs deleted file mode 100644 index 29b33f3a..00000000 --- a/src/AcDream.App/UI/Layout/ItemListCellTemplate.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System.Collections.Generic; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -namespace AcDream.App.UI.Layout; - -/// -/// Resolves the empty-slot background sprite for a UIElement_ItemList's cells the way retail -/// does. Retail ref: UIElement_ItemList::InternalCreateItem (acclient_2013_pseudo_c.txt:231486 / -/// 0x004e3570): GetAttribute_Enum(this, 0x1000000e, &id) -> GetByEnum(0x10000038,5,0x23) [the -/// shared UIItem catalog LayoutDesc, = 0x21000037] -> CreateChildElement(catalog, id); the cloned -/// prototype's ItemSlot_Empty (state 0x1000001c) media is the empty-cell background. -/// -public static class ItemListCellTemplate -{ - /// The shared UIItem cell-template catalog. Hardcoded: retail resolves it via - /// GetByEnum(0x10000038,5,0x23) through a master enum-map DAT object (no code literal); - /// 0x21000037 is the only LayoutDesc that is a catalog of standalone UIItem (0x10000032) - /// prototypes. Validated by the real-dat test (the resolved sprite must be a real 0x06 surface). - public const uint CatalogLayoutId = 0x21000037u; - - private const uint CellTemplateAttr = 0x1000000eu; // UIElement attribute: cell-template element id - private const uint IconChildId = 0x1000033Bu; // m_elem_Icon sub-element (carries ItemSlot_Empty) - private const string ItemSlotEmpty = "ItemSlot_Empty"; // UIStateId.ToString() for state 0x1000001c - - /// - /// Resolve the empty-slot sprite (a 0x06xxxxxx RenderSurface id) for the cells of item-list - /// element in LayoutDesc . - /// Returns 0 when the layout/element/attribute/prototype/media is absent (the caller keeps - /// the default). - /// - public static uint ResolveEmptySprite(DatCollection dats, uint listLayoutId, uint listElementId) - { - var listLd = dats.Get(listLayoutId); - if (listLd is null) return 0; - var listElem = FindDesc(listLd, listElementId); - if (listElem is null) return 0; - - uint protoId = ReadCellTemplateId(listElem); // attr 0x1000000e - if (protoId == 0) return 0; - - var catalog = dats.Get(CatalogLayoutId); - if (catalog is null) return 0; - var proto = FindDesc(catalog, protoId); - if (proto is null) return 0; - - // The empty-slot BACKGROUND is the m_elem_Icon's ItemSlot_Empty media, resolved THROUGH - // BaseElement inheritance: the 36x36 container prototype (0x1000033F) nests its icon behind - // an inner wrapper (0x10000340 -> base 0x1000033E -> 0x1000033B -> 0x06000F6E), so a raw - // child search alone returns nothing for containers. We deliberately do NOT use the - // prototype's DirectState child overlay: on the container prototype that child is the - // open/selected-container TRIANGLE indicator (0x06005D9C), which retail draws ONLY on the - // selected container (a deferred container-selection feature) — never as empty-cell art. - // (Live visual gate 2026-06-22: frame-first stamped the triangle onto every empty cell.) - return FindIconEmpty(catalog, proto, new HashSet()); - } - - // ── attribute 0x1000000e: stored as EnumBaseProperty in the dat (Value is the prototype id) ── - // Note: the spec anticipated DataIdBaseProperty/ArrayBaseProperty based on the font-DID pattern, - // but the live dat uses EnumBaseProperty.Value (uint) — confirmed by runtime reflection. - private static uint ReadCellTemplateId(ElementDesc elem) - { - uint id = ReadIdFromState(elem.StateDesc); - if (id != 0) return id; - foreach (var s in elem.States) - { - id = ReadIdFromState(s.Value); - if (id != 0) return id; - } - return 0; - } - - private static uint ReadIdFromState(StateDesc? sd) - { - if (sd?.Properties is null) return 0; - return sd.Properties.TryGetValue(CellTemplateAttr, out var raw) ? ReadId(raw) : 0; - } - - private static uint ReadId(BaseProperty raw) - { - if (raw is ArrayBaseProperty arr && arr.Value.Count > 0) return ReadId(arr.Value[0]); - if (raw is DataIdBaseProperty did) return did.Value; - if (raw is EnumBaseProperty ep) return ep.Value; // 0x1000000e is EnumBaseProperty in the live dat - return 0; - } - - // ── prototype media: the m_elem_Icon (0x1000033B) ItemSlot_Empty, resolved through inheritance ── - // Find the icon child's empty media within `element`'s subtree, following BaseElement edges - // within the same catalog (cycle-guarded by `baseSeen`). The 32x32 contents prototype carries - // 0x1000033B as a direct child; the 36x36 container prototype reaches it only via an inherited - // base (0x10000340 -> 0x1000033E), so a raw child search alone returns nothing for containers. - private static uint FindIconEmpty(LayoutDesc catalog, ElementDesc element, HashSet baseSeen) - { - if (element.ElementId == IconChildId) - { - uint e = IconEmptyState(element); - if (e != 0) return e; - } - foreach (var kv in element.Children) - { - uint e = FindIconEmpty(catalog, kv.Value, baseSeen); - if (e != 0) return e; - } - if (element.BaseElement != 0 && element.BaseLayoutId == CatalogLayoutId && baseSeen.Add(element.BaseElement)) - { - var baseDesc = FindDesc(catalog, element.BaseElement); - if (baseDesc is not null) - { - uint e = FindIconEmpty(catalog, baseDesc, baseSeen); - if (e != 0) return e; - } - } - return 0; - } - - private static uint IconEmptyState(ElementDesc icon) - { - foreach (var s in icon.States) - if (s.Key.ToString() == ItemSlotEmpty) - { - uint f = StateImage(s.Value); - if (f != 0) return f; - } - return DirectStateImage(icon); // some icons carry empty media on the DirectState - } - - private static uint DirectStateImage(ElementDesc d) - => d.StateDesc is null ? 0u : StateImage(d.StateDesc); - - private static uint StateImage(StateDesc sd) - { - foreach (var m in sd.Media) - if (m is MediaDescImage img && img.File != 0) return img.File; - return 0; - } - - // ── depth-first element search by id (LayoutImporter.FindDesc is private there) ── - private static ElementDesc? FindDesc(LayoutDesc ld, uint id) - { - foreach (var kv in ld.Elements) - { - var f = FindDescIn(kv.Value, id); - if (f is not null) return f; - } - return null; - } - - private static ElementDesc? FindDescIn(ElementDesc d, uint id) - { - if (d.ElementId == id) return d; - foreach (var kv in d.Children) - { - var f = FindDescIn(kv.Value, id); - if (f is not null) return f; - } - return null; - } -} diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs deleted file mode 100644 index 43a60108..00000000 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ /dev/null @@ -1,584 +0,0 @@ -using System; -using System.Collections.Generic; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Enums; -using DatReaderWriter.Types; - -namespace AcDream.App.UI.Layout; - -/// -/// The result of importing a retail LayoutDesc: a tree with -/// an O(1) lookup table for finding any element by its dat id. -/// -public sealed class ImportedLayout -{ - /// Root widget of the imported tree. - public UiElement Root { get; } - - private readonly Dictionary _byId; - - public ImportedLayout(UiElement root, Dictionary byId) - { - Root = root; - _byId = byId; - } - - /// Find a widget by its dat element id (e.g. 0x100000E6). - /// Returns null if the id was skipped (Type-12 prototype) or not present. - public UiElement? FindElement(uint id) - => _byId.TryGetValue(id, out var e) ? e : null; -} - -/// -/// Two-layer layout importer for retail LayoutDesc dat objects. -/// -/// -/// Pure layer ( / ): -/// converts a pre-resolved tree into a -/// tree via . Testable without dats or OpenGL — all tests -/// in LayoutImporterTests.cs exercise this layer only. -/// -/// -/// -/// Dat shell (): reads a , -/// converts each top-level to a fully resolved -/// (applying BaseElement / BaseLayoutId -/// inheritance with a cycle guard), then delegates to . -/// -/// -/// -/// Meter elements (Type 7) consume their own dat-children: -/// reads the grandchild slice-sprite ids during construction, so the -/// children must NOT be added as separate nodes in the tree. -/// Every other element type recurses its children generically. -/// -/// -public static class LayoutImporter -{ - // ── Pure layer ──────────────────────────────────────────────────────────── - - /// - /// Convenience for tests: attach to - /// , then call . - /// The children list is set directly on ; - /// any existing children are replaced. - /// - public static ImportedLayout BuildFromInfos( - ElementInfo rootInfo, - IEnumerable children, - Func resolve, - UiDatFont? datFont, - Func? fontResolve = null) - { - rootInfo.Children = new List(children); - return Build(rootInfo, resolve, datFont, fontResolve); - } - - /// - /// Pure builder: produce the widget tree from a fully resolved - /// tree (children already attached). - /// - /// Optional per-element font resolver — FontDid → - /// (or null if the font can't be loaded). When supplied, - /// elements with a non-zero get their own dat - /// font at build time instead of the shared fallback. - /// Null preserves the original single-font behavior for all callers that don't - /// pass it — no behavior change for the live game path. - public static ImportedLayout Build( - ElementInfo rootInfo, - Func resolve, - UiDatFont? datFont, - Func? fontResolve = null) - { - var byId = new Dictionary(); - // Root is never a Type-12 prototype in practice; fall back to a generic - // container if the factory returns null for an exotic root type. - var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, byId); - if (root is null) - { - Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback."); - root = new UiDatElement(rootInfo, resolve); - } - return new ImportedLayout(root, byId); - } - - private static UiElement? BuildWidget( - ElementInfo info, - Func resolve, - UiDatFont? datFont, - Func? fontResolve, - Dictionary byId) - { - var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve); - if (w is null) return null; // Type-12 style prototype — skip - - if (info.Id != 0) byId[info.Id] = w; - - // Behavioral widgets that draw their full appearance + reproduce their dat - // sub-elements procedurally (Meter's 3-slice, Menu's label/rows, Field/Text caps, - // Button labels, Scrollbar arrows) CONSUME their dat children — building those as - // separate widgets double-draws and lets an invisible child steal pointer/focus - // from the behavioral widget (e.g. the channel Menu's label child intercepting the - // button click). Only generic containers (UiDatElement, panels) recurse. See - // UiElement.ConsumesDatChildren. - if (!w.ConsumesDatChildren) - { - foreach (var child in info.Children) - { - var cw = BuildWidget(child, resolve, datFont, fontResolve, byId); - if (cw is not null) w.AddChild(cw); - } - } - else if (w is UiMeter) - { - // Fix 5: UiMeter.ConsumesDatChildren=true swallows ALL children, including text - // label/value overlays that are separate renderable widgets (not part of the bar - // art). BuildMeter in DatWidgetFactory already consumed the Type-3 slice containers - // (reads their grandchild sprite ids to populate Back*/Front* properties). The - // remaining non-Type-3 children (typically Type-12 UIElement_Text overlays such - // as the XP meter's 0x10000237 label + 0x10000238 value) ARE renderable and belong - // in the widget tree. We build them here explicitly, registered in byId so - // FindElement can locate them, and attached as children of the meter so they render - // as overlays at their dat-local coordinates. The controller can then locate these - // widgets via FindElement and bind LinesProvider without injecting new runtime nodes. - // - // Type-3 children are SKIPPED here because BuildMeter already consumed them (they - // carry the 3-slice sprite ids, not text content; building them again would - // double-draw the bar art). All other child types are built normally. - // - // Safe for vitals: the health/stamina/mana meters have ONLY Type-3 slice children - // (no text children). This loop finds nothing for them → no change to vitals. - foreach (var child in info.Children) - { - if (child.Type == 3) continue; // slice containers: already consumed by BuildMeter - var cw = BuildWidget(child, resolve, datFont, fontResolve, byId); - if (cw is not null) w.AddChild(cw); - } - } - - return w; - } - - // ── Dat shell ───────────────────────────────────────────────────────────── - - /// - /// Dat shell, ElementInfo half: load the layout + resolve inheritance + build the - /// ElementInfo tree (no widgets). Exposed for fixture generation + conformance tests. - /// Returns null if the layout is missing. - /// - /// The dat collection to read the LayoutDesc from. - /// The LayoutDesc dat id to read. - public static ElementInfo? ImportInfos(DatCollection dats, uint layoutId) - { - var ld = dats.Get(layoutId); - if (ld is null) return null; - - // Collect the set of element ids that are referenced as a BaseElement by ANY - // element in THIS layout (where BaseLayoutId == layoutId). Such elements are - // purely inheritance templates ("prototypes") — retail never instantiates them - // as live widgets. Example: the toolbar slot prototype 0x100001B2 in LayoutDesc - // 0x21000016, which all 18 slot elements inherit from and which has no own media. - // - // NOTE: the Resolve path reads BaseElement from the raw dat directly (via - // dats.Get), so the prototype never needs to appear in the built - // widget tree for inheritance to work. Skipping it here is safe. - var referencedAsBase = new HashSet(); - foreach (var kv in ld.Elements) - CollectBaseRefsInDesc(kv.Value, layoutId, referencedAsBase); - - var tops = new List(); - foreach (var kv in ld.Elements) - { - // Skip pure prototype elements: top-level elements that are referenced as a - // base template by another element in this same layout AND have no own state - // media (so they draw nothing and contribute nothing but their inherited shape). - var d = kv.Value; - if (referencedAsBase.Contains(d.ElementId) && HasNoOwnMedia(d)) - { - Console.WriteLine($"[D.2b] LayoutImporter: skipping prototype element 0x{d.ElementId:X8} in layout 0x{layoutId:X8} (no own media, referenced as BaseElement)."); - continue; - } - - tops.Add(Resolve(dats, d, new HashSet<(uint, uint)>())); - } - - return tops.Count == 1 - ? tops[0] - : new ElementInfo { Id = 0, Type = 3, Children = tops }; - } - - /// - /// Dat shell: load the LayoutDesc, resolve inheritance for every top-level - /// element, and build the widget tree. Returns null if the layout is absent - /// from the dats. - /// - /// - /// Dat UIState visibility model (2026-06-26 audit): - /// The dat's ElementDesc.DefaultState field specifies which SPRITE/MEDIA - /// state an element starts in (e.g., Normal, Minimized). It does - /// NOT encode visibility of sibling Group containers. The StateDesc's - /// contains X/Y/Width/Height/ - /// ZLevel/PassToChildren — there is no Visible flag. - /// - /// - /// - /// Windows that display multiple sibling Group containers at the same position - /// (the character footer's three state-groups; the tab-page content areas) manage - /// visibility purely at runtime via C++ controller code. Retail uses - /// UIElement::SetState(stateId) on the parent to propagate state, then - /// C++ getters access the right sub-group by element id. All groups are shipped - /// as visible in the imported widget tree; the relevant controllers - /// () perform the initial show/hide. - /// - /// - /// Optional per-element font resolver (see - /// for details). Null = original single-font behavior. - public static ImportedLayout? Import( - DatCollection dats, - uint layoutId, - Func resolve, - UiDatFont? datFont, - Func? fontResolve = null) - { - var rootInfo = ImportInfos(dats, layoutId); - if (rootInfo is null) return null; - return Build(rootInfo, resolve, datFont, fontResolve); - } - - // ── Inheritance resolution ──────────────────────────────────────────────── - - /// True when a pure-container leaf should inherit its base's subtree (the - /// gmInventoryUI sub-window mount): the derived element has no own children, no own - /// state media, and the base resolved to ≥1 child. Inert for media-bearing inheritors - /// (close button / title), elements with their own children, and childless style - /// prototypes (vitals/chat/toolbar text — the base prototype has no children). - internal static bool ShouldMountBaseChildren(int derivedChildCount, int derivedMediaCount, int baseChildCount) - => derivedChildCount == 0 && derivedMediaCount == 0 && baseChildCount > 0; - - /// - /// Converts an to a resolved : - /// reads own fields + media, applies the BaseElement / BaseLayoutId chain - /// (cycle-guarded by ), then resolves + attaches children. - /// - private static ElementInfo Resolve( - DatCollection dats, - ElementDesc d, - HashSet<(uint layoutId, uint elementId)> baseChain) - { - // Read this element's own fields + media (no inheritance, no children yet). - var self = ToInfo(d); - var result = self; - List? baseChildren = null; - - // Apply BaseElement / BaseLayoutId inheritance if present. - if (d.BaseElement != 0 && d.BaseLayoutId != 0 - && baseChain.Add((d.BaseLayoutId, d.BaseElement))) - { - var baseLd = dats.Get(d.BaseLayoutId); - var baseDesc = baseLd is null ? null : FindDesc(baseLd, d.BaseElement); - if (baseDesc is not null) - { - // Recurse the base chain (already guarded by the HashSet add above). - var baseInfo = Resolve(dats, baseDesc, baseChain); - // Derived fields override the base; children are attached below. - result = ElementReader.Merge(baseInfo, self); - baseChildren = baseInfo.Children; // capture for the sub-window mount - } - } - - // Resolve + attach children. Each child gets a FRESH base-chain set: - // the cycle guard is per-element, not shared across siblings. - foreach (var kv in d.Children) - result.Children.Add(Resolve(dats, kv.Value, new HashSet<(uint, uint)>())); - - // Sub-window mount: a pure-container leaf (no own children, no own media) that inherits - // from a base WITH content attaches the base's subtree. Targets the gmInventoryUI panels - // (0x100001CD/CE/CF — Type-0, media-less, childless, BaseLayoutId → a gm*UI window); - // inert for media-bearing inheritors (close button/title) and childless style prototypes - // (vitals/chat/toolbar). See ShouldMountBaseChildren + the B-Grid spec. - if (baseChildren is not null - && ShouldMountBaseChildren(d.Children.Count, self.StateMedia.Count, baseChildren.Count)) - { - result.Children.AddRange(baseChildren); - - // The mounted slot's layer WITHIN THE FRAME is its OWN ZLevel, not the mounted - // sub-window root's. The gm*UI sub-window roots carry ZLevel 1000 (their standalone - // top-window layer); ElementReader.Merge's zero-wins-base rule made the slot (own - // ZLevel 0) inherit that 1000, and the #145 ZOrder fold (ReadOrder − ZLevel·10000) - // turns 1000 into ZOrder ≈ −10,000,000 — sinking the whole panel BEHIND the frame's - // Alphablend backdrop (ZLevel 100 → ≈ −1,000,000). The backdrop then overpaints the - // panel's captions/meter/cells (the wash-out bug; the paperdoll root happens to be - // ZLevel 0 so it escaped). Restore the slot's own frame-layer so the panel sits in - // FRONT of the backdrop. (B-Controller debug 2026-06-21; continuation of #145.) - result.ZLevel = self.ZLevel; - - // Sub-layout slot sizing: when a sub-layout is mounted into a slot that is TALLER - // (or wider) than the sub-layout's own design height, the cascade of Bottom/Right - // anchored elements must be resized to fill the slot. Without this step, the - // background element (0x10000226 for the Attributes tab, designed at 337px) captures - // _amB = slotH - designH = 238 and permanently stays at 337px, and every element - // within it with Bottom anchor stays at its design size too (list box at 160px - // instead of the correct 398px). - // - // Retail reference: UIElement::UpdateForParentSizeChange (C++ runtime) propagates a - // new parent height down the whole tree, updating each Bottom-anchored child's - // rect by maintaining its bottom margin. We replicate that cascade here at import - // time on the base-children subtree so the anchor-capture during the first render - // frame sees zero (or unchanged) margins — not the spurious "slot bigger than design" - // margin. - // - // Safe guard: only fire when the slot has explicit size AND the base children are - // smaller (i.e., this is the "slot bigger than design" case). - // Inventory/paperdoll slots are unaffected because their slot H already matches the - // sub-layout design H, so no cascade occurs. - if (result.Width > 0 && result.Height > 0) - { - foreach (var child in baseChildren) - { - var childAnchors = ElementReader.ToAnchors(child.Left, child.Top, child.Right, child.Bottom); - const AnchorEdges StretchAll = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom; - if (child.X == 0 && child.Y == 0 - && (childAnchors & StretchAll) == StretchAll // full-stretch background - && child.Width <= result.Width - && child.Height > 0 && child.Height < result.Height) // smaller than slot - { - // Cascade UpdateForParentSizeChange down the subtree: maintain each - // Bottom-anchored element's bottom margin while growing the parent height. - CascadeHeight(child, child.Height, result.Height); - } - } - } - } - - return result; - } - - /// - /// Read an 's own scalar fields + state media into a - /// fresh . No inheritance is applied; children are not - /// attached (the caller handles those). - /// - private static ElementInfo ToInfo(ElementDesc d) - { - // Normalize DefaultState: UIStateId.ToString() gives "Undef"/"Undefined" or "0" when - // no default is set; map those to "" so UiDatElement treats them as "no preference". - var defState = d.DefaultState.ToString(); - var info = new ElementInfo - { - Id = d.ElementId, - Type = d.Type, - X = (float)d.X, - Y = (float)d.Y, - Width = (float)d.Width, - Height = (float)d.Height, - Left = d.LeftEdge, - Top = d.TopEdge, - Right = d.RightEdge, - Bottom = d.BottomEdge, - ReadOrder = d.ReadOrder, - ZLevel = d.ZLevel, - DefaultStateName = (defState is "Undef" or "Undefined" or "0") ? "" : defState, - }; - - // DirectState (unnamed, key ""). - if (d.StateDesc is not null) - ReadState(d.StateDesc, "", info); - - // Named states (e.g. UIStateId.HideDetail → "HideDetail"). - foreach (var s in d.States) - ReadState(s.Value, s.Key.ToString(), info); - - return info; - } - - /// - /// Read the first from into - /// info.StateMedia[name] and extract the font DID from property 0x1A - /// (ArrayBaseProperty → DataIdBaseProperty) if not yet set. - /// - private static void ReadState(StateDesc sd, string name, ElementInfo info) - { - // Only MediaDescImage is read for rendering; MediaDescCursor items (on grips/drag bars) - // are intentionally skipped — cursor behavior is Plan 2. - foreach (var m in sd.Media) - { - if (m is MediaDescImage img && img.File != 0) - { - info.StateMedia[name] = (img.File, (int)img.DrawMode); - break; - } - } - - // Font DID: Properties[0x1A] is ArrayBaseProperty{ DataIdBaseProperty }. - // Format doc §3: "ArrayBaseProperty containing ONE DataIdBaseProperty". - if (info.FontDid == 0 && sd.Properties is not null - && sd.Properties.TryGetValue(0x1Au, out var raw) - && raw is ArrayBaseProperty arr && arr.Value.Count > 0 - && arr.Value[0] is DataIdBaseProperty did) - { - info.FontDid = did.Value; - } - - if (sd.Properties is not null) - { - // HorizontalJustification (0x14): EnumBaseProperty. - // Retail values: 0=Left, 1=Center, 3=Right, 5=Right (treat 5 as Right per spec). - // Only update if still at the default (Center); derived-wins handled in Merge. - if (info.HJustify == HJustify.Center - && sd.Properties.TryGetValue(0x14u, out var hRaw) - && hRaw is EnumBaseProperty hEnum) - { - info.HJustify = hEnum.Value switch - { - 0u => HJustify.Left, - 1u => HJustify.Center, - 3u => HJustify.Right, - 5u => HJustify.Right, - _ => HJustify.Center, // unknown → center (safe default) - }; - } - - // VerticalJustification (0x15): EnumBaseProperty. - // Retail values: 2=Top, 4=Bottom; absent/other = Center. - if (info.VJustify == VJustify.Center - && sd.Properties.TryGetValue(0x15u, out var vRaw) - && vRaw is EnumBaseProperty vEnum) - { - info.VJustify = vEnum.Value switch - { - 2u => VJustify.Top, - 4u => VJustify.Bottom, - _ => VJustify.Center, - }; - } - - // ColorBaseProperty (0x1B): ARGB bytes → normalized [0,1] Vector4 (R,G,B,A). - // Only read when not already set (first dat state wins; Merge propagates from base). - if (info.FontColor is null - && sd.Properties.TryGetValue(0x1Bu, out var cRaw) - && cRaw is ColorBaseProperty cProp) - { - var c = cProp.Value; - // ColorARGB stores components as bytes (0–255); normalize to [0,1] for Vector4. - // Alpha=0 in the dat typically means fully opaque (retail convention: 0 → 255). - float a = c.Alpha == 0 ? 1f : c.Alpha / 255f; - info.FontColor = new System.Numerics.Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, a); - } - } - } - - // ── Prototype detection helpers ─────────────────────────────────────────── - - /// - /// Recursively walks and all its children, adding to - /// the BaseElement of every descriptor that - /// references this layout (BaseLayoutId == layoutId). Used by - /// to identify pure prototype/template elements that - /// should not be instantiated as live widgets. - /// - private static void CollectBaseRefsInDesc(ElementDesc d, uint layoutId, HashSet result) - { - if (d.BaseElement != 0 && d.BaseLayoutId == layoutId) - result.Add(d.BaseElement); - foreach (var kv in d.Children) - CollectBaseRefsInDesc(kv.Value, layoutId, result); - } - - /// - /// Returns true when carries no own state media — i.e. its - /// StateDesc (DirectState) and States (named states) yield no - /// entries with a non-zero file id. - /// Such elements are pure inheritance templates with no rendering content. - /// - private static bool HasNoOwnMedia(ElementDesc d) - { - // Re-use ToInfo's media extraction: if the resulting StateMedia is empty the - // element has no renderable image in any state. - var info = ToInfo(d); - return info.StateMedia.Count == 0; - } - - // ── Element tree search ─────────────────────────────────────────────────── - - /// - /// Find an by id anywhere in the top-level tree of - /// (depth-first). Returns null if not found. - /// - private static ElementDesc? FindDesc(LayoutDesc ld, uint id) - { - foreach (var kv in ld.Elements) - { - var f = FindDescIn(kv.Value, id); - if (f is not null) return f; - } - return null; - } - - private static ElementDesc? FindDescIn(ElementDesc d, uint id) - { - if (d.ElementId == id) return d; - foreach (var kv in d.Children) - { - var f = FindDescIn(kv.Value, id); - if (f is not null) return f; - } - return null; - } - - // ── Sub-layout slot sizing helpers ──────────────────────────────────────── - - /// - /// Recursively propagates a parent-height change from - /// to through and all its - /// descendants, replicating UIElement::UpdateForParentSizeChange from the - /// retail runtime. - /// - /// Three anchor cases for the vertical axis: - /// - /// Top+Bottom (stretch): maintain top margin AND bottom margin; height grows. - /// Bottom only (pin-to-bottom, fixed height): maintain bottom margin; Y moves, H unchanged. - /// Top only or None: Y and H unchanged (pinned to top with fixed height). - /// - /// Recurse with the element's new height so grandchildren see the correct parent size. - /// - /// This is called once at import time on base-children that are mounted into a - /// slot taller than the sub-layout's design height. - /// - private static void CascadeHeight(ElementInfo el, float oldParentH, float newParentH) - { - float oldH = el.Height; - float newH = el.Height; - float oldY = el.Y; - - var anchors = ElementReader.ToAnchors(el.Left, el.Top, el.Right, el.Bottom); - - bool hasTop = (anchors & AnchorEdges.Top) != 0; - bool hasBottom = (anchors & AnchorEdges.Bottom) != 0; - - if (hasTop && hasBottom) - { - // Stretch: maintain both top and bottom margins. - // amT = el.Y (pinned to top, unchanged) - // amB = oldParentH - (el.Y + el.H) - float amB = oldParentH - (el.Y + el.Height); - newH = newParentH - el.Y - amB; - if (newH < 0f) newH = 0f; - el.Height = newH; - } - else if (hasBottom && !hasTop) - { - // Pin to bottom: maintain bottom margin, height fixed, Y moves. - // amB = oldParentH - (el.Y + el.H) - float amB = oldParentH - (el.Y + el.Height); - float newY = newParentH - amB - el.Height; - el.Y = newY; - // Height unchanged; newH = oldH for recursion. - } - // else: Top-only or None — element is top-pinned with fixed height; nothing moves. - - // Recurse into children with the element's new height as their parent. - foreach (var child in el.Children) - CascadeHeight(child, oldH, newH); - } -} diff --git a/src/AcDream.App/UI/Layout/PaperdollController.cs b/src/AcDream.App/UI/Layout/PaperdollController.cs deleted file mode 100644 index 28841b0d..00000000 --- a/src/AcDream.App/UI/Layout/PaperdollController.cs +++ /dev/null @@ -1,237 +0,0 @@ -using System; -using System.Collections.Generic; -using AcDream.App.UI; -using AcDream.Core.Items; - -namespace AcDream.App.UI.Layout; - -/// -/// Binds the ~21 equip slots mounted under the paperdoll (gmPaperDollUI 0x21000024, nested in the -/// inventory frame 0x21000023) to live equipped-item data and makes them drag-drop WIELD targets. -/// The acdream analogue of gmPaperDollUI::PostInit + GetLocationInfoFromElementID (named-retail decomp -/// 175480 / 173620). Slice 1: equip slots only — no 3D doll viewport (that's Slice 2). -/// Unwield is handled by InventoryController (dragging an equipped item onto the pack grid). -/// -public sealed class PaperdollController : IItemListDragHandler -{ - // ── Slots-toggle public surface ─────────────────────────────────────────────────────────────── - /// - /// The 9 armor-slot element-ids whose Visible state the Slots button (0x100005BE) toggles. - /// Doll-view: hidden. Slot-view: shown. Source: gmPaperDollUI::ListenToElementMessage decomp - /// 175674-175706 — these are the only 9 ids that element flips; the other ~12 equip slots - /// (jewelry, weapon, wrists, feet…) are NON-armor and remain visible in both views. - /// - public static readonly uint[] ArmorSlotElementIds = - { - 0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu, - 0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u, - }; - - /// - /// View-state model for the Slots toggle (pure logic, no UI coupling). - /// Default is doll-view (SlotView == false): the 3-D character is visible, - /// the 9 armor slots are hidden. Calling Toggle() alternates between views. - /// - public sealed class PaperdollViewState - { - public bool SlotView { get; private set; } // false = doll-view (default) - public bool DollVisible => !SlotView; - public bool ArmorSlotsVisible => SlotView; - public void Toggle() => SlotView = !SlotView; - } - - // WEAPON_READY_SLOT_LOC (acclient.h:3235): the weapon-hand doll slot accepts any wieldable weapon. - private const EquipMask WeaponSlotMask = - EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded; - - // element-id → EquipMask (verified: dump paperdoll-0x21000024.txt ↔ deep-dive §3a ↔ acclient.h:3193). - // The 3 Aetheria sigil slots (0x10000595/96/97) are SetVisible(0) in retail — skipped (Slice 1 scope). - private static readonly (uint Element, EquipMask Mask)[] SlotMap = - { - (0x100005ABu, EquipMask.HeadWear), // 0x1 - (0x100001E2u, EquipMask.ChestWear), // 0x2 - (0x100001E3u, EquipMask.UpperLegWear), // 0x40 - (0x100005B0u, EquipMask.HandWear), // 0x20 - (0x100005B3u, EquipMask.FootWear), // 0x100 - (0x100005ACu, EquipMask.ChestArmor), // 0x200 - (0x100005ADu, EquipMask.AbdomenArmor), // 0x400 - (0x100005AEu, EquipMask.UpperArmArmor), // 0x800 - (0x100005AFu, EquipMask.LowerArmArmor), // 0x1000 - (0x100005B1u, EquipMask.UpperLegArmor), // 0x2000 - (0x100005B2u, EquipMask.LowerLegArmor), // 0x4000 - (0x100001DAu, EquipMask.NeckWear), // 0x8000 - (0x100001DBu, EquipMask.WristWearLeft), // 0x10000 - (0x100001DDu, EquipMask.WristWearRight), // 0x20000 - (0x100001DCu, EquipMask.FingerWearLeft), // 0x40000 - (0x100001DEu, EquipMask.FingerWearRight), // 0x80000 - (0x100001E1u, EquipMask.Shield), // 0x200000 - (0x100001E0u, EquipMask.MissileAmmo), // 0x800000 (LIKELY — gate-verify, AP row) - (0x100001DFu, WeaponSlotMask), // 0x3500000 composite - (0x1000058Eu, EquipMask.TrinketOne), // 0x4000000 - (0x100005E9u, EquipMask.Cloak), // 0x8000000 - }; - - private readonly ClientObjectTable _objects; - private readonly Func _playerGuid; - private readonly Func _iconIds; - private readonly Action? _sendWield; // (itemGuid, equipMask) → GetAndWieldItem 0x001A - private readonly List<(EquipMask Mask, UiItemList List)> _slots = new(); - - // ── Slots-toggle state ──────────────────────────────────────────────────────────────────────── - private readonly PaperdollViewState _viewState = new(); - private readonly List _armorSlots = new(); - private UiElement? _dollViewport; // UiViewport wired in Slice 3; UiElement? keeps this task independent - - private PaperdollController( - ImportedLayout layout, ClientObjectTable objects, Func playerGuid, - Func iconIds, Action? sendWield, - uint emptySlotSprite, UiDatFont? datFont) - { - _objects = objects; _playerGuid = playerGuid; _iconIds = iconIds; _sendWield = sendWield; - - for (int i = 0; i < SlotMap.Length; i++) - { - var (element, mask) = SlotMap[i]; - if (layout.FindElement(element) is not UiItemList list) continue; - list.RegisterDragHandler(this); - list.Cell.SourceKind = ItemDragSource.Equipment; - list.Cell.SlotIndex = i; // SlotMap position = this equipped item's drag-payload SourceSlot when dragged out to unwield - list.Cell.EmptySprite = emptySlotSprite; // visible empty-slot frame so every position is seen + usable. The live - // 3D character (the "figure" — Slice 1/2 correction: NOT a per-slot - // silhouette) is the doll viewport, which arrives in Slice 2 with the Slots toggle. - // Cell.SpriteResolve + the default accept/reject sprites (ItemSlot_DragOver_Accept ring - // 0x060011F9 / reject circle 0x060011F8 — the discrete-slot frames, NOT the inventory grid's - // insert-arrow 0x060011F7) are already wired by DatWidgetFactory when it built the UiItemList; - // no need to re-set them here. - _slots.Add((mask, list)); - } - - _objects.ObjectAdded += OnObjectChanged; - _objects.ObjectMoved += OnObjectMoved; - _objects.ObjectRemoved += OnObjectChanged; - _objects.ObjectUpdated += OnObjectChanged; - - // ── Slots-toggle wiring ─────────────────────────────────────────────────────────────────── - foreach (var id in ArmorSlotElementIds) - if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor); - - _dollViewport = layout.FindElement(0x100001D5u); // doll viewport (may be null until Slice 3) - - var slotsBtnEl = layout.FindElement(0x100005BEu); - if (slotsBtnEl is UiButton slotsBtn) - { - slotsBtn.OnClick = () => { _viewState.Toggle(); ApplyView(); }; - // The dat element has no face sprite, so without a label the button is invisible. Give it a - // left-aligned WHITE "Slots" caption (retail is white, not gold) so it's findable + clickable. - if (datFont is not null) - { - slotsBtn.Label = "Slots"; - slotsBtn.LabelFont = datFont; - slotsBtn.LabelColor = System.Numerics.Vector4.One; // white (was gold) - slotsBtn.LabelAlign = UiButton.LabelAlignment.Left; // sit at the left, before the slots - } - } - - ApplyView(); // initial state = doll-view (armor slots hidden) - - Populate(); - } - - public static PaperdollController Bind( - ImportedLayout layout, ClientObjectTable objects, Func playerGuid, - Func iconIds, Action? sendWield = null, - uint emptySlotSprite = 0u, UiDatFont? datFont = null) - => new PaperdollController(layout, objects, playerGuid, iconIds, sendWield, emptySlotSprite, datFont); - - private void OnObjectChanged(ClientObject o) { if (Concerns(o)) Populate(); } - private void OnObjectMoved(ClientObject o, uint from, uint to) - { if (Concerns(o) || from == _playerGuid() || to == _playerGuid()) Populate(); } - - /// The object belongs to the player (wielded gear or pack contents) — so a change to it may - /// add/remove/repaint a doll slot. Player-scoped: an NPC's or vendor's wielded item (which also carries - /// CurrentlyEquippedLocation from the wire) must NOT trigger a repaint. A player-equipped item always - /// has WielderId==p (login, from CreateObject) or ContainerId==p (live/optimistic wield, set by - /// WieldItemOptimistic), so the equip-location need not be tested here; OnObjectMoved adds the from/to - /// player backstop for moves that transiently satisfy neither (e.g. unwield into a side bag). - private bool Concerns(ClientObject o) - { - uint p = _playerGuid(); - return o.WielderId == p || o.ContainerId == p; - } - - public void Populate() - { - uint p = _playerGuid(); - - // The player's equipped gear (one pass). Scoped to the player so a vendor's/NPC's wielded item - // (which can also carry CurrentWieldedLocation) can't leak into the doll. - var equipped = new List(); - foreach (var o in _objects.Objects) - if (o.CurrentlyEquippedLocation != EquipMask.None && (o.WielderId == p || o.ContainerId == p)) - equipped.Add(o); - - foreach (var (mask, list) in _slots) - { - ClientObject? worn = null; - foreach (var o in equipped) - if ((o.CurrentlyEquippedLocation & mask) != EquipMask.None) { worn = o; break; } - - if (worn is null) { list.Cell.Clear(); continue; } - uint tex = _iconIds(worn.Type, worn.IconId, worn.IconUnderlayId, worn.IconOverlayId, worn.Effects); - list.Cell.SetItem(worn.ObjectId, tex); - } - } - - private EquipMask MaskFor(UiItemList list) - { - foreach (var (mask, l) in _slots) if (ReferenceEquals(l, list)) return mask; - return EquipMask.None; - } - - // ── IItemListDragHandler ────────────────────────────────────────────────────────────────────── - /// No-op: a wielded item stays put until the server confirms (same as the inventory grid, - /// unlike the toolbar's remove-on-lift). Unwield happens on DROP onto the pack grid — InventoryController. - public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) { } - - /// Accept iff the dragged item can be worn here (its ValidLocations intersects the slot mask). - /// Retail: gmPaperDollUI::OnItemListDragOver (decomp 174302). - public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - { - var item = _objects.Get(payload.ObjId); - if (item is null) return false; - return (item.ValidLocations & MaskFor(targetList)) != EquipMask.None; - } - - /// Wield the dropped item: optimistic local equip (instant) + GetAndWieldItem 0x001A. - /// wieldMask = ValidLocations & slotMask resolves the specific bit (the composite weapon slot, the - /// chosen finger). Retail: gmPaperDollUI HandleDropRelease → GetAndWieldItem. - public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - { - var item = _objects.Get(payload.ObjId); - if (item is null) return; - EquipMask wieldMask = item.ValidLocations & MaskFor(targetList); - if (wieldMask == EquipMask.None) return; // not wieldable here (defensive; OnDragOver already rejected) - _objects.WieldItemOptimistic(payload.ObjId, _playerGuid(), wieldMask); - _sendWield?.Invoke(payload.ObjId, (uint)wieldMask); - } - - /// - /// Pushes the current onto the live widgets: - /// hides/shows the doll viewport and each armor slot according to the toggle. - /// Called once at construction (doll-view default) and on every button click. - /// - private void ApplyView() - { - if (_dollViewport is not null) _dollViewport.Visible = _viewState.DollVisible; - foreach (var a in _armorSlots) a.Visible = _viewState.ArmorSlotsVisible; - } - - /// Detach event handlers (idempotent). - public void Dispose() - { - _objects.ObjectAdded -= OnObjectChanged; - _objects.ObjectMoved -= OnObjectMoved; - _objects.ObjectRemoved -= OnObjectChanged; - _objects.ObjectUpdated -= OnObjectChanged; - } -} diff --git a/src/AcDream.App/UI/Layout/SelectedObjectController.cs b/src/AcDream.App/UI/Layout/SelectedObjectController.cs deleted file mode 100644 index 74dfe76e..00000000 --- a/src/AcDream.App/UI/Layout/SelectedObjectController.cs +++ /dev/null @@ -1,268 +0,0 @@ -using System; -using System.Numerics; -using AcDream.App.UI; - -namespace AcDream.App.UI.Layout; - -/// -/// Controller for the action bar's selected-object strip (ids 0x1000019E–0x100001A1). -/// Analogue of retail gmToolbarUI::HandleSelectionChanged -/// (docs/research/named-retail/acclient_2013_pseudo_c.txt:198635) + -/// RecvNotice_UpdateObjectHealth (:196213). -/// -/// -/// On selection change: clears the strip (name, overlay flash, health meter), then if a -/// guid is provided it sets the name, flashes the selection overlay briefly, and (for -/// health-bearing targets) sends a QueryHealth (0x01BF) request. The Health meter -/// becomes visible only when the server actually reports health for the selected guid — -/// either an UpdateHealth (0x01C0) arrives (retail -/// RecvNotice_UpdateObjectHealthSetVisible(1)) or the value is already -/// cached. So a friendly NPC you have not assessed shows name-only (no bar), and a -/// monster's bar appears after damage / a successful assess — matching retail. -/// -/// -/// -/// Retail element roles (PostInit, :198119): m_pSelObjectField -/// is the container 0x1000019E whose SetState(0x1000000b/0c) drives a -/// 0.25s Pause→Normal flash that cascades to the overlay child's green frame. -/// acdream has no state-cascade / transition-animation system, so this controller drives -/// the overlay element 0x100001A0 directly and reverts it after the same -/// to reproduce the brief flash. The name element -/// 0x1000019F is bumped to the top of the strip's z-order so it draws OVER the -/// overlay frame and the health bar (retail draws the name over the bar — see the -/// "Drudge Slinker" reference shot). -/// -/// -/// -/// Divergence — health-target gate approximation. -/// Retail sends Event_QueryHealth for IsPlayer() || pet_owner || ObjectIsAttackable() -/// (:198754). acdream uses IsLiveCreatureTarget (the ItemType.Creature -/// flag) to gate the QueryHealth send. Visibility itself is health-data-driven (above), so -/// the gate only affects whether we proactively query; recorded in the divergence register. -/// -/// -public sealed class SelectedObjectController -{ - // ── Element ids (toolbar LayoutDesc 0x21000016) ───────────────────────── - /// Selected-object container / field element id (retail m_pSelObjectField). - public const uint ContainerId = 0x1000019E; - /// Selected-object name element id (retail m_pSelObjectName, UIElement_Text). - public const uint NameId = 0x1000019F; - /// Selected-object overlay element id (states: ObjectSelected / StackedItemSelected). - public const uint OverlayId = 0x100001A0; - /// Selected-object health meter element id (retail m_pSelObjectHealthMeter). - public const uint HealthMeterId = 0x100001A1; - - /// Selection-overlay flash duration — retail's container ObjectSelected state is a - /// Pause(0.25s)→Normal transition (toolbar dump, element 0x1000019E). - private const double FlashSeconds = 0.25; - - /// Z-order for the name so it draws OVER the overlay frame + health bar. - /// The strip's other children sit at ReadOrder 1–4; this floats the name to the top. - private const int NameZOrderOnTop = 1_000_000; - - /// Z-order for the selection-flash overlay — above the health meter (so the green - /// flash isn't hidden by the bar) but below the name (so the name stays readable). - private const int OverlayZOrder = NameZOrderOnTop - 1; - - /// Height (px) of the black name band at the top of the 31px bar sprite. The name - /// label is constrained to this band (top-aligned) so the health bar shows below it — - /// retail "name on the black, bar below". The bar sprite's colored region starts ~y14. - private const float NameBandHeight = 15f; - - // ── Found elements (any may be null for partial/test layouts) ─────────── - private readonly UiElement? _name; - private readonly UiDatElement? _overlay; - private readonly UiMeter? _healthMeter; - - // ── Captured delegates ─────────────────────────────────────────────────── - private readonly Func _isHealthTarget; - private readonly Func _resolveName; - private readonly Func _healthPercent; - private readonly Func _hasHealth; - private readonly Func _stackSize; - private readonly Action _sendQueryHealth; - - // ── Live state (read by closures on the per-frame draw path) ──────────── - private uint? _current; - private string? _currentName; - private double _flashRemaining; // > 0 while the selection overlay is flashing - - /// White label color for the name line. - private static readonly Vector4 NameColor = new(1f, 1f, 1f, 1f); - - private SelectedObjectController( - ImportedLayout layout, - Action> subscribeSelectionChanged, - Action> subscribeHealthChanged, - Func isHealthTarget, - Func name, - Func healthPercent, - Func hasHealth, - Func stackSize, - Action sendQueryHealth, - UiDatFont? datFont) - { - _isHealthTarget = isHealthTarget; - _resolveName = name; - _healthPercent = healthPercent; - _hasHealth = hasHealth; - _stackSize = stackSize; - _sendQueryHealth = sendQueryHealth; - - // Find elements — silently skip absent ones (partial/test layouts). - _name = layout.FindElement(NameId); - _overlay = layout.FindElement(OverlayId) as UiDatElement; - _healthMeter = layout.FindElement(HealthMeterId) as UiMeter; - - // The selection-flash overlay must draw OVER the health meter (which spans the whole - // strip) — otherwise the meter hides the green flash whenever a bar is visible (i.e. - // for players/monsters). Float it just below the name so the name stays readable. - if (_overlay is not null) _overlay.ZOrder = OverlayZOrder; - - // This controller owns the health meter's initial-hidden state. - if (_healthMeter is not null) - { - _healthMeter.Visible = false; - // Fill polls live: _current holds the currently-selected guid (or null). - _healthMeter.Fill = () => _current is uint g ? _healthPercent(g) : (float?)0f; - } - - // Attach a centered UiText child to the name element for the object name display. - // Mirrors VitalsController.BindMeter's number attach. The name is floated to the - // top of the strip's z-order so it draws OVER the overlay frame and the health bar - // (retail renders the object name over the bar). - // - // The bar sprite (0x0600193E/F, 146x31) carries a ~14px BLACK name band across its - // TOP with the colored bar in the lower portion (confirmed from the dat). Retail - // draws the object name in that black band with the health bar BELOW it — so the - // label is TOP-aligned by constraining its height to the band, not centered over the - // whole 31px strip (which overlapped the bar's middle). - if (_name is not null) - { - _name.ZOrder = NameZOrderOnTop; - var label = new UiText - { - Left = 0f, Top = 0f, Width = _name.Width, Height = NameBandHeight, - Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, - Centered = true, - DatFont = datFont, - ClickThrough = true, - AcceptsFocus = false, - IsEditControl = false, - CapturesPointerDrag = false, - LinesProvider = () => - { - var n = _currentName; - return string.IsNullOrEmpty(n) - ? Array.Empty() - : new[] { new UiText.Line(n, NameColor) }; - }, - }; - _name.AddChild(label); - } - - // Register the handlers LAST so the initial state is fully set up first. - subscribeSelectionChanged(OnSelectionChanged); - subscribeHealthChanged(OnHealthChanged); - } - - /// - /// Create and bind a to . - /// Port of retail gmToolbarUI::HandleSelectionChanged + RecvNotice_UpdateObjectHealth. - /// - /// Imported toolbar layout (LayoutDesc 0x21000016). - /// Called once with - /// (typical host: h => SelectionChanged += h). - /// Called once with - /// (typical host: h => Combat.HealthChanged += h) — drives meter visibility. - /// Returns true for guids that may show a health meter - /// (proxy for retail's IsPlayer() || pet_owner || ObjectIsAttackable()). - /// Returns the display name for a given guid (or null if unknown). - /// Returns the health fill fraction [0..1] for a given guid. - /// Returns true if real health has been received for a guid - /// (so a re-selected, already-known target shows its bar immediately). - /// Returns the stack size for a guid (0 or 1 = non-stacked). - /// Sends retail QueryHealth (0x01BF); may be a no-op offline. - /// Dat font for the name label; null = debug bitmap font fallback. - public static SelectedObjectController Bind( - ImportedLayout layout, - Action> subscribeSelectionChanged, - Action> subscribeHealthChanged, - Func isHealthTarget, - Func name, - Func healthPercent, - Func hasHealth, - Func stackSize, - Action sendQueryHealth, - UiDatFont? datFont) - => new SelectedObjectController( - layout, subscribeSelectionChanged, subscribeHealthChanged, - isHealthTarget, name, healthPercent, hasHealth, stackSize, sendQueryHealth, datFont); - - /// - /// Port of gmToolbarUI::HandleSelectionChanged (:198635): - /// clear-then-populate the selected-object strip on any selection change. - /// - public void OnSelectionChanged(uint? guid) - { - // ── 1. Clear first (retail: SetText("") + m_pSelObjectField->SetState(0) - // + SetVisible(0) on the meters). ────────────────────────────────────── - if (_healthMeter is not null) _healthMeter.Visible = false; - _currentName = null; - _current = guid; - - if (guid is null) - { - // Deselect: clear the overlay flash immediately too. - SetOverlayState(""); - _flashRemaining = 0; - return; - } - - uint g = guid.Value; - - // ── 2. Name (displayed via the UiText child's LinesProvider reading _currentName). ── - _currentName = _resolveName(g); - - // ── 3. Selection overlay: brief flash (retail container ObjectSelected - // = Pause(0.25s)→Normal). "StackedItemSelected" for stacks. ────────────── - SetOverlayState(_stackSize(g) > 1u ? "StackedItemSelected" : "ObjectSelected"); - _flashRemaining = FlashSeconds; - - // ── 4. Health: query, and show the meter only if real health is already known. - // Otherwise the meter appears when OnHealthChanged fires for this guid - // (retail RecvNotice_UpdateObjectHealth :196213). ────────────────────────── - if (_isHealthTarget(g)) - { - _sendQueryHealth(g); - if (_hasHealth(g) && _healthMeter is not null) - _healthMeter.Visible = true; - } - } - - /// - /// Port of gmToolbarUI::RecvNotice_UpdateObjectHealth (:196213): when the - /// server reports health for the currently-selected guid, make the Health meter visible. - /// The fill value is read live by the meter's provider. - /// - public void OnHealthChanged(uint guid, float percent) - { - if (_current is uint c && c == guid && _isHealthTarget(guid) && _healthMeter is not null) - _healthMeter.Visible = true; - } - - /// Per-frame tick: reverts the selection overlay after the brief flash window. - public void Tick(double deltaSeconds) - { - if (_flashRemaining <= 0) return; - _flashRemaining -= deltaSeconds; - if (_flashRemaining <= 0) - SetOverlayState(""); // flash done → overlay back to blank - } - - private void SetOverlayState(string state) - { - if (_overlay is not null) _overlay.ActiveState = state; - } -} diff --git a/src/AcDream.App/UI/Layout/ToolbarController.cs b/src/AcDream.App/UI/Layout/ToolbarController.cs deleted file mode 100644 index e4fd00c4..00000000 --- a/src/AcDream.App/UI/Layout/ToolbarController.cs +++ /dev/null @@ -1,368 +0,0 @@ -using System; -using System.Collections.Generic; -using AcDream.Core.Combat; -using AcDream.Core.Items; -using AcDream.Core.Net.Messages; - -namespace AcDream.App.UI.Layout; - -/// -/// Binds the imported gmToolbarUI window (LayoutDesc 0x21000016) to live data — -/// the gm*UI::PostInit analogue. Finds the 18 shortcut slots (UiItemList) by id, -/// populates them from the persisted PlayerDescription shortcuts -/// (UpdateFromPlayerDesc), re-binds deferred slots when an item's CreateObject -/// arrives (SetDelayedShortcutNum), and on click uses the bound item -/// (UseShortcut -> ItemHolder::UseObject -> use-item callback). -/// -/// -/// Retail reference: gmToolbarUI::PostInit grabs each slot widget by its -/// id, calls UpdateFromPlayerDesc to flush-and-bind shortcuts from the -/// PlayerDescription trailer, and hooks OnEvent for the Click case to fire -/// UseShortcut. The deferred-rebind path matches -/// gmToolbarUI::SetDelayedShortcutNum which re-tries binding after -/// CreateObject resolves a formerly-unknown guid. -/// -/// -public sealed class ToolbarController : IItemListDragHandler -{ - // Slot element ids in slot-index order (toolbar LayoutDesc 0x21000016, pre-dump). - // Row 1 = slots 0-8 (0x100001A7..0x100001AF), Row 2 = slots 9-17 (0x100006B7..0x100006BF). - private static readonly uint[] SlotIds = - { - 0x100001A7, 0x100001A8, 0x100001A9, 0x100001AA, 0x100001AB, - 0x100001AC, 0x100001AD, 0x100001AE, 0x100001AF, - 0x100006B7, 0x100006B8, 0x100006B9, 0x100006BA, 0x100006BB, - 0x100006BC, 0x100006BD, 0x100006BE, 0x100006BF, - }; - - // Elements hidden by default in retail gmToolbarUI::PostInit. - // Ids confirmed from the toolbar LayoutDesc dump. - // 0x100001A1 (health meter) is now OWNED by SelectedObjectController (D.5.3a) — - // it hides A1 at bind and shows it on a health-target selection, so A1 is removed - // from here to avoid double-ownership. 0x100001A2 (mana meter), 0x100001A3 (stack-size - // entry box) and 0x100001A4 (stack slider) are DEFERRED features (mana #140, stack-split - // UI) with no controller yet, so they stay hidden here — otherwise their dat sprites - // render as stray bars / a black box on the toolbar. Retail hides A3/A4 in - // gmToolbarUI::HandleSelectionChanged (acclient_2013_pseudo_c.txt:198660/198742), - // showing them only for a stacked-item selection. - private static readonly uint[] HiddenIds = { 0x100001A2, 0x100001A3, 0x100001A4 }; - - // Four mutually-exclusive combat-mode indicator elements — exactly one visible at a time. - // Index 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic. - // Retail ref: gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669) - // SetVisible's exactly one element depending on the incoming mode. - private static readonly uint[] CombatIndicatorIds = - { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u }; - - private readonly UiItemList?[] _slots = new UiItemList?[SlotIds.Length]; - private readonly UiElement?[] _combatIndicators = new UiElement?[CombatIndicatorIds.Length]; - private readonly ClientObjectTable _repo; - private readonly Func> _shortcuts; - private readonly Func _iconIds; // (itemType, icon, underlay, overlay, effects) → GL tex - private readonly Action _useItem; // guid → fire UseObject - private readonly AcDream.Core.Items.ShortcutStore _store = new(); - private bool _storeLoaded; - private readonly Action? _sendAddShortcut; // (index, objectGuid) - private readonly Action? _sendRemoveShortcut; // (index) - - // Digit sprite DID arrays for slot labels (top row, numbers 1-9). - // Read from LayoutDesc 0x21000037, element 0x1000034A under composite 0x10000346. - // Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465); - // gmToolbarUI::RecvNotice_SetCombatMode (196610-196621) re-stamps per stance. - // Occupancy branch (decomp 229481): - // occupied → peace 0x10000042 / war 0x10000043 (split by stance) - // empty → background digit 0x1000005e (stance-independent) - private uint[]? _peaceDigits; - private uint[]? _warDigits; - private uint[]? _emptyDigits; - private bool _peace = true; // true = NonCombat (peace), false = any war stance - - private ToolbarController( - ImportedLayout layout, - ClientObjectTable repo, - Func> shortcuts, - Func iconIds, - Action useItem, - CombatState? combatState, - uint[]? peaceDigits, - uint[]? warDigits, - uint[]? emptyDigits, - Action? sendAddShortcut = null, - Action? sendRemoveShortcut = null) - { - _repo = repo; - _shortcuts = shortcuts; - _iconIds = iconIds; - _useItem = useItem; - _peaceDigits = peaceDigits; - _warDigits = warDigits; - _emptyDigits = emptyDigits; - _sendAddShortcut = sendAddShortcut; - _sendRemoveShortcut = sendRemoveShortcut; - - for (int i = 0; i < SlotIds.Length; i++) - { - _slots[i] = layout.FindElement(SlotIds[i]) as UiItemList; - if (_slots[i] is { } list) - { - WireClick(list); - // B.1 drag-drop spine: this controller is the drop handler for every - // toolbar slot list; each cell knows its slot index + that it's a - // shortcut-bar source (retail UIElement_ItemList::RegisterItemListDragHandler). - list.RegisterDragHandler(this); - list.Cell.SlotIndex = i; - list.Cell.SourceKind = ItemDragSource.ShortcutBar; - list.Cell.DragAcceptSprite = 0x060011FAu; // green cross (toolbar), not the ring 0x060011F9 (inventory) - } - } - - // Cache the four mutually-exclusive combat-mode indicator elements. - for (int i = 0; i < CombatIndicatorIds.Length; i++) - _combatIndicators[i] = layout.FindElement(CombatIndicatorIds[i]); - - // Hide target-object meters + stack slider (gmToolbarUI::PostInit). - foreach (var id in HiddenIds) - if (layout.FindElement(id) is { } e) e.Visible = false; - - // Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669): - // exactly one indicator visible at a time. Default to NonCombat (peace) — the player - // always spawns in peace mode; retail has not yet called SetVisible when PostInit runs. - SetCombatMode(CombatMode.NonCombat); - - // Wire live combat-mode changes if a CombatState was provided. - if (combatState is not null) - combatState.CombatModeChanged += SetCombatMode; - - // D.5.4: the table now holds ALL objects (creatures, NPCs, etc.), so filter - // to our 18 shortcut guids — else every creature spawn in a busy zone - // needlessly re-populates the bar (gmToolbarUI::SetDelayedShortcutNum pattern). - repo.ObjectAdded += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); }; - repo.ObjectUpdated += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); }; - repo.ObjectRemoved += o => { if (IsShortcutGuid(o.ObjectId)) Populate(); }; - } - - /// - /// Returns true if is one of the currently-active shortcut guids - /// (i.e., present in the live ). - /// Used to gate repo-event subscriptions so we don't re-populate on every creature spawn. - /// Falls back to scanning _shortcuts() before the store is loaded (pre-PD window). - /// - private bool IsShortcutGuid(uint guid) - { - if (_storeLoaded) - { - for (int s = 0; s < AcDream.Core.Items.ShortcutStore.SlotCount; s++) - if (_store.Get(s) == guid) return true; - return false; - } - // Store not yet loaded — fall back to the shortcuts provider (pre-PD window). - foreach (var sc in _shortcuts()) - if (sc.ObjectGuid == guid) return true; - return false; - } - - /// - /// Create and bind a to . - /// Calls immediately (binds whatever items are in the repo now). - /// Returns the controller so the caller can call again - /// if the shortcut list is refreshed outside the repo-event path. - /// - /// Imported toolbar layout (LayoutDesc 0x21000016). - /// Live item repository — must stay alive for the controller's lifetime. - /// Provider for the current shortcut bar list. - /// Resolves (itemType, iconId, underlayId, overlayId, effects) → GL texture handle. - /// Callback fired when a bound slot is clicked; receives the item guid. - /// - /// Optional live combat state — when provided, the toolbar subscribes to - /// and updates the four mutually-exclusive - /// combat-mode indicator elements accordingly. - /// Pass null to skip live wiring (e.g. in unit tests that don't exercise the indicator). - /// - /// - /// Peace-mode digit DID array (property 0x10000042 from LayoutDesc 0x21000037 element - /// 0x1000034A under composite 0x10000346). Index i → slot label digit (i+1) RenderSurface id. - /// Null if the dat lookup failed (no digits drawn). Retail reference: - /// UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). - /// - /// War-mode digit DID array (property 0x10000043, same element). - /// - /// Empty-slot background digit DID array (property 0x1000005e, stance-independent). - /// Used when a slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum - /// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty state). - /// Null if the dat lookup failed (empty slots draw no digit, which is safe). - /// - public static ToolbarController Bind( - ImportedLayout layout, - ClientObjectTable repo, - Func> shortcuts, - Func iconIds, - Action useItem, - CombatState? combatState = null, - uint[]? peaceDigits = null, - uint[]? warDigits = null, - uint[]? emptyDigits = null, - Action? sendAddShortcut = null, - Action? sendRemoveShortcut = null) - { - var c = new ToolbarController(layout, repo, shortcuts, iconIds, useItem, combatState, - peaceDigits, warDigits, emptyDigits, - sendAddShortcut, sendRemoveShortcut); - c.Populate(); - return c; - } - - /// - /// Port of gmToolbarUI::UpdateFromPlayerDesc: clear all slots, then bind - /// each shortcut entry that has a resolved item in the repository. - /// Entries whose item is not yet in the repo are silently skipped here; the - /// ObjectAdded event re-fires this method when the item arrives - /// (matching retail's SetDelayedShortcutNum deferred-rebind path). - /// As of B.2: the is the - /// authoritative in-session slot map; _shortcuts() seeds it on first call. - /// - public void Populate() - { - // Lazy-load the store from the login shortcut list the first time it's present (PD arrives - // after Bind); thereafter the store is authoritative and drag ops mutate it directly. - if (!_storeLoaded && _shortcuts().Count > 0) - { - _store.Load(System.Linq.Enumerable.Select(_shortcuts(), e => ((int)e.Index, e.ObjectGuid))); - _storeLoaded = true; - } - - foreach (var list in _slots) list?.Cell.Clear(); - - for (int slot = 0; slot < _slots.Length; slot++) - { - uint guid = _store.Get(slot); - if (guid == 0) continue; - var list = _slots[slot]; - if (list is null) continue; - var item = _repo.Get(guid); - if (item is null) continue; // deferred: ObjectAdded re-calls Populate - uint tex = _iconIds(item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects); - list.Cell.SetItem(guid, tex); - } - - // Re-stamp slot number labels after any item change. - // Digit SPRITE SOURCE depends on occupancy (decomp UIElement_UIItem::SetShortcutNum:229481): - // occupied → peace 0x10000042 / war 0x10000043; empty → background 0x1000005e. - // The digit is ALWAYS shown on top-row slots (SetVisible(1) at decomp 229511). - RestampShortcutNumbers(); - } - - /// - /// Port of gmToolbarUI::RecvNotice_SetCombatMode - /// (acclient_2013_pseudo_c.txt:196632-196669): show exactly one of the four - /// mutually-exclusive combat-mode indicator elements and hide the other three. - /// Called at bind-time with (the player - /// always starts in peace mode) and subsequently whenever - /// fires. - /// - public void SetCombatMode(CombatMode mode) - { - // Index → mode mapping matches CombatIndicatorIds declaration order: - // 0 = NonCombat (peace), 1 = Melee, 2 = Missile, 3 = Magic. - bool[] show = - { - mode == CombatMode.NonCombat, - mode == CombatMode.Melee, - mode == CombatMode.Missile, - mode == CombatMode.Magic, - }; - - for (int i = 0; i < _combatIndicators.Length; i++) - { - if (_combatIndicators[i] is { } e) - e.Visible = show[i]; - } - - // Re-stamp digit set: peace glyphs in NonCombat, war glyphs in any combat stance. - // Retail ref: gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196610-196621). - _peace = (mode == CombatMode.NonCombat); - RestampShortcutNumbers(); - } - - /// - /// Push digit-array references and shortcut-number state into every slot cell. - /// Top row (indices 0–8): SetShortcutNum(i, _peace) — numbers 1–9 always shown - /// (the digit is ALWAYS visible, SetVisible(1) at decomp 229511; only the sprite - /// SOURCE differs by occupancy — see UIElement_UIItem::SetShortcutNum decomp 229481). - /// Bottom row (indices 9–17): ClearShortcutNum() — retail shows no numbers there. - /// Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465); - /// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621). - /// Occupancy → source: occupied → peace 0x10000042 / war 0x10000043; - /// empty → background 0x1000005e (decomp 229481/229493). - /// - private void RestampShortcutNumbers() - { - for (int i = 0; i < _slots.Length; i++) - { - var cell = _slots[i]?.Cell; - if (cell is null) continue; - cell.PeaceDigits = _peaceDigits; - cell.WarDigits = _warDigits; - cell.EmptyDigits = _emptyDigits; - if (i < 9) - cell.SetShortcutNum(i, _peace); // top row: slot label digits 1–9 always shown - else - cell.ClearShortcutNum(); // bottom row: no slot labels - } - } - - /// - /// Wire the callback on a slot cell so that - /// clicking a bound item fires with the slot's current guid. - /// Mirrors retail's gmToolbarUI click → UseShortcut dispatch. - /// - private void WireClick(UiItemList list) - { - list.Cell.Clicked = () => - { - if (list.Cell.ItemId != 0) - _useItem(list.Cell.ItemId); - }; - } - - // ── IItemListDragHandler (B.2 live handler) ────────────────────────────── - // Retail: gmToolbarUI is the m_dragHandler for every shortcut slot list. - // Retail model (remove-on-lift / place-on-drop / no-restore): - // lift → RemoveShortcut (0x019D) + store.Remove (slot empties immediately) - // drop → AddShortcut (0x019C) + optional swap of evicted item into source - // off-bar release → the lift's removal stands (no restore) - // Retail ref: gmToolbarUI::HandleDropRelease acclient_2013_pseudo_c.txt:197971 - - /// - public void OnDragLift(UiItemList sourceList, UiItemSlot sourceCell, ItemDragPayload payload) - { - // Retail RecvNotice_ItemListBeginDrag → RemoveShortcut (0x004bd930/0x004bd450): the lifted - // shortcut leaves the bar (+ wire) the instant the drag starts; it's re-placed only on a - // drop onto a slot. Off-bar release leaves it removed. - _store.Remove(payload.SourceSlot); - _sendRemoveShortcut?.Invoke((uint)payload.SourceSlot); - Populate(); - } - - /// - public bool OnDragOver(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - => payload.ObjId != 0; - - /// - public void HandleDropRelease(UiItemList targetList, UiItemSlot targetCell, ItemDragPayload payload) - { - // Retail gmToolbarUI::HandleDropRelease (0x004be7c0) within-bar reorder branch (deep-dive §5): - // evict the target's occupant, place the dragged item there, and bump the evicted item into - // the (now-vacated) source slot if it's free. - int target = targetCell.SlotIndex; - uint evicted = _store.Get(target); // RemoveShortCutInSlotNum(target) - if (evicted != 0) { _store.Remove(target); _sendRemoveShortcut?.Invoke((uint)target); } - _store.Set(target, payload.ObjId); // AddShortcut(dragged, target) - _sendAddShortcut?.Invoke((uint)target, payload.ObjId); - if (evicted != 0 && evicted != payload.ObjId && _store.IsEmpty(payload.SourceSlot)) - { // displaced → vacated source slot - _store.Set(payload.SourceSlot, evicted); - _sendAddShortcut?.Invoke((uint)payload.SourceSlot, evicted); - } - Populate(); - } -} diff --git a/src/AcDream.App/UI/Layout/UiDatElement.cs b/src/AcDream.App/UI/Layout/UiDatElement.cs deleted file mode 100644 index a187f008..00000000 --- a/src/AcDream.App/UI/Layout/UiDatElement.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Numerics; - -namespace AcDream.App.UI.Layout; - -/// -/// Generic dat element: draws its active state's media by DrawMode (Normal=tile, -/// Alphablend/Overlay=blended overlay). The fallback renderer for every element type -/// without a dedicated behavioral widget (chrome corners/edges, drag bars, resize grips); -/// faithful because retail's base element render is exactly "stamp the media per draw-mode". -/// -/// -/// For Plan 1, all observed draw modes produce the same alpha-blended tiled quad — the -/// sprite shader already alpha-blends, so no per-mode branch is needed here. The named -/// constants document the real enum for Plan 2. -/// -/// -/// -/// DrawModeType (DatReaderWriter.Enums), stored as int in to -/// keep this dat-free. See docs/research/2026-06-15-layoutdesc-format.md §6: -/// Undefined=0, Normal=1, Overlay=2, Alphablend=3. There is no Stretch mode. -/// -/// -/// -/// Tiling uses UV-repeat on BOTH axes (Width/tw, Height/th) so vertical -/// chrome edges (e.g. a 5×10 sprite drawn over a 5×48 rect) tile vertically too. -/// sets -/// GL_REPEAT on both S and T, so vertical tiling is always active. -/// -/// -public sealed class UiDatElement : UiElement -{ - // DrawModeType enum values from DatReaderWriter.Enums. - // See docs/research/2026-06-15-layoutdesc-format.md §6. -#pragma warning disable IDE0051 // private constants kept for documentation / Plan 2 - private const int DrawUndefined = 0; - private const int DrawNormal = 1; - private const int DrawOverlay = 2; - private const int DrawAlphablend = 3; -#pragma warning restore IDE0051 - - private readonly ElementInfo _info; - private readonly Func _resolve; - - /// The dat element id from . Exposed so controllers - /// can identify which logical element a UiDatElement represents when walking subtrees - /// (e.g. footer state groups that appear once per tab page but share the same dat id). - public uint ElementId => _info.Id; - - /// Which state name to render. "" = the unnamed DirectState. - /// Falls back to DirectState if the named state is absent. - public string ActiveState { get; set; } = ""; - - /// Merged for this element. - /// Dat file-id → (GL texture handle, native px width, native px height). - /// Returns (0,0,0) when the texture is not yet uploaded. - public UiDatElement(ElementInfo info, Func resolve) - { - _info = info; - _resolve = resolve; - ClickThrough = true; // generic decoration; behavioral widgets opt back in - - // Pick the initial active state: retail applies DefaultState when set; falls back - // to "Normal" when the element has a Normal-state sprite (retail's implicit default - // for stateful elements like tabs and buttons); else the unnamed DirectState (""). - if (!string.IsNullOrEmpty(info.DefaultStateName)) - ActiveState = info.DefaultStateName; - else if (info.StateMedia.ContainsKey("Normal")) - ActiveState = "Normal"; - // else ActiveState stays "" (DirectState) - } - - /// - /// Returns the (File, DrawMode) for the current , - /// falling back to the DirectState ("" key) if the named state is absent. - /// Returns (0, 0) if neither exists. - /// - // exposed for unit testing - public (uint File, int DrawMode) ActiveMedia() - => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m - : _info.StateMedia.TryGetValue("", out var d) ? d - : (0u, 0); - - /// Optional click handler. Set by a controller for interactive dat - /// elements (e.g. the chat Send / max-min buttons). Requires - /// = false to receive click events. - public Action? OnClick { get; set; } - - public override bool OnEvent(in UiEvent e) - { - if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; } - return false; - } - - /// Optional centered text label drawn over the sprite (e.g. the "Send" - /// button face whose dat sprite is a blank frame). Null = sprite only. - public string? Label { get; set; } - /// Dat font for . Required for the label to draw. - public UiDatFont? LabelFont { get; set; } - /// Label color (default white). - public Vector4 LabelColor { get; set; } = Vector4.One; - - protected override void OnDraw(UiRenderContext ctx) - { - var (file, _) = ActiveMedia(); - if (file != 0) - { - var (tex, tw, th) = _resolve(file); - if (tex != 0 && tw != 0 && th != 0) - { - // Normal → TILE at native size on both axes (UV-repeat; GL_REPEAT-wrapped UI - // texture), matching ImgTex::TileCSI. Overlay/Alphablend use the same blit (the - // sprite shader already alpha-blends). No Stretch mode exists in DrawModeType. - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); - } - } - - // Centered text label over the sprite (retail draws button captions as text; - // their dat sprites are blank frames). - if (Label is { Length: > 0 } label && LabelFont is { } lf) - { - float tx = (Width - lf.MeasureWidth(label)) * 0.5f; - float ty = (Height - lf.LineHeight) * 0.5f; - ctx.DrawStringDat(lf, label, tx, ty, LabelColor); - } - } -} diff --git a/src/AcDream.App/UI/Layout/VitalsController.cs b/src/AcDream.App/UI/Layout/VitalsController.cs deleted file mode 100644 index 39f2f396..00000000 --- a/src/AcDream.App/UI/Layout/VitalsController.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System; -using System.Numerics; -using AcDream.App.UI; - -namespace AcDream.App.UI.Layout; - -/// -/// Per-window controller for the vitals layout (LayoutDesc 0x2100006C). -/// Mirrors retail gmVitalsUI::PostInit: grab the three meter elements -/// by their dat element ids and bind live data providers (fill fraction + cur/max -/// text) to each. This is the ONLY per-window code in the whole importer — pure -/// data wiring, not graphics. -/// -/// The slice sprites + dat font on each are already -/// set by during tree construction; this controller -/// only binds the dynamic vitals data. Do not touch meter rendering fields here. -/// -/// Element ids confirmed from -/// docs/research/2026-06-15-layoutdesc-format.md §11 -/// (vitals window 0x2100006C dump). -/// -public static class VitalsController -{ - /// Dat element id for the Health meter (0x100000E6). - public const uint Health = 0x100000E6; - /// Dat element id for the Stamina meter (0x100000EC). - public const uint Stamina = 0x100000EC; - /// Dat element id for the Mana meter (0x100000EE). - public const uint Mana = 0x100000EE; - - /// - /// Bind live vitals data providers to the Health, Stamina, and Mana meter - /// elements found in . Any meter whose id is absent - /// from the layout is silently skipped — partial layouts (e.g. test fakes) - /// do not cause errors. - /// - /// Imported vitals layout tree. - /// Provider returning Health fill fraction [0..1]. - /// Provider returning Stamina fill fraction [0..1]. - /// Provider returning Mana fill fraction [0..1]. - /// Provider returning Health "cur/max" overlay text. - /// Provider returning Stamina "cur/max" overlay text. - /// Provider returning Mana "cur/max" overlay text. - public static void Bind( - ImportedLayout layout, - Func healthPct, - Func staminaPct, - Func manaPct, - Func healthText, - Func staminaText, - Func manaText) - { - BindMeter(layout, Health, healthPct, healthText); - BindMeter(layout, Stamina, staminaPct, staminaText); - BindMeter(layout, Mana, manaPct, manaText); - } - - /// White cur/max numbers — matches the former UiMeter.LabelColor default. - private static readonly Vector4 NumberColor = new(1f, 1f, 1f, 1f); - - private static void BindMeter( - ImportedLayout layout, uint id, - Func pct, - Func text) - { - // Silently skip if the id is absent — missing meters are not an error (partial layouts). - if (layout.FindElement(id) is not UiMeter m) return; - - m.Fill = () => pct(); - - // Retail gmVitalsUI renders the cur/max as a real UIElement_Text centered over the - // bar — NOT a meter-internal label. Attach a centered UiText (non-interactive - // decoration) that fills + stretches with the meter, and stop the meter drawing its - // own label. UiText.Centered uses the SAME centering formula the meter's overlay did, - // so the numbers stay pixel-identical (locked by the visual gate). - m.Label = () => null; - - var number = new UiText - { - Left = 0f, Top = 0f, Width = m.Width, Height = m.Height, - Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom, - Centered = true, - DatFont = m.DatFont, // the same dat font the meter used for its label - ClickThrough = true, // decoration: no focus / selection / drag - AcceptsFocus = false, - IsEditControl = false, - CapturesPointerDrag = false, - LinesProvider = () => - { - var s = text(); - return string.IsNullOrEmpty(s) - ? Array.Empty() - : new[] { new UiText.Line(s, NumberColor) }; - }, - }; - m.AddChild(number); - } -} diff --git a/src/AcDream.App/UI/MarkupDocument.cs b/src/AcDream.App/UI/MarkupDocument.cs deleted file mode 100644 index 1132479b..00000000 --- a/src/AcDream.App/UI/MarkupDocument.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System; -using System.Globalization; -using System.Numerics; -using System.Reflection; -using System.Xml.Linq; - -namespace AcDream.App.UI; - -/// -/// Parses our KSML-style panel markup (mirrors retail's ElementDesc fields) -/// into a live subtree. {Binding} attribute -/// values resolve against a supplied object by property name (reflection). -/// This is the format the future LayoutDesc importer will emit. See D.2b spec §7. -/// -public static class MarkupDocument -{ - /// Raw XML markup for a single panel. - /// Object whose public properties are bound to {PropName} attributes. - /// Surface id → (GL handle, width, height) for chrome sprites. - /// Optional controls.ini stylesheet for the title color. - public static UiNineSlicePanel Build( - string xml, object binding, Func resolve, - ControlsIni? style = null) - { - var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup"); - if (root.Name.LocalName != "panel") - throw new FormatException($"root must be , got <{root.Name.LocalName}>"); - - var panel = new UiNineSlicePanel(resolve) - { - Left = F(root, "x"), - Top = F(root, "y"), - Width = F(root, "w"), - Height = F(root, "h"), - }; - - // Optional per-window resize-axis lock: resize="x" | "y" | "both" | "none". - string? resize = (string?)root.Attribute("resize"); - if (resize is not null) - { - panel.ResizeX = resize is "x" or "both"; - panel.ResizeY = resize is "y" or "both"; - } - - string? title = (string?)root.Attribute("title"); - if (!string.IsNullOrEmpty(title)) - { - Vector4 tc = style is not null && style.TryColor("title", "color", out var c) ? c : Vector4.One; - panel.AddChild(new UiLabel { Text = title, Left = 8, Top = 4, TextColor = tc }); - } - - foreach (var el in root.Elements()) - { - switch (el.Name.LocalName) - { - case "meter": - var cur = BindUint((string?)el.Attribute("cur"), binding); - var max = BindUint((string?)el.Attribute("max"), binding); - panel.AddChild(new UiMeter - { - Left = F(el, "x"), - Top = F(el, "y"), - Width = F(el, "w"), - Height = F(el, "h"), - BarColor = Color((string?)el.Attribute("color")), - Fill = BindFloat((string?)el.Attribute("fill"), binding), - Label = () => (cur(), max()) is (uint c, uint m) ? $"{c}/{m}" : null, - Anchors = Anchor((string?)el.Attribute("anchor")), - SpriteResolve = resolve, - BackLeft = Hex((string?)el.Attribute("backleft")), - BackTile = Hex((string?)el.Attribute("backtile")), - BackRight = Hex((string?)el.Attribute("backright")), - FrontLeft = Hex((string?)el.Attribute("frontleft")), - FrontTile = Hex((string?)el.Attribute("fronttile")), - FrontRight = Hex((string?)el.Attribute("frontright")), - }); - break; - // future element kinds (label, button, image) added here - } - } - return panel; - } - - private static float F(XElement e, string attr) - => float.TryParse((string?)e.Attribute(attr), NumberStyles.Float, - CultureInfo.InvariantCulture, out var v) ? v : 0f; - - /// - /// Parses #AARRGGBB → RGBA (alpha first, matching - /// controls.ini convention). Falls back to opaque white on bad input. - /// - private static Vector4 Color(string? hex) - { - if (hex is { Length: 9 } && hex[0] == '#' - && uint.TryParse(hex.AsSpan(1), NumberStyles.HexNumber, - CultureInfo.InvariantCulture, out uint argb)) - return new Vector4( - ((argb >> 16) & 0xFF) / 255f, - ((argb >> 8) & 0xFF) / 255f, - (argb & 0xFF) / 255f, - ((argb >> 24) & 0xFF) / 255f); - return Vector4.One; - } - - private static Func BindFloat(string? expr, object binding) - { - var pi = Prop(expr, binding); - if (pi is null) return () => 0f; - return () => pi.GetValue(binding) switch - { - float f => f, - null => (float?)null, - var v => Convert.ToSingle(v, CultureInfo.InvariantCulture), - }; - } - - private static Func BindUint(string? expr, object binding) - { - var pi = Prop(expr, binding); - if (pi is null) return () => null; - return () => pi.GetValue(binding) switch - { - uint u => u, - null => (uint?)null, - var v => Convert.ToUInt32(v, CultureInfo.InvariantCulture), - }; - } - - private static PropertyInfo? Prop(string? expr, object binding) - { - if (expr is null || expr.Length < 3 || expr[0] != '{' || expr[^1] != '}') return null; - return binding.GetType().GetProperty(expr[1..^1]); - } - - private static uint Hex(string? s) - { - if (string.IsNullOrWhiteSpace(s)) return 0; - var t = s.Trim(); - if (t.StartsWith("0x", System.StringComparison.OrdinalIgnoreCase)) t = t[2..]; - return uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, - System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u; - } - - private static AnchorEdges Anchor(string? csv) - { - if (string.IsNullOrWhiteSpace(csv)) return AnchorEdges.Left | AnchorEdges.Top; - var a = AnchorEdges.None; - foreach (var part in csv.Split(',', System.StringSplitOptions.TrimEntries | System.StringSplitOptions.RemoveEmptyEntries)) - a |= part.ToLowerInvariant() switch - { - "left" => AnchorEdges.Left, - "top" => AnchorEdges.Top, - "right" => AnchorEdges.Right, - "bottom" => AnchorEdges.Bottom, - _ => AnchorEdges.None, - }; - return a == AnchorEdges.None ? AnchorEdges.Left | AnchorEdges.Top : a; - } -} diff --git a/src/AcDream.App/UI/RetailChromeSprites.cs b/src/AcDream.App/UI/RetailChromeSprites.cs deleted file mode 100644 index f2a80fd7..00000000 --- a/src/AcDream.App/UI/RetailChromeSprites.cs +++ /dev/null @@ -1,66 +0,0 @@ -namespace AcDream.App.UI; - -/// -/// Retail window-chrome RenderSurface DataIds, CONFIRMED via the D.2b Step-0 -/// prove-out (2026-06-14). These are RenderSurface objects (0x06xxxxxx) decoded -/// DIRECTLY (), NOT -/// through the Surface→SurfaceTexture chain. -/// -/// -/// The universal floating-window bevel is an 8-piece border (4 corners -/// 5×5 + 4 edges) drawn around a tiled center fill — it is NOT a single -/// 9-slice texture. Decoded sizes are in the comments (from the prove-out). -/// -/// -/// -/// The edge/corner → position mapping below is a reasonable guess pending the -/// LayoutDesc 0x21000040 parse (sub-project 3) and is confirmed visually in the -/// first vitals-panel render. If a corner's bevel highlight looks wrong, swap -/// the four corner constants; if top/bottom or left/right look inverted, swap -/// those edge pairs. -/// -/// -public static class RetailChromeSprites -{ - /// Tiled interior fill — the shared panel background (48×48). - public const uint CenterFill = 0x06004CC2; - - /// Horizontal top edge (10×5, tiled across the top span). - public const uint TopEdge = 0x060074BF; - /// Horizontal bottom edge (10×5). - public const uint BottomEdge = 0x060074C1; - /// Vertical left edge (5×10). - public const uint LeftEdge = 0x060074C0; - /// Vertical right edge (5×10). - public const uint RightEdge = 0x060074C2; - - /// Top-left corner (5×5). - public const uint CornerTL = 0x060074C3; - /// Top-right corner (5×5). - public const uint CornerTR = 0x060074C4; - /// Bottom-left corner (5×5). - public const uint CornerBL = 0x060074C5; - /// Bottom-right corner (5×5). - public const uint CornerBR = 0x060074C6; - - /// Border thickness in pixels = the corner/edge sprite size (5px). - public const int Border = 5; - - // ── Resize-grip overlay ────────────────────────────────────────────── - // A second 8-piece layer drawn ON TOP of the bevel above: the gold ridged - // accents + square corner studs that frame a resizable retail window. From - // the vitals LayoutDesc 0x2100006C (elements 0x1000063B–0x10000642): each - // corner is the same 5×5 stud (0x06006129); the edges are gold double-line - // strips tiled along each side. These have transparent gaps, so the bevel - // shows through — both layers are needed. - /// Corner grip stud, all four corners (5×5). - public const uint GripCorner = 0x06006129; - /// Top edge grip (10×5, tiled across). - public const uint GripTop = 0x0600612A; - /// Left edge grip (5×10, tiled down). - public const uint GripLeft = 0x0600612B; - /// Bottom edge grip (10×5). - public const uint GripBottom = 0x0600612C; - /// Right edge grip (5×10). - public const uint GripRight = 0x0600612D; -} diff --git a/src/AcDream.App/UI/UiButton.cs b/src/AcDream.App/UI/UiButton.cs deleted file mode 100644 index 440b848c..00000000 --- a/src/AcDream.App/UI/UiButton.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System; -using System.Numerics; -using AcDream.App.UI.Layout; - -namespace AcDream.App.UI; - -/// -/// Generic dat-widget button — the production replacement for any dat element of -/// Type 1 (UIElement_Button, registered via RegisterElementClass(1, UIElement_Button::Create) -/// @ acclient_2013_pseudo_c.txt:125828). -/// -/// -/// Draws per-state sprite media exactly like (same -/// ActiveState defaulting, same ActiveMedia() fallback chain, same tiled -/// DrawSprite call with UV-repeat so chrome edges tile correctly) plus an -/// optional centered text label. The click behavior mirrors -/// one-for-one so the chat Send and Max/Min buttons that previously bound through -/// UiDatElement.OnClick continue to work without behavioral change. -/// -/// -/// -/// State selection: picks if set, then -/// "Normal" if the element has a Normal state sprite, then falls back to the unnamed -/// DirectState ("" key) — identical to . -/// -/// -/// -/// Built by for Type-1 elements (chat Send 0x10000019, -/// Max/Min 0x1000046F). NOT the same as , which is an -/// earlier dev-scaffold widget with no dat sprites. -/// -/// -public sealed class UiButton : UiElement -{ - private readonly ElementInfo _info; - private readonly Func _resolve; - - /// Optional click handler. Wired by the controller (e.g. chat Submit, ToggleMaximize). - public Action? OnClick { get; set; } - - /// Optional centered text label drawn over the sprite (e.g. "Send" on a blank gold frame). - public string? Label { get; set; } - - /// Dat font for . Required for the label to draw. - public UiDatFont? LabelFont { get; set; } - - /// Label color (default white). - public Vector4 LabelColor { get; set; } = Vector4.One; - - /// Horizontal alignment of . Center (default) for normal buttons; - /// Left for the paperdoll "Slots" caption that sits at the left edge, before the slots. - public LabelAlignment LabelAlign { get; set; } = LabelAlignment.Center; - - /// Label horizontal alignment options. - public enum LabelAlignment { Center, Left } - - /// - /// Active state name, runtime-settable (e.g. Max/Min toggling Normal ↔ Minimized). - /// Matches . - /// - public string ActiveState { get; set; } = ""; - - /// Merged for this element. - /// Dat file-id → (GL texture handle, native px width, native px height). - /// Returns (0,0,0) when the texture is not yet uploaded. - public UiButton(ElementInfo info, Func resolve) - { - _info = info; - _resolve = resolve; - ClickThrough = false; // buttons are interactive — opt OUT of click-through - - // State defaulting matches UiDatElement exactly: - // DefaultStateName wins; else "Normal" if that state has a sprite; else DirectState (""). - if (!string.IsNullOrEmpty(info.DefaultStateName)) - ActiveState = info.DefaultStateName; - else if (info.StateMedia.ContainsKey("Normal")) - ActiveState = "Normal"; - // else ActiveState stays "" (DirectState) - } - - /// The button draws its own face + label; any dat label child is reproduced - /// procedurally, so the importer must not build the button's children as widgets. - public override bool ConsumesDatChildren => true; - - /// A button is interactive — it must receive its Click even inside a whole-window-Draggable - /// frame (e.g. the paperdoll "Slots" toggle in the inventory window), so it opts out of the - /// IA-12 whole-window-drag that would otherwise swallow the press. - public override bool HandlesClick => true; - - /// - /// Returns the File id for the current , falling back to - /// the DirectState ("" key) if the named state is absent. - /// Returns 0 if neither exists. - /// Mirrors . - /// - private uint ActiveFile() - => _info.StateMedia.TryGetValue(ActiveState, out var m) ? m.File - : _info.StateMedia.TryGetValue("", out var d) ? d.File : 0u; - - protected override void OnDraw(UiRenderContext ctx) - { - uint file = ActiveFile(); - if (file != 0) - { - var (tex, tw, th) = _resolve(file); - if (tex != 0 && tw != 0 && th != 0) - { - // Tiled draw — same call shape as UiDatElement.OnDraw (UV-repeat; GL_REPEAT-wrapped - // UI texture). Matches ImgTex::TileCSI; no Stretch mode exists. - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); - } - } - - if (Label is { Length: > 0 } label && LabelFont is { } lf) - { - float tx = LabelAlign == LabelAlignment.Left - ? 3f // small left pad (room for a future checkbox box) - : (Width - lf.MeasureWidth(label)) * 0.5f; // centered (default) - float ty = (Height - lf.LineHeight) * 0.5f; - ctx.DrawStringDat(lf, label, tx, ty, LabelColor); - } - } - - public override bool OnEvent(in UiEvent e) - { - if (e.Type == UiEventType.Click && OnClick is not null) { OnClick(); return true; } - return false; - } -} diff --git a/src/AcDream.App/UI/UiCollapsibleFrame.cs b/src/AcDream.App/UI/UiCollapsibleFrame.cs deleted file mode 100644 index 7f1fc340..00000000 --- a/src/AcDream.App/UI/UiCollapsibleFrame.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace AcDream.App.UI; - -/// -/// A toolbar-frame variant that snaps its height between two stops — collapsed (row 2 hidden) and -/// expanded (row 2 shown) — and toggles a set of "second-row" elements to match. Resized via the -/// bottom edge (the mount sets = Bottom); each tick it resolves -/// the dragged height to the nearer stop so the frame always rests collapsed or expanded — never a -/// half-row. Toolkit UX (keystone.dll has no decomp; the dat stacks both rows always) — see IA-17. -/// -public sealed class UiCollapsibleFrame : UiNineSlicePanel -{ - public UiCollapsibleFrame(Func resolve) : base(resolve) { } - - public float CollapsedHeight { get; set; } - public float ExpandedHeight { get; set; } - /// Elements shown only when expanded (the row-2 slot lists). Hidden when collapsed. - public IReadOnlyList SecondRow { get; set; } = Array.Empty(); - - /// True when the frame is at (or nearer) the expanded stop. - public bool IsExpanded => Height >= (CollapsedHeight + ExpandedHeight) * 0.5f; - - protected override void OnTick(double deltaSeconds) - { - base.OnTick(deltaSeconds); - if (ExpandedHeight <= CollapsedHeight) return; // not configured yet — no snap - bool expanded = IsExpanded; - Height = expanded ? ExpandedHeight : CollapsedHeight; // snap the dragged height to a stop - for (int i = 0; i < SecondRow.Count; i++) SecondRow[i].Visible = expanded; - } - - /// Test hook — OnTick is protected. Drives one snap+visibility reconcile. - internal void TickForTest(double dt) => OnTick(dt); -} diff --git a/src/AcDream.App/UI/UiDatFont.cs b/src/AcDream.App/UI/UiDatFont.cs deleted file mode 100644 index 400ccf0f..00000000 --- a/src/AcDream.App/UI/UiDatFont.cs +++ /dev/null @@ -1,162 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.Rendering; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -namespace AcDream.App.UI; - -/// -/// A retail dat-font (DB_TYPE_FONT, id range 0x40000000-0x40000FFF) ready for -/// 2D drawing. Holds the two GL atlas textures (foreground glyph pixels + -/// background outline/shadow), the per-glyph descriptor table, and the line -/// metrics, so can blit each glyph -/// as two textured quads exactly the way the retail client does. -/// -/// -/// Retail render model — SurfaceWindow::DrawCharacter -/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits): for -/// each glyph it copies the BACKGROUND atlas sub-rect first, tinted with the -/// outline color (black), then the FOREGROUND atlas sub-rect, tinted with the -/// requested text color. The pen advances by -/// HorizontalOffsetBefore + Width + HorizontalOffsetAfter (the function's -/// return value, accumulated by the string loop at 0x00467ed4 -/// edi_3 += var_98), and each glyph is drawn starting at -/// penX + HorizontalOffsetBefore. -/// -/// -/// -/// Atlas format: the foreground atlas (0x06005EE5 for Font 0x40000000) is -/// PFID_A8 — alpha-only. Our SurfaceDecoder expands A8 to RGBA as -/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag, -/// uUseTexture==2) MULTIPLIES the sampled texel by the per-vertex tint -/// (texture(uTex,vUv) * vColor), so tinting a white+alpha glyph by a -/// color gives that color with the glyph's alpha — black for the outline pass, -/// text color for the fill pass. No shader change was needed. -/// -/// -public sealed class UiDatFont -{ - /// Retail UI font id (Latin-1, 16x16 max, with outline atlas). - public const uint DefaultFontId = 0x40000000u; - - /// Foreground (glyph pixels) GL texture handle + atlas pixel size. - public uint ForegroundTexture { get; } - public int ForegroundWidth { get; } - public int ForegroundHeight { get; } - - /// Background (outline/shadow) GL texture handle + atlas pixel size. - /// 0 when the font has no background atlas (then the outline pass is skipped). - public uint BackgroundTexture { get; } - public int BackgroundWidth { get; } - public int BackgroundHeight { get; } - - /// Vertical advance between lines (retail MaxCharHeight). - public float LineHeight { get; } - - /// Distance from a line's top to its baseline (retail BaselineOffset). - public float BaselineOffset { get; } - - private readonly Dictionary _glyphs; - - private UiDatFont( - uint fgTex, int fgW, int fgH, - uint bgTex, int bgW, int bgH, - float lineHeight, float baselineOffset, - Dictionary glyphs) - { - ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH; - BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH; - LineHeight = lineHeight; - BaselineOffset = baselineOffset; - _glyphs = glyphs; - } - - /// True if this font carries a separate outline/shadow atlas - /// (retail's m_pBackgroundSurface). When false the outline pass is - /// skipped and only the foreground (fill) glyphs are drawn. - public bool HasBackground => BackgroundTexture != 0; - - /// Look up a glyph descriptor for a character. Returns false for - /// characters not present in the font's table (callers skip them). - public bool TryGetGlyph(char c, out FontCharDesc glyph) => _glyphs.TryGetValue(c, out glyph!); - - /// - /// Load Font from the dat collection and upload - /// both atlases through the texture cache (the same direct-RenderSurface - /// path the D.2b chrome sprites use). Returns null if the Font DBObj is - /// missing — callers fall back to the debug bitmap font. - /// - public static UiDatFont? Load(DatCollection dats, TextureCache cache, uint fontId = DefaultFontId) - { - ArgumentNullException.ThrowIfNull(dats); - ArgumentNullException.ThrowIfNull(cache); - - if (!dats.TryGet(fontId, out var font) || font is null) - return null; - - // Foreground atlas is required; without it there are no glyph pixels. - if (font.ForegroundSurfaceDataId == 0) - return null; - - // Point-sample the glyph atlases (nearest) so small UI text stays pixel-crisp; - // bilinear softens the dat font noticeably (the chat menu/button text "blur"). - uint fgTex = cache.GetOrUploadRenderSurface(font.ForegroundSurfaceDataId, out int fgW, out int fgH, nearest: true); - - uint bgTex = 0; int bgW = 0, bgH = 0; - if (font.BackgroundSurfaceDataId != 0) - bgTex = cache.GetOrUploadRenderSurface(font.BackgroundSurfaceDataId, out bgW, out bgH, nearest: true); - - // Build the char->descriptor lookup. FontCharDesc.Unicode is the code - // point; for Latin-1 fonts this is a direct char cast. Last write wins - // on the rare duplicate (retail's Font::GetCharDesc does a linear scan - // and returns the first match, but the dat tables have no duplicates). - var glyphs = new Dictionary(font.CharDescs.Count); - foreach (var cd in font.CharDescs) - glyphs[(char)cd.Unicode] = cd; - - return new UiDatFont( - fgTex, fgW, fgH, - bgTex, bgW, bgH, - lineHeight: font.MaxCharHeight, - baselineOffset: font.BaselineOffset, - glyphs); - } - - /// - /// Total pen advance (in pixels) for , summing each - /// glyph's retail advance. Characters not in the font contribute nothing. - /// - public float MeasureWidth(string text) - => MeasureWidth(text, c => _glyphs.TryGetValue(c, out var g) ? g : null); - - /// - /// Pure pen-advance summation seam: total width of - /// given a that maps each char to its descriptor - /// (null = not in the font → contributes nothing). Lets the advance math be - /// unit-tested with synthetic glyphs, with no GL or dat dependency. - /// - public static float MeasureWidth(string? text, Func lookup) - { - ArgumentNullException.ThrowIfNull(lookup); - if (string.IsNullOrEmpty(text)) return 0f; - float w = 0f; - for (int i = 0; i < text.Length; i++) - if (lookup(text[i]) is { } g) - w += GlyphAdvance(g); - return w; - } - - /// - /// The retail per-glyph horizontal advance: - /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter. This is the - /// value SurfaceWindow::DrawCharacter returns for proportional text - /// (flag bit 0x10 set, acclient 0x00442c3a) and the string loop accumulates - /// into the pen. Pulled out as a pure static so the math is unit-testable - /// without GL or the dat. - /// - public static float GlyphAdvance(FontCharDesc g) - => g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; -} diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs index 20c328e4..ae9a0a7c 100644 --- a/src/AcDream.App/UI/UiElement.cs +++ b/src/AcDream.App/UI/UiElement.cs @@ -4,11 +4,6 @@ using System.Numerics; namespace AcDream.App.UI; -/// Which parent edges a child keeps a fixed margin to on resize. -/// Left+Right ⇒ width stretches; Top+Bottom ⇒ height stretches. -[System.Flags] -public enum AnchorEdges { None = 0, Left = 1, Top = 2, Right = 4, Bottom = 8 } - /// /// Base class for every UI widget in the retained-mode tree. /// @@ -93,64 +88,6 @@ public abstract class UiElement /// Painter's-algorithm z-order within siblings. Higher = on top. public int ZOrder { get; set; } - /// Window opacity (0..1) multiplied into this element's and its - /// descendants' background + sprite draws (text stays opaque). 1 = fully opaque. - /// Set on a top-level window (e.g. the chat frame) for retail's translucent chat. - public float Opacity { get; set; } = 1f; - - /// If true, a left-drag on this element (or a non-draggable child of - /// it) repositions it as a movable window. Intended for top-level panels, - /// whose Left/Top are screen coordinates (Root sits at the origin). - public bool Draggable { get; set; } - - /// If true, a left-drag starting near this element's edge/corner - /// resizes it (window resize). Intended for top-level panels. - public bool Resizable { get; set; } - - /// If true, a left-drag starting on this element is delivered to the - /// element (e.g. text selection) instead of moving/resizing an ancestor window. - /// Edge resize on a resizable ancestor still wins — only the interior move / - /// drag-drop candidacy is suppressed in favour of the element's own handling. - public bool CapturesPointerDrag { get; set; } - - /// If true, a left-press-and-move on this element starts a DRAG-DROP - /// ( promotes to BeginDrag) rather than moving a Draggable - /// ancestor window — so an item cell inside the toolbar frame drags the item, not - /// the window. Distinct from (a self-driven - /// interior drag like text selection, which does NOT promote to BeginDrag). Default - /// false; overridden by drag sources (e.g. an occupied ). - public virtual bool IsDragSource => false; - - /// If true, a left-press on this element is handled BY the element (it receives the Click - /// on release) instead of being captured as a whole-window move on a Draggable ancestor. Set by - /// interactive leaf widgets (e.g. ) so they stay clickable inside a - /// whole-window-Draggable frame like the inventory window — where, without this, the IA-12 - /// whole-window-drag swallows the press and the Click is never emitted. Distinct from - /// (starts a drag-drop) and (a - /// self-driven interior drag such as text selection). Default false. - public virtual bool HandlesClick => false; - - /// Minimum size enforced while resizing. - public float MinWidth { get; set; } = 40f; - public float MinHeight { get; set; } = 40f; - - /// Maximum height enforced while resizing (default unbounded). Pairs with MinHeight. - public float MaxHeight { get; set; } = float.MaxValue; - - /// Allow horizontal (width) resize. Ignored unless . - public bool ResizeX { get; set; } = true; - /// Allow vertical (height) resize. Ignored unless . - public bool ResizeY { get; set; } = true; - - /// Which edges may start a resize, beyond the ResizeX/ResizeY axis gates. Default: all. - /// Set to e.g. to allow only a bottom-edge drag (the collapse toolbar). - public ResizeEdges ResizableEdges { get; set; } = - ResizeEdges.Left | ResizeEdges.Right | ResizeEdges.Top | ResizeEdges.Bottom; - - /// Edges this element anchors to in its parent. Default Left|Top - /// (pinned top-left, fixed size — no reflow). Left|Right stretches width. - public AnchorEdges Anchors { get; set; } = AnchorEdges.Left | AnchorEdges.Top; - // ── Tree structure ────────────────────────────────────────────────── public UiElement? Parent { get; private set; } @@ -171,19 +108,6 @@ public abstract class UiElement return true; } - /// - /// True if this widget draws its full appearance itself and REPRODUCES its dat - /// sub-elements procedurally (3-slice caps, button labels, scroll arrows, popup - /// rows…) — so the must NOT build - /// those dat child elements as separate widgets (they would double-draw and, worse, - /// steal pointer/focus from the behavioral widget). All registered behavioral widgets - /// (Meter/Menu/Button/Scrollbar/Text/Field) return true; the generic container - /// () and panels return false - /// and recurse their children normally. Mirrors retail, where each - /// UIElement_X::DrawSelf owns its internal structure. - /// - public virtual bool ConsumesDatChildren => false; - // ── Virtual overrides ─────────────────────────────────────────────── /// @@ -192,25 +116,6 @@ public abstract class UiElement /// protected virtual void OnDraw(UiRenderContext ctx) { } - /// - /// Draw AFTER this element's own children, but still within this element's - /// transform/alpha (NOT a global pass like ). Use for a - /// window FRAME border, which must be the outermost layer drawn OVER its content's - /// edges (so content can't poke through the frame), while the frame's center fill - /// stays a background in . Default: nothing. - /// - protected virtual void OnDrawAfterChildren(UiRenderContext ctx) { } - - /// - /// Draw content that must sit ON TOP of the ENTIRE UI, regardless of this - /// element's position in the tree — open menus, dropdowns, tooltips. Called in - /// a SECOND traversal after the whole tree's pass, with the - /// same accumulated transform/alpha this element had during its normal draw. - /// Retail spawns popups as ROOT elements (UIElement_Menu::MakePopup) for exactly - /// this reason; this is the equivalent without reparenting. Default: nothing. - /// - protected virtual void OnDrawOverlay(UiRenderContext ctx) { } - /// Per-frame tick (animations, timers, caret blink). protected virtual void OnTick(double deltaSeconds) { } @@ -228,16 +133,6 @@ public abstract class UiElement /// public virtual bool OnEvent(in UiEvent e) => false; - /// The data this element carries when a drag begins. - /// pulls this on drag-promote; a NULL return CANCELS the drag (retail: - /// ItemList_BeginDrag only arms an occupied cell). Default null = not draggable. - public virtual object? GetDragPayload() => null; - - /// The texture paints at the cursor while this element - /// is the drag source: (GL handle, width, height). Null = no ghost. Keeps - /// item-agnostic. Retail analog: m_dragIcon (decomp 229738). - public virtual (uint tex, int w, int h)? GetDragGhost() => null; - /// /// Tooltip text for this widget. Retail fires event 0x07 after /// ~1000ms hover, then queries the widget's virtual "GetString" @@ -251,18 +146,12 @@ public abstract class UiElement { if (!Visible) return; - // Translate into our local space + push this window's opacity (multiplies into - // descendants' sprite/rect draws; text bypasses the alpha so it stays sharp). + // Translate into our local space. ctx.PushTransform(Left, Top); - ctx.PushAlpha(Opacity); try { OnDraw(ctx); - // Anchor layout: reflow children to this element's current size. - for (int i = 0; i < _children.Count; i++) - _children[i].ApplyAnchor(Width, Height); - // Children painted back-to-front (lowest ZOrder first). if (_children.Count > 0) { @@ -272,42 +161,9 @@ public abstract class UiElement for (int i = 0; i < ordered.Length; i++) ordered[i].DrawSelfAndChildren(ctx); } - - // Foreground pass for this element (e.g. a window frame's border drawn - // OVER its content's edges). Default no-op for ordinary elements. - OnDrawAfterChildren(ctx); } finally { - ctx.PopAlpha(); - ctx.PopTransform(); - } - } - - /// Second draw traversal: re-walks the tree applying the same - /// transform/alpha as and calls - /// on each element, so popups composite on top of - /// everything drawn in the main pass (dat-font glyphs and sprites share one - /// submission-ordered bucket, so later submissions win). - internal void DrawOverlays(UiRenderContext ctx) - { - if (!Visible) return; - ctx.PushTransform(Left, Top); - ctx.PushAlpha(Opacity); - try - { - OnDrawOverlay(ctx); - if (_children.Count > 0) - { - var ordered = _children.ToArray(); - Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder)); - for (int i = 0; i < ordered.Length; i++) - ordered[i].DrawOverlays(ctx); - } - } - finally - { - ctx.PopAlpha(); ctx.PopTransform(); } } @@ -327,14 +183,9 @@ public abstract class UiElement /// internal UiElement? HitTest(float localX, float localY) { - if (!Visible || !Enabled) return null; + if (!Visible || !Enabled || ClickThrough) return null; - // Children first, in reverse Z-order (topmost first). ClickThrough means - // THIS element is transparent to the pointer — but its children are NOT. - // A ClickThrough container (e.g. a UiDatElement panel that hosts the chat - // input / transcript) must still let the pointer reach its behavioral - // children, so the ClickThrough check happens AFTER the child walk, gating - // only whether THIS element claims the hit. + // Children first, in reverse Z-order (topmost first). if (_children.Count > 0) { var ordered = _children.ToArray(); @@ -347,70 +198,6 @@ public abstract class UiElement } } - if (ClickThrough) return null; return OnHitTest(localX, localY) ? this : null; } - - // ── Anchor layout ──────────────────────────────────────────────────── - - private bool _anchorCaptured; - private float _amL, _amT, _amR, _amB, _aw0, _ah0; - - /// Reposition/resize this element per , keeping - /// the margins captured (at first layout / design size) to each anchored edge. - /// Called by the parent each frame before drawing children. - internal void ApplyAnchor(float parentW, float parentH) - { - if (Anchors == AnchorEdges.None) return; - if (!_anchorCaptured) - { - _amL = Left; _amT = Top; - _amR = parentW - (Left + Width); - _amB = parentH - (Top + Height); - _aw0 = Width; _ah0 = Height; - _anchorCaptured = true; - } - var (x, y, w, h) = ComputeAnchoredRect(Anchors, _amL, _amT, _amR, _amB, _aw0, _ah0, parentW, parentH); - Left = x; Top = y; Width = w; Height = h; - } - - /// Forget the captured anchor margins so the next - /// re-captures them from the CURRENT rect. Call after manually repositioning/resizing - /// an anchored element at runtime (e.g. reflowing the chat input when the channel - /// button width changes) so the new rect becomes the anchor baseline. - internal void ResetAnchorCapture() => _anchorCaptured = false; - - /// Walk up to the owning (the top of the tree), or null - /// if this element is not attached. Lets a widget reach focus/capture services — e.g. - /// a chat input blurring itself (exiting write mode) after submit. - internal UiRoot? FindRoot() - { - UiElement e = this; - while (e.Parent is not null) e = e.Parent; - return e as UiRoot; - } - - /// Compute an anchored child rect. Left&Right ⇒ stretch width - /// (keep both margins); Right only ⇒ pin to right at fixed width; otherwise - /// pin left at fixed width. Same logic vertically. - public static (float x, float y, float w, float h) ComputeAnchoredRect( - AnchorEdges a, float mL, float mT, float mR, float mB, - float w0, float h0, float parentW, float parentH) - { - bool l = (a & AnchorEdges.Left) != 0, r = (a & AnchorEdges.Right) != 0; - float x, w; - if (l && r) { x = mL; w = parentW - mR - mL; } - else if (r) { w = w0; x = parentW - mR - w0; } - else { x = mL; w = w0; } - - bool t = (a & AnchorEdges.Top) != 0, b = (a & AnchorEdges.Bottom) != 0; - float y, h; - if (t && b) { y = mT; h = parentH - mB - mT; } - else if (b) { h = h0; y = parentH - mB - h0; } - else { y = mT; h = h0; } - - if (w < 0) w = 0; - if (h < 0) h = 0; - return (x, y, w, h); - } } diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs deleted file mode 100644 index 9bc7ef32..00000000 --- a/src/AcDream.App/UI/UiField.cs +++ /dev/null @@ -1,420 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// Generic editable one-line field widget. Port of retail UIElement_Field -/// (RegisterElementClass(3) @ acclient_2013_pseudo_c.txt:126190). Carries -/// retail Field's drag-drop hooks (CatchDroppedItem/MouseOverTop) -/// as stubs for future item-window use. -/// -/// -/// Caret is a glyph index; the caret pixel-X is Σ glyph advances (UiDatFont) to the -/// caret. Supports mouse + Shift-arrow SELECTION, clipboard cut/copy/paste, and -/// held-key auto-repeat (hold Backspace deletes continuously). Submit (Enter / Send) -/// fires , clears, and pushes history (100-entry cap, -/// sentinel 0xFFFFFFFF — port of ChatInterface::ProcessCommand @0x4f5100). -/// -/// -/// Decomp: UIElement_Text MoveCursor @0x468d00, FindPixelsFromPos @0x472b40. -/// -public sealed class UiField : UiElement -{ - public UiDatFont? DatFont { get; set; } - public AcDream.App.Rendering.BitmapFont? Font { get; set; } - public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); - public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); - /// Selected-span highlight (translucent blue, behind the text). - public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); - public float Padding { get; set; } = 4f; - public int MaxCharacters { get; set; } = 0xFFFF; - - /// Keyboard device for clipboard (Ctrl+C/X/V) + modifier state (Ctrl/Shift). - /// Wired by the host from . - public Silk.NET.Input.IKeyboard? Keyboard { get; set; } - - /// Dat sprite resolver (id → GL texture + size) for the focused-field - /// background. Null = fall back to the flat rect. - public Func? SpriteResolve { get; set; } - /// Gold "lit" field background drawn when focused (retail Normal_focussed - /// state, RenderSurface 0x060011AB). 0 = no focus sprite. - public uint FocusFieldSprite { get; set; } - - public Action? OnSubmit { get; set; } - - private string _text = ""; - private int _caret; - private int? _selAnchor; // selection fixed end (null = no selection); span = [min,max] with _caret - public string Text => _text; - public int CaretPos => _caret; - - private readonly List _history = new(); - private int _historyIndex = -1; - public int HistoryCount => _history.Count; - - private bool _focused; - private bool _selecting; // mouse drag in progress - private float _scrollX; // horizontal pixel scroll so the caret stays in the field - - // Held-key auto-repeat (Silk delivers one KeyDown per physical press). - private Silk.NET.Input.Key? _repeatKey; - private double _repeatTimer; - private const double RepeatDelay = 0.40; // s before the first repeat - private const double RepeatRate = 0.04; // s between repeats (~25/s) - - public UiField() - { - AcceptsFocus = true; - IsEditControl = true; - CapturesPointerDrag = true; // interior drag selects, doesn't move the window - } - - /// The field draws its own background + caret + caps; its dat cap sub-elements - /// are reproduced procedurally, so the importer must not build them as widgets. - public override bool ConsumesDatChildren => true; - - // ── Editing primitives ────────────────────────────────────────────── - - public void InsertChar(char c) - { - if (c < 0x20 || c == 0x7F) return; - DeleteSelection(); - if (_text.Length >= MaxCharacters) return; - _text = _text.Insert(_caret, c.ToString()); - _caret++; - _historyIndex = -1; - } - - public void Backspace() - { - if (DeleteSelection()) return; - if (_caret == 0) return; - _text = _text.Remove(_caret - 1, 1); - _caret--; - } - - public void DeleteForward() - { - if (DeleteSelection()) return; - if (_caret >= _text.Length) return; - _text = _text.Remove(_caret, 1); - } - - private void MoveCaretTo(int target, bool shift) - { - target = Math.Clamp(target, 0, _text.Length); - if (shift) _selAnchor ??= _caret; // begin/extend selection from the old caret - else _selAnchor = null; // plain move collapses any selection - _caret = target; - _historyIndex = -1; - } - - /// Move the caret left (negative) or right (positive) by - /// glyph positions without extending a selection. Public for test access. - public void MoveCaret(int delta) => MoveCaretTo(_caret + delta, false); - - private void MoveCaret(int delta, bool shift) => MoveCaretTo(_caret + delta, shift); - - // ── Selection ──────────────────────────────────────────────────────── - - private (int lo, int hi) SelSpan() - { - if (_selAnchor is not { } a || a == _caret) return (_caret, _caret); - return (Math.Min(a, _caret), Math.Max(a, _caret)); - } - - private bool HasSelection => _selAnchor is { } a && a != _caret; - - private string SelectedText() - { - var (lo, hi) = SelSpan(); - return hi > lo ? _text.Substring(lo, hi - lo) : ""; - } - - /// Remove the selected span (if any). Returns true if it removed anything. - private bool DeleteSelection() - { - if (!HasSelection) { _selAnchor = null; return false; } - var (lo, hi) = SelSpan(); - _text = _text.Remove(lo, hi - lo); - _caret = lo; - _selAnchor = null; - return true; - } - - private void SelectAll() - { - if (_text.Length == 0) { _selAnchor = null; return; } - _selAnchor = 0; - _caret = _text.Length; - } - - private void CopySelection() - { - var s = SelectedText(); - if (s.Length > 0 && Keyboard is not null) Keyboard.ClipboardText = s; - } - - private void CutSelection() - { - if (!HasSelection) return; - CopySelection(); - DeleteSelection(); - _historyIndex = -1; - } - - private void Paste() - { - if (Keyboard is null) return; - string clip = Keyboard.ClipboardText ?? ""; - if (clip.Length == 0) return; - - // Single-line field: strip control chars (newlines/tabs) from pasted text. - var sb = new System.Text.StringBuilder(clip.Length); - foreach (char ch in clip) - if (ch >= 0x20 && ch != 0x7F) sb.Append(ch); - if (sb.Length == 0) return; - - DeleteSelection(); - int room = MaxCharacters - _text.Length; - if (room <= 0) return; - string ins = sb.Length > room ? sb.ToString(0, room) : sb.ToString(); - _text = _text.Insert(_caret, ins); - _caret += ins.Length; - _historyIndex = -1; - } - - // ── Submit + history ───────────────────────────────────────────────── - - public void Submit() - { - var t = _text; - if (t.Trim().Length == 0) { Clear(); return; } - OnSubmit?.Invoke(t); - PushHistory(t); - Clear(); - } - - private void Clear() { _text = ""; _caret = 0; _selAnchor = null; _historyIndex = -1; } - - private void PushHistory(string t) - { - _history.Add(t); - if (_history.Count > 100) _history.RemoveAt(0); - _historyIndex = -1; - } - - public void HistoryPrev() - { - if (_history.Count == 0) return; - _historyIndex = _historyIndex < 0 ? _history.Count - 1 : Math.Max(0, _historyIndex - 1); - SetTextFromHistory(); - } - - public void HistoryNext() - { - if (_historyIndex < 0) return; - _historyIndex++; - if (_historyIndex >= _history.Count) { _historyIndex = -1; Clear(); return; } - SetTextFromHistory(); - } - - private void SetTextFromHistory() - { - _text = _history[_historyIndex]; - _caret = _text.Length; - _selAnchor = null; - } - - // ── Geometry ───────────────────────────────────────────────────────── - - /// Pixel-X of the caret (Σ glyph advances to ). - private float MeasureTo(int i) - { - if (i <= 0) return 0f; - string s = _text.Substring(0, Math.Min(i, _text.Length)); - return DatFont is { } df ? df.MeasureWidth(s) - : Font is { } bf ? bf.MeasureWidth(s) : 0f; - } - - public float CaretPixelX() => MeasureTo(_caret); - - /// Map a local X (click) to the nearest caret index — retail - /// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset. - private int HitCharX(float localX) - { - float target = localX - Padding + _scrollX; - if (target <= 0f) return 0; - int best = 0; - float bestDist = float.MaxValue; - for (int i = 0; i <= _text.Length; i++) - { - float d = MathF.Abs(MeasureTo(i) - target); - if (d < bestDist) { bestDist = d; best = i; } - } - return best; - } - - // ── Draw ───────────────────────────────────────────────────────────── - - protected override void OnDraw(UiRenderContext ctx) - { - // Focused = "write mode": draw the gold lit field sprite (retail Normal_focussed). - // Unfocused: the flat translucent rect. Both go through the sprite bucket - // (DrawFill / DrawSprite) so the text — also sprite-bucket — draws on top. - bool lit = _focused && SpriteResolve is not null && FocusFieldSprite != 0; - if (lit) - { - var (tex, tw, th) = SpriteResolve!(FocusFieldSprite); - if (tex != 0 && tw > 0) ctx.DrawSprite(tex, 0, 0, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - else lit = false; - } - if (!lit) ctx.DrawFill(0, 0, Width, Height, BackgroundColor); - - float lh = DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; - float ty = (Height - lh) * 0.5f; - float visibleW = MathF.Max(1f, Width - 2f * Padding); - - // Horizontal scroll: keep the caret inside the field; clamp so we never scroll past - // the text. Then draw only the glyph window that lands inside the field — a single- - // line text box clips + scrolls (retail UIElement_Text) rather than overflowing the - // field (which previously spilled the text out into the 3D world). - float caretX = MeasureTo(_caret); - float fullW = MeasureTo(_text.Length); - if (caretX - _scrollX > visibleW) _scrollX = caretX - visibleW; - if (caretX < _scrollX) _scrollX = caretX; - _scrollX = Math.Clamp(_scrollX, 0f, MathF.Max(0f, fullW - visibleW)); - - // Visible character window [start, end). - int start = 0; - while (start < _text.Length && MeasureTo(start + 1) <= _scrollX) start++; - int end = start; - while (end < _text.Length && MeasureTo(end + 1) - _scrollX <= visibleW) end++; - - // Selection highlight BEHIND the text, clipped to the field. - if (HasSelection) - { - var (lo, hi) = SelSpan(); - float h0 = MathF.Max(MeasureTo(lo) - _scrollX, 0f); - float h1 = MathF.Min(MeasureTo(hi) - _scrollX, visibleW); - if (h1 > h0) ctx.DrawFill(Padding + h0, ty, h1 - h0, lh, SelectionColor); - } - - if (end > start) - { - string vis = _text.Substring(start, end - start); - float vx = Padding + (MeasureTo(start) - _scrollX); - if (DatFont is { } df2) ctx.DrawStringDat(df2, vis, vx, ty, TextColor); - else ctx.DrawString(vis, vx, ty, TextColor, Font); - } - - if (_focused) - { - // Caret on TOP of the text → submitted after the text in the same bucket. - float cx = Padding + (caretX - _scrollX); - if (cx >= Padding - 1f && cx <= Width - Padding + 1f) - ctx.DrawFill(cx, ty, 1f, lh, TextColor); - } - } - - // ── Auto-repeat ────────────────────────────────────────────────────── - - protected override void OnTick(double deltaSeconds) - { - if (_repeatKey is not { } k) return; - _repeatTimer -= deltaSeconds; - if (_repeatTimer > 0) return; - _repeatTimer = RepeatRate; - bool shift = ShiftHeld(); - switch (k) - { - case Silk.NET.Input.Key.Backspace: Backspace(); break; - case Silk.NET.Input.Key.Delete: DeleteForward(); break; - case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); break; - case Silk.NET.Input.Key.Right: MoveCaret(1, shift); break; - default: _repeatKey = null; break; - } - } - - private void StartRepeat(Silk.NET.Input.Key k) { _repeatKey = k; _repeatTimer = RepeatDelay; } - - private bool CtrlHeld() => Keyboard is not null - && (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft) - || Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight)); - - private bool ShiftHeld() => Keyboard is not null - && (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftLeft) - || Keyboard.IsKeyPressed(Silk.NET.Input.Key.ShiftRight)); - - // ── Events ─────────────────────────────────────────────────────────── - - public override bool OnEvent(in UiEvent e) - { - switch (e.Type) - { - case UiEventType.FocusGained: _focused = true; return true; - case UiEventType.FocusLost: - _focused = false; _historyIndex = -1; - _selAnchor = null; _selecting = false; _repeatKey = null; - return true; - - case UiEventType.Char: - InsertChar((char)e.Data0); - return true; - - case UiEventType.MouseDown: - _caret = HitCharX(e.Data1); - _selAnchor = _caret; // anchor; a drag will extend, a plain click won't - _selecting = true; - return true; - case UiEventType.MouseMove: - if (_selecting) _caret = HitCharX(e.Data1); - return true; - case UiEventType.MouseUp: - _selecting = false; - return true; - - case UiEventType.KeyUp: - if ((Silk.NET.Input.Key)e.Data0 == _repeatKey) _repeatKey = null; - return true; - - case UiEventType.KeyDown: - { - var key = (Silk.NET.Input.Key)e.Data0; - if (CtrlHeld()) - { - switch (key) - { - case Silk.NET.Input.Key.A: SelectAll(); return true; - case Silk.NET.Input.Key.C: CopySelection(); return true; - case Silk.NET.Input.Key.X: CutSelection(); return true; - case Silk.NET.Input.Key.V: Paste(); return true; - } - return true; // swallow other Ctrl combos while typing - } - - bool shift = ShiftHeld(); - switch (key) - { - case Silk.NET.Input.Key.Enter: - case Silk.NET.Input.Key.KeypadEnter: - Submit(); - FindRoot()?.SetKeyboardFocus(null); // exit write mode after sending - return true; - case Silk.NET.Input.Key.Backspace: Backspace(); StartRepeat(key); return true; - case Silk.NET.Input.Key.Delete: DeleteForward(); StartRepeat(key); return true; - case Silk.NET.Input.Key.Left: MoveCaret(-1, shift); StartRepeat(key); return true; - case Silk.NET.Input.Key.Right: MoveCaret(1, shift); StartRepeat(key); return true; - case Silk.NET.Input.Key.Home: MoveCaretTo(0, shift); return true; - case Silk.NET.Input.Key.End: MoveCaretTo(_text.Length, shift); return true; - case Silk.NET.Input.Key.Up: HistoryPrev(); return true; - case Silk.NET.Input.Key.Down: HistoryNext(); return true; - } - return false; - } - } - return false; - } -} diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs index 8e4c66f3..5f697cfb 100644 --- a/src/AcDream.App/UI/UiHost.cs +++ b/src/AcDream.App/UI/UiHost.cs @@ -39,13 +39,6 @@ public sealed class UiHost : System.IDisposable public UiRoot Root { get; } = new(); public TextRenderer TextRenderer { get; } public BitmapFont? DefaultFont { get; set; } - - /// The last wired keyboard. Exposed so widgets that need clipboard - /// access () or modifier-key state - /// () — e.g. 's - /// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins. - public IKeyboard? Keyboard { get; private set; } - private long _startTicks = System.Environment.TickCount64; public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null) @@ -89,7 +82,6 @@ public sealed class UiHost : System.IDisposable public void WireKeyboard(IKeyboard kb) { - Keyboard = kb; // last wired keyboard wins (one-keyboard desktop) kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k); kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k); kb.KeyChar += (_, c) => Root.OnChar(c); @@ -103,14 +95,6 @@ public sealed class UiHost : System.IDisposable _ => UiMouseButton.Left, }; - // ── Window manager forwarders (delegate to UiRoot) ───────────────── - - /// Register a top-level window for Show/Hide/Toggle. See . - public void RegisterWindow(string name, UiElement window) => Root.RegisterWindow(name, window); - - /// Toggle a registered window's visibility; returns the new IsVisible. - public bool ToggleWindow(string name) => Root.ToggleWindow(name); - public void Dispose() { TextRenderer.Dispose(); diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs deleted file mode 100644 index eedb6ff3..00000000 --- a/src/AcDream.App/UI/UiItemList.cs +++ /dev/null @@ -1,173 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace AcDream.App.UI; - -/// -/// A container of item cells (port of retail UIElement_ItemList, class 0x10000031). -/// Behavioral LEAF: it creates/owns its UiItemSlot children procedurally, so the -/// LayoutImporter must NOT build dat children. The toolbar uses single-cell -/// instances (one slot); the inventory phase will grow this to an N-cell grid. -/// -public sealed class UiItemList : UiElement -{ - private readonly List _cells = new(); - - /// Vertical scroll model for grid mode (clip+scroll). Bound to the gutter - /// UiScrollbar by the panel controller. Inert in fill mode (single toolbar cell). - public UiScrollable Scroll { get; } = new(); - - public UiItemList(Func? spriteResolve = null) - { - SpriteResolve = spriteResolve; - // Single-cell default: every toolbar slot always shows one cell (empty or filled). - AddItem(new UiItemSlot { SpriteResolve = spriteResolve }); - } - - public override bool ConsumesDatChildren => true; - - public Func? SpriteResolve { get; set; } - - private uint _cellEmptySprite; - /// Empty-slot sprite for THIS list's cells, resolved from the dat cell template - /// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see - /// ). 0 = leave the - /// default (0x060074CF). Applied to every existing - /// AND future cell. - public uint CellEmptySprite - { - get => _cellEmptySprite; - set - { - _cellEmptySprite = value; - if (value != 0) - foreach (var c in _cells) c.EmptySprite = value; - } - } - - /// The drag handler this list routes drops to (a panel controller). - /// Null = the list accepts no drops. Retail: UIElement_ItemList::m_dragHandler. - public IItemListDragHandler? DragHandler { get; private set; } - - /// Register the panel's drag handler on this list. Retail: - /// UIElement_ItemList::RegisterItemListDragHandler (decomp 230461). - public void RegisterDragHandler(IItemListDragHandler handler) => DragHandler = handler; - - /// Convenience for single-cell slots (the toolbar): the first cell. - /// Valid only while the list has at least one cell; after - /// (the inventory-phase rebuild path) the list is empty until - /// runs, so use there instead. - /// the list has no cells (e.g. after Flush). - public UiItemSlot Cell => _cells.Count > 0 - ? _cells[0] - : throw new InvalidOperationException("UiItemList has no cells; call AddItem first or use GetItem(index)."); - - public int GetNumUIItems() => _cells.Count; - - public UiItemSlot? GetItem(int index) - => index >= 0 && index < _cells.Count ? _cells[index] : null; - - public void AddItem(UiItemSlot cell) - { - cell.SpriteResolve ??= SpriteResolve; - if (_cellEmptySprite != 0) cell.EmptySprite = _cellEmptySprite; - // The list lays cells out procedurally (grid offset + scroll clip), so cells must be - // EXEMPT from the parent anchor pass — UiElement.DrawSelfAndChildren runs ApplyAnchor - // on every child after OnDraw, which would otherwise reset the scroll offset to each - // cell's captured base position (the escaping-grid bug). Anchors=None makes LayoutCells - // the sole authority over cell rects. - cell.Anchors = AnchorEdges.None; - _cells.Add(cell); - AddChild(cell); - LayoutCells(); - } - - /// Grid columns (row-major). 1 = single column. Ignored in fill mode. - public int Columns { get; set; } = 1; - - /// Fixed cell width in grid mode. 0 = "fill the list" — the single cell sizes - /// to the whole list (the toolbar single-slot legacy). Set >0 (with CellHeight) for a grid. - public float CellWidth { get; set; } - /// Fixed cell height in grid mode (pairs with CellWidth). - public float CellHeight { get; set; } - - /// Whole rows needed for cells in a grid of - /// columns. - public static int RowCount(int cellCount, int columns) - { - int cols = columns < 1 ? 1 : columns; - return (cellCount + cols - 1) / cols; - } - - /// Row-major pixel offset of cell in a grid of - /// columns at the given cell pitch. - internal static (float x, float y) CellOffset(int index, int columns, float cellW, float cellH) - { - int col = index % columns, row = index / columns; - return (col * cellW, row * cellH); - } - - /// Position every cell per the current mode: fill (CellWidth<=0) sizes the single - /// cell to the list; grid (CellWidth>0) tiles cells row-major, offset by the scroll position - /// with whole-row vertical clipping (cells fully outside the view are hidden). - internal void LayoutCells() - { - if (CellWidth <= 0f) - { - // Fill mode (the toolbar single cell): size the one cell to the list. - if (_cells.Count > 0) - { - var c = _cells[0]; - c.Left = 0; c.Top = 0; c.Width = Width; c.Height = Height; c.Visible = true; - } - return; - } - - int cols = Columns < 1 ? 1 : Columns; - int cellH = (int)MathF.Round(CellHeight); - - // Drive the shared scroll model from the current geometry, then re-clamp the offset to - // the (possibly changed) max. ContentHeight/ViewHeight must be set BEFORE reading ScrollY - // so the clamp uses the right max. - Scroll.LineHeight = cellH > 0 ? cellH : 1; - Scroll.ContentHeight = RowCount(_cells.Count, cols) * cellH; - Scroll.ViewHeight = (int)MathF.Floor(Height); - Scroll.SetScrollY(Scroll.ScrollY); // re-clamp to the new max - float scrollY = Scroll.ScrollY; - - for (int i = 0; i < _cells.Count; i++) - { - var (x, baseY) = CellOffset(i, cols, CellWidth, CellHeight); - float top = baseY - scrollY; - var cell = _cells[i]; - cell.Left = x; cell.Top = top; cell.Width = CellWidth; cell.Height = CellHeight; - // Whole-row vertical clip (no scissor — mirrors UiText.cs:198). A row fully inside - // [0, Height] draws; a partially-scrolled row is hidden. - cell.Visible = top >= -0.5f && top + CellHeight <= Height + 0.5f; - } - } - - public void Flush() - { - foreach (var c in _cells) RemoveChild(c); - _cells.Clear(); - } - - public override bool OnEvent(in UiEvent e) - { - if (e.Type == UiEventType.Scroll && CellWidth > 0f) - { - // Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0. - Scroll.ScrollByLines(-e.Data0); - return true; - } - return base.OnEvent(e); - } - - protected override void OnDraw(UiRenderContext ctx) - { - // The factory sets Width/Height AFTER construction, so re-layout each frame: - // fill mode keeps the single toolbar cell sized to the list; grid mode tiles cells. - LayoutCells(); - } -} diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs deleted file mode 100644 index 10e1d7fe..00000000 --- a/src/AcDream.App/UI/UiItemSlot.cs +++ /dev/null @@ -1,308 +0,0 @@ -using System; -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// One item-in-a-slot cell (port of retail UIElement_UIItem, class 0x10000032). -/// A behavioral LEAF: it draws the empty-slot sprite when unbound, else a -/// pre-composited icon texture (set by the controller). Holds the bound weenie -/// guid (retail UIElement_UIItem::itemID, +0x5FC). -/// -public sealed class UiItemSlot : UiElement -{ - public UiItemSlot() { ClickThrough = false; } - - public override bool ConsumesDatChildren => true; - - /// Bound weenie guid (0 = empty). Retail UIElement_UIItem::itemID. - public uint ItemId { get; private set; } - - /// Pre-composited icon GL texture for the bound item (0 = none). - public uint IconTexture { get; private set; } - - /// This cell's own index within its panel (0..17 toolbar; container slot - /// for inventory). Distinct from (the 1–9 label, -1 on the - /// bottom row). Set by the controller; used as the drag payload's SourceSlot and to - /// identify the drop TARGET slot. - public int SlotIndex { get; set; } = -1; - - /// What kind of slot this is, for the drag payload (retail InqDropIconInfo - /// flags). Controller overrides; default Inventory. - public ItemDragSource SourceKind { get; set; } = ItemDragSource.Inventory; - - /// Drag-rollover accept frame (retail ItemSlot_DragOver_Accept 0x060011F9, - /// state id 0x10000041). Configurable; guard id != 0 before resolving. - public uint DragAcceptSprite { get; set; } = 0x060011F9u; - /// Drag-rollover reject frame (retail ItemSlot_DragOver_Reject 0x060011F8, - /// state id 0x10000040). - public uint DragRejectSprite { get; set; } = 0x060011F8u; - - /// True when this cell is the OPEN container (its contents fill the grid). Draws the - /// open-container triangle. Port of UIElement_ItemList::UpdateOpenContainerIndicator - /// (0x004e3070) → SetOpenContainerState (0x004e1200): shown when item.itemID == openContainerId. - public bool IsOpenContainer { get; set; } - /// Open-container triangle sprite (element 0x10000450 on the container prototype - /// 0x1000033F). Configurable; guard id != 0 before resolving. - public uint OpenContainerSprite { get; set; } = 0x06005D9Cu; - - /// True when this cell is the SELECTED item. Draws the green/yellow selection square. - /// Port of UIElement_ItemList::ItemList_SetSelectedItem (0x004e2fe0) → SetSelectedState - /// (0x004e1240): shown when item.itemID == selectedItemId. Uniform across item + container cells. - public bool Selected { get; set; } - /// Selected-item square sprite (element 0x10000342 on the 32×32 item prototype - /// 0x10000341; pixel-confirmed green/yellow frame). Drawn as a procedural overlay so it renders - /// on the 36×36 container cell too (whose prototype lacks the square child). - public uint SelectedSprite { get; set; } = 0x06004D21u; - - /// Container fullness [0..1], or -1 = hidden (the cell is not a container, or its - /// itemsCapacity is unknown/0). Port of UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a - /// per-cell vertical UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only when - /// isContainer && itemsCapacity > 0, fill = numContainedItems / itemsCapacity (meter attr 0x69). - public float CapacityFill { get; set; } = -1f; - /// Capacity-bar track sprite (meter 0x10000347 DirectState). - public uint CapacityBackSprite { get; set; } = 0x06004D22u; - /// Capacity-bar fill sprite (meter 0x10000347 front child 0x00000002 DirectState). - public uint CapacityFrontSprite { get; set; } = 0x06004D23u; - - /// Accept/reject overlay state while a drag hovers this cell. - public enum DragAcceptState { None, Accept, Reject } - private DragAcceptState _dragAccept = DragAcceptState.None; - /// Current overlay state — internal so unit tests can assert it (InternalsVisibleTo). - internal DragAcceptState DragAcceptVisual => _dragAccept; - - /// Empty-slot sprite. Default = the generic toolbar empty-slot border - /// 0x060074CF (uiitem template 0x21000037, state ItemSlot_Empty). Configurable so - /// paperdoll equip slots can use their per-slot silhouettes later. - public uint EmptySprite { get; set; } = 0x060074CFu; - - /// RenderSurface id -> (GL texture, w, h). Set by the factory/controller. - public Func? SpriteResolve { get; set; } - - public void SetItem(uint itemId, uint iconTexture) - { - ItemId = itemId; - IconTexture = iconTexture; - } - - public void Clear() { ItemId = 0; IconTexture = 0; } - - /// - public override object? GetDragPayload() - => ItemId != 0 ? new ItemDragPayload(ItemId, SourceKind, SlotIndex, this) : null; - - /// - public override (uint tex, int w, int h)? GetDragGhost() - => ItemId != 0 && IconTexture != 0 ? (IconTexture, (int)Width, (int)Height) : null; - - /// An OCCUPIED slot is a drag source — a press-and-move picks up the item - /// rather than moving the toolbar window. An EMPTY slot is NOT a drag source, so a - /// press-and-move there falls through to the IA-12 whole-window-drag, keeping the bar - /// movable by its empty cells / chrome. Drives 's mousedown - /// window-vs-item disambiguation (retail moves the window via a dragbar, never cells; - /// our whole-window-drag approximation reconciles by gating on occupancy). - public override bool IsDragSource => ItemId != 0; - - /// Walk up to the containing (the drop handler owner). - private UiItemList? FindList() - { - UiElement? e = Parent; - while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; } - return null; - } - - // ── Shortcut number (slot label) ───────────────────────────────────────── - // Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). - // Retail draws the digit on the cell's ShortcutNum sub-element, picking the - // digit image from a DID-array property: 0x10000042 (peace) / 0x10000043 (war), - // indexed by slot position. Each digit is a 32×32 PFID_A8R8G8B8 RenderSurface - // with the digit baked into the top-left corner (rest alpha=0), drawn Alphablend. - - /// Slot position in the shortcut bar (0-indexed). -1 = no number (retail - /// SetVisible(0) when edi < 0). Top row: 0..8 → digits 1..9. Bottom row: -1. - public int ShortcutNum { get; private set; } = -1; - - /// True = draw peace digit set; false = war digit set. - public bool ShortcutPeace { get; private set; } = true; - - /// Peace digit DID array. Index i → digit (i+1) sprite RenderSurface id. - /// Injected by the controller after reading LayoutDesc 0x21000037. - /// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — occupied slot picks - /// property 0x10000042 (peace) or 0x10000043 (war) by stance. - public uint[]? PeaceDigits { get; set; } - - /// War digit DID array. Same layout as PeaceDigits. - /// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229493) — war stance. - public uint[]? WarDigits { get; set; } - - /// Empty-slot digit DID array (property 0x1000005e, stance-independent). - /// Used when the slot is EMPTY (ItemId == 0). Retail ref: UIElement_UIItem::SetShortcutNum - /// (decomp 229481) — else branch when m_elem_Icon->m_state == 0x1000001c (empty). - public uint[]? EmptyDigits { get; set; } - - /// Set the slot's shortcut position and combat stance so the correct digit - /// is drawn. Call with index 0..8 for the top row; pass peace=true for NonCombat. - public void SetShortcutNum(int index, bool peace) - { - ShortcutNum = index; - ShortcutPeace = peace; - } - - /// Clear the shortcut number label (hides the digit). - public void ClearShortcutNum() { ShortcutNum = -1; } - - /// - /// Returns the digit DID array that OnDraw will use, following the retail occupancy - /// branch in UIElement_UIItem::SetShortcutNum (decomp 229481): - /// occupied (ItemId != 0) → ShortcutPeace ? PeaceDigits : WarDigits (0x10000042/43) - /// empty (ItemId == 0) → EmptyDigits (0x1000005e, stance-independent) - /// Exposed as an internal method so unit tests can assert array selection without - /// needing a real render context. - /// - internal uint[]? ActiveDigitArray() - { - bool occupied = ItemId != 0; - return occupied ? (ShortcutPeace ? PeaceDigits : WarDigits) : EmptyDigits; - } - - // ── Events / draw ───────────────────────────────────────────────────────── - - /// Invoked by when a left-button-down lands on - /// a bound slot. Wired by ToolbarController to the use-item callback. - public Action? Clicked { get; set; } - - /// - public override bool OnEvent(in UiEvent e) - { - switch (e.Type) - { - // Use fires on CLICK (mouse-up over the same cell), not MouseDown — so a - // drag (press + >3px move) does NOT also use the item. UiRoot suppresses the - // post-drag Click (UiRoot.cs FinishDrag returns before the Click emit). - case UiEventType.MouseDown: - return true; // consume the press; no use here - case UiEventType.Click: - Clicked?.Invoke(); - return true; - - case UiEventType.DragBegin: - // Notify the source list's handler so it can lift (remove + wire) — retail - // RecvNotice_ItemListBeginDrag → RemoveShortcut. UiRoot snapshotted the ghost first. - if (FindList() is { DragHandler: { } lh } liftList && e.Payload is ItemDragPayload lp) - lh.OnDragLift(liftList, this, lp); - return true; - - case UiEventType.DragEnter: // pointer entered me mid-drag → ask the list's handler - _dragAccept = (FindList() is { DragHandler: { } h } list - && e.Payload is ItemDragPayload p && h.OnDragOver(list, this, p)) - ? DragAcceptState.Accept : DragAcceptState.Reject; - return true; - - case UiEventType.DragOver: // UiRoot fires this on LEAVE → neutral - _dragAccept = DragAcceptState.None; - return true; - - case UiEventType.DropReleased: - _dragAccept = DragAcceptState.None; - if (FindList() is { DragHandler: { } dh } dl && e.Payload is ItemDragPayload dp) - dh.HandleDropRelease(dl, this, dp); - return true; - } - return false; - } - - protected override void OnDraw(UiRenderContext ctx) - { - // Draw the icon (filled slot) or the empty-slot border. Both paths fall through - // to the digit draw below; the slot label always shows on top-row slots. - if (ItemId != 0 && IconTexture != 0) - { - ctx.DrawSprite(IconTexture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - else if (SpriteResolve is not null && EmptySprite != 0) - { - var (tex, _, _) = SpriteResolve(EmptySprite); - if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - - // Digit overlay: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). - // Occupancy branch (decomp 229481): - // occupied (ItemId != 0) → peace/war digit set 0x10000042/43, split by stance - // empty (ItemId == 0) → background digit set 0x1000005e, stance-independent - // Each digit image is corner-baked (glyph in top-left, rest alpha=0); drawn - // full-cell Alphablend so the transparent region is invisible. - if (ShortcutNum >= 0 && SpriteResolve is not null) - { - var arr = ActiveDigitArray(); - if (arr is not null && ShortcutNum < arr.Length) - { - uint did = arr[ShortcutNum]; - if (did != 0) - { - var (tex, _, _) = SpriteResolve(did); - if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - } - } - - // Container capacity bar — UIElement_UIItem::UpdateCapacityDisplay (0x004e16e0): a vertical - // UIElement_Meter (element 0x10000347, 5×30 at x=26,y=1) shown only for container cells - // (CapacityFill >= 0). Track drawn full; fill clipped to the fraction from the BOTTOM (the - // "how full" direction). Procedural — UiItemSlot is a behavioral leaf. Guard id != 0 first. - if (CapacityFill >= 0f && SpriteResolve is not null) - { - const float by = 1f, bw = 5f, bh = 30f; // element 0x10000347 size (dat 5×30 at y=1) - float bx = Width - bw; // flush to the cell's right edge (visual gate: the dat X=26 sat ~5px off the edge) - if (CapacityBackSprite != 0) - { - var (bt, _, _) = SpriteResolve(CapacityBackSprite); - if (bt != 0) ctx.DrawSprite(bt, bx, by, bw, bh, 0f, 0f, 1f, 1f, Vector4.One); - } - float f = Math.Clamp(CapacityFill, 0f, 1f); - if (f > 0f && CapacityFrontSprite != 0) - { - var (ft, _, _) = SpriteResolve(CapacityFrontSprite); - if (ft != 0) - { - // Bottom-up fill: draw the bottom f-fraction of the bar, sampling the matching - // bottom slice of the front sprite (UV v from 1-f to 1). - float fh = bh * f; - ctx.DrawSprite(ft, bx, by + (bh - fh), bw, fh, 0f, 1f - f, 1f, 1f, Vector4.One); - } - } - } - - // Open-container triangle (retail SetOpenContainerState 0x004e1200) + selected-item square - // (retail SetSelectedState 0x004e1240). Drawn procedurally like the digit/drag overlays; - // guard id != 0 BEFORE resolving (feedback_ui_resolve_zero_magenta). Triangle under the - // square; both under the transient drag overlay below. - if (IsOpenContainer && SpriteResolve is not null && OpenContainerSprite != 0) - { - var (tex, _, _) = SpriteResolve(OpenContainerSprite); - if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - if (Selected && SpriteResolve is not null && SelectedSprite != 0) - { - var (tex, _, _) = SpriteResolve(SelectedSprite); - if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - - // Drag-rollover accept/reject frame (retail SetDragAcceptState 0x10000041/40). - // Guard id != 0 BEFORE resolving — resolve(0) returns the 1×1 magenta placeholder - // with a non-zero GL handle (feedback_ui_resolve_zero_magenta). - if (_dragAccept != DragAcceptState.None && SpriteResolve is not null) - { - uint id = _dragAccept == DragAcceptState.Accept ? DragAcceptSprite : DragRejectSprite; - if (id != 0) - { - var (tex, _, _) = SpriteResolve(id); - if (tex != 0) - ctx.DrawSprite(tex, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One); - } - } - } -} diff --git a/src/AcDream.App/UI/UiMenu.cs b/src/AcDream.App/UI/UiMenu.cs deleted file mode 100644 index c10bd419..00000000 --- a/src/AcDream.App/UI/UiMenu.cs +++ /dev/null @@ -1,246 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// Generic dropdown menu. Ports retail UIElement_Menu -/// (RegisterElementClass(6) @ acclient_2013_pseudo_c.txt:120163) + -/// UIElement_Menu::MakePopup @0x46d310: the button is labelled with -/// the active target; clicking opens a column-major popup on the dat-driven menu -/// chrome (panel + per-row + selected-row sprites). Items and all chat-channel -/// knowledge are populated by the controller, not baked into this widget. Built -/// by for Type-6 elements. -/// -public sealed class UiMenu : UiElement -{ - /// One menu row: its label + an opaque payload the controller maps back. - public readonly record struct MenuItem(string Label, object? Payload); - - /// The rows, populated by the controller. Laid out column-major: - /// rows 0..RowsPerColumn-1 in column 0, then the next group in column 1, etc. - public IReadOnlyList Items { get; set; } = System.Array.Empty(); - - /// The currently-selected payload (drives the highlighted row). - public object? Selected { get; set; } - - /// Fired with the picked item's payload when a row is chosen. - public Action? OnSelect { get; set; } - - /// Per-payload enabled gate (disabled rows render greyed + are inert). Null ⇒ all enabled. - public Func? EnabledProvider { get; set; } - - /// Button-face caption (the active target). Null ⇒ blank face. - public Func? ButtonLabelProvider { get; set; } - - public int RowsPerColumn { get; set; } = 7; // items per column (dat item template) - public float RowHeight { get; set; } = 17f; // dat item template 0x1000001E H=17 - public float ColumnWidth { get; set; } = 191f; // dat item template W=191 - - private const int Border = RetailChromeSprites.Border; // 8-piece bevel thickness (5px) - // The row sprites 0x0600124E/4D bake a checkbox/checkmark into the leftmost ~17px - // square; the label starts just past it (box width + small gap) so text aligns with - // the box instead of overlapping it. - private const float TextIndent = 19f; - // The button face sprite (0x06004D65/66) bakes a status LED (red→green) into its - // left socket (~x4–20 of the 46px button); the caption starts past it so it doesn't - // render over the LED. - private const float ButtonTextIndent = 20f; - - public UiDatFont? DatFont { get; set; } - public AcDream.App.Rendering.BitmapFont? Font { get; set; } - public Func? SpriteResolve { get; set; } - - // Button face sprites (dat menu element 0x10000014). - public uint NormalSprite { get; set; } - public uint PressedSprite { get; set; } - // Popup chrome sprites (dat menu popup template, layout 0x21000006). - public uint PopupBgSprite { get; set; } // 0x0600124C — panel fill (191×2 tiles) - public uint ItemNormalSprite { get; set; } // 0x0600124E — a row background (191×17) - public uint ItemHighlightSprite { get; set; } // 0x0600124D — the active channel's row - - public Vector4 TextColor { get; set; } = new(1f, 0.92f, 0.72f, 1f); - /// Available item text — retail white #FFFFFF (gmMainChatUI talk-focus - /// enabled state). Confirmed via decomp: enabled items render white. - public Vector4 TextColorAvailable { get; set; } = new(1f, 1f, 1f, 1f); - /// Disabled/unavailable item text — retail GREYS these (UIElement state 0xd - /// disabled StateDesc colour). NOT the salmon colorPink (0x81c528) we had before — that - /// belongs to the chat-MESSAGE palette and was misapplied. Exact float lives in the dat - /// StateDesc (not a code symbol); ~0.5 neutral grey here pending a live cdb dump. - public Vector4 TextColorGhosted { get; set; } = new(0.5f, 0.5f, 0.5f, 1f); - - private bool _open; - // Interior = the row content; Outer = interior + the 8-piece bevel ring. - private int ColumnCount => (Items.Count + RowsPerColumn - 1) / System.Math.Max(1, RowsPerColumn); - private float InteriorW => ColumnCount * ColumnWidth; - private float InteriorH => RowsPerColumn * RowHeight; - private float OuterW => InteriorW + 2 * Border; - private float OuterH => InteriorH + 2 * Border; - - public UiMenu() { CapturesPointerDrag = true; } - - /// The menu draws its own button face + popup; its dat label/row children - /// must NOT be built (an invisible label child would intercept the button click). - public override bool ConsumesDatChildren => true; - - protected override void OnDraw(UiRenderContext ctx) - { - var resolve = SpriteResolve; - - // Button face (3-sliced so it can widen to fit the label) + the active-target label. - if (resolve is not null) - { - var (tex, tw, _) = resolve(_open ? PressedSprite : NormalSprite); - if (tex != 0 && tw > 0) DrawButtonFace(ctx, tex, tw); - } - DrawLabel(ctx, ButtonLabelProvider?.Invoke() ?? "", ButtonTextIndent, (Height - LineH()) * 0.5f, TextColor); - } - - // 3-slice caps for the 46px LED-arrow button face (0x06004D65): a LEFT cap holding the - // round LED socket, a stretchable plain-gold MIDDLE, and a RIGHT cap holding the arrow - // point. Slicing keeps the LED + arrow undistorted when the button widens to its label. - private const float FaceCapL = 20f, FaceCapR = 12f; - - private void DrawButtonFace(UiRenderContext ctx, uint tex, float tw) - { - float uL = FaceCapL / tw, uR = (tw - FaceCapR) / tw; - float midDest = Width - FaceCapL - FaceCapR; - ctx.DrawSprite(tex, 0f, 0f, FaceCapL, Height, 0f, 0f, uL, 1f, Vector4.One); // LED cap - if (midDest > 0f) - ctx.DrawSprite(tex, FaceCapL, 0f, midDest, Height, uL, 0f, uR, 1f, Vector4.One); // gold body (stretched) - ctx.DrawSprite(tex, Width - FaceCapR, 0f, FaceCapR, Height, uR, 0f, 1f, 1f, Vector4.One); // arrow cap - } - - /// The button width that fits "LED cap + channel label + arrow cap" — retail - /// sizes the talk-focus button to its selected label. The controller widens the button - /// to this and reflows the input field to start after it. - public float NaturalButtonWidth() - { - string text = ButtonLabelProvider?.Invoke() ?? ""; - float textW = DatFont?.MeasureWidth(text) ?? Font?.MeasureWidth(text) ?? text.Length * 7f; - return ButtonTextIndent + textW + 4f + FaceCapR; // text start (clears LED) + text + gap + arrow cap - } - - /// The open popup draws in the OVERLAY pass so it sits on top of the whole - /// UI — otherwise the translucent chat panel (drawn after this element in the main - /// pass) greys out the part of the popup that overlaps it. - protected override void OnDrawOverlay(UiRenderContext ctx) - { - var resolve = SpriteResolve; - if (!_open || resolve is null) return; - - // Column-major popup opening UPWARD from the button, wrapped in the universal - // 8-piece window bevel (retail UIElement_Menu::MakePopup spawns the popup as a - // bevelled floating window). Force OPAQUE (a menu reads solid even though the - // chat window is translucent). Draw bevel → panel fill → row sprites → labels, - // all through the sprite bucket in submission order so labels land on top. - ctx.PushAlphaAbsolute(1f); - try - { - float outerTop = -OuterH; // popup bottom sits at the button top (y=0) - float inX = Border, inY = outerTop + Border; // interior origin (inside the bevel) - - DrawBevel(ctx, resolve, 0f, outerTop, OuterW, OuterH); - DrawSprite(ctx, resolve, PopupBgSprite, inX, inY, InteriorW, InteriorH); // panel fill behind rows - - for (int i = 0; i < Items.Count; i++) - { - int col = i / RowsPerColumn, row = i % RowsPerColumn; - float x = inX + col * ColumnWidth, y = inY + row * RowHeight; - bool selected = Equals(Items[i].Payload, Selected); - DrawSprite(ctx, resolve, selected ? ItemHighlightSprite : ItemNormalSprite, x, y, ColumnWidth, RowHeight); - } - - float textY = (RowHeight - LineH()) * 0.5f; // center the label in its row - for (int i = 0; i < Items.Count; i++) - { - int col = i / RowsPerColumn, row = i % RowsPerColumn; - // Items grey out when unavailable; when EnabledProvider is null all items are enabled. - bool avail = EnabledProvider?.Invoke(Items[i].Payload) ?? true; - DrawLabel(ctx, Items[i].Label, inX + col * ColumnWidth + TextIndent, inY + row * RowHeight + textY, - avail ? TextColorAvailable : TextColorGhosted); - } - } - finally { ctx.PopAlpha(); } - } - - /// Draw the universal 8-piece retail window bevel (corners + tiled edges + - /// tiled centre fill) framing the rect (,, - /// ,). Reuses the same geometry + - /// ids as ; no resize - /// grips (a menu popup is not resizable). - private void DrawBevel(UiRenderContext ctx, Func resolve, - float x, float y, float w, float h) - { - var r = UiNineSlicePanel.ComputeFrameRects(w, h, Border); - void P(uint id, in UiNineSlicePanel.Rect d) => DrawSprite(ctx, resolve, id, x + d.X, y + d.Y, d.W, d.H); - P(RetailChromeSprites.CenterFill, r.Center); - P(RetailChromeSprites.TopEdge, r.Top); - P(RetailChromeSprites.BottomEdge, r.Bottom); - P(RetailChromeSprites.LeftEdge, r.Left); - P(RetailChromeSprites.RightEdge, r.Right); - P(RetailChromeSprites.CornerTL, r.TL); - P(RetailChromeSprites.CornerTR, r.TR); - P(RetailChromeSprites.CornerBL, r.BL); - P(RetailChromeSprites.CornerBR, r.BR); - } - - private float LineH() => DatFont?.LineHeight ?? Font?.LineHeight ?? 14f; - - private void DrawSprite(UiRenderContext ctx, Func resolve, - uint id, float x, float y, float w, float h) - { - if (id == 0) return; - var (tex, tw, th) = resolve(id); - if (tex == 0 || tw == 0 || th == 0) return; - // Tile at native size (the panel fill is 191×2; rows are 191×17 = 1:1). - ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One); - } - - private void DrawLabel(UiRenderContext ctx, string s, float x, float y, Vector4 color) - { - if (DatFont is { } df) ctx.DrawStringDat(df, s, x, y, color); - else ctx.DrawString(s, x, y, color, Font); - } - - protected override bool OnHitTest(float lx, float ly) - => _open ? (lx >= 0 && lx < OuterW && ly >= -OuterH && ly < Height) - : base.OnHitTest(lx, ly); - - public override bool OnEvent(in UiEvent e) - { - if (e.Type != UiEventType.MouseDown) return false; - - float lx = e.Data1, ly = e.Data2; - if (_open && ly < 0) // clicked inside the upward popup - { - // Map into the bevel interior, then to (col,row). Clicks in the bevel ring - // (outside the interior) just close the menu. - float ix = lx - Border, iy = ly - (-OuterH + Border); - if (ix >= 0 && ix < InteriorW && iy >= 0 && iy < InteriorH) - { - int col = (int)(ix / ColumnWidth); - int row = (int)(iy / RowHeight); - int idx = col * RowsPerColumn + row; - // Only pick enabled items. - if (row >= 0 && row < RowsPerColumn && idx >= 0 && idx < Items.Count - && (EnabledProvider?.Invoke(Items[idx].Payload) ?? true)) - { - // The widget REPORTS the pick; the controller owns Selected (it sets - // Selected only for payloads it acts on). This mirrors retail - // UIElement_Menu::NewSelection delegating to the owner rather than - // self-selecting — so a deferred/no-op item (e.g. the Squelch / - // Tell-to-Selected specials, null payload) leaves the current - // selection + highlight unchanged when the controller ignores it. - OnSelect?.Invoke(Items[idx].Payload); - } - } - _open = false; - return true; - } - - _open = !_open; // toggle on button click - return true; - } -} diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs deleted file mode 100644 index 5d7fc535..00000000 --- a/src/AcDream.App/UI/UiMeter.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// A horizontal vital bar (retail HP/Stamina/Mana style): a background rect, a -/// partial-width solid fill, and an optional centered "current/max" numeric -/// overlay. returns 0..1 (null = no data → empty bar); -/// returns the overlay text (null = no number). -/// -/// -/// Solid-color fill + debug font for Spec 1. The retail gradient bar sprite -/// (glassy center highlight) and the retail dat font are a later polish pass — -/// retail's vitals are bars exactly like this, just sprited. -/// -/// -public sealed class UiMeter : UiElement -{ - - /// Fill fraction provider; a null result draws an empty bar. - public Func Fill { get; set; } = () => 0f; - /// Centered overlay text provider (e.g. "291/291"); null = none. - public Func Label { get; set; } = () => null; - public Vector4 BarColor { get; set; } = new(1f, 0f, 0f, 1f); - public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f); - public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f); - - /// Retail dat font (Font 0x40000000) for the "cur/max" overlay. When - /// set, the label renders through the dat-font two-pass blit (outline + fill); - /// when null, the debug bitmap font - /// is used instead. Set by the host when the retail UI is active. - public UiDatFont? DatFont { get; set; } - - /// Resolver from a RenderSurface DataId to (GL handle, w, h). When set - /// with the 9-slice ids below, the bar draws the retail sprites instead of solid color. - public Func? SpriteResolve { get; set; } - - // Retail vital bars are a horizontal 3-slice: a fixed-width bevelled left-cap, - // a TILED gradient middle (the "fill-tile" repeats at native width — it does not - // stretch), and a fixed-width right-cap. The "back" slice is the empty track - // (drawn full width); the "front" slice is the coloured fill (drawn full-geometry - // but CLIPPED to the fill fraction — its own right-cap shows at 100%, the back's - // shows through when partial). Ids come from the stacked vitals LayoutDesc - // (0x2100006C) via the dump-vitals-layout CLI; 0 = none. - /// Empty-track left-cap RenderSurface id. - public uint BackLeft { get; set; } - /// Empty-track middle (tiled gradient) RenderSurface id. - public uint BackTile { get; set; } - /// Empty-track right-cap RenderSurface id. - public uint BackRight { get; set; } - /// Coloured-fill left-cap RenderSurface id. - public uint FrontLeft { get; set; } - /// Coloured-fill middle (tiled gradient) RenderSurface id. - public uint FrontTile { get; set; } - /// Coloured-fill right-cap RenderSurface id. - public uint FrontRight { get; set; } - - /// Vertical orientation (retail m_eDirection 2/4). Default false = - /// horizontal (direction 1). The burden bar is the only vertical meter today. - public bool Vertical { get; set; } - - /// For a vertical meter, fill grows from the bottom up (retail direction 4) - /// when true, top-down (direction 2) when false. Default bottom-up. Visual-confirmed. - public bool FillFromBottom { get; set; } = true; - - public UiMeter() { ClickThrough = true; } - - /// The meter draws its own 3-slice bars; the importer must not build its - /// grandchild slice/text elements as separate widgets. - public override bool ConsumesDatChildren => true; - - /// Clamp to [0,1] and return the fill rect - /// (local px) for a bar of x . - public static (float x, float y, float w, float h) ComputeFillRect( - float pct, float w, float h) - { - if (pct < 0f) pct = 0f; - if (pct > 1f) pct = 1f; - return (0f, 0f, w * pct, h); - } - - /// Clamp to [0,1] and return the vertical fill rect - /// (local px). true → the fill occupies the bottom - /// h*pct px (retail direction 4); false → the top (direction 2). - public static (float x, float y, float w, float h) ComputeVFillRect( - float pct, float w, float h, bool fromBottom) - { - if (pct < 0f) pct = 0f; - if (pct > 1f) pct = 1f; - float fh = h * pct; - return (0f, fromBottom ? h - fh : 0f, w, fh); - } - - protected override void OnDraw(UiRenderContext ctx) - { - float? pct = Fill(); - float p = pct is float pf ? (pf < 0f ? 0f : pf > 1f ? 1f : pf) : 0f; - - if (SpriteResolve is { } resolve && (BackLeft != 0 || BackTile != 0 || FrontTile != 0)) - { - if (Vertical) - { - // Track full height, fill clipped to the fraction along the fill direction. - // The burden meter is a single-tile bar (BackTile/FrontTile, no caps). - DrawVBar(ctx, resolve, BackTile, Height, fromBottom: FillFromBottom, isFill: false); - if (pct is not null && p > 0f) - DrawVBar(ctx, resolve, FrontTile, Height * p, fromBottom: FillFromBottom, isFill: true); - } - else - { - // Retail meter (UIElement_Meter::DrawChildren): the BACK 3-slice is the - // empty track, drawn full width; the FRONT 3-slice is the coloured fill, - // drawn at FULL width too but horizontally CLIPPED to the fill fraction. - // The front carries its own right-cap (shown at 100%); clipping below 100% - // removes it and reveals the back track's right-cap — retail's scissor-fill. - DrawHBar(ctx, resolve, BackLeft, BackTile, BackRight, Width); - if (pct is not null && p > 0f) - DrawHBar(ctx, resolve, FrontLeft, FrontTile, FrontRight, Width * p); - } - } - else - { - // Placeholder solid-color fallback. - ctx.DrawRect(0, 0, Width, Height, BgColor); - if (pct is not null && p > 0f) - { - var (fx, fy, fw, fh) = ComputeFillRect(p, Width, Height); - if (fw > 0f) ctx.DrawRect(fx, fy, fw, fh, BarColor); - } - } - - string? label = Label(); - if (!string.IsNullOrEmpty(label)) - { - if (DatFont is { } datFont) - { - // Retail path: centered cur/max via the dat font's two-pass blit. - float tw = datFont.MeasureWidth(label); - float tx = (Width - tw) * 0.5f; - float ty = (Height - datFont.LineHeight) * 0.5f; - ctx.DrawStringDat(datFont, label, tx, ty, LabelColor); - } - else if (ctx.DefaultFont is { } font) - { - // Fallback: debug bitmap font (no dat font available). - float tw = font.MeasureWidth(label); - float tx = (Width - tw) * 0.5f; - float ty = (Height - font.LineHeight) * 0.5f; - ctx.DrawString(label, tx, ty, LabelColor); - } - } - } - - /// - /// Draws the full-width horizontal 3-slice (native-width left-cap, stretched - /// middle, native-width right-cap) over this meter's rect, horizontally CLIPPED - /// so nothing past (local px from the left) is drawn. - /// The back track passes clipW = Width; the front fill passes - /// clipW = Width * fraction. Clipping UV-crops each slice proportionally, - /// so the fill ends cleanly and the back's right-cap shows through when partial. - /// A 0 id skips that slice. - /// - private void DrawHBar( - UiRenderContext ctx, Func resolve, - uint leftId, uint midId, uint rightId, float clipW) - { - if (clipW <= 0f) return; - float w = Width, h = Height; - // Only resolve a slice when its id is non-zero. resolve(0) returns the 1x1 MAGENTA - // placeholder with a NON-ZERO GL handle, so resolving a zero (absent) cap id and then - // testing `tex != 0` would draw a 1px magenta cap. The single-image meter (toolbar - // selected-object bar) has no left/right caps (ids 0); the 3-slice vitals meter sets - // all six ids. Guard on the id, not the resolved handle. - var (lt, lw, _) = leftId != 0 ? resolve(leftId) : (0u, 0, 0); - var (mt, mw, _) = midId != 0 ? resolve(midId) : (0u, 0, 0); - var (rt, rw, _) = rightId != 0 ? resolve(rightId) : (0u, 0, 0); - - float capL = lt != 0 ? MathF.Min(lw, w) : 0f; - float capR = rt != 0 ? MathF.Min(rw, w - capL) : 0f; - float midW = w - capL - capR; - - // Each slice's texture repeats every NATIVE-width px (UV-repeat; the UI - // texture is GL_REPEAT-wrapped — TextureCache.UploadRgba8). Caps span their - // own native width → a single 1:1 copy. The wide middle spans many native - // widths → it TILES, matching retail's "fill-tile" + ImgTex::TileCSI rather - // than stretching one copy. (Same UV-repeat the chrome border already uses.) - DrawPiece(ctx, lt, 0f, capL, lw, h, clipW); - DrawPiece(ctx, mt, capL, midW, mw, h, clipW); - DrawPiece(ctx, rt, w - capR, capR, rw, h, clipW); - } - - /// Draws a single-tile vertical bar slice. The track ( - /// false) spans the full height; the fill spans px from the - /// bottom () or top, UV-cropped to that fraction of the - /// sprite so the fill reveals the matching part of the art (retail - /// UIElement_Meter::DrawChildren Box2D clip, direction 2/4). A 0 id is a no-op. - private void DrawVBar(UiRenderContext ctx, Func resolve, - uint tileId, float visibleH, bool fromBottom, bool isFill) - { - if (tileId == 0 || visibleH <= 0f) return; - var (tex, _, _) = resolve(tileId); - if (tex == 0) return; - float w = Width, h = Height; - if (visibleH > h) visibleH = h; - float frac = h > 0f ? visibleH / h : 0f; - // Bottom-up fill: bottom of the rect AND bottom of the sprite (v in [1-frac, 1]). - // Top-down / track: top of the rect AND top of the sprite (v in [0, frac]). - float y = isFill && fromBottom ? h - visibleH : 0f; - float v0 = isFill && fromBottom ? 1f - frac : 0f; - float v1 = isFill && fromBottom ? 1f : frac; - ctx.DrawSprite(tex, 0f, y, w, visibleH, 0f, v0, 1f, v1, System.Numerics.Vector4.One); - } - - /// Draw a slice over local [, - /// pieceX+], with the texture repeating every - /// px (UV-repeat — the UI texture is GL_REPEAT-wrapped). - /// Clipped so nothing past shows. For a cap (span == native) - /// this is one 1:1 copy; for the wide middle it tiles; a partial last copy is - /// UV-cropped. - private static void DrawPiece( - UiRenderContext ctx, uint tex, float pieceX, float pieceW, float nativeW, float h, float clipW) - { - if (tex == 0 || pieceW <= 0f || nativeW <= 0f) return; - float visibleW = MathF.Min(pieceW, clipW - pieceX); - if (visibleW <= 0f) return; - float u1 = visibleW / nativeW; // >1 ⇒ texture repeats (tiles); ≤1 ⇒ a partial copy - ctx.DrawSprite(tex, pieceX, 0f, visibleW, h, 0f, 0f, u1, 1f, Vector4.One); - } -} diff --git a/src/AcDream.App/UI/UiNineSlicePanel.cs b/src/AcDream.App/UI/UiNineSlicePanel.cs deleted file mode 100644 index 1c4c207b..00000000 --- a/src/AcDream.App/UI/UiNineSlicePanel.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// A whose background is the retail 8-piece window bevel -/// (): 4 corners + 4 edges around a tiled -/// center fill. Retires the flat translucent rect (divergence row TS-30). -/// Sprites resolve to (GL handle, width, height) via an injected delegate so -/// the widget is testable without GL. In production: -/// id => { var t = cache.GetOrUploadRenderSurface(id, out var w, out var h); return (t, w, h); }. -/// -public class UiNineSlicePanel : UiPanel -{ - /// A placed chrome piece: destination rect in local pixel space. - public readonly record struct Rect(float X, float Y, float W, float H); - - /// The nine destination rects for an 8-piece border + center. - public readonly record struct FrameRects( - Rect Center, Rect Top, Rect Bottom, Rect Left, Rect Right, - Rect TL, Rect TR, Rect BL, Rect BR); - - private readonly System.Func _resolve; - - public UiNineSlicePanel(System.Func resolve) - { - _resolve = resolve; - BackgroundColor = Vector4.Zero; // suppress the base flat-rect fill - BorderColor = Vector4.Zero; - Draggable = true; // retail windows are movable - Resizable = true; // retail windows are resizable - // A top-level window is USER-positioned: it must NOT be anchor-managed - // by its parent (UiRoot), or the per-frame anchor pass would reset its - // Left/Top/Width/Height every frame and undo move/resize. Children - // INSIDE the window still anchor to it (the bars stretch with width). - Anchors = AnchorEdges.None; - } - - /// - /// Destination rects (local px) for a frame of (, - /// ) with border thickness : - /// b×b corners, top/bottom edges spanning the interior width at height b, - /// left/right edges spanning the interior height at width b, center fills - /// the interior. - /// - public static FrameRects ComputeFrameRects(float w, float h, int b) - { - float innerW = w - 2 * b; - float innerH = h - 2 * b; - return new FrameRects( - Center: new Rect(b, b, innerW, innerH), - Top: new Rect(b, 0, innerW, b), - Bottom: new Rect(b, h - b, innerW, b), - Left: new Rect(0, b, b, innerH), - Right: new Rect(w - b, b, b, innerH), - TL: new Rect(0, 0, b, b), - TR: new Rect(w - b, 0, b, b), - BL: new Rect(0, h - b, b, b), - BR: new Rect(w - b, h - b, b, b)); - } - - protected override void OnDraw(UiRenderContext ctx) - { - // Center fill is the window BACKGROUND — it must sit UNDER the content, so it - // draws here (before children). The bevel border + grip is the OUTERMOST layer - // and draws in OnDrawAfterChildren (over the content's edges) so content can - // never poke through the frame (e.g. the toolbar's 2px bottom-right cap overhang). - var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border); - DrawTiled(ctx, RetailChromeSprites.CenterFill, r.Center); - } - - protected override void OnDrawAfterChildren(UiRenderContext ctx) - { - var r = ComputeFrameRects(Width, Height, RetailChromeSprites.Border); - // 8-piece bevel: edges tile (UV repeat); corners stretch 1:1. - DrawTiled(ctx, RetailChromeSprites.TopEdge, r.Top); - DrawTiled(ctx, RetailChromeSprites.BottomEdge, r.Bottom); - DrawTiled(ctx, RetailChromeSprites.LeftEdge, r.Left); - DrawTiled(ctx, RetailChromeSprites.RightEdge, r.Right); - DrawStretched(ctx, RetailChromeSprites.CornerTL, r.TL); - DrawStretched(ctx, RetailChromeSprites.CornerTR, r.TR); - DrawStretched(ctx, RetailChromeSprites.CornerBL, r.BL); - DrawStretched(ctx, RetailChromeSprites.CornerBR, r.BR); - - // Resize-grip overlay (gold ridged edges + square corner studs) on top of the - // bevel — the second border layer the vitals LayoutDesc carries (0x1000063B–0x10000642). - DrawTiled(ctx, RetailChromeSprites.GripTop, r.Top); - DrawTiled(ctx, RetailChromeSprites.GripBottom, r.Bottom); - DrawTiled(ctx, RetailChromeSprites.GripLeft, r.Left); - DrawTiled(ctx, RetailChromeSprites.GripRight, r.Right); - DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TL); - DrawStretched(ctx, RetailChromeSprites.GripCorner, r.TR); - DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BL); - DrawStretched(ctx, RetailChromeSprites.GripCorner, r.BR); - } - - private void DrawTiled(UiRenderContext ctx, uint id, Rect d) - { - if (d.W <= 0 || d.H <= 0) return; - var (tex, tw, th) = _resolve(id); - if (tex == 0 || tw == 0 || th == 0) return; - ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, d.W / tw, d.H / th, Vector4.One); - } - - private void DrawStretched(UiRenderContext ctx, uint id, Rect d) - { - if (d.W <= 0 || d.H <= 0) return; - var (tex, _, _) = _resolve(id); - if (tex == 0) return; - ctx.DrawSprite(tex, d.X, d.Y, d.W, d.H, 0, 0, 1, 1, Vector4.One); - } -} diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs index de31ecda..9f941da1 100644 --- a/src/AcDream.App/UI/UiPanel.cs +++ b/src/AcDream.App/UI/UiPanel.cs @@ -1,4 +1,3 @@ -using System; using System.Numerics; namespace AcDream.App.UI; @@ -24,28 +23,10 @@ public class UiPanel : UiElement public float BorderThickness { get; set; } = 1f; - /// Optional dat RenderSurface id for the panel background sprite, drawn - /// in place of (or alongside) . 0 = none. - /// When set, the sprite is stretched to fill the panel rect. - /// Used by the attribute-list selected-row highlight (sprite 0x06001397 = Button state 6). - public uint BackgroundSprite { get; set; } - - /// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height). - /// Required when is non-zero. - public Func? SpriteResolve { get; set; } - protected override void OnDraw(UiRenderContext ctx) { - if (BackgroundSprite != 0 && SpriteResolve is { } sr) - { - var (tex, tw, th) = sr(BackgroundSprite); - if (tex != 0 && tw != 0 && th != 0) - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); - } - else if (BackgroundColor.W > 0f) - { + if (BackgroundColor.W > 0f) ctx.DrawRect(0, 0, Width, Height, BackgroundColor); - } if (BorderColor.W > 0f && BorderThickness > 0f) ctx.DrawRectOutline(0, 0, Width, Height, BorderColor, BorderThickness); @@ -76,17 +57,14 @@ public class UiLabel : UiElement /// callback. Retail equivalent is Keystone's button widget, driven by /// a StateDesc per UIStateId (normal / hot / pressed / /// disabled) from the panel layout. -/// Note: the dat-widget button (Type 1 / UIElement_Button) is -/// in UiButton.cs — that is the production widget used by D.2b panels. -/// This class is the earlier dev-scaffold button (plain rect + text; no dat sprites). /// -public class UiSimpleButton : UiPanel +public class UiButton : UiPanel { public string Text { get; set; } = string.Empty; public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f); public event System.Action? Click; - public UiSimpleButton() + public UiButton() { BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f); BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f); @@ -113,90 +91,3 @@ public class UiSimpleButton : UiPanel ctx.DrawString(Text, tx, ty, TextColor); } } - -/// -/// A that fires an callback when the user -/// left-clicks it. Used for the attribute-list rows in the Character window — each row -/// is a transparent container that needs to respond to pointer hits while its children -/// (icon, name, value) are ClickThrough decorations. -/// -/// Retail analog: the AttributeInfoRegion row widget in gmAttributeUI -/// catches UIEvent_LeftClick (0x01) and calls SetSelectedAttribute on the -/// parent window. In acdream we wire the equivalent via this action callback instead of -/// the retail message bus. -/// -/// When is true and -/// is non-zero, draws the sprite as a thin full-width bar at the TOP and BOTTOM edges of -/// the row (not stretched to fill). This matches retail's selection highlight which shows -/// a horizontal dark bar on both the top and bottom edge of the selected attribute row, -/// with NO left/right end-caps. Bar height is pixels -/// (default 3px). -/// -public class UiClickablePanel : UiPanel -{ - /// Called when the user releases the left mouse button over this panel. - public Action? OnClick { get; set; } - - /// When true and is non-zero, draws - /// the sprite as a thin horizontal bar at the top AND bottom edges of the panel, - /// NOT as a full-height stretched fill. Matches retail's selected-row highlight - /// (sprite 0x06001397 — 300×32 px — shown as bars, not a block fill). - /// Default false (preserves legacy full-stretch behavior). - public bool UseSelectionBars { get; set; } - - /// Height in pixels of each selection bar (top and bottom). Default 3px. - /// Ignored when is false. - public float SelectionBarHeight { get; set; } = 3f; - - public UiClickablePanel() - { - // Rows must receive pointer events — override the UiPanel default (ClickThrough=false, - // which is the UiElement base default). Explicit for clarity. - ClickThrough = false; - } - - /// HandlesClick = true ensures this row receives its own Click even when - /// it is nested inside a Draggable ancestor window frame (e.g. the future character - /// window with a whole-window drag handle). Without this, the press would be consumed - /// by the ancestor's drag logic and the Click would never fire. - public override bool HandlesClick => true; - - public override bool OnEvent(in UiEvent e) - { - if (e.Type == UiEventType.Click && Enabled) - { - OnClick?.Invoke(); - return true; - } - return false; - } - - protected override void OnDraw(UiRenderContext ctx) - { - if (UseSelectionBars && BackgroundSprite != 0 && SpriteResolve is { } sr) - { - // Draw the selection highlight as a thin bar at the TOP and BOTTOM of the row. - // The sprite (0x06001397) is 300×32 px — we draw it as horizontal strips at - // native height (SelectionBarHeight), stretched to full panel width (UV tile - // horizontally). No left/right end-caps: u0=0, u1=Width/nativeW (UV repeat). - var (tex, tw, th) = sr(BackgroundSprite); - if (tex != 0 && tw > 0 && th > 0) - { - float barH = SelectionBarHeight; - float uTile = tw > 0 ? Width / tw : 1f; - // Top bar: shows the top barH px of the sprite (v = 0 → barH/th). - float vBot = th > 0 ? barH / th : 1f; - ctx.DrawSprite(tex, 0f, 0f, Width, barH, 0f, 0f, uTile, vBot, Vector4.One); - // Bottom bar: shows the bottom barH px of the sprite (v = 1−barH/th → 1). - float vTop2 = th > 0 ? 1f - barH / th : 0f; - ctx.DrawSprite(tex, 0f, Height - barH, Width, barH, 0f, vTop2, uTile, 1f, Vector4.One); - } - // Selection-bar mode draws no border (rows have BorderColor=Zero by design). - } - else - { - // Default UiPanel draw: handles BackgroundSprite, BackgroundColor, AND border. - base.OnDraw(ctx); - } - } -} diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs index ebf6fc69..51ce7b83 100644 --- a/src/AcDream.App/UI/UiRenderContext.cs +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -22,29 +22,6 @@ public sealed class UiRenderContext private readonly System.Collections.Generic.List _stack = new(); private Vector2 _current; - // Alpha (opacity) stack — a window pushes its Opacity so its background/sprite - // draws fade (retail's translucent-chat effect). Text draws bypass this (they go - // straight to TextRenderer), so text stays sharp over a translucent background. - private readonly System.Collections.Generic.List _alphaStack = new(); - private float _alpha = 1f; - - /// Current cumulative opacity multiplier applied to sprite + rect draws. - public float AlphaMod => _alpha; - - /// Multiply into the running opacity. Pair with . - public void PushAlpha(float a) { _alphaStack.Add(_alpha); _alpha *= a; } - - /// Push an ABSOLUTE opacity (replaces, not multiplies) — for popups/overlays - /// that must stay opaque even inside a translucent window. Pair with . - public void PushAlphaAbsolute(float a) { _alphaStack.Add(_alpha); _alpha = a; } - - public void PopAlpha() - { - if (_alphaStack.Count == 0) return; - _alpha = _alphaStack[^1]; - _alphaStack.RemoveAt(_alphaStack.Count - 1); - } - public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null) { TextRenderer = tr; @@ -68,33 +45,13 @@ public sealed class UiRenderContext public Vector2 CurrentOrigin => _current; - /// Route subsequent draws to the overlay layer (flushed on top of the whole - /// UI). Used by the root for the popup/overlay traversal. Pair with . - public void BeginOverlayLayer() => TextRenderer.OverlayMode = true; - public void EndOverlayLayer() => TextRenderer.OverlayMode = false; - // ── Pass-through draw helpers (add current translate) ────────────── public void DrawRect(float x, float y, float w, float h, Vector4 color) - => TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color)); - - /// Solid-colour fill drawn in the SPRITE bucket (painter order with text), for - /// a panel BACKGROUND that text draws on top of. composites after - /// all sprites and would cover the text — use this for backgrounds, that for foreground - /// fills (carets, vital bars). - public void DrawFill(float x, float y, float w, float h, Vector4 color) - => TextRenderer.DrawFill(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color)); + => TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, color); public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f) - => TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, ApplyAlpha(color), thickness); - - public void DrawSprite(uint texture, float x, float y, float w, float h, - float u0, float v0, float u1, float v1, Vector4 tint) - => TextRenderer.DrawSprite(texture, - _current.X + x, _current.Y + y, w, h, u0, v0, u1, v1, ApplyAlpha(tint)); - - /// Multiply the current window opacity into a draw color's alpha. - private Vector4 ApplyAlpha(Vector4 c) => _alpha >= 1f ? c : new Vector4(c.X, c.Y, c.Z, c.W * _alpha); + => TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, color, thickness); public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null) { @@ -102,101 +59,4 @@ public sealed class UiRenderContext if (f is null) return; TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color); } - - /// - /// Draw a single line of text with a retail dat font (), - /// at , = the top-left of the - /// typographic block (in this element's local space). Mirrors retail's - /// SurfaceWindow::DrawCharacter (acclient 0x00442bd0): for each glyph - /// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline), - /// then the FOREGROUND atlas sub-rect tinted (the - /// fill). The pen advances by - /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter and each - /// glyph is positioned at pen + HorizontalOffsetBefore on the X axis - /// and at baseline + VerticalOffsetBefore - (BaselineOffset) via the - /// glyph's OffsetY into the atlas. - /// - /// gates the black outline pass. Retail decides - /// this PER text element: UIElement_Text::DrawSelf (acclient 0x00467aa0) - /// runs the outline pass only when m_bitField & 0x10 is set — i.e. the - /// element called SetOutline(true) (LayoutDesc property 0xd). The DEFAULT - /// is OFF (one fill-only pass): the talk-focus menu items set no outline, so an - /// always-on outline shows as a grey halo over the solid menu panel. Pass - /// outline:true only for elements retail outlines. - /// - public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, bool outline = false) - { - if (font is null || string.IsNullOrEmpty(text)) return; - - // Baseline of this line in local space; retail draws glyphs whose - // descriptor OffsetY already places them relative to the line top, so we - // anchor each glyph's quad at the line top (y) plus its VerticalOffsetBefore. - float originX = _current.X + x; - float originY = _current.Y + y; - float pen = originX; - - // Snap the LINE baseline to a whole pixel ONCE. Retail's - // SurfaceWindow::DrawCharacter (acclient 0x00442bd0) takes an int32 pen Y - // (arg3) and adds the glyph's integer m_VerticalOffsetBefore (a schar) — every - // glyph on a line shares one integer baseline. If we instead round EACH glyph's - // Y independently and the caller passes a fractional line Y (e.g. a channel-menu - // item centered in a 17px row over a 16px font → y = 0.5), adjacent letters round - // to different rows and the line looks crooked ("letters dip down"). The vitals - // digits never showed it because their bar baseline lands on an integer; chat text - // does. Snapping the baseline once, then adding the integer offset, keeps the whole - // line on one row and pixel-aligned. - float baseY = System.MathF.Round(originY); - - var outlineTint = new Vector4(0f, 0f, 0f, color.W); - - for (int i = 0; i < text.Length; i++) - { - if (!font.TryGetGlyph(text[i], out var g)) - continue; - - // Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its - // true fractional advance). Vertical: integer baseline + integer per-glyph - // offset — never an independent per-glyph round (see baseY note above). - float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore); - float gy = baseY + g.VerticalOffsetBefore; - float gw = g.Width; - float gh = g.Height; - - if (gw > 0f && gh > 0f) - { - // Background (outline) atlas pass, tinted black — drawn behind. Gated by - // `outline` (retail's per-element m_bitField & 0x10); off by default so UI - // text is crisp fill-only and free of the grey halo over solid panels. - if (outline && font.BackgroundTexture != 0) - { - var (bu0, bv0, bu1, bv1) = AtlasUv( - g.OffsetX, g.OffsetY, g.Width, g.Height, - font.BackgroundWidth, font.BackgroundHeight); - TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outlineTint); - } - - // Foreground (fill) atlas pass, tinted with the requested color. - var (fu0, fv0, fu1, fv1) = AtlasUv( - g.OffsetX, g.OffsetY, g.Width, g.Height, - font.ForegroundWidth, font.ForegroundHeight); - TextRenderer.DrawSprite(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, color); - } - - pen += UiDatFont.GlyphAdvance(g); - } - } - - /// Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to - /// normalized UVs for an atlas of x - /// . Guards against a zero-sized atlas. - private static (float u0, float v0, float u1, float v1) AtlasUv( - int offsetX, int offsetY, int width, int height, int atlasW, int atlasH) - { - if (atlasW <= 0 || atlasH <= 0) return (0f, 0f, 0f, 0f); - float u0 = offsetX / (float)atlasW; - float v0 = offsetY / (float)atlasH; - float u1 = (offsetX + width) / (float)atlasW; - float v1 = (offsetY + height) / (float)atlasH; - return (u0, v0, u1, v1); - } } diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs index 251b66c1..7df41739 100644 --- a/src/AcDream.App/UI/UiRoot.cs +++ b/src/AcDream.App/UI/UiRoot.cs @@ -4,10 +4,6 @@ using System.Numerics; namespace AcDream.App.UI; -/// Which edges of a window a resize-drag is affecting (corners combine two). -[System.Flags] -public enum ResizeEdges { None = 0, Left = 1, Right = 2, Top = 4, Bottom = 8 } - /// /// Top-level UI container. Implements the retail "Device" responsibilities /// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture, @@ -44,10 +40,6 @@ public sealed class UiRoot : UiElement /// Widget currently receiving keyboard events. public UiElement? KeyboardFocus { get; private set; } - /// The edit control activated by Tab/Enter when nothing is focused — retail's - /// chat input "write mode" toggle. Set by the host once the chat window is built. - public UiElement? DefaultTextInput { get; set; } - /// /// Single modal overlay; while set, mouse clicks outside its rect /// are ignored. Retail sets this via Device vtable +0x48. @@ -57,33 +49,12 @@ public sealed class UiRoot : UiElement /// Widget with mouse capture (during click-drag). public UiElement? Captured { get; private set; } - /// - /// True when the pointer is over a widget OR a widget holds mouse capture. - /// The host ORs this into the InputDispatcher's WantCaptureMouse gate so game - /// actions (movement, world-pick) are suppressed while the user interacts with - /// a retail window — mirrors ImGui's WantCaptureMouse. - /// - public bool WantsMouse => Captured is not null || HitTestTopDown(MouseX, MouseY).element is not null; - - /// True when a widget holds keyboard focus (e.g. a focused chat input). - public bool WantsKeyboard => KeyboardFocus is not null; - /// Current drag source (set between drag-begin and drop/cancel). public UiElement? DragSource { get; private set; } public object? DragPayload { get; private set; } - private (uint tex, int w, int h)? _dragGhost; - /// Snapshotted drag-ghost (tex,w,h), exposed for tests. See BeginDrag. - internal (uint tex, int w, int h)? DragGhostForTest => _dragGhost; private UiElement? _lastDragHoverTarget; private int _pressX, _pressY; private bool _dragCandidate; - private UiElement? _windowDragTarget; - private int _windowDragOffX, _windowDragOffY; - private UiElement? _resizeTarget; - private ResizeEdges _resizeEdges; - private float _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH; - private int _resizeMouseX, _resizeMouseY; - private const int ResizeGrip = 5; // px proximity to an edge to start a resize private const int DragDistanceThreshold = 3; // pixels, retail-observed // Hover / tooltip tracking. @@ -138,27 +109,6 @@ public sealed class UiRoot : UiElement // Render children (panels) sorted by z-order — modal last so it // sits on top. DrawSelfAndChildren(ctx); - // Second pass: open popups/menus draw ON TOP of the whole tree (so e.g. the - // chat channel menu isn't greyed by the translucent chat panel that draws - // after it in the main pass). Routed to the renderer's overlay layer so it - // beats even rect backgrounds. Faithful to retail's root-level MakePopup. - ctx.BeginOverlayLayer(); - DrawOverlays(ctx); - DrawDragGhost(ctx); - ctx.EndOverlayLayer(); - } - - private const float GhostAlpha = 1.0f; // retail m_dragIcon is the full icon, no fade - - /// Paint the drag ghost at the cursor. The texture comes from the snapshotted - /// ghost captured in so UiRoot stays item-agnostic and the ghost - /// survives the source cell emptying on lift; the ghost is NOT a tree element, so it - /// never intercepts hit-tests. - private void DrawDragGhost(UiRenderContext ctx) - { - if (_dragGhost is not { } g || g.tex == 0) return; - ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h, - 0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha)); } // ── Input entry points (called from GameWindow's Silk.NET handlers) ── @@ -170,27 +120,6 @@ public sealed class UiRoot : UiElement MouseX = x; MouseY = y; - // Window resize takes precedence over move / drag-drop / hover. - if (_resizeTarget is not null) - { - var (nx, ny, nw, nh) = ResizeRect( - _resizeStartX, _resizeStartY, _resizeStartW, _resizeStartH, - _resizeEdges, x - _resizeMouseX, y - _resizeMouseY, - _resizeTarget.MinWidth, _resizeTarget.MinHeight, - float.MaxValue, _resizeTarget.MaxHeight); - _resizeTarget.Left = nx; _resizeTarget.Top = ny; - _resizeTarget.Width = nw; _resizeTarget.Height = nh; - return; - } - - // Window-move drag takes precedence over drag-drop / hover / fall-through. - if (_windowDragTarget is not null) - { - _windowDragTarget.Left = x - _windowDragOffX; - _windowDragTarget.Top = y - _windowDragOffY; - return; - } - // If we have capture, deliver MouseMove to the captured widget // AND drive drag state machine; do NOT fall through. if (Captured is not null) @@ -203,7 +132,7 @@ public sealed class UiRoot : UiElement if (Math.Abs(x - _pressX) > DragDistanceThreshold || Math.Abs(y - _pressY) > DragDistanceThreshold) { - BeginDrag(Captured); + BeginDrag(Captured, payload: null); } } if (DragSource is not null) @@ -226,82 +155,19 @@ public sealed class UiRoot : UiElement if (Modal is not null && !ContainsAbsolute(Modal, x, y)) return; - var (target, _, _) = HitTestTopDown(x, y); + var (target, lx, ly) = HitTestTopDown(x, y); if (target is null) { - // Clicking the 3D world exits write mode (no submit) and returns control to - // the character — retail blurs the chat input on an outside click. - if (btn == UiMouseButton.Left) SetKeyboardFocus(null); WorldMouseFallThrough?.Invoke(btn, x, y, flags); return; } - // Keyboard focus follows a left click: the input bar (an edit control) takes - // focus = enters write mode; clicking anything else (chrome, Send, scrollbar, - // menu, another window) blurs the input = exits write mode WITHOUT submitting. - if (btn == UiMouseButton.Left) - SetKeyboardFocus(target.AcceptsFocus ? target : null); + // Set keyboard focus if target accepts it. + if (target.AcceptsFocus) SetKeyboardFocus(target); + // Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold). SetCapture(target); - - // Window resize / move: find the window (Draggable or Resizable ancestor). - // A left-drag starting near an edge resizes; interior drag repositions; - // otherwise it's a normal drag-drop candidate. - var window = FindWindow(target); - // Retail-faithful: pressing on a window raises it above its peers. - if (window is not null) BringToFront(window); - if (btn == UiMouseButton.Left && window is not null) - { - var edges = window.Resizable ? HitEdges(window, x, y, ResizeGrip) : ResizeEdges.None; - if (edges != ResizeEdges.None) - { - // Edge resize still wins, even over a CapturesPointerDrag child: - // a resizable chat window can be resized from its frame. - _resizeTarget = window; - _resizeEdges = edges; - _resizeStartX = window.Left; _resizeStartY = window.Top; - _resizeStartW = window.Width; _resizeStartH = window.Height; - _resizeMouseX = x; _resizeMouseY = y; - _dragCandidate = false; - } - else if (target.IsDragSource) - { - // A drag SOURCE (e.g. an occupied item cell) inside a Draggable window - // starts an item drag-drop, NOT a window move. UiRoot stays item-agnostic: - // it only reads the IsDragSource flag (the cell decides occupancy). The - // BeginDrag promotion happens on the >3px move (and cancels if the source's - // GetDragPayload() returns null). Empty cells are NOT drag sources, so they - // fall through to window.Draggable below (IA-12 whole-window-drag), keeping - // the bar movable by its empty cells / chrome. - _dragCandidate = true; - } - else if (target.CapturesPointerDrag || target.HandlesClick) - { - // The pressed widget owns its pointer interaction — either an interior drag (e.g. text - // selection, CapturesPointerDrag) or a click it must receive (e.g. a UiButton, - // HandlesClick). Either way do NOT move the ancestor window. The already-dispatched - // MouseDown + SetCapture(target) let the target handle it; on release OnMouseUp emits - // the Click over the same element. (A HandlesClick widget is not a drag candidate.) - _dragCandidate = false; - } - else if (window.Draggable) - { - _windowDragTarget = window; - _windowDragOffX = x - (int)window.Left; - _windowDragOffY = y - (int)window.Top; - _dragCandidate = false; - } - else { _dragCandidate = true; } - } - else if (target.CapturesPointerDrag) - { - // No window ancestor, but the target still owns its interior drag. - _dragCandidate = false; - } - else - { - _dragCandidate = true; - } + _dragCandidate = true; // Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201). int rawType = btn switch @@ -311,13 +177,8 @@ public sealed class UiRoot : UiElement UiMouseButton.Middle => UiEventType.MiddleDown, _ => UiEventType.MouseDown, }; - // Deliver TARGET-LOCAL coords (consistent with MouseMove/MouseUp, which use - // target.ScreenPosition). HitTestTopDown's lx/ly are relative to the TOP-LEVEL - // child, so for a nested target (e.g. the chat view inset inside its window) - // they'd be offset by the child's position — which mis-anchored drag-select. - var sp = target.ScreenPosition; var e = new UiEvent(target.EventId, target, rawType, - Data0: (int)flags, Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y)); + Data0: (int)flags, Data1: (int)lx, Data2: (int)ly); BubbleEvent(target, in e); } @@ -326,20 +187,6 @@ public sealed class UiRoot : UiElement MouseX = x; MouseY = y; UpdateButtonFlag(btn, down: false); - if (_resizeTarget is not null) - { - _resizeTarget = null; - ReleaseCapture(); - return; - } - - if (_windowDragTarget is not null) - { - _windowDragTarget = null; - ReleaseCapture(); - return; - } - if (DragSource is not null) { FinishDrag(x, y); @@ -404,18 +251,6 @@ public sealed class UiRoot : UiElement public void OnKeyDown(int vk, uint lparam = 0) { - // Nothing focused yet: Tab or Enter enters "write mode" by focusing the chat - // input (retail's chat-activation hotkeys). Consumed so the same press doesn't - // also fall through to a game hotkey. - if (KeyboardFocus is null && DefaultTextInput is not null - && (vk == (int)Silk.NET.Input.Key.Tab - || vk == (int)Silk.NET.Input.Key.Enter - || vk == (int)Silk.NET.Input.Key.KeypadEnter)) - { - SetKeyboardFocus(DefaultTextInput); - return; - } - // Focus widget first. if (KeyboardFocus is not null) { @@ -477,64 +312,12 @@ public sealed class UiRoot : UiElement public void SetCapture(UiElement e) => Captured = e; public void ReleaseCapture() => Captured = null; - // ── Window manager (named top-level windows: Show / Hide / Toggle) ─── - - private readonly Dictionary _windows = new(); - - /// Register a top-level window under a name for Show/Hide/Toggle. - /// Does NOT add it to the tree — the caller mounts via AddChild and controls - /// initial Visible. Idempotent (re-register replaces). The dict is the source - /// of truth — independent of UiElement.Name (init-only, not set here). - public void RegisterWindow(string name, UiElement window) => _windows[name] = window; - - /// Make the named window visible. No-op (returns false) if unknown. - public bool ShowWindow(string name) - { - if (!_windows.TryGetValue(name, out var w)) return false; - w.Visible = true; - BringToFront(w); - return true; - } - - /// Hide the named window. No-op (returns false) if unknown. - public bool HideWindow(string name) - { - if (!_windows.TryGetValue(name, out var w)) return false; - w.Visible = false; - return true; - } - - /// Flip the named window's visibility (Show if hidden, Hide if shown). - /// Returns the new IsVisible state (false for an unknown name). - public bool ToggleWindow(string name) - { - if (!_windows.TryGetValue(name, out var w)) return false; - if (w.Visible) { HideWindow(name); return false; } - ShowWindow(name); - return true; - } - - /// Raise a top-level window above its siblings by setting its ZOrder - /// one past the current max among the OTHER top-level children. Used on Show - /// and on click. Leaves ZOrder unchanged if it is the only / already-topmost child. - public void BringToFront(UiElement window) - { - int top = window.ZOrder; - foreach (var c in Children) - if (!ReferenceEquals(c, window)) - top = System.Math.Max(top, c.ZOrder + 1); - window.ZOrder = top; - } - // ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ──────── - private void BeginDrag(UiElement source) + private void BeginDrag(UiElement source, object? payload) { - var payload = source.GetDragPayload(); - if (payload is null) { _dragCandidate = false; return; } DragSource = source; DragPayload = payload; - _dragGhost = source.GetDragGhost(); // snapshot NOW — the DragBegin handler may empty the source cell var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload); source.OnEvent(in e); } @@ -567,18 +350,16 @@ public sealed class UiRoot : UiElement private void FinishDrag(int x, int y) { var (t, lx, ly) = HitTestTopDown(x, y); - if (t is not null) - { - // Dropped on a real element — deliver DropReleased; the hit cell's handler places. - // A non-item target's OnEvent ignores it, so an off-bar drop leaves the lift's removal. - var e = new UiEvent(DragSource!.EventId, t, UiEventType.DropReleased, - Data1: (int)lx, Data2: (int)ly, Payload: DragPayload); - t.OnEvent(in e); - } - // else: dropped on nothing — no drop fires; the lift (drag-begin removal) stands (retail). + var target = t ?? DragSource!; + var accepted = t is not null && t != DragSource; + var e = new UiEvent(DragSource!.EventId, target, UiEventType.DropReleased, + Data0: accepted ? 1 : 0, + Data1: (int)lx, Data2: (int)ly, + Payload: DragPayload); + target.OnEvent(in e); + DragSource = null; DragPayload = null; - _dragGhost = null; _lastDragHoverTarget = null; } @@ -655,54 +436,6 @@ public sealed class UiRoot : UiElement return (null, 0, 0); } - /// Public hit-test for tooling (the UI Studio inspector): the topmost element under - /// (x,y) in root space, honoring modal exclusivity + Z-order. Wraps the private HitTestTopDown. - public UiElement? Pick(int x, int y) => HitTestTopDown(x, y).element; - - private static UiElement? FindWindow(UiElement? e) - { - while (e is not null) - { - if (e.Draggable || e.Resizable) return e; - e = e.Parent; - } - return null; - } - - /// Which edges of 's screen rect the point - /// (,) is within px of. - /// None if the point is outside the grip-expanded box entirely. - internal static ResizeEdges HitEdges(UiElement w, int x, int y, int grip) - { - float l = w.Left, t = w.Top, r = w.Left + w.Width, b = w.Top + w.Height; - if (x < l - grip || x > r + grip || y < t - grip || y > b + grip) return ResizeEdges.None; - var e = ResizeEdges.None; - if (System.Math.Abs(x - l) <= grip) e |= ResizeEdges.Left; - if (System.Math.Abs(x - r) <= grip) e |= ResizeEdges.Right; - if (System.Math.Abs(y - t) <= grip) e |= ResizeEdges.Top; - if (System.Math.Abs(y - b) <= grip) e |= ResizeEdges.Bottom; - if (!w.ResizeX) e &= ~(ResizeEdges.Left | ResizeEdges.Right); - if (!w.ResizeY) e &= ~(ResizeEdges.Top | ResizeEdges.Bottom); - e &= w.ResizableEdges; - return e; - } - - /// Compute a resized rect from a start rect + drag delta + which edges, - /// clamping to (,) and - /// (,). Left/Top edges move the - /// origin so the opposite edge stays put. - public static (float x, float y, float w, float h) ResizeRect( - float startX, float startY, float startW, float startH, - ResizeEdges edges, float dx, float dy, float minW, float minH, float maxW, float maxH) - { - float x = startX, y = startY, w = startW, h = startH; - if ((edges & ResizeEdges.Right) != 0) w = System.Math.Clamp(startW + dx, minW, maxW); - if ((edges & ResizeEdges.Bottom) != 0) h = System.Math.Clamp(startH + dy, minH, maxH); - if ((edges & ResizeEdges.Left) != 0) { float nw = System.Math.Clamp(startW - dx, minW, maxW); x = startX + (startW - nw); w = nw; } - if ((edges & ResizeEdges.Top) != 0) { float nh = System.Math.Clamp(startH - dy, minH, maxH); y = startY + (startH - nh); h = nh; } - return (x, y, w, h); - } - private static bool ContainsAbsolute(UiElement e, int x, int y) { var sp = e.ScreenPosition; diff --git a/src/AcDream.App/UI/UiScrollable.cs b/src/AcDream.App/UI/UiScrollable.cs deleted file mode 100644 index f9e78a12..00000000 --- a/src/AcDream.App/UI/UiScrollable.cs +++ /dev/null @@ -1,57 +0,0 @@ -using System; - -namespace AcDream.App.UI; - -/// -/// Pixel-based vertical scroll model. Port of retail UIElement_Scrollable: -/// the scroll offset is an integer pixel value (m_iScrollableY) clamped to -/// [0, ContentHeight - ViewHeight]; the thumb ratio is view/content; the position -/// ratio is scroll/(content-view). Pure (no GL) so it is fully unit-tested and -/// shared by the transcript (UiText) and the scrollbar (UiScrollbar). -/// Decomp anchors: SetScrollableXY @0x4740c0, UpdateScrollbarSize_ @0x4741a0, -/// UpdateScrollbarPosition_ @0x473f20, UIElement_Text::InqScrollDelta @0x4689b0. -/// -public sealed class UiScrollable -{ - /// Total wrapped content height in px (m_iScrollableHeight). - public int ContentHeight { get; set; } - /// Visible viewport height in px. - public int ViewHeight { get; set; } - /// Pixels per text line (scroll quantum). InqScrollDelta line case. - public int LineHeight { get; set; } = 16; - - private int _scrollY; - /// Current scroll offset in px from the top of the content. - public int ScrollY => _scrollY; - - /// Max scroll = max(0, content - view). - public int MaxScroll => Math.Max(0, ContentHeight - ViewHeight); - - /// True when content exceeds the view (a scrollbar is warranted). - public bool HasOverflow => ContentHeight > ViewHeight; - - /// True when the offset is at (or past) the bottom — used for bottom-pin. - public bool AtEnd => _scrollY >= MaxScroll; - - /// Set the offset, clamped to [0, MaxScroll] (SetScrollableXY clamp). - public void SetScrollY(int y) => _scrollY = Math.Clamp(y, 0, MaxScroll); - - /// Pin to the bottom (newest content visible). - public void ScrollToEnd() => _scrollY = MaxScroll; - - /// Thumb size ratio = view/content, clamped to 1 (UpdateScrollbarSize_). - public float ThumbRatio => ContentHeight <= 0 ? 1f : Math.Min(1f, (float)ViewHeight / ContentHeight); - - /// Position ratio = scroll/(content-view) in [0,1] (UpdateScrollbarPosition_). - public float PositionRatio => MaxScroll <= 0 ? 0f : (float)_scrollY / MaxScroll; - - /// Inverse of PositionRatio — used when the user drags the thumb. - public void SetPositionRatio(float ratio) - => SetScrollY((int)MathF.Round(Math.Clamp(ratio, 0f, 1f) * MaxScroll)); - - /// Scroll by whole lines (sign: +down/newer, -up/older). - public void ScrollByLines(int lines) => SetScrollY(_scrollY + lines * LineHeight); - - /// Scroll by a page = one view height (InqScrollDelta page case). - public void ScrollByPage(int pages) => SetScrollY(_scrollY + pages * ViewHeight); -} diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs deleted file mode 100644 index d574b597..00000000 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ /dev/null @@ -1,210 +0,0 @@ -using System; -using System.Numerics; - -namespace AcDream.App.UI; - -/// -/// Generic scrollbar. Ports retail UIElement_Scrollbar -/// (RegisterElementClass(0xb) @ acclient_2013_pseudo_c.txt:124137); -/// thumb size = trackLen * ThumbRatio (min 8px); step ±1 line. -/// -/// -/// Dat element ids (chat LayoutDesc 0x21000006): track 0x10000012 (X=474 Y=6 W=16 H=68), -/// thumb 0x1000048C. The track is instanced from base layout 0x2100003E which contains -/// the full scrollbar widget with distinct up/down button children: -/// Up button element 0x10000071 — Y=0, 16×16, Normal sprite 0x06004C69. -/// Down button element 0x10000072 — Y=32, 16×16, Normal sprite 0x06004C6C. -/// Track body sprite: 0x06004C5F (48px tall in the base template; stretched to H=68 in chat). -/// Thumb is a 3-slice: top cap 0x06004C60, middle 0x06004C63, bottom cap 0x06004C66. -/// For Task H wiring: up/down regions occupy the top and bottom ButtonH (16px) of the -/// rendered scrollbar's height; the widget responds to those regions directly via hit -/// comparison in OnEvent without requiring separate child elements. -/// -public sealed class UiScrollbar : UiElement -{ - /// The scroll model this bar reflects + drives (shared with the transcript). - public UiScrollable? Model { get; set; } - - /// RenderSurface id → (GL tex, w, h). 0 id = skip. - public Func? SpriteResolve { get; set; } - - /// Track background sprite id (0x06004C5F from layout 0x2100003E element 0x10000455). - public uint TrackSprite { get; set; } - - /// Thumb 3-slice MIDDLE tile sprite id (0x06004C63), tiled between the caps. - public uint ThumbSprite { get; set; } - - /// Thumb 3-slice TOP cap sprite id (0x06004C60, 3px tall). - public uint ThumbTopSprite { get; set; } - - /// Thumb 3-slice BOTTOM cap sprite id (0x06004C66, 3px tall). - public uint ThumbBotSprite { get; set; } - - /// Up-arrow button sprite id (0x06004C69 Normal state, element 0x10000071). - public uint UpSprite { get; set; } - - /// Down-arrow button sprite id (0x06004C6C Normal state, element 0x10000072). - public uint DownSprite { get; set; } - - /// Retail attribute 0x89 floor: minimum thumb height in pixels. - private const float MinThumb = 8f; - - /// Thumb cap height (native sprite height from base layout 0x2100003E). - private const float CapH = 3f; - - /// Up/down button height in pixels. Matches element height 16px from - /// the up/down button children in base layout 0x2100003E. - private const float ButtonH = 16f; - - private bool _draggingThumb; - private float _dragOffsetY; - - public UiScrollbar() { CapturesPointerDrag = true; } - - /// The scrollbar draws its own track/thumb/arrows; its dat up/down button - /// children are reproduced procedurally, so the importer must not build them. - public override bool ConsumesDatChildren => true; - - /// - /// Computes the thumb rectangle (local y origin and height) within the track area - /// between the two end buttons. Ports retail UIElement_Scrollbar::UpdateLayout - /// @0x4710d0: thumb height = max(MinThumb, trackLen * ThumbRatio); thumb top - /// offset = trackTop + (trackLen - thumbH) * PositionRatio. - /// - /// The scroll model. - /// Y of the top of the usable track area (below up-button). - /// Pixel length of the usable track area (between up and down buttons). - /// Local Y of the thumb's top edge, and its pixel height. - public static (float y, float h) ThumbRect(UiScrollable m, float trackTop, float trackLen) - { - float h = MathF.Max(MinThumb, trackLen * m.ThumbRatio); - float travel = trackLen - h; - float y = trackTop + travel * m.PositionRatio; - return (y, h); - } - - protected override void OnDraw(UiRenderContext ctx) - { - if (Model is not { } m || SpriteResolve is not { } resolve) return; - - // Track background — TILED vertically (retail DrawMode=Normal). The native track - // sprite (~16×32) repeats to fill the element height instead of stretch-distorting. - DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); - - // Up button — top ButtonH rows. UpSprite (0x06004C6C) is the up-arrow art, drawn 1:1. - DrawSprite(ctx, resolve, UpSprite, 0f, 0f, Width, ButtonH); - - // Down button — bottom ButtonH rows. DownSprite (0x06004C69) is the down-arrow art. - DrawSprite(ctx, resolve, DownSprite, 0f, Height - ButtonH, Width, ButtonH); - - // Thumb — only when content overflows the view. Retail 3-slice: top cap + - // tiled middle + bottom cap (base layout 0x2100003E thumb sub-elements - // 0x10000364/65/66). Falls back to a single tiled middle if the caps are unset - // or the thumb is too short to hold both caps. - if (m.HasOverflow) - { - float trackTop = ButtonH; - float trackLen = Height - 2f * ButtonH; - var (ty, th) = ThumbRect(m, trackTop, trackLen); - if (ThumbTopSprite != 0 && ThumbBotSprite != 0 && th >= 2f * CapH) - { - DrawSprite(ctx, resolve, ThumbTopSprite, 0f, ty, Width, CapH); - DrawTiled(ctx, resolve, ThumbSprite, 0f, ty + CapH, Width, th - 2f * CapH); - DrawSprite(ctx, resolve, ThumbBotSprite, 0f, ty + th - CapH, Width, CapH); - } - else - { - DrawTiled(ctx, resolve, ThumbSprite, 0f, ty, Width, th); - } - } - } - - /// Draw a sprite stretched 1:1 to the dest rect. - private void DrawSprite(UiRenderContext ctx, Func resolve, - uint id, float x, float y, float w, float h) - { - if (id == 0 || w <= 0f || h <= 0f) return; - var (tex, _, _) = resolve(id); - if (tex == 0) return; - ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, 1f, 1f, Vector4.One); - } - - /// Draw a sprite 1:1 but vertically FLIPPED (V0/V1 swapped) — used to point - /// the top scroll button's (down-art) arrow upward. - private void DrawSpriteFlipV(UiRenderContext ctx, Func resolve, - uint id, float x, float y, float w, float h) - { - if (id == 0 || w <= 0f || h <= 0f) return; - var (tex, _, _) = resolve(id); - if (tex == 0) return; - ctx.DrawSprite(tex, x, y, w, h, 0f, 1f, 1f, 0f, Vector4.One); - } - - /// Draw a sprite TILED to fill the dest rect (UV-repeat at native size on - /// both axes — the UI texture is GL_REPEAT-wrapped). A native-width axis gives 1:1. - private void DrawTiled(UiRenderContext ctx, Func resolve, - uint id, float x, float y, float w, float h) - { - if (id == 0 || w <= 0f || h <= 0f) return; - var (tex, tw, th) = resolve(id); - if (tex == 0 || tw == 0 || th == 0) return; - ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One); - } - - public override bool OnEvent(in UiEvent e) - { - if (Model is not { } m) return false; - - switch (e.Type) - { - case UiEventType.MouseDown: - { - // e.Data1 = local X, e.Data2 = local Y (int pixel coords, see UiRoot hit dispatch). - float ly = e.Data2; - - // Up-button region: top ButtonH rows. - if (ly <= ButtonH) { m.ScrollByLines(-1); return true; } - - // Down-button region: bottom ButtonH rows. - if (ly >= Height - ButtonH) { m.ScrollByLines(1); return true; } - - // Track interior: start a thumb drag or page-scroll. - float trackTop = ButtonH; - float trackLen = Height - 2f * ButtonH; - var (ty, th) = ThumbRect(m, trackTop, trackLen); - - if (ly >= ty && ly <= ty + th) - { - // Clicked inside the thumb — begin drag with offset from thumb top. - _draggingThumb = true; - _dragOffsetY = ly - ty; - } - else - { - // Clicked above or below thumb — page scroll (HandleButtonClick page case). - m.ScrollByPage(ly < ty ? -1 : 1); - } - return true; - } - - case UiEventType.MouseMove when _draggingThumb: - { - // Map current local Y (minus drag offset from thumb top) back to a - // position ratio across the available travel distance. - float trackTop = ButtonH; - float trackLen = Height - 2f * ButtonH; - float thumbH = MathF.Max(MinThumb, trackLen * m.ThumbRatio); - float travel = MathF.Max(1f, trackLen - thumbH); - float newRatio = ((float)e.Data2 - _dragOffsetY - trackTop) / travel; - m.SetPositionRatio(newRatio); - return true; - } - - case UiEventType.MouseUp: - _draggingThumb = false; - return true; - } - - return false; - } -} diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs deleted file mode 100644 index 0d9f2b15..00000000 --- a/src/AcDream.App/UI/UiText.cs +++ /dev/null @@ -1,525 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using System.Text; -using AcDream.App.Rendering; -using AcDream.App.UI.Layout; - -namespace AcDream.App.UI; - -/// -/// Scrollable text view for retail UIElement_Text elements -/// (RegisterElementClass(0xc) @ acclient_2013_pseudo_c.txt:115655). -/// Renders the lines from bottom-pinned (newest at the bottom, -/// like retail) with mouse-wheel scrollback. Whole-line vertical clipping keeps -/// text inside the window. -/// -/// -/// Supports Windows-like text selection: a left-click-drag inside the transcript -/// selects characters (the opt-out -/// stops that interior drag from moving the host window), and Ctrl+C copies the -/// selected span to the clipboard. Ctrl+A selects everything. -/// -/// -public sealed class UiText : UiElement -{ - /// One display line: pre-formatted text + its colour. - public readonly record struct Line(string Text, Vector4 Color); - - /// A caret position: a line index into the cached line list plus a - /// character index (0..line.Text.Length, i.e. a caret slot between glyphs). - public readonly record struct Pos(int Line, int Col); - - /// Provider of the lines to show, oldest-first. Polled each frame. - public Func> LinesProvider { get; set; } = static () => Array.Empty(); - - /// Font for the transcript; falls back to the context default. - public BitmapFont? Font { get; set; } - - /// Retail dat font (0x40000000) for the transcript. When set, glyphs - /// render via the two-pass dat-font blit and measure/hit-test use the dat glyph - /// advance; when null, the debug BitmapFont path is used. Set by the controller. - public UiDatFont? DatFont { get; set; } - - /// Keyboard device for clipboard (Ctrl+C) + modifier state. Wired by - /// the host from . - public Silk.NET.Input.IKeyboard? Keyboard { get; set; } - - /// - /// Default line color used by controllers when they do not supply a per-line - /// color explicitly. Set by DatWidgetFactory.BuildText - /// from ElementInfo.FontColor when the dat carries a 0x1B ColorBaseProperty; - /// otherwise white (). - /// - /// Controllers that supply a per-line color via - /// (e.g. new UiText.Line(text, explicitColor)) are unaffected — they always - /// win over this default. This property is only a convenience starting point for - /// controllers that want to read the dat color rather than hard-code it. - /// - public Vector4 DefaultColor { get; set; } = Vector4.One; - - /// Backing fill behind the text. Defaults to transparent so an unbound - /// UiText (no controller) draws nothing. Set to the retail translucent value by - /// the controller (e.g. ChatWindowController). - public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f); - - /// Optional dat state-sprite background (the element's own media), drawn - /// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none. - public uint BackgroundSprite { get; set; } - - /// Resolves a dat RenderSurface id to (GL tex handle, pixel width, pixel height). - /// Required when is non-zero. - public Func? SpriteResolve { get; set; } - - /// Highlight colour painted behind a selected character span. - public Vector4 SelectionColor { get; set; } = new(0.25f, 0.45f, 0.85f, 0.5f); - - /// Inner text inset from the view edges, px. - public float Padding { get; set; } = 4f; - - /// Static centered single-line mode (retail UIElement_Text center - /// justification): draws the FIRST line centered horizontally AND vertically in the - /// element rect, with NO scroll/selection machinery. Used for static labels such as - /// the vitals cur/max numbers. The centering formula is IDENTICAL to - /// 's former number overlay so those numbers stay pixel-identical - /// after the rewire. Pair with ClickThrough = true for non-interactive labels. - public bool Centered { get; set; } - - /// Static right-aligned single-line mode: draws the FIRST line right-justified - /// within the element rect, vertically centered, with NO scroll/selection machinery. - /// Used for value labels in attribute/skill rows where the number must hug the right edge. - /// Mutually exclusive with — if both are true, Centered takes - /// precedence. Pair with ClickThrough = true for non-interactive labels. - public bool RightAligned { get; set; } - - /// - /// Vertical position of the text within the element rect in single-line mode - /// ( or ). - /// - /// Center (default) — vertically centered, matching the original - /// behavior of the centered/right-aligned paths. - /// Top — text is placed at y = Padding (top of the content - /// area), so the text sits at the top of the element rather than centering in it. - /// Used for footer title elements whose dat box is the full footer height (55 px) but - /// the text should render near the top. - /// Bottom — text is placed at y = Height - lineHeight - Padding. - /// - /// Only meaningful when or is true. - /// Has no effect on the scrollable multi-line path. - /// - public VJustify VerticalJustify { get; set; } = VJustify.Center; - - /// The scroll model — also read by the linked UiScrollbar. - public UiScrollable Scroll { get; } = new(); - - /// True while the view is pinned to the newest line (auto-scrolls as content grows). - private bool _pinBottom = true; - - private const float WheelLines = 1f; // lines advanced per wheel notch (retail = 1 line per notch) - - // ── Cached layout from the last OnDraw, so OnEvent hit-tests the SAME geometry ── - private IReadOnlyList _lastLines = Array.Empty(); - private BitmapFont? _lastFont; - private UiDatFont? _lastDatFont; - private float _lastLineHeight = 16f; - private float _lastBaseY; // top Y of line 0 in local space - private float _lastPadding = 4f; - - // ── Selection state ────────────────────────────────────────────────── - private Pos? _selAnchor; // where the drag started - private Pos? _selCaret; // where the drag currently is - private bool _selecting; - - public UiText() - { - AcceptsFocus = true; - IsEditControl = true; // absorb keys (Ctrl+C) while focused - CapturesPointerDrag = true; // interior drag selects, doesn't move the window - } - - /// The text view draws its own lines + background; any dat sub-elements - /// (scroll indicators, caps) are not built as separate widgets by the importer. - public override bool ConsumesDatChildren => true; - - /// - /// Clamp a scroll offset to [0, max] where max = content-height - view-height - /// (never negative — when everything fits, scroll is pinned to 0). Exposed for tests. - /// - public static float ClampScroll(float scroll, float contentHeight, float viewHeight) - { - float max = Math.Max(0f, contentHeight - viewHeight); - if (scroll < 0f) return 0f; - return scroll > max ? max : scroll; - } - - protected override void OnDraw(UiRenderContext ctx) - { - // Optional dat state-sprite background drawn UNDER everything else. - if (BackgroundSprite != 0 && SpriteResolve is { } sr) - { - var (tex, tw, th) = sr(BackgroundSprite); - if (tex != 0 && tw != 0 && th != 0) - ctx.DrawSprite(tex, 0, 0, Width, Height, 0, 0, Width / tw, Height / th, Vector4.One); - } - - // Background must draw UNDER the transcript text. DrawStringDat emits into the - // sprite bucket which flushes BEFORE rects, so a DrawRect background would wash - // over the text. DrawFill routes the background through the sprite bucket too, - // submitted first → text on top. - ctx.DrawFill(0, 0, Width, Height, BackgroundColor); - - // Static centered single-line mode (vitals cur/max numbers etc.): draw the first - // line centered H+V (or H+Top/Bottom per VerticalJustify) with the SAME formula - // UIElement_Meter used for its label, then skip the scroll/selection machinery entirely. - if (Centered) - { - var cLines = LinesProvider(); - if (cLines.Count == 0) return; - var line0 = cLines[0]; - if (DatFont is { } cdf) - { - float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f; - float cy = VOffset(Height, cdf.LineHeight, Padding, VerticalJustify); - ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color); - } - else if ((Font ?? ctx.DefaultFont) is { } cbf) - { - float cx = (Width - cbf.MeasureWidth(line0.Text)) * 0.5f; - float cy = VOffset(Height, cbf.LineHeight, Padding, VerticalJustify); - ctx.DrawString(line0.Text, cx, cy, line0.Color, cbf); - } - return; - } - - // Static right-aligned single-line mode: draw the first line flush with the right - // edge, vertical position per VerticalJustify, then skip the scroll/selection machinery. - if (RightAligned) - { - var rLines = LinesProvider(); - if (rLines.Count == 0) return; - var line0 = rLines[0]; - if (DatFont is { } rdf) - { - float rx = Width - rdf.MeasureWidth(line0.Text) - Padding; - float ry = VOffset(Height, rdf.LineHeight, Padding, VerticalJustify); - ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color); - } - else if ((Font ?? ctx.DefaultFont) is { } rbf) - { - float rx = Width - rbf.MeasureWidth(line0.Text) - Padding; - float ry = VOffset(Height, rbf.LineHeight, Padding, VerticalJustify); - ctx.DrawString(line0.Text, rx, ry, line0.Color, rbf); - } - return; - } - - // Prefer the retail dat font when set; fall back to BitmapFont. - var datFont = DatFont; - var bitmapFont = datFont is null ? (Font ?? ctx.DefaultFont) : null; - if (datFont is null && bitmapFont is null) return; - - var lines = LinesProvider(); - - // Cache the geometry OnEvent will hit-test against. Even when there are no - // lines we record the font/padding so a stray hit-test is harmless. - _lastLines = lines; - _lastDatFont = datFont; - _lastFont = bitmapFont; - _lastLineHeight = datFont is not null ? datFont.LineHeight : bitmapFont!.LineHeight; - _lastPadding = Padding; - - if (lines.Count == 0) return; - - float lh = _lastLineHeight; - float top = Padding, bottom = Height - Padding; - float innerH = bottom - top; - float contentH = lines.Count * lh; - - // Drive the shared scroll model with the current geometry. - Scroll.LineHeight = (int)MathF.Round(lh); - Scroll.ContentHeight = (int)MathF.Ceiling(contentH); - Scroll.ViewHeight = (int)MathF.Floor(innerH); - if (_pinBottom) Scroll.ScrollToEnd(); - - // UiScrollable: ScrollY=0 is TOP/oldest, ScrollY=MaxScroll is BOTTOM/newest. - // Visual layout: newest at bottom → baseY = bottom - contentH (ScrollY at max). - // Invert: baseY = bottom - contentH + (MaxScroll - ScrollY). - // With _pinBottom: ScrollY=MaxScroll → baseY=bottom-contentH → last line ends at bottom. ✓ - // Scrolled to top: ScrollY=0 → baseY=bottom-contentH+MaxScroll=bottom-innerH=top. ✓ - float baseY = bottom - contentH + (Scroll.MaxScroll - Scroll.ScrollY); - _lastBaseY = baseY; - - // Normalised selection span (start <= end), if any. - bool hasSel = TryGetOrderedSelection(out Pos selStart, out Pos selEnd); - - for (int i = 0; i < lines.Count; i++) - { - float y = baseY + i * lh; - if (y < top || y + lh > bottom) continue; // whole-line vertical clip (no scissor yet) - - string text = lines[i].Text; - - // Selection highlight behind this line's selected character span. - if (hasSel && i >= selStart.Line && i <= selEnd.Line) - { - int c0 = i == selStart.Line ? selStart.Col : 0; - int c1 = i == selEnd.Line ? selEnd.Col : text.Length; - c0 = Math.Clamp(c0, 0, text.Length); - c1 = Math.Clamp(c1, 0, text.Length); - if (c1 > c0) - { - float hx, hw; - if (datFont is not null) - { - hx = Padding + datFont.MeasureWidth(text.Substring(0, c0)); - hw = datFont.MeasureWidth(text.Substring(c0, c1 - c0)); - } - else - { - hx = Padding + bitmapFont!.MeasureWidth(text.Substring(0, c0)); - hw = bitmapFont.MeasureWidth(text.Substring(c0, c1 - c0)); - } - // Highlight sits BEHIND the line's text → sprite bucket, submitted - // before this line's DrawStringDat. - ctx.DrawFill(hx, y, hw, lh, SelectionColor); - } - } - - if (datFont is not null) - ctx.DrawStringDat(datFont, text, Padding, y, lines[i].Color); - else - ctx.DrawString(text, Padding, y, lines[i].Color, bitmapFont); - } - } - - public override bool OnEvent(in UiEvent e) - { - switch (e.Type) - { - case UiEventType.Scroll: - { - // Silk wheel +Y = scroll up = reveal older = toward the TOP = decrease ScrollY. - // ScrollByLines sign: +down/newer, -up/older. - // e.Data0 > 0 → wheel up → want older → ScrollByLines with negative lines. - Scroll.ScrollByLines((int)(-e.Data0 * WheelLines)); - _pinBottom = Scroll.AtEnd; - return true; - } - - case UiEventType.MouseDown: - { - // Data1/Data2 = local-to-target coords (UiRoot.OnMouseDown). - var p = HitChar(e.Data1, e.Data2); - _selAnchor = p; - _selCaret = p; - _selecting = true; - return true; - } - - case UiEventType.MouseMove: - { - if (_selecting) - { - // Data1/Data2 = local-to-target coords (DispatchMouseMove). - _selCaret = HitChar(e.Data1, e.Data2); - return true; - } - return false; - } - - case UiEventType.MouseUp: - { - _selecting = false; - return true; - } - - case UiEventType.KeyDown: - { - var key = (Silk.NET.Input.Key)e.Data0; - bool ctrl = Keyboard is not null - && (Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlLeft) - || Keyboard.IsKeyPressed(Silk.NET.Input.Key.ControlRight)); - if (ctrl && key == Silk.NET.Input.Key.C) - { - // Only touch the clipboard when there's a selection — an empty - // copy must NOT clobber what the user previously copied. - if (Keyboard is not null) - { - string sel = SelectedText(); - if (sel.Length > 0) Keyboard.ClipboardText = sel; - } - return true; - } - if (ctrl && key == Silk.NET.Input.Key.A) - { - SelectAll(); - return true; - } - return false; - } - } - return false; - } - - // ── Selection helpers ──────────────────────────────────────────────── - - /// Select the entire cached transcript (Ctrl+A). - private void SelectAll() - { - var lines = _lastLines; - if (lines.Count == 0) - { - _selAnchor = _selCaret = null; - return; - } - int last = lines.Count - 1; - _selAnchor = new Pos(0, 0); - _selCaret = new Pos(last, lines[last].Text.Length); - } - - /// Normalise (anchor, caret) into ordered (start, end). False if no - /// selection or it is empty (anchor == caret). - private bool TryGetOrderedSelection(out Pos start, out Pos end) - { - start = default; end = default; - if (_selAnchor is not { } a || _selCaret is not { } c) return false; - (start, end) = Order(a, c); - return !(start.Line == end.Line && start.Col == end.Col); - } - - /// The currently-selected text against the cached lines. Empty when - /// nothing is selected. - public string SelectedText() - { - if (!TryGetOrderedSelection(out var start, out var end)) return string.Empty; - return SelectedText(_lastLines, start, end); - } - - // ── Pure, testable logic (no GL / no font texture) ─────────────────── - - /// - /// Compute the Y offset (local space) for a single line in the Centered/RightAligned - /// single-line path, given the element height, font line-height, padding, and - /// vertical justification. - /// - /// Element height in pixels. - /// Font line height in pixels. - /// Content padding. - /// Vertical justification. - public static float VOffset(float height, float lineHeight, float padding, VJustify vj) - => vj switch - { - VJustify.Top => padding, - VJustify.Bottom => height - lineHeight - padding, - _ => (height - lineHeight) * 0.5f, // Center (default) - }; - - /// Order two caret positions so the first is <= the second (by line, - /// then column). - public static (Pos start, Pos end) Order(Pos a, Pos b) - { - if (a.Line < b.Line || (a.Line == b.Line && a.Col <= b.Col)) return (a, b); - return (b, a); - } - - /// - /// Assemble the selected substring spanning .. - /// (inclusive of start.Col, exclusive of end.Col) from - /// . Multi-line selections are joined with "\n": - /// the first line from start.Col to its end, whole middle lines, and the last - /// line up to end.Col. Pure — unit-testable without GL. - /// - public static string SelectedText(IReadOnlyList lines, Pos start, Pos end) - { - if (lines.Count == 0) return string.Empty; - (start, end) = Order(start, end); - - int sl = Math.Clamp(start.Line, 0, lines.Count - 1); - int el = Math.Clamp(end.Line, 0, lines.Count - 1); - - if (sl == el) - { - string t = lines[sl].Text; - int c0 = Math.Clamp(start.Col, 0, t.Length); - int c1 = Math.Clamp(end.Col, 0, t.Length); - if (c1 <= c0) return string.Empty; - return t.Substring(c0, c1 - c0); - } - - var sb = new StringBuilder(); - - // First line: from start.Col to its end. - { - string t = lines[sl].Text; - int c0 = Math.Clamp(start.Col, 0, t.Length); - sb.Append(t.AsSpan(c0)); - } - - // Whole middle lines. - for (int i = sl + 1; i < el; i++) - { - sb.Append('\n'); - sb.Append(lines[i].Text); - } - - // Last line: up to end.Col. - { - sb.Append('\n'); - string t = lines[el].Text; - int c1 = Math.Clamp(end.Col, 0, t.Length); - sb.Append(t.AsSpan(0, c1)); - } - - return sb.ToString(); - } - - /// - /// Convert a local-space point to a caret against the cached - /// layout from the last draw. line = floor((localY - baseY)/lineHeight) clamped - /// to the line range; col via . - /// - private Pos HitChar(float localX, float localY) - { - var lines = _lastLines; - if (lines.Count == 0) return new Pos(0, 0); - - float lh = _lastLineHeight <= 0f ? 16f : _lastLineHeight; - int line = (int)MathF.Floor((localY - _lastBaseY) / lh); - line = Math.Clamp(line, 0, lines.Count - 1); - - string text = lines[line].Text; - int col = _lastDatFont is { } df - ? CharIndexAt(text, ch => df.TryGetGlyph(ch, out var g) ? UiDatFont.GlyphAdvance(g) : 0f, - localX - _lastPadding) - : (_lastFont is { } bf - ? CharIndexAt(text, ch => bf.TryGetGlyph(ch, out var bg) ? bg.Advance : 0f, - localX - _lastPadding) - : 0); - return new Pos(line, col); - } - - /// - /// The caret column for a horizontal position (already - /// adjusted for the left padding, so x=0 is the start of the text). Walks the - /// string accumulating each glyph's advance and snaps the caret to whichever - /// side of the glyph midpoint falls on — natural - /// Windows-like caret placement. Pure — unit-testable with a synthetic advance. - /// - /// The line text. - /// Per-character advance (pixels) lookup. - /// Horizontal position relative to the text's left edge. - public static int CharIndexAt(string text, Func advanceOf, float x) - { - if (string.IsNullOrEmpty(text) || x <= 0f) return 0; - - float cursor = 0f; - for (int i = 0; i < text.Length; i++) - { - float adv = advanceOf(text[i]); - float mid = cursor + adv * 0.5f; - if (x < mid) return i; // caret sits before this glyph - cursor += adv; - } - return text.Length; // past the last glyph → end caret - } -} diff --git a/src/AcDream.App/UI/UiViewport.cs b/src/AcDream.App/UI/UiViewport.cs deleted file mode 100644 index 8a8c8ffa..00000000 --- a/src/AcDream.App/UI/UiViewport.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Numerics; - -namespace AcDream.App.UI; - -/// Leaf widget for dat Type 0xD (UIElement_Viewport). Blits the texture produced by its -/// IUiViewportRenderer (run in the pre-UI hook) as a single sprite at its own rect. The 3-D render -/// does NOT happen here (OnDraw only has a 2-D context). -public sealed class UiViewport : UiElement -{ - public override bool ConsumesDatChildren => true; - - /// Renderer that produces the off-screen texture. Set by GameWindow wiring (later task). - public IUiViewportRenderer? Renderer { get; set; } - - /// Last GL color-texture handle produced by the pre-UI hook. 0 = nothing to blit. - public uint TextureHandle { get; set; } - - protected override void OnDraw(UiRenderContext ctx) - { - if (!Visible || TextureHandle == 0) return; - // Local origin is already at this widget's Left/Top (PushTransform applied by DrawSelfAndChildren). - // V is FLIPPED (v0=1, v1=0): TextureHandle is an off-screen FBO color texture, whose origin is - // bottom-left (GL), while the UI sprite convention is top-left. Without the flip the doll renders - // upside-down. (If the doll appears upside-down at the visual gate, this is the line to revisit.) - ctx.DrawSprite(TextureHandle, 0f, 0f, Width, Height, 0f, 1f, 1f, 0f, Vector4.One); - } -} diff --git a/src/AcDream.App/UI/WindowNames.cs b/src/AcDream.App/UI/WindowNames.cs deleted file mode 100644 index 34cf5c43..00000000 --- a/src/AcDream.App/UI/WindowNames.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace AcDream.App.UI; - -/// Canonical registry names for top-level retail-UI windows, so the -/// mount, the window registry, and the toggle keybind all agree on one literal. -public static class WindowNames -{ - public const string Inventory = "inventory"; -} diff --git a/src/AcDream.Bake/AcDream.Bake.csproj b/src/AcDream.Bake/AcDream.Bake.csproj deleted file mode 100644 index 263db624..00000000 --- a/src/AcDream.Bake/AcDream.Bake.csproj +++ /dev/null @@ -1,25 +0,0 @@ - - - - Exe - acdream-bake - net10.0 - enable - enable - latest - true - - - - - - - - - - - - - diff --git a/src/AcDream.Bake/BakeRunner.cs b/src/AcDream.Bake/BakeRunner.cs deleted file mode 100644 index 9e5676d3..00000000 --- a/src/AcDream.Bake/BakeRunner.cs +++ /dev/null @@ -1,252 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using AcDream.Content; -using AcDream.Content.Pak; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; - -namespace AcDream.Bake; - -/// Options for one bake run (parsed from CLI args by Program, or built directly by tests). -public sealed record BakeOptions { - public required string DatDir { get; init; } - public required string OutPath { get; init; } - public HashSet? IdFilter { get; init; } - public HashSet? LandblockFilter { get; init; } - public int Threads { get; init; } = System.Environment.ProcessorCount; -} - -/// -/// The bake pipeline: enumerate ids -> sorted-batch parallel extraction -> -/// deterministic pak write. Public (rather than inline in Program) so the -/// dat-gated byte-reproducibility test can drive the REAL pipeline. -/// -/// Determinism + bounded memory (review finding 1): the full id -/// list is built first and sorted by pak key; work proceeds in fixed-size -/// batches — extraction is parallel WITHIN a batch, then the batch's results -/// are sorted by key and written sequentially before the next batch starts. -/// Batches are contiguous key ranges, so the blob region ends up in global -/// key order regardless of thread scheduling, and memory is bounded by one -/// batch's decoded output instead of the whole game's. Side-staged -/// particle-preload meshes are deduped into a keyed map as each batch -/// drains (first instance wins — extraction output per id is -/// deterministic, so instance choice cannot affect bytes) and written after -/// all batches, sorted by key, skipping keys already written. -/// -public static class BakeRunner { - /// Ids extracted in parallel per batch before the sequential sorted write. Bounds peak memory (~512 decoded ObjectMeshData) while keeping the workers busy. - private const int BatchSize = 512; - - public static int Run(BakeOptions options) { - Console.WriteLine("acdream-bake"); - Console.WriteLine($"dat dir: {options.DatDir}"); - Console.WriteLine($"out: {options.OutPath}"); - Console.WriteLine($"threads: {options.Threads}"); - Console.WriteLine(); - - var sw = Stopwatch.StartNew(); - using var dats = new DatCollection(options.DatDir, DatAccessType.Read); - // The unified AcDream.Content.DatCollectionAdapter (MP1b review - // finding 7): same instance class the client and the equivalence - // suite use — adapter drift between bake and runtime is impossible - // by construction. - using var datReaderWriter = new DatCollectionAdapter(dats); - var extractorLogger = new ConsoleErrorLogger(nameof(MeshExtractor)); - - // Thread-safe side-stage sink for particle-preload meshes MeshExtractor - // emits mid-extraction (MP1a documented contract; extractor is shared - // across the batch workers). - var sideStaged = new ConcurrentQueue(); - var extractor = new MeshExtractor(datReaderWriter, extractorLogger, data => sideStaged.Enqueue(data)); - - // ---- enumeration ----------------------------------------------------- - - var gfxObjIds = dats.GetAllIdsOfType().ToList(); - var setupIds = dats.GetAllIdsOfType().ToList(); - var envCellIds = EnumerateEnvCellIds(dats, options.LandblockFilter); - - if (options.IdFilter is not null) { - gfxObjIds = gfxObjIds.Where(options.IdFilter.Contains).ToList(); - setupIds = setupIds.Where(options.IdFilter.Contains).ToList(); - envCellIds = envCellIds.Where(options.IdFilter.Contains).ToList(); - } - - // Full work list sorted by pak key — the batching below preserves this - // global order on disk (determinism precondition). - var work = new List<(ulong Key, PakAssetType Type, uint FileId)>(gfxObjIds.Count + setupIds.Count + envCellIds.Count); - work.AddRange(gfxObjIds.Select(id => (PakKey.Compose(PakAssetType.GfxObjMesh, id), PakAssetType.GfxObjMesh, id))); - work.AddRange(setupIds.Select(id => (PakKey.Compose(PakAssetType.SetupMesh, id), PakAssetType.SetupMesh, id))); - work.AddRange(envCellIds.Select(id => (PakKey.Compose(PakAssetType.EnvCellMesh, id), PakAssetType.EnvCellMesh, id))); - work.Sort((a, b) => a.Key.CompareTo(b.Key)); - - Console.WriteLine($"enumerated: {gfxObjIds.Count:N0} GfxObj, {setupIds.Count:N0} Setup, {envCellIds.Count:N0} EnvCell " + - $"({work.Count:N0} total)"); - Console.WriteLine(); - - // ---- sorted-batch extraction + streaming write ------------------------- - - var header = new PakHeader { - PortalIteration = (uint)dats.Portal.Iteration!.CurrentIteration, - CellIteration = (uint)dats.Cell.Iteration!.CurrentIteration, - HighResIteration = (uint)dats.HighRes.Iteration!.CurrentIteration, - LanguageIteration = (uint)dats.Local.Iteration!.CurrentIteration, - BakeToolVersion = 1, - }; - - var failures = new ConcurrentBag<(PakAssetType Type, uint FileId, string Reason)>(); - var writtenKeys = new HashSet(); - var sideStagedByKey = new Dictionary(); - long completed = 0; - int writtenCounts0 = 0, writtenCounts1 = 0, writtenCounts2 = 0; // GfxObj, Setup, EnvCell - var lastProgressReport = Stopwatch.StartNew(); - - using (var writer = new PakWriter(options.OutPath, header)) { - for (int batchStart = 0; batchStart < work.Count; batchStart += BatchSize) { - var batch = work.Skip(batchStart).Take(BatchSize).ToList(); - var batchResults = new ConcurrentBag<(ulong Key, PakAssetType Type, ObjectMeshData Data)>(); - - Parallel.ForEach( - batch, - new ParallelOptions { MaxDegreeOfParallelism = options.Threads }, - item => { - var (key, type, fileId) = item; - try { - // EnvCell entries store the cell's synthetic geometry mesh - // (bit 32 set — see MeshExtractor.PrepareMeshData's EnvCell - // branch); the cell's static objects are covered by their - // own GfxObj/Setup entries. - ulong extractorId = type == PakAssetType.EnvCellMesh ? fileId | 0x1_0000_0000UL : fileId; - // isSetup: matches the runtime's own request sites — - // WbMeshAdapter.IncrementRefCount/EnsureLoaded pass - // isSetup: false for every MeshRef id (incl. cell-geometry - // ids); only true Setup extraction passes true. (Review - // finding 10 — keep aligned with PakEquivalenceTests.) - bool isSetup = type == PakAssetType.SetupMesh; - var data = extractor.PrepareMeshData(extractorId, isSetup); - if (data is not null) { - batchResults.Add((key, type, data)); - } - else { - failures.Add((type, fileId, "extractor returned null (no polygons or unresolvable id)")); - } - } - catch (Exception ex) { - // A malformed dat entry skips that id — never fatal to the - // bake, matching the runtime's own per-id behavior. - failures.Add((type, fileId, ex.Message)); - } - - Interlocked.Increment(ref completed); - }); - - // Sequential sorted write: batch results in key order. - foreach (var (key, type, data) in batchResults.OrderBy(r => r.Key)) { - writer.AddBlob(key, data); - writtenKeys.Add(key); - switch (type) { - case PakAssetType.GfxObjMesh: writtenCounts0++; break; - case PakAssetType.SetupMesh: writtenCounts1++; break; - case PakAssetType.EnvCellMesh: writtenCounts2++; break; - } - } - - // Drain side-staged preloads per batch into the dedup map so the - // queue never grows past one batch's emissions (memory bound); - // written after all batches, sorted (see below). - while (sideStaged.TryDequeue(out var staged)) { - uint fileId = (uint)(staged.ObjectId & 0xFFFFFFFFu); - ulong key = PakKey.Compose(PakAssetType.GfxObjMesh, fileId); - if (!sideStagedByKey.ContainsKey(key)) sideStagedByKey[key] = staged; - } - - if (lastProgressReport.Elapsed.TotalSeconds >= 5 || batchStart + BatchSize >= work.Count) { - ReportProgress(completed, work.Count, failures.Count, sw.Elapsed); - lastProgressReport.Restart(); - } - } - - // Side-staged preload meshes not already covered by a primary entry: - // sorted by key for deterministic placement. - int sideStagedWritten = 0, sideStagedDuped = 0; - foreach (var (key, data) in sideStagedByKey.OrderBy(kv => kv.Key)) { - if (writtenKeys.Contains(key)) { sideStagedDuped++; continue; } - writer.AddBlob(key, data); - writtenKeys.Add(key); - sideStagedWritten++; - } - - writer.Finish(); - sw.Stop(); - - var outSize = new FileInfo(options.OutPath).Length; - Console.WriteLine(); - Console.WriteLine("=== bake summary ==="); - Console.WriteLine($" GfxObj baked: {writtenCounts0:N0}"); - Console.WriteLine($" Setup baked: {writtenCounts1:N0}"); - Console.WriteLine($" EnvCell baked: {writtenCounts2:N0}"); - Console.WriteLine($" side-staged baked (particle preload GfxObjs): {sideStagedWritten:N0} ({sideStagedDuped:N0} deduped)"); - Console.WriteLine($" total blobs: {writtenKeys.Count:N0}"); - Console.WriteLine($" failures: {failures.Count:N0}"); - Console.WriteLine($" elapsed: {sw.Elapsed.TotalSeconds:F1} s"); - Console.WriteLine($" output size: {outSize / 1024.0 / 1024.0:F1} MB"); - Console.WriteLine($" output path: {options.OutPath}"); - } - - if (!failures.IsEmpty) { - Console.WriteLine(); - Console.WriteLine($"failures ({failures.Count}):"); - foreach (var (type, fileId, reason) in failures.OrderBy(f => f.FileId).Take(200)) { - Console.WriteLine($" {type,-12} 0x{fileId:X8}: {reason}"); - } - if (failures.Count > 200) Console.WriteLine($" ... and {failures.Count - 200} more"); - } - - return 0; - } - - private static void ReportProgress(long done, int total, int failures, TimeSpan elapsed) { - double rate = elapsed.TotalSeconds > 0 ? done / elapsed.TotalSeconds : 0; - double etaSeconds = rate > 0 ? (total - done) / rate : 0; - Console.WriteLine($"[{elapsed:hh\\:mm\\:ss}] baked {done:N0}/{total:N0}, failures={failures:N0}, " + - $"elapsed={elapsed.TotalSeconds:F0}s, ETA={etaSeconds:F0}s"); - } - - /// - /// Enumerates EnvCell ids by walking the cell dat's LandBlockInfo entries - /// (0xFFFE low 16 bits) and, for each, the NumCells-derived cell id range — - /// GetAllIdsOfType<T>() does not cover cell-dat range-based types - /// (DatReaderWriter's documented limitation; see the low-16-bit bucketing - /// idiom in src/AcDream.Cli/Program.cs's CountCellByLow16, and the - /// firstCellId/NumCells hydration idiom in GameWindow.BuildPhysicsDatBundle - /// / BuildInteriorEntitiesForStreaming). - /// - private static List EnumerateEnvCellIds(DatCollection dats, HashSet? landblockFilter) { - var landblockInfoIds = new List(); - foreach (var file in dats.Cell.Tree) { - if ((file.Id & 0xFFFFu) != 0xFFFEu) continue; - uint landblockId = file.Id & 0xFFFF0000u; - if (landblockFilter is not null && !landblockFilter.Contains(landblockId)) continue; - landblockInfoIds.Add(file.Id); - } - - var envCellIds = new List(); - foreach (var lbInfoId in landblockInfoIds) { - if (!dats.Cell.TryGet(lbInfoId, out var lbInfo) || lbInfo is null) continue; - if (lbInfo.NumCells == 0) continue; - - uint landblockId = lbInfoId & 0xFFFF0000u; - uint firstCellId = landblockId | 0x0100u; - for (uint offset = 0; offset < lbInfo.NumCells; offset++) { - envCellIds.Add(firstCellId + offset); - } - } - return envCellIds; - } -} diff --git a/src/AcDream.Bake/ConsoleErrorLogger.cs b/src/AcDream.Bake/ConsoleErrorLogger.cs deleted file mode 100644 index a8be4302..00000000 --- a/src/AcDream.Bake/ConsoleErrorLogger.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using Microsoft.Extensions.Logging; - -namespace AcDream.Bake; - -/// -/// Minimal writing to stderr with a level prefix. -/// MeshExtractor logs failures via ILogger.LogError/LogWarning (dat-read -/// anomalies, malformed entries) — per the project's "logger injection for -/// silent catches" lesson, the bake tool must NOT wire a NullLogger here or -/// every per-id extraction failure goes silent. A tiny hand-rolled logger -/// avoids pulling in the Microsoft.Extensions.Logging.Console package -/// (not already a transitive dependency) for what is a one-line need. -/// -public sealed class ConsoleErrorLogger : ILogger { - private readonly string _categoryName; - - public ConsoleErrorLogger(string categoryName) { - _categoryName = categoryName; - } - - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { - if (!IsEnabled(logLevel)) return; - var message = formatter(state, exception); - Console.Error.WriteLine($"[{logLevel}] {_categoryName}: {message}"); - if (exception is not null) Console.Error.WriteLine(exception); - } -} - -/// Factory producing instances for any category/type. -public sealed class ConsoleErrorLoggerProvider : ILoggerProvider { - public ILogger CreateLogger(string categoryName) => new ConsoleErrorLogger(categoryName); - public void Dispose() { } -} diff --git a/src/AcDream.Bake/Program.cs b/src/AcDream.Bake/Program.cs deleted file mode 100644 index 2d7ee8c0..00000000 --- a/src/AcDream.Bake/Program.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using AcDream.Bake; - -// acdream-bake: offline CLI producing a versioned pak file containing every -// ObjectMeshData the client's decode workers would otherwise produce at -// runtime (GfxObj / Setup / EnvCell mesh+texture payloads). -// -// Thin arg-parsing shell over BakeRunner (the real pipeline lives there so -// the dat-gated byte-reproducibility test can drive it directly). -// -// Plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md, Task 5. - -string? datDir = null; -string? outPath = null; -HashSet? idFilter = null; -HashSet? landblockFilter = null; -int threads = Environment.ProcessorCount; - -for (int i = 0; i < args.Length; i++) { - switch (args[i]) { - case "--dat-dir": - datDir = args.ElementAtOrDefault(++i); - break; - case "--out": - outPath = args.ElementAtOrDefault(++i); - break; - case "--ids": - idFilter = ParseHexList(args.ElementAtOrDefault(++i)); - break; - case "--landblocks": - landblockFilter = ParseHexList(args.ElementAtOrDefault(++i)); - break; - case "--threads": - if (int.TryParse(args.ElementAtOrDefault(++i), out var t) && t > 0) threads = t; - break; - default: - Console.Error.WriteLine($"unrecognized argument: {args[i]}"); - return 2; - } -} - -if (string.IsNullOrWhiteSpace(datDir)) { - Console.Error.WriteLine("usage: acdream-bake --dat-dir [--out ] [--ids 0xId,0xId,...] [--landblocks 0xId,...] [--threads ]"); - return 2; -} - -if (!Directory.Exists(datDir)) { - Console.Error.WriteLine($"error: directory not found: {datDir}"); - return 2; -} - -outPath ??= Path.Combine(datDir, "acdream.pak"); - -return BakeRunner.Run(new BakeOptions { - DatDir = datDir, - OutPath = outPath, - IdFilter = idFilter, - LandblockFilter = landblockFilter, - Threads = threads, -}); - -static HashSet ParseHexList(string? raw) { - var result = new HashSet(); - if (string.IsNullOrWhiteSpace(raw)) return result; - foreach (var token in raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { - var hex = token.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? token[2..] : token; - if (uint.TryParse(hex, System.Globalization.NumberStyles.HexNumber, null, out var value)) { - result.Add(value); - } - else { - Console.Error.WriteLine($"warning: could not parse id '{token}' — skipped"); - } - } - return result; -} diff --git a/src/AcDream.Cli/AcDream.Cli.csproj b/src/AcDream.Cli/AcDream.Cli.csproj index e964e5cb..7d30223e 100644 --- a/src/AcDream.Cli/AcDream.Cli.csproj +++ b/src/AcDream.Cli/AcDream.Cli.csproj @@ -9,14 +9,6 @@ - - - - - - diff --git a/src/AcDream.Cli/FontAtlasDump.cs b/src/AcDream.Cli/FontAtlasDump.cs deleted file mode 100644 index f9f49161..00000000 --- a/src/AcDream.Cli/FontAtlasDump.cs +++ /dev/null @@ -1,182 +0,0 @@ -using AcDream.Core.Textures; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; -using DatReaderWriter.Types; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; - -namespace AcDream.Cli; - -/// -/// Headless inspection of a retail dat Font (DB_TYPE_FONT, 0x40000000…). Writes: -/// • <out>-fg.png — foreground (fill) atlas, alpha→luminance (white on black) -/// • <out>-bg.png — background (outline) atlas, alpha→luminance -/// • <out>-sample.png — a sample string composited EXACTLY the way -/// UiRenderContext.DrawStringDat does it (black outline pass behind, -/// colored fill pass on top) onto the dark chat-panel colour, at native 1:1 -/// and at 6× nearest zoom side by side. -/// -/// The sample reproduces our client's glyph math deterministically so the -/// "not sharp" artifact can be judged offline: if the 1:1 sample is crisp, the -/// softness is downstream (a post-process / scale); if the sample itself is -/// soft, the cause is the atlas or the two-pass outline. -/// -public static class FontAtlasDump -{ - public static int Run(string datDir, string? fontIdText, string? sampleText, string outBase) - { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } - uint fontId = string.IsNullOrWhiteSpace(fontIdText) ? 0x40000000u : ParseHex(fontIdText); - string sample = string.IsNullOrEmpty(sampleText) ? "Chat Send 12345 ghpqy" : sampleText; - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var font = dats.Get(fontId); - if (font is null) { Console.Error.WriteLine($"error: Font 0x{fontId:X8} not found"); return 1; } - - Console.WriteLine($"Font 0x{fontId:X8}: fg=0x{font.ForegroundSurfaceDataId:X8} bg=0x{font.BackgroundSurfaceDataId:X8} " + - $"MaxCharHeight={font.MaxCharHeight} Baseline={font.BaselineOffset} glyphs={font.CharDescs.Count}"); - - DecodedTexture fg = DecodeRs(dats, font.ForegroundSurfaceDataId); - DecodedTexture? bg = font.BackgroundSurfaceDataId != 0 ? DecodeRs(dats, font.BackgroundSurfaceDataId) : null; - Console.WriteLine($" fg atlas {fg.Width}x{fg.Height}" + (bg is { } b ? $" bg atlas {b.Width}x{b.Height}" : " (no bg atlas)")); - - AlphaLuma(fg).SaveAsPng($"{outBase}-fg.png"); - Console.WriteLine($"wrote {outBase}-fg.png"); - if (bg is { } bgt) { AlphaLuma(bgt).SaveAsPng($"{outBase}-bg.png"); Console.WriteLine($"wrote {outBase}-bg.png"); } - - // Build a glyph lookup. - var glyphs = new Dictionary(); - foreach (var cd in font.CharDescs) glyphs[(char)cd.Unicode] = cd; - - // Render the sample the way DrawStringDat does, onto the dark chat panel colour. - var panel = new Rgba32(28, 28, 32, 255); - var fill = new Rgba32(255, 255, 255, 255); // white fill, like System default-ish - var outline = new Rgba32(0, 0, 0, 255); - - int lineH = Math.Max((int)font.MaxCharHeight, 8); - - // (a) integer baseline, per-glyph round (works — like the vitals digits). - using var native = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0f, snapOnce: false); - Save6x(native, $"{outBase}-sample"); - - // (b) FRACTIONAL baseline (textY=0.5, like a menu item centered in a 17px row over - // a 16px font) with the OLD per-glyph rounding → reproduces the "letters dip down" - // jitter the user reported. - using var jitter = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: false); - Save6x(jitter, $"{outBase}-jitter"); - - // (c) Same fractional baseline, but the line baseline is snapped to a whole pixel ONCE - // before adding the integer per-glyph offsets → the fix. Should be straight again. - using var fixed_ = RenderSample(sample, glyphs, fg, bg, lineH, panel, fill, outline, 0.5f, snapOnce: true); - Save6x(fixed_, $"{outBase}-fixed"); - - Console.WriteLine($"wrote {outBase}-sample-6x.png (ok), {outBase}-jitter-6x.png (bug repro), {outBase}-fixed-6x.png (fix)"); - return 0; - } - - /// Composite the sample string with the two-pass outline+fill model, - /// blitting atlas sub-rects 1:1. adds a fractional - /// line origin; selects the FIX (snap the line baseline - /// to a whole pixel once) vs the BUG (round each glyph's Y independently). - private static Image RenderSample( - string text, Dictionary glyphs, - DecodedTexture fg, DecodedTexture? bg, int lineH, - Rgba32 panel, Rgba32 fill, Rgba32 outline, float originYExtra, bool snapOnce) - { - // First pass: measure pen width. - float pen = 0; float maxX = 0; - foreach (char ch in text) - if (glyphs.TryGetValue(ch, out var g)) { maxX = Math.Max(maxX, pen + g.HorizontalOffsetBefore + g.Width); pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; } - int w = Math.Max(8, (int)MathF.Ceiling(Math.Max(maxX, pen)) + 4); - int h = lineH + 6; - var img = new Image(w, h, panel); - - float originY = 3f + originYExtra; - float baseY = MathF.Round(originY); // snapped line baseline (the fix) - pen = 2; - foreach (char ch in text) - { - if (!glyphs.TryGetValue(ch, out var g)) { continue; } - float gx = MathF.Round(pen + g.HorizontalOffsetBefore); - float gy = snapOnce - ? baseY + g.VerticalOffsetBefore // fix: integer baseline + integer offset - : MathF.Round(originY + g.VerticalOffsetBefore); // bug: independent per-glyph rounding - if (g.Width > 0 && g.Height > 0) - { - if (bg is { } bgt) BlitGlyph(img, bgt, g, (int)gx, (int)gy, outline); - BlitGlyph(img, fg, g, (int)gx, (int)gy, fill); - } - pen += g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter; - } - return img; - } - - private static void Save6x(Image native, string outBase) - { - using var zoom = native.Clone(c => c.Resize(native.Width * 6, native.Height * 6, KnownResamplers.NearestNeighbor)); - zoom.SaveAsPng($"{outBase}-6x.png"); - } - - /// Alpha-blend one glyph's atlas sub-rect onto the canvas using its alpha - /// as coverage, tinted by . 1:1 (no scaling), so this is the - /// pixel-exact result GL_NEAREST + native-size quad produces. - private static void BlitGlyph(Image dst, DecodedTexture atlas, FontCharDesc g, int dx, int dy, Rgba32 tint) - { - for (int sy = 0; sy < g.Height; sy++) - { - int py = dy + sy; - if (py < 0 || py >= dst.Height) continue; - int ay = g.OffsetY + sy; - if (ay < 0 || ay >= atlas.Height) continue; - for (int sx = 0; sx < g.Width; sx++) - { - int px = dx + sx; - if (px < 0 || px >= dst.Width) continue; - int ax = g.OffsetX + sx; - if (ax < 0 || ax >= atlas.Width) continue; - int idx = (ay * atlas.Width + ax) * 4; - // Atlas is A8 expanded to (255,255,255,alpha); coverage = alpha. - float cov = atlas.Rgba8[idx + 3] / 255f; - if (cov <= 0f) continue; - var bgpx = dst[px, py]; - dst[px, py] = new Rgba32( - (byte)(tint.R * cov + bgpx.R * (1 - cov)), - (byte)(tint.G * cov + bgpx.G * (1 - cov)), - (byte)(tint.B * cov + bgpx.B * (1 - cov)), - 255); - } - } - } - - /// Render an A8/RGBA atlas's ALPHA channel as opaque white-on-black luminance, - /// zoomed 4× nearest, so the glyph shapes are visible regardless of PNG viewer alpha. - private static Image AlphaLuma(DecodedTexture t) - { - var img = new Image(t.Width, t.Height); - for (int y = 0; y < t.Height; y++) - for (int x = 0; x < t.Width; x++) - { - byte a = t.Rgba8[(y * t.Width + x) * 4 + 3]; - img[x, y] = new Rgba32(a, a, a, 255); - } - img.Mutate(c => c.Resize(t.Width * 4, t.Height * 4, KnownResamplers.NearestNeighbor)); - return img; - } - - private static DecodedTexture DecodeRs(DatCollection dats, uint id) - { - var rs = dats.Get(id); - if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return DecodedTexture.Magenta; } - return SurfaceDecoder.DecodeRenderSurface(rs); - } - - private static uint ParseHex(string s) - { - s = s.Trim(); - if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..]; - return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber, - System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u; - } -} diff --git a/src/AcDream.Cli/LayoutIndexDump.cs b/src/AcDream.Cli/LayoutIndexDump.cs deleted file mode 100644 index 5276486c..00000000 --- a/src/AcDream.Cli/LayoutIndexDump.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System.Reflection; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; -using DatReaderWriter.Types; - -namespace AcDream.Cli; - -/// -/// Read-only research diagnostic: index EVERY UI in the -/// dat by its root element's Type + size + an element-Type histogram, so a -/// panel re-drive can locate its layout from the decomp-registered class id -/// (e.g. gmMainChatUI registers type 0x10000041 → the chat window -/// is the layout whose root element has Type 0x10000041). Optionally filter to a -/// single root Type. No writes; purely a console dump used during brainstorming. -/// -public static class LayoutIndexDump -{ - public static int Run(string datDir, string? rootTypeText) - { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - uint? filter = null; - if (!string.IsNullOrWhiteSpace(rootTypeText)) - { - var t = rootTypeText.Trim(); - if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..]; - if (uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out var f)) filter = f; - } - - Console.WriteLine(filter is { } ff - ? $"=== LayoutDescs with a root element of Type 0x{ff:X8} ===" - : "=== All LayoutDescs (id : root element Type : size : #elements : type histogram) ==="); - - int total = 0, shown = 0; - foreach (var id in dats.GetAllIdsOfType().OrderBy(x => x)) - { - var l = dats.Get(id); - if (l is null) continue; - total++; - - // The root is the single top-level element (or, if several, the largest). - ElementDesc? root = null; - foreach (var kv in l.Elements) - if (root is null || Area(kv.Value) > Area(root)) root = kv.Value; - if (root is null) continue; - - if (filter is { } want && root.Type != want) continue; - shown++; - - var hist = new SortedDictionary(); - int count = 0; - CountTypes(root, hist, ref count); - string h = string.Join(" ", hist.Select(kv => $"{TypeName(kv.Key)}×{kv.Value}")); - Console.WriteLine( - $" 0x{id:X8} root=0x{root.ElementId:X8} type=0x{root.Type:X8}({TypeName(root.Type)}) " + - $"{root.Width}x{root.Height} n={count} [{h}]"); - } - - Console.WriteLine(); - Console.WriteLine($"shown {shown} / {total} LayoutDescs."); - return 0; - } - - private static long Area(ElementDesc e) => (long)e.Width * e.Height; - - private static void CountTypes(ElementDesc e, SortedDictionary hist, ref int count) - { - count++; - hist[e.Type] = hist.TryGetValue(e.Type, out var c) ? c + 1 : 1; - foreach (var kv in e.Children) - CountTypes(kv.Value, hist, ref count); - } - - private static string TypeName(uint t) => t switch - { - 0 => "Text0", - 1 => "Button", - 2 => "Dragbar", - 3 => "Field", - 5 => "ListBox", - 6 => "Menu", - 7 => "Meter", - 8 => "Panel", - 9 => "Resizebar", - 0xB => "Scrollbar", - 0xC => "Text", - 0xD => "Viewport", - 0xE => "Browser", - 0x10 => "ColorPicker", - 0x11 => "GroupBox", - 0x12 => "Proto", - 0x10000041 => "gmMainChatUI", - 0x10000040 => "gmFloatyChatUI", - 0x10000050 => "gmFloatyMainChatUI", - 0x10000042 => "gmChatOptionsUI", - 0x10000009 => "gmVitalsUI", - _ => $"0x{t:X}", - }; -} diff --git a/src/AcDream.Cli/Program.cs b/src/AcDream.Cli/Program.cs index 4bb7cba8..a4c290ee 100644 --- a/src/AcDream.Cli/Program.cs +++ b/src/AcDream.Cli/Program.cs @@ -1,139 +1,10 @@ using System.Diagnostics; -using AcDream.Cli; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Options; -using DatReaderWriter.Types; using Env = System.Environment; -// ─── subcommand dispatch ──────────────────────────────────────────────────── -if (args.Length >= 1 && args[0] == "dump-vitals-bars") -{ - string? dvbDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - if (string.IsNullOrWhiteSpace(dvbDatDir)) - { - Console.Error.WriteLine("usage: AcDream.Cli dump-vitals-bars "); - return 2; - } - return DumpVitalsBars(dvbDatDir); -} - -if (args.Length >= 1 && args[0] == "dump-vitals-layout") -{ - string? dvlDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - string? dvlLayout = args.ElementAtOrDefault(2); - if (string.IsNullOrWhiteSpace(dvlDatDir)) - { - Console.Error.WriteLine("usage: AcDream.Cli dump-vitals-layout [0xLayoutId]"); - return 2; - } - return VitalsLayoutDump.Run(dvlDatDir, dvlLayout); -} - -if (args.Length >= 1 && args[0] == "list-ui-layouts") -{ - string? luiDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - string? luiRootType = args.ElementAtOrDefault(2); - if (string.IsNullOrWhiteSpace(luiDatDir)) - { - Console.Error.WriteLine("usage: AcDream.Cli list-ui-layouts [0xRootType]"); - return 2; - } - return LayoutIndexDump.Run(luiDatDir, luiRootType); -} - -if (args.Length >= 1 && args[0] == "render-vitals-mockup") -{ - string? rvmDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - string rvmOut = args.ElementAtOrDefault(2) ?? "vitals-mockup.png"; - if (string.IsNullOrWhiteSpace(rvmDatDir)) - { - Console.Error.WriteLine("usage: AcDream.Cli render-vitals-mockup [out.png]"); - return 2; - } - return VitalsMockup.Render(rvmDatDir, rvmOut); -} - -if (args.Length >= 1 && args[0] == "dump-sprite-sheet") -{ - string? dssDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - string? dssIds = args.ElementAtOrDefault(2); - string dssOut = args.ElementAtOrDefault(3) ?? "sprite-sheet.png"; - if (string.IsNullOrWhiteSpace(dssDir) || string.IsNullOrWhiteSpace(dssIds)) - { - Console.Error.WriteLine("usage: AcDream.Cli dump-sprite-sheet <0xId,0xId,...> [out.png]"); - return 2; - } - return VitalsMockup.ExportSheet(dssDir, dssIds, dssOut); -} - -if (args.Length >= 1 && args[0] == "dump-font-atlas") -{ - string? dfaDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - string? dfaFont = args.ElementAtOrDefault(2); // 0xFontId (default 0x40000000) - string? dfaSample = args.ElementAtOrDefault(3); // sample string - string dfaOut = args.ElementAtOrDefault(4) ?? "font-atlas"; - if (string.IsNullOrWhiteSpace(dfaDir)) - { - Console.Error.WriteLine("usage: AcDream.Cli dump-font-atlas [0xFontId] [sample] [outBase]"); - return 2; - } - return FontAtlasDump.Run(dfaDir, dfaFont, dfaSample, dfaOut); -} - -if (args.Length >= 1 && args[0] == "probe") -{ - // probe - if (args.Length < 6) { Console.Error.WriteLine("usage: AcDream.Cli probe "); return 2; } - return VitalsMockup.Probe(args[1], int.Parse(args[2]), int.Parse(args[3]), int.Parse(args[4]), int.Parse(args[5])); -} - -if (args.Length >= 1 && args[0] == "crop") -{ - // crop - if (args.Length < 8) { Console.Error.WriteLine("usage: AcDream.Cli crop "); return 2; } - return VitalsMockup.Crop(args[1], - int.Parse(args[2]), int.Parse(args[3]), int.Parse(args[4]), int.Parse(args[5]), int.Parse(args[6]), args[7]); -} - -if (args.Length >= 1 && args[0] == "dump-edges") -{ - string? deDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - string? deId = args.ElementAtOrDefault(2); - if (string.IsNullOrWhiteSpace(deDir) || string.IsNullOrWhiteSpace(deId)) - { - Console.Error.WriteLine("usage: AcDream.Cli dump-edges <0xId>"); - return 2; - } - return VitalsMockup.DumpEdges(deDir, deId); -} - -if (args.Length >= 1 && args[0] == "mock-selbar") -{ - string? msbDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - string msbOut = args.ElementAtOrDefault(2) ?? "selbar.png"; - if (string.IsNullOrWhiteSpace(msbDir)) - { - Console.Error.WriteLine("usage: AcDream.Cli mock-selbar [out.png]"); - return 2; - } - return VitalsMockup.MockSelBar(msbDir, msbOut); -} - -if (args.Length >= 1 && args[0] == "export-ui-sprite") -{ - string? eusDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - string? eusId = args.ElementAtOrDefault(2); - string eusOut = args.ElementAtOrDefault(3) ?? "sprite.png"; - if (string.IsNullOrWhiteSpace(eusDatDir) || string.IsNullOrWhiteSpace(eusId)) - { - Console.Error.WriteLine("usage: AcDream.Cli export-ui-sprite <0xId> [out.png]"); - return 2; - } - return VitalsMockup.ExportSprite(eusDatDir, eusId, eusOut); -} - // Phase 0: open the four AC dat files and print how many of each asset type live in them. // This proves DatReaderWriter works on our retail dats and gives us a baseline inventory // to compare against what a future renderer needs. @@ -289,146 +160,3 @@ static (string Name, Func Count)[] CountCellByLow16(DatCollection dats) ("Region", () => dats.GetAllIdsOfType().Count()), }; } - -/// -/// dump-vitals-bars: find the vitals window LayoutDesc (0x21000014) and print the -/// RenderSurface DataIds (0x06xxxxxx) used by the Health, Stamina, and Mana meter -/// bars. Each meter element (E6/EC/EE) has two child sub-groups per bar visual -/// (front-bar and back-bar/track), each containing: -/// - elem 0x100004A9 (ShowDetail state image = Alphablend fill sprite) -/// - elem 0x100000E8 (DirectStateDesc = left-edge sprite) -/// - elem 0x100000E9 (DirectStateDesc = fill-tile sprite) -/// - elem 0x100000EA (DirectStateDesc = right-edge sprite) -/// -/// Based on the Sept 2013 EoR retail dat, vitals layout id = 0x21000014. -/// Element ids from gmVitalsUI::PostInit in acclient_2013_pseudo_c.txt. -/// -static int DumpVitalsBars(string dvbDatDir) -{ - const uint HEALTH_ELEM_ID = 0x100000E6u; - const uint STAMINA_ELEM_ID = 0x100000ECu; - const uint MANA_ELEM_ID = 0x100000EEu; - - if (!Directory.Exists(dvbDatDir)) - { - Console.Error.WriteLine($"error: directory not found: {dvbDatDir}"); - return 2; - } - - using var dats = new DatCollection(dvbDatDir, DatAccessType.Read); - - // Find the vitals layout: scan all LayoutDescs for one containing the health meter element. - Console.WriteLine("Scanning LayoutDescs for vitals window (element 0x100000E6 = Health meter)..."); - uint? vitalsId = null; - LayoutDesc? vitalsLayout = null; - foreach (var id in dats.GetAllIdsOfType()) - { - var ld = dats.Get(id); - if (ld is null) continue; - if (VbContainsElementId(ld, HEALTH_ELEM_ID)) { vitalsId = id; vitalsLayout = ld; break; } - } - - if (vitalsLayout is null) - { - Console.Error.WriteLine("ERROR: no LayoutDesc contains element 0x100000E6 (Health meter)."); - return 1; - } - Console.WriteLine($"Found vitals layout: 0x{vitalsId!.Value:X8}"); - Console.WriteLine(); - - // For each vital meter, collect all MediaDescImage DataIds from its sub-tree. - var meters = new[] { (HEALTH_ELEM_ID, "HEALTH"), (STAMINA_ELEM_ID, "STAMINA"), (MANA_ELEM_ID, "MANA") }; - foreach (var (eid, vitalName) in meters) - { - Console.WriteLine($"{vitalName} meter (element 0x{eid:X8}) in layout 0x{vitalsId!.Value:X8}:"); - var meterElem = VbFindElement(vitalsLayout!, eid); - if (meterElem is null) { Console.WriteLine(" "); continue; } - - var sprites = new List<(string Role, uint DataId, string DrawMode)>(); - VbCollectSprites(meterElem, sprites, 0); - - if (sprites.Count == 0) - { - Console.WriteLine(" "); - } - else - { - foreach (var (role, dataId, drawMode) in sprites) - Console.WriteLine($" {role,-35} 0x{dataId:X8} ({drawMode})"); - } - Console.WriteLine(); - } - - return 0; -} - -// ─── dump-vitals-bars helpers ─────────────────────────────────────────────── - -static bool VbContainsElementId(LayoutDesc ld, uint targetId) -{ - var elems = ld.Elements; - foreach (var kvp in elems) - { - if (kvp.Key == targetId) return true; - if (VbChildContains(kvp.Value, targetId)) return true; - } - return false; -} - -static bool VbChildContains(ElementDesc elem, uint targetId) -{ - foreach (var kvp in elem.Children) - { - if (kvp.Key == targetId) return true; - if (VbChildContains(kvp.Value, targetId)) return true; - } - return false; -} - -static ElementDesc? VbFindElement(LayoutDesc ld, uint targetId) -{ - foreach (var kvp in ld.Elements) - { - if (kvp.Key == targetId) return kvp.Value; - var found = VbFindChild(kvp.Value, targetId); - if (found is not null) return found; - } - return null; -} - -static ElementDesc? VbFindChild(ElementDesc elem, uint targetId) -{ - foreach (var kvp in elem.Children) - { - if (kvp.Key == targetId) return kvp.Value; - var found = VbFindChild(kvp.Value, targetId); - if (found is not null) return found; - } - return null; -} - -static void VbCollectSprites(ElementDesc elem, List<(string, uint, string)> out_, int depth) -{ - string indent = new string(' ', depth * 2); - - // Check the element's direct StateDesc - if (elem.StateDesc is not null) - VbExtractMedia(elem.StateDesc, $"{indent}elem_0x{elem.ElementId:X8}.DirectState", out_); - - // Check each named state - foreach (var kvp in elem.States) - VbExtractMedia(kvp.Value, $"{indent}elem_0x{elem.ElementId:X8}.{kvp.Key}", out_); - - // Recurse into children - foreach (var kvp in elem.Children) - VbCollectSprites(kvp.Value, out_, depth + 1); -} - -static void VbExtractMedia(StateDesc sd, string role, List<(string, uint, string)> out_) -{ - foreach (var m in sd.Media) - { - if (m is MediaDescImage img && img.File != 0) - out_.Add((role, img.File, img.DrawMode.ToString())); - } -} diff --git a/src/AcDream.Cli/VitalsLayoutDump.cs b/src/AcDream.Cli/VitalsLayoutDump.cs deleted file mode 100644 index 675f671b..00000000 --- a/src/AcDream.Cli/VitalsLayoutDump.cs +++ /dev/null @@ -1,152 +0,0 @@ -using System.Collections; -using System.Reflection; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; -using DatReaderWriter.Types; - -namespace AcDream.Cli; - -/// -/// Full reflective dump of a vitals LayoutDesc element tree: every scalar -/// property (position/size/flags) of each ElementDesc + its state sprites, -/// so the real bar rects + spacing + window size can be read from the dat -/// instead of guessed. Uses reflection so it doesn't depend on knowing the -/// DatReaderWriter property names ahead of time. -/// -public static class VitalsLayoutDump -{ - public static int Run(string datDir, string? layoutIdText) - { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - // Default to the vitals layout dump-vitals-bars found; allow override. - uint layoutId = 0x21000014u; - if (!string.IsNullOrWhiteSpace(layoutIdText)) - { - var t = layoutIdText.Trim(); - if (t.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) t = t[2..]; - uint.TryParse(t, System.Globalization.NumberStyles.HexNumber, null, out layoutId); - } - - // First: scan ALL LayoutDescs that contain a vitals meter element, with root size, - // so we can tell whether 0x21000014 is the one the user sees (row vs stacked). - Console.WriteLine("=== LayoutDescs containing a vitals meter element (0x100000E6/EC/EE) ==="); - foreach (var id in dats.GetAllIdsOfType()) - { - var l = dats.Get(id); - if (l is null) continue; - if (!ContainsAny(l, 0x100000E6u, 0x100000ECu, 0x100000EEu)) continue; - Console.WriteLine($" 0x{id:X8} {RootSizeSummary(l)}"); - } - Console.WriteLine(); - - var ld = dats.Get(layoutId); - if (ld is null) { Console.Error.WriteLine($"layout 0x{layoutId:X8} not found"); return 1; } - - Console.WriteLine($"=== FULL DUMP layout 0x{layoutId:X8} ==="); - DumpScalars("LayoutDesc", ld, 0); - foreach (var kv in ld.Elements) - DumpElement(kv.Value, 1); - return 0; - } - - private static bool ContainsAny(LayoutDesc l, params uint[] ids) - { - foreach (var kv in l.Elements) - if (ElemContains(kv.Value, ids)) return true; - return false; - } - - private static bool ElemContains(ElementDesc e, uint[] ids) - { - if (Array.IndexOf(ids, e.ElementId) >= 0) return true; - foreach (var kv in e.Children) - if (ElemContains(kv.Value, ids)) return true; - return false; - } - - private static string RootSizeSummary(LayoutDesc l) - { - // Print any LayoutDesc-level scalar that looks like a size. - var sb = new System.Text.StringBuilder(); - foreach (var p in l.GetType().GetProperties()) - { - if (p.GetIndexParameters().Length > 0) continue; - if (p.Name is "Elements") continue; - object? v; try { v = p.GetValue(l); } catch { continue; } - if (v is null) continue; - if (IsScalar(v)) sb.Append($"{p.Name}={v} "); - } - return sb.ToString().Trim(); - } - - private static void DumpElement(ElementDesc e, int depth) - { - string ind = new string(' ', depth * 2); - Console.WriteLine($"{ind}element 0x{e.ElementId:X8}"); - DumpScalars(ind + " ", e, depth); - - if (e.StateDesc is not null) DumpMedia(ind + " [DirectState]", e.StateDesc); - foreach (var s in e.States) - DumpMedia($"{ind} [state {s.Key}]", s.Value); - - foreach (var c in e.Children) - DumpElement(c.Value, depth + 1); - } - - private static readonly HashSet Skip = new() { "Children", "States", "StateDesc", "Elements", "Media" }; - - private static void DumpScalars(string label, object o, int depth) - { - foreach (var (name, val) in Members(o)) - { - if (Skip.Contains(name)) continue; - if (IsScalar(val)) - Console.WriteLine($"{label} {name} = {Fmt(name, val)}"); - } - } - - private static void DumpMedia(string label, StateDesc sd) - { - foreach (var m in sd.Media) - { - var sb = new System.Text.StringBuilder(); - foreach (var (name, val) in Members(m)) - if (IsScalar(val)) sb.Append($"{name}={Fmt(name, val)} "); - Console.WriteLine($"{label} {m.GetType().Name}: {sb.ToString().Trim()}"); - } - } - - /// Enumerate public properties AND public fields (the DatReaderWriter - /// generated types expose geometry/file ids as fields, not properties). - private static IEnumerable<(string name, object val)> Members(object o) - { - var t = o.GetType(); - foreach (var p in t.GetProperties(BindingFlags.Public | BindingFlags.Instance)) - { - if (p.GetIndexParameters().Length > 0) continue; - object? v; try { v = p.GetValue(o); } catch { continue; } - if (v is not null) yield return (p.Name, v); - } - foreach (var f in t.GetFields(BindingFlags.Public | BindingFlags.Instance)) - { - object? v; try { v = f.GetValue(o); } catch { continue; } - if (v is not null) yield return (f.Name, v); - } - } - - private static string Fmt(string name, object v) => - name.Contains("File", StringComparison.OrdinalIgnoreCase) && v is uint u ? $"0x{u:X8}" : v.ToString() ?? ""; - - private static bool IsScalar(object v) - { - var t = v.GetType(); - if (v is string) return true; - if (t.IsPrimitive || t.IsEnum) return true; - if (v is IEnumerable) return false; - // value-type structs (Rectangle/Point/etc.) — print via ToString - return t.IsValueType; - } -} diff --git a/src/AcDream.Cli/VitalsMockup.cs b/src/AcDream.Cli/VitalsMockup.cs deleted file mode 100644 index 312c97d1..00000000 --- a/src/AcDream.Cli/VitalsMockup.cs +++ /dev/null @@ -1,324 +0,0 @@ -using AcDream.Core.Textures; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; - -namespace AcDream.Cli; - -/// -/// Headless PNG preview of the retail STACKED vitals window (LayoutDesc -/// 0x2100006C). Renders the window WIDENED, twice: once with the middle slice -/// STRETCHED (acdream's current behaviour) and once TILED (retail behaviour — -/// the "fill-tile" element is repeated at native width, last copy clipped). -/// Lets the stretch-vs-tile difference be judged by eye before touching the -/// client. Bars = back 3-slice (empty track, full) + front 3-slice (fill). -/// -public static class VitalsMockup -{ - // 8-piece chrome border (dat-verified in 0x2100006C; 5px). - private const uint TL = 0x060074C3, TOP = 0x060074BF, TR = 0x060074C4; - private const uint LEFT = 0x060074C0, RIGHT = 0x060074C2; - private const uint BL = 0x060074C5, BOT = 0x060074C1, BR = 0x060074C6; - - private readonly record struct Vital( - string Name, float Frac, - uint BackL, uint BackM, uint BackR, uint FrontL, uint FrontM, uint FrontR); - - private static readonly Vital[] Vitals = - { - new("health", 0.80f, 0x0600747E, 0x0600747F, 0x06007480, 0x06007481, 0x06007482, 0x06007483), - new("stamina", 0.50f, 0x06007484, 0x06007485, 0x06007486, 0x06007487, 0x06007488, 0x06007489), - new("mana", 0.65f, 0x0600748A, 0x0600748B, 0x0600748C, 0x0600748D, 0x0600748E, 0x0600748F), - }; - - private const uint CenterFill = 0x06004CC2; // dark interior panel (UiNineSlicePanel draws this) - private const int Border = 5, BarH = 16, Zoom = 6; - private const int BarW = 150; // default vitals window bar width (0x2100006C) - private static readonly int[] BarLocalY = { 0, 16, 32 }; // flush stacked inside the interior - - public static int Render(string datDir, string outPath) - { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - int winW = BarW + 2 * Border; // 160 - int winH = 3 * BarH + 2 * Border; // 58 - using var canvas = new Image(winW, winH, new Rgba32(20, 20, 24, 255)); - - DrawWindow(canvas, dats, 0, winW, winH, tileMid: true); - - canvas.Mutate(c => c.Resize(canvas.Width * Zoom, canvas.Height * Zoom, KnownResamplers.NearestNeighbor)); - canvas.SaveAsPng(outPath); - Console.WriteLine($"wrote {outPath} ({canvas.Width}x{canvas.Height}) — faithful default vitals window 0x2100006C"); - return 0; - } - - private static void DrawWindow(Image canvas, DatCollection dats, int offY, int winW, int winH, bool tileMid) - { - // Dark interior fill (matches UiNineSlicePanel's CenterFill behind the bars). - using (var cf = Load(dats, CenterFill)) - Blit(canvas, cf, Border, offY + Border, winW - 2 * Border, winH - 2 * Border); - - // 8-piece chrome border (corners native 5x5, edges stretched for this preview). - using (var tl = Load(dats, TL)) using (var top = Load(dats, TOP)) using (var tr = Load(dats, TR)) - using (var le = Load(dats, LEFT)) using (var ri = Load(dats, RIGHT)) - using (var bl = Load(dats, BL)) using (var bo = Load(dats, BOT)) using (var br = Load(dats, BR)) - { - Blit(canvas, tl, 0, offY, Border, Border); - Blit(canvas, top, Border, offY, winW - 2 * Border, Border); - Blit(canvas, tr, winW - Border, offY, Border, Border); - Blit(canvas, le, 0, offY + Border, Border, winH - 2 * Border); - Blit(canvas, ri, winW - Border, offY + Border, Border, winH - 2 * Border); - Blit(canvas, bl, 0, offY + winH - Border, Border, Border); - Blit(canvas, bo, Border, offY + winH - Border, winW - 2 * Border, Border); - Blit(canvas, br, winW - Border, offY + winH - Border, Border, Border); - } - - // Resize-grip overlay: gold ridged edge strips + square corner studs, on - // top of the bevel (vitals LayoutDesc 0x1000063B–0x10000642). Edges shown - // stretched here for the preview; the client tiles them via UV-repeat. - using (var gc = Load(dats, 0x06006129)) - using (var gt = Load(dats, 0x0600612A)) using (var gb = Load(dats, 0x0600612C)) - using (var gl = Load(dats, 0x0600612B)) using (var gr = Load(dats, 0x0600612D)) - { - Blit(canvas, gt, Border, offY, winW - 2 * Border, Border); - Blit(canvas, gb, Border, offY + winH - Border, winW - 2 * Border, Border); - Blit(canvas, gl, 0, offY + Border, Border, winH - 2 * Border); - Blit(canvas, gr, winW - Border, offY + Border, Border, winH - 2 * Border); - Blit(canvas, gc, 0, offY, Border, Border); - Blit(canvas, gc, winW - Border, offY, Border, Border); - Blit(canvas, gc, 0, offY + winH - Border, Border, Border); - Blit(canvas, gc, winW - Border, offY + winH - Border, Border, Border); - } - - for (int i = 0; i < Vitals.Length; i++) - { - var v = Vitals[i]; - int y = offY + Border + BarLocalY[i]; - using var bl_ = Load(dats, v.BackL); using var bm = Load(dats, v.BackM); using var br_ = Load(dats, v.BackR); - using var fl = Load(dats, v.FrontL); using var fm = Load(dats, v.FrontM); using var fr = Load(dats, v.FrontR); - DrawHBar(canvas, bl_, bm, br_, Border, y, BarW, BarH, BarW, tileMid); - int fw = (int)MathF.Round(BarW * v.Frac); - if (fw > 0) DrawHBar(canvas, fl, fm, fr, Border, y, BarW, BarH, fw, tileMid); - } - } - - /// Horizontal 3-slice: native-width left-cap, middle (STRETCHED or TILED - /// per ), native-width right-cap; clipped to clipW. - private static void DrawHBar( - Image canvas, Image left, Image mid, Image right, - int x, int y, int w, int h, int clipW, bool tileMid) - { - if (w <= 0 || clipW <= 0) return; - int capL = Math.Min(left.Width, w); - int capR = Math.Min(right.Width, w - capL); - int midW = w - capL - capR; - - DrawClippedPiece(canvas, left, x, y, 0, capL, h, clipW); // left cap (once, native) - if (tileMid) TileMiddle(canvas, mid, x, y, capL, midW, h, clipW); // repeat native-width copies - else DrawClippedPiece(canvas, mid, x, y, capL, midW, h, clipW); // stretch across the span - DrawClippedPiece(canvas, right, x, y, w - capR, capR, h, clipW); // right cap (once, native) - } - - /// Fill [midLocalX, midLocalX+midW] by repeating the native-width tile at - /// 1:1 (no horizontal scaling), clipping the final partial copy and honouring clipW. - private static void TileMiddle( - Image canvas, Image mid, int x, int y, int midLocalX, int midW, int h, int clipW) - { - int tileW = Math.Max(1, mid.Width); - for (int mx = 0; mx < midW; mx += tileW) - { - int localX = midLocalX + mx; - int segW = Math.Min(tileW, midW - mx); // last copy may be partial - int visible = Math.Min(segW, clipW - localX); // fill-fraction clip - if (visible <= 0) break; - // 1:1 — crop the source to `visible` px (no resize-stretch), draw at native scale. - int cropW = Math.Min(visible, mid.Width); - using var seg = mid.Clone(c => c.Crop(new Rectangle(0, 0, cropW, mid.Height)).Resize(visible, h)); - canvas.Mutate(c => c.DrawImage(seg, new Point(x + localX, y), 1f)); - } - } - - /// Draw one slice spanning [pieceLocalX, +pieceW] STRETCHED to fill, UV-cropped - /// (proportionally) so nothing past clipW shows. - private static void DrawClippedPiece( - Image canvas, Image src, int x, int y, int pieceLocalX, int pieceW, int h, int clipW) - { - if (pieceW <= 0) return; - int visibleW = Math.Min(pieceW, clipW - pieceLocalX); - if (visibleW <= 0) return; - int srcCropW = Math.Max(1, (int)MathF.Round(src.Width * (visibleW / (float)pieceW))); - srcCropW = Math.Min(srcCropW, src.Width); - using var piece = src.Clone(c => c.Crop(new Rectangle(0, 0, srcCropW, src.Height)).Resize(visibleW, h)); - canvas.Mutate(c => c.DrawImage(piece, new Point(x + pieceLocalX, y), 1f)); - } - - private static void Blit(Image canvas, Image src, int x, int y, int dw, int dh) - { - if (dw <= 0 || dh <= 0) return; - using var s = src.Clone(c => c.Resize(dw, dh)); - canvas.Mutate(c => c.DrawImage(s, new Point(x, y), 1f)); - } - - /// Composite a comma-separated list of sprite ids into one row, magnified, - /// on a neutral background — so the exact chrome/bar graphics can be eyeballed. - public static int ExportSheet(string datDir, string idsCsv, string outPath) - { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - var ids = idsCsv.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Select(ParseHex).Where(x => x != 0).ToArray(); - if (ids.Length == 0) { Console.Error.WriteLine("no valid ids"); return 2; } - - var imgs = ids.Select(id => Load(dats, id)).ToArray(); - const int pad = 6, zoom = 10; - int totalW = pad + imgs.Sum(i => i.Width + pad); - int maxH = imgs.Max(i => i.Height); - using var canvas = new Image(totalW, maxH + 2 * pad, new Rgba32(64, 64, 72, 255)); - int x = pad; - foreach (var im in imgs) - { - canvas.Mutate(c => c.DrawImage(im, new Point(x, pad), 1f)); - x += im.Width + pad; - } - canvas.Mutate(c => c.Resize(canvas.Width * zoom, canvas.Height * zoom, KnownResamplers.NearestNeighbor)); - canvas.SaveAsPng(outPath); - Console.WriteLine("order (L→R): " + string.Join(" ", ids.Zip(imgs, (id, im) => $"0x{id:X8}={im.Width}x{im.Height}"))); - foreach (var im in imgs) im.Dispose(); - return 0; - } - - /// - /// Composite the selected-object health bar (back-track 0x0600193E + red fill 0x0600193F) - /// the same way the in-game UiMeter draws it: the 146px sprite mapped 1:1 into the 140px - /// meter element (right 6px cropped), back drawn full, fill drawn over the left - /// fraction*width. Rendered at several health fractions stacked so the end-caps / purple - /// can be eyeballed offline (D.5.3a purple-end investigation). - /// - public static int MockSelBar(string datDir, string outPath) - { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - using var back = Load(dats, 0x0600193E); - using var fill = Load(dats, 0x0600193F); - - const int elemW = 140, zoom = 8, gap = 4; - int elemH = Math.Min(back.Height, fill.Height); - float[] fracs = { 1.0f, 0.9f, 0.7f, 0.5f, 0.0f }; - int rowH = elemH + gap; - using var canvas = new Image(elemW, rowH * fracs.Length, new Rgba32(20, 20, 24, 255)); - - for (int i = 0; i < fracs.Length; i++) - { - int y = i * rowH; - float p = fracs[i]; - int backCrop = Math.Min(elemW, back.Width); - using (var b = back.Clone(c => c.Crop(new Rectangle(0, 0, backCrop, elemH)))) - canvas.Mutate(c => c.DrawImage(b, new Point(0, y), 1f)); - int fillW = (int)MathF.Round(elemW * p); - if (fillW > 0) - { - int fillCrop = Math.Min(fillW, fill.Width); - using var f = fill.Clone(c => c.Crop(new Rectangle(0, 0, fillCrop, elemH))); - canvas.Mutate(c => c.DrawImage(f, new Point(0, y), 1f)); - } - } - - canvas.Mutate(c => c.Resize(canvas.Width * zoom, canvas.Height * zoom, KnownResamplers.NearestNeighbor)); - canvas.SaveAsPng(outPath); - Console.WriteLine($"wrote {outPath} — selbar composite, rows = health 1.0 / 0.9 / 0.7 / 0.5 / 0.0"); - return 0; - } - - /// Print the RGB of a rectangular block of pixels from a PNG (framebuffer probe). - public static int Probe(string inPath, int x0, int y0, int x1, int y1) - { - if (!File.Exists(inPath)) { Console.Error.WriteLine($"not found: {inPath}"); return 2; } - using var img = Image.Load(inPath); - x0 = Math.Clamp(x0, 0, img.Width - 1); x1 = Math.Clamp(x1, 0, img.Width - 1); - y0 = Math.Clamp(y0, 0, img.Height - 1); y1 = Math.Clamp(y1, 0, img.Height - 1); - Console.WriteLine($"{inPath} {img.Width}x{img.Height} cols x={x0}..{x1}"); - for (int y = y0; y <= y1; y++) - { - var sb = new System.Text.StringBuilder($"y={y,4}: "); - for (int x = x0; x <= x1; x++) { var p = img[x, y]; sb.Append($"{p.R:X2}{p.G:X2}{p.B:X2} "); } - Console.WriteLine(sb.ToString()); - } - return 0; - } - - /// Crop a region of a PNG and upscale (nearest) — for zooming into a framebuffer dump. - public static int Crop(string inPath, int x, int y, int w, int h, int zoom, string outPath) - { - if (!File.Exists(inPath)) { Console.Error.WriteLine($"not found: {inPath}"); return 2; } - using var img = Image.Load(inPath); - x = Math.Clamp(x, 0, img.Width - 1); - y = Math.Clamp(y, 0, img.Height - 1); - w = Math.Clamp(w, 1, img.Width - x); - h = Math.Clamp(h, 1, img.Height - y); - if (zoom < 1) zoom = 1; - img.Mutate(c => c.Crop(new Rectangle(x, y, w, h)).Resize(w * zoom, h * zoom, KnownResamplers.NearestNeighbor)); - img.SaveAsPng(outPath); - Console.WriteLine($"wrote {outPath} ({w * zoom}x{h * zoom}) from {inPath} region ({x},{y},{w},{h})"); - return 0; - } - - /// Print the RGB of the first/last few columns of a sprite at every row, so the - /// end-cap colors can be inspected (D.5.3a purple-end investigation). - public static int DumpEdges(string datDir, string idText) - { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } - uint id = ParseHex(idText); - using var dats = new DatCollection(datDir, DatAccessType.Read); - var rs = dats.Get(id); - if (rs is null) { Console.Error.WriteLine($"no RenderSurface 0x{id:X8}"); return 1; } - var pal = rs.DefaultPaletteId != 0 ? dats.Get(rs.DefaultPaletteId) : null; - var dec = SurfaceDecoder.DecodeRenderSurface(rs, pal); - Console.WriteLine($"0x{id:X8} {rs.Format} {dec.Width}x{dec.Height}"); - int[] cols = { 0, 1, 2, 3, dec.Width - 4, dec.Width - 3, dec.Width - 2, dec.Width - 1 }; - foreach (int cx in cols) - { - if (cx < 0 || cx >= dec.Width) continue; - var sb = new System.Text.StringBuilder(); - for (int y = 0; y < dec.Height; y++) - { - int i = (y * dec.Width + cx) * 4; - sb.Append($"{dec.Rgba8[i]:X2}{dec.Rgba8[i + 1]:X2}{dec.Rgba8[i + 2]:X2} "); - } - Console.WriteLine($"x={cx,3}: {sb}"); - } - return 0; - } - - public static int ExportSprite(string datDir, string idText, string outPath) - { - if (!Directory.Exists(datDir)) { Console.Error.WriteLine($"error: dir not found: {datDir}"); return 2; } - uint id = ParseHex(idText); - if (id == 0) { Console.Error.WriteLine($"error: bad id '{idText}'"); return 2; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - using var img = Load(dats, id); - img.SaveAsPng(outPath); - Console.WriteLine($"wrote {outPath} (0x{id:X8} {img.Width}x{img.Height})"); - return 0; - } - - private static Image Load(DatCollection dats, uint id) - { - var rs = dats.Get(id); - if (rs is null) { Console.Error.WriteLine($" missing RenderSurface 0x{id:X8}"); return new Image(1, 1); } - var dt = SurfaceDecoder.DecodeRenderSurface(rs); - return Image.LoadPixelData(dt.Rgba8, dt.Width, dt.Height); - } - - private static uint ParseHex(string s) - { - s = s.Trim(); - if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) s = s[2..]; - return uint.TryParse(s, System.Globalization.NumberStyles.HexNumber, - System.Globalization.CultureInfo.InvariantCulture, out var v) ? v : 0u; - } -} diff --git a/src/AcDream.Content/AcDream.Content.csproj b/src/AcDream.Content/AcDream.Content.csproj deleted file mode 100644 index 93c7dbd2..00000000 --- a/src/AcDream.Content/AcDream.Content.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net10.0 - enable - enable - latest - true - true - - - - - - - - - - - - - - diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs deleted file mode 100644 index bc728132..00000000 --- a/src/AcDream.Content/MeshExtractor.cs +++ /dev/null @@ -1,1172 +0,0 @@ -using AcDream.Core.Rendering.Wb; -using BCnEncoder.Decoder; -using BCnEncoder.ImageSharp; -using BCnEncoder.Shared; -using Chorizite.Core.Lib; -using Chorizite.Core.Render.Enums; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Enums; -using DatReaderWriter.Types; -using Microsoft.Extensions.Logging; -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using System.Threading; -using CullMode = DatReaderWriter.Enums.CullMode; -using BoundingBox = Chorizite.Core.Lib.BoundingBox; - -namespace AcDream.Content; - -/// -/// MP1a (2026-07-05): the GL-free CPU half of the former ObjectMeshManager — -/// dat read → polygon walk → vertex/index build → inline texture decode → -/// . Extracted VERBATIM so the MP1b bake tool -/// and the live client run the SAME extraction code (byte-identical output -/// is the pak conformance foundation). ObjectMeshManager (App) retains the -/// queue/worker lifecycle and all GL upload; it delegates here. -/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §6.1. -/// -public sealed class MeshExtractor { - private readonly IDatReaderWriter _dats; - private readonly ILogger _logger; - - // Cache for decoded textures to avoid redundant BCn decoding - private readonly ConcurrentQueue _decodedTextureLru = new(); - private readonly ConcurrentDictionary _decodedTextureCache = new(); - private const int MaxDecodedTextures = 128; - private readonly ThreadLocal _bcDecoder = new(() => new BcDecoder()); - - /// - /// MP1a mechanical seam: receives particle-preload meshes staged - /// mid-extraction (see ). The App - /// wires this to its staged-upload queue, restoring the original - /// immediate-enqueue semantics — entries must survive a subsequent throw - /// in the same Prepare* call (retail's code enqueued directly onto - /// ObjectMeshManager's _stagedMeshData mid-Prepare, so preloads - /// staged before a malformed-dat texture-decode throw were already safe). - /// The MP1b bake tool passes its own collector. The sink must be - /// thread-safe: one MeshExtractor is shared by up to MaxParallelLoads (4) - /// decode workers. - /// The constructor argument is REQUIRED (no default): a bake tool that - /// forgot the sink would silently lose particle-preload meshes; requiring - /// the argument forces the decision. The type stays nullable so a caller - /// can consciously pass null when preloads are irrelevant. - /// - private readonly Action? _sideStagedSink; - - public MeshExtractor(IDatReaderWriter dats, ILogger logger, Action? sideStagedSink) { - _dats = dats; - _logger = logger; - _sideStagedSink = sideStagedSink; - } - - /// - /// Phase 1 (Background Thread): Prepare CPU-side mesh data from DAT. - /// This loads vertices, indices, and texture data but creates NO GPU resources. - /// Thread-safe: only reads from DAT files. - /// - public ObjectMeshData? PrepareMeshData(ulong id, bool isSetup, CancellationToken ct = default) { - try { - // Use the low 32 bits as the DAT file ID - var datId = (uint)(id & 0xFFFFFFFFu); - var resolutions = _dats.ResolveId(datId).ToList(); - var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); - if (selectedResolution == null) return null; - - var type = selectedResolution.Type; - var db = selectedResolution.Database; - - if (type == DBObjType.Setup) { - if (!db.TryGet(datId, out var setup)) return null; - return PrepareSetupMeshData(id, setup, ct); - } - else if (type == DBObjType.GfxObj) { - if (!db.TryGet(datId, out var gfxObj)) return null; - return PrepareGfxObjMeshData(id, gfxObj, Vector3.One, ct); - } - else if (type == DBObjType.EnvCell) { - if (!db.TryGet(datId, out var envCell)) return null; - - // If bit 32 is set, this is a request for the cell's synthetic geometry only - if ((id & 0x1_0000_0000UL) != 0) { - uint envId = 0x0D000000u | envCell.EnvironmentId; - if (_dats.Portal.TryGet(envId, out var environment)) { - if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { - return PrepareCellStructMeshData(id, cellStruct, envCell.Surfaces, Matrix4x4.Identity, ct); - } - } - return null; - } - - return PrepareEnvCellMeshData(id, envCell, ct); - } - else if (type == DBObjType.Environment) { - if (!db.TryGet(datId, out var environment)) return null; - - // For Environment objects, create wireframe-only edge geometry - if (environment.Cells.Count > 0) { - var result = PrepareCellStructEdgeLineData(id, environment.Cells, Matrix4x4.Identity, ct); - return result; - } - return null; - } - return null; - } - catch (OperationCanceledException) { - // Ignore - return null; - } - catch (Exception ex) { - _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id); - return null; - } - } - - private ObjectMeshData? PrepareSetupMeshData(ulong id, Setup setup, CancellationToken ct) { - var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); - var min = new Vector3(float.MaxValue); - var max = new Vector3(float.MinValue); - bool hasBounds = false; - - CollectParts((uint)(id & 0xFFFFFFFFu), Matrix4x4.Identity, parts, ref min, ref max, ref hasBounds, ct); - - var emitters = new List(); - var processedScripts = new HashSet(); - if (setup.DefaultScript.DataId != 0) { - if (processedScripts.Add(setup.DefaultScript.DataId)) { - CollectEmittersFromScript(setup.DefaultScript.DataId, emitters, ct); - } - } - - return new ObjectMeshData { - ObjectId = id, - IsSetup = true, - SetupParts = parts, - ParticleEmitters = emitters, - BoundingBox = hasBounds ? new BoundingBox(min, max) : default, - SelectionSphere = setup.SelectionSphere - }; - } - - private void CollectEmittersFromScript(uint scriptId, List emitters, CancellationToken ct) { - if (_dats.Portal.TryGet(scriptId, out var script)) { - foreach (var hook in script.ScriptData) { - if (hook.Hook.HookType == AnimationHookType.CreateParticle && hook.Hook is CreateParticleHook particleHook) { - if (_dats.Portal.TryGet(particleHook.EmitterInfoId.DataId, out var emitter)) { - emitters.Add(new StagedEmitter { - Emitter = emitter, - PartIndex = particleHook.PartIndex, - Offset = Matrix4x4.CreateFromQuaternion(particleHook.Offset.Orientation) * Matrix4x4.CreateTranslation(particleHook.Offset.Origin) - }); - - // Pre-load and stage the particle's GfxObjs - if (emitter.HwGfxObjId.DataId != 0) { - var meshData = PrepareMeshData(emitter.HwGfxObjId.DataId, false, ct); - if (meshData != null) { - _sideStagedSink?.Invoke(meshData); - } - } - if (emitter.GfxObjId.DataId != 0 && emitter.GfxObjId.DataId != emitter.HwGfxObjId.DataId) { - var meshData = PrepareMeshData(emitter.GfxObjId.DataId, false, ct); - if (meshData != null) { - _sideStagedSink?.Invoke(meshData); - } - } - } - } - } - } - } - - public void CollectParts(uint id, Matrix4x4 currentTransform, List<(ulong GfxObjId, Matrix4x4 Transform)> parts, ref Vector3 min, ref Vector3 max, ref bool hasBounds, CancellationToken ct, int depth = 0) { - if (depth > 50) { - _logger.LogWarning("Max recursion depth reached while collecting parts for 0x{Id:X8}. Possible circular dependency.", id); - return; - } - ct.ThrowIfCancellationRequested(); - - var resolutions = _dats.ResolveId(id).ToList(); - var selectedResolution = resolutions.OrderByDescending(r => r.Database == _dats.Portal).FirstOrDefault(); - if (selectedResolution == null) return; - - var type = selectedResolution.Type; - var db = selectedResolution.Database; - - if (type == DBObjType.Setup) { - if (!db.TryGet(id, out var setup)) return; - - // Use Resting placement first, then default - if (!setup.PlacementFrames.TryGetValue(Placement.Resting, out var placementFrame)) { - if (!setup.PlacementFrames.TryGetValue(Placement.Default, out placementFrame)) { - placementFrame = setup.PlacementFrames.Values.FirstOrDefault(); - } - } - if (placementFrame == null) return; - - for (int i = 0; i < setup.Parts.Count; i++) { - var partId = setup.Parts[i]; - var transform = Matrix4x4.Identity; - - if (setup.Flags.HasFlag(SetupFlags.HasDefaultScale) && setup.DefaultScale.Count > i) { - transform *= Matrix4x4.CreateScale(setup.DefaultScale[i]); - } - - if (placementFrame.Frames != null && i < placementFrame.Frames.Count) { - var orientation = new System.Numerics.Quaternion( - (float)placementFrame.Frames[i].Orientation.X, - (float)placementFrame.Frames[i].Orientation.Y, - (float)placementFrame.Frames[i].Orientation.Z, - (float)placementFrame.Frames[i].Orientation.W - ); - transform *= Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin); - } - - CollectParts(partId, transform * currentTransform, parts, ref min, ref max, ref hasBounds, ct, depth + 1); - } - } - else if (type == DBObjType.EnvCell) { - if (!db.TryGet(id, out var envCell)) return; - - // Calculate the inverse transform of the cell to localize its contents - var cellOrientation = new System.Numerics.Quaternion( - (float)envCell.Position.Orientation.X, - (float)envCell.Position.Orientation.Y, - (float)envCell.Position.Orientation.Z, - (float)envCell.Position.Orientation.W - ); - var cellTransform = Matrix4x4.CreateFromQuaternion(cellOrientation) * - Matrix4x4.CreateTranslation(envCell.Position.Origin); - if (!Matrix4x4.Invert(cellTransform, out var invertCellTransform)) { - invertCellTransform = Matrix4x4.Identity; - } - - // Include cell geometry - uint envId = 0x0D000000u | envCell.EnvironmentId; - if (_dats.Portal.TryGet(envId, out var environment)) { - if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { - foreach (var vert in cellStruct.VertexArray.Vertices.Values) { - var transformed = Vector3.Transform(vert.Origin, currentTransform); - min = Vector3.Min(min, transformed); - max = Vector3.Max(max, transformed); - } - hasBounds = true; - - // Add synthetic geometry ID to parts list - parts.Add(((ulong)id | 0x1_0000_0000UL, currentTransform)); - } - } - - foreach (var stab in envCell.StaticObjects) { - var orientation = new System.Numerics.Quaternion( - (float)stab.Frame.Orientation.X, - (float)stab.Frame.Orientation.Y, - (float)stab.Frame.Orientation.Z, - (float)stab.Frame.Orientation.W - ); - var transform = Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(stab.Frame.Origin); - // Localize static object transform relative to the cell - var localizedTransform = transform * invertCellTransform; - - CollectParts(stab.Id, localizedTransform * currentTransform, parts, ref min, ref max, ref hasBounds, ct, depth + 1); - } - } - else if (type == DBObjType.GfxObj) { - parts.Add((id, currentTransform)); - - if (db.TryGet(id, out var partGfx)) { - var (partMin, partMax) = ComputeBounds(partGfx, Vector3.One); - var corners = new Vector3[8]; - corners[0] = new Vector3(partMin.X, partMin.Y, partMin.Z); - corners[1] = new Vector3(partMin.X, partMin.Y, partMax.Z); - corners[2] = new Vector3(partMin.X, partMax.Y, partMin.Z); - corners[3] = new Vector3(partMin.X, partMax.Y, partMax.Z); - corners[4] = new Vector3(partMax.X, partMin.Y, partMin.Z); - corners[5] = new Vector3(partMax.X, partMin.Y, partMax.Z); - corners[6] = new Vector3(partMax.X, partMax.Y, partMin.Z); - corners[7] = new Vector3(partMax.X, partMax.Y, partMax.Z); - - foreach (var corner in corners) { - var transformed = Vector3.Transform(corner, currentTransform); - min = Vector3.Min(min, transformed); - max = Vector3.Max(max, transformed); - } - hasBounds = true; - } - } - } - - private ObjectMeshData? PrepareGfxObjMeshData(ulong id, GfxObj gfxObj, Vector3 scale, CancellationToken ct) { - var vertices = new List(); - var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>(); - var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List>(); - - var (min, max) = ComputeBounds(gfxObj, scale); - var boundingBox = new BoundingBox(min, max); - - // #113 (2026-06-11): retail draws a GfxObj by TRAVERSING its drawing - // BSP — a polygon present in the Polygons dictionary but referenced by - // no DrawingBSP node is never rendered (physics/no-draw geometry). - // The Holtburg meeting hall (0x010014C3) keeps its walkable exterior - // stair-ramp as dictionary polys {0,1}: in the PhysicsBSP (NPCs walk - // it) but absent from every DrawingBSP node — retail shows a plain - // wall; iterating the dictionary draws the "phantom staircase" - // (invisible-but-walkable in retail, visible in acdream). The hill - // cottage (0x01000827) carries 8 such orphans. - // - // ⚠️ FILTER NOT APPLIED (e46d3d9 un-applied same day): naively - // filtering to CollectDrawingBspPolygonIds(gfxObj) made DOORS - // disappear across Holtburg (user gate 2026-06-11) — the naive - // PosNode/NegNode walk evidently misses polys some models reference - // another way (portal-type nodes? leaf indexing? DatReaderWriter - // parse gap?). Diagnose with the histogram fact in - // Issue113PhantomStairsDumpTests on a door GfxObj BEFORE re-landing. - // The full retail draw is BSP-TRAVERSAL ORDER drawing, not a - // dictionary iteration with a filter — see the holistic port handoff - // docs/research/2026-06-11-building-render-holistic-port-handoff.md. - foreach (var polyEntry in gfxObj.Polygons) { - ct.ThrowIfCancellationRequested(); - var poly = polyEntry.Value; - if (poly.VertexIds.Count < 3) continue; - - // Handle Positive Surface - if (!poly.Stippling.HasFlag(StipplingType.NoPos)) { - AddSurfaceToBatch(poly, poly.PosSurface, false); - } - - // Handle Negative Surface - // Some objects use Clockwise CullMode to indicate negative surface data is present - bool hasNeg = poly.Stippling.HasFlag(StipplingType.Negative) || - poly.Stippling.HasFlag(StipplingType.Both) || - (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise); - - if (hasNeg) { - AddSurfaceToBatch(poly, poly.NegSurface, true); - } - - void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool isNeg) { - if (surfaceIdx < 0 || surfaceIdx >= gfxObj.Surfaces.Count) return; - - var surfaceId = gfxObj.Surfaces[surfaceIdx]; - if (!_dats.Portal.TryGet(surfaceId, out var surface)) { - // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) - Console.WriteLine($"[tex-skip] gfxobj Surface 0x{surfaceId:X8} miss -> poly batch dropped (obj 0x{gfxObj.Id:X8})"); - return; - } - - int texWidth, texHeight; - byte[] textureData; - TextureFormat textureFormat; - UploadPixelFormat? uploadPixelFormat = null; - UploadPixelType? uploadPixelType = null; - bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); - bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); - uint paletteId = 0; - bool isDxt3or5 = false; - DatReaderWriter.Enums.PixelFormat? sourceFormat = null; - var isAdditive = false; - var isTransparent = false; - - if (isSolid) { - texWidth = texHeight = 32; - textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); - textureFormat = TextureFormat.RGBA8; - uploadPixelFormat = UploadPixelFormat.Rgba; - } - else if (_dats.Portal.TryGet(surface.OrigTextureId, out var surfaceTexture)) { - var renderSurfaceId = surfaceTexture.Textures.First(); - if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { - // check highres - if (!_dats.HighRes.TryGet(renderSurfaceId, out var hrRenderSurface)) { - throw new Exception($"Unable to load RenderSurface: 0x{renderSurfaceId:X8}"); - } - - renderSurface = hrRenderSurface; - } - - texWidth = renderSurface.Width; - texHeight = renderSurface.Height; - paletteId = renderSurface.DefaultPaletteId; - sourceFormat = renderSurface.Format; - - if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { - isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; - textureFormat = TextureFormat.RGBA8; - uploadPixelFormat = UploadPixelFormat.Rgba; - - if (_decodedTextureCache.TryGetValue(renderSurfaceId, out textureData!)) { - // use cached data - } - else { - textureData = new byte[texWidth * texHeight * 4]; - - CompressionFormat compressionFormat = renderSurface.Format switch { - DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1, - DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2, - DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3, - _ => throw new NotSupportedException($"Unsupported compressed format: {renderSurface.Format}") - }; - - using (var image = _bcDecoder.Value!.DecodeRawToImageRgba32(renderSurface.SourceData, texWidth, texHeight, compressionFormat)) { - image.CopyPixelDataTo(textureData); - } - _decodedTextureCache.TryAdd(renderSurfaceId, textureData); - } - - if (isClipMap && textureData != null) { - // If we got this from the cache, we need to clone it so we don't scale the cached raw data - if (_decodedTextureCache.ContainsKey(renderSurfaceId)) { - var clonedData = new byte[textureData.Length]; - System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); - textureData = clonedData; - } - - for (int i = 0; i < textureData.Length; i += 4) { - if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) { - textureData[i + 3] = 0; - } - } - } - } - else { - textureFormat = TextureFormat.RGBA8; - textureData = renderSurface.SourceData; - switch (renderSurface.Format) { - case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16: - if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var paletteData)) - throw new Exception($"Unable to load Palette: 0x{renderSurface.DefaultPaletteId:X8}"); - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_P8: - if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var p8PaletteData)) - throw new Exception($"Unable to load Palette: 0x{renderSurface.DefaultPaletteId:X8}"); - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_A8: - case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA: - textureData = new byte[texWidth * texHeight * 4]; - if (surface.Type.HasFlag(SurfaceType.Additive)) { - TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - } - else { - TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - } - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - default: - throw new NotSupportedException($"Unsupported surface format: {renderSurface.Format}"); - } - } - - if (surface.Translucency > 0.0f && textureData != null) { - // If we got this from the cache, we need to clone it so we don't scale the cached raw data - if (sourceFormat.HasValue && TextureHelpers.IsCompressedFormat(sourceFormat.Value) && _decodedTextureCache.ContainsKey(renderSurfaceId)) { - var clonedData = new byte[textureData.Length]; - System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); - textureData = clonedData; - } - - float alphaScale = 1.0f - surface.Translucency; - for (int i = 3; i < textureData.Length; i += 4) { - textureData[i] = (byte)(textureData[i] * alphaScale); - } - } - - isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive); - isTransparent = isSolid ? surface.ColorValue.Alpha < 255 : - (surface.Type.HasFlag(SurfaceType.Translucent) || - surface.Type.HasFlag(SurfaceType.Base1ClipMap) || - ((uint)surface.Type & 0x100) != 0 || // Alpha - ((uint)surface.Type & 0x200) != 0 || // InvAlpha - isAdditive || - (surface.Translucency > 0.0f && surface.Translucency < 1.0f) || - textureFormat == TextureFormat.A8 || - textureFormat == TextureFormat.Rgba32f || - isDxt3or5 || - (sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 || - sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 || - sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || - sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5))); - } - else { - // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) - Console.WriteLine($"[tex-skip] gfxobj SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> poly batch dropped (surface 0x{surfaceId:X8})"); - return; - } - - var format = (texWidth, texHeight, textureFormat); - var key = new TextureKey { - SurfaceId = surfaceId, - PaletteId = paletteId, - Stippling = poly.Stippling, - IsSolid = isSolid - }; - - if (!batchesByFormat.TryGetValue(format, out var batches)) { - batches = new List(); - batchesByFormat[format] = batches; - } - - var batch = batches.FirstOrDefault(b => b.Key.Equals(key) && b.CullMode == poly.SidesType); - if (batch == null) { - batch = new TextureBatchData { - Key = key, - CullMode = poly.SidesType, - TextureData = textureData!, - UploadPixelFormat = uploadPixelFormat, - UploadPixelType = uploadPixelType, - IsTransparent = isTransparent, - IsAdditive = isAdditive - }; - batches.Add(batch); - } - - bool batchHasWrappingUVs = batch.HasWrappingUVs; - BuildPolygonIndices(poly, gfxObj, scale, UVLookup, vertices, batch.Indices, isNeg, ref batchHasWrappingUVs); - batch.HasWrappingUVs = batchHasWrappingUVs; - } - } - - return new ObjectMeshData { - ObjectId = id, - IsSetup = false, - Vertices = vertices.ToArray(), - TextureBatches = batchesByFormat, - BoundingBox = boundingBox, - SortCenter = gfxObj?.SortCenter ?? Vector3.Zero, - DIDDegrade = gfxObj != null && gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) ? gfxObj.DIDDegrade : 0, - SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f } - }; - } - - private ObjectMeshData? PrepareEnvCellMeshData(ulong id, EnvCell envCell, CancellationToken ct) { - var parts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(); - var min = new Vector3(float.MaxValue); - var max = new Vector3(float.MinValue); - bool hasBounds = false; - - // Calculate the inverse transform of the cell to localize its contents - var cellOrientation = new System.Numerics.Quaternion( - (float)envCell.Position.Orientation.X, - (float)envCell.Position.Orientation.Y, - (float)envCell.Position.Orientation.Z, - (float)envCell.Position.Orientation.W - ); - var cellTransform = Matrix4x4.CreateFromQuaternion(cellOrientation) * - Matrix4x4.CreateTranslation(envCell.Position.Origin); - if (!Matrix4x4.Invert(cellTransform, out var invertCellTransform)) { - invertCellTransform = Matrix4x4.Identity; - } - - // Add static objects - var emitters = new List(); - foreach (var stab in envCell.StaticObjects) { - var orientation = new System.Numerics.Quaternion( - (float)stab.Frame.Orientation.X, - (float)stab.Frame.Orientation.Y, - (float)stab.Frame.Orientation.Z, - (float)stab.Frame.Orientation.W - ); - var transform = Matrix4x4.CreateFromQuaternion(orientation) - * Matrix4x4.CreateTranslation(stab.Frame.Origin); - - // Localize static object transform relative to the cell - var localizedTransform = transform * invertCellTransform; - - CollectParts(stab.Id, localizedTransform, parts, ref min, ref max, ref hasBounds, ct); - - // For EnvCell static objects, we need to manually collect emitters if they are Setups. - // Bugfix 2026-05-19 (acdream): pre-check the Setup-prefix (0x02xxxxxx) before calling - // TryGet. Without this, calling TryGet on a GfxObj-prefixed id - // (0x01xxxxxx) throws ArgumentOutOfRangeException as DatReaderWriter tries to parse - // GfxObj bytes as a Setup record. The exception bubbles up through PrepareMeshData's - // outer catch and the entire cell fails to upload — manifesting as missing floors - // in any building whose StaticObjects include a GfxObj-typed stab (very common). - // Confirmed via acdream's Phase 2 indoor-cell-rendering diagnostic probes; see - // docs/research/2026-05-19-indoor-cell-rendering-cause.md in the acdream repo. - if ((stab.Id & 0xFF000000u) == 0x02000000u - && _dats.Portal.TryGet(stab.Id, out var stabSetup)) { - var stabEmitters = new List(); - var processedScripts = new HashSet(); - if (stabSetup.DefaultScript.DataId != 0) { - if (processedScripts.Add(stabSetup.DefaultScript.DataId)) { - CollectEmittersFromScript(stabSetup.DefaultScript.DataId, stabEmitters, ct); - } - } - - foreach (var emitter in stabEmitters) { - emitters.Add(new StagedEmitter { - Emitter = emitter.Emitter, - PartIndex = emitter.PartIndex, - Offset = emitter.Offset * localizedTransform - }); - } - } - } - - // Load environment and cell structure geometry - uint envId = 0x0D000000u | envCell.EnvironmentId; - ObjectMeshData? cellGeometry = null; - if (_dats.Portal.TryGet(envId, out var environment)) { - if (environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { - var cellGeomId = id | 0x1_0000_0000UL; - cellGeometry = PrepareCellStructMeshData(cellGeomId, cellStruct, envCell.Surfaces, Matrix4x4.Identity, ct); - if (cellGeometry != null) { - parts.Add((cellGeomId, Matrix4x4.Identity)); - min = Vector3.Min(min, cellGeometry.BoundingBox.Min); - max = Vector3.Max(max, cellGeometry.BoundingBox.Max); - hasBounds = true; - } - } - } - - return new ObjectMeshData { - ObjectId = id, - IsSetup = true, - SetupParts = parts, - ParticleEmitters = emitters, - EnvCellGeometry = cellGeometry, - BoundingBox = hasBounds ? new BoundingBox(min, max) : default, - SelectionSphere = new Sphere { Origin = hasBounds ? (min + max) / 2f : Vector3.Zero, Radius = hasBounds ? Vector3.Distance(max, min) / 2.0f : 0f } - }; - } - - public ObjectMeshData? PrepareCellStructMeshData(ulong id, CellStruct cellStruct, List surfaceOverrides, Matrix4x4 transform, CancellationToken ct) { - var vertices = new List(); - var UVLookup = new Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort>(); - var batchesByFormat = new Dictionary<(int Width, int Height, TextureFormat Format), List>(); - - var min = new Vector3(float.MaxValue); - var max = new Vector3(float.MinValue); - foreach (var vert in cellStruct.VertexArray.Vertices.Values) { - var localizedPos = Vector3.Transform(vert.Origin, transform); - min = Vector3.Min(min, localizedPos); - max = Vector3.Max(max, localizedPos); - } - var boundingBox = new BoundingBox(min, max); - - foreach (var poly in cellStruct.Polygons.Values) { - ct.ThrowIfCancellationRequested(); - if (poly.VertexIds.Count < 3) continue; - - // Retail D3DPolyRender::ConstructMesh (0x0059dfa0) treats this - // DatReaderWriter "CullMode" as CPolygon::sides_type, not as a - // GL cull enum: 0 = pos, 1 = pos twice with reversed winding, - // 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still - // suppress hidden portal/cap faces before they reach our mesh. - bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos); - bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg); - - if (hasPos) - AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: false, reverseWinding: false); - if (hasPos && poly.SidesType == CullMode.None) { - AddSurfaceToBatch(poly, poly.PosSurface, useNegUv: false, invertNormal: true, reverseWinding: true); - } - else if (hasNeg && poly.SidesType == CullMode.Clockwise) { - AddSurfaceToBatch(poly, poly.NegSurface, useNegUv: true, invertNormal: true, reverseWinding: false); - } - - void AddSurfaceToBatch(Polygon poly, short surfaceIdx, bool useNegUv, bool invertNormal, bool reverseWinding) { - if (surfaceIdx < 0) return; - - uint surfaceId; - if (surfaceIdx < surfaceOverrides.Count) { - surfaceId = 0x08000000u | surfaceOverrides[surfaceIdx]; - } - else { - _logger.LogWarning($"Failed to find surface override for index {surfaceIdx} in CellStruct 0x{cellStruct:X4}"); - return; - } - - if (!_dats.Portal.TryGet(surfaceId, out var surface)) { - // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) - Console.WriteLine($"[tex-skip] cellstruct Surface 0x{surfaceId:X8} miss -> WALL poly batch dropped (cellstruct 0x{cellStruct:X4})"); - return; - } - - int texWidth, texHeight; - byte[] textureData; - TextureFormat textureFormat; - UploadPixelFormat? uploadPixelFormat = null; - UploadPixelType? uploadPixelType = null; - bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); - bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); - uint paletteId = 0; - bool isDxt3or5 = false; - DatReaderWriter.Enums.PixelFormat? sourceFormat = null; - var isAdditive = false; - var isTransparent = false; - - if (isSolid) { - texWidth = texHeight = 32; - textureData = TextureHelpers.CreateSolidColorTexture(surface.ColorValue, texWidth, texHeight); - textureFormat = TextureFormat.RGBA8; - uploadPixelFormat = UploadPixelFormat.Rgba; - } - else if (_dats.Portal.TryGet(surface.OrigTextureId, out var surfaceTexture)) { - var renderSurfaceId = surfaceTexture.Textures.First(); - if (!_dats.Portal.TryGet(renderSurfaceId, out var renderSurface)) { - if (!_dats.HighRes.TryGet(renderSurfaceId, out var hrRenderSurface)) { - // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) - Console.WriteLine($"[tex-skip] cellstruct RenderSurface 0x{renderSurfaceId:X8} miss (portal+highres) -> WALL poly batch dropped"); - return; - } - renderSurface = hrRenderSurface; - } - - texWidth = renderSurface.Width; - texHeight = renderSurface.Height; - paletteId = renderSurface.DefaultPaletteId; - sourceFormat = renderSurface.Format; - - if (_decodedTextureCache.TryGetValue(renderSurfaceId, out var cachedData)) { - textureData = cachedData; - textureFormat = TextureFormat.RGBA8; - uploadPixelFormat = UploadPixelFormat.Rgba; - } - else { - if (TextureHelpers.IsCompressedFormat(renderSurface.Format)) { - isDxt3or5 = renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || renderSurface.Format == DatReaderWriter.Enums.PixelFormat.PFID_DXT5; - textureFormat = TextureFormat.RGBA8; - uploadPixelFormat = UploadPixelFormat.Rgba; - - textureData = new byte[texWidth * texHeight * 4]; - CompressionFormat compressionFormat = renderSurface.Format switch { - DatReaderWriter.Enums.PixelFormat.PFID_DXT1 => CompressionFormat.Bc1, - DatReaderWriter.Enums.PixelFormat.PFID_DXT3 => CompressionFormat.Bc2, - DatReaderWriter.Enums.PixelFormat.PFID_DXT5 => CompressionFormat.Bc3, - _ => throw new NotSupportedException($"Unsupported compressed format: {renderSurface.Format}") - }; - - using (var image = _bcDecoder.Value!.DecodeRawToImageRgba32(renderSurface.SourceData, texWidth, texHeight, compressionFormat)) { - image.CopyPixelDataTo(textureData); - } - } - else { - textureFormat = TextureFormat.RGBA8; - textureData = renderSurface.SourceData; - switch (renderSurface.Format) { - case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillA8R8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillR8G8B8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16: - if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var paletteData)) return; - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillIndex16(renderSurface.SourceData, paletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_P8: - if (!_dats.Portal.TryGet(renderSurface.DefaultPaletteId, out var p8PaletteData)) return; - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillP8(renderSurface.SourceData, p8PaletteData, textureData.AsSpan(), texWidth, texHeight, isClipMap); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillR5G6B5(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: - textureData = new byte[texWidth * texHeight * 4]; - TextureHelpers.FillA4R4G4B4(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - case DatReaderWriter.Enums.PixelFormat.PFID_A8: - case DatReaderWriter.Enums.PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA: - textureData = new byte[texWidth * texHeight * 4]; - if (surface.Type.HasFlag(SurfaceType.Additive)) { - TextureHelpers.FillA8Additive(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - } - else { - TextureHelpers.FillA8(renderSurface.SourceData, textureData.AsSpan(), texWidth, texHeight); - } - uploadPixelFormat = UploadPixelFormat.Rgba; - break; - default: return; - } - } - - // Add to cache with LRU logic - if (textureData != null && _decodedTextureCache.TryAdd(renderSurfaceId, textureData)) { - _decodedTextureLru.Enqueue(renderSurfaceId); - if (_decodedTextureCache.Count > MaxDecodedTextures) { - if (_decodedTextureLru.TryDequeue(out var evictedId)) { - _decodedTextureCache.TryRemove(evictedId, out _); - } - } - } - } - - if (isClipMap && textureData != null) { - // If we got this from the cache, we need to clone it so we don't scale the cached raw data - var clonedData = new byte[textureData.Length]; - System.Buffer.BlockCopy(textureData, 0, clonedData, 0, textureData.Length); - textureData = clonedData; - - for (int i = 0; i < textureData.Length; i += 4) { - if (textureData[i] == 0 && textureData[i + 1] == 0 && textureData[i + 2] == 0) { - textureData[i + 3] = 0; - } - } - } - } - else { - // TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix) - Console.WriteLine($"[tex-skip] cellstruct SurfaceTexture 0x{surface.OrigTextureId:X8} miss -> WALL poly batch dropped (surface 0x{surfaceId:X8})"); - return; - } - - isAdditive = !isSolid && surface.Type.HasFlag(SurfaceType.Additive); - isTransparent = isSolid ? surface.ColorValue.Alpha < 255 : - (surface.Type.HasFlag(SurfaceType.Translucent) || - surface.Type.HasFlag(SurfaceType.Base1ClipMap) || - ((uint)surface.Type & 0x100) != 0 || // Alpha - ((uint)surface.Type & 0x200) != 0 || // InvAlpha - isAdditive || - (surface.Translucency > 0.0f && surface.Translucency < 1.0f) || - textureFormat == TextureFormat.A8 || - textureFormat == TextureFormat.Rgba32f || - isDxt3or5 || - (sourceFormat != null && (sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8 || - sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4 || - sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT3 || - sourceFormat == DatReaderWriter.Enums.PixelFormat.PFID_DXT5))); - - var format = (texWidth, texHeight, textureFormat); - var key = new TextureKey { - SurfaceId = surfaceId, - PaletteId = paletteId, - Stippling = poly.Stippling, - IsSolid = isSolid - }; - - if (!batchesByFormat.TryGetValue(format, out var batches)) { - batches = new List(); - batchesByFormat[format] = batches; - } - - var batch = batches.FirstOrDefault(b => b.Key.Equals(key) && b.CullMode == poly.SidesType); - if (batch == null) { - batch = new TextureBatchData { - Key = key, - CullMode = poly.SidesType, - TextureData = textureData!, - UploadPixelFormat = uploadPixelFormat, - UploadPixelType = uploadPixelType, - IsTransparent = isTransparent, - IsAdditive = isAdditive - }; - batches.Add(batch); - } - - // Helper for CellStruct vertices - bool batchHasWrappingUVs = batch.HasWrappingUVs; - BuildCellStructPolygonIndices( - poly, - cellStruct, - UVLookup, - vertices, - batch.Indices, - useNegUv, - invertNormal, - reverseWinding, - transform, - ref batchHasWrappingUVs); - batch.HasWrappingUVs = batchHasWrappingUVs; - } - } - - return new ObjectMeshData { - ObjectId = id, - IsSetup = false, - Vertices = vertices.ToArray(), - TextureBatches = batchesByFormat, - BoundingBox = boundingBox, - SortCenter = Vector3.Zero, - SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f } - }; - } - - private void BuildCellStructPolygonIndices(Polygon poly, CellStruct cellStruct, - Dictionary<(ushort vertId, ushort uvIdx, bool invertNormal), ushort> UVLookup, - List vertices, List indices, - bool useNegUv, bool invertNormal, bool reverseWinding, - Matrix4x4 transform, ref bool hasWrappingUVs) { - - var polyIndices = new List(); - - for (int i = 0; i < poly.VertexIds.Count; i++) { - ushort vertId = (ushort)poly.VertexIds[i]; - ushort uvIdx = 0; - - if (useNegUv && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) - uvIdx = poly.NegUVIndices[i]; - else if (poly.PosUVIndices != null && i < poly.PosUVIndices.Count) - uvIdx = poly.PosUVIndices[i]; - - if (!cellStruct.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue; - - if (uvIdx >= vertex.UVs.Count) { - uvIdx = 0; - } - - var key = (vertId, uvIdx, invertNormal); - - if (!hasWrappingUVs) { - var uvCheck = vertex.UVs.Count > 0 - ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) - : Vector2.Zero; - if (uvCheck.X < 0f || uvCheck.X > 1f || uvCheck.Y < 0f || uvCheck.Y > 1f) { - hasWrappingUVs = true; - } - } - - if (!UVLookup.TryGetValue(key, out var idx)) { - var uv = vertex.UVs.Count > 0 - ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) - : Vector2.Zero; - - var normal = Vector3.Normalize(Vector3.TransformNormal(vertex.Normal, transform)); - if (invertNormal) { - normal = -normal; - } - - idx = (ushort)vertices.Count; - vertices.Add(new VertexPositionNormalTexture( - Vector3.Transform(vertex.Origin, transform), - normal, - uv - )); - UVLookup[key] = idx; - } - polyIndices.Add(idx); - } - - if (reverseWinding) { - for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[i]); - indices.Add(polyIndices[i - 1]); - indices.Add(polyIndices[0]); - } - } - else { - for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[0]); - indices.Add(polyIndices[i - 1]); - indices.Add(polyIndices[i]); - } - } - } - - private void BuildPolygonIndices(Polygon poly, GfxObj gfxObj, Vector3 scale, - Dictionary<(ushort vertId, ushort uvIdx, bool isNeg), ushort> UVLookup, - List vertices, List indices, bool useNegSurface, ref bool hasWrappingUVs) { - - var polyIndices = new List(); - - for (int i = 0; i < poly.VertexIds.Count; i++) { - ushort vertId = (ushort)poly.VertexIds[i]; - ushort uvIdx = 0; - - if (useNegSurface && poly.NegUVIndices != null && i < poly.NegUVIndices.Count) - uvIdx = poly.NegUVIndices[i]; - else if (!useNegSurface && poly.PosUVIndices != null && i < poly.PosUVIndices.Count) - uvIdx = poly.PosUVIndices[i]; - - if (!gfxObj.VertexArray.Vertices.TryGetValue(vertId, out var vertex)) continue; - - if (uvIdx >= vertex.UVs.Count) { - uvIdx = 0; - } - - var key = (vertId, uvIdx, useNegSurface); - - if (!hasWrappingUVs) { - var uvCheck = vertex.UVs.Count > 0 - ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) - : Vector2.Zero; - if (uvCheck.X < 0f || uvCheck.X > 1f || uvCheck.Y < 0f || uvCheck.Y > 1f) { - hasWrappingUVs = true; - } - } - - if (!UVLookup.TryGetValue(key, out var idx)) { - var uv = vertex.UVs.Count > 0 - ? new Vector2(vertex.UVs[uvIdx].U, vertex.UVs[uvIdx].V) - : Vector2.Zero; - - var normal = Vector3.Normalize(vertex.Normal); - if (useNegSurface) { - normal = -normal; - } - - idx = (ushort)vertices.Count; - vertices.Add(new VertexPositionNormalTexture( - vertex.Origin * scale, - normal, - uv - )); - UVLookup[key] = idx; - } - polyIndices.Add(idx); - } - - if (useNegSurface) { - // Reverse winding for negative surface so it's visible from the other side - for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[0]); - indices.Add(polyIndices[i - 1]); - indices.Add(polyIndices[i]); - } - } - else { - for (int i = 2; i < polyIndices.Count; i++) { - indices.Add(polyIndices[i]); - indices.Add(polyIndices[i - 1]); - indices.Add(polyIndices[0]); - } - } - } - - public (Vector3 Min, Vector3 Max) ComputeBounds(GfxObj gfxObj, Vector3 scale) { - var min = new Vector3(float.MaxValue); - var max = new Vector3(float.MinValue); - foreach (var vert in gfxObj.VertexArray.Vertices.Values) { - var p = vert.Origin * scale; - min = Vector3.Min(min, p); - max = Vector3.Max(max, p); - } - return (min, max); - } - - private ObjectMeshData? PrepareCellStructEdgeLineData(ulong id, Dictionary cellStructs, Matrix4x4 transform, CancellationToken ct) { - var cellStructList = cellStructs.ToList(); - if (cellStructList.Count == 0) { - return null; - } - - // Calculate bounding box from ALL vertices in all cell structures - var min = new Vector3(float.MaxValue); - var max = new Vector3(float.MinValue); - var allEdgeLines = new List(); - - // Process each CellStruct and collect all edge lines - foreach (var cellStructKvp in cellStructList) { - var cellStruct = cellStructKvp.Value; - - // Build edge lines for this CellStruct - var edgeLines = EdgeLineBuilder.BuildEdgeLines(cellStruct); - - // Transform edge lines to world space and add to collection - foreach (var edgeLine in edgeLines) { - allEdgeLines.Add(Vector3.Transform(edgeLine, transform)); - } - - // Update bounding box with vertices from this CellStruct - foreach (var vert in cellStruct.VertexArray.Vertices.Values) { - var localizedPos = Vector3.Transform(vert.Origin, transform); - min = Vector3.Min(min, localizedPos); - max = Vector3.Max(max, localizedPos); - } - } - - if (allEdgeLines.Count == 0) { - return null; - } - - var boundingBox = new BoundingBox(min, max); - - // Create minimal mesh data for edge line rendering - // We still need some vertices for rendering system to work, but they'll be transparent - var vertices = new List { - new VertexPositionNormalTexture { Position = Vector3.Zero, Normal = Vector3.UnitZ, UV = Vector2.Zero } - }; - var indices = new List { 0, 0, 0 }; // Dummy triangle - - // Create a transparent texture for base triangles (so only edge lines are visible) - var transparentTexture = TextureHelpers.CreateSolidColorTexture(new ColorARGB { Alpha = 0, Red = 255, Green = 255, Blue = 255 }, 1, 1); - - var result = new ObjectMeshData { - ObjectId = id, - IsSetup = false, - Vertices = vertices.ToArray(), - Batches = new List { - new MeshBatchData { - Indices = indices.ToArray(), - TextureFormat = (1, 1, TextureFormat.RGBA8), - TextureKey = new TextureKey { - SurfaceId = 0xFFFFFFFF, // Dummy surface ID - PaletteId = 0, - Stippling = StipplingType.NoPos, - IsSolid = true - }, - TextureIndex = 0, - TextureData = transparentTexture, - UploadPixelFormat = UploadPixelFormat.Rgba, - UploadPixelType = UploadPixelType.UnsignedByte, - CullMode = CullMode.None - } - }, - // Also populate TextureBatches for GPU upload - TextureBatches = new Dictionary<(int Width, int Height, TextureFormat Format), List> { - [(1, 1, TextureFormat.RGBA8)] = new List { - new TextureBatchData { - Indices = indices.ToList(), - Key = new TextureKey { - SurfaceId = 0xFFFFFFFF, // Dummy surface ID - PaletteId = 0, - Stippling = StipplingType.NoPos, - IsSolid = true - }, - TextureData = transparentTexture, - UploadPixelFormat = UploadPixelFormat.Rgba, - UploadPixelType = UploadPixelType.UnsignedByte, - CullMode = CullMode.None, - IsTransparent = false // Render in opaque pass but transparent - } - } - }, - BoundingBox = boundingBox, - SelectionSphere = new Sphere { Origin = boundingBox.Center, Radius = Vector3.Distance(boundingBox.Max, boundingBox.Min) / 2.0f } - }; - - // Store all edge lines in mesh data for later use in UploadMeshData - result.EdgeLines = allEdgeLines.ToArray(); - - return result; - } -} diff --git a/src/AcDream.Content/ObjectMeshData.cs b/src/AcDream.Content/ObjectMeshData.cs deleted file mode 100644 index b5f7d5b0..00000000 --- a/src/AcDream.Content/ObjectMeshData.cs +++ /dev/null @@ -1,124 +0,0 @@ -using Chorizite.Core.Lib; -using Chorizite.Core.Render.Enums; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; -using System; -using System.Collections.Generic; -using System.Numerics; -using System.Runtime.InteropServices; -using BoundingBox = Chorizite.Core.Lib.BoundingBox; - -namespace AcDream.Content; - -/// -/// Vertex format for scenery mesh rendering: position, normal, UV. -/// -[StructLayout(LayoutKind.Sequential)] -public struct VertexPositionNormalTexture { - public Vector3 Position; - public Vector3 Normal; - public Vector2 UV; - - public static int Size => 8 * sizeof(float); // 3+3+2 = 8 floats = 32 bytes - - public VertexPositionNormalTexture(Vector3 position, Vector3 normal, Vector2 uv) { - Position = position; - Normal = normal; - UV = uv; - } -} - -/// -/// Staged data for a particle emitter to be created on the GL thread. -/// -public struct StagedEmitter { - public ParticleEmitter Emitter; - public uint PartIndex; - public Matrix4x4 Offset; -} - -/// -/// CPU-side mesh data prepared on a background thread. -/// Contains vertex data and per-batch index/texture info, but NO GPU resources. -/// -public class ObjectMeshData { - public ulong ObjectId { get; set; } - public bool IsSetup { get; set; } - public VertexPositionNormalTexture[] Vertices { get; set; } = Array.Empty(); - public List Batches { get; set; } = new(); - - /// - /// #125 (2026-06-12): GL upload-retry counter. A failed upload through - /// the App-side upload path (it returns null from its - /// catch) used to be dropped permanently — the staged item was consumed, - /// no render data was produced, and the prepared data lingered in the CPU - /// cache where PrepareMeshDataAsync's cache-hit short-circuit - /// returned it without ever re-staging it for upload (session-sticky - /// invisible mesh, one [wb-error] line). The drain loop now re-stages a - /// failed upload for the NEXT frame up to the App-side upload retry - /// limit (MaxUploadRetries). The counter lives on the mesh-data - /// object so - /// it resets to 0 naturally whenever the id is re-prepared (fresh object), - /// and bounds a deterministic GL failure to a few loud lines instead of a - /// silent permanent drop OR an unbounded per-frame retry storm. Retail - /// loads content synchronously and has no such failure mode — this - /// converges our async pipeline toward that guarantee. - /// - public int UploadAttempts; - - /// For EnvCell: the geometry of the cell itself. - public ObjectMeshData? EnvCellGeometry { get; set; } - - /// For Setup objects: parts with their local transforms. - public List<(ulong GfxObjId, Matrix4x4 Transform)> SetupParts { get; set; } = new(); - - /// Particle emitters from physics scripts. - public List ParticleEmitters { get; set; } = new(); - - /// Per-format texture atlas data (to be uploaded to GPU on main thread). - public Dictionary<(int Width, int Height, TextureFormat Format), List> TextureBatches { get; set; } = new(); - - /// Local bounding box. - public BoundingBox BoundingBox { get; set; } - - /// Approximate center point used for depth sorting / transparency ordering. - public Vector3 SortCenter { get; set; } - - /// DataID of a simpler GfxObj to use at long distance / low quality, or GfxObjDegradeInfo. - public uint DIDDegrade { get; set; } - - /// Sphere used for mouse selection. - public Sphere? SelectionSphere { get; set; } - - /// Edge line vertices for Environment wireframe rendering. - public Vector3[] EdgeLines { get; set; } = Array.Empty(); -} - -/// -/// CPU-side data for a single rendering batch (indices + texture reference). -/// -public class MeshBatchData { - public ushort[] Indices { get; set; } = Array.Empty(); - public (int Width, int Height, TextureFormat Format) TextureFormat { get; set; } - public TextureKey TextureKey { get; set; } - public int TextureIndex { get; set; } - public byte[] TextureData { get; set; } = Array.Empty(); - public UploadPixelFormat? UploadPixelFormat { get; set; } - public UploadPixelType? UploadPixelType { get; set; } - public DatReaderWriter.Enums.CullMode CullMode { get; set; } -} - -/// -/// CPU-side texture info for deduplication during background preparation. -/// -public class TextureBatchData { - public TextureKey Key { get; set; } - public byte[] TextureData { get; set; } = Array.Empty(); - public UploadPixelFormat? UploadPixelFormat { get; set; } - public UploadPixelType? UploadPixelType { get; set; } - public List Indices { get; set; } = new(); - public DatReaderWriter.Enums.CullMode CullMode { get; set; } - public bool IsTransparent { get; set; } - public bool IsAdditive { get; set; } - public bool HasWrappingUVs { get; set; } -} diff --git a/src/AcDream.Content/Pak/Crc32.cs b/src/AcDream.Content/Pak/Crc32.cs deleted file mode 100644 index e2afda95..00000000 --- a/src/AcDream.Content/Pak/Crc32.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System; - -namespace AcDream.Content.Pak; - -/// -/// Standard CRC-32 (IEEE 802.3 polynomial 0xEDB88320, the same variant used -/// by zip/gzip/PNG). Implemented directly rather than pulling in -/// System.IO.Hashing to keep AcDream.Content's dependency surface minimal -/// (mirrors the "no GL binaries, keep it lean" intent of the project — see -/// AcDream.Content.csproj's package comments). Used as the pak TOC's -/// per-blob corruption tripwire. -/// -public static class Crc32 { - private static readonly uint[] Table = BuildTable(); - - private static uint[] BuildTable() { - const uint polynomial = 0xEDB88320u; - var table = new uint[256]; - for (uint i = 0; i < 256; i++) { - uint c = i; - for (int k = 0; k < 8; k++) { - c = (c & 1) != 0 ? polynomial ^ (c >> 1) : c >> 1; - } - table[i] = c; - } - return table; - } - - public static uint Compute(ReadOnlySpan data) { - uint crc = 0xFFFFFFFFu; - foreach (var b in data) { - crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8); - } - return crc ^ 0xFFFFFFFFu; - } -} diff --git a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs b/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs deleted file mode 100644 index 622f64cd..00000000 --- a/src/AcDream.Content/Pak/ObjectMeshDataSerializer.cs +++ /dev/null @@ -1,519 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Numerics; -using System.Runtime.InteropServices; -using Chorizite.Core.Render.Enums; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; -using BoundingBox = Chorizite.Core.Lib.BoundingBox; -using CullMode = DatReaderWriter.Enums.CullMode; - -namespace AcDream.Content.Pak; - -/// -/// Deterministic binary (de)serializer for the -/// family (, , -/// , , plus the DRW -/// / value types). -/// -/// Layout rules (normative, see -/// docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1"): -/// primitives raw little-endian; arrays as count:i32 + payload; blittable -/// arrays (VertexPositionNormalTexture[], ushort[] indices, byte[] texture -/// data) written via bulk -/// copy; is written sorted by the -/// key tuple (Width, Height, Format) so bakes are byte-reproducible run to -/// run regardless of dictionary insertion order; nullable fields as -/// present:byte + value. -/// -/// EVERY field of every type in the family is serialized — see the plan's -/// Task 3 checklist. nests -/// recursively (present:byte + nested block) since MeshExtractor can -/// populate it with another full ObjectMeshData. -/// -public static class ObjectMeshDataSerializer { - public static void Write(ObjectMeshData data, Stream stream) { - using var bw = new BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true); - WriteObjectMeshData(bw, data); - } - - /// Deserializes directly over — no defensive copy (PakReader's single-pass read reuses its CRC buffer here). - public static ObjectMeshData Read(byte[] bytes) { - using var ms = new MemoryStream(bytes, writable: false); - using var br = new BinaryReader(ms, System.Text.Encoding.UTF8, leaveOpen: true); - return ReadObjectMeshData(br); - } - - public static ObjectMeshData Read(ReadOnlySpan bytes) => Read(bytes.ToArray()); - - // ---- ObjectMeshData ----------------------------------------------------- - - private static void WriteObjectMeshData(BinaryWriter w, ObjectMeshData data) { - w.Write(data.ObjectId); - w.Write(data.IsSetup); - - WriteVertexArray(w, data.Vertices); - - w.Write(data.Batches.Count); - foreach (var batch in data.Batches) WriteMeshBatchData(w, batch); - - w.Write(data.UploadAttempts); - - // EnvCellGeometry: recursive nested block. - w.Write(data.EnvCellGeometry is not null); - if (data.EnvCellGeometry is not null) WriteObjectMeshData(w, data.EnvCellGeometry); - - w.Write(data.SetupParts.Count); - foreach (var (gfxObjId, transform) in data.SetupParts) { - w.Write(gfxObjId); - WriteMatrix4x4(w, transform); - } - - w.Write(data.ParticleEmitters.Count); - foreach (var emitter in data.ParticleEmitters) WriteStagedEmitter(w, emitter); - - WriteTextureBatches(w, data.TextureBatches); - - WriteBoundingBox(w, data.BoundingBox); - WriteVector3(w, data.SortCenter); - w.Write(data.DIDDegrade); - - w.Write(data.SelectionSphere is not null); - if (data.SelectionSphere is not null) WriteSphere(w, data.SelectionSphere); - - WriteVector3Array(w, data.EdgeLines); - } - - private static ObjectMeshData ReadObjectMeshData(BinaryReader r) { - var data = new ObjectMeshData { - ObjectId = r.ReadUInt64(), - IsSetup = r.ReadBoolean(), - }; - - data.Vertices = ReadVertexArray(r); - - int batchCount = r.ReadInt32(); - var batches = new List(batchCount); - for (int i = 0; i < batchCount; i++) batches.Add(ReadMeshBatchData(r)); - data.Batches = batches; - - data.UploadAttempts = r.ReadInt32(); - - bool hasEnvCellGeometry = r.ReadBoolean(); - data.EnvCellGeometry = hasEnvCellGeometry ? ReadObjectMeshData(r) : null; - - int setupPartCount = r.ReadInt32(); - var setupParts = new List<(ulong GfxObjId, Matrix4x4 Transform)>(setupPartCount); - for (int i = 0; i < setupPartCount; i++) { - ulong gfxObjId = r.ReadUInt64(); - var transform = ReadMatrix4x4(r); - setupParts.Add((gfxObjId, transform)); - } - data.SetupParts = setupParts; - - int emitterCount = r.ReadInt32(); - var emitters = new List(emitterCount); - for (int i = 0; i < emitterCount; i++) emitters.Add(ReadStagedEmitter(r)); - data.ParticleEmitters = emitters; - - data.TextureBatches = ReadTextureBatches(r); - - data.BoundingBox = ReadBoundingBox(r); - data.SortCenter = ReadVector3(r); - data.DIDDegrade = r.ReadUInt32(); - - bool hasSelectionSphere = r.ReadBoolean(); - data.SelectionSphere = hasSelectionSphere ? ReadSphere(r) : null; - - data.EdgeLines = ReadVector3Array(r); - - return data; - } - - // ---- MeshBatchData ------------------------------------------------------- - - private static void WriteMeshBatchData(BinaryWriter w, MeshBatchData batch) { - WriteUInt16Array(w, batch.Indices); - WriteTextureFormatTuple(w, batch.TextureFormat); - WriteTextureKey(w, batch.TextureKey); - w.Write(batch.TextureIndex); - WriteByteArray(w, batch.TextureData); - WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0); - WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0); - w.Write((int)batch.CullMode); - } - - private static MeshBatchData ReadMeshBatchData(BinaryReader r) { - var batch = new MeshBatchData { - Indices = ReadUInt16Array(r), - TextureFormat = ReadTextureFormatTuple(r), - TextureKey = ReadTextureKey(r), - TextureIndex = r.ReadInt32(), - TextureData = ReadByteArray(r), - }; - batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v); - batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v); - batch.CullMode = (CullMode)r.ReadInt32(); - return batch; - } - - // ---- TextureBatchData / TextureBatches dictionary ------------------------- - - private static void WriteTextureBatchData(BinaryWriter w, TextureBatchData batch) { - WriteTextureKey(w, batch.Key); - WriteByteArray(w, batch.TextureData); - WriteNullableInt32Enum(w, batch.UploadPixelFormat.HasValue, batch.UploadPixelFormat is { } upf ? (int)upf : 0); - WriteNullableInt32Enum(w, batch.UploadPixelType.HasValue, batch.UploadPixelType is { } upt ? (int)upt : 0); - WriteUInt16List(w, batch.Indices); - w.Write((int)batch.CullMode); - w.Write(batch.IsTransparent); - w.Write(batch.IsAdditive); - w.Write(batch.HasWrappingUVs); - } - - private static TextureBatchData ReadTextureBatchData(BinaryReader r) { - var batch = new TextureBatchData { - Key = ReadTextureKey(r), - TextureData = ReadByteArray(r), - }; - batch.UploadPixelFormat = ReadNullableInt32Enum(r, v => (UploadPixelFormat)v); - batch.UploadPixelType = ReadNullableInt32Enum(r, v => (UploadPixelType)v); - batch.Indices = ReadUInt16List(r); - batch.CullMode = (CullMode)r.ReadInt32(); - batch.IsTransparent = r.ReadBoolean(); - batch.IsAdditive = r.ReadBoolean(); - batch.HasWrappingUVs = r.ReadBoolean(); - return batch; - } - - /// - /// Writes the TextureBatches dictionary sorted ascending by the key tuple - /// (Width, Height, Format) — REQUIRED for byte-reproducible bakes - /// independent of Dictionary iteration/insertion order. - /// - private static void WriteTextureBatches( - BinaryWriter w, - Dictionary<(int Width, int Height, TextureFormat Format), List> batches) { - var sortedKeys = batches.Keys - .OrderBy(k => k.Width) - .ThenBy(k => k.Height) - .ThenBy(k => (int)k.Format) - .ToList(); - - w.Write(sortedKeys.Count); - foreach (var key in sortedKeys) { - w.Write(key.Width); - w.Write(key.Height); - w.Write((int)key.Format); - - var list = batches[key]; - w.Write(list.Count); - foreach (var item in list) WriteTextureBatchData(w, item); - } - } - - private static Dictionary<(int Width, int Height, TextureFormat Format), List> ReadTextureBatches(BinaryReader r) { - int groupCount = r.ReadInt32(); - var result = new Dictionary<(int Width, int Height, TextureFormat Format), List>(groupCount); - for (int i = 0; i < groupCount; i++) { - int width = r.ReadInt32(); - int height = r.ReadInt32(); - var format = (TextureFormat)r.ReadInt32(); - - int listCount = r.ReadInt32(); - var list = new List(listCount); - for (int j = 0; j < listCount; j++) list.Add(ReadTextureBatchData(r)); - - result[(width, height, format)] = list; - } - return result; - } - - // ---- StagedEmitter / ParticleEmitter (DBObj) ------------------------------ - - private static void WriteStagedEmitter(BinaryWriter w, StagedEmitter emitter) { - w.Write(emitter.PartIndex); - WriteMatrix4x4(w, emitter.Offset); - w.Write(emitter.Emitter is not null); - if (emitter.Emitter is not null) WriteParticleEmitter(w, emitter.Emitter); - } - - private static StagedEmitter ReadStagedEmitter(BinaryReader r) { - uint partIndex = r.ReadUInt32(); - var offset = ReadMatrix4x4(r); - bool hasEmitter = r.ReadBoolean(); - var pe = hasEmitter ? ReadParticleEmitter(r) : null; - return new StagedEmitter { - PartIndex = partIndex, - Offset = offset, - Emitter = pe!, - }; - } - - /// - /// Field-by-field serialization of DatReaderWriter.DBObjs.ParticleEmitter - /// (the dat DBObj type — StagedEmitter.Emitter resolves to THIS type via - /// ObjectMeshData.cs's `using DatReaderWriter.DBObjs;`, NOT - /// AcDream.Core.Vfx.ParticleEmitter). Every public field/property on the - /// pinned Chorizite.DatReaderWriter 2.1.7 type is written explicitly - /// (verified via reflection against the exact installed package) rather - /// than delegating to the type's own Pack/Unpack: those require a live - /// DatBinWriter/DatBinReader bound to a DatDatabase, which would couple - /// our pak's determinism to a third-party wire-format helper we don't - /// control the versioning of. - /// - private static void WriteParticleEmitter(BinaryWriter w, ParticleEmitter pe) { - w.Write(pe.Id); - w.Write(pe.DataCategory); - w.Write(pe.Unknown); - w.Write((int)pe.EmitterType); - w.Write((int)pe.ParticleType); - w.Write(pe.GfxObjId.DataId); - w.Write(pe.HwGfxObjId.DataId); - w.Write(pe.Birthrate); - w.Write(pe.MaxParticles); - w.Write(pe.InitialParticles); - w.Write(pe.TotalParticles); - w.Write(pe.TotalSeconds); - w.Write(pe.Lifespan); - w.Write(pe.LifespanRand); - WriteVector3(w, pe.OffsetDir); - w.Write(pe.MinOffset); - w.Write(pe.MaxOffset); - WriteVector3(w, pe.A); - w.Write(pe.MinA); - w.Write(pe.MaxA); - WriteVector3(w, pe.B); - w.Write(pe.MinB); - w.Write(pe.MaxB); - WriteVector3(w, pe.C); - w.Write(pe.MinC); - w.Write(pe.MaxC); - w.Write(pe.StartScale); - w.Write(pe.FinalScale); - w.Write(pe.ScaleRand); - w.Write(pe.StartTrans); - w.Write(pe.FinalTrans); - w.Write(pe.TransRand); - w.Write(pe.IsParentLocal); - } - - private static ParticleEmitter ReadParticleEmitter(BinaryReader r) { - var pe = new ParticleEmitter { - Id = r.ReadUInt32(), - DataCategory = r.ReadUInt32(), - }; - pe.Unknown = r.ReadUInt32(); - pe.EmitterType = (DatReaderWriter.Enums.EmitterType)r.ReadInt32(); - pe.ParticleType = (DatReaderWriter.Enums.ParticleType)r.ReadInt32(); - pe.GfxObjId = new QualifiedDataId { DataId = r.ReadUInt32() }; - pe.HwGfxObjId = new QualifiedDataId { DataId = r.ReadUInt32() }; - pe.Birthrate = r.ReadDouble(); - pe.MaxParticles = r.ReadInt32(); - pe.InitialParticles = r.ReadInt32(); - pe.TotalParticles = r.ReadInt32(); - pe.TotalSeconds = r.ReadDouble(); - pe.Lifespan = r.ReadDouble(); - pe.LifespanRand = r.ReadDouble(); - pe.OffsetDir = ReadVector3(r); - pe.MinOffset = r.ReadSingle(); - pe.MaxOffset = r.ReadSingle(); - pe.A = ReadVector3(r); - pe.MinA = r.ReadSingle(); - pe.MaxA = r.ReadSingle(); - pe.B = ReadVector3(r); - pe.MinB = r.ReadSingle(); - pe.MaxB = r.ReadSingle(); - pe.C = ReadVector3(r); - pe.MinC = r.ReadSingle(); - pe.MaxC = r.ReadSingle(); - pe.StartScale = r.ReadSingle(); - pe.FinalScale = r.ReadSingle(); - pe.ScaleRand = r.ReadSingle(); - pe.StartTrans = r.ReadSingle(); - pe.FinalTrans = r.ReadSingle(); - pe.TransRand = r.ReadSingle(); - pe.IsParentLocal = r.ReadBoolean(); - return pe; - } - - // ---- TextureKey ------------------------------------------------------ - - private static void WriteTextureKey(BinaryWriter w, TextureKey key) { - w.Write(key.SurfaceId); - w.Write(key.PaletteId); - w.Write((byte)key.Stippling); - w.Write(key.IsSolid); - } - - private static TextureKey ReadTextureKey(BinaryReader r) { - return new TextureKey { - SurfaceId = r.ReadUInt32(), - PaletteId = r.ReadUInt32(), - Stippling = (DatReaderWriter.Enums.StipplingType)r.ReadByte(), - IsSolid = r.ReadBoolean(), - }; - } - - private static void WriteTextureFormatTuple(BinaryWriter w, (int Width, int Height, TextureFormat Format) tuple) { - w.Write(tuple.Width); - w.Write(tuple.Height); - w.Write((int)tuple.Format); - } - - private static (int Width, int Height, TextureFormat Format) ReadTextureFormatTuple(BinaryReader r) { - int width = r.ReadInt32(); - int height = r.ReadInt32(); - var format = (TextureFormat)r.ReadInt32(); - return (width, height, format); - } - - // ---- Sphere / BoundingBox (DRW / Chorizite value types) ------------------- - - private static void WriteSphere(BinaryWriter w, Sphere sphere) { - WriteVector3(w, sphere.Origin); - w.Write(sphere.Radius); - } - - private static Sphere ReadSphere(BinaryReader r) { - var origin = ReadVector3(r); - float radius = r.ReadSingle(); - return new Sphere { Origin = origin, Radius = radius }; - } - - private static void WriteBoundingBox(BinaryWriter w, BoundingBox box) { - WriteVector3(w, box.Min); - WriteVector3(w, box.Max); - } - - private static BoundingBox ReadBoundingBox(BinaryReader r) { - var min = ReadVector3(r); - var max = ReadVector3(r); - return new BoundingBox(min, max); - } - - // ---- primitive helpers ------------------------------------------------- - - private static void WriteVector3(BinaryWriter w, Vector3 v) { - w.Write(v.X); - w.Write(v.Y); - w.Write(v.Z); - } - - private static Vector3 ReadVector3(BinaryReader r) { - float x = r.ReadSingle(); - float y = r.ReadSingle(); - float z = r.ReadSingle(); - return new Vector3(x, y, z); - } - - private static void WriteMatrix4x4(BinaryWriter w, Matrix4x4 m) { - w.Write(m.M11); w.Write(m.M12); w.Write(m.M13); w.Write(m.M14); - w.Write(m.M21); w.Write(m.M22); w.Write(m.M23); w.Write(m.M24); - w.Write(m.M31); w.Write(m.M32); w.Write(m.M33); w.Write(m.M34); - w.Write(m.M41); w.Write(m.M42); w.Write(m.M43); w.Write(m.M44); - } - - private static Matrix4x4 ReadMatrix4x4(BinaryReader r) { - return new Matrix4x4( - r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), - r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), - r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), - r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle()); - } - - /// count:i32 + MemoryMarshal.AsBytes bulk copy (32 bytes/vertex). - private static void WriteVertexArray(BinaryWriter w, VertexPositionNormalTexture[] vertices) { - w.Write(vertices.Length); - if (vertices.Length == 0) return; - var bytes = MemoryMarshal.AsBytes(vertices.AsSpan()); - w.Write(bytes); - } - - private static VertexPositionNormalTexture[] ReadVertexArray(BinaryReader r) { - int count = r.ReadInt32(); - if (count == 0) return Array.Empty(); - var result = new VertexPositionNormalTexture[count]; - var bytes = r.ReadBytes(count * VertexPositionNormalTexture.Size); - bytes.CopyTo(MemoryMarshal.AsBytes(result.AsSpan())); - return result; - } - - /// count:i32 + MemoryMarshal.AsBytes bulk copy (12 bytes/Vector3). - private static void WriteVector3Array(BinaryWriter w, Vector3[] array) { - w.Write(array.Length); - if (array.Length == 0) return; - var bytes = MemoryMarshal.AsBytes(array.AsSpan()); - w.Write(bytes); - } - - private static Vector3[] ReadVector3Array(BinaryReader r) { - int count = r.ReadInt32(); - if (count == 0) return Array.Empty(); - var result = new Vector3[count]; - var bytes = r.ReadBytes(count * 12); - bytes.CopyTo(MemoryMarshal.AsBytes(result.AsSpan())); - return result; - } - - /// count:i32 + MemoryMarshal.AsBytes bulk copy (2 bytes/ushort). - private static void WriteUInt16Array(BinaryWriter w, ushort[] array) { - w.Write(array.Length); - if (array.Length == 0) return; - var bytes = MemoryMarshal.AsBytes(array.AsSpan()); - w.Write(bytes); - } - - private static ushort[] ReadUInt16Array(BinaryReader r) { - int count = r.ReadInt32(); - if (count == 0) return Array.Empty(); - var result = new ushort[count]; - var bytes = r.ReadBytes(count * sizeof(ushort)); - bytes.CopyTo(MemoryMarshal.AsBytes(result.AsSpan())); - return result; - } - - private static void WriteUInt16List(BinaryWriter w, List list) { - w.Write(list.Count); - if (list.Count == 0) return; - var array = list.ToArray(); - var bytes = MemoryMarshal.AsBytes(array.AsSpan()); - w.Write(bytes); - } - - private static List ReadUInt16List(BinaryReader r) { - int count = r.ReadInt32(); - var list = new List(count); - if (count == 0) return list; - var array = new ushort[count]; - var bytes = r.ReadBytes(count * sizeof(ushort)); - bytes.CopyTo(MemoryMarshal.AsBytes(array.AsSpan())); - list.AddRange(array); - return list; - } - - /// count:i32 + raw byte payload. - private static void WriteByteArray(BinaryWriter w, byte[] array) { - w.Write(array.Length); - if (array.Length > 0) w.Write(array); - } - - private static byte[] ReadByteArray(BinaryReader r) { - int count = r.ReadInt32(); - return count == 0 ? Array.Empty() : r.ReadBytes(count); - } - - /// present:byte + value:i32 for a nullable enum-backed-by-int field. - private static void WriteNullableInt32Enum(BinaryWriter w, bool present, int value) { - w.Write(present); - w.Write(value); - } - - private static TEnum? ReadNullableInt32Enum(BinaryReader r, Func project) where TEnum : struct, Enum { - bool present = r.ReadBoolean(); - int value = r.ReadInt32(); - return present ? project(value) : null; - } -} diff --git a/src/AcDream.Content/Pak/PakFormat.cs b/src/AcDream.Content/Pak/PakFormat.cs deleted file mode 100644 index bf8b85bd..00000000 --- a/src/AcDream.Content/Pak/PakFormat.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System; -using System.Buffers.Binary; -using System.IO; - -namespace AcDream.Content.Pak; - -/// -/// Pak format constants shared by writer and reader. -/// -public static class PakFormat { - /// - /// The one format version this build writes and reads. PakWriter stamps - /// it unconditionally (callers cannot override it via the header - /// template); PakReader refuses to open any other version. Bump ONLY - /// with an accompanying reader migration path. - /// - public const uint CurrentFormatVersion = 1; -} - -/// -/// Fixed 64-byte pak file header. Layout (all integers little-endian): -/// -/// offset size field -/// 0 4 magic 'ACPK' (0x4B504341) -/// 4 4 formatVersion = 1 -/// 8 4 portalIteration (DatCollection.Portal.Iteration) -/// 12 4 cellIteration -/// 16 4 highResIteration -/// 20 4 languageIteration -/// 24 8 tocOffset (u64) -/// 32 4 tocCount (u32) -/// 36 4 bakeToolVersion = 1 -/// 40 24 reserved (zero) -/// -/// Spec: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1 (normative)". -/// -public struct PakHeader { - public const int Size = 64; - public const uint MagicValue = 0x4B504341u; // 'ACPK' little-endian - - /// Always after ; not settable by callers building a header to write. - public uint Magic { get; private set; } = MagicValue; - - public uint FormatVersion; - public uint PortalIteration; - public uint CellIteration; - public uint HighResIteration; - public uint LanguageIteration; - public ulong TocOffset; - public uint TocCount; - public uint BakeToolVersion; - - public PakHeader() { } - - public void WriteTo(Span dest) { - if (dest.Length < Size) throw new ArgumentException($"destination must be at least {Size} bytes", nameof(dest)); - BinaryPrimitives.WriteUInt32LittleEndian(dest[0..4], MagicValue); - BinaryPrimitives.WriteUInt32LittleEndian(dest[4..8], FormatVersion); - BinaryPrimitives.WriteUInt32LittleEndian(dest[8..12], PortalIteration); - BinaryPrimitives.WriteUInt32LittleEndian(dest[12..16], CellIteration); - BinaryPrimitives.WriteUInt32LittleEndian(dest[16..20], HighResIteration); - BinaryPrimitives.WriteUInt32LittleEndian(dest[20..24], LanguageIteration); - BinaryPrimitives.WriteUInt64LittleEndian(dest[24..32], TocOffset); - BinaryPrimitives.WriteUInt32LittleEndian(dest[32..36], TocCount); - BinaryPrimitives.WriteUInt32LittleEndian(dest[36..40], BakeToolVersion); - dest[40..64].Clear(); // reserved, zero - } - - public void WriteTo(Stream stream) { - Span buf = stackalloc byte[Size]; - WriteTo(buf); - stream.Write(buf); - } - - public static PakHeader ReadFrom(ReadOnlySpan src) { - if (src.Length < Size) throw new ArgumentException($"source must be at least {Size} bytes", nameof(src)); - var magic = BinaryPrimitives.ReadUInt32LittleEndian(src[0..4]); - if (magic != MagicValue) { - throw new InvalidDataException($"pak header magic mismatch: expected 0x{MagicValue:X8}, got 0x{magic:X8}"); - } - return new PakHeader { - FormatVersion = BinaryPrimitives.ReadUInt32LittleEndian(src[4..8]), - PortalIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[8..12]), - CellIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[12..16]), - HighResIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[16..20]), - LanguageIteration = BinaryPrimitives.ReadUInt32LittleEndian(src[20..24]), - TocOffset = BinaryPrimitives.ReadUInt64LittleEndian(src[24..32]), - TocCount = BinaryPrimitives.ReadUInt32LittleEndian(src[32..36]), - BakeToolVersion = BinaryPrimitives.ReadUInt32LittleEndian(src[36..40]), - }; - } - - public static PakHeader ReadFrom(Stream stream) { - Span buf = stackalloc byte[Size]; - stream.ReadExactly(buf); - return ReadFrom((ReadOnlySpan)buf); - } -} - -/// -/// One 24-byte TOC entry: key u64, offset u64, length u32, crc32 u32. -/// Entries in a pak's TOC are sorted ascending by to allow -/// binary-search lookup. is a corruption tripwire computed -/// over the blob bytes; the reader verifies lazily on first access. -/// -public struct PakTocEntry { - public const int Size = 24; - - public ulong Key; - public ulong Offset; - public uint Length; - public uint Crc32; - - public void WriteTo(Span dest) { - if (dest.Length < Size) throw new ArgumentException($"destination must be at least {Size} bytes", nameof(dest)); - BinaryPrimitives.WriteUInt64LittleEndian(dest[0..8], Key); - BinaryPrimitives.WriteUInt64LittleEndian(dest[8..16], Offset); - BinaryPrimitives.WriteUInt32LittleEndian(dest[16..20], Length); - BinaryPrimitives.WriteUInt32LittleEndian(dest[20..24], Crc32); - } - - public void WriteTo(Stream stream) { - Span buf = stackalloc byte[Size]; - WriteTo(buf); - stream.Write(buf); - } - - public static PakTocEntry ReadFrom(ReadOnlySpan src) { - if (src.Length < Size) throw new ArgumentException($"source must be at least {Size} bytes", nameof(src)); - return new PakTocEntry { - Key = BinaryPrimitives.ReadUInt64LittleEndian(src[0..8]), - Offset = BinaryPrimitives.ReadUInt64LittleEndian(src[8..16]), - Length = BinaryPrimitives.ReadUInt32LittleEndian(src[16..20]), - Crc32 = BinaryPrimitives.ReadUInt32LittleEndian(src[20..24]), - }; - } -} diff --git a/src/AcDream.Content/Pak/PakKey.cs b/src/AcDream.Content/Pak/PakKey.cs deleted file mode 100644 index da1a8753..00000000 --- a/src/AcDream.Content/Pak/PakKey.cs +++ /dev/null @@ -1,33 +0,0 @@ -namespace AcDream.Content.Pak; - -/// -/// MP1b pak asset-type discriminant — the top byte of a . -/// Numeric values are a WIRE FORMAT (persisted in every pak's TOC): never -/// renumber existing members, only append. -/// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §6.2; -/// plan: docs/superpowers/plans/2026-07-05-mp1b-pak-and-bake.md "Format v1". -/// -public enum PakAssetType : byte { - GfxObjMesh = 1, - SetupMesh = 2, - EnvCellMesh = 3, -} - -/// -/// Composes/decomposes the 64-bit pak asset key: type:u8 | fileId:u32 | reserved:u24. -/// Layout: ((ulong)type << 56) | ((ulong)fileId << 24) — the low 24 bits are -/// reserved (variant/zero in v1). Ascending numeric key order equals ascending -/// (type, fileId) tuple order, which is what makes the pak TOC's binary search -/// over raw u64 keys valid. -/// -public static class PakKey { - public static ulong Compose(PakAssetType type, uint fileId) { - return ((ulong)type << 56) | ((ulong)fileId << 24); - } - - public static (PakAssetType Type, uint FileId) Decompose(ulong key) { - var type = (PakAssetType)(byte)(key >> 56); - var fileId = (uint)((key >> 24) & 0xFFFFFFFFu); - return (type, fileId); - } -} diff --git a/src/AcDream.Content/Pak/PakReader.cs b/src/AcDream.Content/Pak/PakReader.cs deleted file mode 100644 index 9a7549d5..00000000 --- a/src/AcDream.Content/Pak/PakReader.cs +++ /dev/null @@ -1,222 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; -using System.IO; -using System.IO.MemoryMappedFiles; - -namespace AcDream.Content.Pak; - -/// -/// mmap reader for an acdream pak file. One -/// for the whole file's lifetime; TOC is loaded once at construction into a -/// key-sorted array for O(log n) binary-search lookup. -/// -/// Corrupt = missing (documented contract): a pak is an external -/// file — the reader's job is to surface corruption loudly ONCE and then -/// behave as if the damaged entry were absent, never to hand back garbage or -/// throw from a lookup. Three tripwires feed the same "missing" state: -/// (1) structurally invalid TOC entries (offset/length outside the file) are -/// detected at open; (2) blob CRC-32 is verified LAZILY on first access and -/// cached; (3) a blob whose bytes fail deserialization despite a matching -/// CRC (malformed structure) fails at read. Each is logged once per entry. -/// Structurally unopenable files (bad magic, wrong format version, -/// unfinalized header, TOC past EOF) throw at OPEN — before any lookups — -/// with a message naming the problem. -/// -/// Thread safety: no locks on the read path — the mapped memory -/// is immutable for the lifetime of this reader (acdream never mutates a pak -/// while it's open for reading; MP1c's cutover writes a NEW pak and swaps it -/// in). The lazy per-entry verdict set is a ConcurrentDictionary because -/// MP1c calls TryReadObjectMeshData from up to 4 decode workers concurrently. -/// -/// Perf note: a first read costs ONE exact-size byte[] copy out -/// of the map — CRC and deserialization both run over that same buffer in a -/// single pass. True span-over-mmap zero-copy (deserializing straight out of -/// the mapped view with no copy at all) is deferred to MP1c profiling — do -/// not add it speculatively. -/// -public sealed class PakReader : IDisposable { - private readonly MemoryMappedFile _mmf; - private readonly MemoryMappedViewAccessor _accessor; - private readonly long _fileLength; - private readonly PakTocEntry[] _toc; // sorted ascending by Key - - /// Lazy per-entry verdict: absent = not yet judged, 0 = bad (bounds/crc/structure), 1 = ok. - private readonly ConcurrentDictionary _entryVerdictByTocIndex = new(); - private readonly ConcurrentDictionary _loggedCorruption = new(); - - public PakHeader Header { get; } - - public PakReader(string path) { - _fileLength = new FileInfo(path).Length; - if (_fileLength < PakHeader.Size) { - throw new InvalidDataException($"pak file '{path}' is {_fileLength} bytes — smaller than the {PakHeader.Size}-byte header"); - } - - _mmf = MemoryMappedFile.CreateFromFile(path, FileMode.Open, mapName: null, capacity: 0, MemoryMappedFileAccess.Read); - _accessor = _mmf.CreateViewAccessor(0, 0, MemoryMappedFileAccess.Read); - - var headerBytes = new byte[PakHeader.Size]; - _accessor.ReadArray(0, headerBytes, 0, PakHeader.Size); - Header = PakHeader.ReadFrom((ReadOnlySpan)headerBytes); - - if (Header.FormatVersion != PakFormat.CurrentFormatVersion) { - throw new InvalidDataException( - $"pak file '{path}' has format version {Header.FormatVersion}; this build reads only " + - $"version {PakFormat.CurrentFormatVersion}. Re-bake with the matching acdream-bake."); - } - - // Half-written pak: PakWriter writes a placeholder header (TocOffset 0) - // first and only seeks back to finalize it in Finish(). A crash between - // those leaves TocOffset below the header size — structurally - // unfinalized; refuse loudly rather than serving an empty TOC over a - // file that has blob bytes in it. - if (Header.TocOffset < PakHeader.Size) { - throw new InvalidDataException( - $"pak file '{path}' has an unfinalized header (tocOffset={Header.TocOffset}) — " + - "the bake was interrupted before Finish(); re-bake."); - } - - long tocBytesTotal = (long)Header.TocCount * PakTocEntry.Size; - if ((long)Header.TocOffset + tocBytesTotal > _fileLength) { - throw new InvalidDataException( - $"pak file '{path}' TOC (offset {Header.TocOffset}, {Header.TocCount} entries) extends past " + - $"the file's actual length ({_fileLength} bytes) — truncated or corrupt file"); - } - - _toc = new PakTocEntry[Header.TocCount]; - var tocBytes = new byte[PakTocEntry.Size]; - long tocPos = (long)Header.TocOffset; - for (int i = 0; i < _toc.Length; i++) { - _accessor.ReadArray(tocPos, tocBytes, 0, PakTocEntry.Size); - _toc[i] = PakTocEntry.ReadFrom((ReadOnlySpan)tocBytes); - tocPos += PakTocEntry.Size; - - // Per-entry bounds validation (corrupt = missing, not throw): a - // blob region must sit fully between the header and the TOC, and - // its offset must survive the (long) cast the accessor needs. - ref readonly var entry = ref _toc[i]; - bool invalid = - entry.Offset < PakHeader.Size || - entry.Offset > long.MaxValue || - entry.Offset + entry.Length > Header.TocOffset || - entry.Offset + entry.Length > (ulong)_fileLength; - if (invalid) { - _entryVerdictByTocIndex[i] = 0; - LogCorruptionOnce(i, $"TOC entry out of bounds (offset={entry.Offset}, length={entry.Length}, " + - $"file={_fileLength}, toc@{Header.TocOffset})"); - } - } - } - - /// True if is present AND its blob verifies (bounds + CRC). - public bool ContainsKey(ulong key) { - int index = BinarySearch(key); - if (index < 0) return false; - return VerdictFor(index) == 1; - } - - /// - /// Reads and deserializes the stored under - /// . Returns false (data null) if the key is absent - /// OR the blob fails any tripwire (bounds, CRC, structural deserialization - /// failure) — logged once per entry, per the corrupt-=-missing contract. - /// - public bool TryReadObjectMeshData(ulong key, out ObjectMeshData? data) { - data = null; - int index = BinarySearch(key); - if (index < 0) return false; - - // Previously judged bad (bounds at open, or an earlier CRC/structure - // failure): missing, no re-read, no re-log. - bool judged = _entryVerdictByTocIndex.TryGetValue(index, out var verdict); - if (judged && verdict == 0) return false; - - // Single pass (review finding 4): ONE copy out of the map; CRC and - // deserialization both run over this same buffer. - ref readonly var entry = ref _toc[index]; - var bytes = new byte[entry.Length]; - _accessor.ReadArray((long)entry.Offset, bytes, 0, (int)entry.Length); - - if (!judged) { - uint actualCrc = Crc32.Compute(bytes); - if (actualCrc != entry.Crc32) { - _entryVerdictByTocIndex[index] = 0; - LogCorruptionOnce(index, $"crc mismatch (expected 0x{entry.Crc32:X8}, got 0x{actualCrc:X8})"); - return false; - } - _entryVerdictByTocIndex[index] = 1; - } - - try { - data = ObjectMeshDataSerializer.Read(bytes); - return true; - } - catch (Exception ex) { - // Structurally malformed blob behind a valid CRC (bake-side bug or - // a tamper that recomputed the CRC). External-file input: demote - // to missing, log once — never propagate from a lookup. - data = null; - _entryVerdictByTocIndex[index] = 0; - LogCorruptionOnce(index, $"deserialization failed despite matching CRC: {ex.GetType().Name}: {ex.Message}"); - return false; - } - } - - /// Returns the blob's file offset for alignment assertions in tests. - public long GetBlobOffsetForTest(ulong key) { - int index = BinarySearch(key); - if (index < 0) throw new KeyNotFoundException($"pak key 0x{key:X16} not found"); - return (long)_toc[index].Offset; - } - - /// O(n) linear scan, used only to cross-check the binary search in tests. - public bool DebugLinearScanContainsKey(ulong key) { - for (int i = 0; i < _toc.Length; i++) { - if (_toc[i].Key == key) return VerdictFor(i) == 1; - } - return false; - } - - /// Resolves (and caches) the entry's verdict, reading + CRC-checking the blob if not yet judged. - private int VerdictFor(int tocIndex) { - if (_entryVerdictByTocIndex.TryGetValue(tocIndex, out var cached)) return cached; - - ref readonly var entry = ref _toc[tocIndex]; - var bytes = new byte[entry.Length]; - _accessor.ReadArray((long)entry.Offset, bytes, 0, (int)entry.Length); - uint actualCrc = Crc32.Compute(bytes); - bool ok = actualCrc == entry.Crc32; - - _entryVerdictByTocIndex[tocIndex] = ok ? 1 : 0; - if (!ok) { - LogCorruptionOnce(tocIndex, $"crc mismatch (expected 0x{entry.Crc32:X8}, got 0x{actualCrc:X8})"); - } - return ok ? 1 : 0; - } - - private void LogCorruptionOnce(int tocIndex, string reason) { - if (!_loggedCorruption.TryAdd(tocIndex, true)) return; - ref readonly var entry = ref _toc[tocIndex]; - Console.Error.WriteLine( - $"[pak-corrupt] key 0x{entry.Key:X16} at offset {entry.Offset} (length {entry.Length}): " + - $"{reason} — treating as missing"); - } - - private int BinarySearch(ulong key) { - int lo = 0, hi = _toc.Length - 1; - while (lo <= hi) { - int mid = lo + (hi - lo) / 2; - ulong midKey = _toc[mid].Key; - if (midKey == key) return mid; - if (midKey < key) lo = mid + 1; - else hi = mid - 1; - } - return -1; - } - - public void Dispose() { - _accessor.Dispose(); - _mmf.Dispose(); - } -} diff --git a/src/AcDream.Content/Pak/PakWriter.cs b/src/AcDream.Content/Pak/PakWriter.cs deleted file mode 100644 index 2a745669..00000000 --- a/src/AcDream.Content/Pak/PakWriter.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; - -namespace AcDream.Content.Pak; - -/// -/// Streams pak blobs to disk: header placeholder -> 64-byte-aligned blobs -> -/// TOC (sorted by key) -> seek back and finalize the header once -/// tocOffset/tocCount are known. This lets the writer stream blobs without -/// knowing the final count up front (plan: "TOC last"). -/// -public sealed class PakWriter : IDisposable { - private const int Alignment = 64; - - private readonly FileStream _stream; - private readonly PakHeader _headerTemplate; - private readonly List _tocEntries = new(); - private readonly HashSet _seenKeys = new(); - private bool _finished; - - public PakWriter(string path, PakHeader headerTemplate) { - _stream = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None); - _headerTemplate = headerTemplate; - // The format version is the WRITER's identity, not caller input — - // stamp it unconditionally so a caller-populated header template can - // never produce a pak claiming a version this code doesn't write - // (the default-0 footgun; PakReader rejects any version but - // PakFormat.CurrentFormatVersion). - _headerTemplate.FormatVersion = PakFormat.CurrentFormatVersion; - - // Write a header placeholder now; finalized in Finish() once TocOffset/TocCount are known. - _headerTemplate.WriteTo(_stream); - PadToAlignment(); - } - - /// - /// Adds one blob (an serialized via - /// ) under . - /// Blobs are written immediately at the current (64-byte-aligned) stream - /// position; the writer pads after each blob so the NEXT blob also - /// starts aligned. - /// - public void AddBlob(ulong key, ObjectMeshData data) { - ThrowIfFinished(); - if (!_seenKeys.Add(key)) { - throw new ArgumentException($"duplicate pak key 0x{key:X16}", nameof(key)); - } - - long offset = _stream.Position; - using var ms = new MemoryStream(); - ObjectMeshDataSerializer.Write(data, ms); - var bytes = ms.ToArray(); - - _stream.Write(bytes); - PadToAlignment(); - - _tocEntries.Add(new PakTocEntry { - Key = key, - Offset = (ulong)offset, - Length = (uint)bytes.Length, - Crc32 = Content.Pak.Crc32.Compute(bytes), - }); - } - - /// Writes the sorted TOC and finalizes the header. Must be called exactly once. - public void Finish() { - ThrowIfFinished(); - _finished = true; - - var sorted = _tocEntries.OrderBy(e => e.Key).ToList(); - ulong tocOffset = (ulong)_stream.Position; - foreach (var entry in sorted) { - entry.WriteTo(_stream); - } - - var finalHeader = _headerTemplate; - finalHeader.TocOffset = tocOffset; - finalHeader.TocCount = (uint)sorted.Count; - - _stream.Position = 0; - finalHeader.WriteTo(_stream); - _stream.Flush(); - } - - private void PadToAlignment() { - long pos = _stream.Position; - long remainder = pos % Alignment; - if (remainder == 0) return; - long pad = Alignment - remainder; - Span zeros = stackalloc byte[(int)pad]; - zeros.Clear(); - _stream.Write(zeros); - } - - private void ThrowIfFinished() { - if (_finished) throw new InvalidOperationException("PakWriter.Finish() has already been called."); - } - - public void Dispose() { - // Best-effort finalize so a forgotten Finish() call doesn't leave a - // half-written file with a placeholder header claiming 0 entries - // while blobs are on disk. try/finally, NOT a bare sequence: Finish() - // performs real I/O (TOC write, header seek-back, flush) and can - // throw for environmental reasons (disk full, I/O error) — the - // stream must ALWAYS be closed so the file handle is never leaked - // mid-unwind (review finding 5 on the a5926ebc cleanup, which had - // removed exactly this guarantee). - try { - if (!_finished) { - Finish(); - } - } - finally { - _stream.Dispose(); - } - } -} diff --git a/src/AcDream.Content/TextureKey.cs b/src/AcDream.Content/TextureKey.cs deleted file mode 100644 index 3fe41827..00000000 --- a/src/AcDream.Content/TextureKey.cs +++ /dev/null @@ -1,30 +0,0 @@ -using DatReaderWriter.Enums; -using System; - -namespace AcDream.Content; - -// MP1a (2026-07-05): lifted verbatim out of TextureAtlasManager (GL class) -// so the GL-free mesh-extraction path (MeshExtractor / ObjectMeshData) can -// reference the atlas dedup key without a Silk.NET dependency. Field set, -// equality semantics, and hash behavior are UNCHANGED. -public struct TextureKey : IEquatable { - public uint SurfaceId; - public uint PaletteId; - public StipplingType Stippling; - public bool IsSolid; - - public bool Equals(TextureKey other) { - return SurfaceId == other.SurfaceId && - PaletteId == other.PaletteId && - Stippling == other.Stippling && - IsSolid == other.IsSolid; - } - - public override bool Equals(object? obj) { - return obj is TextureKey other && Equals(other); - } - - public override int GetHashCode() { - return HashCode.Combine(SurfaceId, PaletteId, Stippling, IsSolid); - } -} diff --git a/src/AcDream.Content/UploadFormats.cs b/src/AcDream.Content/UploadFormats.cs deleted file mode 100644 index b0f63612..00000000 --- a/src/AcDream.Content/UploadFormats.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace AcDream.Content; - -// MP1a follow-up (2026-07-05): Content-owned upload-format hint enums. -// AcDream.Content must stay Silk.NET-free (the MP1b bake tool must not ship -// GL binaries), so the moved MeshBatchData/TextureBatchData records carry -// these instead of Silk.NET.OpenGL.PixelFormat/PixelType. The underlying -// values are the OpenGL ABI constants, kept numerically identical to the -// corresponding Silk.NET.OpenGL members (verified against Silk.NET.OpenGL -// 2.23.0) so the App-boundary cast -// `(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` is -// value-preserving. GL enum constants are a stable specification ABI — -// they cannot drift between Silk.NET versions. -// -// Members are ONLY the values the extraction code actually assigns -// (see MeshExtractor.cs). Add new members with their GL constant if a -// future decode path needs them — never renumber. - -/// -/// GL pixel-format upload hint computed at extraction time. -/// Numeric values = OpenGL GL_* constants (= Silk.NET.OpenGL.PixelFormat). -/// -public enum UploadPixelFormat { - /// GL_RGBA. - Rgba = 0x1908, -} - -/// -/// GL pixel-type upload hint computed at extraction time. -/// Numeric values = OpenGL GL_* constants (= Silk.NET.OpenGL.PixelType). -/// -public enum UploadPixelType { - /// GL_UNSIGNED_BYTE. - UnsignedByte = 0x1401, -} diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index df487c77..c5f61e32 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -11,7 +11,7 @@ namespace AcDream.Core.Net; /// /// Central registration point that wires every parsed GameEvent from /// into the appropriate Core state -/// class (, , +/// class (, , /// , ). /// /// @@ -32,7 +32,7 @@ public static class GameEventWiring { public static void WireAll( GameEventDispatcher dispatcher, - ClientObjectTable items, + ItemRepository items, CombatState combat, Spellbook spellbook, ChatLog chat, @@ -61,14 +61,7 @@ public static class GameEventWiring // (matching ACE's CreatureSkill.Current minus // augs/multipliers/vitae which we still don't model). Action? onSkillsUpdated = null, - Func /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null, - // D.5.1 Task 4: persists Shortcuts from each PlayerDescription so the - // toolbar can populate itself at login without keeping a parser reference. - // Optional so all existing callers and tests compile unchanged. - Action>? onShortcuts = null, - // B-Wire: the local player's server guid. When provided, the PD handler upserts - // the player's own PropertyBundle (EncumbranceVal etc.) into the player ClientObject. - Func? playerGuid = null) + Func /*attrCurrents*/, uint /*formulaBonus*/>? resolveSkillFormulaBonus = null) { ArgumentNullException.ThrowIfNull(dispatcher); ArgumentNullException.ThrowIfNull(items); @@ -238,71 +231,23 @@ public static class GameEventWiring dispatcher.Register(GameEventType.WieldObject, e => { var p = GameEvents.ParseWieldObject(e.Payload.Span); - if (p is null) return; - items.MoveItem( + if (p is not null) items.MoveItem( p.Value.ItemGuid, newContainerId: p.Value.WielderGuid, newEquipLocation: (AcDream.Core.Items.EquipMask)p.Value.EquipLoc); - items.ConfirmMove(p.Value.ItemGuid); // Slice 1: confirm an optimistic wield (mirrors the 0x0022 handler) }); dispatcher.Register(GameEventType.InventoryPutObjInContainer, e => { var p = GameEvents.ParsePutObjInContainer(e.Payload.Span); - if (p is null) return; - items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, newSlot: (int)p.Value.Placement); - items.ConfirmMove(p.Value.ItemGuid); // B-Drag: the server confirmed our optimistic move + if (p is not null) items.MoveItem(p.Value.ItemGuid, p.Value.ContainerGuid, + newSlot: (int)p.Value.Placement); }); - - // ViewContents (0x0196) — the server's AUTHORITATIVE full contents list for a container you - // opened (Use 0x0036). Treat it as a full REPLACE: flush the container's prior membership and - // record the new entries in order (ContainerSlot = index — ACE writes entries - // OrderBy(PlacementPosition) with no slot field). The container-open UI (InventoryController) - // repaints the grid off the resulting ObjectAdded/Moved events. Retail: ClientUISystem::OnViewContents. - dispatcher.Register(GameEventType.ViewContents, e => - { - var p = GameEvents.ParseViewContents(e.Payload.Span); - if (p is null) return; - var guids = new uint[p.Value.Items.Count]; - for (int i = 0; i < guids.Length; i++) guids[i] = p.Value.Items[i].Guid; - items.ReplaceContents(p.Value.ContainerGuid, guids); - }); - - // B-Wire: InventoryPutObjectIn3D (0x019A) — server confirms an item dropped - // to the world. Unparent it from its container (it's now a ground object) so - // the inventory grid drops the cell; the object itself survives. - dispatcher.Register(GameEventType.InventoryPutObjectIn3D, e => - { - var guid = GameEvents.ParsePutObjectIn3D(e.Payload.Span); - if (guid is not null) items.MoveItem(guid.Value, newContainerId: 0u); - }); - - // B-Drag: InventoryServerSaveFailed (0x00A0) — server rejected an optimistic move. - // Snap the item back to its pre-move slot. Log only when there was no pending move - // (a server-initiated failure on a non-optimistic path). - dispatcher.Register(GameEventType.InventoryServerSaveFailed, e => - { - var p = GameEvents.ParseInventoryServerSaveFailed(e.Payload.Span); - if (p is null) return; - // B-Drag: the server rejected an optimistic move — snap the item back to its pre-move slot. - if (!items.RollbackMove(p.Value.ItemGuid)) - Console.WriteLine($"[B-Drag] InventoryServerSaveFailed guid=0x{p.Value.ItemGuid:X8} err=0x{p.Value.WeenieError:X} (no pending move)"); - }); - - // B-Wire: CloseGroundContainer (0x0052) — server closed a ground-container - // view. No table change (the view is UI-only, wired in container-open); log. - dispatcher.Register(GameEventType.CloseGroundContainer, e => - { - var guid = GameEvents.ParseCloseGroundContainer(e.Payload.Span); - if (guid is not null) - Console.WriteLine($"[B-Wire] CloseGroundContainer guid=0x{guid.Value:X8}"); - }); - dispatcher.Register(GameEventType.IdentifyObjectResponse, e => { var p = AppraiseInfoParser.TryParse(e.Payload.Span); if (p is null || !p.Value.Success) return; // Merge parsed properties into the item if we know about it. - if (items.Get(p.Value.Guid) is not null) + if (items.GetItem(p.Value.Guid) is not null) items.UpdateProperties(p.Value.Guid, p.Value.Properties); // Spellbook from appraise: for caster items / scrolls this is // the cast-on-use list. The local player's full learned @@ -340,13 +285,6 @@ public static class GameEventWiring Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}"); if (p is null) return; - // B-Wire: deliver the player's OWN properties to the player ClientObject. - // (PD's "membership manifest" rule is about ITEMS, whose data comes from - // CreateObject; the player's own stats legitimately come from PD.) Upsert - // because PD can arrive before the player's CreateObject. Retires AP-48/AP-49. - if (playerGuid is not null) - items.UpsertProperties(playerGuid(), p.Value.Properties); - // K-fix13 (2026-04-26): build attrId → current map while // iterating attributes so the skill-formula resolver below // can apply (attr1.current * mult1 + attr2.current * mult2) @@ -458,19 +396,40 @@ public static class GameEventWiring Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}"); } - // D.5.4: PlayerDescription is a membership MANIFEST, not the data - // source. Record existence (+ equip slot); CreateObject fills the - // actual weenie data via ObjectTableWiring. (Previously this seeded - // stubs with WeenieClassId = ContainerType, a misuse — ContainerType - // is a 0/1/2 container-kind discriminator, not a weenie class id.) + // Issue #13 — register inventory entries with ItemRepository so + // panels (inventory, paperdoll, hotbars) light up after login. + // Equipped entries share the same ObjectId as inventory entries + // (an equipped item is also in inventory) — register both, but + // the equipped record carries the slot mask which we surface via + // MoveItem so paperdoll can render. foreach (var inv in p.Value.Inventory) - items.RecordMembership(inv.Guid); + { + if (items.GetItem(inv.Guid) is null) + { + items.AddOrUpdate(new ItemInstance + { + ObjectId = inv.Guid, + WeenieClassId = inv.ContainerType, + }); + } + } foreach (var eq in p.Value.Equipped) - items.RecordMembership(eq.Guid, equip: (EquipMask)eq.EquipLocation); - - // D.5.1 Task 4: forward shortcut bar entries to the caller so the - // toolbar can read them without holding a parser reference. - onShortcuts?.Invoke(p.Value.Shortcuts); + { + if (items.GetItem(eq.Guid) is null) + { + items.AddOrUpdate(new ItemInstance + { + ObjectId = eq.Guid, + WeenieClassId = 0, + }); + } + // Reflect the equip slot — paperdoll uses CurrentlyEquippedLocation. + items.MoveItem( + itemId: eq.Guid, + newContainerId: 0, + newSlot: -1, + newEquipLocation: (EquipMask)eq.EquipLocation); + } }); } } diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs index 25409ca6..48b678d3 100644 --- a/src/AcDream.Core.Net/Messages/CreateObject.cs +++ b/src/AcDream.Core.Net/Messages/CreateObject.cs @@ -119,9 +119,6 @@ public static class CreateObject ushort TeleportSequence = 0, ushort ServerControlSequence = 0, ushort ForcePositionSequence = 0, - // L.2g S1 (DEV-6): ObjectMovement stamp (timestamp block index 1) - // seeds MotionSequenceGate's MOVEMENT_TS at spawn. - ushort MovementSequence = 0, uint? PhysicsState = null, uint? ObjectDescriptionFlags = null, // L.3b (2026-04-30): per-object friction + elasticity from the @@ -130,12 +127,6 @@ public static class CreateObject // defaults (0.05f elasticity, 0.5f friction). float? Friction = null, float? Elasticity = null, - // D.5.1 (2026-06-16): icon dat id (0x06xxxxxx) from the WeenieHeader - // fixed prefix. Previously discarded at cs:516; surfaced so the action - // bar / equipment UI can display the correct icon sprite without a - // separate dat lookup. Zero means "not sent" (packed zero sentinel in - // ReadPackedDwordOfKnownType preserves 0 as-is). - uint IconId = 0, // 2026-05-15: optional WeenieHeader tail. The retail // `ITEM_USEABLE _useability` (acclient.h:6478) — gates whether the // R-key Use action does anything. (Useability & USEABLE_REMOTE @@ -148,45 +139,7 @@ public static class CreateObject // a sizing hint for selection indicators on entities that // publish it. uint? Useability = null, - float? UseRadius = null, - // D.5.1 (2026-06-17): icon overlay/underlay dat ids from the - // WeenieHeader optional tail. IconOverlayId is gated by - // WeenieHeaderFlag.IconOverlay (0x40000000) in weenieFlags; - // IconUnderlayId is gated by WeenieHeaderFlag2.IconUnderlay (0x01) - // in weenieFlags2 (present when objDescFlags bit 0x04000000 is set). - // Sourced from ACE WorldObject_Networking.cs:202-206. Zero when - // the server did not send the field (most entities have neither). - // IconComposer.GetIcon already composites these layers in the correct - // retail order (underlay / base / overlay+tint / effect). - uint IconOverlayId = 0, - uint IconUnderlayId = 0, - // D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's - // effect recolor (Magical=0x1 … Nether=0x1000). The ONLY wire path for the effect - // state (PropertyInt.UiEffects=18 has no [AssessmentProperty] → not in appraise). - // Previously read + discarded at the UiEffects skip. 0 = no effect. - uint UiEffects = 0, - // D.5.4 (2026-06-18): full item field set from the WeenieHeader tail — - // previously walked-past. Wire bits per r06 §4 / PublicWeenieDesc. - // Quantity fields are int? to match ClientObject storage (ACE PropertyInt - // convention; the wire ushort/byte values widen losslessly); id/mask - // fields are uint?. null = the gated flag was absent (don't clobber on - // merge). WeenieClassId is the fixed-prefix class id (was discarded at - // cs:538); it is non-nullable — 0 means the prefix was absent/zero. - uint WeenieClassId = 0, - int? Value = null, - int? StackSize = null, - int? StackSizeMax = null, - int? Burden = null, - int? ItemsCapacity = null, - int? ContainersCapacity = null, - uint? ContainerId = null, - uint? WielderId = null, - uint? ValidLocations = null, - uint? CurrentWieldedLocation = null, - uint? Priority = null, - int? Structure = null, - int? MaxStructure = null, - float? Workmanship = null); + float? UseRadius = null); /// /// The relevant subset of the server-sent MovementData / @@ -217,21 +170,6 @@ public static class CreateObject /// 0x40=TurnSpeed. /// /// - /// - /// R4-V3 deliverable D — the trailing f32 runRate on MoveToObject - /// (6) / MoveToPosition (7) payloads. Retail's unpack_movement - /// writes this straight onto CMotionInterp::my_run_rate - /// (r4-moveto-decomp.md §2f: this->motion_interpreter->my_run_rate = - /// read_float(), both @300603 case 6 and @300660 case 7 — SAME - /// write for both types, immediately after UnPackNet). Today this - /// field only seeds PlanMoveToStart's local heuristic (plan M13); - /// the interp's actual - /// field is a SEPARATE consumer write the V4/V5 MoveToManager cutover - /// performs at the GameWindow mt 6-9 routing site (r4-port-plan.md §4, - /// step 2: Motion.MyRunRate = MoveToRunRate) — this record is - /// wire-primitive only; it does not write MotionInterpreter state - /// itself. - /// public readonly record struct ServerMotionState( ushort Stance, ushort? ForwardCommand, @@ -245,30 +183,7 @@ public static class CreateObject uint? MoveToParameters = null, float? MoveToSpeed = null, float? MoveToRunRate = null, - MoveToPathData? MoveToPath = null, - // R4-V3 (closes M7): movement types 8 (TurnToObject) and 9 - // (TurnToHeading) — previously dropped end-to-end (UpdateMotion.cs - // only branched on `movementType is 6 or 7`). Carries the DECODED - // wire payload (guid + standalone wire_heading for type 8, plus the - // shared 3-dword UnPackNet triple for both types) per V0-pins.md P6. - TurnToPathData? TurnToPath = null, - // R4-V3 (closes M14-wire-note): the 0xF74C motionFlags sticky-guid - // trailer, mt=0 (Invalid) only — ACE MovementInvalid.Write gates the - // trailing guid on MotionFlags.StickToObject (0x1); the decomp's - // `unpack_movement` case 0 reads it right after - // InterpretedMotionState::UnPack (r4-moveto-decomp.md §2f - // @0052455d: `if (header & 0x100) sticky_object_guid = read_dword()` - // — bit 0x100 of the combined header word is motionFlags byte1&0x1). - // R5-V4 consumes it: the GameWindow mt-0 tail routes it into - // CPhysicsObj::stick_to_object's port (target PartArray radii → - // PositionManager.StickTo — decomp 0x005127e0, call @00524589). - uint? StickyObjectGuid = null, - // R5-V4 (closes the "documented but NOT consumed" note in - // UpdateMotion.cs): motionFlags & 0x2 — retail `unpack_movement` - // case 0 writes it onto `motion_interpreter->standing_longjump` - // UNCONDITIONALLY (@0052458e: absent flag CLEARS it). Consumed at - // the GameWindow mt-0 tails (remote + player). - bool StandingLongJump = false) + MoveToPathData? MoveToPath = null) { /// /// ACE/retail movement types 6 and 7 are server-controlled @@ -278,13 +193,6 @@ public static class CreateObject /// public bool IsServerControlledMoveTo => MovementType is 6 or 7; - /// - /// R4-V3: movement types 8 (TurnToObject) and 9 (TurnToHeading) — - /// the turn-only sibling of . - /// Neither carries an InterpretedMotionState.ForwardCommand either. - /// - public bool IsServerControlledTurnTo => MovementType is 8 or 9; - public bool MoveToCanRun => !MoveToParameters.HasValue || (MoveToParameters.Value & 0x2u) != 0; @@ -344,51 +252,6 @@ public static class CreateObject float MinDistance, float FailDistance, float WalkRunThreshold, - float DesiredHeading, - uint Bitfield = 0); // R4-V4: the raw UnPackNet flags dword, feeds MovementParameters.FromWire - - /// - /// R4-V3 (closes M7) — path-control payload of a server-controlled - /// TurnTo packet (movementType 8 TurnToObject or 9 TurnToHeading). - /// Sibling of : kept as a SEPARATE record - /// rather than widening MoveToPathData in place, because the two - /// wire forms genuinely diverge (7-dword UnPackNet with an - /// Origin+optional-guid head for move types vs. the 3-dword - /// UnPackNet with a guid+standalone-heading head for turn types — - /// V0-pins.md P6) and a single record would need every move-only field - /// nullable for turn payloads (and vice versa) for no reader benefit — - /// no code path ever needs "either a move or a turn path" polymorphically, - /// every consumer already switches on - /// first. - /// - /// - /// type 8 (TurnToObject) only: u32 TargetGuid, f32 - /// WireHeading — the STANDALONE heading field (ACE - /// TurnToObject.DesiredHeading, distinct from - /// below despite ACE always populating - /// both from the same source; V0-pins.md P6's fixture caveat: never - /// distinguish the two fields by value in a test, only by - /// OFFSET). Consumed ONLY in retail's unresolvable-object fallback - /// (decomp §2f case 8: if (GetObjectA(object_id) == 0) { - /// params.desired_heading = wire_heading; goto TurnToHeading; }) - /// — the resolved-object path never reads it. - /// TurnToParameters (0xc bytes, exact retail order — - /// MovementParameters::UnPackNet 3-dword TurnTo form, decomp - /// §2g): u32 Bitfield, f32 Speed, f32 - /// DesiredHeading. Present for BOTH type 8 and type 9 (type 9 - /// has no guid/WireHeading head — and - /// are null). - /// - /// - /// Feeds - /// at the (future) App-layer consumer — this record stays wire-primitive - /// (no domain-object construction in the Net-layer parser). - /// - public readonly record struct TurnToPathData( - uint? TargetGuid, - float? WireHeading, - uint Bitfield, - float Speed, float DesiredHeading); /// @@ -624,13 +487,11 @@ public static class CreateObject if ((physicsFlags & PhysicsDescriptionFlag.DefaultScriptIntensity) != 0) pos += 4; // 9 sequence timestamps, always present at end of PhysicsData. - // PhysicsTimeStamp enum order (acclient.h:6084; ACE - // WorldObject_Networking.cs:411-420): 0=position, 1=movement, - // 4=teleport, 5=serverControl, 6=forcePosition, 8=instance. + // Indices per holtburger: 0=position, 4=teleport, 5=serverControl, + // 6=forcePosition, 8=instance. if (body.Length - pos < 9 * 2) return PartialResult(); var seqSpan = body.Slice(pos, 9 * 2); ushort instanceSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(8 * 2)); - ushort movementSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(1 * 2)); ushort teleportSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(4 * 2)); ushort serverControlSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(5 * 2)); ushort forcePositionSeq = BinaryPrimitives.ReadUInt16LittleEndian(seqSpan.Slice(6 * 2)); @@ -645,30 +506,14 @@ public static class CreateObject string? name = null; uint? itemType = null; uint weenieFlags = 0; - uint iconId = 0; - uint weenieClassId = 0; - int? wValue = null; - int? wStackSize = null; - int? wMaxStackSize = null; - int? wBurden = null; - int? wItemsCapacity = null; - int? wContainersCapacity = null; - uint? wContainerId = null; - uint? wWielderId = null; - uint? wValidLocations = null; - uint? wCurrentWieldedLocation = null; - uint? wPriority = null; - int? wStructure = null; - int? wMaxStructure = null; - float? wWorkmanship = null; if (body.Length - pos >= 4) { weenieFlags = ReadU32(body, ref pos); try { name = ReadString16L(body, ref pos); - weenieClassId = ReadPackedDword(body, ref pos); // WeenieClassId (D.5.4: was discarded) - iconId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); + _ = ReadPackedDword(body, ref pos); // WeenieClassId + _ = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); if (body.Length - pos >= 4) itemType = ReadU32(body, ref pos); if (body.Length - pos >= 4) @@ -687,63 +532,31 @@ public static class CreateObject catch { /* truncated name — partial result is still useful */ } } - // --- WeenieHeader optional tail: walk every conditional field - // in EXACT ACE write order (WorldObject_Networking.cs:87-219) - // so the cursor reaches IconOverlay + IconUnderlay. + // --- WeenieHeader optional tail (2026-05-15): walk the + // conditional fields up through Useability + UseRadius. // - // We MUST skip every field that precedes IconOverlay even when - // we don't need its value — each one occupies bytes on the wire - // and a cursor error here would desync ALL downstream optional - // reads for the rest of this entity's packet. + // Wire order is fixed by ACE WorldObject_Networking.cs:87-114 + // and matches retail PWD::Pack order. We MUST skip every + // preceding optional field (even those we don't care about) + // because each one moves the parse cursor. // - // Wire order (verified against ACE WorldObject_Networking.cs): - // bit field width - // --------- ------------------ ----- - // 0x04000000 (objDescFlags) weenieFlags2 u32 (skip) - // 0x00000001 PluralName String16L (skip) - // 0x00000002 ItemsCapacity u8 (skip) - // 0x00000004 ContainersCapacity u8 (skip) - // 0x00000100 AmmoType u16 (skip) - // 0x00000008 Value u32 (skip) - // 0x00000010 Usable u32 KEPT - // 0x00000020 UseRadius f32 KEPT - // 0x00080000 TargetType u32 (skip) - // 0x00000080 UiEffects u32 CAPTURE (D.5.2) - // 0x00000200 CombatUse sbyte/1 byte (skip) - // 0x00000400 Structure u16 (skip) - // 0x00000800 MaxStructure u16 (skip) - // 0x00001000 StackSize u16 (skip) - // 0x00002000 MaxStackSize u16 (skip) - // 0x00004000 Container u32 (skip) - // 0x00008000 Wielder u32 (skip) - // 0x00010000 ValidLocations u32 (skip) - // 0x00020000 CurrentlyWieldedLocation u32 (skip) - // 0x00040000 Priority u32 (skip) - // 0x00100000 RadarBlipColor u8 (skip) - // 0x00800000 RadarBehavior u8 (skip) - // 0x08000000 PScript u16 (skip) - // 0x01000000 Workmanship f32 (skip) - // 0x00200000 Burden u16 (skip) - // 0x00400000 Spell u16 (skip) - // 0x02000000 HouseOwner u32 (skip) - // 0x04000000 HouseRestrictions RestrictionDB (skip, variable-length) - // 0x20000000 HookItemTypes u32 (skip) - // 0x00000040 Monarch u32 (skip) - // 0x10000000 HookType u16 (skip) - // 0x40000000 IconOverlay PackedDwordKnownType(0x06000000) CAPTURE - // weenieFlags2 bit 0x01: - // IconUnderlay PackedDwordKnownType(0x06000000) CAPTURE + // Field bit width decoded? + // ------- ------ -------- -------- + // weenieFlags2 conditional on objDescFlags & 0x80000000 (BF_INCLUDES_SECOND_HEADER) + // u32 skipped + // PluralName 0x1 String16L (variable, padded to 4) skipped + // ItemCapacity 0x2 1 byte skipped + // ContainerCap 0x4 1 byte skipped + // AmmoType 0x100 u16 skipped + // Value 0x8 u32 skipped + // Useability 0x10 u32 KEPT + // UseRadius 0x20 f32 KEPT // - // The entire walk is inside try/catch. A truncated packet degrades - // gracefully: whatever was parsed before the throw is kept, and - // IconOverlayId/IconUnderlayId stay 0 (no overlay drawn). This is - // SAFE because IconComposer early-returns on id==0 per layer. + // Wrapped in try/catch — if a malformed entity truncates the + // tail we still return the prefix fields. Most spawned entities + // either have all of these or none of them. uint? useability = null; float? useRadius = null; - uint iconOverlayId = 0; - uint iconUnderlayId = 0; - uint uiEffects = 0; - uint weenieFlags2 = 0; try { // BF_INCLUDES_SECOND_HEADER = 0x04000000 per acclient.h:6458 @@ -751,28 +564,23 @@ public static class CreateObject // Earlier code had this as 0x80000000 — wrong bit, so the // weenieFlags2 4-byte skip never fired for entities that // actually had it set, corrupting downstream optional-tail - // offsets. Now correct. We CAPTURE weenieFlags2 now (instead - // of skipping) so we can gate IconUnderlay from bit 0x01. + // offsets. Now correct. bool hasSecondHeader = objectDescriptionFlags.HasValue && (objectDescriptionFlags.Value & 0x04000000u) != 0; - if (hasSecondHeader) - { - if (body.Length - pos < 4) throw new FormatException("trunc weenieFlags2"); - weenieFlags2 = ReadU32(body, ref pos); - } + if (hasSecondHeader && body.Length - pos >= 4) pos += 4; // weenieFlags2 if ((weenieFlags & 0x00000001u) != 0) // PluralName _ = ReadString16L(body, ref pos); - if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity u8 + if ((weenieFlags & 0x00000002u) != 0) // ItemCapacity { if (body.Length - pos < 1) throw new FormatException("trunc ItemCap"); - wItemsCapacity = body[pos]; pos += 1; + pos += 1; } - if ((weenieFlags & 0x00000004u) != 0) // ContainersCapacity u8 + if ((weenieFlags & 0x00000004u) != 0) // ContainerCapacity { if (body.Length - pos < 1) throw new FormatException("trunc ContCap"); - wContainersCapacity = body[pos]; pos += 1; + pos += 1; } if ((weenieFlags & 0x00000100u) != 0) // AmmoType u16 { @@ -782,9 +590,9 @@ public static class CreateObject if ((weenieFlags & 0x00000008u) != 0) // Value u32 { if (body.Length - pos < 4) throw new FormatException("trunc Value"); - wValue = (int)ReadU32(body, ref pos); + pos += 4; } - if ((weenieFlags & 0x00000010u) != 0) // Usable u32 ← KEEP + if ((weenieFlags & 0x00000010u) != 0) // Useability u32 ← KEEP { if (body.Length - pos < 4) throw new FormatException("trunc Useability"); useability = ReadU32(body, ref pos); @@ -795,167 +603,15 @@ public static class CreateObject useRadius = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4; } - - // ---- Extended walk: fields after UseRadius through IconOverlay ---- - // Source: ACE WorldObject_Networking.cs:108-206 (verified 2026-06-17). - - if ((weenieFlags & 0x00080000u) != 0) // TargetType u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc TargetType"); - pos += 4; - } - if ((weenieFlags & 0x00000080u) != 0) // UiEffects u32 ← CAPTURE - { - if (body.Length - pos < 4) throw new FormatException("trunc UiEffects"); - uiEffects = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00000200u) != 0) // CombatUse sbyte (1 byte) - { - if (body.Length - pos < 1) throw new FormatException("trunc CombatUse"); - pos += 1; - } - if ((weenieFlags & 0x00000400u) != 0) // Structure u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc Structure"); - wStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; - } - if ((weenieFlags & 0x00000800u) != 0) // MaxStructure u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc MaxStructure"); - wMaxStructure = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; - } - if ((weenieFlags & 0x00001000u) != 0) // StackSize u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc StackSize"); - wStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; - } - if ((weenieFlags & 0x00002000u) != 0) // MaxStackSize u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc MaxStackSize"); - wMaxStackSize = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; - } - if ((weenieFlags & 0x00004000u) != 0) // Container u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Container"); - wContainerId = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00008000u) != 0) // Wielder u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Wielder"); - wWielderId = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00010000u) != 0) // ValidLocations u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc ValidLocations"); - wValidLocations = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00020000u) != 0) // CurrentlyWieldedLocation u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc CurrentlyWieldedLocation"); - wCurrentWieldedLocation = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00040000u) != 0) // Priority u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Priority"); - wPriority = ReadU32(body, ref pos); - } - if ((weenieFlags & 0x00100000u) != 0) // RadarBlipColor u8 - { - if (body.Length - pos < 1) throw new FormatException("trunc RadarBlipColor"); - pos += 1; - } - if ((weenieFlags & 0x00800000u) != 0) // RadarBehavior u8 - { - if (body.Length - pos < 1) throw new FormatException("trunc RadarBehavior"); - pos += 1; - } - if ((weenieFlags & 0x08000000u) != 0) // PScript u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc PScript"); - pos += 2; - } - if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Workmanship"); - wWorkmanship = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); pos += 4; - } - if ((weenieFlags & 0x00200000u) != 0) // Burden u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc Burden"); - wBurden = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; - } - if ((weenieFlags & 0x00400000u) != 0) // Spell u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc Spell"); - pos += 2; - } - if ((weenieFlags & 0x02000000u) != 0) // HouseOwner u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc HouseOwner"); - pos += 4; - } - if ((weenieFlags & 0x04000000u) != 0) // HouseRestrictions (RestrictionDB) - { - // Wire layout per ACE RestrictionDB + RestrictionDBExtensions.Write: - // u32 Version, u32 OpenStatus, u32 MonarchId, - // u16 count, u16 numBuckets, then count × (u32 guid + u32 value). - // Fixed header = 12 bytes; PackableHashTable header = 4 bytes. - // Total = 16 + count * 8. - if (body.Length - pos < 16) throw new FormatException("trunc RestrictionDB header"); - // Version(4) + OpenStatus(4) + MonarchId(4) = 12 bytes - pos += 12; - ushort tableCount = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); - pos += 2; // count u16 - pos += 2; // numBuckets u16 - int entryBytes = tableCount * 8; // each entry: u32 guid + u32 value - if (body.Length - pos < entryBytes) throw new FormatException("trunc RestrictionDB entries"); - pos += entryBytes; - } - if ((weenieFlags & 0x20000000u) != 0) // HookItemTypes u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc HookItemTypes"); - pos += 4; - } - if ((weenieFlags & 0x00000040u) != 0) // Monarch u32 - { - if (body.Length - pos < 4) throw new FormatException("trunc Monarch"); - pos += 4; - } - if ((weenieFlags & 0x10000000u) != 0) // HookType u16 - { - if (body.Length - pos < 2) throw new FormatException("trunc HookType"); - pos += 2; - } - if ((weenieFlags & 0x40000000u) != 0) // IconOverlay PackedDwordOfKnownType(0x06000000) ← CAPTURE - { - iconOverlayId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); - } - // IconUnderlay is gated by weenieFlags2 bit 0x01, not weenieFlags. - // weenieFlags2 is only present when hasSecondHeader (captured above). - if ((weenieFlags2 & 0x00000001u) != 0) // IconUnderlay PackedDwordOfKnownType(0x06000000) ← CAPTURE - { - iconUnderlayId = ReadPackedDwordOfKnownType(body, ref pos, IconTypePrefix); - } } catch { /* truncated weenie tail — keep whatever we got. */ } return new Parsed(guid, position, setupTableId, animParts, textureChanges, subPalettes, basePaletteId, objScale, name, itemType, motionState, motionTableId, instanceSeq, teleportSeq, serverControlSeq, forcePositionSeq, - movementSeq, physicsState, objectDescriptionFlags, friction, elasticity, - IconId: iconId, - Useability: useability, UseRadius: useRadius, - IconOverlayId: iconOverlayId, IconUnderlayId: iconUnderlayId, - UiEffects: uiEffects, - WeenieClassId: weenieClassId, - Value: wValue, StackSize: wStackSize, StackSizeMax: wMaxStackSize, - Burden: wBurden, ItemsCapacity: wItemsCapacity, ContainersCapacity: wContainersCapacity, - ContainerId: wContainerId, WielderId: wWielderId, - ValidLocations: wValidLocations, CurrentWieldedLocation: wCurrentWieldedLocation, - Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure, - Workmanship: wWorkmanship); + useability, useRadius); // Local helper: if we ran out of fields past PhysicsData, still // return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion). diff --git a/src/AcDream.Core.Net/Messages/DeleteObject.cs b/src/AcDream.Core.Net/Messages/DeleteObject.cs index fc88f28f..c18bb139 100644 --- a/src/AcDream.Core.Net/Messages/DeleteObject.cs +++ b/src/AcDream.Core.Net/Messages/DeleteObject.cs @@ -17,23 +17,11 @@ public static class DeleteObject { public const uint Opcode = 0xF747u; - /// - /// when this delete was sourced from a - /// PickupEvent (0xF74A) — the object left the 3-D world view but - /// the weenie record still exists (it is moving into a container). - /// (default) when sourced from DeleteObject - /// (0xF747) — the weenie was destroyed and must be evicted from - /// . - /// Retail two-table model: PickupEvent removes from object_table; - /// DeleteObject removes from weenie_object_table. - /// - public readonly record struct Parsed(uint Guid, ushort InstanceSequence, bool FromPickup = false); + public readonly record struct Parsed(uint Guid, ushort InstanceSequence); /// /// Parse a 0xF747 body. must start with the /// 4-byte opcode, matching every other parser in this namespace. - /// The returned is always - /// — this parser handles true destroys only. /// public static Parsed? TryParse(ReadOnlySpan body) { @@ -46,6 +34,6 @@ public static class DeleteObject uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(4, 4)); ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(8, 2)); - return new Parsed(guid, instanceSequence, FromPickup: false); + return new Parsed(guid, instanceSequence); } } diff --git a/src/AcDream.Core.Net/Messages/GameEvents.cs b/src/AcDream.Core.Net/Messages/GameEvents.cs index 71c7a4f5..d9131628 100644 --- a/src/AcDream.Core.Net/Messages/GameEvents.cs +++ b/src/AcDream.Core.Net/Messages/GameEvents.cs @@ -343,47 +343,19 @@ public static class GameEvents BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))); } - /// 0x0022 InventoryPutObjInContainer: server puts item into container slot. - /// 4 fields (ACE GameEventItemServerSaysContainId.cs): itemGuid, containerGuid, - /// placement, containerType. ContainerType (0=item,1=container,2=foci) confirmed - /// vs holtburger events.rs fixture (slot=3 type=1). + /// 0x0022 InventoryPutObjInContainer: server puts item into container slot. public readonly record struct InventoryPutObjInContainer( uint ItemGuid, uint ContainerGuid, - uint Placement, - uint ContainerType); + uint Placement); public static InventoryPutObjInContainer? ParsePutObjInContainer(ReadOnlySpan payload) { - if (payload.Length < 16) return null; + if (payload.Length < 12) return null; return new InventoryPutObjInContainer( BinaryPrimitives.ReadUInt32LittleEndian(payload), BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)), - BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8)), - BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(12))); - } - - /// 0x0196 ViewContents: full contents list of a container you opened. - /// Layout (ACE GameEventViewContents.cs): containerGuid, count, [guid, containerType]×count. - /// Client consumer: ClientUISystem::OnViewContents (PackableList<ContentProfile>). - public readonly record struct ViewContentsEntry(uint Guid, uint ContainerType); - public readonly record struct ViewContents(uint ContainerGuid, System.Collections.Generic.IReadOnlyList Items); - - public static ViewContents? ParseViewContents(ReadOnlySpan payload) - { - if (payload.Length < 8) return null; - uint containerGuid = BinaryPrimitives.ReadUInt32LittleEndian(payload); - uint count = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4)); - int pos = 8; - if ((long)payload.Length - pos < (long)count * 8) return null; - var items = new ViewContentsEntry[count]; - for (int i = 0; i < count; i++) - { - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - uint type = BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(pos)); pos += 4; - items[i] = new ViewContentsEntry(guid, type); - } - return new ViewContents(containerGuid, items); + BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(8))); } // ── Other small-payload events ────────────────────────────────────────── @@ -402,17 +374,11 @@ public static class GameEvents return BinaryPrimitives.ReadUInt32LittleEndian(payload); } - /// 0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op. - /// (itemGuid, weenieError) — ACE GameEventInventoryServerSaveFailed.cs; holtburger - /// events.rs:147 reads both fields. - public readonly record struct InventoryServerSaveFailed(uint ItemGuid, uint WeenieError); - - public static InventoryServerSaveFailed? ParseInventoryServerSaveFailed(ReadOnlySpan payload) + /// 0x00A0 InventoryServerSaveFailed: revert a speculative local inventory op. + public static uint? ParseInventoryServerSaveFailed(ReadOnlySpan payload) { - if (payload.Length < 8) return null; - return new InventoryServerSaveFailed( - BinaryPrimitives.ReadUInt32LittleEndian(payload), - BinaryPrimitives.ReadUInt32LittleEndian(payload.Slice(4))); + if (payload.Length < 4) return null; + return BinaryPrimitives.ReadUInt32LittleEndian(payload); } /// 0x0052 CloseGroundContainer: server closed a ground container view. diff --git a/src/AcDream.Core.Net/Messages/InventoryActions.cs b/src/AcDream.Core.Net/Messages/InventoryActions.cs index cebe9a82..f46bd5b6 100644 --- a/src/AcDream.Core.Net/Messages/InventoryActions.cs +++ b/src/AcDream.Core.Net/Messages/InventoryActions.cs @@ -22,9 +22,6 @@ public static class InventoryActions public const uint AddShortcutOpcode = 0x019Cu; public const uint RemoveShortcutOpcode = 0x019Du; public const uint TeleToPoiOpcode = 0x00B1u; - public const uint GetAndWieldItemOpcode = 0x001Au; - public const uint DropItemOpcode = 0x001Bu; - public const uint NoLongerViewingContentsOpcode = 0x0195u; /// /// Merge stack A into stack B of the same item type. Server validates @@ -98,20 +95,17 @@ public static class InventoryActions return body; } - /// Pin an item/spell to a quickbar slot. ShortCutData = Index(u32), ObjectId(u32), - /// SpellId(u16), Layer(u16) — CONFIRMED across ACE/Chorizite/holtburger (action-bar deep-dive - /// §131-145). For an ITEM: objectGuid = item guid, spellId = layer = 0. + /// Pin an item / spell to a quickbar slot. public static byte[] BuildAddShortcut( - uint seq, uint index, uint objectGuid, ushort spellId, ushort layer) + uint seq, uint slotIndex, uint objectType, uint targetId) { byte[] body = new byte[24]; BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), AddShortcutOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), index); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectGuid); - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(20), spellId); - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(22), layer); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), slotIndex); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), objectType); + BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(20), targetId); return body; } @@ -136,39 +130,4 @@ public static class InventoryActions BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), poiId); return body; } - - /// Drop an item on the ground. ACE GameActionDropItem.Handle reads 1 u32. - public static byte[] BuildDropItem(uint seq, uint itemGuid) - { - byte[] body = new byte[16]; - BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), DropItemOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid); - return body; - } - - /// Equip an item from inventory onto the doll. holtburger actions.rs - /// GetAndWieldItemActionData = (itemGuid, equipMask). - public static byte[] BuildGetAndWieldItem(uint seq, uint itemGuid, uint equipMask) - { - byte[] body = new byte[20]; - BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), GetAndWieldItemOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), itemGuid); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(16), equipMask); - return body; - } - - /// Close a side-pack / ground-container view. holtburger actions.rs = (containerGuid). - public static byte[] BuildNoLongerViewingContents(uint seq, uint containerGuid) - { - byte[] body = new byte[16]; - BinaryPrimitives.WriteUInt32LittleEndian(body, GameActionEnvelope); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4), seq); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(8), NoLongerViewingContentsOpcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(12), containerGuid); - return body; - } } diff --git a/src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs b/src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs deleted file mode 100644 index b89d9cd4..00000000 --- a/src/AcDream.Core.Net/Messages/InventoryRemoveObject.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Buffers.Binary; - -namespace AcDream.Core.Net.Messages; - -/// -/// Inbound InventoryRemoveObject (0x0024) — a top-level GameMessage (UIQueue), -/// NOT a GameEvent. The server tells the client an object left its inventory view -/// (given away / sold / destroyed). The client drops it from object maintenance. -/// -/// Wire layout (ACE GameMessageInventoryRemoveObject.cs, size hint 8): -/// -/// u32 opcode = 0x0024 -/// u32 guid -/// -/// -public static class InventoryRemoveObject -{ - public const uint Opcode = 0x0024u; - - public readonly record struct Parsed(uint Guid); - - /// Parse a raw 0x0024 body. Returns null on opcode mismatch / truncation. - public static Parsed? TryParse(ReadOnlySpan body) - { - if (body.Length < 8) return null; // 4 + 4 - if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[4..]); - return new Parsed(guid); - } -} diff --git a/src/AcDream.Core.Net/Messages/JumpAction.cs b/src/AcDream.Core.Net/Messages/JumpAction.cs index 8740a759..d5bc6d74 100644 --- a/src/AcDream.Core.Net/Messages/JumpAction.cs +++ b/src/AcDream.Core.Net/Messages/JumpAction.cs @@ -9,27 +9,18 @@ namespace AcDream.Core.Net.Messages; /// the extent + velocity to validate the jump and replicate it to nearby /// clients. /// -/// -/// Wire layout — ports retail's JumpPack::Pack verbatim -/// (0x00516d10, decomp lines ~284934-284963). Confirmed against the -/// Ghidra decompile-by-address bridge during this slice (2026-06-30): -/// -/// -/// GameAction envelope: u32 0xF7B1, u32 sequence, u32 0xF61B -/// f32 extent (0.0-1.0 charge power) -/// f32 velocity.x, velocity.y, velocity.z -/// Position::Pack: u32 cellId, f32 x, f32 y, f32 z, -/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes) -/// Sequences: u16 instance, u16 serverControl, -/// u16 teleport, u16 forcePosition -/// Align to 4 bytes -/// -/// -/// -/// D4 fix. Retail's JumpPack does NOT pack an objectGuid or a -/// spellId, and DOES pack the full Position — the pre-slice code had -/// it backwards (two spurious trailing zero u32s, no Position at all). -/// +/// Wire layout (from holtburger JumpActionData::pack): +/// u32 0xF7B1 (GameAction envelope) +/// u32 sequence +/// u32 0xF61B (Jump sub-opcode) +/// f32 extent (0.0–1.0 charge power) +/// f32 velocity.x, f32 velocity.y, f32 velocity.z +/// u16 instanceSequence +/// u16 serverControlSequence +/// u16 teleportSequence +/// u16 forcePositionSequence +/// u32 objectGuid (0 for normal jump) +/// u32 spellId (0 for normal jump) /// public static class JumpAction { @@ -37,45 +28,29 @@ public static class JumpAction public const uint JumpOpcode = 0xF61Bu; public static byte[] Build( - uint gameActionSequence, - float extent, - Vector3 velocity, - uint cellId, - Vector3 position, - Quaternion rotation, - ushort instanceSequence, - ushort serverControlSequence, - ushort teleportSequence, - ushort forcePositionSequence) + uint gameActionSequence, + float extent, + Vector3 velocity, + ushort instanceSequence, + ushort serverControlSequence, + ushort teleportSequence, + ushort forcePositionSequence) { - var w = new PacketWriter(80); + var w = new PacketWriter(48); w.WriteUInt32(GameActionOpcode); w.WriteUInt32(gameActionSequence); w.WriteUInt32(JumpOpcode); - w.WriteFloat(extent); w.WriteFloat(velocity.X); w.WriteFloat(velocity.Y); w.WriteFloat(velocity.Z); - - // --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) --- - w.WriteUInt32(cellId); - w.WriteFloat(position.X); - w.WriteFloat(position.Y); - w.WriteFloat(position.Z); - // Quaternion wire order: W, X, Y, Z - w.WriteFloat(rotation.W); - w.WriteFloat(rotation.X); - w.WriteFloat(rotation.Y); - w.WriteFloat(rotation.Z); - w.WriteUInt16(instanceSequence); w.WriteUInt16(serverControlSequence); w.WriteUInt16(teleportSequence); w.WriteUInt16(forcePositionSequence); - - w.AlignTo4(); + w.WriteUInt32(0); // objectGuid — 0 for normal jump + w.WriteUInt32(0); // spellId — 0 for normal jump return w.ToArray(); } diff --git a/src/AcDream.Core.Net/Messages/MoveToState.cs b/src/AcDream.Core.Net/Messages/MoveToState.cs index e705d79b..08356961 100644 --- a/src/AcDream.Core.Net/Messages/MoveToState.cs +++ b/src/AcDream.Core.Net/Messages/MoveToState.cs @@ -1,6 +1,5 @@ using System.Numerics; using AcDream.Core.Net.Packets; -using AcDream.Core.Physics; namespace AcDream.Core.Net.Messages; @@ -12,39 +11,61 @@ namespace AcDream.Core.Net.Messages; /// state and to drive interpolated position for other nearby clients. /// /// -/// Wire layout — ports retail's MoveToStatePack::Pack verbatim -/// (0x005168f0, decomp lines ~284694-284722). Confirmed against the -/// Ghidra decompile-by-address bridge during this slice (2026-06-30): +/// Wire layout (ported from +/// references/holtburger/crates/holtburger-protocol/src/messages/movement/actions.rs +/// MoveToStateActionData::pack and +/// types.rs RawMotionState::pack): /// /// /// GameAction envelope: u32 0xF7B1, u32 sequence, u32 0xF61C -/// RawMotionState::Pack: see -/// — default-difference flags dword + conditional fields + actions. -/// Position::Pack: u32 cellId, f32 x, f32 y, f32 z, -/// f32 qw, f32 qx, f32 qy, f32 qz (32 bytes) +/// RawMotionState: u32 packed_flags (bits 0-10 = flag bits, +/// bits 11-31 = command list length), then conditional u32/f32 fields +/// in flag-bit order (see RawMotionFlags in types.rs) +/// WorldPosition: u32 cellId, f32 x, f32 y, f32 z, +/// f32 rotW, f32 rotX, f32 rotY, f32 rotZ /// Sequences: u16 instance, u16 serverControl, /// u16 teleport, u16 forcePosition -/// Trailing byte: (standingLongjump ? 0x02 : 0) | -/// (contact ? 0x01 : 0)MoveToStatePack::Pack trailing -/// byte expression (longjump_mode == 0) - 1U & 2 | -/// contact != 0. +/// Contact byte: u8 (1 = on ground, 0 = airborne) /// Align to 4 bytes /// +/// +/// +/// The command list length is packed into bits 11-31 of the flags dword. +/// We always send 0 commands (no discrete motion events), so those bits stay 0. +/// /// public static class MoveToState { public const uint GameActionOpcode = 0xF7B1u; public const uint MoveToStateAction = 0xF61Cu; + // RawMotionFlags bit positions (from holtburger types.rs) + private const uint FlagCurrentHoldKey = 0x001u; + private const uint FlagCurrentStyle = 0x002u; + private const uint FlagForwardCommand = 0x004u; + private const uint FlagForwardHoldKey = 0x008u; + private const uint FlagForwardSpeed = 0x010u; + private const uint FlagSidestepCommand = 0x020u; + private const uint FlagSidestepHoldKey = 0x040u; + private const uint FlagSidestepSpeed = 0x080u; + private const uint FlagTurnCommand = 0x100u; + private const uint FlagTurnHoldKey = 0x200u; + private const uint FlagTurnSpeed = 0x400u; + /// /// Build a MoveToState GameAction body. /// /// Monotonically increasing counter from /// . - /// Complete raw-motion snapshot, matching - /// retail's CPhysicsObj::InqRawMotionState(). Fields equal to - /// are omitted from the wire - /// (see ). + /// Raw motion command (u32 in AC's command space), + /// e.g. 0x45000005 = WalkForward. Null = no forward command. + /// Normalised speed scalar (0.0-1.0). Only written + /// when is non-null. + /// Sidestep command or null. + /// Sidestep speed or null. + /// Turn command or null. + /// Turn speed or null. + /// Hold-key state (1=None, 2=Run). Null = omit. /// Landblock cell ID (u32). /// World-space position relative to the landblock. /// Rotation quaternion. AC wire order is W, X, Y, Z. @@ -52,22 +73,27 @@ public static class MoveToState /// Server-control sequence number. /// Teleport sequence number. /// Force-position sequence number. - /// True if the character is on the ground. - /// True during a standing (charged, - /// stationary) longjump wind-up. Not yet implemented in acdream — - /// callers pass false honestly until that feature lands. + /// 1 if the character is on the ground, 0 if airborne. public static byte[] Build( - uint gameActionSequence, - RawMotionState rawMotionState, - uint cellId, - Vector3 position, - Quaternion rotation, - ushort instanceSequence, - ushort serverControlSequence, - ushort teleportSequence, - ushort forcePositionSequence, - bool contact = true, - bool standingLongjump = false) + uint gameActionSequence, + uint? forwardCommand, + float? forwardSpeed, + uint? sidestepCommand, + float? sidestepSpeed, + uint? turnCommand, + float? turnSpeed, + uint? holdKey, + uint cellId, + Vector3 position, + Quaternion rotation, + ushort instanceSequence, + ushort serverControlSequence, + ushort teleportSequence, + ushort forcePositionSequence, + byte contactLongJump = 1, + uint? forwardHoldKey = null, + uint? sidestepHoldKey = null, + uint? turnHoldKey = null) { var w = new PacketWriter(128); @@ -76,10 +102,43 @@ public static class MoveToState w.WriteUInt32(gameActionSequence); w.WriteUInt32(MoveToStateAction); - // --- RawMotionState::Pack (0x0051ed10) --- - RawMotionStatePacker.Pack(w, rawMotionState); + // --- RawMotionState --- + // Build the flags word. Command list length (bits 11-31) is always 0. + // Field order matches holtburger's RawMotionState::pack — for any axis + // where we send a COMMAND + SPEED, retail expects the matching + // *_HOLD_KEY to accompany them (see holtburger's + // build_motion_state_raw_motion_state). Without the per-axis hold + // keys the server gets the flags but can't classify the input as a + // continuously-held key, so other players see the character sliding + // forward without an animation cycle. + uint flags = 0u; + if (holdKey.HasValue) flags |= FlagCurrentHoldKey; + if (forwardCommand.HasValue) flags |= FlagForwardCommand; + if (forwardHoldKey.HasValue) flags |= FlagForwardHoldKey; + if (forwardSpeed.HasValue) flags |= FlagForwardSpeed; + if (sidestepCommand.HasValue) flags |= FlagSidestepCommand; + if (sidestepHoldKey.HasValue) flags |= FlagSidestepHoldKey; + if (sidestepSpeed.HasValue) flags |= FlagSidestepSpeed; + if (turnCommand.HasValue) flags |= FlagTurnCommand; + if (turnHoldKey.HasValue) flags |= FlagTurnHoldKey; + if (turnSpeed.HasValue) flags |= FlagTurnSpeed; - // --- Position::Pack (0x005a9640): cellId + Frame::Pack (32 bytes) --- + w.WriteUInt32(flags); // bits 0-10 = flags, bits 11-31 = 0 (no command list) + + // Conditional fields in RawMotionFlags bit order: + if (holdKey.HasValue) w.WriteUInt32(holdKey.Value); + // FlagCurrentStyle (0x2): not sent — we don't track stance changes here + if (forwardCommand.HasValue) w.WriteUInt32(forwardCommand.Value); + if (forwardHoldKey.HasValue) w.WriteUInt32(forwardHoldKey.Value); + if (forwardSpeed.HasValue) w.WriteFloat(forwardSpeed.Value); + if (sidestepCommand.HasValue) w.WriteUInt32(sidestepCommand.Value); + if (sidestepHoldKey.HasValue) w.WriteUInt32(sidestepHoldKey.Value); + if (sidestepSpeed.HasValue) w.WriteFloat(sidestepSpeed.Value); + if (turnCommand.HasValue) w.WriteUInt32(turnCommand.Value); + if (turnHoldKey.HasValue) w.WriteUInt32(turnHoldKey.Value); + if (turnSpeed.HasValue) w.WriteFloat(turnSpeed.Value); + + // --- WorldPosition (32 bytes) --- w.WriteUInt32(cellId); w.WriteFloat(position.X); w.WriteFloat(position.Y); @@ -96,10 +155,8 @@ public static class MoveToState w.WriteUInt16(teleportSequence); w.WriteUInt16(forcePositionSequence); - // --- Trailing byte (MoveToStatePack::Pack, 0x005168f0): --- - // ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0) - byte trailing = (byte)((standingLongjump ? 0x02 : 0) | (contact ? 0x01 : 0)); - w.WriteByte(trailing); + // --- Contact byte + 4-byte align --- + w.WriteByte(contactLongJump); w.AlignTo4(); return w.ToArray(); diff --git a/src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs b/src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs deleted file mode 100644 index 5d60af03..00000000 --- a/src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Buffers.Binary; - -namespace AcDream.Core.Net.Messages; - -/// -/// Inbound PrivateUpdatePropertyInt (0x02CD) — the server updates one -/// PropertyInt on the player's OWN object. Unlike the sibling -/// (0x02CE), this carries NO guid (it -/// implicitly targets the local player). Burden (EncumbranceVal, PropertyInt 5) -/// changes ride this opcode on every pick-up / drop. -/// -/// Wire layout (ACE GameMessagePrivateUpdatePropertyInt): -/// -/// u32 opcode = 0x02CD -/// u8 sequence // single byte (ByteSequence) — not honored, latest-wins (DR-4) -/// u32 property // PropertyInt enum -/// i32 value -/// -/// -public static class PrivateUpdatePropertyInt -{ - public const uint Opcode = 0x02CDu; - - public readonly record struct Parsed(uint Property, int Value); - - /// Parse a raw 0x02CD body. Returns null on opcode mismatch / truncation. - public static Parsed? TryParse(ReadOnlySpan body) - { - if (body.Length < 13) return null; // 4 + 1 + 4 + 4 - if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; - int pos = 4; - pos += 1; // sequence byte (not honored) - uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); - return new Parsed(prop, value); - } -} diff --git a/src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs b/src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs deleted file mode 100644 index 35d466a6..00000000 --- a/src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using System.Buffers.Binary; - -namespace AcDream.Core.Net.Messages; - -/// -/// Inbound PublicUpdatePropertyInt (0x02CE) — the server updates one -/// PropertyInt on a visible object (carries the object guid). Standalone -/// GameMessage, dispatched like / CreateObject. -/// -/// -/// The companion PrivateUpdatePropertyInt (0x02CD) targets the player's OWN -/// object (no guid) and is not parsed here — it has no item-icon impact. -/// -/// -/// Wire layout (ACE GameMessagePublicUpdatePropertyInt, size hint 17): -/// -/// u32 opcode = 0x02CE -/// u8 sequence // single byte (ByteSequence.NextBytes) — see PrivateUpdateVital -/// u32 guid -/// u32 property // PropertyInt enum; UiEffects = 18 -/// i32 value -/// -/// The sequence is parsed-past but not honored (latest-wins; divergence DR-4). -/// -public static class PublicUpdatePropertyInt -{ - public const uint Opcode = 0x02CEu; - - public readonly record struct Parsed(uint Guid, uint Property, int Value); - - /// Parse a raw 0x02CE body. Returns null on opcode mismatch / truncation. - public static Parsed? TryParse(ReadOnlySpan body) - { - if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4 - if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; - int pos = 4; - pos += 1; // sequence byte (not honored) - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - uint prop = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); - return new Parsed(guid, prop, value); - } -} diff --git a/src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs b/src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs deleted file mode 100644 index bf73a84e..00000000 --- a/src/AcDream.Core.Net/Messages/RawMotionStatePacker.cs +++ /dev/null @@ -1,98 +0,0 @@ -using AcDream.Core.Net.Packets; -using AcDream.Core.Physics; - -namespace AcDream.Core.Net.Messages; - -/// -/// Ports retail's RawMotionState::Pack verbatim -/// (0x0051ed10, decomp lines ~293761-294013; bitfield layout -/// acclient.h RawMotionState::PackBitfield, line 46474). Confirmed -/// against the Ghidra decompile-by-address bridge during this slice -/// (2026-06-30). -/// -/// -/// D1 fix. Retail does NOT set a field's bit merely because the -/// caller supplied a value — it compares every field against its retail -/// DEFAULT () and sets the bit (and -/// emits the field) only when the live value DIFFERS. This packer mirrors -/// that default-difference comparison exactly, field by field, in bit -/// order. -/// -/// -/// -/// Flags dword layout (bits 0-15 only; bits 16-31 are unused — retail's -/// num_actions is a 5-bit field occupying bits 11-15, not "the rest -/// of the dword"): -/// -/// -/// 0x001current_holdkey != HoldKey.None -/// 0x002current_style != 0x8000003D -/// 0x004forward_command != 0x41000003 -/// 0x008forward_holdkey != HoldKey.Invalid -/// 0x010forward_speed != 1.0f -/// 0x020sidestep_command != 0 -/// 0x040sidestep_holdkey != HoldKey.Invalid -/// 0x080sidestep_speed != 1.0f -/// 0x100turn_command != 0 -/// 0x200turn_holdkey != HoldKey.Invalid -/// 0x400turn_speed != 1.0f -/// 0xF800num_actions (bits 11-15, count not values) -/// -/// -public static class RawMotionStatePacker -{ - private const uint FlagCurrentHoldKey = 0x001u; - private const uint FlagCurrentStyle = 0x002u; - private const uint FlagForwardCommand = 0x004u; - private const uint FlagForwardHoldKey = 0x008u; - private const uint FlagForwardSpeed = 0x010u; - private const uint FlagSidestepCommand = 0x020u; - private const uint FlagSidestepHoldKey = 0x040u; - private const uint FlagSidestepSpeed = 0x080u; - private const uint FlagTurnCommand = 0x100u; - private const uint FlagTurnHoldKey = 0x200u; - private const uint FlagTurnSpeed = 0x400u; - private const int NumActionsShift = 11; - - public static void Pack(PacketWriter w, RawMotionState state) - { - var defaults = RawMotionState.Default; - - uint flags = 0u; - if (state.CurrentHoldKey != defaults.CurrentHoldKey) flags |= FlagCurrentHoldKey; - if (state.CurrentStyle != defaults.CurrentStyle) flags |= FlagCurrentStyle; - if (state.ForwardCommand != defaults.ForwardCommand) flags |= FlagForwardCommand; - if (state.ForwardHoldKey != defaults.ForwardHoldKey) flags |= FlagForwardHoldKey; - if (state.ForwardSpeed != defaults.ForwardSpeed) flags |= FlagForwardSpeed; - if (state.SidestepCommand != defaults.SidestepCommand) flags |= FlagSidestepCommand; - if (state.SidestepHoldKey != defaults.SidestepHoldKey) flags |= FlagSidestepHoldKey; - if (state.SidestepSpeed != defaults.SidestepSpeed) flags |= FlagSidestepSpeed; - if (state.TurnCommand != defaults.TurnCommand) flags |= FlagTurnCommand; - if (state.TurnHoldKey != defaults.TurnHoldKey) flags |= FlagTurnHoldKey; - if (state.TurnSpeed != defaults.TurnSpeed) flags |= FlagTurnSpeed; - - int numActions = state.Actions.Count; - flags |= (uint)(numActions << NumActionsShift); - - w.WriteUInt32(flags); - - if ((flags & FlagCurrentHoldKey) != 0) w.WriteUInt32((uint)state.CurrentHoldKey); - if ((flags & FlagCurrentStyle) != 0) w.WriteUInt32(state.CurrentStyle); - if ((flags & FlagForwardCommand) != 0) w.WriteUInt32(state.ForwardCommand); - if ((flags & FlagForwardHoldKey) != 0) w.WriteUInt32((uint)state.ForwardHoldKey); - if ((flags & FlagForwardSpeed) != 0) w.WriteFloat(state.ForwardSpeed); - if ((flags & FlagSidestepCommand) != 0) w.WriteUInt32(state.SidestepCommand); - if ((flags & FlagSidestepHoldKey) != 0) w.WriteUInt32((uint)state.SidestepHoldKey); - if ((flags & FlagSidestepSpeed) != 0) w.WriteFloat(state.SidestepSpeed); - if ((flags & FlagTurnCommand) != 0) w.WriteUInt32(state.TurnCommand); - if ((flags & FlagTurnHoldKey) != 0) w.WriteUInt32((uint)state.TurnHoldKey); - if ((flags & FlagTurnSpeed) != 0) w.WriteFloat(state.TurnSpeed); - - foreach (var action in state.Actions) - { - w.WriteUInt16(action.Command); - ushort stampWord = (ushort)((action.Stamp & 0x7FFF) | (action.Autonomous ? 0x8000 : 0)); - w.WriteUInt16(stampWord); - } - } -} diff --git a/src/AcDream.Core.Net/Messages/SetStackSize.cs b/src/AcDream.Core.Net/Messages/SetStackSize.cs deleted file mode 100644 index 5d7a8313..00000000 --- a/src/AcDream.Core.Net/Messages/SetStackSize.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Buffers.Binary; - -namespace AcDream.Core.Net.Messages; - -/// -/// Inbound SetStackSize (0x0197) — a top-level GameMessage (UIQueue group), -/// NOT a GameEvent. The server updates a stack's count + value after a merge / split. -/// Client consumer: ACCWeenieObject::ServerSaysSetStackSize. -/// -/// Wire layout (ACE GameMessageSetStackSize.cs, size hint 17): -/// -/// u32 opcode = 0x0197 -/// u8 sequence // ByteSequence (UpdatePropertyInt) — not honored -/// u32 guid -/// i32 stackSize -/// i32 value -/// -/// -public static class SetStackSize -{ - public const uint Opcode = 0x0197u; - - public readonly record struct Parsed(uint Guid, int StackSize, int Value); - - /// Parse a raw 0x0197 body. Returns null on opcode mismatch / truncation. - public static Parsed? TryParse(ReadOnlySpan body) - { - if (body.Length < 17) return null; // 4 + 1 + 4 + 4 + 4 - if (BinaryPrimitives.ReadUInt32LittleEndian(body) != Opcode) return null; - int pos = 4; - pos += 1; // sequence byte (not honored) - uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body[pos..]); pos += 4; - int stack = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); pos += 4; - int value = BinaryPrimitives.ReadInt32LittleEndian(body[pos..]); - return new Parsed(guid, stack, value); - } -} diff --git a/src/AcDream.Core.Net/Messages/UpdateMotion.cs b/src/AcDream.Core.Net/Messages/UpdateMotion.cs index 2049bdbc..87562815 100644 --- a/src/AcDream.Core.Net/Messages/UpdateMotion.cs +++ b/src/AcDream.Core.Net/Messages/UpdateMotion.cs @@ -34,20 +34,7 @@ namespace AcDream.Core.Net.Messages; /// u32 flagsAndCommandCount, then each present field in flag order /// (CurrentStyle u16, ForwardCommand u16, SidestepCommand u16, /// TurnCommand u16, forward speed f32, sidestep speed f32, -/// turn speed f32), commands list, align; THEN — only when -/// motionFlags & 0x1 (StickToObject) — one trailing u32 -/// sticky object guid (ACE MovementInvalid.Write; R4-V3). -/// MoveToObject (6) / MoveToPosition (7): [u32 target guid, -/// MoveToObject only] Origin (u32 cell + 3×f32), MoveToParameters / -/// UnPackNet 7-dword form (u32 bitfield + 6×f32), f32 -/// runRate. R4-V3: exposed via -/// . -/// TurnToObject (8) / TurnToHeading (9): [u32 target guid, f32 -/// standalone wire heading, TurnToObject only] TurnToParameters / -/// UnPackNet 3-dword form (u32 bitfield, f32 speed, f32 -/// desired heading). R4-V3 (closes M7): exposed via -/// -/// — previously dropped end-to-end. +/// turn speed f32), commands list, align. /// /// /// @@ -70,21 +57,10 @@ public static class UpdateMotion /// command is nullable because the ForwardCommand flag may be /// unset in the InterpretedMotionState; the stance is always present /// (even if 0, meaning "no specific stance"). - /// - /// L.2g S1 (DEV-6): the three staleness stamps + autonomy flag are - /// exposed for the retail gate ( - /// MotionSequenceGate) — retail compares them against - /// update_times[INSTANCE_TS/MOVEMENT_TS/SERVER_CONTROLLED_MOVE_TS] - /// and stores last_move_was_autonomous - /// (CPhysics::SetObjectMovement 0x00509690). /// public readonly record struct Parsed( uint Guid, - CreateObject.ServerMotionState MotionState, - ushort InstanceSequence, - ushort MovementSequence, - ushort ServerControlSequence, - bool IsAutonomous); + CreateObject.ServerMotionState MotionState); /// /// Parse a reassembled UpdateMotion body. must @@ -106,11 +82,8 @@ public static class UpdateMotion uint guid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; - // ObjectInstance sequence (u16) — retail's dispatch-level - // INSTANCE_TS staleness gate compares this against the object's - // known incarnation (DispatchSmartBoxEvent case 0xF74C). + // ObjectInstance sequence (u16) — tracked but not used for pose. if (body.Length - pos < 2) return null; - ushort instanceSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; // MovementData header: u16 movementSequence, u16 serverControlSequence, @@ -129,34 +102,13 @@ public static class UpdateMotion // Previous version mistakenly reserved 8 bytes here, which shifted // every subsequent field by 2 and made every remote-char UpdateMotion // decode as garbage (stance read from the packed-flags dword). - // - // L.2g S1 (DEV-6): the header fields feed retail's - // CPhysics::SetObjectMovement staleness gates + the - // last_move_was_autonomous store, so parse them instead of - // skipping. if (body.Length - pos < 6) return null; - ushort movementSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); - pos += 2; - ushort serverControlSequence = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); - pos += 2; - bool isAutonomous = body[pos] != 0; - pos += 2; // u8 isAutonomous + Align(4) pad byte + pos += 6; // movementType u8, motionFlags u8, currentStyle u16 if (body.Length - pos < 4) return null; byte movementType = body[pos]; pos += 1; - // MotionFlags (ACE ACE.Entity.Enum.MotionFlags, byte): - // 0x1 = StickToObject, 0x2 = StandingLongJump. - // R4-V3 (closes M14-wire-note): previously discarded as - // `_motionFlags`. StickToObject now drives the sticky-guid - // trailer parse below (mt=0 only, per ACE MovementInvalid.Write - // + decomp §2f case 0 @0052455d). StandingLongJump (0x2) is - // carried on ServerMotionState.StandingLongJump (R5-V4) and - // consumed at the GameWindow mt-0 tails — retail's - // `unpack_movement` case 0 writes it onto - // `motion_interpreter->standing_longjump` (§2f @0052458e, - // UNCONDITIONAL — an absent flag clears it). - byte motionFlags = body[pos]; pos += 1; + byte _motionFlags = body[pos]; pos += 1; ushort currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; @@ -166,7 +118,7 @@ public static class UpdateMotion var hex = new System.Text.StringBuilder(); for (int i = 0; i < preHex; i++) hex.Append($"{body[i]:X2} "); System.Console.WriteLine( - $" UM raw: mt=0x{movementType:X2} mf=0x{motionFlags:X2} cs=0x{currentStyle:X4} | {hex}"); + $" UM raw: mt=0x{movementType:X2} mf=0x{_motionFlags:X2} cs=0x{currentStyle:X4} | {hex}"); } ushort? forwardCommand = null; @@ -179,8 +131,6 @@ public static class UpdateMotion float? moveToSpeed = null; float? moveToRunRate = null; CreateObject.MoveToPathData? moveToPath = null; - CreateObject.TurnToPathData? turnToPath = null; - uint? stickyObjectGuid = null; List? commands = null; if (movementType == 0) @@ -189,7 +139,7 @@ public static class UpdateMotion // MovementInvalid branch, just reached via the header'd path. // Includes the Commands list (MotionItem[]) that carries // Actions, emotes, and other one-shots not in ForwardCommand. - if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous); + if (body.Length - pos < 4) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType)); uint packed = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); pos += 4; uint flags = packed & 0x7Fu; @@ -212,13 +162,13 @@ public static class UpdateMotion if ((flags & 0x1u) != 0) { - if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous); + if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType)); currentStyle = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; } if ((flags & 0x2u) != 0) { - if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType), instanceSequence, movementSequence, serverControlSequence, isAutonomous); + if (body.Length - pos < 2) return new Parsed(guid, new CreateObject.ServerMotionState(currentStyle, null, MovementType: movementType)); forwardCommand = BinaryPrimitives.ReadUInt16LittleEndian(body.Slice(pos)); pos += 2; } @@ -273,23 +223,6 @@ public static class UpdateMotion commands.Add(new CreateObject.MotionItem(cmd, seq, speed)); } } - - // R4-V3 (closes M14-wire-note): the sticky-guid trailer. - // ACE MovementInvalid.Write (Motion/MovementInvalid.cs:41-47) - // writes the InterpretedMotionState FIRST (everything above, - // including the Commands list), THEN — only when - // MotionFlags.StickToObject (0x1) is set — one trailing u32 - // guid. Decomp confirms the same order (§2f case 0: - // InterpretedMotionState::UnPack, then - // `if (header & 0x100) sticky_object_guid = read_dword()`). - // Parsed here purely to keep the cursor honest past this - // field; R5's PositionManager::StickTo body is the eventual - // consumer (M14 — "seam now, body R5"). - if ((motionFlags & 0x1) != 0 && body.Length - pos >= 4) - { - stickyObjectGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); - pos += 4; - } done:; } else if (movementType is 6 or 7) @@ -303,14 +236,6 @@ public static class UpdateMotion out moveToRunRate, out moveToPath); } - else if (movementType is 8 or 9) - { - TryParseTurnToPayload( - body, - pos, - movementType, - out turnToPath); - } return new Parsed(guid, new CreateObject.ServerMotionState( currentStyle, forwardCommand, forwardSpeed, commands, @@ -319,14 +244,7 @@ public static class UpdateMotion moveToParameters, moveToSpeed, moveToRunRate, - moveToPath, - turnToPath, - stickyObjectGuid, - // R5-V4: MotionFlags 0x2 — consumed at the GameWindow mt-0 - // tails (retail unpack_movement @0052458e writes it onto - // minterp->standing_longjump unconditionally). - (motionFlags & 0x2) != 0), - instanceSequence, movementSequence, serverControlSequence, isAutonomous); + moveToPath)); } catch { @@ -408,73 +326,6 @@ public static class UpdateMotion minDistance, failDistance, walkRunThreshold, - desiredHeading, - movementParameters ?? 0u); // R4-V4: the raw flags dword -> FromWire - return true; - } - - /// - /// R4-V3 (closes M7) — parses movementType 8 (TurnToObject) / 9 - /// (TurnToHeading), byte-for-byte per V0-pins.md P6 (built + confirmed - /// against ACE's own writers: TurnToObjectExtensions.Write, - /// TurnToParametersExtensions.Write, - /// TurnToHeadingExtensions.Write — - /// references/ACE/Source/ACE.Server/Network/Motion/): - /// - /// type 8 (TurnToObject) only: u32 object guid, f32 - /// wire_heading (the STANDALONE heading field — ACE - /// TurnToObject.DesiredHeading, "used instead of the - /// DesiredHeading in the TurnToParameters" per its own field - /// comment). - /// TurnToParameters / MovementParameters::UnPackNet - /// 3-dword TurnTo form (0xc bytes, decomp §2g), present for BOTH - /// types: u32 bitfield, f32 speed, f32 - /// desired_heading. - /// - /// Matches retail's read order exactly (decomp §2f case 8: - /// object_id = read_dword(); wire_heading = read_dword(); - /// UnPackNet(...)). The unresolvable-object fallback (substitute - /// wire_heading into params.desired_heading, degrade to - /// TurnToHeading) is a CONSUMER decision (needs a live object-id - /// resolution the wire parser has no visibility into) — this method - /// only exposes both heading fields distinctly so that fallback can be - /// implemented at the call site (V4/V5, GameWindow routing per - /// r4-port-plan.md §4). - /// - private static bool TryParseTurnToPayload( - ReadOnlySpan body, - int pos, - byte movementType, - out CreateObject.TurnToPathData? path) - { - path = null; - - uint? targetGuid = null; - float? wireHeading = null; - if (movementType == 8) - { - if (body.Length - pos < 8) return false; - targetGuid = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); - pos += 4; - wireHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); - pos += 4; - } - - // TurnToParameters / UnPackNet 3-dword TurnTo form (0xc bytes): - // bitfield, speed, desired_heading. - if (body.Length - pos < 12) return false; - - uint bitfield = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos)); - pos += 4; - float speed = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); - pos += 4; - float desiredHeading = BinaryPrimitives.ReadSingleLittleEndian(body.Slice(pos)); - - path = new CreateObject.TurnToPathData( - targetGuid, - wireHeading, - bitfield, - speed, desiredHeading); return true; } diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs deleted file mode 100644 index e47f8aaa..00000000 --- a/src/AcDream.Core.Net/ObjectTableWiring.cs +++ /dev/null @@ -1,85 +0,0 @@ -using AcDream.Core.Items; - -namespace AcDream.Core.Net; - -/// -/// Wires WorldSession GameMessage-level object events into the client object -/// table: CreateObject (0xF745) = canonical merge-upsert, DeleteObject (0xF747) -/// = evict, PublicUpdatePropertyInt (0x02CE) = live int apply, PrivateUpdatePropertyInt -/// (0x02CD) = player int (burden), SetStackSize (0x0197) = stack count, InventoryRemoveObject -/// (0x0024) = inventory-view removal. -/// Keeps object ingestion in Core.Net (pure data, no GL) and off GameWindow. -/// Retail: ACCObjectMaint::CreateObject / DeleteObject (the weenie_object_table side). -/// -public static class ObjectTableWiring -{ - /// - /// Subscribe to the three object-lifecycle events - /// on . Call this BEFORE the render handler subscribes - /// to EntitySpawned so the table is populated before the render path runs. - /// - public static void Wire(WorldSession session, ClientObjectTable table, Func? playerGuid = null) - { - ArgumentNullException.ThrowIfNull(session); - ArgumentNullException.ThrowIfNull(table); - - session.EntitySpawned += s => table.Ingest(ToWeenieData(s)); - // Retail two-table model: - // PickupEvent (0xF74A) → object left the 3-D world view; the weenie persists - // (it is moving into a container, e.g. unwield-to-pack). Remove the 3-D - // render entity only — do NOT evict from ClientObjectTable. - // DeleteObject (0xF747) → weenie is DESTROYED; evict from both tables. - // FromPickup = true guards the weenie eviction so it only fires for true destroys. - session.EntityDeleted += d => { if (!d.FromPickup) table.Remove(d.Guid); }; - - // B-Wire: apply EVERY PropertyInt update on a visible object (0x02CE), not just - // UiEffects — the server is the authority on object properties. UpdateIntProperty - // stores it in the bundle and still mirrors UiEffects → the typed Effects field. - session.ObjectIntPropertyUpdated += u => - table.UpdateIntProperty(u.Guid, u.Property, u.Value); - - // B-Wire: PrivateUpdatePropertyInt (0x02CD) carries no guid — it targets the - // local player. Route it to the player object so live EncumbranceVal updates the - // burden bar. The player ClientObject is created at login by the PD UpsertProperties - // call (which precedes any live 0x02CD), so UpdateIntProperty finds it. If it somehow - // hasn't yet, this no-ops (UpdateIntProperty returns false on an unknown guid) rather - // than creating a phantom — the next PD / CreateObject seeds it. - session.PlayerIntPropertyUpdated += u => - { - if (playerGuid is not null) - table.UpdateIntProperty(playerGuid(), u.Property, u.Value); - }; - - // B-Wire: SetStackSize (0x0197) — update the object's stack count + value. - session.StackSizeUpdated += u => table.UpdateStackSize(u.Guid, u.StackSize, u.Value); - - // B-Wire: InventoryRemoveObject (0x0024) — the object left the player's inventory - // view; drop it from the table (retail ClientUISystem removes it from maintenance). - session.InventoryObjectRemoved += guid => table.Remove(guid); - } - - /// Translate the wire spawn into the table's merge patch. - public static WeenieData ToWeenieData(WorldSession.EntitySpawn s) => new( - Guid: s.Guid, - Name: s.Name, - Type: s.ItemType is { } it ? (ItemType)it : (ItemType?)null, - WeenieClassId: s.WeenieClassId, - IconId: s.IconId, - IconOverlayId: s.IconOverlayId, - IconUnderlayId: s.IconUnderlayId, - Effects: s.UiEffects, - Value: s.Value, - StackSize: s.StackSize, - StackSizeMax: s.StackSizeMax, - Burden: s.Burden, - ContainerId: s.ContainerId, - WielderId: s.WielderId, - ValidLocations: s.ValidLocations, - CurrentWieldedLocation: s.CurrentWieldedLocation, - Priority: s.Priority, - ItemsCapacity: s.ItemsCapacity, - ContainersCapacity: s.ContainersCapacity, - Structure: s.Structure, - MaxStructure: s.MaxStructure, - Workmanship: s.Workmanship); -} diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 44e2a55d..8b4e0f74 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1,5 +1,4 @@ using System.Buffers.Binary; -using System.Diagnostics; using System.Net; using System.Threading.Channels; using AcDream.Core.Combat; @@ -81,41 +80,7 @@ public sealed class WorldSession : IDisposable // sizing hint for tall-scenery selection indicators when the // server publishes it for non-useable display entities. uint? Useability = null, - float? UseRadius = null, - // D.5.1: icon datId from CreateObject WeenieHeader, for toolbar rendering. - uint IconId = 0, - // D.5.1 (2026-06-17): icon overlay/underlay dat ids from the extended - // WeenieHeader optional tail. Gated by WeenieHeaderFlag.IconOverlay - // (0x40000000) and WeenieHeaderFlag2.IconUnderlay (0x01) respectively. - // Zero when the server did not send the field (common for most entities). - uint IconOverlayId = 0, - uint IconUnderlayId = 0, - // D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags 0x80) — drives the icon's - // effect recolor. CreateObject-only; 0 = no effect. - uint UiEffects = 0, - // D.5.4 (2026-06-18): full item field set, forwarded to the object table. - // Quantity fields int? (ACE PropertyInt convention); id/mask fields uint?. - uint WeenieClassId = 0, - int? Value = null, - int? StackSize = null, - int? StackSizeMax = null, - int? Burden = null, - int? ItemsCapacity = null, - int? ContainersCapacity = null, - uint? ContainerId = null, - uint? WielderId = null, - uint? ValidLocations = null, - uint? CurrentWieldedLocation = null, - uint? Priority = null, - int? Structure = null, - int? MaxStructure = null, - float? Workmanship = null, - // L.2g S1 (DEV-6): PhysicsDesc timestamp-block stamps that seed the - // per-entity MotionSequenceGate (retail update_times INSTANCE_TS / - // MOVEMENT_TS / SERVER_CONTROLLED_MOVE_TS). - ushort InstanceSequence = 0, - ushort MovementSequence = 0, - ushort ServerControlSequence = 0); + float? UseRadius = null); /// Fires when the session finishes parsing a CreateObject. public event Action? EntitySpawned; @@ -137,11 +102,7 @@ public sealed class WorldSession : IDisposable /// public readonly record struct EntityMotionUpdate( uint Guid, - CreateObject.ServerMotionState MotionState, - ushort InstanceSequence, - ushort MovementSequence, - ushort ServerControlSequence, - bool IsAutonomous); + CreateObject.ServerMotionState MotionState); /// /// Fires when the session parses a 0xF74C UpdateMotion game message. @@ -192,39 +153,6 @@ public sealed class WorldSession : IDisposable /// public event Action? StateUpdated; - /// - /// Payload for : a single PropertyInt change on - /// a visible object (from PublicUpdatePropertyInt 0x02CE). Subscribers map the - /// property to typed state (e.g. UiEffects → the item's icon effect). - /// - public readonly record struct ObjectIntPropertyUpdate(uint Guid, uint Property, int Value); - - /// - /// Fires when the session parses a PublicUpdatePropertyInt (0x02CE) — one - /// PropertyInt updated on a visible object. D.5.2 routes UiEffects (18) to the - /// item repository so the icon re-composites live. - /// - public event Action? ObjectIntPropertyUpdated; - - /// Payload for : a PropertyInt change on - /// the player's OWN object (from PrivateUpdatePropertyInt 0x02CD — no guid on the wire). - public readonly record struct PlayerIntPropertyUpdate(uint Property, int Value); - - /// Fires when the session parses a PrivateUpdatePropertyInt (0x02CD) — one - /// PropertyInt updated on the player. B-Wire routes EncumbranceVal (5) to the burden bar. - public event Action? PlayerIntPropertyUpdated; - - /// Payload for : SetStackSize (0x0197) — a stack's - /// count + value after a merge / split. - public readonly record struct StackSizeUpdate(uint Guid, int StackSize, int Value); - - /// Fires when the session parses a SetStackSize (0x0197) top-level GameMessage. - public event Action? StackSizeUpdated; - - /// Fires when the session parses an InventoryRemoveObject (0x0024) — the guid left - /// the player's inventory view. - public event Action? InventoryObjectRemoved; - /// /// Fires when the server sends a PlayerTeleport (0xF751) game message, /// signalling that the player is entering portal space. The uint payload @@ -620,50 +548,22 @@ public sealed class WorldSession : IDisposable _netThread.Start(); } - // Per-frame inbound time budget (#2 flood timeslice). On a teleport arrival ACE floods - // a town's CreateObjects; draining them ALL in one Tick (each hydrates mesh + textures - // under _datLock on the render thread) monopolized the update loop for ~a minute and - // starved the streaming apply, so neighbor landblocks trickled in. A wall-clock budget - // self-adapts to the highly variable per-datagram cost (cheap UpdatePosition vs an - // expensive clothed CreateObject); the tail drains over the next few frames, FIFO - // preserved, nothing lost (the inbox channel is unbounded). ~4ms leaves the rest of a - // 60fps update frame for streaming + physics. - private static readonly long InboundBudgetTicks = Stopwatch.Frequency / 1000 * 4; - /// - /// Non-blocking pump. Drains datagrams buffered by the background net thread (Phase A.3), - /// decodes them, and fires events. Call once per game-loop frame. Once in-world the drain - /// is bounded to ~4ms so a CreateObject flood can't monopolize the frame. Returns the - /// number of datagrams processed. + /// Non-blocking pump. Drains any datagrams buffered by the background + /// net thread (Phase A.3), decodes them, and fires events. Call once + /// per game-loop frame. Returns the number of datagrams processed. /// public int Tick() { int processed = 0; - long start = Stopwatch.GetTimestamp(); while (_inboundQueue.Reader.TryRead(out var bytes)) { ProcessDatagram(bytes); processed++; - // Bound ONLY in-world: the handshake uses the blocking PumpOnce path, never Tick - // (the net thread that feeds _inboundQueue starts at Transition(State.InWorld)). - // Acks are queued per packet inside ProcessDatagram BEFORE the heavy handler, so - // deferring the tail only delays the tail's acks a few frames — within ACE's - // tolerance (holtburger defers acks on a flush cadence). The tail stays queued - // (unbounded channel, FIFO) and drains next frame. - if (InboundBudgetExceeded(CurrentState, start, Stopwatch.GetTimestamp(), InboundBudgetTicks)) - break; } return processed; } - /// - /// Pure, testable decision for the per-frame inbound bound: stop draining only when - /// in-world AND the elapsed Stopwatch ticks have reached the budget. Extracted so the - /// gate logic is unit-tested without the ISAAC/decode/channel machinery. - /// - internal static bool InboundBudgetExceeded(State state, long startTicks, long nowTicks, long budgetTicks) - => state == State.InWorld && nowTicks - startTicks >= budgetTicks; - /// /// Phase A.3: background receive loop. Runs on a dedicated daemon /// thread started at the end of . Continuously @@ -816,29 +716,7 @@ public sealed class WorldSession : IDisposable parsed.Value.Friction, parsed.Value.Elasticity, parsed.Value.Useability, - parsed.Value.UseRadius, - parsed.Value.IconId, - parsed.Value.IconOverlayId, - parsed.Value.IconUnderlayId, - parsed.Value.UiEffects, - parsed.Value.WeenieClassId, - parsed.Value.Value, - parsed.Value.StackSize, - parsed.Value.StackSizeMax, - parsed.Value.Burden, - parsed.Value.ItemsCapacity, - parsed.Value.ContainersCapacity, - parsed.Value.ContainerId, - parsed.Value.WielderId, - parsed.Value.ValidLocations, - parsed.Value.CurrentWieldedLocation, - parsed.Value.Priority, - parsed.Value.Structure, - parsed.Value.MaxStructure, - parsed.Value.Workmanship, - InstanceSequence: parsed.Value.InstanceSequence, - MovementSequence: parsed.Value.MovementSequence, - ServerControlSequence: parsed.Value.ServerControlSequence)); + parsed.Value.UseRadius)); } } else if (op == DeleteObject.Opcode) @@ -849,18 +727,16 @@ public sealed class WorldSession : IDisposable } else if (op == PickupEvent.Opcode) { - // ACE sends PickupEvent (0xF74A) when an object leaves the 3-D world view - // (player picks it up, or a player unwields gear that goes back to their pack). - // The WEENIE is NOT destroyed — it is moving into a container. We adapt to - // DeleteObject.Parsed so the render/entity layer (GameWindow.OnLiveEntityDeleted) - // still removes the 3-D WorldEntity, but FromPickup = true tells ObjectTableWiring - // to skip the ClientObjectTable eviction (retail two-table model: PickupEvent - // targets object_table only; DeleteObject 0xF747 targets weenie_object_table). + // ACE sends PickupEvent (0xF74A) instead of DeleteObject + // when a player picks up a world item (Player_Tracking + // .RemoveTrackedObject with fromPickup=true). Downstream + // view-removal semantics are identical, so we adapt to + // DeleteObject.Parsed and reuse the existing handler. var parsed = PickupEvent.TryParse(body); if (parsed is not null) EntityDeleted?.Invoke( new DeleteObject.Parsed( - parsed.Value.Guid, parsed.Value.InstanceSequence, FromPickup: true)); + parsed.Value.Guid, parsed.Value.InstanceSequence)); } else if (op == UpdateMotion.Opcode) { @@ -875,11 +751,7 @@ public sealed class WorldSession : IDisposable { MotionUpdated?.Invoke(new EntityMotionUpdate( motion.Value.Guid, - motion.Value.MotionState, - motion.Value.InstanceSequence, - motion.Value.MovementSequence, - motion.Value.ServerControlSequence, - motion.Value.IsAutonomous)); + motion.Value.MotionState)); } } else if (op == UpdatePosition.Opcode) @@ -1018,32 +890,6 @@ public sealed class WorldSession : IDisposable if (parsed is not null) VitalCurrentUpdated?.Invoke(parsed.Value); } - else if (op == PublicUpdatePropertyInt.Opcode) - { - var p = PublicUpdatePropertyInt.TryParse(body); - if (p is not null) - ObjectIntPropertyUpdated?.Invoke( - new ObjectIntPropertyUpdate(p.Value.Guid, p.Value.Property, p.Value.Value)); - } - else if (op == PrivateUpdatePropertyInt.Opcode) - { - var p = PrivateUpdatePropertyInt.TryParse(body); - if (p is not null) - PlayerIntPropertyUpdated?.Invoke( - new PlayerIntPropertyUpdate(p.Value.Property, p.Value.Value)); - } - else if (op == SetStackSize.Opcode) - { - var p = SetStackSize.TryParse(body); - if (p is not null) - StackSizeUpdated?.Invoke( - new StackSizeUpdate(p.Value.Guid, p.Value.StackSize, p.Value.Value)); - } - else if (op == InventoryRemoveObject.Opcode) - { - var p = InventoryRemoveObject.TryParse(body); - if (p is not null) InventoryObjectRemoved?.Invoke(p.Value.Guid); - } else if (op == GameEventEnvelope.Opcode) { // Phase F.1: 0xF7B0 is the GameEvent envelope. Parse the @@ -1226,73 +1072,6 @@ public sealed class WorldSession : IDisposable SendGameAction(body); } - /// Send AddShortcut (0x019C) — pin an item to toolbar slot . - /// Retail: CM_Character::Event_AddShortCut. Mirrors the SendChangeCombatMode pattern. - public void SendAddShortcut(uint index, uint objectGuid, ushort spellId = 0, ushort layer = 0) - { - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildAddShortcut(seq, index, objectGuid, spellId, layer)); - } - - /// Send RemoveShortcut (0x019D) — clear toolbar slot . - /// Retail: CM_Character::Event_RemoveShortCut. - public void SendRemoveShortcut(uint index) - { - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildRemoveShortcut(seq, index)); - } - - /// Send DropItem (0x001B) — drop an item on the ground. - public void SendDropItem(uint itemGuid) - { - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid)); - } - - /// Send GetAndWieldItem (0x001A) — equip an item to an equip slot. - public void SendGetAndWieldItem(uint itemGuid, uint equipMask) - { - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildGetAndWieldItem(seq, itemGuid, equipMask)); - } - - /// Send NoLongerViewingContents (0x0195) — close a container view. - public void SendNoLongerViewingContents(uint containerGuid) - { - uint seq = NextGameActionSequence(); - SendGameAction(InventoryActions.BuildNoLongerViewingContents(seq, containerGuid)); - } - - /// Send Use (0x0036) — open/use an object by guid (e.g. open a container in your - /// inventory). A direct wire send: opening a pack you already hold needs no autowalk, unlike - /// GameWindow.SendUse (the world-interaction path). Retail: CM_Physics::Event_Use. - public void SendUse(uint targetGuid) - { - uint seq = NextGameActionSequence(); - SendGameAction(InteractRequests.BuildUse(seq, targetGuid)); - } - - /// Send PutItemInContainer (0x0019) — move an item into a container at a slot. placement - /// = the target slot (server packs/shifts); the drag-drop drop handler computes it. Retail: - /// CM_Inventory::Event_PutItemInContainer → ACE Player.HandleActionPutItemInContainer. - public void SendPutItemInContainer(uint itemGuid, uint containerGuid, int placement) - { - uint seq = NextGameActionSequence(); - SendGameAction(InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)); - } - - /// Send retail QueryHealth (0x01BF). Server replies UpdateHealth (0x01C0). - /// - /// Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 - /// (docs/research/named-retail/acclient_2013_pseudo_c.txt). - /// - public void SendQueryHealth(uint targetGuid) - { - uint seq = NextGameActionSequence(); - byte[] body = SocialActions.BuildQueryHealth(seq, targetGuid); - SendGameAction(body); - } - /// Send retail TargetedMeleeAttack (0x0008). public void SendMeleeAttack(uint targetGuid, AttackHeight attackHeight, float powerLevel) { diff --git a/src/AcDream.Core/Combat/CombatAnimationPlanner.cs b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs index f7b7ca36..bf67a852 100644 --- a/src/AcDream.Core/Combat/CombatAnimationPlanner.cs +++ b/src/AcDream.Core/Combat/CombatAnimationPlanner.cs @@ -1,5 +1,4 @@ using AcDream.Core.Physics; -using Drw = DatReaderWriter.Enums.MotionCommand; namespace AcDream.Core.Combat; @@ -201,146 +200,109 @@ public enum CombatAnimationKind Death, } -/// -/// The full 32-bit retail MotionCommand values the planner classifies, each -/// derived DIRECTLY from (Chorizite.DatReaderWriter — the -/// same enum ACE broadcasts and that the local DAT MotionTables key on) so -/// the values can never drift from the wire again. -/// -/// -/// History (ISSUES #159): these were previously hand-transcribed from the -/// Sept 2013 EoR decomp's command_ids table, which numbers the whole -/// late-combat block (Offhand* / Attack4-6 / Punch*) -/// THREE lower than ACE/DRW — a contiguous low-word "+3" shift that begins -/// around SnowAngelState (0x115). Against a live ACE server those -/// commands silently misclassified: the resolver returned the correct ACE -/// value (e.g. wire 0x0173 -> 0x10000173 OffhandSlashHigh), -/// but the planner's constant held the 2013 value (0x10000170), so no -/// match. Reload was worse — hardcoded 0x100000D4, a value -/// absent from DRW entirely; the real ACE/DRW Reload is the -/// 0x40000016 SubState. Deriving from the enum by name fixes the whole -/// block at once and drops ~80 magic numbers. -/// See docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md. -/// -/// internal static class CombatAnimationMotionCommands { - // Combat stances (Style class 0x80). Already correct before #159. - public const uint HandCombat = (uint)Drw.HandCombat; - public const uint SwordCombat = (uint)Drw.SwordCombat; - public const uint BowCombat = (uint)Drw.BowCombat; - public const uint SwordShieldCombat = (uint)Drw.SwordShieldCombat; - public const uint CrossbowCombat = (uint)Drw.CrossbowCombat; - public const uint SlingCombat = (uint)Drw.SlingCombat; - public const uint TwoHandedSwordCombat = (uint)Drw.TwoHandedSwordCombat; - public const uint TwoHandedStaffCombat = (uint)Drw.TwoHandedStaffCombat; - public const uint DualWieldCombat = (uint)Drw.DualWieldCombat; - public const uint ThrownWeaponCombat = (uint)Drw.ThrownWeaponCombat; - public const uint Magic = (uint)Drw.Magic; - public const uint AtlatlCombat = (uint)Drw.AtlatlCombat; - public const uint ThrownShieldCombat = (uint)Drw.ThrownShieldCombat; + public const uint HandCombat = 0x8000003Cu; + public const uint SwordCombat = 0x8000003Eu; + public const uint BowCombat = 0x8000003Fu; + public const uint SwordShieldCombat = 0x80000040u; + public const uint CrossbowCombat = 0x80000041u; + public const uint SlingCombat = 0x80000043u; + public const uint TwoHandedSwordCombat = 0x80000044u; + public const uint TwoHandedStaffCombat = 0x80000045u; + public const uint DualWieldCombat = 0x80000046u; + public const uint ThrownWeaponCombat = 0x80000047u; + public const uint Magic = 0x80000049u; + public const uint AtlatlCombat = 0x8000013Bu; + public const uint ThrownShieldCombat = 0x8000013Cu; - // Hit reactions / staggers (Action class 0x10, unshifted). - public const uint FallDown = (uint)Drw.FallDown; - public const uint Twitch1 = (uint)Drw.Twitch1; - public const uint Twitch2 = (uint)Drw.Twitch2; - public const uint Twitch3 = (uint)Drw.Twitch3; - public const uint Twitch4 = (uint)Drw.Twitch4; - public const uint StaggerBackward = (uint)Drw.StaggerBackward; - public const uint StaggerForward = (uint)Drw.StaggerForward; - public const uint Sanctuary = (uint)Drw.Sanctuary; + public const uint FallDown = 0x10000050u; + public const uint Twitch1 = 0x10000051u; + public const uint Twitch2 = 0x10000052u; + public const uint Twitch3 = 0x10000053u; + public const uint Twitch4 = 0x10000054u; + public const uint StaggerBackward = 0x10000055u; + public const uint StaggerForward = 0x10000056u; + public const uint Sanctuary = 0x10000057u; + public const uint ThrustMed = 0x10000058u; + public const uint ThrustLow = 0x10000059u; + public const uint ThrustHigh = 0x1000005Au; + public const uint SlashHigh = 0x1000005Bu; + public const uint SlashMed = 0x1000005Cu; + public const uint SlashLow = 0x1000005Du; + public const uint BackhandHigh = 0x1000005Eu; + public const uint BackhandMed = 0x1000005Fu; + public const uint BackhandLow = 0x10000060u; + public const uint Shoot = 0x10000061u; + public const uint AttackHigh1 = 0x10000062u; + public const uint AttackMed1 = 0x10000063u; + public const uint AttackLow1 = 0x10000064u; + public const uint AttackHigh2 = 0x10000065u; + public const uint AttackMed2 = 0x10000066u; + public const uint AttackLow2 = 0x10000067u; + public const uint AttackHigh3 = 0x10000068u; + public const uint AttackMed3 = 0x10000069u; + public const uint AttackLow3 = 0x1000006Au; - // Single melee attacks (Action class 0x10, unshifted). - public const uint ThrustMed = (uint)Drw.ThrustMed; - public const uint ThrustLow = (uint)Drw.ThrustLow; - public const uint ThrustHigh = (uint)Drw.ThrustHigh; - public const uint SlashHigh = (uint)Drw.SlashHigh; - public const uint SlashMed = (uint)Drw.SlashMed; - public const uint SlashLow = (uint)Drw.SlashLow; - public const uint BackhandHigh = (uint)Drw.BackhandHigh; - public const uint BackhandMed = (uint)Drw.BackhandMed; - public const uint BackhandLow = (uint)Drw.BackhandLow; + public const uint MissileAttack1 = 0x100000D0u; + public const uint MissileAttack2 = 0x100000D1u; + public const uint MissileAttack3 = 0x100000D2u; + public const uint CastSpell = 0x400000D3u; + public const uint Reload = 0x100000D4u; + public const uint UseMagicStaff = 0x400000E0u; + public const uint UseMagicWand = 0x400000E1u; - // Missile attacks (Action 0x10) + reload (SubState 0x40, NOT the dead - // 2013 0x100000D4 — the real DRW Reload is 0x40000016). - public const uint Shoot = (uint)Drw.Shoot; - public const uint MissileAttack1 = (uint)Drw.MissileAttack1; - public const uint MissileAttack2 = (uint)Drw.MissileAttack2; - public const uint MissileAttack3 = (uint)Drw.MissileAttack3; - public const uint Reload = (uint)Drw.Reload; + public const uint DoubleSlashLow = 0x1000011Fu; + public const uint DoubleSlashMed = 0x10000120u; + public const uint DoubleSlashHigh = 0x10000121u; + public const uint TripleSlashLow = 0x10000122u; + public const uint TripleSlashMed = 0x10000123u; + public const uint TripleSlashHigh = 0x10000124u; + public const uint DoubleThrustLow = 0x10000125u; + public const uint DoubleThrustMed = 0x10000126u; + public const uint DoubleThrustHigh = 0x10000127u; + public const uint TripleThrustLow = 0x10000128u; + public const uint TripleThrustMed = 0x10000129u; + public const uint TripleThrustHigh = 0x1000012Au; - // Creature attacks 1-6 (Action class 0x10). 1-3 are the unshifted low - // block (0x62-0x6A); 4-6 live in the shifted late block (0x186+). - public const uint AttackHigh1 = (uint)Drw.AttackHigh1; - public const uint AttackMed1 = (uint)Drw.AttackMed1; - public const uint AttackLow1 = (uint)Drw.AttackLow1; - public const uint AttackHigh2 = (uint)Drw.AttackHigh2; - public const uint AttackMed2 = (uint)Drw.AttackMed2; - public const uint AttackLow2 = (uint)Drw.AttackLow2; - public const uint AttackHigh3 = (uint)Drw.AttackHigh3; - public const uint AttackMed3 = (uint)Drw.AttackMed3; - public const uint AttackLow3 = (uint)Drw.AttackLow3; - public const uint AttackHigh4 = (uint)Drw.AttackHigh4; - public const uint AttackMed4 = (uint)Drw.AttackMed4; - public const uint AttackLow4 = (uint)Drw.AttackLow4; - public const uint AttackHigh5 = (uint)Drw.AttackHigh5; - public const uint AttackMed5 = (uint)Drw.AttackMed5; - public const uint AttackLow5 = (uint)Drw.AttackLow5; - public const uint AttackHigh6 = (uint)Drw.AttackHigh6; - public const uint AttackMed6 = (uint)Drw.AttackMed6; - public const uint AttackLow6 = (uint)Drw.AttackLow6; - - // Spell casts (SubState class 0x40). - public const uint CastSpell = (uint)Drw.CastSpell; - public const uint UseMagicStaff = (uint)Drw.UseMagicStaff; - public const uint UseMagicWand = (uint)Drw.UseMagicWand; - - // Multi-strike melee (Action class 0x10, unshifted 0x11F-0x12A). - public const uint DoubleSlashLow = (uint)Drw.DoubleSlashLow; - public const uint DoubleSlashMed = (uint)Drw.DoubleSlashMed; - public const uint DoubleSlashHigh = (uint)Drw.DoubleSlashHigh; - public const uint TripleSlashLow = (uint)Drw.TripleSlashLow; - public const uint TripleSlashMed = (uint)Drw.TripleSlashMed; - public const uint TripleSlashHigh = (uint)Drw.TripleSlashHigh; - public const uint DoubleThrustLow = (uint)Drw.DoubleThrustLow; - public const uint DoubleThrustMed = (uint)Drw.DoubleThrustMed; - public const uint DoubleThrustHigh = (uint)Drw.DoubleThrustHigh; - public const uint TripleThrustLow = (uint)Drw.TripleThrustLow; - public const uint TripleThrustMed = (uint)Drw.TripleThrustMed; - public const uint TripleThrustHigh = (uint)Drw.TripleThrustHigh; - - // Offhand strikes (Action class 0x10, shifted late block 0x173+). - public const uint OffhandSlashHigh = (uint)Drw.OffhandSlashHigh; - public const uint OffhandSlashMed = (uint)Drw.OffhandSlashMed; - public const uint OffhandSlashLow = (uint)Drw.OffhandSlashLow; - public const uint OffhandThrustHigh = (uint)Drw.OffhandThrustHigh; - public const uint OffhandThrustMed = (uint)Drw.OffhandThrustMed; - public const uint OffhandThrustLow = (uint)Drw.OffhandThrustLow; - public const uint OffhandDoubleSlashLow = (uint)Drw.OffhandDoubleSlashLow; - public const uint OffhandDoubleSlashMed = (uint)Drw.OffhandDoubleSlashMed; - public const uint OffhandDoubleSlashHigh = (uint)Drw.OffhandDoubleSlashHigh; - public const uint OffhandTripleSlashLow = (uint)Drw.OffhandTripleSlashLow; - public const uint OffhandTripleSlashMed = (uint)Drw.OffhandTripleSlashMed; - public const uint OffhandTripleSlashHigh = (uint)Drw.OffhandTripleSlashHigh; - public const uint OffhandDoubleThrustLow = (uint)Drw.OffhandDoubleThrustLow; - public const uint OffhandDoubleThrustMed = (uint)Drw.OffhandDoubleThrustMed; - public const uint OffhandDoubleThrustHigh = (uint)Drw.OffhandDoubleThrustHigh; - public const uint OffhandTripleThrustLow = (uint)Drw.OffhandTripleThrustLow; - public const uint OffhandTripleThrustMed = (uint)Drw.OffhandTripleThrustMed; - public const uint OffhandTripleThrustHigh = (uint)Drw.OffhandTripleThrustHigh; - public const uint OffhandKick = (uint)Drw.OffhandKick; - - // Punches (Action class 0x10, shifted late block). - public const uint PunchFastHigh = (uint)Drw.PunchFastHigh; - public const uint PunchFastMed = (uint)Drw.PunchFastMed; - public const uint PunchFastLow = (uint)Drw.PunchFastLow; - public const uint PunchSlowHigh = (uint)Drw.PunchSlowHigh; - public const uint PunchSlowMed = (uint)Drw.PunchSlowMed; - public const uint PunchSlowLow = (uint)Drw.PunchSlowLow; - public const uint OffhandPunchFastHigh = (uint)Drw.OffhandPunchFastHigh; - public const uint OffhandPunchFastMed = (uint)Drw.OffhandPunchFastMed; - public const uint OffhandPunchFastLow = (uint)Drw.OffhandPunchFastLow; - public const uint OffhandPunchSlowHigh = (uint)Drw.OffhandPunchSlowHigh; - public const uint OffhandPunchSlowMed = (uint)Drw.OffhandPunchSlowMed; - public const uint OffhandPunchSlowLow = (uint)Drw.OffhandPunchSlowLow; + public const uint OffhandSlashHigh = 0x10000170u; + public const uint OffhandSlashMed = 0x10000171u; + public const uint OffhandSlashLow = 0x10000172u; + public const uint OffhandThrustHigh = 0x10000173u; + public const uint OffhandThrustMed = 0x10000174u; + public const uint OffhandThrustLow = 0x10000175u; + public const uint OffhandDoubleSlashLow = 0x10000176u; + public const uint OffhandDoubleSlashMed = 0x10000177u; + public const uint OffhandDoubleSlashHigh = 0x10000178u; + public const uint OffhandTripleSlashLow = 0x10000179u; + public const uint OffhandTripleSlashMed = 0x1000017Au; + public const uint OffhandTripleSlashHigh = 0x1000017Bu; + public const uint OffhandDoubleThrustLow = 0x1000017Cu; + public const uint OffhandDoubleThrustMed = 0x1000017Du; + public const uint OffhandDoubleThrustHigh = 0x1000017Eu; + public const uint OffhandTripleThrustLow = 0x1000017Fu; + public const uint OffhandTripleThrustMed = 0x10000180u; + public const uint OffhandTripleThrustHigh = 0x10000181u; + public const uint OffhandKick = 0x10000182u; + public const uint AttackHigh4 = 0x10000183u; + public const uint AttackMed4 = 0x10000184u; + public const uint AttackLow4 = 0x10000185u; + public const uint AttackHigh5 = 0x10000186u; + public const uint AttackMed5 = 0x10000187u; + public const uint AttackLow5 = 0x10000188u; + public const uint AttackHigh6 = 0x10000189u; + public const uint AttackMed6 = 0x1000018Au; + public const uint AttackLow6 = 0x1000018Bu; + public const uint PunchFastHigh = 0x1000018Cu; + public const uint PunchFastMed = 0x1000018Du; + public const uint PunchFastLow = 0x1000018Eu; + public const uint PunchSlowHigh = 0x1000018Fu; + public const uint PunchSlowMed = 0x10000190u; + public const uint PunchSlowLow = 0x10000191u; + public const uint OffhandPunchFastHigh = 0x10000192u; + public const uint OffhandPunchFastMed = 0x10000193u; + public const uint OffhandPunchFastLow = 0x10000194u; + public const uint OffhandPunchSlowHigh = 0x10000195u; + public const uint OffhandPunchSlowMed = 0x10000196u; + public const uint OffhandPunchSlowLow = 0x10000197u; } diff --git a/src/AcDream.Core/Combat/CombatState.cs b/src/AcDream.Core/Combat/CombatState.cs index 1143115e..15018b0f 100644 --- a/src/AcDream.Core/Combat/CombatState.cs +++ b/src/AcDream.Core/Combat/CombatState.cs @@ -92,16 +92,6 @@ public sealed class CombatState public float GetHealthPercent(uint guid) => _healthByGuid.TryGetValue(guid, out var pct) ? pct : 1f; - /// - /// True if an UpdateHealth (0x01C0) has ever been received for this guid — i.e. the - /// server has reported real health for it (via damage broadcast or a successful - /// assess/QueryHealth reply). Distinguishes a known value from the 1.0 default that - /// returns for unseen guids. Used by the selected-object - /// meter to gate visibility (retail shows the bar only once health is known — - /// gmToolbarUI::RecvNotice_UpdateObjectHealth, acclient_2013_pseudo_c.txt:196213). - /// - public bool HasHealth(uint guid) => _healthByGuid.ContainsKey(guid); - public int TrackedTargetCount => _healthByGuid.Count; // ── Inbound handlers (wired from WorldSession.GameEvents) ──────────────── diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs deleted file mode 100644 index a80b0fb2..00000000 --- a/src/AcDream.Core/Items/ClientObjectTable.cs +++ /dev/null @@ -1,512 +0,0 @@ -using System; -using System.Collections.Concurrent; -using System.Collections.Generic; - -namespace AcDream.Core.Items; - -/// -/// The client's table of every server object (retail weenie_object_table / -/// CObjectMaint). Resolve by guid via Get. -/// -/// -/// Retail semantics (r06): -/// -/// -/// Every object is a with a unique -/// ObjectId. CreateObject seeds it when the server tells us -/// the item exists (in our inventory, on the ground, in a -/// vendor's list, etc). -/// -/// -/// Moves happen via -carrying messages: -/// WieldObject, InventoryPutObjInContainer, -/// InventoryPutObjectIn3D, ViewContents, -/// CloseGroundContainer. -/// -/// -/// InventoryServerSaveFailed reverts a speculative local -/// state change (e.g. when a drag-drop was rejected server-side). -/// -/// -/// -/// -/// -/// Thread safety: designed for single-threaded use from the render -/// thread; the event delegates run synchronously on the caller's -/// thread. A backs the -/// map so plugin code can look up items from any thread without -/// corrupting state. -/// -/// -public sealed class ClientObjectTable -{ - private readonly ConcurrentDictionary _objects = new(); - private readonly ConcurrentDictionary _containers = new(); - private readonly Dictionary> _containerIndex = new(); - - // B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE - // the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0), - // cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo. - private readonly Dictionary _pendingMoves = new(); - - /// Fires when an object is first added to the session. - public event Action? ObjectAdded; - - /// - /// Fires when an object's container / slot changes (moved between - /// packs, equipped, unequipped, dropped on ground). Old and new - /// container ids are 0 if origin or destination is "world" / "nowhere". - /// - public event Action? ObjectMoved; - - /// Fires when an object is removed from the session. - public event Action? ObjectRemoved; - - /// Fires when an object's properties are updated (typically after Appraise). - public event Action? ObjectUpdated; - - /// PropertyInt.UiEffects (ACE enum value 18) — the icon effect bitfield; - /// the typed mirror maintains on - /// . - public const uint UiEffectsPropertyId = 18u; - - public int ObjectCount => _objects.Count; - public int ContainerCount => _containers.Count; - - public IEnumerable Objects => _objects.Values; - public IEnumerable Containers => _containers.Values; - - /// - /// Look up an object by its server-assigned ObjectId. - /// - public ClientObject? Get(uint objectId) => - _objects.TryGetValue(objectId, out var item) ? item : null; - - /// - /// Look up a container by object id, creating a lightweight stub if - /// the id doesn't match any known container (defensive — avoids losing - /// references when the server announces a move into a container it - /// hasn't described yet). - /// - public Container? GetContainer(uint objectId) => - _containers.TryGetValue(objectId, out var c) ? c : null; - - /// - /// Register / refresh an object in the table. Called on - /// CreateObject for item-typed weenies and on IdentifyObjectResponse - /// to fill in detail properties. - /// Does NOT update the container index — use Ingest for container-tracked objects. - /// - public void AddOrUpdate(ClientObject item) - { - ArgumentNullException.ThrowIfNull(item); - bool existed = _objects.ContainsKey(item.ObjectId); - _objects[item.ObjectId] = item; - if (!existed) ObjectAdded?.Invoke(item); - else ObjectUpdated?.Invoke(item); - } - - /// - /// Register a container. Idempotent. - /// - public void AddContainer(Container container) - { - ArgumentNullException.ThrowIfNull(container); - _containers[container.ObjectId] = container; - } - - /// - /// Handle a server-driven move — called from - /// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023) - /// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation - /// and fires ObjectMoved. Does NOT touch WielderId — neither the wield nor the - /// unwield path manages it (a wielded item is modeled as contained-by-the-wielder), - /// so RollbackMove restores full pre-move state through this method alone. - /// - public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1, - EquipMask newEquipLocation = EquipMask.None) - { - if (!_objects.TryGetValue(itemId, out var item)) return false; - - uint oldContainer = item.ContainerId; - item.ContainerId = newContainerId; - item.ContainerSlot = newSlot; - item.CurrentlyEquippedLocation = newEquipLocation; - Reindex(item, oldContainer); - ObjectMoved?.Invoke(item, oldContainer, newContainerId); - return true; - } - - /// Snapshot the item's pre-move (container, slot, equip) the FIRST time it moves, and - /// bump the OUTSTANDING count on subsequent in-flight moves of the same item (so an early confirm - /// can't clear a still-pending later move — the I1 hardening). Shared by MoveItemOptimistic (unwield/ - /// move) and WieldItemOptimistic (wield). - private void RecordPending(uint itemId, ClientObject item) - { - if (_pendingMoves.TryGetValue(itemId, out var p)) - _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding + 1); - else - _pendingMoves[itemId] = (item.ContainerId, item.ContainerSlot, - item.CurrentlyEquippedLocation, 1); - } - - /// - /// Optimistic (instant) move: snapshot the item's current (ContainerId, ContainerSlot) the FIRST - /// time it moves, then (immediate repaint via ObjectMoved). The wire - /// PutItemInContainer is sent by the caller; / - /// reconcile against the server. Retail: local ItemList_InsertItem + ServerSaysMoveItem reconcile. - /// - public bool MoveItemOptimistic(uint itemId, uint newContainerId, int newSlot) - { - if (!_objects.TryGetValue(itemId, out var item)) return false; - // Snapshot the ORIGINAL position once + count OUTSTANDING optimistic moves of this item. The - // count stops an early confirm (0x0022) for the first of several in-flight moves of the SAME - // item from clearing the snapshot while a later move is still unconfirmed — else a later - // reject would find nothing pending and strand the item at the rejected position. - RecordPending(itemId, item); - - // Gapless INSERT — treat newSlot as the insert INDEX, shift the other items, and renumber both - // containers' slots 0..N-1. A raw MoveItem sets ONLY this item's slot, which then collides with - // the item already at that slot; the sort-by-slot tie reshuffles the whole grid on every repaint - // (the "items change position when I move one" bug). Retail's ItemList_InsertItem shifts the list. - uint oldContainer = item.ContainerId; - item.ContainerId = newContainerId; - item.CurrentlyEquippedLocation = EquipMask.None; - - if (oldContainer != 0 && oldContainer != newContainerId - && _containerIndex.TryGetValue(oldContainer, out var srcList)) - { srcList.Remove(itemId); RenumberContainer(srcList); } // keep the source gapless too - - if (newContainerId != 0) - { - if (!_containerIndex.TryGetValue(newContainerId, out var dstList)) - _containerIndex[newContainerId] = dstList = new List(); - dstList.Remove(itemId); // same-container move: pull out first - int idx = (newSlot < 0 || newSlot > dstList.Count) ? dstList.Count : newSlot; - dstList.Insert(idx, itemId); - RenumberContainer(dstList); // sequential ContainerSlots → no ties - } - else item.ContainerSlot = newSlot; - - ObjectMoved?.Invoke(item, oldContainer, newContainerId); - return true; - } - - /// Optimistic (instant) wield: snapshot the pre-wield (container, slot, equip), then set - /// ContainerId = wielderGuid and CurrentlyEquippedLocation = equipMask — EXACTLY what the server's - /// WieldObject 0x0023 confirm does via (acdream models a wielded item as - /// contained-by-the-wielder). Neither path writes WielderId, so the optimistic state equals the - /// confirmed state and via MoveItem fully restores pre-move state. Fires - /// ObjectMoved for an immediate repaint. The caller sends GetAndWieldItem; ConfirmMove (on the 0x0023 - /// echo) / RollbackMove (on 0x00A0) reconcile. - public bool WieldItemOptimistic(uint itemId, uint wielderGuid, EquipMask equipMask) - { - if (!_objects.TryGetValue(itemId, out var item)) return false; - RecordPending(itemId, item); - return MoveItem(itemId, wielderGuid, newSlot: -1, newEquipLocation: equipMask); - } - - /// Assign each item in a sequential ContainerSlot (0..N-1) matching - /// its list position — keeps a container gapless + collision-free so the grid order is stable. - private void RenumberContainer(List list) - { - for (int i = 0; i < list.Count; i++) - if (_objects.TryGetValue(list[i], out var o)) o.ContainerSlot = i; - } - - /// The server confirmed a move (InventoryPutObjInContainer 0x0022 echo) — decrement the - /// outstanding count; drop the snapshot only once no optimistic moves of this item remain. No-op - /// for a server-initiated move we never tracked. - public void ConfirmMove(uint itemId) - { - if (!_pendingMoves.TryGetValue(itemId, out var p)) return; - if (p.outstanding <= 1) _pendingMoves.Remove(itemId); - else _pendingMoves[itemId] = (p.container, p.slot, p.equip, p.outstanding - 1); - } - - /// The server rejected a move (InventoryServerSaveFailed 0x00A0) — restore the item to its - /// pre-move (container, slot) and drop the snapshot entirely (the server's next snapshot reconciles - /// any still-outstanding moves). False if nothing was pending. - public bool RollbackMove(uint itemId) - { - if (!_pendingMoves.TryGetValue(itemId, out var pre)) return false; - _pendingMoves.Remove(itemId); - return MoveItem(itemId, pre.container, pre.slot, pre.equip); - } - - /// - /// Handle a server-driven remove (destroyed item, dropped into 3D - /// space, stolen, etc). - /// - public bool Remove(uint itemId) - { - if (!_objects.TryRemove(itemId, out var item)) return false; - if (item.ContainerId != 0 && _containerIndex.TryGetValue(item.ContainerId, out var l)) - l.Remove(itemId); - _pendingMoves.Remove(itemId); // a destroyed item must not leave a snapshot that mis-rolls-back a recycled guid - ObjectRemoved?.Invoke(item); - return true; - } - - /// - /// Apply a patch (e.g. from an - /// IdentifyObjectResponse) to an existing object. Individual - /// keys in the incoming bundle overwrite existing values; keys not - /// present are left untouched. - /// - public bool UpdateProperties(uint itemId, PropertyBundle incoming) - { - if (!_objects.TryGetValue(itemId, out var item)) return false; - foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; - foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; - foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value; - foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value; - foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; - foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; - foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; - ObjectUpdated?.Invoke(item); - return true; - } - - /// - /// Apply a patch, creating the object if it does not - /// exist yet. Used for the local player's own properties from PlayerDescription - /// (0x0013), which may arrive BEFORE the player's CreateObject — unlike - /// , which no-ops on an unknown object. Fires - /// ObjectAdded on create, else ObjectUpdated. - /// - public void UpsertProperties(uint guid, PropertyBundle incoming) - { - ArgumentNullException.ThrowIfNull(incoming); - bool existed = _objects.TryGetValue(guid, out var item); - if (!existed || item is null) - { - item = new ClientObject { ObjectId = guid }; - _objects[guid] = item; - } - foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; - foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; - foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value; - foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value; - foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; - foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; - foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; - if (!existed) ObjectAdded?.Invoke(item); else ObjectUpdated?.Invoke(item); - } - - /// - /// Apply a single PropertyInt update (from PublicUpdatePropertyInt 0x02CE) to an - /// object: store it in the bundle and, for known typed ints, mirror to the typed - /// field. Today: UiEffects (18) → . Fires - /// ObjectUpdated so bound widgets re-composite. Extensible hook for future - /// typed PropertyInts (StackSize, Structure, …). False if the object is unknown. - /// - public bool UpdateIntProperty(uint itemId, uint propertyId, int value) - { - if (!_objects.TryGetValue(itemId, out var item)) return false; - item.Properties.Ints[propertyId] = value; - if (propertyId == UiEffectsPropertyId) item.Effects = (uint)value; - ObjectUpdated?.Invoke(item); - return true; - } - - /// - /// Apply a SetStackSize (0x0197) update: set the object's StackSize + Value and - /// fire ObjectUpdated so bound widgets refresh the quantity overlay. False if the - /// object is unknown. Retail: ACCWeenieObject::ServerSaysSetStackSize (0x0058...). - /// - public bool UpdateStackSize(uint guid, int stackSize, int value) - { - if (!_objects.TryGetValue(guid, out var item)) return false; - item.StackSize = stackSize; - item.Value = value; - ObjectUpdated?.Invoke(item); - return true; - } - - /// - /// Canonical CreateObject ingestion: create-if-absent, else patch the - /// wire-carried fields in place (retail SetWeenieDesc). Preserves the - /// PropertyBundle (appraise) and any field the wire didn't carry. - /// Effects is assigned unconditionally (0 clears) — the D.5.2 icon contract. - /// - public ClientObject Ingest(WeenieData d) - { - bool existed = _objects.TryGetValue(d.Guid, out var obj); - if (!existed || obj is null) // keep: satisfies nullable flow analysis - { - obj = new ClientObject { ObjectId = d.Guid }; - _objects[d.Guid] = obj; - } - uint oldContainer = obj.ContainerId; - - if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!; - if (d.Type is { } t) obj.Type = t; - // WeenieClassId arrives on every CreateObject (fixed prefix) and is never - // legitimately 0 for a real weenie; the != 0 guard avoids clobbering a known - // class id with a spurious 0 (and leaves a PD stub's 0 until CreateObject fills it). - if (d.WeenieClassId != 0) obj.WeenieClassId = d.WeenieClassId; - if (d.IconId != 0) obj.IconId = d.IconId; - if (d.IconOverlayId != 0) obj.IconOverlayId = d.IconOverlayId; - if (d.IconUnderlayId != 0) obj.IconUnderlayId = d.IconUnderlayId; - obj.Effects = d.Effects; // D.5.2 contract - if (d.Value is { } v) obj.Value = v; - if (d.StackSize is { } s) obj.StackSize = s; - if (d.StackSizeMax is { } sm) obj.StackSizeMax = sm; - if (d.Burden is { } b) obj.Burden = b; - if (d.ContainerId is { } c) obj.ContainerId = c; - if (d.WielderId is { } w) obj.WielderId = w; - if (d.ValidLocations is { } vl) obj.ValidLocations = (EquipMask)vl; - if (d.CurrentWieldedLocation is { } cwl) obj.CurrentlyEquippedLocation = (EquipMask)cwl; - if (d.Priority is { } pr) obj.Priority = pr; - if (d.ItemsCapacity is { } ic) obj.ItemsCapacity = ic; - if (d.ContainersCapacity is { } cc) obj.ContainersCapacity = cc; - if (d.Structure is { } st) obj.Structure = st; - if (d.MaxStructure is { } ms) obj.MaxStructure = ms; - if (d.Workmanship is { } wm) obj.Workmanship = wm; - - Reindex(obj, oldContainer); - if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); - return obj; - } - - /// - /// PlayerDescription manifest: record that this guid is the player's - /// (in inventory or equipped at ), creating an - /// empty entry if CreateObject hasn't arrived yet. Never touches - /// icon/name/type/effects — that data comes from CreateObject. - /// - public ClientObject RecordMembership(uint guid, uint containerId = 0, - EquipMask equip = EquipMask.None) - { - bool existed = _objects.TryGetValue(guid, out var obj); - if (!existed || obj is null) // keep: satisfies nullable flow analysis - { - obj = new ClientObject { ObjectId = guid }; - _objects[guid] = obj; - } - uint oldContainer = obj.ContainerId; - if (containerId != 0) obj.ContainerId = containerId; - if (equip != EquipMask.None) obj.CurrentlyEquippedLocation = equip; - Reindex(obj, oldContainer); - if (!existed) ObjectAdded?.Invoke(obj); else ObjectUpdated?.Invoke(obj); - return obj; - } - - private void Reindex(ClientObject obj, uint oldContainerId) - { - if (oldContainerId != obj.ContainerId && oldContainerId != 0 - && _containerIndex.TryGetValue(oldContainerId, out var oldList)) - oldList.Remove(obj.ObjectId); - - if (obj.ContainerId != 0) - { - if (!_containerIndex.TryGetValue(obj.ContainerId, out var list)) - _containerIndex[obj.ContainerId] = list = new List(); - if (!list.Contains(obj.ObjectId)) list.Add(obj.ObjectId); - list.Sort((a, b) => SlotOf(a).CompareTo(SlotOf(b))); - } - } - - private int SlotOf(uint guid) => - _objects.TryGetValue(guid, out var o) ? o.ContainerSlot : int.MaxValue; - - /// - /// Ordered item guids in a container (retail object_inventory_table), by ContainerSlot. - /// Returns a SNAPSHOT (safe to hold / read off-thread); empty for an unknown container. - /// - public IReadOnlyList GetContents(uint containerId) => - _containerIndex.TryGetValue(containerId, out var l) - ? l.ToArray() : System.Array.Empty(); - - /// - /// Replace a container's entire membership with (in order) — the - /// authoritative full snapshot the server sends in ViewContents (0x0196) when you open a - /// container. Members no longer present are detached (ContainerId → 0); new members are - /// recorded with ContainerSlot = list index. ACE writes ViewContents entries - /// OrderBy(PlacementPosition) with NO explicit slot field (GameEventViewContents.cs), so the - /// list order IS the slot order. Fires ObjectAdded/ObjectMoved so bound UI repaints. - /// Retail consumer: ClientUISystem::OnViewContents. - /// - public void ReplaceContents(uint containerId, IReadOnlyList guids) - { - ArgumentNullException.ThrowIfNull(guids); - if (containerId == 0) return; - - var keep = new HashSet(guids); - - // Detach prior members no longer present (they left the container server-side). GetContents - // returns a snapshot, so mutating the index inside the loop is safe. - foreach (var old in GetContents(containerId)) - { - if (keep.Contains(old)) continue; - if (!_objects.TryGetValue(old, out var o) || o.ContainerId != containerId) continue; - o.ContainerId = 0; - Reindex(o, containerId); - ObjectMoved?.Invoke(o, containerId, 0); - } - - // Record new members in order; ContainerSlot = index reconstructs the server PlacementPosition. - for (int i = 0; i < guids.Count; i++) - { - uint g = guids[i]; - bool existed = _objects.TryGetValue(g, out var obj); - if (!existed || obj is null) - { - obj = new ClientObject { ObjectId = g }; - _objects[g] = obj; - } - uint oldContainer = obj.ContainerId; - obj.ContainerId = containerId; - obj.ContainerSlot = i; - Reindex(obj, oldContainer); - if (!existed) ObjectAdded?.Invoke(obj); - else ObjectMoved?.Invoke(obj, oldContainer, containerId); - } - } - - /// - /// Σ Burden over every object carried by : items - /// whose container chain roots at the owner (pack + side-bag contents, retail - /// hierarchy is 2-deep) plus items wielded by the owner. The client-side - /// equivalent of the server's EncumbranceVal (PropertyInt 5) — used by - /// the inventory burden bar until the wire value is parsed (B-Wire). The hop - /// cap (8) is a cycle guard; real chains are ≤2. - /// - public int SumCarriedBurden(uint ownerGuid) - { - int total = 0; - foreach (var o in _objects.Values) - if (IsCarriedBy(o, ownerGuid)) - total += o.Burden; - return total; - } - - // NOTE: WielderId is populated from wire data only (CreateObject → Ingest). An - // optimistically-wielded item (WieldItemOptimistic, before the server confirm) has - // WielderId == 0 and is detected via the ContainerId walk below (ContainerId == wielder). - // A future "is this wielded by me?" check must therefore use ContainerId, NOT WielderId alone. - private bool IsCarriedBy(ClientObject o, uint ownerGuid) - { - if (o.WielderId == ownerGuid) return true; - uint c = o.ContainerId; - for (int hops = 0; c != 0 && hops < 8; hops++) - { - if (c == ownerGuid) return true; - c = _objects.TryGetValue(c, out var parent) ? parent.ContainerId : 0u; - } - return false; - } - - /// - /// Flush the table — typically called on logoff or teleport - /// that drops the session's object state. - /// - public void Clear() - { - _objects.Clear(); - _containers.Clear(); - _containerIndex.Clear(); - _pendingMoves.Clear(); // B-Drag: drop in-flight optimistic snapshots (a recycled guid must not mis-rollback) - } -} diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ItemInstance.cs similarity index 51% rename from src/AcDream.Core/Items/ClientObject.cs rename to src/AcDream.Core/Items/ItemInstance.cs index b058bf20..d1b5685f 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ItemInstance.cs @@ -57,48 +57,46 @@ public enum ItemType : uint } /// -/// Equipment slot bitmask — the verbatim retail INVENTORY_LOC enum -/// (docs/research/named-retail/acclient.h:3193; identical to ACE's EquipMask). -/// The wire (ValidLocations / CurrentWieldedLocation / WieldObject EquipLoc) delivers -/// these exact bits. Pinned by EquipMaskTests — do NOT renumber. -/// (The header's CLOTHING_LOC composite also sets bit 31, 0x80000000, which is -/// not a named INVENTORY_LOC primitive and has no member here; ALL_LOC tops out at bit 30.) +/// Equipment slot bitmask. 31 slots from head to Aetheria. Paperdoll +/// widget offsets +0x604..+0x660 in the retail panel correspond +/// to these bits 1:1 (see r06 §2 and UI slice 05 paperdoll section). /// [Flags] public enum EquipMask : uint { - None = 0x00000000, - HeadWear = 0x00000001, - ChestWear = 0x00000002, - AbdomenWear = 0x00000004, - UpperArmWear = 0x00000008, - LowerArmWear = 0x00000010, - HandWear = 0x00000020, - UpperLegWear = 0x00000040, - LowerLegWear = 0x00000080, - FootWear = 0x00000100, - ChestArmor = 0x00000200, - AbdomenArmor = 0x00000400, - UpperArmArmor = 0x00000800, - LowerArmArmor = 0x00001000, - UpperLegArmor = 0x00002000, - LowerLegArmor = 0x00004000, - NeckWear = 0x00008000, - WristWearLeft = 0x00010000, - WristWearRight = 0x00020000, - FingerWearLeft = 0x00040000, - FingerWearRight = 0x00080000, - MeleeWeapon = 0x00100000, - Shield = 0x00200000, - MissileWeapon = 0x00400000, - MissileAmmo = 0x00800000, - Held = 0x01000000, - TwoHanded = 0x02000000, - TrinketOne = 0x04000000, - Cloak = 0x08000000, - SigilOne = 0x10000000, - SigilTwo = 0x20000000, - SigilThree = 0x40000000, + None = 0, + HeadWear = 0x00000001, + ChestWear = 0x00000002, + AbdomenWear = 0x00000004, + UpperArmWear = 0x00000008, + LowerArmWear = 0x00000010, + HandWear = 0x00000020, + UpperLegWear = 0x00000040, + LowerLegWear = 0x00000080, + FootWear = 0x00000100, + ChestArmor = 0x00000200, + AbdomenArmor = 0x00000400, + UpperArmArmor = 0x00000800, + LowerArmArmor = 0x00001000, + HandArmor = 0x00002000, + UpperLegArmor = 0x00004000, + LowerLegArmor = 0x00008000, + FootArmor = 0x00010000, + Necklace = 0x00020000, + LeftBracelet = 0x00040000, + RightBracelet = 0x00080000, + LeftRing = 0x00100000, + RightRing = 0x00200000, + MeleeWeapon = 0x00400000, + Shield = 0x00800000, + MissileWeapon = 0x01000000, + Held = 0x02000000, // lit torch, book in hand + MissileAmmo = 0x04000000, + Cloak = 0x08000000, + TrinketOne = 0x10000000, + AetheriaRed = 0x20000000, + AetheriaYellow= 0x40000000, + AetheriaBlue = 0x80000000u, } /// @@ -123,13 +121,14 @@ public sealed class PropertyBundle } /// -/// Per-object live state (the data side of every server object — items and creatures alike). -/// Retail ACCWeenieObject. +/// Per-item live state. The server owns item identity (ObjectId); +/// acdream mirrors properties here on CreateObject and updates +/// via UpdateProperty* messages. /// -public sealed class ClientObject +public sealed class ItemInstance { public uint ObjectId { get; init; } - public uint WeenieClassId { get; set; } // "blueprint" + public uint WeenieClassId { get; init; } // "blueprint" public string Name { get; set; } = ""; public ItemType Type { get; set; } public EquipMask ValidLocations { get; set; } @@ -137,13 +136,6 @@ public sealed class ClientObject public uint IconId { get; set; } // 0x06xxxxxx public uint IconUnderlayId{ get; set; } // "magic" underlay public uint IconOverlayId { get; set; } // "enchanted" overlay - /// - /// UiEffects bitfield (retail PublicWeenieDesc._effects, acclient.h:37183). - /// Drives the icon's effect-overlay recolor (Magical=0x1 … Nether=0x1000). - /// CreateObject-only (weenieFlags 0x80) + live PublicUpdatePropertyInt(0x02CE); - /// appraise never carries it. 0 = no effect. - /// - public uint Effects { get; set; } public int StackSize { get; set; } = 1; public int StackSizeMax { get; set; } = 1; public int Burden { get; set; } // per-stack total @@ -152,49 +144,9 @@ public sealed class ClientObject public int ContainerSlot { get; set; } = -1; public bool Attuned { get; set; } public bool Bonded { get; set; } - public uint WielderId { get; set; } // PropertyInstanceId.Wielder; 0 = not wielded - public int ItemsCapacity { get; set; } // main-pack slots (containers) - public int ContainersCapacity{ get; set; } // side-pack slots (containers) - public uint Priority { get; set; } // ClothingPriority / CoverageMask layer order - public int Structure { get; set; } // charges/uses remaining - public int MaxStructure { get; set; } - public float Workmanship { get; set; } // 0..10 (fractional on the wire) public PropertyBundle Properties { get; } = new(); } -/// -/// The wire-delivered patch from a CreateObject (0xF745). Nullable fields -/// were gated by a WeenieHeader flag that was ABSENT — the merge upsert -/// (ClientObjectTable.Ingest) leaves the existing value untouched -/// for those, matching retail's SetWeenieDesc (patches only present fields). -/// Non-nullable id/effect fields use 0 = "not sent". Effects is assigned -/// unconditionally (0 clears) — the D.5.2 icon contract. Quantity fields are -/// int? (ACE PropertyInt convention); id/mask fields are uint?. -/// -public readonly record struct WeenieData( - uint Guid, - string? Name, - ItemType? Type, - uint WeenieClassId, - uint IconId, - uint IconOverlayId, - uint IconUnderlayId, - uint Effects, - int? Value, - int? StackSize, - int? StackSizeMax, - int? Burden, - uint? ContainerId, - uint? WielderId, - uint? ValidLocations, - uint? CurrentWieldedLocation, - uint? Priority, - int? ItemsCapacity, - int? ContainersCapacity, - int? Structure, - int? MaxStructure, - float? Workmanship); - /// /// Container = inventory pack. Hierarchy is strictly 2-deep: character /// → side packs; a side pack cannot hold another side pack (r06 §7). @@ -205,7 +157,7 @@ public sealed class Container public int Capacity { get; set; } = 102; // main inv default public int SideCapacity { get; set; } = 0; // 0 for side-pack public int BurdenLimit { get; set; } - public List Items { get; } = new(); + public List Items { get; } = new(); public List SidePacks { get; } = new(); // empty for side-pack public bool IsSidePack => SideCapacity == 0; } @@ -218,57 +170,6 @@ public static class BurdenMath { public const int BurdenPerStrength = 150; - /// Augmentation bonus-burden per aug rank (retail EncumbranceCapacity, 0x1e). - public const int AugBurdenPerRank = 30; - /// Max augmentation bonus-burden contribution per Strength (retail clamp 0x96). - public const int AugBurdenCap = 150; - - /// - /// Retail EncumbranceSystem::EncumbranceCapacity(strength, aug) - /// (named-retail decomp 256393, 0x004fcc00): - /// strength <= 0 ? 0 : strength*150 + clamp(aug*30, 0, 150)*strength. - /// - public static int EncumbranceCapacity(int strength, int aug) - { - if (strength <= 0) return 0; - int bonus = aug * AugBurdenPerRank; - if (bonus < 0) bonus = 0; // decomp: eax_2 < 0 -> base only - if (bonus > AugBurdenCap) bonus = AugBurdenCap; - return strength * BurdenPerStrength + bonus * strength; - } - - /// - /// Retail EncumbranceSystem::Load(capacity, burden) (decomp 256413, - /// 0x004fcc40): the encumbrance ratio burden / capacity - /// (1.0 = at capacity, up to ~3.0). The decompiler mangled the FP divide to - /// return burden; the cap <= 0 guard + single divide is unambiguous. - /// Returns 0 when capacity <= 0 (no-data). - /// - public static float LoadRatio(int capacity, int burden) - => capacity <= 0 ? 0f : (float)burden / capacity; - - /// - /// Retail gmBackpackUI::SetLoadLevel bar fill (decomp 176542, - /// 0x004a6ea6): clamp(load * 0.3333…, 0, 1) — the bar is 1/3 full at - /// 100% capacity, full at 300%. - /// - public static float LoadToFill(float load) - { - float fill = load / 3f; - if (fill < 0f) return 0f; - return fill > 1f ? 1f : fill; - } - - /// - /// Retail gmBackpackUI::SetLoadLevel percent text (decomp 176542-176576): the - /// percent is computed from the CLAMPED fill — arg2 = load/3 is clamped to [0,1] - /// (the 176544-176563 block sets arg2 = 1.0 when fill >= 1) BEFORE - /// floor(arg2 * 300). So the number SATURATES at 300% (it does NOT read 400% at - /// 4x capacity). Equivalent to floor(LoadToFill(load) * 300). - /// - public static int LoadToPercent(float load) - => (int)System.MathF.Floor(LoadToFill(load) * 300f); - public static int ComputeMax(int strength, int bonusBurden) => BurdenPerStrength * strength + strength * bonusBurden; diff --git a/src/AcDream.Core/Items/ItemRepository.cs b/src/AcDream.Core/Items/ItemRepository.cs new file mode 100644 index 00000000..02c864a2 --- /dev/null +++ b/src/AcDream.Core/Items/ItemRepository.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace AcDream.Core.Items; + +/// +/// Live item-state mirror — the client-side view of every item the +/// server has spawned for this session. Owns +/// records, tracks which container holds each item, and fires events so +/// UI panels (inventory, paperdoll, hotbars) can redraw on change. +/// +/// +/// Retail semantics (r06): +/// +/// +/// Every item is a with a unique +/// ObjectId. CreateObject seeds it when the server tells us +/// the item exists (in our inventory, on the ground, in a +/// vendor's list, etc). +/// +/// +/// Moves happen via -carrying messages: +/// WieldObject, InventoryPutObjInContainer, +/// InventoryPutObjectIn3D, ViewContents, +/// CloseGroundContainer. +/// +/// +/// InventoryServerSaveFailed reverts a speculative local +/// state change (e.g. when a drag-drop was rejected server-side). +/// +/// +/// +/// +/// +/// Thread safety: designed for single-threaded use from the render +/// thread; the event delegates run synchronously on the caller's +/// thread. A backs the +/// map so plugin code can look up items from any thread without +/// corrupting state. +/// +/// +public sealed class ItemRepository +{ + private readonly ConcurrentDictionary _items = new(); + private readonly ConcurrentDictionary _containers = new(); + + /// Fires when an item is first added to the session. + public event Action? ItemAdded; + + /// + /// Fires when an item's container / slot changes (moved between + /// packs, equipped, unequipped, dropped on ground). Old and new + /// container ids are 0 if origin or destination is "world" / "nowhere". + /// + public event Action? ItemMoved; + + /// Fires when an item is removed from the session. + public event Action? ItemRemoved; + + /// Fires when an item's properties are updated (typically after Appraise). + public event Action? ItemPropertiesUpdated; + + public int ItemCount => _items.Count; + public int ContainerCount => _containers.Count; + + public IEnumerable Items => _items.Values; + public IEnumerable Containers => _containers.Values; + + /// + /// Look up an item by its server-assigned ObjectId. + /// + public ItemInstance? GetItem(uint objectId) => + _items.TryGetValue(objectId, out var item) ? item : null; + + /// + /// Look up a container by object id, creating a lightweight stub if + /// the id doesn't match any known container (defensive — avoids losing + /// references when the server announces a move into a container it + /// hasn't described yet). + /// + public Container? GetContainer(uint objectId) => + _containers.TryGetValue(objectId, out var c) ? c : null; + + /// + /// Register / refresh an item in the repository. Called on + /// CreateObject for item-typed weenies and on IdentifyObjectResponse + /// to fill in detail properties. + /// + public void AddOrUpdate(ItemInstance item) + { + ArgumentNullException.ThrowIfNull(item); + bool existed = _items.ContainsKey(item.ObjectId); + _items[item.ObjectId] = item; + if (!existed) ItemAdded?.Invoke(item); + else ItemPropertiesUpdated?.Invoke(item); + } + + /// + /// Register a container. Idempotent. + /// + public void AddContainer(Container container) + { + ArgumentNullException.ThrowIfNull(container); + _containers[container.ObjectId] = container; + } + + /// + /// Handle a server-driven move — called from + /// InventoryPutObjInContainer (0x0022) and WieldObject (0x0023) + /// handlers. Updates ContainerId / ContainerSlot / CurrentlyEquippedLocation + /// and fires ItemMoved. + /// + public bool MoveItem(uint itemId, uint newContainerId, int newSlot = -1, + EquipMask newEquipLocation = EquipMask.None) + { + if (!_items.TryGetValue(itemId, out var item)) return false; + + uint oldContainer = item.ContainerId; + item.ContainerId = newContainerId; + item.ContainerSlot = newSlot; + item.CurrentlyEquippedLocation = newEquipLocation; + + ItemMoved?.Invoke(item, oldContainer, newContainerId); + return true; + } + + /// + /// Handle a server-driven remove (destroyed item, dropped into 3D + /// space, stolen, etc). + /// + public bool Remove(uint itemId) + { + if (!_items.TryRemove(itemId, out var item)) return false; + ItemRemoved?.Invoke(item); + return true; + } + + /// + /// Apply a patch (e.g. from an + /// IdentifyObjectResponse) to an existing item. Individual + /// keys in the incoming bundle overwrite existing values; keys not + /// present are left untouched. + /// + public bool UpdateProperties(uint itemId, PropertyBundle incoming) + { + if (!_items.TryGetValue(itemId, out var item)) return false; + foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value; + foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value; + foreach (var kv in incoming.Bools) item.Properties.Bools[kv.Key] = kv.Value; + foreach (var kv in incoming.Floats) item.Properties.Floats[kv.Key] = kv.Value; + foreach (var kv in incoming.Strings) item.Properties.Strings[kv.Key] = kv.Value; + foreach (var kv in incoming.DataIds) item.Properties.DataIds[kv.Key] = kv.Value; + foreach (var kv in incoming.InstanceIds) item.Properties.InstanceIds[kv.Key] = kv.Value; + ItemPropertiesUpdated?.Invoke(item); + return true; + } + + /// + /// Flush the repository — typically called on logoff or teleport + /// that drops the session's item state. + /// + public void Clear() + { + _items.Clear(); + _containers.Clear(); + } +} diff --git a/src/AcDream.Core/Items/ShortcutStore.cs b/src/AcDream.Core/Items/ShortcutStore.cs deleted file mode 100644 index 0f581a2d..00000000 --- a/src/AcDream.Core/Items/ShortcutStore.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace AcDream.Core.Items; - -/// -/// Mutable client-side model of the 18 toolbar shortcut slots — port of retail -/// ShortCutManager::shortCuts_[18] (acclient.h:36492). Holds the bound object guid per -/// slot (0 = empty). Loaded from the login shortcut list, then mutated by drag-drop (lift -/// removes, drop places); the server is notified via AddShortcut/RemoveShortcut so the store -/// stays the client's source of truth within a session (retail: client owns the array). -/// Item shortcuts only — entries with ObjGuid 0 (spell-only) are skipped on Load. Pure model -/// (no Core.Net dependency): callers project their wire entries to (slot, objGuid) pairs. -/// -public sealed class ShortcutStore -{ - public const int SlotCount = 18; - private readonly uint[] _objIds = new uint[SlotCount]; - - /// Replace all slots from a (slot, objectGuid) sequence (item entries only; - /// ObjGuid 0 and out-of-range slots are skipped). - public void Load(IEnumerable<(int Slot, uint ObjGuid)> entries) - { - Array.Clear(_objIds); - foreach (var (slot, objGuid) in entries) - if ((uint)slot < SlotCount && objGuid != 0) _objIds[slot] = objGuid; - } - - /// Bound object guid at , or 0 (empty / out of range). - public uint Get(int slot) => (uint)slot < SlotCount ? _objIds[slot] : 0u; - public bool IsEmpty(int slot) => Get(slot) == 0u; - public void Set(int slot, uint objId) { if ((uint)slot < SlotCount) _objIds[slot] = objId; } - public void Remove(int slot) { if ((uint)slot < SlotCount) _objIds[slot] = 0u; } -} diff --git a/src/AcDream.Core/Lighting/GlobalLightPacker.cs b/src/AcDream.Core/Lighting/GlobalLightPacker.cs deleted file mode 100644 index 3d718dee..00000000 --- a/src/AcDream.Core/Lighting/GlobalLightPacker.cs +++ /dev/null @@ -1,56 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace AcDream.Core.Lighting; - -/// -/// Packs a point-light snapshot into the flat float layout the bindless mesh -/// shader reads at SSBO binding=4 (mesh_modern.vert GlobalLight gLights[]): -/// 16 floats (4 vec4) per light — posAndKind, dirAndRange, colorAndIntensity, -/// coneAngleEtc. Pure (no GL), so both WbDrawDispatcher and -/// EnvCellRenderer share ONE layout and cannot drift. -/// -public static class GlobalLightPacker -{ - public const int FloatsPerLight = 16; - - /// - /// Fill (grown + zero-cleared as needed) with the - /// packed snapshot; returns the light count n. The buffer always has at - /// least floats (so a zero-light frame still - /// uploads a non-empty SSBO). Callers upload max(n,1) * FloatsPerLight floats. - /// - public static int Pack(IReadOnlyList? snapshot, ref float[] buffer) - { - int n = snapshot?.Count ?? 0; - int floatsNeeded = Math.Max(n, 1) * FloatsPerLight; - if (buffer.Length < floatsNeeded) - buffer = new float[floatsNeeded + FloatsPerLight * 16]; - Array.Clear(buffer, 0, floatsNeeded); - - for (int i = 0; i < n; i++) - { - var L = snapshot![i]; - int o = i * FloatsPerLight; - // posAndKind (xyz world pos, w kind) - buffer[o + 0] = L.WorldPosition.X; - buffer[o + 1] = L.WorldPosition.Y; - buffer[o + 2] = L.WorldPosition.Z; - buffer[o + 3] = (int)L.Kind; - // dirAndRange (xyz forward, w range) - buffer[o + 4] = L.WorldForward.X; - buffer[o + 5] = L.WorldForward.Y; - buffer[o + 6] = L.WorldForward.Z; - buffer[o + 7] = L.Range; // w = Range = Falloff × static_light_factor (1.3), pre-multiplied by LightInfoLoader — NOT the raw dat Falloff - // colorAndIntensity (xyz linear colour, w intensity) - buffer[o + 8] = L.ColorLinear.X; - buffer[o + 9] = L.ColorLinear.Y; - buffer[o + 10] = L.ColorLinear.Z; - buffer[o + 11] = L.Intensity; - // coneAngleEtc (x cone radians; y = #143 dynamic flag; zw reserved) - buffer[o + 12] = L.ConeAngle; - buffer[o + 13] = L.IsDynamic ? 1f : 0f; // shader: 1/d D3D attenuation vs static 1/d³ - } - return n; - } -} diff --git a/src/AcDream.Core/Lighting/LightBake.cs b/src/AcDream.Core/Lighting/LightBake.cs deleted file mode 100644 index 1ab52714..00000000 --- a/src/AcDream.Core/Lighting/LightBake.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; - -namespace AcDream.Core.Lighting; - -/// -/// Retail per-vertex static-light burn-in. Ported verbatim from -/// calc_point_light (acclient 0x0059c8b0), the function retail's -/// D3DPolyRender::SetStaticLightingVertexColors (0x0059cfe0) runs over -/// EVERY vertex of an EnvCell mesh × EVERY reaching static light, baking the -/// result into the vertex diffuse colour ONCE (then the rasteriser Gouraud- -/// interpolates it across each triangle and the texture stage modulates it). -/// -/// -/// This is the faithful answer to the dungeon "spotlight" look (#133 A7): our -/// old per-pixel nearest-8 path lit only the 8 torches nearest the CAMERA and -/// re-ranked them every frame (the sliding crescent). The retail bake sums ALL -/// reaching lights into the vertex once, keyed on light position not camera — -/// uniform, stable, and never blown out (each light is clamped to its own -/// colour, then the vertex sum is clamped to [0,1]). -/// -/// -/// Constants (decomp-cited, not guessed): -/// -/// static_light_factor = 1.3 (0x00820e24) — folded into -/// by LightInfoLoader, so -/// falloff_eff == light.Range here. -/// LIGHT_POINT_RANGE = 0.75 (0x007e5430) — the half-Lambert wrap -/// uses 2·LPR = 1.5 as the divisor and (2·LPR − 1) = 0.5 as the -/// distance bias, so even surfaces angled away from a torch receive some light. -/// -/// -public static class LightBake -{ - // calc_point_light literals. - private const float TwoLpr = 1.5f; // LIGHT_POINT_RANGE + LIGHT_POINT_RANGE - private const float WrapBias = 0.5f; // (2 · LIGHT_POINT_RANGE) − 1.0 - - /// - /// Accumulate one static light's contribution into a per-vertex RGB sum, - /// exactly as calc_point_light does. Returns the contribution to ADD - /// (already per-channel clamped to the light's own colour); the caller sums - /// over all reaching lights and clamps the total to [0,1]. - /// - public static Vector3 PointContribution( - Vector3 vtxWorldPos, Vector3 vtxWorldNormal, LightSource light) - { - // D = light − vertex (FROM vertex TO light), used un-normalised. - float dx = light.WorldPosition.X - vtxWorldPos.X; - float dy = light.WorldPosition.Y - vtxWorldPos.Y; - float dz = light.WorldPosition.Z - vtxWorldPos.Z; - - float distsq = dx * dx + dy * dy + dz * dz; - float dist = MathF.Sqrt(distsq); - float falloffEff = light.Range; // = Falloff × static_light_factor(1.3) - if (dist >= falloffEff || falloffEff <= 1e-4f) - return Vector3.Zero; - - // Half-Lambert wrap: (1/1.5)·(N·D + 0.5·dist), N un-normalised vertex normal. - float wrap = (1f / TwoLpr) * - (vtxWorldNormal.X * dx + vtxWorldNormal.Y * dy + vtxWorldNormal.Z * dz - + WrapBias * dist); - if (wrap <= 0f) - return Vector3.Zero; - - // norm branch — ported EXACTLY (changes the near-vs-far falloff shape). - float norm = distsq > 1f ? distsq * dist : dist; - float scale = (1f - dist / falloffEff) * light.Intensity * (wrap / norm); - - // Per channel: contribution clamped to the light's own colour (a single - // light can never push a channel past its colour — the no-blowout ceiling). - return new Vector3( - MathF.Min(scale * light.ColorLinear.X, light.ColorLinear.X), - MathF.Min(scale * light.ColorLinear.Y, light.ColorLinear.Y), - MathF.Min(scale * light.ColorLinear.Z, light.ColorLinear.Z)); - } - - /// - /// Bake the full per-vertex colour by summing every reaching lit point/spot - /// light, then clamping to [0,1] (the SetStaticLightingVertexColors - /// final clamp). Directional lights are skipped — they are handled by the - /// sun path, not the static burn-in. - /// - public static Vector3 ComputeVertexColor( - Vector3 vtxWorldPos, Vector3 vtxWorldNormal, IReadOnlyList reaching) - { - float r = 0f, g = 0f, b = 0f; - for (int i = 0; i < reaching.Count; i++) - { - var light = reaching[i]; - if (!light.IsLit || light.Kind == LightKind.Directional) continue; - var c = PointContribution(vtxWorldPos, vtxWorldNormal, light); - r += c.X; g += c.Y; b += c.Z; - } - return new Vector3( - Math.Clamp(r, 0f, 1f), - Math.Clamp(g, 0f, 1f), - Math.Clamp(b, 0f, 1f)); - } -} diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs index b3e9d4a8..63a250f4 100644 --- a/src/AcDream.Core/Lighting/LightInfoLoader.cs +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -36,9 +36,7 @@ public static class LightInfoLoader Setup setup, uint ownerId, Vector3 entityPosition, - Quaternion entityRotation, - bool isDynamic = false, - uint cellId = 0) + Quaternion entityRotation) { var results = new List(); if (setup?.Lights is null || setup.Lights.Count == 0) return results; @@ -81,18 +79,10 @@ public static class LightInfoLoader (info.Color?.Green ?? 255) / 255f, (info.Color?.Blue ?? 255) / 255f), Intensity = info.Intensity, - // Range factor by light path (#143). STATIC dat-baked lights use - // static_light_factor 1.3 (calc_point_light 0x0059c8b0 bake — the path - // that lights dungeon/house WALLS, #133 A7) and the shader's 1/d³ ramp. - // DYNAMIC lights (server-object/portal lights registered from the live - // spawn path with isDynamic:true) use config_hardware_light's rangeAdjust - // 1.5 (0x00820cc4) and the shader's D3D 1/d attenuation — softer, broader. - Range = info.Falloff * (isDynamic ? 1.5f : 1.3f), + Range = info.Falloff, ConeAngle = info.ConeAngle, OwnerId = ownerId, - CellId = cellId, // owning cell — scopes the per-frame visible-cell pool (A7 #176/#177) IsLit = true, - IsDynamic = isDynamic, }; results.Add(light); } diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index c4e45451..a9ba8dfc 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -11,25 +11,23 @@ namespace AcDream.Core.Lighting; /// §12.2). /// /// -/// Active-light selection algorithm (r13 §12.2), as implemented by -/// : +/// Active-light selection algorithm (r13 §12.2 "Tick" steps): /// /// -/// Reserve slot 0 for the sun (directional, infinite range) when present. +/// Recompute DistSq from viewer to every registered +/// point/spot light. /// /// -/// For every registered lit point/spot light, recompute DistSq -/// from the viewer and keep the nearest (MaxActiveLights − sunSlot) -/// directly in the active window via an allocation-free insertion -/// partial-select (no per-frame list/sort). +/// Drop lights outside Range² * 1.1 (10% slack prevents +/// pop as we walk across the boundary). +/// +/// +/// Rank remaining lights by DistSq ascending. Pick top 7. +/// +/// +/// Reserve slot 0 for the sun (directional, infinite range). /// /// -/// There is deliberately NO viewer-range candidacy filter: each light's -/// own range cutoff is applied PER SURFACE in the shader -/// (mesh_modern.frag: d < range), so a torch the viewer -/// stands outside the range of must still light the wall it sits on. The -/// earlier Range² × 1.1 slack filter wrongly dropped exactly those -/// lights (the #133 "lighting off" report). /// /// /// @@ -39,11 +37,11 @@ namespace AcDream.Core.Lighting; public sealed class LightManager { public const int MaxActiveLights = 8; // D3D parity + private const float RangeSlack = 1.1f; // 10% hysteresis around hard cutoff private readonly List _all = new(); private readonly LightSource?[] _active = new LightSource?[MaxActiveLights]; private int _activeCount; - private LightSource? _viewerLight; // retail SmartBox::viewer_light (see UpdateViewerLight) /// Current cell ambient state applied to everything. public CellAmbientState CurrentAmbient { get; set; } @@ -87,7 +85,6 @@ public sealed class LightManager _all.Clear(); Array.Clear(_active); _activeCount = 0; - _viewerLight = null; // re-created + re-registered by the next UpdateViewerLight } /// @@ -97,286 +94,45 @@ public sealed class LightManager /// public void Tick(Vector3 viewerWorldPos) { - // Retail D3D-style fixed-pipeline lighting takes the nearest (MaxActiveLights-1) - // point lights (slot 0 is the sun) and applies each light's hard range cutoff - // PER SURFACE in the shader (mesh_modern.frag: `if (d < range && range > 1e-3)`), - // NOT a viewer-range candidacy filter — a torch the viewer stands outside the - // range of must still light the wall it sits on. - // - // Allocation-free partial selection: the old path built `new List<>(N)` and - // ran an O(N log N) Sort EVERY FRAME; in a dungeon N is thousands of torches, - // so that allocated a large list per frame (GC pressure → FPS). Instead keep - // the nearest maxPoint directly in the _active window, maintained sorted by - // insertion. O(N · maxPoint), maxPoint ≤ 8, zero allocation. + // Pass 1: compute DistSq + filter out lights outside the slack radius. + var candidates = new List(_all.Count); + foreach (var light in _all) + { + if (!light.IsLit) continue; + if (light.Kind == LightKind.Directional) + { + // Directional lights don't participate in this ranking — + // the sun is always slot 0. + continue; + } + + Vector3 delta = light.WorldPosition - viewerWorldPos; + light.DistSq = delta.LengthSquared(); + + float rangeSq = light.Range * light.Range * RangeSlack * RangeSlack; + if (light.DistSq > rangeSq) continue; + candidates.Add(light); + } + + // Pass 2: sort by DistSq ascending, take up to 7. + candidates.Sort((a, b) => a.DistSq.CompareTo(b.DistSq)); + Array.Clear(_active); _activeCount = 0; - // Slot 0 = sun when present (directional; never ranked by distance). - int baseSlot = 0; + // Slot 0 = sun when present. if (Sun is not null) { _active[0] = Sun; - baseSlot = 1; + _activeCount = 1; } - int maxPoint = MaxActiveLights - baseSlot; - int filled = 0; - if (maxPoint > 0) + int maxPoint = MaxActiveLights - _activeCount; + int pointCount = Math.Min(maxPoint, candidates.Count); + for (int i = 0; i < pointCount; i++) { - foreach (var light in _all) - { - if (!light.IsLit || light.Kind == LightKind.Directional) continue; - - Vector3 delta = light.WorldPosition - viewerWorldPos; - light.DistSq = delta.LengthSquared(); - - // Maintain _active[baseSlot .. baseSlot+filled) sorted ascending by - // DistSq. Insert if there's room or this light is nearer than the - // current farthest (then the farthest falls off the end). - if (filled < maxPoint) - { - int j = baseSlot + filled; - while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq) - { - _active[j] = _active[j - 1]; - j--; - } - _active[j] = light; - filled++; - } - else if (light.DistSq < _active[baseSlot + maxPoint - 1]!.DistSq) - { - int j = baseSlot + maxPoint - 1; - while (j > baseSlot && _active[j - 1]!.DistSq > light.DistSq) - { - _active[j] = _active[j - 1]; - j--; - } - _active[j] = light; - } - } + _active[_activeCount + i] = candidates[i]; } - - _activeCount = baseSlot + filled; - } - - // ── Fix B (A7 #3): per-OBJECT light selection — minimize_object_lighting ── - // - // The single global nearest-8-to-VIEWER set above (Tick) is camera-relative: - // a wall's brightness changes as the camera moves because the wall's torches - // swap in/out of that global top-8. Retail instead picks up-to-8 lights PER - // OBJECT by the OBJECT's own position (minimize_object_lighting, 0x0054d480), - // so a torch always lights the wall it sits on, camera-independent. The two - // members below feed the per-instance light path in WbDrawDispatcher; Tick - // remains the source of the legacy single-UBO path + the sun slot. - - /// Max point/spot lights any one object can be lit by — retail's - /// D3D fixed-function 8-light cap (minimize_object_lighting). The sun - /// is global, not part of an object's per-object set, so all 8 are point/spot. - public const int MaxLightsPerObject = 8; - - /// Hard cap on the per-frame global point-light snapshot the shader - /// indexes. ⚠️ LOAD-BEARING STOPGAP — read before touching (#176/#177, - /// 2026-07-06): this cap BITES in the Facility Hub (366 registered fixtures → - /// 238 camera-distance evictions/frame), and the eviction is the CONFIRMED - /// mechanism of the #176 purple seam flash + the #177 stair-room light - /// pop-in — an in-range torch of a visible cell that ranks past the cap - /// drops out of that cell's 8-set, so per-cell Gouraud lighting pops as the - /// camera moves. Retail's minimize_object_lighting (0x0054d480) has - /// NO global camera-nearest cap. HOWEVER: raising the cap to 1024 was - /// live-tested 2026-07-06 and REVERTED — with the full pool active, three - /// unported retail lighting semantics dominate the frame: (a) lights reach - /// THROUGH solid floors/walls (retail registers lights per-CELL via - /// insert_light 0x0054d1b0 — a portal's purple light below never - /// touches the corridor above; our flat sphere-overlap selection has no - /// reach/occlusion notion), (b) stationary weenie fixtures ride the DYNAMIC - /// 1/d falloff (~9× stronger at 3 m than retail's static 1/d³ bake curve), - /// (c) an unexplained striped z-fight-like artifact on lit floor regions - /// (user screenshot, launch-176-texflush session). The proper fix is the - /// A7 dungeon-lighting arc: per-cell light registration + the static curve - /// for fixtures + the stripe hunt, THEN uncap. Register row AP-85; desired - /// end-state pin (currently Skip): - /// LightManagerTests.PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant. - public const int MaxGlobalLights = 128; - - private readonly List _pointSnapshot = new(); - - /// - /// Per-frame snapshot of lit point/spot lights, stable-indexed for the global - /// shader light buffer and for per-object selection: the index of a light here - /// IS the index the per-instance light-set SSBO references. Built by - /// . - /// - public IReadOnlyList PointSnapshot => _pointSnapshot; - - /// - /// Rebuild from the registered lit point/spot - /// lights. The sun and unlit lights are excluded (the sun is global ambient- - /// path; unlit torches contribute nothing). When more than - /// qualify, keeps the nearest the camera so the - /// most relevant lights survive the cap. Call once per frame before - /// per-object selection. - /// - public void BuildPointLightSnapshot(Vector3 cameraWorldPos) - => BuildPointLightSnapshot(cameraWorldPos, visibleCells: null); - - /// - /// Visible-cell-scoped snapshot build — the faithful port of retail's per-frame - /// light collection (CObjCell::add_*_to_global_lights 0x0052b350/0x0052b390 - /// walked over CEnvCell::visible_cell_table 0x0052d410). When - /// is non-null (an indoor root with a portal - /// flood), a cell-tagged light is a candidate ONLY when its - /// is in the visible set — a cell-less light (CellId == 0: the viewer fill, - /// global lights) is always included. This is what (a) stops an under-room light - /// washing THROUGH a solid floor (its cell isn't visibly flooded → excluded) and - /// (b) bounds the pool to the handful of visible cells so - /// never evicts a visible cell's in-range light (the #176/#177 mechanism). When - /// is null (outdoor root / no flood) the behaviour - /// is unchanged from the legacy full-pool path. - /// - public void BuildPointLightSnapshot(Vector3 cameraWorldPos, IReadOnlySet? visibleCells) - { - _pointSnapshot.Clear(); - foreach (var light in _all) - { - if (!light.IsLit || light.Kind == LightKind.Directional) continue; - // Visible-cell scoping (retail add_*_lights over visible_cell_table). A - // cell-less light (CellId == 0: viewer fill / global) is always a candidate; - // a cell-tagged light joins the pool ONLY when its cell is visibly flooded. - if (visibleCells is not null && light.CellId != 0 && !visibleCells.Contains(light.CellId)) - continue; - light.DistSq = (light.WorldPosition - cameraWorldPos).LengthSquared(); - _pointSnapshot.Add(light); - } - if (_pointSnapshot.Count > MaxGlobalLights) - { - _pointSnapshot.Sort(static (a, b) => a.DistSq.CompareTo(b.DistSq)); - _pointSnapshot.RemoveRange(MaxGlobalLights, _pointSnapshot.Count - MaxGlobalLights); - } - - // A7.L1 SET-COMPOSITION probe — only meaningful on the scoped (indoor) path. - // Inert unless ACDREAM_PROBE_INDOOR_LIGHT=1; the flag check keeps it zero-cost off. - if (visibleCells is not null && AcDream.Core.Rendering.RenderingDiagnostics.ProbeIndoorLightEnabled) - AcDream.Core.Rendering.RenderingDiagnostics.EmitIndoorLight(visibleCells.Count, _all, _pointSnapshot); - } - - // ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ────────────── - // Retail adds a white fill light pinned to the player EVERY frame via - // Render::add_dynamic_light. It is the dominant INTERIOR fill: the outdoor - // stage runs useSunlightSet(1) (sun only — dynamics are NOT enabled), but the - // interior stage runs minimize_envcell_lighting, which enables the dynamic - // lights, so EnvCell walls + indoor objects are lit by this viewer light while - // the player is inside. acdream registered NO dynamic lights at all, so - // interiors had only flat ambient and read dark/cool vs retail's lit rooms. - // - // It rides the existing point-light path: registered in _all ⇒ included in - // BuildPointLightSnapshot ⇒ selected by SelectForObject for nearby cells / - // objects. The AP-43 indoor gate (WbDrawDispatcher.IndoorObjectReceivesTorches) - // already restricts per-object point lights to EnvCell-parented objects, and - // only EnvCellRenderer selects per-cell lights, so the viewer light lights - // ONLY indoor draws — matching retail's interior-stage-only dynamic enable. - // Terrain has no point-light path, so it is unaffected. - // - // Params from the live cdb capture (reference_retail_ambient_values.md): - // intensity 2.25, falloff 10, colour white, offset (0,0,2) above the player - // (SmartBox::set_viewer player branch). Dynamic lights use rangeAdjust 1.5 - // (config_hardware_light 0x0059ad30) ⇒ Range = 10 × 1.5 = 15 m. - public const float ViewerLightIntensity = 2.25f; // GRV SmartBox.ViewerLightIntensity - public const float ViewerLightFalloff = 10f; // GRV SmartBox.ViewerLightFalloff - private const uint ViewerLightOwnerId = 0xFFFFFFFFu; // sentinel; never an entity id - - /// - /// Reposition the always-on viewer fill light at the player (offset +2 m up), - /// registering it on first call. Call once per frame BEFORE - /// / . Mirrors retail's - /// per-frame SmartBox::set_viewer add_dynamic_light; here the light lives - /// in _all and is repositioned so the existing snapshot + per-object - /// selection light the cell around the player. Indoor-only via the AP-43 gate - /// (see the note above). - /// - public void UpdateViewerLight(Vector3 playerWorldPos) - { - if (_viewerLight is null) - { - _viewerLight = new LightSource - { - Kind = LightKind.Point, - ColorLinear = Vector3.One, // white (1,1,1) - Intensity = ViewerLightIntensity, - Range = ViewerLightFalloff * 1.5f, // dynamic rangeAdjust 1.5 - OwnerId = ViewerLightOwnerId, - IsLit = true, - IsDynamic = true, // #143: D3D 1/d attenuation (soft fill, not 1/d³) - }; - _all.Add(_viewerLight); - } - _viewerLight.WorldPosition = playerWorldPos + new Vector3(0f, 0f, 2f); - } - - /// - /// Select up to point/spot lights from - /// that reach the object sphere - /// (, ), nearest-first. - /// Faithful to retail's minimize_object_lighting (0x0054d480): a light - /// is a candidate iff its falloff sphere overlaps the object sphere — - /// (light.pos − center)² < (light.Range + radius)² — and when more - /// than 8 candidates qualify, the 8 NEAREST the object centre are kept (the - /// farthest fall off). already folds - /// static_light_factor (1.3), matching the per-vertex cutoff so a - /// selected light always actually contributes in the shader. - /// - /// Writes indices INTO to - /// (ascending by distance) and returns the count. - /// Pure + static: camera-INDEPENDENT (depends only on the object centre), so a - /// static object's set is stable and may be computed once. Unit-testable - /// without GL. - /// - /// - public static int SelectForObject( - IReadOnlyList snapshot, - Vector3 center, - float radius, - Span outIndices) - { - int cap = Math.Min(outIndices.Length, MaxLightsPerObject); - if (cap <= 0) return 0; - - Span keptDistSq = stackalloc float[MaxLightsPerObject]; - int count = 0; - - for (int li = 0; li < snapshot.Count; li++) - { - var light = snapshot[li]; - float reach = light.Range + radius; - float dsq = (light.WorldPosition - center).LengthSquared(); - if (dsq >= reach * reach) continue; // light's sphere doesn't reach the object - - if (count < cap) - { - int j = count; - while (j > 0 && keptDistSq[j - 1] > dsq) - { - keptDistSq[j] = keptDistSq[j - 1]; - outIndices[j] = outIndices[j - 1]; - j--; - } - keptDistSq[j] = dsq; - outIndices[j] = li; - count++; - } - else if (dsq < keptDistSq[cap - 1]) - { - int j = cap - 1; - while (j > 0 && keptDistSq[j - 1] > dsq) - { - keptDistSq[j] = keptDistSq[j - 1]; - outIndices[j] = outIndices[j - 1]; - j--; - } - keptDistSq[j] = dsq; - outIndices[j] = li; - } - } - return count; + _activeCount += pointCount; } } diff --git a/src/AcDream.Core/Lighting/LightSource.cs b/src/AcDream.Core/Lighting/LightSource.cs index 3d0bd705..7bfb7d45 100644 --- a/src/AcDream.Core/Lighting/LightSource.cs +++ b/src/AcDream.Core/Lighting/LightSource.cs @@ -46,15 +46,7 @@ public sealed class LightSource public float Range = 10f; // metres, hard cutoff public float ConeAngle = 0f; // radians, Spot only public uint OwnerId; // attached entity id; 0 = world-global - public uint CellId; // owning cell id (0xLLLLNNNN); 0 = cell-less/global (viewer fill, sun). - // Retail carries this on the RenderLight (insert_light arg6, +0x6c) so the - // per-frame pool can be built from only the VISIBLE cells' lights - // (CObjCell::add_*_to_global_lights over CEnvCell::visible_cell_table). - // acdream uses it to scope BuildPointLightSnapshot — a cell-tagged light is - // only a candidate when its cell is visibly flooded (#176/#177 A7 fix). public bool IsLit = true; // SetLightHook latch - public bool IsDynamic; // #143: true = D3D hardware path (1/d att, range×1.5); - // false = static dat-baked bake (1/d³, range×1.3) // Cached each frame by LightManager. public float DistSq; diff --git a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs index c9c2bedd..c8d38bf7 100644 --- a/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs +++ b/src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs @@ -141,53 +141,4 @@ public static class GfxObjDegradeResolver resolvedGfxObj = closeGfxObj; return true; } - - /// - /// True when a GfxObj is an EDITOR-ONLY placement marker that retail's distance-based - /// degrade hides at any runtime distance. Such a marker's closest degrade slot is visible - /// ONLY at distance 0 (Degrades[0].MaxDist == 0) and the table degrades to GfxObj - /// id 0 (= nothing) at real distance. Retail - /// (CPhysicsPart::UpdateViewerDistance 0x0050E030 → Draw 0x0050D7A0 picks - /// gfxobj[deg_level] by viewer distance) therefore never draws it in the live - /// client — only WorldBuilder shows it at the editor origin. acdream has no per-frame - /// distance-LOD (the resolver above always returns slot 0), so without this check it - /// renders the marker mesh forever — the #136 dungeon "red/green cone" (Setup 0x02000C39 - /// / GfxObj 0x010028CA, whose degrade table 0x11000118 is {slot0 Id=mesh MaxDist=0, - /// slot1 Id=0 MaxDist=FLT_MAX}). Callers that hydrate static geometry (always viewed at - /// distance > 0) skip such GfxObjs. - /// - public static bool IsRuntimeHiddenMarker(DatCollection dats, uint gfxObjId) - => IsRuntimeHiddenMarker( - id => dats.Get(id), - id => dats.Get(id), - gfxObjId); - - /// Loader-callback overload of . - public static bool IsRuntimeHiddenMarker( - Func getGfxObj, - Func getDegradeInfo, - uint gfxObjId) - { - var gfxObj = getGfxObj(gfxObjId); - if (gfxObj is null - || !gfxObj.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) - || gfxObj.DIDDegrade == 0) - return false; - - var info = getDegradeInfo(gfxObj.DIDDegrade); - if (info is null || info.Degrades.Count == 0) - return false; - - // Closest slot visible only at distance exactly 0 = editor-only placement marker. - bool firstSlotEditorOnly = info.Degrades[0].MaxDist == 0f; - if (!firstSlotEditorOnly) - return false; - - // ...and the table degrades to NOTHING (id 0) at real distance — confirms it - // becomes invisible at runtime rather than LOD-swapping to a real mesh. - foreach (var d in info.Degrades) - if ((uint)d.Id == 0u) - return true; - return false; - } } diff --git a/src/AcDream.Core/Physics/AceModernCommandCatalog.cs b/src/AcDream.Core/Physics/AceModernCommandCatalog.cs deleted file mode 100644 index 0294f59e..00000000 --- a/src/AcDream.Core/Physics/AceModernCommandCatalog.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using System.Collections.Generic; -using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; - -namespace AcDream.Core.Physics; - -/// -/// Runtime-default . Built from the -/// enum (generated from the protocol XML; -/// mirrors ACE's MotionCommand enum and matches the values actually -/// found in the local DAT MotionTable records). Use this catalog -/// while talking to ACE — it is what -/// delegates to. -/// -/// -/// Reconstruction is a flat wireLow16 -> full32 lookup built once -/// at construction. When more than one enum value shares the same low 16 -/// bits (a true class collision — class byte differs, low word doesn't), -/// the LOWER class byte wins: Action (0x10) < ChatEmote (0x12/0x13) -/// < Modifier (0x20) < SubState (0x41/...) < ... < Style (0x80). -/// See . -/// -/// -/// -/// No magic per-range override. The original -/// MotionCommandResolver.ApplyNamedRetailOverrides force-mapped -/// wire 0x016E-0x0197 to 0x10000000 | lo on the theory that the -/// generated DRW enum was "shifted." Verified false: building the lookup -/// straight from (Chorizite.DatReaderWriter -/// 2.1.7, 409 distinct values, zero same-low16 collisions) already resolves -/// LifestoneRecall (0x0153), MarketplaceRecall (0x0166), -/// AllegianceHometownRecall (0x0171), and OffhandSlashHigh -/// (0x0173) to their correct Action-class full values with no override. -/// The override loop is deleted in this slice (it was masking nothing; the -/// enum was already correct). See -/// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md. -/// -/// -public sealed class AceModernCommandCatalog : IMotionCommandCatalog -{ - private readonly Dictionary _lookup; - - public AceModernCommandCatalog() - { - _lookup = BuildLookup(); - } - - public uint ReconstructFullCommand(ushort wireCommand) - { - if (wireCommand == 0) return 0u; - _lookup.TryGetValue(wireCommand, out var full); - return full; - } - - private static Dictionary BuildLookup() - { - var byLow = new Dictionary>(512); - foreach (DRWMotionCommand v in Enum.GetValues(typeof(DRWMotionCommand))) - { - uint full = (uint)v; - ushort lo = (ushort)(full & 0xFFFFu); - if (lo == 0) continue; // Invalid / unmappable - - if (!byLow.TryGetValue(lo, out var list)) - byLow[lo] = list = new List(1); - if (!list.Contains(full)) - list.Add(full); - } - - var result = new Dictionary(byLow.Count); - foreach (var (lo, candidates) in byLow) - { - result[lo] = candidates.Count == 1 - ? candidates[0] - : ResolveClassPriority(candidates); - } - - return result; - } - - /// - /// Given a set of full 32-bit MotionCommand values that all share the - /// same low 16 bits, return the one whose class byte (bits 24-31) is - /// numerically lowest — retail's class priority (Action 0x10 beats - /// Modifier 0x20 beats SubState 0x41 beats Style 0x80, etc). Exposed - /// as a static so it can be exercised directly by tests even when the - /// live enum has no real collisions to exercise it through - /// . - /// - public static uint ResolveClassPriority(IReadOnlyList candidates) - { - uint best = candidates[0]; - for (int i = 1; i < candidates.Count; i++) - { - uint candidate = candidates[i]; - if ((candidate >> 24) < (best >> 24)) - best = candidate; - } - return best; - } -} diff --git a/src/AcDream.Core/Physics/AnimationSequencer.cs b/src/AcDream.Core/Physics/AnimationSequencer.cs index 4ee17977..4dc4aef9 100644 --- a/src/AcDream.Core/Physics/AnimationSequencer.cs +++ b/src/AcDream.Core/Physics/AnimationSequencer.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Numerics; -using AcDream.Core.Physics.Motion; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; @@ -31,33 +30,19 @@ public sealed class DatCollectionLoader : IAnimationLoader } // ───────────────────────────────────────────────────────────────────────────── -// AnimationSequencer — adapter rehosted on the verbatim retail CSequence core -// (R1-P5, docs/research/2026-07-02-r1-csequence/r1-gap-map.md §3 P5). +// AnimationSequencer — faithful port of the decompiled retail AC client +// animation system. // -// R1-P5 REHOST: the legacy AnimNode/LinkedList queue machinery is -// gone. All frame-advance, list-surgery, and boundary-math state now lives in -// AcDream.Core.Physics.Motion.CSequence (R1-P1..P4, verbatim retail port — -// see CSequence.cs/AnimSequenceNode.cs/FrameOps.cs). This adapter keeps every -// public member's signature (the API-migration table in the gap map is the -// contract) and re-expresses each one over the core. Invented mechanisms that -// were R2/R3 scope (K-fix18 skipTransitionLink DELETED in R3-W4, Fix B locomotion -// link-skip, stop-anim fallback, GetLink's reversed branch, velocity/omega -// synthesis constants) SURVIVE unchanged at the adapter level per the gap -// map's "Invented behaviors NOT in the gap list" note. -// -// Primary references (docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md): -// append_animation 0x00525510 (§24) -// remove_cyclic_anims 0x00524e40 (§6) -// remove_all_link_animations 0x00524ca0 (§8) -// clear_animations 0x00524dc0 (§4) -// clear_physics 0x00524d50 (§9? see CSequence.cs) -// update / update_internal 0x00525b80 / 0x005255d0 (§21/§22) -// advance_to_next_animation 0x005252b0 (§23) -// multiply_cyclic_animation_fr 0x00524940 (§14) -// FUN_005360d0 — quaternion slerp with dot-product sign-flip (render-side, -// NOT CSequence scope — kept verbatim in this adapter) +// Primary references (pseudocode at docs/research/acclient_animation_pseudocode.md): +// FUN_005267E0 — multiply_framerate: swaps startFrame↔endFrame for negative speed +// FUN_005261D0 — update_internal: the core per-frame advance loop +// FUN_00525EB0 — advance_to_next_animation: node transition + wrap to firstCyclic +// FUN_00526880 — GetStartFramePosition: double start pos (speed-dependent) +// FUN_005268B0 — GetEndFramePosition: double end pos (speed-dependent) +// FUN_005360d0 — quaternion slerp with dot-product sign-flip // MotionInterp.cs:394-428 (ACE) — adjust_motion: left→right remapping -// (R2/R3 scope; kept verbatim in this adapter per the gap map) +// Sequence.cs:262-270 (ACE) — execute_hooks (Both or matching direction fires) +// Sequence.cs:351-443 (ACE) — update_internal with per-frame hook dispatch // // DatReaderWriter types used: // MotionTable.Links : Dictionary @@ -92,16 +77,125 @@ public readonly struct PartTransform } } +/// +/// One entry in the animation queue (link transition or looping cycle). +/// +/// Faithfully models the retail client AnimNode struct at +0x0C..+0x18. +/// Carries the parent 's +/// Velocity and Omega fields so per-tick physics deltas can be surfaced +/// while this node is current (ACE Sequence.Velocity / Omega equivalent +/// for the single-active-MotionData case). +/// +internal sealed class AnimNode +{ + public Animation Anim; + public double Framerate; // signed; negative means reverse playback + public int StartFrame; // inclusive start frame (post-swap for negative speed) + public int EndFrame; // inclusive end frame (post-swap for negative speed) + public bool IsLooping; // true only for the tail cyclic node + public bool HasPosFrames; // mirror of Anim.Flags & AnimationFlags.PosFrames + + // Carried from the source MotionData (one MotionData may produce N nodes; + // each carries the same vel/omega, and when the node becomes current the + // sequencer surfaces these values). + public Vector3 Velocity; // meters/sec, world-space + public Vector3 Omega; // radians/sec per axis + + public AnimNode( + Animation anim, + double framerate, + int startFrame, + int endFrame, + bool isLooping, + bool hasPosFrames, + Vector3 velocity, + Vector3 omega) + { + Anim = anim; + Framerate = framerate; + StartFrame = startFrame; + EndFrame = endFrame; + IsLooping = isLooping; + HasPosFrames = hasPosFrames; + Velocity = velocity; + Omega = omega; + } + + // ── FUN_005267E0 — multiply_framerate ───────────────────────────────── + // Scales this node's framerate by a factor. Used by + // AnimationSequencer.MultiplyCyclicFramerate to retarget an already-queued + // cyclic animation at a new playback speed without restarting. + // + // Retail's implementation additionally swapped StartFrame↔EndFrame for a + // negative factor (so the forward-playback advance loop could traverse + // either direction), but acdream's AnimNode keeps StartFrame ≤ EndFrame + // as an invariant and encodes direction purely via Framerate's sign — the + // Advance loop then checks against StartFrame as the lower bound for + // negative delta. So here we only scale. + // + // Mirrors ACE AnimSequenceNode.multiply_framerate / Sequence.cs L277-L287 + // modulo the swap difference. Valid because the callers we care about + // (ForwardSpeed updates from UpdateMotion) only ever pass positive factors. + public void MultiplyFramerate(double factor) + { + Framerate *= factor; + } + + // ── FUN_00526880 — GetStartFramePosition ────────────────────────────── + // Returns the initial framePosition cursor for this node. + // speedScale >= 0 → (double)startFrame + // speedScale < 0 → (double)(endFrame + 1) - EPSILON + // EPSILON = _DAT_007c92b4 (a tiny float just below the boundary) + public double GetStartFramePosition() + { + if (Framerate >= 0.0) + return (double)StartFrame; + else + return (double)(EndFrame + 1) - FrameEpsilon; + } + + // ── FUN_005268B0 — GetEndFramePosition ─────────────────────────────── + // Returns where the cursor sits when this node is exhausted. + // speedScale >= 0 → (double)(endFrame + 1) - EPSILON + // speedScale < 0 → (double)startFrame + public double GetEndFramePosition() + { + if (Framerate >= 0.0) + return (double)(EndFrame + 1) - FrameEpsilon; + else + return (double)StartFrame; + } + + // Small double constant matching _DAT_007c92b4 in the retail binary. + // Used to position the cursor just before a frame boundary. + private const double FrameEpsilon = 1e-5; +} + /// /// Full animation playback engine for one entity. /// /// -/// R1-P5: this is now a thin adapter over — the -/// verbatim retail port of the AC client's CSequence object. The -/// adapter owns caller-facing bookkeeping (CurrentStyle/CurrentMotion/ -/// CurrentSpeedMod, dat lookups via Setup/MotionTable, the retail-faithful -/// but still-R2/R3-scope invented mechanisms) while all frame-advance, -/// list-surgery, and boundary math live in the core. +/// This is a faithful port of the retail AC client's Sequence object +/// (docs/research/acclient_animation_pseudocode.md, sections 5–7). +/// Key invariants: +/// +/// +/// _framePosition is a double matching the retail client's +/// 64-bit field at Sequence+0x30. +/// +/// +/// Negative framerate means reverse playback. +/// +/// +/// When a node's frames are exhausted, advance_to_next_animation +/// wraps to _firstCyclic (the looping tail of the queue). +/// +/// +/// Every integer frame boundary crossed in a tick fires the hooks at +/// that frame whose matches the playback +/// direction (or Both). Mirrors ACE Sequence.execute_hooks. +/// +/// /// /// /// @@ -110,8 +204,9 @@ public readonly struct PartTransform /// var seq = new AnimationSequencer(setup, motionTable, dats); /// seq.SetCycle(style, motion, speedMod); /// // each frame: -/// var transforms = seq.Advance(dt, rootMotionFrame); // root motion lands in the Frame +/// var transforms = seq.Advance(dt); /// var hooks = seq.ConsumePendingHooks(); // fire audio / VFX / damage +/// var root = seq.ConsumeRootMotionDelta(); // add to AFrame if desired /// /// /// @@ -119,34 +214,25 @@ public sealed class AnimationSequencer { // ── Public state ───────────────────────────────────────────────────────── - /// - /// Current style (stance) command — R2-Q4: read-only mirror of the - /// retail (MotionState OWNS - /// style/substate/substate_mod; the adapter no longer keeps copies). - /// - public uint CurrentStyle => _state.Style; + /// Current style (stance) command. + public uint CurrentStyle { get; private set; } + + /// Current cyclic motion command. + public uint CurrentMotion { get; private set; } /// - /// Current cyclic motion command — mirror of - /// . NOTE (Q4): this is the - /// POST-adjust_motion substate (WalkBackward reads back as WalkForward - /// with a negative ) — retail's interpreted - /// state is post-adjustment. + /// Speed multiplier currently applied to the cyclic tail. Starts at 1.0 + /// and is updated by when the same motion is + /// re-issued with a different speed (which triggers + /// instead of a cycle restart). /// - public uint CurrentMotion => _state.Substate; + public float CurrentSpeedMod { get; private set; } = 1f; /// - /// Speed multiplier of the current substate — mirror of - /// (signed; see - /// ). - /// - public float CurrentSpeedMod => _state.SubstateMod; - - /// - /// Sequence-wide velocity mirror of the core's - /// field (retail Sequence::Velocity). Updated each time a - /// MotionData is appended — reflects the MOST RECENT MotionData's - /// velocity × speedMod (retail set_velocity semantics, + /// Sequence-wide velocity mirror of ACE's Sequence.Velocity field. + /// Updated each time a MotionData is appended or combined — reflects the + /// MOST RECENT MotionData's velocity × speedMod, matching + /// Sequence.SetVelocity semantics (ACE Sequence.cs L127-L130, /// MotionTable.add_motion L358-L370). /// /// @@ -157,47 +243,35 @@ public sealed class AnimationSequencer /// transitions. /// /// - public Vector3 CurrentVelocity => _core.Velocity; + public Vector3 CurrentVelocity { get; private set; } /// /// Sequence-wide omega, matching 's semantics. /// - public Vector3 CurrentOmega => _core.Omega; + public Vector3 CurrentOmega { get; private set; } // Diagnostics - public int QueueCount => _core.Count; - public bool HasCurrentNode => _core.CurrAnim != null; - - /// Test seam (Q4 trace conformance): the verbatim CSequence core. - internal CSequence Core => _core; + public int QueueCount => _queue.Count; + public bool HasCurrentNode => _currNode != null; /// - /// Diagnostic snapshot of the core's curr_anim identity + frame - /// state, for the per-tick CURRNODE log line in - /// GameWindow.TickAnimations. Lets the caller see whether the - /// actual node being read by Advance / BuildBlendedFrame is what - /// SetCycle was supposed to leave it on. AnimRefHash uses - /// object-identity hashing on the Animation reference so different Walk - /// vs Run anim resources can be distinguished even without exposing the - /// full Animation type. - /// - /// IsLooping here means "this node IS the cyclic tail" (core structural - /// test: curr == first_cyclic) — retail nodes carry no per-node - /// IsLooping flag (G16); this diagnostic re-derives the same meaning - /// from list structure. + /// Diagnostic snapshot of _currNode's identity + frame state, for + /// the per-tick CURRNODE log line in GameWindow.TickAnimations. + /// Lets the caller see whether the actual node being read by Advance / + /// BuildBlendedFrame is what SetCycle was supposed to leave it on. + /// AnimRefHash uses object-identity hashing on the Animation reference + /// so different Walk vs Run anim resources can be distinguished even + /// without exposing the full Animation type. /// public (int AnimRefHash, bool IsLooping, double Framerate, int StartFrame, int EndFrame, double FramePosition, int QueueCount) CurrentNodeDiag { get { - var n = _core.CurrAnim; - if (n is null) - return (0, false, 0.0, 0, 0, 0.0, _core.Count); - int hash = n.Anim is null - ? 0 - : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(n.Anim); - bool isLooping = ReferenceEquals(_core.CurrAnim, _core.FirstCyclic); - return (hash, isLooping, n.Framerate, n.LowFrame, n.HighFrame, _core.FrameNumber, _core.Count); + if (_currNode is null) + return (0, false, 0.0, 0, 0, 0.0, _queue.Count); + var n = _currNode.Value; + int hash = System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(n.Anim); + return (hash, n.IsLooping, n.Framerate, n.StartFrame, n.EndFrame, _framePosition, _queue.Count); } } @@ -205,19 +279,13 @@ public sealed class AnimationSequencer /// Diagnostic: the AnimRefHash for the FIRST cyclic node in the queue /// (i.e., what SetCycle is trying to land us on for a locomotion cycle). /// Compare against 's AnimRefHash to see - /// whether the core's curr_anim is actually pointing at the new - /// cycle or something stale. + /// whether _currNode is actually pointing at the new cycle or + /// something stale. /// - public int FirstCyclicAnimRefHash - { - get - { - var fc = _core.FirstCyclic; - return fc?.Anim is null - ? 0 - : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(fc.Anim); - } - } + public int FirstCyclicAnimRefHash => + _firstCyclic is null + ? 0 + : System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(_firstCyclic.Value.Anim); // ── Private state ──────────────────────────────────────────────────────── @@ -225,47 +293,35 @@ public sealed class AnimationSequencer private readonly MotionTable _mtable; private readonly IAnimationLoader _loader; - // R1-P5: the verbatim retail CSequence core. Owns the animation list, - // curr_anim/first_cyclic cursors, frame_number, and Velocity/Omega - // accumulators. - private readonly CSequence _core; + // Animation queue: non-looping link frames followed by the looping cycle. + private readonly LinkedList _queue = new(); + private LinkedListNode? _currNode; + private LinkedListNode? _firstCyclic; - // R2-Q4: the verbatim motion-selection stack. CMotionTable resolves - // (style, substate, speed) requests (Q2); MotionState owns - // style/substate/mod + the modifier/action chains (Q1); - // MotionTableManager owns the pending-animation queue + the - // tick-countdown completion machinery (Q3). SetCycle/PlayAction are - // thin shims over MotionTableManager.PerformMovement. - private readonly CMotionTable _table; - private readonly MotionState _state; - private readonly MotionTableManager _manager; + // 64-bit fractional frame position — matches Sequence+0x30 in the retail client. + // Named _framePosition to distinguish it from the old float _frameNum. + private double _framePosition; - // Retail lazy-create analog (MovementManager::get_minterp → - // enter_default_state, r3-motioninterp-decomp §6g): the manager's - // initialize_state runs on the FIRST SetCycle/PlayAction, not at - // construction, so a sequencer that is never driven stays do-nothing - // (RenderBootstrap invariant). - private bool _initialized; - - // Hooks pending dispatch. Accumulated during Advance (via the - // AdapterHookQueue seam below); drained via ConsumePendingHooks. + // Hooks pending dispatch. Accumulated during Advance; drained via + // ConsumePendingHooks. private readonly List _pendingHooks = new(); - // MP-Alloc (2026-07-05): reusable per-part transform buffer. _setup is - // assigned once in the constructor and never reassigned, so - // _setup.Parts.Count is fixed for this sequencer's lifetime — one - // allocation instead of one `new PartTransform[partCount]` per Advance() - // call (every animated entity, every tick, including idle NPCs on a - // breathe cycle). BuildBlendedFrame/BuildIdentityFrame overwrite every - // slot before returning this array, so the values are bit-identical to - // the old fresh-array version; callers (GameWindow.TickAnimations) - // consume the returned IReadOnlyList synchronously within - // the same loop iteration and never cache it across Advance() calls. - private readonly PartTransform[] _partTransformScratch; + // Root motion (PosFrames) delta accumulated during Advance. Drained via + // ConsumeRootMotionDelta. Matches the retail client's AFrame.Combine / + // AFrame.Subtract chain in Sequence.update_internal. + private Vector3 _rootMotionPos; + private Quaternion _rootMotionRot = Quaternion.Identity; - // R1-P6: root motion flows through the Advance(dt, Frame) overload - // (retail CSequence::update(Frame*), 0x00525b80) — the old adapter - // accumulator fields are gone with ConsumeRootMotionDelta. + private const double FrameEpsilon = 1e-5; + private const double RateEpsilon = 1e-6; + + // ── Diagnostics (Commit A 2026-05-03) ─────────────────────────────────── + // Throttle clock for the [SCFAST] / [SCFULL] / [SCNULLFALLBACK] log lines + // emitted from SetCycle. Gated on env var ACDREAM_REMOTE_VEL_DIAG=1; reads + // the env var inline rather than caching so a launch can be re-toggled + // without restarting. 0.5s per sequencer instance keeps logs readable + // while still capturing meaningful state changes. + private double _lastSetCycleDiagTime; // ── Constructor ────────────────────────────────────────────────────────── @@ -287,55 +343,10 @@ public sealed class AnimationSequencer _setup = setup; _mtable = motionTable; _loader = loader; - _partTransformScratch = new PartTransform[_setup.Parts.Count]; - _core = new CSequence(loader); - _core.HookObj = new AdapterHookQueue(this); - _table = new CMotionTable(motionTable); - _state = new MotionState(); - _manager = new MotionTableManager(_table, _state, _core, new ForwardingMotionDoneSink(this)); - } - - /// - /// R2-Q4: the entity's — the caller's - /// per-tick drive surface ( - /// per drained AnimDone hook, - /// once per tick). - /// - public MotionTableManager Manager => _manager; - - /// - /// R2-Q4 seam: where lands. - /// Null → dropped (diagnostic recorder bound by the host under - /// ACDREAM_DUMP_MOTION); R3 binds MotionInterpreter.MotionDone here - /// (register row: MotionDone observed-not-consumed until R3). - /// - public Action? MotionDoneTarget { get; set; } - - private sealed class ForwardingMotionDoneSink : IMotionDoneSink - { - private readonly AnimationSequencer _owner; - public ForwardingMotionDoneSink(AnimationSequencer owner) => _owner = owner; - public void MotionDone(uint motion, bool success) - => _owner.MotionDoneTarget?.Invoke(motion, success); - } - - private void EnsureInitialized() - { - if (_initialized) - return; - _initialized = true; - _manager.InitializeState(); } // ── Public API ─────────────────────────────────────────────────────────── - // R2-Q5: HasCycle DELETED (caller-free). The missing-cycle hazard it - // guarded ("torso on the ground": legacy SetCycle's unconditional - // cyclic-tail drop) is structurally gone — the verbatim - // GetObjectSequence checks the cycle BEFORE any list surgery and leaves - // the sequence untouched on a miss, so the spawn/UM fallback chains - // (Run→Walk→Ready) that probed it are retired with RemoteMotionSink. - /// /// Switch to a new cyclic motion, prepending any transition link frames /// so the switch is smooth. If the motion table has no link for the @@ -352,18 +363,47 @@ public sealed class AnimationSequencer /// MotionCommand style / stance (e.g. NonCombat 0x003D0000). /// Target motion command (e.g. WalkForward 0x45000005). /// Speed multiplier applied to framerates (1.0 = normal). - public void SetCycle(uint style, uint motion, float speedMod = 1f) + /// K-fix18 (2026-04-26): when true, do + /// NOT enqueue the transition-link frames between the previous and + /// new cycle. Used when the caller wants the new cycle to engage + /// instantly — e.g. swapping to Falling on a jump start, where the + /// RunForward→Falling link is a short "stop running" pose that + /// makes the jump look delayed (legs stand still for ~100 ms while + /// the link drains, then fold into Falling). Defaults to false to + /// preserve normal smooth transitions for everything else. + /// + /// Check whether the underlying MotionTable contains a cycle for the + /// given (style, motion) pair. Useful for callers that want to fall + /// back to a known-good motion (e.g. WalkForward → + /// Ready) instead of triggering 's + /// unconditional ClearCyclicTail path on a missing cycle — + /// which leaves the body without any animation tail and snaps every + /// part to the setup-default offset (visible as "torso on the + /// ground" since most creatures' setup-default has limbs at the + /// torso origin). + /// + public bool HasCycle(uint style, uint motion) { - EnsureInitialized(); + // adjust_motion remapping (mirrors the head of SetCycle): + // TurnLeft, SideStepLeft, WalkBackward map to their right/forward + // mirror cycles. + uint adjustedMotion = motion; + switch (motion & 0xFFFFu) + { + case 0x000E: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Du; break; + case 0x0010: adjustedMotion = (motion & 0xFFFF0000u) | 0x000Fu; break; + case 0x0006: adjustedMotion = (motion & 0xFFFF0000u) | 0x0005u; break; + } + int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu)); + return _mtable.Cycles.ContainsKey(cycleKey); + } - // ── Q4 boundary normalization (adjust_motion cyclic subset) ─────── - // Retail adjusts BEFORE the table sees the motion (CMotionInterp:: - // adjust_motion, R3 scope) — the interpreted state on the wire is - // already post-adjustment, so remote-driven callers never pass these. - // GameWindow's LOCAL-player path (UpdatePlayerAnimation) still passes - // raw TurnLeft/SideStepLeft/WalkBackward, so the adapter normalizes - // ONCE at this boundary. DELETED in R3-W6 when the local player - // unifies onto MotionInterpreter (r3-port-plan.md J15). + public void SetCycle(uint style, uint motion, float speedMod = 1f, bool skipTransitionLink = false) + { + // ── adjust_motion: remap left→right / backward→forward variants ─── + // ACE MotionInterp.cs:394-428. The MotionTable never stores TurnLeft, + // SideStepLeft, or WalkBackward cycles; the client plays the mirror + // animation with a negated speed so it runs backward. uint adjustedMotion = motion; float adjustedSpeed = speedMod; switch (motion & 0xFFFFu) @@ -382,34 +422,336 @@ public sealed class AnimationSequencer break; } - // ── R2-Q4: dispatch through the verbatim motion-selection stack ─── - // Style change first (style-class ids route GetObjectSequence - // Branch 1 — exit link + entry hop + target style's default cycle), - // then the motion itself (Branch 2 cycle / 3 action / 4 modifier; - // the Branch-2 fast re-speed path replaces the old adapter - // fast-path, remove_redundant_links replaces Fix B, get_link's - // reversed-key double-hop replaces the stop-anim fallback). - if (style != 0 && style != _state.Style) - _manager.PerformMovement(MotionTableMovement.Interpreted(style, 1f)); + // Fast-path: already playing this exact motion. + // + // Retail (ACE MotionTable.cs:132-139): when motion == current and + // sign(speedMod) matches, DON'T restart the cycle — just rescale the + // in-flight cyclic-tail's framerate via multiply_cyclic_animation_framerate. + // This keeps the run/walk loop smooth when a new UpdateMotion arrives + // with a different ForwardSpeed (e.g. when the server broadcasts a + // player's updated RunRate mid-step). + // + // **Sign-flip case (2026-05-02):** when the server sends adjust_motion'd + // backward walk as `WalkForward + speed=-N`, motion stays 0x45000005 + // but speedMod sign flips. We MUST do a full cycle restart in that case + // so the new (negative) framerate takes effect; otherwise the cycle + // keeps playing forward with the old positive framerate and the + // observer sees the player walking forward despite the negative speed. + if (CurrentStyle == style && CurrentMotion == motion + && _firstCyclic != null && _queue.Count > 0 + && MathF.Sign(speedMod) == MathF.Sign(CurrentSpeedMod)) + { + if (MathF.Abs(speedMod - CurrentSpeedMod) > 1e-4f + && MathF.Abs(CurrentSpeedMod) > 1e-6f) + { + MultiplyCyclicFramerate(speedMod / CurrentSpeedMod); + CurrentSpeedMod = speedMod; + } - // Motion via the PerformMovement passthrough (velocity synthesis on - // success — same helper the R2-Q5 funnel sink path uses; the - // adjusted motion + adjusted speed produce the identical synthesis - // result as the original pair). - uint dispatchResult = PerformMovement( - MotionTableMovement.Interpreted(adjustedMotion, adjustedSpeed)); - - // R3-W4: the K-fix18 skipTransitionLink flag is DELETED — the - // instant-Falling engage is now retail's own mechanism: the entity's - // MotionInterpreter.LeaveGround (0x00528b00) fires the - // RemoveLinkAnimations seam (bound to this sequencer's - // RemoveAllLinkAnimations) when the body leaves the ground. - - // Failed dispatch (missing cycle for this style, is_allowed reject): - // retail leaves sequence AND state untouched — no synthesis either. - // (Velocity synthesis already ran inside PerformMovement on success.) - if (dispatchResult != MotionTableManagerError.Success) + // D3 (Commit A 2026-05-03): SCFAST — proves whether the fast-path + // is firing instead of the full rebuild. Throttled to 0.5s per + // instance (re-throttled after A.1 unthrottled experiment). + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (nowSec - _lastSetCycleDiagTime > 0.5) + { + System.Console.WriteLine( + $"[SCFAST] motion=0x{motion:X8} speedMod={speedMod:F3} " + + $"oldSpeedMod={CurrentSpeedMod:F3} " + + $"qCount={_queue.Count} " + + $"currNodeIsCyclic={(_currNode == _firstCyclic)}"); + _lastSetCycleDiagTime = nowSec; + } + } return; + } + + // Resolve transition link (currentSubstate → adjustedMotion). Pass + // both speeds — GetLink switches lookup branches based on sign. + // CurrentSpeedMod defaults to 1.0 (positive) on a fresh sequencer, + // so a Ready → WalkBackward transition correctly enters GetLink's + // negative-speed (reversed-key) branch. + // K-fix18: when the caller asked to skip the transition link + // (instant-engage cases like Falling on jump start), force + // linkData to null so only the cycle gets enqueued. + MotionData? linkData = (skipTransitionLink || CurrentMotion == 0) + ? null + : GetLink(style, CurrentMotion, CurrentSpeedMod, adjustedMotion, adjustedSpeed); + + // Stop-anim fallback: dat-authored leg-settle / turn-stop links are + // keyed under the FORWARD/RIGHT variant only. Stopping from + // WalkBackward / SideStepLeft / TurnLeft hits a null linkData and + // would visibly snap to Ready. The settle anim is direction-agnostic + // (legs come to standing the same way regardless of which way you + // were walking), so retry GetLink with the substate's low-byte + // remapped to its forward/right peer. + if (linkData is null && !skipTransitionLink && CurrentMotion != 0) + { + uint substateLow = CurrentMotion & 0xFFu; + uint adjustedSubstate = substateLow switch + { + 0x06u => (CurrentMotion & 0xFFFFFF00u) | 0x05u, // WalkBackward → WalkForward + 0x10u => (CurrentMotion & 0xFFFFFF00u) | 0x0Fu, // SideStepLeft → SideStepRight + 0x0Eu => (CurrentMotion & 0xFFFFFF00u) | 0x0Du, // TurnLeft → TurnRight + _ => CurrentMotion, + }; + if (adjustedSubstate != CurrentMotion) + { + linkData = GetLink(style, adjustedSubstate, CurrentSpeedMod, adjustedMotion, adjustedSpeed); + } + } + + // Resolve target cycle using the ADJUSTED motion (TurnRight not TurnLeft). + int cycleKey = (int)(((style & 0xFFFFu) << 16) | (adjustedMotion & 0xFFFFFFu)); + _mtable.Cycles.TryGetValue(cycleKey, out var cycleData); + + // Clear the old cyclic tail; keep any non-cyclic head that hasn't + // been played yet (ACE behaviour: non-cyclic anims drain naturally). + ClearCyclicTail(); + + // K-fix18: when the caller asked for instant-engage, ALSO drain + // any in-flight non-cyclic transition frames from the previous + // cycle. Without this, the old RunForward → ??? link would + // continue draining for ~100 ms before the new Falling cycle + // starts, defeating the "skip the link" intent. + if (skipTransitionLink) + { + _queue.Clear(); + _currNode = null; + _firstCyclic = null; + _framePosition = 0.0; + } + + // Clear sequence-wide physics before the rebuild. Retail's + // GetObjectSequence calls sequence.clear_physics() before each + // add_motion chain (MotionTable.cs L100-L101, L152-L153). + ClearPhysics(); + + // Snapshot the queue tail BEFORE appending new motion data so we + // can locate the first newly-added node afterward and force + // _currNode onto it. Without this, _currNode can stay pointing + // into stale non-cyclic head frames left over from the previous + // cycle (typically a Walk_link or Ready_link's tail), and the + // visible animation continues playing those stale frames before + // the queue advances naturally to the new cycle. For remote + // entities receiving many bundled UMs over time, this stale-head + // build-up was the root cause of "transitions between cycles + // don't visibly switch the leg pose" even though SetCycle's + // CurrentMotion/CurrentSpeedMod were updated correctly. Local + // player avoided the bug because PlayerMovementController fires + // SetCycle in a tight per-input loop that keeps the queue clean. + var preEnqueueTail = _queue.Last; + + // Enqueue link frames (with adjusted speed for left→right remapping). + if (linkData is { Anims.Count: > 0 }) + EnqueueMotionData(linkData, adjustedSpeed, isLooping: false); + + // Enqueue new cycle. + if (cycleData is { Anims.Count: > 0 }) + { + EnqueueMotionData(cycleData, adjustedSpeed, isLooping: true); + } + else if (_queue.Count == 0) + { + // No cycle and no link — nothing to play; reset fully. + _currNode = null; + _firstCyclic = null; + _framePosition = 0.0; + CurrentStyle = style; + CurrentMotion = motion; + return; + } + + // Mark the first cyclic node (the looping tail after all link frames). + _firstCyclic = null; + for (var n = _queue.First; n != null; n = n.Next) + { + if (n.Value.IsLooping) + { + _firstCyclic = n; + break; + } + } + + // Force _currNode onto the FIRST NEWLY-ENQUEUED node so the + // visible animation switches to the new cycle/link immediately + // instead of finishing whatever stale head frames were sitting + // at the front of the queue. preEnqueueTail.Next is the first + // newly-added node; if preEnqueueTail was null (queue was empty + // before enqueue), the first new node is _queue.First. + var firstNew = preEnqueueTail is null ? _queue.First : preEnqueueTail.Next; + + // #39 Fix B (2026-05-06): for direct cyclic-locomotion → + // cyclic-locomotion transitions (Walk↔Run on Shift toggle, + // W↔S direct flip, A↔D, Forward↔Strafe), land _currNode on + // the new CYCLE (_firstCyclic), NOT on the link (firstNew), + // and remove the just-enqueued link from the queue. + // + // Why: the transition link's drain time (~100–300 ms at + // Framerate 30 × link runSpeed) gets restarted before it can + // end if the user toggles Shift faster than that. _currNode + // sits on a fresh link every UM and Advance never reaches + // the cycle. User observes "blips forward in walking + // animation" — what they're seeing is the link's + // interpolation pose, never the new cycle. + // + // Conditional on BOTH old AND new being locomotion cycles to + // avoid regressing the cases where the link IS the right + // animation: + // - Idle (Ready) → any cycle: link is the wind-up pose + // - Falling → Ready: landing animation + // - Ready → Sitting/Crouching: pose-change links + // - Combat substates (attack/parry/ready transitions) + // Commit c06b6c5 (reverted in a2ae2ae) demonstrated that + // unconditionally skipping the link breaks all of these. + // + // Retail reference: cdb live trace 2026-05-03 of a Walk→Run + // direct transition logged + // add_to_queue(45000005, looping=1) walk + // add_to_queue(44000007, looping=1) run + // with truncate_animation_list never firing — i.e. retail + // appends the new cycle directly without a separate link + // enqueue or visible link pose for cyclic→cyclic. Our + // structural mismatch was always enqueueing link+cycle and + // forcing _currNode onto the link; this fix matches retail's + // observed semantics for the locomotion subset. + bool prevIsLocomotion = IsLocomotionCycleLowByte(CurrentMotion & 0xFFu); + bool newIsLocomotion = IsLocomotionCycleLowByte(motion & 0xFFu); + if (prevIsLocomotion && newIsLocomotion && _firstCyclic is not null) + { + // Drop the just-enqueued link node (firstNew) from the + // queue if it's distinct from the cycle — nothing should + // ever play it, and leaving stale non-cyclic nodes ahead + // of _currNode contributes to the unbounded queue growth + // observed in [SCFULL] (qCount climbing past 49 over + // ~30 transitions). + if (firstNew is not null && firstNew != _firstCyclic) + { + _queue.Remove(firstNew); + } + _currNode = _firstCyclic; + _framePosition = _firstCyclic.Value.GetStartFramePosition(); + } + else if (firstNew is not null) + { + _currNode = firstNew; + _framePosition = _currNode.Value.GetStartFramePosition(); + } + else if (_currNode == null) + { + // Defensive fallback: nothing newly added AND no current node. + _currNode = _queue.First; + _framePosition = _currNode?.Value.GetStartFramePosition() ?? 0.0; + + // D4 (Commit A 2026-05-03): SCNULLFALLBACK — proves whether the + // null-data fallback is being hit. If this fires during a + // Walk→Run transition for the watched remote, H4 (MotionTable + // GetLink/GetCycle returns null for the remote's setup) is the + // bug. linkData/cycleData null almost certainly means a + // MotionTable lookup gap for that style+motion combo. + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + System.Console.WriteLine( + $"[SCNULLFALLBACK] motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} " + + $"linkNull={(linkData is null)} cycleNull={(cycleData is null)} " + + $"qCount={_queue.Count}"); + } + } + + // D3 (Commit A 2026-05-03): SCFULL — counterpart to SCFAST. Fires on + // the full-rebuild SetCycle path. Throttled to 0.5s per instance. + // Logs prev CurrentMotion so the line shows the transition directly + // (e.g. "Run → Ready" = cycle just got reset). + if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") + { + double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; + if (nowSec - _lastSetCycleDiagTime > 0.5) + { + System.Console.WriteLine( + $"[SCFULL] prev=0x{CurrentMotion:X8} -> motion=0x{motion:X8} adjustedMotion=0x{adjustedMotion:X8} " + + $"speedMod={speedMod:F3} " + + $"qCount={_queue.Count} " + + $"firstNewNull={(firstNew is null)} " + + $"currNodeIsCyclic={(_currNode == _firstCyclic)} " + + $"firstCyclicNull={(_firstCyclic is null)}"); + _lastSetCycleDiagTime = nowSec; + } + } + + CurrentStyle = style; + CurrentMotion = motion; + CurrentSpeedMod = speedMod; + + // ── Synthesize CurrentVelocity for locomotion cycles ────────────── + // The Humanoid motion table ships every locomotion MotionData with + // Flags=0x00 (no HasVelocity), so EnqueueMotionData leaves + // CurrentVelocity at Vector3.Zero. That matches the literal retail + // dat, but retail's body physics uses CMotionInterp::get_state_velocity + // (FUN_00528960) which returns RunAnimSpeed × ForwardSpeed for + // RunForward, independent of the dat's HasVelocity flag. The dat + // velocity is a separate additive source (kick-off velocity, flying + // creatures, etc) not the primary locomotion drive. + // + // For our sequencer's to be usable by + // consumers (local-player get_state_velocity via Option B, remote + // dead-reckoning in GameWindow) it must carry the retail-constant + // locomotion value when the dat is silent. Synthesize it here, + // post-EnqueueMotionData, only when the cycle is a locomotion cycle + // AND the dat didn't populate it. + // + // Constants match etc — + // decompiled from _DAT_007c96e0/e4/e8. The velocity is body-local + // (+Y = forward, +X = right); consumers rotate into world space via + // the owning entity's orientation. + // For known locomotion cycles, ALWAYS overwrite CurrentVelocity with + // the synthesized value — even if the transition link set + // CurrentVelocity from its own HasVelocity flag. The link's velocity + // is for the brief transition (e.g. small stride into run-pose); the + // cycle's intended steady-state velocity is what consumers (remote + // body translation in GameWindow.TickAnimations env-var path) need. + // Without this, walking-to-running transitions left CurrentVelocity + // at the link's slow pace, and the user reported "it just blips + // forward walking" until another motion command (turn, etc) forced + // a re-synth. The gate that previously read + // `if (CurrentVelocity.LengthSquared() < 1e-9f)` allowed dat-baked + // velocity to win over synthesis — which is correct for non- + // locomotion (e.g. flying creatures with HasVelocity) but wrong for + // Humanoid run/walk/strafe where the dat is silent and the link + // velocity is the only thing setting it. + { + float yvel = 0f; + float xvel = 0f; + uint low = motion & 0xFFu; + bool isLocomotion = false; + switch (low) + { + case 0x05: // WalkForward + yvel = WalkAnimSpeed * adjustedSpeed; + isLocomotion = true; + break; + case 0x06: // WalkBackward — adjust_motion remapped to WalkForward + // with speedMod *= -0.65f. + yvel = WalkAnimSpeed * adjustedSpeed; + isLocomotion = true; + break; + case 0x07: // RunForward + yvel = RunAnimSpeed * adjustedSpeed; + isLocomotion = true; + break; + case 0x0F: // SideStepRight + xvel = SidestepAnimSpeed * adjustedSpeed; + isLocomotion = true; + break; + case 0x10: // SideStepLeft — remapped to SideStepRight with + // negated speed; same handling as backward walk. + xvel = SidestepAnimSpeed * adjustedSpeed; + isLocomotion = true; + break; + } + if (isLocomotion) + CurrentVelocity = new Vector3(xvel, yvel, 0f); + } // ── Synthesize CurrentOmega for turn cycles ─────────────────────── // Same story as velocity synthesis above: Humanoid turn MotionData @@ -420,7 +762,7 @@ public sealed class AnimationSequencer // chunk_00520000.c TurnRate globals (~π/2 rad/s for speed=1) // The ACE port uses `omega.z = ±(π/2) × turnSpeed` for right/left // turns (holtburger confirms the same via motion_resolution.rs). - if (_core.Omega.LengthSquared() < 1e-9f) + if (CurrentOmega.LengthSquared() < 1e-9f) { float zomega = 0f; uint low = motion & 0xFFu; @@ -441,91 +783,10 @@ public sealed class AnimationSequencer break; } if (zomega != 0f) - _core.SetOmega(new Vector3(0f, 0f, zomega)); + CurrentOmega = new Vector3(0f, 0f, zomega); } } - /// - /// R3-W4: the retail link-strip primitive - /// (CPhysicsObj::RemoveLinkAnimations 0x0050fe20 → - /// CSequence::remove_all_link_animations 0x00524ca0). The App - /// binds the entity's MotionInterpreter.RemoveLinkAnimations seam - /// here so HitGround/LeaveGround strip pending transition links exactly - /// where retail does. The pending-queue tick counts intentionally stay - /// (retail's primitive does not touch the manager queue; the countdown - /// chain absorbs it). - /// - public void RemoveAllLinkAnimations() => _core.RemoveAllLinkAnimations(); - - /// - /// R2-Q5: run retail's enter_default_state analog now if it - /// hasn't run yet (idempotent). Spawn paths call this so an entity with - /// no initial wire motion still plays the table default (retail: every - /// CPhysicsObj entering the world runs initialize_state). - /// - public void InitializeState() => EnsureInitialized(); - - /// - /// R2-Q5: the single dispatch entry — lazy initialize_state, then - /// , then (on a - /// successful InterpretedCommand) the locomotion velocity synthesis - /// (register AP-75; the consumers are remote body translation via - /// RemoteMotionCombiner.ComputeOffset and the local Option-B - /// get_state_velocity — retire in R6 when root motion drives the body). - /// Omega is deliberately NOT synthesized here: remote rotation is the - /// ObservedOmega seam (MotionTableDispatchSink callbacks, retire R6); - /// only the SetCycle path keeps the turn-omega fallback. - /// - public uint PerformMovement(MotionTableMovement movement) - { - EnsureInitialized(); - uint result = _manager.PerformMovement(movement); - if (result == MotionTableManagerError.Success - && movement.Type == MovementType.InterpretedCommand) - { - SynthesizeLocomotionVelocity(movement.Motion, movement.Speed); - } - return result; - } - - /// - /// Overwrite sequence velocity with the retail locomotion constant when - /// the dispatched motion is a locomotion cycle. The Humanoid motion - /// table ships every locomotion MotionData with a zero Velocity, so - /// add_motion's unconditional set leaves the sequence at zero — - /// but retail's body physics uses CMotionInterp::get_state_velocity - /// (0x00528960: RunAnimSpeed × ForwardSpeed etc., independent of the - /// dat), so consumers of need the - /// constant here. Velocity is body-local (+Y forward, +X right); - /// consumers rotate into world space via the entity orientation. - /// Constants decompiled from _DAT_007c96e0/e4/e8. - /// - private void SynthesizeLocomotionVelocity(uint motion, float speed) - { - float yvel = 0f; - float xvel = 0f; - bool isLocomotion = false; - switch (motion & 0xFFu) - { - case 0x05: // WalkForward - case 0x06: // WalkBackward (pre-adjust callers; adjusted = 0x05) - yvel = WalkAnimSpeed * speed; - isLocomotion = true; - break; - case 0x07: // RunForward - yvel = RunAnimSpeed * speed; - isLocomotion = true; - break; - case 0x0F: // SideStepRight - case 0x10: // SideStepLeft (pre-adjust callers; adjusted = 0x0F) - xvel = SidestepAnimSpeed * speed; - isLocomotion = true; - break; - } - if (isLocomotion) - _core.SetVelocity(new Vector3(xvel, yvel, 0f)); - } - // Retail locomotion constants — mirror of MotionInterpreter.RunAnimSpeed // etc. Kept here to keep AnimationSequencer self-contained for the // synthesize-velocity path above. Values decompiled from _DAT_007c96e0/e4/e8. @@ -533,23 +794,64 @@ public sealed class AnimationSequencer private const float RunAnimSpeed = 4.0f; private const float SidestepAnimSpeed = 1.25f; - // R2-Q4: MultiplyCyclicFramerate DELETED (zero external callers). The - // gap-map-G13 composite stand-in (framerate scale + velocity/omega - // rescale) is retired — same-motion re-speeds now route through the - // verbatim CMotionTable fast re-speed path (change_cycle_speed + - // subtract_motion(old) + combine_motion(new), decomp §5). + /// + /// Scale every cyclic node's framerate by , mirroring + /// ACE's Sequence.multiply_cyclic_animation_framerate + /// (references/ACE/Source/ACE.Server/Physics/Animation/Sequence.cs L277-L287, + /// retail decompile FUN_00525CE0). Walks _firstCyclic through + /// the tail of the queue and calls + /// on each. The non-cyclic head (link frames) is untouched — those drain + /// at their original framerate, which matches retail: the sequencer + /// "catches up" the transition before applying the new run speed. + /// + /// + /// Called from when the same (style, motion) pair + /// is re-issued with a different speedMod — for instance, when a remote + /// player's ForwardSpeed changes mid-run. Does NOT restart the animation, + /// so footsteps keep planting where they are. + /// + /// + /// Framerate multiplier (newSpeed / oldSpeed). + public void MultiplyCyclicFramerate(float factor) + { + if (_firstCyclic == null) return; + if (factor < 0f || float.IsNaN(factor) || float.IsInfinity(factor)) + return; + + for (var node = _firstCyclic; node != null; node = node.Next) + { + node.Value.MultiplyFramerate((double)factor); + } + + // Sequence-wide velocity/omega scale too. Retail's flow is + // subtract_motion(oldSpeed) + combine_motion(newSpeed) in + // MotionTable.change_cycle_speed (MotionTable.cs L372-L379), which + // algebraically equals scaling by newSpeed/oldSpeed — exactly + // what the factor represents here. + CurrentVelocity *= factor; + CurrentOmega *= factor; + } /// /// Advance the animation by seconds and return the /// per-part transforms for the current blended keyframe. /// /// - /// R1-P5: delegates all frame-advance math to - /// (the verbatim update/update_internal/ - /// advance_to_next_animation port — gap map G3/G4/G5/G8/G9/G19). - /// The core queues hooks through into - /// ; this method only builds the blended - /// render-side output. + /// Implements Sequence::update_internal (FUN_005261D0 / ACE + /// Sequence.cs:351-443): walks every integer frame boundary crossed in + /// this tick, calls execute_hooks for each with the playback + /// direction, and accumulates root + /// motion into the pending delta. Hooks fire only once per crossing + /// regardless of framerate scaling. + /// + /// + /// + /// Crossing semantics (forward): as floor(framePos) increments + /// from i to i+1, hooks attached to frame i with + /// direction Forward or Both fire. Reverse: as + /// floor(framePos) decrements from i to i-1, + /// hooks with direction Backward or Both fire on frame + /// i. /// /// /// Elapsed time in seconds since the last call. @@ -558,35 +860,100 @@ public sealed class AnimationSequencer /// If no animation is loaded, all parts get identity transforms. /// public IReadOnlyList Advance(float dt) - => Advance(dt, rootMotionFrame: null); - - /// - /// R1-P6 (gap map G7): the root-motion overload. When - /// is supplied, the core's - /// update/update_internal apply BOTH root-motion sources - /// into it exactly as retail's CPartArray::Update path does — - /// the per-crossed-frame PosFrames combine/subtract AND the - /// sequence velocity/omega via apply_physics (0x00524ab0). This - /// is the seam R6's retail per-tick order consumes - /// (UpdatePositionInternal → CPartArray.Update → adjust_offset → - /// Frame.combine). - /// - public IReadOnlyList Advance(float dt, Frame? rootMotionFrame) { int partCount = _setup.Parts.Count; - if (_core.CurrAnim == null && rootMotionFrame is null) + if (_currNode == null || dt <= 0f) return BuildIdentityFrame(partCount); - if (dt <= 0f) - return _core.CurrAnim == null - ? BuildIdentityFrame(partCount) - : BuildBlendedFrame(); - _core.Update(dt, rootMotionFrame); + // ── update_internal (FUN_005261D0 / ACE Sequence.update_internal) ─ + // Loop because a large dt can exhaust multiple nodes sequentially. + double timeRemaining = (double)dt; + int safety = 64; // cap in case of a degenerate motion table - return _core.CurrAnim == null - ? BuildIdentityFrame(partCount) - : BuildBlendedFrame(); + while (timeRemaining > 0.0 && _currNode != null && safety-- > 0) + { + var curr = _currNode.Value; + double rate = curr.Framerate; // signed (negative = reverse) + double delta = rate * timeRemaining; + + if (Math.Abs(delta) < RateEpsilon) + break; // rate ≈ 0 — nothing to do + + // lastFrame = floor(_framePosition) BEFORE advance (ACE pattern). + int lastFrame = (int)Math.Floor(_framePosition); + + double newPos = _framePosition + delta; + bool wrapped = false; + double overflow = 0.0; + + if (delta > 0.0) + { + // ── FORWARD PLAYBACK ────────────────────────────────────── + double maxBoundary = (double)(curr.EndFrame + 1); + if (newPos >= maxBoundary - FrameEpsilon) + { + // Time spilled past the boundary. + overflow = (newPos - maxBoundary) / rate; + if (overflow < 0.0) overflow = 0.0; + + _framePosition = maxBoundary - FrameEpsilon; + wrapped = true; + } + else + { + _framePosition = newPos; + } + + // Walk every integer frame boundary crossed: apply posFrame + // delta and fire hooks with Forward direction. + while ((int)Math.Floor(_framePosition) > lastFrame) + { + ApplyPosFrame(curr, lastFrame, reverse: false); + ExecuteHooks(curr, lastFrame, AnimationHookDir.Forward); + lastFrame++; + } + } + else + { + // ── REVERSE PLAYBACK ───────────────────────────────────── + double minBoundary = (double)curr.StartFrame; + if (newPos <= minBoundary) + { + overflow = (newPos - minBoundary) / rate; + if (overflow < 0.0) overflow = 0.0; + + _framePosition = minBoundary; + wrapped = true; + } + else + { + _framePosition = newPos; + } + + // Walk every integer boundary crossed DOWN: subtract posFrame + // delta and fire hooks with Backward direction. + while ((int)Math.Floor(_framePosition) < lastFrame) + { + ApplyPosFrame(curr, lastFrame, reverse: true); + ExecuteHooks(curr, lastFrame, AnimationHookDir.Backward); + lastFrame--; + } + } + + if (!wrapped) + break; // consumed all dt without hitting node boundary — done + + // ── advance_to_next_animation (FUN_00525EB0) ───────────────── + // Fire AnimationDone for any drained link node before wrap. + if (_currNode != null && !_currNode.Value.IsLooping) + _pendingHooks.Add(AnimationDoneSentinel); + + AdvanceToNextAnimation(); + timeRemaining = overflow; // continue with leftover time + } + + return BuildBlendedFrame(); } /// @@ -604,106 +971,454 @@ public sealed class AnimationSequencer return result; } - // R1-P6: ConsumeRootMotionDelta DELETED (zero external callers; gap map - // API-migration table). Root motion flows through the - // Advance(dt, Frame) overload — retail's CSequence::update(Frame*) - // contract — for R6's per-tick wiring. + /// + /// Retrieve and clear the root-motion displacement accumulated from + /// during the last + /// calls. Returns (Zero, Identity) when no PosFrames exist on the + /// current animation. The caller should combine this with their AFrame + /// (object placement) to propagate root motion — e.g. baked-in footsteps + /// on a running animation. + /// + public (Vector3 Position, Quaternion Rotation) ConsumeRootMotionDelta() + { + var result = (_rootMotionPos, _rootMotionRot); + _rootMotionPos = Vector3.Zero; + _rootMotionRot = Quaternion.Identity; + return result; + } /// - /// Play a one-shot action / modifier motion (Jump, emote, attack, etc.) - /// on top of the current cycle. + /// Play a one-shot action/modifier motion (Jump, emote, attack, etc.) + /// on top of the current cycle. The action frames are inserted in the + /// queue immediately before the looping cyclic tail; they drain once + /// and then the cycle resumes naturally. /// /// - /// R2-Q4: a thin shim over - /// — action-class ids - /// (0x10000000) route GetObjectSequence Branch 3 (rebuild: - /// substate→action link + base cycle re-added, the action tracked on the - /// MotionState action FIFO and popped by the manager's countdown); - /// modifier-class ids (0x20000000) route Branch 4 (PHYSICS-ONLY - /// combine_motion — no animation frames; the pre-Q4 - /// insert-before-tail modifier-anim mechanism was an acdream invention, - /// deleted). Unknown ids fail the dispatch and are a no-op, matching the - /// pre-Q4 contract. + /// Retail semantics: actions and modifiers live in + /// (a separate dict from + /// ) keyed by + /// (style << 16) | (motion & 0xFFFFFF). A motion like + /// Jump = 0x2500003b is a Modifier (class byte 0x25) not a + /// SubState — feeding it to silently fails the + /// cycle lookup. Routing through PlayAction instead resolves + /// from the Modifiers table and interleaves the action frames with + /// the ongoing cyclic motion. + /// + /// + /// + /// If no entry is found in the Modifiers table for the requested + /// motion, this is a no-op. /// /// /// Raw MotionCommand (e.g. 0x2500003b for Jump). /// Speed multiplier for the action's framerate. public void PlayAction(uint motionCommand, float speedMod = 1f) { - EnsureInitialized(); - _manager.PerformMovement(MotionTableMovement.Interpreted(motionCommand, speedMod)); + // Resolve motion data. The lookup depends on the command's mask class: + // + // - Action (mask 0x10): stored in the Links dict as the transition + // FROM currentSubstate TO the action motion. Matches ACE + // MotionTable.GetObjectSequence @ line 189-207 (CommandMask.Action). + // - Modifier (mask 0x20): stored in the Modifiers dict, keyed by + // (style<<16) | (motion&0xFFFFFF) (or unstyled key). Matches ACE + // @ line 234-242 (CommandMask.Modifier). + // + // Jump (0x2500003B) has BOTH bits set (0x20|0x04|0x01) but ACE treats + // it via the Modifier path. FallDown (0x10000050) / Jumpup (0x1000004B) + // are pure Actions (mask 0x10) and live in Links. + // + // We try Links first (via GetLink, which reproduces ACE's get_link + // fallback chain). If that fails and the motion is a Modifier, fall + // through to the Modifiers dict. + const uint ActionMask = 0x10000000u; + const uint ModifierMask = 0x20000000u; + + MotionData? data = null; + if ((motionCommand & ActionMask) != 0 && CurrentMotion != 0) + { + // Action: look up the transition link from current substate → action. + // Action overlays always play forward (positive speeds) — the + // action speed mod is the caller-supplied modifier, not part of + // the substate cycle's direction. + data = GetLink(CurrentStyle, CurrentMotion, /*substateSpeed:*/ 1f, motionCommand, /*speed:*/ 1f); + } + if (data is null && (motionCommand & ModifierMask) != 0) + { + uint styleKey = CurrentStyle << 16; + int keyStyled = (int)(styleKey | (motionCommand & 0xFFFFFFu)); + int keyPlain = (int)(motionCommand & 0xFFFFFFu); + if (!_mtable.Modifiers.TryGetValue(keyStyled, out data)) + _mtable.Modifiers.TryGetValue(keyPlain, out data); + } + + if (data is null || data.Anims.Count == 0) + return; + + // Build AnimNodes from the action's AnimData list. All non-looping — + // they drain once, then the queue falls through to _firstCyclic. + Vector3 vel = data.Flags.HasFlag(MotionDataFlags.HasVelocity) + ? data.Velocity * speedMod : Vector3.Zero; + Vector3 omg = data.Flags.HasFlag(MotionDataFlags.HasOmega) + ? data.Omega * speedMod : Vector3.Zero; + + var newNodes = new List(data.Anims.Count); + for (int i = 0; i < data.Anims.Count; i++) + { + var node = LoadAnimNode(data.Anims[i], speedMod, isLooping: false, vel, omg); + if (node != null) newNodes.Add(node); + } + if (newNodes.Count == 0) return; + + // Insert before the cyclic tail (so the action plays, then cycle resumes). + // If there's no cyclic tail yet, append at the end. + LinkedListNode? firstInserted = null; + if (_firstCyclic != null) + { + foreach (var n in newNodes) + { + var inserted = _queue.AddBefore(_firstCyclic, n); + firstInserted ??= inserted; + } + } + else + { + foreach (var n in newNodes) + { + var inserted = _queue.AddLast(n); + firstInserted ??= inserted; + } + } + + // If we're currently on the cyclic tail (or past where we inserted), + // jump the cursor back to the first newly-inserted action node so the + // action plays immediately instead of after the next cycle wrap. + bool cursorOnCyclic = _currNode != null && _currNode.Value.IsLooping; + if (cursorOnCyclic || _currNode == null) + { + _currNode = firstInserted; + if (_currNode != null) + _framePosition = _currNode.Value.GetStartFramePosition(); + } } /// /// Reset the sequencer to an unplaying state without clearing the - /// motion table reference. R2-Q4: drains the pending-animation queue - /// (exit-world semantics — each entry fires MotionDone(success:false)) - /// and zeroes the MotionState so the next SetCycle re-runs - /// initialize_state. + /// motion table reference. /// public void Reset() { - _manager.HandleExitWorld(); - _core.Clear(); + _queue.Clear(); + _currNode = null; + _firstCyclic = null; + _framePosition = 0.0; _pendingHooks.Clear(); - _state.Style = 0; - _state.Substate = 0; - _state.SubstateMod = 1f; - _state.ClearModifiers(); - _state.ClearActions(); - _initialized = false; + _rootMotionPos = Vector3.Zero; + _rootMotionRot = Quaternion.Identity; + CurrentStyle = 0; + CurrentMotion = 0; + CurrentSpeedMod = 1f; + CurrentVelocity = Vector3.Zero; + CurrentOmega = Vector3.Zero; } // ── Private helpers ────────────────────────────────────────────────────── - /// - /// Host seam wiring the core's into this - /// adapter's list. Mirrors retail's - /// CPhysicsObj.anim_hooks SmartArray queue-then-drain model (gap - /// map G6): the core QUEUES matched hooks here during - /// ; - /// drains them at the render-tick call site (GameWindow), same as - /// pre-cutover. The AnimDone hook maps to the existing - /// singleton so downstream sinks - /// (AnimationHookRouter) see the same instance type they always have. - /// - private sealed class AdapterHookQueue : IAnimHookQueue - { - private readonly AnimationSequencer _owner; - public AdapterHookQueue(AnimationSequencer owner) => _owner = owner; - - public void AddAnimHook(AnimationHook hook) => _owner._pendingHooks.Add(hook); - - public void AddAnimDoneHook() => _owner._pendingHooks.Add(AnimationDoneSentinel); - } - // Sentinel hook fired when a non-cyclic link node drains naturally. // Mirrors ACE's PhysicsObj.add_anim_hook(AnimationHook.AnimDoneHook). private static readonly AnimationDoneHook AnimationDoneSentinel = new() { Direction = AnimationHookDir.Both }; - // R2-Q4: GetLink DELETED - re-homed verbatim as CMotionTable.GetLink - // (Q2, field-validated reversed-key branch preserved; Q0-pins A1). + /// + /// Look up the transition MotionData for going from + /// (current state, played at ) to + /// (new state, played at ). + /// + /// + /// Port of ACE's MotionTable.get_link (MotionTable.cs:395-426). The lookup + /// path differs by sign of the speeds — the retail/ACE mechanism is two + /// distinct branches: + /// + /// Both speeds positive (forward → forward, normal case): + /// Look up Links[(style<<16) | substate][motion] — the link FROM + /// substate TO motion. Played forward. + /// Either speed negative (any direction reversal — + /// WalkBackward, SideStepLeft, TurnLeft): Look up the REVERSED key + /// Links[(style<<16) | motion][substate] — the link FROM motion TO + /// substate. Played in reverse, this anim visually transitions + /// substate → motion's pose, then the cycle continues from where it + /// left off. Without this branch, Ready→WalkBackward would queue the + /// "start walking forward" link played in reverse, which strands the + /// cursor at the wrong cycle frame and causes the user-visible + /// "left leg twitches forward two times" glitch on the X key. + /// + /// + /// + /// DatReaderWriter encodes Links as Dictionary<int, MotionCommandData> + /// where MotionCommandData.MotionData is Dictionary<int, MotionData>. + /// + private MotionData? GetLink(uint style, uint substate, float substateSpeed, uint motion, float speed) + { + if (speed < 0f || substateSpeed < 0f) + { + // Reversed-direction path: link FROM motion TO substate. + int reversedKey = (int)((style << 16) | (motion & 0xFFFFFFu)); + if (_mtable.Links.TryGetValue(reversedKey, out var revLink) + && revLink.MotionData.TryGetValue((int)substate, out var revResult)) + { + return revResult; + } - // R2-Q4: BuildNode + EnqueueMotionData DELETED - MotionData appends are - // owned by CMotionTable.AddMotion (add_motion 0x005224b0, unconditional - // velocity/omega set = G17 core semantics; the adapter's HasVelocity/ - // HasOmega gate is retired with them). + // Style-defaults fallback per ACE MotionTable.cs:405-409. + if (_mtable.StyleDefaults.TryGetValue( + (DatReaderWriter.Enums.MotionCommand)style, out var defaultMotion)) + { + int subKey = (int)((style << 16) | (substate & 0xFFFFFFu)); + if (_mtable.Links.TryGetValue(subKey, out var subLink) + && subLink.MotionData.TryGetValue((int)defaultMotion, out var subResult)) + { + return subResult; + } + } + return null; + } + + // Forward-direction path: link FROM substate TO motion (the original + // implementation pre-K-fix6). + int outerKey1 = (int)((style << 16) | (substate & 0xFFFFFFu)); + if (_mtable.Links.TryGetValue(outerKey1, out var cmd1) + && cmd1.MotionData.TryGetValue((int)motion, out var result1)) + { + return result1; + } + + // Fallback: style-level catch-all (ACE line 419-422). + int outerKey2 = (int)(style << 16); + if (_mtable.Links.TryGetValue(outerKey2, out var cmd2) + && cmd2.MotionData.TryGetValue((int)motion, out var result2)) + { + return result2; + } + + return null; + } + + /// + /// Load an Animation from the dat by its + /// and resolve the sentinel frame bounds (HighFrame == -1 means "all frames"). + /// + private AnimNode? LoadAnimNode( + AnimData ad, + float speedMod, + bool isLooping, + Vector3 velocity, + Vector3 omega) + { + uint animId = (uint)ad.AnimId; + if (animId == 0) return null; + + var anim = _loader.LoadAnimation(animId); + if (anim is null || anim.PartFrames.Count == 0) return null; + + int numFrames = anim.PartFrames.Count; + int low = ad.LowFrame; + int high = ad.HighFrame; + + // Sentinel resolution (same as MotionResolver.GetIdleCycle). + if (high < 0) high = numFrames - 1; + if (low >= numFrames) low = numFrames - 1; + if (high >= numFrames) high = numFrames - 1; + if (low < 0) low = 0; + + double fr = (double)ad.Framerate * (double)speedMod; + + // Do NOT swap StartFrame↔EndFrame for negative speed. + // The Advance loop handles negative delta by checking against + // StartFrame as the lower boundary. GetStartFramePosition uses + // EndFrame (the HIGH value) to start the cursor near the top + // for reverse playback, so the cursor traverses all frames + // from high→low instead of being stuck in [0,1). + if (low > high) high = low; + + bool hasPosFrames = anim.Flags.HasFlag(AnimationFlags.PosFrames) + && anim.PosFrames.Count >= numFrames; + + return new AnimNode( + anim, + fr, + startFrame: low, + endFrame: high, + isLooping, + hasPosFrames, + velocity, + omega); + } + + /// + /// Reset the sequence's Velocity + Omega (retail Sequence.clear_physics, + /// ACE Sequence.cs L256-L260). Called before a style-transition rebuild + /// in SetCycle so we don't inherit velocity from the previous cycle. + /// + private void ClearPhysics() + { + CurrentVelocity = Vector3.Zero; + CurrentOmega = Vector3.Zero; + } + + /// + /// Append all AnimData entries from to the + /// queue. Each AnimData becomes one AnimNode. Velocity / Omega from the + /// MotionData are applied to every resulting node so they remain active + /// while the node is current. + /// + private void EnqueueMotionData(MotionData motionData, float speedMod, bool isLooping) + { + Vector3 vel = motionData.Flags.HasFlag(MotionDataFlags.HasVelocity) + ? motionData.Velocity * speedMod : Vector3.Zero; + Vector3 omg = motionData.Flags.HasFlag(MotionDataFlags.HasOmega) + ? motionData.Omega * speedMod : Vector3.Zero; + + // Sequence-wide velocity/omega update, matching ACE's + // MotionTable.add_motion (MotionTable.cs L358-L370): SetVelocity + // REPLACES the previous sequence velocity. When SetCycle enqueues + // link then cycle, the final CurrentVelocity is the cycle's — which + // is what dead-reckoning needs to read from the first frame of the + // link transition (the cycle velocity is already "queued up" even + // while a zero-velocity link plays visually). + // + // Only replace if HasVelocity (else we'd zero out a running cycle + // when a transient HasVelocity=0 modifier enqueues). Matches + // retail's conditional behavior: MotionData without HasVelocity + // doesn't touch the sequence velocity. + if (motionData.Flags.HasFlag(MotionDataFlags.HasVelocity)) + CurrentVelocity = vel; + if (motionData.Flags.HasFlag(MotionDataFlags.HasOmega)) + CurrentOmega = omg; + + for (int i = 0; i < motionData.Anims.Count; i++) + { + bool nodeCycling = isLooping && (i == motionData.Anims.Count - 1); + var node = LoadAnimNode(motionData.Anims[i], speedMod, nodeCycling, vel, omg); + if (node != null) + _queue.AddLast(node); + } + } + + /// + /// Remove all cyclic (looping) nodes from the tail of the queue starting + /// from . Non-cyclic link frames remain so they + /// can drain naturally. + /// + private void ClearCyclicTail() + { + if (_firstCyclic == null) return; + + var node = _firstCyclic; + while (node != null) + { + var next = node.Next; + // If the active node is being removed, jump it to the preceding + // non-cyclic node (or reset if there is none). + if (_currNode == node) + { + _currNode = node.Previous; + if (_currNode != null) + _framePosition = _currNode.Value.GetEndFramePosition(); + else + _framePosition = 0.0; + } + _queue.Remove(node); + node = next; + } + + _firstCyclic = null; + } + + /// + /// Move to the next node in the queue, or wrap + /// back to when the queue is exhausted. + /// + /// Implements FUN_00525EB0 (Sequence::advance_to_next_animation). + /// The retail client walks a doubly-linked list; we mirror that with + /// LinkedList.Next plus the _firstCyclic wrap sentinel. + /// + private void AdvanceToNextAnimation() + { + if (_currNode == null) return; + + LinkedListNode? next = _currNode.Next; + + if (next != null) + { + _currNode = next; + } + else if (_firstCyclic != null) + { + // Wrap to first cyclic node — this is the loop that keeps idle/walk + // animations playing forever. + _currNode = _firstCyclic; + } + // else: end of a finite non-looping sequence; stay on last node. + + if (_currNode != null) + _framePosition = _currNode.Value.GetStartFramePosition(); + } + + /// + /// Dispatch any hooks on the given part frame whose direction matches + /// the playback direction (or Both). Mirrors ACE's + /// Sequence.execute_hooks (Sequence.cs:262). + /// + private void ExecuteHooks(AnimNode node, int frameIndex, AnimationHookDir playbackDir) + { + if (frameIndex < 0 || frameIndex >= node.Anim.PartFrames.Count) return; + var frame = node.Anim.PartFrames[frameIndex]; + if (frame.Hooks.Count == 0) return; + + for (int i = 0; i < frame.Hooks.Count; i++) + { + var hook = frame.Hooks[i]; + if (hook == null) continue; + // ACE: hook.Direction == Both || hook.Direction == playbackDir + if (hook.Direction == AnimationHookDir.Both + || hook.Direction == playbackDir) + { + _pendingHooks.Add(hook); + } + } + } + + /// + /// Apply the (root motion) delta for + /// to the accumulated pending delta. + /// Mirrors ACE's AFrame.Combine (forward) / frame.Subtract + /// (backward) calls in update_internal. + /// + private void ApplyPosFrame(AnimNode node, int frameIndex, bool reverse) + { + if (!node.HasPosFrames) return; + var posFrames = node.Anim.PosFrames; + if (frameIndex < 0 || frameIndex >= posFrames.Count) return; + var pf = posFrames[frameIndex]; + + if (!reverse) + { + // AFrame.Combine: position += rot.Rotate(pf.Origin); rot *= pf.Orientation + _rootMotionPos += Vector3.Transform(pf.Origin, _rootMotionRot); + _rootMotionRot = Quaternion.Normalize(_rootMotionRot * pf.Orientation); + } + else + { + // AFrame.Subtract: rot *= conj(pf.Orientation); position -= rot.Rotate(pf.Origin) + var invRot = Quaternion.Conjugate(pf.Orientation); + _rootMotionRot = Quaternion.Normalize(_rootMotionRot * invRot); + _rootMotionPos -= Vector3.Transform(pf.Origin, _rootMotionRot); + } + } /// /// Build the per-part blended transform from the current animation frame. - /// Blends between floor(FrameNumber) and floor(FrameNumber)+1 within the - /// CURRENT core node, using the fractional part of FrameNumber. - /// - /// - /// R1-P5: this is render-side interpolation, NOT CSequence scope (gap map - /// G19's "blend seam MED" note) — the core only tracks the floored - /// current frame (get_curr_animframe). The +1 index is clamped to - /// the node's /LowFrame window - /// (direction-aware) to preserve the pre-cutover #61 fix (link-tail - /// holds its end pose instead of blending into frame 0 of the next - /// node). - /// + /// Blends between floor(_framePosition) and floor(_framePosition)+1 using + /// the fractional part of _framePosition. /// /// Uses the retail-client slerp () for /// quaternion interpolation and linear lerp for position. @@ -712,56 +1427,49 @@ public sealed class AnimationSequencer { int partCount = _setup.Parts.Count; - var curr = _core.CurrAnim; - if (curr is null || curr.Anim is null) + if (_currNode == null) return BuildIdentityFrame(partCount); + var curr = _currNode.Value; int numPartFrames = curr.Anim.PartFrames.Count; - // Clamp frameIndex to the node's valid window. - int rangeLo = Math.Min(curr.LowFrame, curr.HighFrame); - int rangeHi = Math.Max(curr.LowFrame, curr.HighFrame); + // Clamp frameIndex to valid range. + int rangeLo = Math.Min(curr.StartFrame, curr.EndFrame); + int rangeHi = Math.Max(curr.StartFrame, curr.EndFrame); rangeHi = Math.Min(rangeHi, numPartFrames - 1); - rangeLo = Math.Max(rangeLo, 0); - int frameIdx = (int)Math.Floor(_core.FrameNumber); + int frameIdx = (int)Math.Floor(_framePosition); frameIdx = Math.Clamp(frameIdx, rangeLo, rangeHi); // Next frame for interpolation: step in the playback direction. - // Wrap to the opposite end ONLY when this node IS the cyclic tail - // (curr == first_cyclic). For one-shot nodes (link transitions, - // action overlays), hold the boundary frame instead — otherwise the - // fractional tail of the anim blends frame[end] with frame[0], - // producing a brief flash through the anim's starting pose at the - // link→cycle boundary (issue #61: door swing-open flap; run-stop - // twitch). This is render-side clamping, not a CSequence semantic. - bool nodeIsCyclic = ReferenceEquals(_core.CurrAnim, _core.FirstCyclic); + // Wrap to opposite end ONLY for looping cyclic nodes. For one-shot + // nodes (link transitions, action overlays), hold the boundary + // frame instead — otherwise the fractional tail of the anim + // blends frame[end] with frame[0], producing a brief flash through + // the anim's starting pose at the link→cycle boundary (issue #61: + // door swing-open flap; run-stop twitch). int nextIdx; - if (curr.Framerate >= 0f) + if (curr.Framerate >= 0.0) { nextIdx = frameIdx + 1; if (nextIdx > rangeHi || nextIdx >= numPartFrames) - nextIdx = nodeIsCyclic ? rangeLo : frameIdx; + nextIdx = curr.IsLooping ? rangeLo : frameIdx; } else { nextIdx = frameIdx - 1; if (nextIdx < rangeLo) - nextIdx = nodeIsCyclic ? rangeHi : frameIdx; + nextIdx = curr.IsLooping ? rangeHi : frameIdx; } // Fractional blend weight (always in [0, 1]). - double rawT = _core.FrameNumber - Math.Floor(_core.FrameNumber); + double rawT = _framePosition - Math.Floor(_framePosition); float t = (float)Math.Clamp(rawT, 0.0, 1.0); var f0Parts = curr.Anim.PartFrames[frameIdx].Frames; var f1Parts = curr.Anim.PartFrames[nextIdx].Frames; - // MP-Alloc: overwrite the reusable per-instance buffer in place - // instead of allocating a fresh PartTransform[partCount] every call. - // Sized once in the constructor to _setup.Parts.Count, which never - // changes, so partCount here always matches the buffer length. - var result = _partTransformScratch; + var result = new PartTransform[partCount]; for (int i = 0; i < partCount; i++) { if (i < f0Parts.Count) @@ -782,20 +1490,27 @@ public sealed class AnimationSequencer return result; } - private IReadOnlyList BuildIdentityFrame(int partCount) + private static IReadOnlyList BuildIdentityFrame(int partCount) { - // MP-Alloc: same reusable buffer as BuildBlendedFrame (see - // _partTransformScratch) — overwritten in place, never reallocated. - var result = _partTransformScratch; + var result = new PartTransform[partCount]; for (int i = 0; i < partCount; i++) result[i] = new PartTransform(Vector3.Zero, Quaternion.Identity); return result; } - // R2-Q4: IsLocomotionCycleLowByte DELETED with Fix B - the cyclic-to- - // cyclic link-skip is now retail's remove_redundant_links (0x0051bf20, - // MotionTableManager.RemoveRedundantLinks) operating on the pending - // queue, not a locomotion-subset special case in the adapter. + /// + /// True if the given motion-low-byte names a locomotion cycle — + /// WalkForward (0x05), WalkBackward (0x06), RunForward (0x07), + /// SideStepRight (0x0F), or SideStepLeft (0x10). + /// Used by to recognise cyclic→cyclic + /// direct transitions and bypass the transition link in that case + /// (retail's observed add_to_queue semantics). + /// + private static bool IsLocomotionCycleLowByte(uint lowByte) + { + return lowByte == 0x05u || lowByte == 0x06u || lowByte == 0x07u + || lowByte == 0x0Fu || lowByte == 0x10u; + } /// /// Quaternion slerp matching the retail client's FUN_005360d0 diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 3538e497..99d906b2 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1399,35 +1399,26 @@ public static class BSPQuery // ------------------------------------------------------------------------- // slide_sphere — BSPTree level - // Retail: BSPTREE::slide_sphere (find_collisions Contact head-hit dispatch - // at 0x0053a697). ACE: BSPTree.cs:310-316. + // ACE: BSPTree.cs slide_sphere // ------------------------------------------------------------------------- /// - /// BSPTree.slide_sphere — dispatch the real sphere-level slide - /// (CSphere::slide_sphere 0x00537440, ported as - /// ): slide IN-FRAME along the - /// crease between the collision normal and the contact plane, applied to - /// sphere 0's check position (ACE BSPTree.cs:315 — - /// GlobalSphere[0].SlideSphere(..., GlobalCurrCenter[0].Center)). + /// BSPTree.slide_sphere — apply sliding collision response. /// /// - /// #137 mechanism 2 (2026-07-06): this was a stub that set - /// CollisionInfo.SlidingNormal and returned Slid. Retail's BSP layer - /// never writes the sliding normal — its only in-transition writer is - /// CTransition::validate_transition (0x0050ac21) — so the stub's - /// leaked normal survived to the body writeback and absorbed the next - /// frame's exactly-anti-parallel offset: the Facility Hub corridor - /// phantom's dead-stop half (ISSUES #137). + /// Sets the sliding normal on CollisionInfo so the outer transition loop + /// applies a wall-slide projection. /// + /// + /// ACE: BSPTree.cs slide_sphere — calls GlobalSphere[0].SlideSphere. /// - /// Collision normal already in world space (the - /// call sites apply L2W; ACE globalizes inside slide_sphere instead). private static TransitionState SlideSphere( Transition transition, - Vector3 worldNormal) - => transition.SlideSphereInternal( - worldNormal, transition.SpherePath.GlobalCurrCenter[0].Origin); + Vector3 collisionNormal) + { + transition.CollisionInfo.SetSlidingNormal(collisionNormal); + return TransitionState.Slid; + } // ------------------------------------------------------------------------- // collide_with_pt — BSPTree level @@ -1721,20 +1712,9 @@ public static class BSPQuery Vector3 L2W(Vector3 v) => Vector3.Transform(v, localToWorld); // ---------------------------------------------------------------- - // Path 1: Placement or obstruction_ethereal → sphere_intersects_solid + // Path 1: Placement or Ethereal → sphere_intersects_solid // ---------------------------------------------------------------- - // Retail BSPTREE::find_collisions pc:323742 / 0x0053a496: - // `if (insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0)` - // obstruction_ethereal is a per-object flag set by FindObjCollisions - // (pc:276806) when the target is ETHEREAL-alone (ETHEREAL_PS=0x4 set, - // IGNORE_COLLISIONS_PS=0x10 NOT set). When set, Path 1 fires instead of - // the blocking paths: sphere_intersects_solid only returns COLLIDED if - // the player sphere is inside a BSP solid leaf. For an open door (no - // solid wall at the doorway), no solid leaf intersects → OK → passable. - // Previously obj.Ethereal (ObjectInfo.Ethereal, always false) was here — - // the correct flag is SpherePath.ObstructionEthereal, set per target. - // Task 3 (2026-06-24) / retail divergence register AD-7 retired. - if (path.InsertType == InsertType.Placement || path.ObstructionEthereal) + if (path.InsertType == InsertType.Placement || obj.Ethereal) { // BR-7 / A6.P4 (2026-06-11): retail weakens the solid test // against BUILDING shells while the path engages interior cells @@ -1903,14 +1883,11 @@ public static class BSPQuery if (engine is not null && !path.StepUp && !path.StepDown) return StepSphereUp(transition, worldNormal, engine); - // No engine OR step-up/step-down already in progress — the - // real slide response. Retail: a blocked step-up funnels to - // SPHEREPATH::step_up_slide → CSphere::slide_sphere (ACE - // SpherePath.cs:316); the slide records the collision normal - // itself and never writes the sliding normal (#137 mechanism - // 2 — the old SetSlidingNormal stub here leaked a normal that - // wedged the next frame's forward offset). - return SlideSphere(transition, worldNormal); + // No engine OR step-up/step-down already in progress — fall + // back to wall-slide. + collisions.SetCollisionNormal(worldNormal); + collisions.SetSlidingNormal(worldNormal); + return TransitionState.Slid; } // Sphere 0 didn't fully hit. Per retail, the head-sphere test AND @@ -1949,10 +1926,9 @@ public static class BSPQuery PhysicsDiagnostics.LastBspHitPoly = hitPoly1; var worldNormal = L2W(hitPoly1!.Plane.Normal); - // Retail head-sphere full hit → BSPTREE::slide_sphere - // (0x0053a697; ACE BSPTree.cs:202) — the real in-frame - // slide, no sliding-normal write (#137 mechanism 2). - return SlideSphere(transition, worldNormal); + collisions.SetCollisionNormal(worldNormal); + collisions.SetSlidingNormal(worldNormal); + return TransitionState.Slid; } // Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide. diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index a5977dd3..00ea9aa6 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -677,8 +677,7 @@ public static class CellTransit Vector3 worldSphereCenter, float sphereRadius, uint currentCellId, - out IReadOnlyCollection cellSet, - Vector3? carriedBlockOrigin = null) + out IReadOnlyCollection cellSet) { var spheres = new[] { @@ -689,7 +688,7 @@ public static class CellTransit }, }; - return FindCellSet(cache, spheres, spheres.Length, currentCellId, out cellSet, carriedBlockOrigin); + return FindCellSet(cache, spheres, spheres.Length, currentCellId, out cellSet); } /// @@ -702,12 +701,11 @@ public static class CellTransit IReadOnlyList worldSpheres, int numSpheres, uint currentCellId, - out IReadOnlyCollection cellSet, - Vector3? carriedBlockOrigin = null) + out IReadOnlyCollection cellSet) { var containing = BuildCellSetAndPickContaining( cache, worldSpheres, numSpheres, currentCellId, - carriedBlockOrigin, out var candidates); + out var candidates); cellSet = candidates; return containing; } @@ -717,7 +715,6 @@ public static class CellTransit IReadOnlyList worldSpheres, int numSpheres, uint currentCellId, - Vector3? carriedBlockOrigin, out CellArray candidates) { // Ordered, deduped candidate array — retail CELLARRAY (add_cell @701036). @@ -732,42 +729,11 @@ public static class CellTransit float sphereRadius = worldSpheres[0].Radius; uint currentLow = currentCellId & 0xFFFFu; - // #145: the carried cell-relative frame supplies the TRUE landblock world - // origin (body.Position - body.CellPosition.Frame.Origin) — correct even - // for an UNSTREAMED neighbour, where TryGetTerrainOrigin returns (0,0) and - // the pick marches the cell id one block per tick (the far-town cascade). - // Falls back to the terrain registry for unseeded movers (NPCs/tests) and - // indoor seeds. The caller guarantees the anchor's landblock == currentCellId's - // (PhysicsEngine passes body.CellPosition.ObjCellId as the cell when seeded). - // - // #106: blockOrigin converts the world-frame sphere coords into retail's - // block-local frame for the LandDefs lcoord math. - // - // #145 D (2026-06-22): honor TryGetTerrainOrigin's false return for OUTDOOR - // seeds. When the current landblock has not yet been applied (priority-apply - // in flight or streaming still warm), blockOrigin=(0,0) is wrong — the pick - // treats the world-frame sphere coordinates as if they were block-local and - // marches the cell one block per tick until lbX or lbY underflows to 0x00 - // (the "lbX=0" outbound wire that ACE rejects). "frame not yet authoritative - // → preserve verbatim" mirrors the NO-LANDBLOCK contract in PhysicsEngine.Resolve. - // Indoor seeds are NOT guarded here because blockOrigin is only consumed by the - // outdoor pick path (outdoorPickAllowed=false for indoor seeds); returning early - // for indoor seeds would break all interior cell-set builds (regression). - // Adaptation (not a direct retail port — retail gets it free via cell-relative - // storage); registered as divergence-register row added in this commit. - Vector3 blockOrigin; - if (carriedBlockOrigin is { } carriedAnchor) - { - blockOrigin = carriedAnchor; - } - else - { - bool terrainResident = cache.CellGraph.TryGetTerrainOrigin(currentCellId, out blockOrigin); - // Outdoor seed with no resident terrain: no valid block-local frame → preserve seed verbatim. - // Indoor seeds proceed regardless (blockOrigin unused for indoor picks). - if (!terrainResident && currentLow < 0x0100u) - return currentCellId; - } + // #106: the current block's world origin converts the world-frame sphere + // coords into retail's block-local frame for the LandDefs lcoord math. + // Unregistered terrain (tests; pre-stream) falls back to Vector3.Zero — + // the legacy anchor-frame assumption (world frame == block-local frame). + cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin); // #112 rider: outdoor candidates may win the pick only when retail would // have admitted them — outdoor seeds always; indoor seeds only when a diff --git a/src/AcDream.Core/Physics/CollisionExemption.cs b/src/AcDream.Core/Physics/CollisionExemption.cs index dc22e7df..2e66751b 100644 --- a/src/AcDream.Core/Physics/CollisionExemption.cs +++ b/src/AcDream.Core/Physics/CollisionExemption.cs @@ -59,20 +59,23 @@ public static class CollisionExemption public static bool ShouldSkip(uint targetState, EntityCollisionFlags targetFlags, ObjectInfoState moverState) { - // 1. Target ETHEREAL + IGNORE_COLLISIONS → instant-skip. - // Retail (acclient_2013_pseudo_c.txt:276782): - // `if ((state & 4) AND (state & 0x10)) return 1` - // BOTH bits are required. ETHEREAL-alone takes the retail - // `obstruction_ethereal` path instead (pc:276806): the flag is - // set to 1 on the SpherePath and the shape test still runs, - // but BSP Path 1 (sphere_intersects_solid) weakens solid- - // containment so the player passes through the open door. - // ACE's Door.Open() broadcasts ETHEREAL only (0x0001000C) — - // this faithful port makes open doors passable via the BSP - // sphere_intersects_solid path (no solid leaf at the opening), - // which subsumes the former AD-7 shim. Divergence register - // row AD-7 retired in the same commit as this change. - if ((targetState & ETHEREAL_PS) != 0 && (targetState & IGNORE_COLLISIONS_PS) != 0) + // 1. Target ETHEREAL → walk through. + // Retail (acclient_2013_pseudo_c.txt:276782) requires BOTH + // ETHEREAL_PS (0x4) AND IGNORE_COLLISIONS_PS (0x10) to wrap + // the entire body of FindObjCollisions and skip collision. + // ETHEREAL alone takes a different retail path (line 276795 + // sets sphere_path.obstruction_ethereal = 1 and downstream + // movement allows passage despite the contact). We haven't + // ported that downstream path yet. + // + // L.2g slice 1b (2026-05-13): ACE's Door.Open() sends only + // ETHEREAL (state=0x0001000C observed live), not the + // ETHEREAL|IGNORE_COLLISIONS combo retail servers broadcast. + // Pragmatic shortcut: exempt on ETHEREAL alone so doors + // become passable when ACE flips the bit. Retail-server + // broadcasts (state=0x14+) still hit this branch correctly + // because both bits set implies ETHEREAL set. + if ((targetState & ETHEREAL_PS) != 0) return true; // 2. Viewer mover + creature target → walk through. diff --git a/src/AcDream.Core/Physics/CollisionPrimitives.cs b/src/AcDream.Core/Physics/CollisionPrimitives.cs index 4e139a24..3324461a 100644 --- a/src/AcDream.Core/Physics/CollisionPrimitives.cs +++ b/src/AcDream.Core/Physics/CollisionPrimitives.cs @@ -634,115 +634,6 @@ public static class CollisionPrimitives return (offset - dist) / denom; } - // ----------------------------------------------------------------------- - // 8b. SweptSphereHitsSphere — CSphere::intersects_sphere narrow-phase - // ----------------------------------------------------------------------- - - /// - /// Returns when a moving sphere first intersects a - /// stationary sphere within the movement step, and the parametric contact - /// time is in (0, 1]. - /// - /// - /// Ported from CSphere::FindTimeOfCollision in - /// ACE.Server/Physics/Sphere.cs, which is a line-for-line C# port - /// of retail's CSphere::intersects_sphere @ 0x00537A80 (the - /// "collide ≠ 0, not creature" branch at 0x00537B8C). - /// - /// - /// - /// The retail quadratic (from the decomp): - /// - /// distSq = |movement|² — squared length of sweep vector. - /// gap = |spherePos|² − radSum² — positive when centers - /// are separated, negative when already overlapping. - /// similar = −dot(spherePos, movement) — projection of the - /// separation onto the movement direction. - /// disc = similar² − gap·distSq — discriminant. - /// Pick the earlier root, normalise by distSq. - /// - /// - /// - /// - /// Returns when the spheres are already overlapping - /// (gap < ε), the discriminant is negative (miss), the movement - /// is degenerate, or the contact time is outside (0, 1]. - /// - /// - /// - /// World-space centre of the moving sphere at the START of the step. - /// - /// Radius of the moving sphere. - /// - /// Movement vector: checkPos − currCenter. - /// - /// - /// World-space centre of the stationary target sphere. - /// - /// Radius of the target sphere. - /// - /// On success: parametric fraction of at - /// which the sphere surfaces first touch (in (0, 1]). - /// Undefined on failure. - /// - /// - /// when the mover hits the target within this step. - /// - public static bool SweptSphereHitsSphere( - Vector3 moverCenter, float moverRadius, - Vector3 sweepDelta, - Vector3 targetCenter, float targetRadius, - out float t) - { - t = 0f; - - // movement = sweepDelta (mover travels from moverCenter by this vector) - // spherePos = targetCenter − moverCenter (target relative to mover start) - // radSum = combined radius for first-surface-contact - float radSum = moverRadius + targetRadius; - - float mx = sweepDelta.X, my = sweepDelta.Y, mz = sweepDelta.Z; - float distSq = mx * mx + my * my + mz * mz; - if (distSq < EpsilonSq) - return false; // degenerate sweep (stationary mover) - - float sx = targetCenter.X - moverCenter.X; - float sy = targetCenter.Y - moverCenter.Y; - float sz = targetCenter.Z - moverCenter.Z; - - // gap = |spherePos|² − radSum² - // Positive → centers are separated (the common case). - // Negative → already overlapping → treat as no forward collision (retail returns -1). - float gap = sx * sx + sy * sy + sz * sz - radSum * radSum; - if (gap < EpsilonSq) - return false; // already overlapping — use static test separately - - // similar = −dot(spherePos, movement) - // Positive when the sphere is in FRONT of us (moving toward it). - float similar = -(sx * mx + sy * my + sz * mz); - - // discriminant = similar² − gap · distSq - float disc = similar * similar - gap * distSq; - if (disc < 0f) - return false; // ray misses the combined-radius sphere entirely - - float cDist = MathF.Sqrt(disc); - - // Pick the nearer root. ACE mirrors retail (Sphere.cs::FindTimeOfCollision): - // if (similar − cDist < 0) → return −1 × (cDist + similar) / distSq - // else → return −1 × (similar − cDist) / distSq - // The −1 negation converts from ACE's "closest-approach" parameterisation - // back to a forward t ∈ (0,1] (positive = hit ahead of mover). - float root = (similar - cDist < 0f) ? -(cDist + similar) : -(similar - cDist); - - // Normalise to [0, 1] scale - t = root / distSq; - - // t ≤ 0: contact is behind / at the start (already handled by gap check). - // t > 1: contact is beyond this movement step — miss. - return t > 0f && t <= 1f; - } - // ----------------------------------------------------------------------- // 9. land_on_sphere — FUN_00538f50 // ----------------------------------------------------------------------- diff --git a/src/AcDream.Core/Physics/IMotionCommandCatalog.cs b/src/AcDream.Core/Physics/IMotionCommandCatalog.cs deleted file mode 100644 index 9951a6df..00000000 --- a/src/AcDream.Core/Physics/IMotionCommandCatalog.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace AcDream.Core.Physics; - -/// -/// Reconstructs a full 32-bit retail MotionCommand from the 16-bit wire -/// value broadcast in InterpretedMotionState.Commands[] (the server -/// truncates the class byte — see for -/// why that byte must be restored before routing). -/// -/// -/// Two implementations exist because the wire-numbering used by ACE / the -/// local DATs and the wire-numbering used by the Sept 2013 EoR retail -/// decomp diverge for ~130 command names (a contiguous low-word "+3" shift -/// starting at SnowAngelState). See -/// (runtime default) and -/// (conformance/reference), and -/// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md for the -/// full divergence analysis. -/// -/// -public interface IMotionCommandCatalog -{ - /// - /// Reconstruct the full 32-bit MotionCommand from a 16-bit wire value. - /// Returns 0 if no entry matches. - /// - uint ReconstructFullCommand(ushort wireCommand); -} diff --git a/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs b/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs deleted file mode 100644 index 8c0dea7e..00000000 --- a/src/AcDream.Core/Physics/InterpretedMotionFunnel.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.Collections.Generic; - -namespace AcDream.Core.Physics; - -/// -/// L.2g S2 — supporting types for the inbound CMotionInterp funnel -/// (deviation DEV-1). Spec: -/// docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md; decomp: -/// MovementManager::unpack_movement 0x00524440, -/// CMotionInterp::move_to_interpreted_state 0x005289c0, -/// apply_interpreted_movement 0x00528600, DoInterpretedMotion 0x00528360 — -/// dispatch order validated against a LIVE retail-observer cdb trace -/// (tools/cdb/l2g-observer.cdb). -/// -public interface IInterpretedMotionSink -{ - /// - /// The CPhysicsObj::DoInterpretedMotion → … → - /// CMotionTable::GetObjectSequence backend — called for every motion - /// that passes contact_allows_move, in retail dispatch order - /// (style → forward-or-Falling → sidestep → turn → actions). R2-Q5: the - /// production implementation is Motion.MotionTableDispatchSink, - /// which dispatches straight into the entity's motion-table stack - /// (PerformMovement → GetObjectSequence + is_allowed decide) — - /// no sink-side axis pick or fallback chain. - /// - /// - /// R3-W5: retail's own CPhysicsObj::DoInterpretedMotion RETURNS a - /// result (result == 0 = the motion table found a cycle for this - /// id; nonzero = MotionTableManagerError.MotionFailed/no-table) — - /// CMotionInterp::DoInterpretedMotion's caller (raw 305591-305610) - /// gates BOTH the add_to_queue call AND the - /// InterpretedMotionState::ApplyMotion state-write on that result. - /// This matters concretely for the very first dispatch of every - /// apply_interpreted_movement call — the STYLE/stance id - /// (current_style, always >= 0x80000000) has no - /// locomotion MotionData entry in the dat, so - /// CMotionTable::DoObjectMotion genuinely fails for it; if that - /// failure isn't propagated, the state-write's negative-motion branch - /// (: arg2 < 0 → - /// forward_command = 0x41000003) clobbers ForwardCommand - /// BEFORE the very next line reads it for the real forward dispatch — - /// confirmed regression against the 183-case live retail-observer trace - /// (RetailObserverTraceConformanceTests) when this was void. - /// Return true when the underlying PerformMovement call - /// succeeded (MotionTableManagerError.Success). - /// - bool ApplyMotion(uint motion, float speed); - - /// - /// StopInterpretedMotion notification for an axis the incoming - /// state cleared (sidestep 0x6500000F / turn 0x6500000D). Unconditional - /// (no contact gate) per apply_interpreted_movement. - /// - /// Same result-propagation rationale as - /// — retail's CPhysicsObj::StopInterpretedMotion also returns a - /// result that gates the post-stop add_to_queue + state-removal. - bool StopMotion(uint motion); - - /// - /// R4-V5 wedge fix — retail CPhysicsObj::StopCompletely_Internal - /// (0x0050ead0) → CPartArray::StopCompletelyInternal (0x00518890) - /// → MotionTableManager::PerformMovement(MovementStruct{type=5}): - /// the ANIMATION-side full stop dispatched from the middle of - /// CMotionInterp::StopCompletely (raw @00527e90). The manager - /// queues its pending_animations entry UNCONDITIONALLY for type 5, and - /// that entry's AnimationDone → MotionDone is the matched pop for the A9 - /// pending_motions node StopCompletely enqueues right after this - /// call — without it the A9 node is an orphan and MotionsPending - /// never drains at idle (the 2026-07-03 moveto wedge). Default - /// implementation returns true (the null-sink "no animation - /// backend" posture — bare tests and sink-less interps are unaffected). - /// - bool StopCompletely() => true; -} - -/// -/// One entry of the inbound action list (retail MotionItem / -/// InterpretedMotionState.actions): a one-shot command with the -/// 15-bit server action stamp + autonomy bit -/// (u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0))). -/// -public readonly record struct InboundMotionAction( - uint Command, int Stamp, bool Autonomous, float Speed); - -/// -/// A fully-decoded inbound InterpretedMotionState: wire fields where -/// present, retail UnPack defaults where absent -/// (InterpretedMotionState::UnPack 0x0051f400 / -/// ctor 0x0051e8d0). A flags=0 "empty" UM decodes to exactly -/// — a retail-verbatim full stop. -/// -public struct InboundInterpretedState -{ - public uint CurrentStyle; - public uint ForwardCommand; - public float ForwardSpeed; - public uint SideStepCommand; - public float SideStepSpeed; - public uint TurnCommand; - public float TurnSpeed; - public IReadOnlyList? Actions; - - public static InboundInterpretedState Default() => new() - { - CurrentStyle = 0x8000003Du, - ForwardCommand = 0x41000003u, - ForwardSpeed = 1.0f, - SideStepCommand = 0u, - SideStepSpeed = 1.0f, - TurnCommand = 0u, - TurnSpeed = 1.0f, - Actions = null, - }; -} diff --git a/src/AcDream.Core/Physics/LandDefs.cs b/src/AcDream.Core/Physics/LandDefs.cs index 8ddbde03..a6a61040 100644 --- a/src/AcDream.Core/Physics/LandDefs.cs +++ b/src/AcDream.Core/Physics/LandDefs.cs @@ -141,34 +141,6 @@ public static class LandDefs return false; } - /// - /// LandDefs::get_block_offset (pc:69189, @0x0043e630): world-meter offset - /// from 's landblock origin to 's. - /// ZeroVector when both ids share a landblock (high words equal). The decomp's - /// block_byte<<3 converts to lcoord (cell) units and the literal - /// ·24 m/cell then nets to (Δlandblock)·192 m per axis. The - /// degenerate arg==0 fallbacks are ported verbatim (dest==0 → raw source - /// id; source==0 → 0 lcoord). The ONLY cross-cell translation in retail physics: - /// a delta of two NAMED cell ids, never an accumulation against a moving center. - /// - public static Vector3 GetBlockOffset(uint source, uint dest) - { - uint srcBlock = source >> 16; - uint dstBlock = dest >> 16; - if (srcBlock == dstBlock) - return Vector3.Zero; - - int srcLx, srcLy; - if (source == 0u) { srcLx = 0; srcLy = 0; } - else { srcLx = (int)((source >> 21) & 0x7f8u); srcLy = (int)((srcBlock & 0xFFu) << 3); } - - int dstLx, dstLy; - if (dest == 0u) { dstLx = (int)source; dstLy = (int)source; } // retail degenerate guard - else { dstLx = (int)((dest >> 21) & 0x7f8u); dstLy = (int)((dstBlock & 0xFFu) << 3); } - - return new Vector3((dstLx - srcLx) * CellLength, (dstLy - srcLy) * CellLength, 0f); - } - // Retail cell_in_range: landcell, envcell, or the block sentinel. private static bool CellLowInRange(uint low) => low is (>= 1u and <= 0x40u) or (>= 0x100u and <= 0xFFFDu) or 0xFFFFu; diff --git a/src/AcDream.Core/Physics/Motion/AnimSequenceNode.cs b/src/AcDream.Core/Physics/Motion/AnimSequenceNode.cs deleted file mode 100644 index e7c80588..00000000 --- a/src/AcDream.Core/Physics/Motion/AnimSequenceNode.cs +++ /dev/null @@ -1,159 +0,0 @@ -using System; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R1-P1 — verbatim port of retail's AnimSequenceNode (Phase R plan -/// `docs/plans/2026-07-02-retail-motion-animation-rewrite.md`, stage R1; -/// oracle `docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md` -/// §25-28). -/// -/// One node of a CSequence's animation list: a resolved dat -/// plus a playable frame window -/// (..) and a signed -/// whose SIGN is retail's playback-direction flag. -/// -/// Retail semantics preserved exactly (gap-map items G1/G2/G16/G18): -/// -/// The boundary pair ( / -/// ) is direction-aware and returns BARE -/// integers — retail has NO epsilon here (0x00525c80/0x00525cb0). ACE's -/// PhysicsGlobals.EPSILON subtraction compensates for ACE's own -/// float FrameNumber and must not be copied (P0-pins.md). -/// SWAPS and -/// when the factor is negative (0x00525be0) — -/// coupled with the boundary pair's framerate < 0 test. -/// clamps in retail's exact order -/// (0x00525d60): high<0 → num−1; low≥num → num−1; high≥num → num−1; -/// low>high → high=low. -/// returns null out of range -/// (retail; ACE's identity-frame return is an ACE-ism). -/// -/// -/// Unlike the legacy AnimationSequencer.AnimNode, retail nodes carry -/// NO per-node IsLooping/Velocity/Omega — loop membership is list structure -/// (first_cyclic) and physics accumulators live on the sequence -/// (G16). -/// -public sealed class AnimSequenceNode -{ - /// Resolved dat animation, or null (id 0 / missing). - public Animation? Anim { get; private set; } - - /// - /// Frames per second; NEGATIVE means reverse playback (retail's - /// direction flag). Default 30f (0x00525d30). - /// - public float Framerate = 30f; - - /// Inclusive window low bound. Default −1 (0x00525d30). - public int LowFrame = -1; - - /// Inclusive window high bound; −1 = "to the end" sentinel - /// resolved by . Default −1. - public int HighFrame = -1; - - public bool HasAnim => Anim is not null; - - /// Default ctor — retail defaults (0x00525d30). - public AnimSequenceNode() - { - } - - /// - /// Ctor from a MotionData entry (0x00525f90): - /// copy framerate/low/high, then resolve + clamp via - /// . - /// - public AnimSequenceNode(AnimData animData, IAnimationLoader loader) - { - Framerate = animData.Framerate; - LowFrame = animData.LowFrame; - HighFrame = animData.HighFrame; - SetAnimationId((uint)animData.AnimId, loader); - } - - /// - /// Resolve the dat animation and clamp the frame window - /// (0x00525d60). The clamp block runs only when an animation resolved; - /// order is retail-exact. - /// - public void SetAnimationId(uint animId, IAnimationLoader loader) - { - Anim = animId == 0 ? null : loader.LoadAnimation(animId); - - if (Anim is null) - return; - - int numFrames = Anim.PartFrames.Count; - - if (HighFrame < 0) - HighFrame = numFrames - 1; - if (LowFrame >= numFrames) - LowFrame = numFrames - 1; - if (HighFrame >= numFrames) - HighFrame = numFrames - 1; - if (LowFrame > HighFrame) - HighFrame = LowFrame; - } - - /// - /// Direction-aware starting boundary (0x00525c80): reverse playback - /// starts at high_frame + 1, forward at low_frame. - /// BARE int — no epsilon (G1). - /// - public int GetStartingFrame() => Framerate < 0f ? HighFrame + 1 : LowFrame; - - /// - /// Direction-aware ending boundary (0x00525cb0): reverse ends at - /// low_frame, forward at high_frame + 1. BARE int. - /// - public int GetEndingFrame() => Framerate < 0f ? LowFrame : HighFrame + 1; - - /// - /// Scale playback rate (0x00525be0): a NEGATIVE factor swaps - /// low/high before multiplying — the swapped fields plus the - /// now-negative framerate are how retail encodes reversed windows. - /// - public void MultiplyFramerate(float factor) - { - if (factor < 0f) - { - (LowFrame, HighFrame) = (HighFrame, LowFrame); - } - Framerate *= factor; - } - - /// - /// Root-motion frame at a double position (0x005247b0): floor then the - /// int overload. - /// - public Frame? GetPosFrame(double frameNumber) - => GetPosFrame((int)Math.Floor(frameNumber)); - - /// - /// Root-motion frame by index (0x00525c10): null when no animation, - /// index out of range, or the animation carries no PosFrames. - /// - public Frame? GetPosFrame(int index) - { - if (Anim is null || index < 0 || index >= Anim.PartFrames.Count) - return null; - if (Anim.PosFrames is null || index >= Anim.PosFrames.Count) - return null; - return Anim.PosFrames[index]; - } - - /// - /// Skeletal part frame by index — same bounds discipline as - /// . - /// - public AnimationFrame? GetPartFrame(int index) - { - if (Anim is null || index < 0 || index >= Anim.PartFrames.Count) - return null; - return Anim.PartFrames[index]; - } -} diff --git a/src/AcDream.Core/Physics/Motion/CMotionTable.cs b/src/AcDream.Core/Physics/Motion/CMotionTable.cs deleted file mode 100644 index 02b63172..00000000 --- a/src/AcDream.Core/Physics/Motion/CMotionTable.cs +++ /dev/null @@ -1,692 +0,0 @@ -using System; -using System.Linq; -using System.Numerics; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -// Alias the DatReaderWriter enum so it doesn't clash with -// AcDream.Core.Physics.MotionCommand (a static class of uint constants) — -// same convention as AnimationSequencerTests.cs. -using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; - -namespace AcDream.Core.Physics.Motion; - -// ───────────────────────────────────────────────────────────────────────────── -// R2-Q2 — verbatim port of retail's CMotionTable (the motion-selection -// dispatcher). Oracle: docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md -// (all §-references below point there); ambiguity pins: -// docs/research/2026-07-02-r2-motiontable/Q0-pins.md (A1-A5). -// -// Scope (r2-port-plan.md §3 Q2): pure motion-SELECTION logic — resolves which -// MotionData(s) to append to a CSequence and updates a MotionState in place. -// Does NOT own a pending-animation queue (that is MotionTableManager, Q3) and -// does NOT drive per-tick advance (CSequence.Update, R1). This file re-homes -// the adapter's already-field-validated GetLink (AnimationSequencer.cs -// :953-997) verbatim per the keep-list (r2-port-plan.md §2) and adds every -// other CMotionTable/free-function member the decomp documents. -// -// Command-word class bits (decomp §1 "Motion-id class bits", §15): -// 0x80000000 style-class (top bit set; the low 31 bits + top bit read as -// a NEGATIVE int32 — retail's `(int32_t)ebx_1 < 0` test) -// 0x40000000 cycle-class (cyclic/looping base state, `this->cycles`) -// 0x20000000 modifier-class (physics-only overlay, `this->modifiers`) -// 0x10000000 action-class (one-shot, `add_action` FIFO) -// 0x41000003 Ready — the "stop / default state" sentinel (decomp §15) -// -// DatReaderWriter.DBObjs.MotionTable field map (verified via reflection, -// package Chorizite.DatReaderWriter 2.1.7, 2026-07-02): -// DefaultStyle : MotionCommand → this->default_style -// StyleDefaults : Dictionary → this->style_defaults -// Cycles : Dictionary → this->cycles -// Modifiers : Dictionary → this->modifiers -// Links : Dictionary → this->links -// (MotionCommandData.MotionData : Dictionary = the inner -// per-target-substate hash) -// MotionData.Bitfield : byte (A5 CONFIRMED present — bit0=clear-mods-on- -// entry, bit1=substate-gated for is_allowed) -// MotionData.Anims : List — retail's `num_anims`/`anims[]` pair; -// Anims.Count IS num_anims (A3 — no separate packed-byte field exists on -// the managed type; the decomp's "packed byte at MotionData+0x10" is -// this same count, just read through the raw struct layout). -// ───────────────────────────────────────────────────────────────────────────── - -/// -/// Retail's CMotionTable (0x00522xxx-0x00523xxx region) — the -/// motion-selection dispatcher. Wraps a loaded DBObj -/// and resolves (style, substate, speed) requests into MotionData -/// chains appended to a , mutating a -/// in place. -/// -public sealed class CMotionTable -{ - private readonly MotionTable _table; - - public CMotionTable(MotionTable table) - { - ArgumentNullException.ThrowIfNull(table); - _table = table; - } - - // ── free functions (decomp §2) ────────────────────────────────────── - - /// - /// same_sign 0x00522260 (@298253). True when - /// and are on the same side of zero (0 counts as - /// non-negative, matching the decomp's >= 0f reading). - /// - internal static bool SameSign(float a, float b) => (a >= 0f) == (b >= 0f); - - /// - /// change_cycle_speed 0x00522290 (@298276): rescale - /// 's cyclic-tail framerate by - /// newSpeed/oldSpeed. Ported VERBATIM including the A4-#2 gap: when - /// is ~0 and is - /// NOT ~0, retail's fabsl branch structure suppresses the rescale - /// entirely (no zeroing, no scaling — a silent no-op). Only when BOTH are - /// ~0 does retail explicitly zero the framerate. - /// - internal static void ChangeCycleSpeed(CSequence sequence, MotionData? cyclic, float oldSpeed, float newSpeed) - { - const float Epsilon = 0.000199999995f; // ~0.0002f, verbatim retail constant - - if (MathF.Abs(oldSpeed) > Epsilon) - { - sequence.MultiplyCyclicAnimationFramerate(newSpeed / oldSpeed); - return; - } - - // oldSpeed ~ 0: only the "newSpeed also ~0" leg does anything (zero - // the framerate). The "newSpeed NOT ~0" leg is a silent no-op — - // retail's own gap, kept verbatim (Q0-pins A4-#2). - if (MathF.Abs(newSpeed) <= Epsilon) - { - sequence.MultiplyCyclicAnimationFramerate(0f); - } - } - - /// - /// add_motion 0x005224b0 (@298437): UNCONDITIONALLY sets - /// 's velocity/omega to - /// motion.Velocity/Omega * speedMod (replace, not accumulate — a - /// dat-silent MotionData carries a zero Vector3, so "unconditional - /// replace" and "replace with zero" are the same call; G17 core), then - /// appends every AnimData in speed-scaled - /// (framerate only, retail AnimData::operator*). - /// - internal static void AddMotion(CSequence sequence, MotionData? motion, float speedMod) - { - if (motion is null) - return; - - sequence.SetVelocity(motion.Velocity * speedMod); - sequence.SetOmega(motion.Omega * speedMod); - - foreach (var ad in motion.Anims) - { - sequence.AppendAnimation(new AnimData - { - AnimId = ad.AnimId, - LowFrame = ad.LowFrame, - HighFrame = ad.HighFrame, - Framerate = ad.Framerate * speedMod, - }); - } - } - - /// - /// combine_motion 0x00522580 (@298472): ADDS - /// 's velocity/omega (scaled by - /// ) onto the sequence's existing physics. - /// Never touches anims — used for modifier overlays where the base - /// cycle's animation frames stay untouched. - /// - internal static void CombineMotion(CSequence sequence, MotionData? motion, float speedMod) - { - if (motion is null) - return; - - sequence.CombinePhysics(motion.Velocity * speedMod, motion.Omega * speedMod); - } - - /// - /// subtract_motion 0x00522600 (@298492): inverse of - /// — used when REMOVING a modifier's - /// physics contribution. - /// - internal static void SubtractMotion(CSequence sequence, MotionData? motion, float speedMod) - { - if (motion is null) - return; - - sequence.SubtractPhysics(motion.Velocity * speedMod, motion.Omega * speedMod); - } - - // ── members ────────────────────────────────────────────────────────── - - /// - /// is_allowed 0x005226c0 (@298526): a bitfield-bit1 ("gated") - /// cycle is only reusable when the current substate matches the - /// candidate, OR the current substate already equals the owning style's - /// default substate. Null is never allowed. - /// - public bool IsAllowed(uint candidateSubstate, MotionData? candidate, MotionState state) - { - if (candidate is null) - return false; - - if ((candidate.Bitfield & 2) != 0) - { - uint substate = state.Substate; - if (candidateSubstate != substate) - { - uint defaultSubstate = LookupStyleDefault(state.Style); - return defaultSubstate == substate; - } - } - - return true; - } - - /// - /// get_link 0x00522710 (@298552). Re-homed verbatim from the - /// working adapter (AnimationSequencer.GetLink, field-validated — - /// the reversed-key branch fixed the Ready→WalkBackward "left leg - /// twitches" glitch) per Q0-pins A1: EITHER speed negative routes through - /// the swapped-key branch (link stored FROM - /// TO ), falling back to the style's - /// default-substate hop; otherwise the forward branch (link FROM - /// TO ), - /// falling back to the style-level catch-all (unstyled outer key). - /// - public MotionData? GetLink(uint fromStyle, uint fromSubstate, float fromSubstateMod, uint toSubstate, float toSubstateMod) - { - if (toSubstateMod < 0f || fromSubstateMod < 0f) - { - // Reversed-direction path: link FROM toSubstate TO fromSubstate. - int reversedKey = (int)((fromStyle << 16) | (toSubstate & 0xFFFFFFu)); - if (_table.Links.TryGetValue(reversedKey, out var revLink) - && revLink.MotionData.TryGetValue((int)fromSubstate, out var revResult)) - { - return revResult; - } - - // Style-defaults fallback (decomp §4 second predicate block, "else" branch). - uint defaultSubstate = LookupStyleDefault(fromStyle); - int subKey = (int)((fromStyle << 16) | (fromSubstate & 0xFFFFFFu)); - if (_table.Links.TryGetValue(subKey, out var subLink) - && subLink.MotionData.TryGetValue((int)defaultSubstate, out var subResult)) - { - return subResult; - } - return null; - } - - // Forward-direction path: link FROM fromSubstate TO toSubstate. - int outerKey1 = (int)((fromStyle << 16) | (fromSubstate & 0xFFFFFFu)); - if (_table.Links.TryGetValue(outerKey1, out var cmd1) - && cmd1.MotionData.TryGetValue((int)toSubstate, out var result1)) - { - return result1; - } - - // Fallback: style-level catch-all (unstyled outer key, decomp §4 "else if" branch). - int outerKey2 = (int)(fromStyle << 16); - if (_table.Links.TryGetValue(outerKey2, out var cmd2) - && cmd2.MotionData.TryGetValue((int)toSubstate, out var result2)) - { - return result2; - } - - return null; - } - - /// - /// GetObjectSequence 0x00522860 (@298636) — the FULL dispatcher. - /// See decomp §5 for the annotated retail source; the branch structure - /// below mirrors it 1:1 (comments cite the corresponding decomp block). - /// - /// Requested target substate/motion id. - /// In/out current motion state. - /// Sequence to build/mutate. - /// Requested speed_mod for the new substate. - /// OUT: tick count of appended animation, minus 1. - /// false = normal "do motion"; true = re-invoked - /// from . - public bool GetObjectSequence(uint motion, MotionState state, CSequence sequence, float speed, out uint outTicks, bool stopCall) - { - outTicks = 0; - - uint style = state.Style; - if (style == 0) - return false; - - uint substate = state.Substate; - if (substate == 0) - return false; - - uint styleDefault = LookupStyleDefault(style); // var_c - - uint target = motion; // ebx_1 working copy - - // ---- FAST PATH: requesting the style default while already a - // modifier-class substate, not a stop-call. ---- - if (target == styleDefault && !stopCall && (substate & 0x20000000) != 0) - return true; - - float requestedSpeed = speed; // ebp_1 - - // ================================================================ - // BRANCH 1: target interpreted as NEGATIVE int32 — style-change request. - // ================================================================ - if ((int)target < 0) - { - if (style == target) - return true; // already in that style - - uint currentStyleDefault = LookupStyleDefault(style); // eax_1 - - MotionData? exitLink = null; // var_4_1 - if (substate != currentStyleDefault) - { - exitLink = GetLink(style, substate, state.SubstateMod, currentStyleDefault, requestedSpeed); - } - - uint targetStyleDefault = LookupStyleDefault(target); // arg7_style_default - if (_table.StyleDefaults.ContainsKey((DRWMotionCommand)target)) - { - MotionData? newCycle = LookupCycle(target, targetStyleDefault); // eax_5 - - if (newCycle is not null) - { - if ((newCycle.Bitfield & 1) != 0) - state.ClearModifiers(); - - // link FROM current style's default substate TO target style's default substate - MotionData? directOrHop1 = GetLink(style, styleDefault, state.SubstateMod, target, requestedSpeed); // arg2 - MotionData? hop2 = null; // var_10_1 - - if (directOrHop1 is null && target != style) - { - // DOUBLE-HOP VIA default_style. - directOrHop1 = GetLink(style, styleDefault, 1f, (uint)_table.DefaultStyle, 1f); - uint defaultStyleDefaultSubstate = LookupStyleDefault((uint)_table.DefaultStyle); - hop2 = GetLink((uint)_table.DefaultStyle, defaultStyleDefaultSubstate, 1f, target, 1f); - } - - sequence.ClearPhysics(); - sequence.RemoveCyclicAnims(); - AddMotion(sequence, exitLink, requestedSpeed); - AddMotion(sequence, directOrHop1, requestedSpeed); - AddMotion(sequence, hop2, requestedSpeed); - AddMotion(sequence, newCycle, requestedSpeed); - - state.Substate = targetStyleDefault; - state.Style = target; - state.SubstateMod = speed; - ReModify(sequence, state); - - uint numAnims2 = (uint)(exitLink?.Anims.Count ?? 0); - uint ecx20 = (uint)(directOrHop1?.Anims.Count ?? 0); - uint numAnims1 = (uint)(hop2?.Anims.Count ?? 0); - outTicks = (uint)newCycle.Anims.Count + numAnims1 + ecx20 + numAnims2 - 1; - return true; - } - } - // else: fall through to the class-bit blocks below with target still negative. - } - - // ================================================================ - // BRANCH 2: target has the CYCLE-CLASS bit (0x40000000) set. - // ================================================================ - if ((target & 0x40000000) != 0) - { - uint substateId = target & 0xFFFFFFu; - MotionData? cyclic = LookupCycle(style, substateId); // eax_24 - - if (cyclic is null) - { - // No cycle for THIS style at that id -> retry under the - // table-wide default_style (decomp §5 "label_522ae6" retry — - // unconditional, no style==0 guard in retail). - cyclic = LookupCycle((uint)_table.DefaultStyle, substateId); - } - - if (cyclic is not null && IsAllowed(target, cyclic, state)) - { - - // ---- FAST RE-SPEED PATH ---- - if (target == substate - && SameSign(requestedSpeed, state.SubstateMod) - && sequence.HasAnims()) - { - ChangeCycleSpeed(sequence, cyclic, state.SubstateMod, requestedSpeed); - SubtractMotion(sequence, cyclic, state.SubstateMod); - CombineMotion(sequence, cyclic, requestedSpeed); - state.SubstateMod = speed; - return true; - } - - if ((cyclic.Bitfield & 1) != 0) - state.ClearModifiers(); - - MotionData? directLink = GetLink(style, substate, state.SubstateMod, target, requestedSpeed); // eax_34 - bool sameSignDirect = directLink is not null && SameSign(requestedSpeed, state.SubstateMod); - - MotionData? hop2 = null; // var_10_1 - MotionData? linkOrHop1 = directLink; // arg2 - - if (directLink is null || !sameSignDirect) - { - uint styleDefaultSubstate = LookupStyleDefault(style); - linkOrHop1 = GetLink(style, substate, state.SubstateMod, styleDefaultSubstate, 1f); - hop2 = GetLink(style, styleDefaultSubstate, 1f, target, requestedSpeed); - } - - sequence.ClearPhysics(); - sequence.RemoveCyclicAnims(); - - if (hop2 is null) - { - float signedSpeed = (state.SubstateMod == 0f || SameSign(state.SubstateMod, speed)) - ? speed : -speed; - AddMotion(sequence, linkOrHop1, signedSpeed); - } - else - { - AddMotion(sequence, linkOrHop1, state.SubstateMod); - AddMotion(sequence, hop2, requestedSpeed); - } - - AddMotion(sequence, cyclic, requestedSpeed); - - // Leaving a modifier-class substate for something else: re-register - // the OLD substate as a still-active modifier unless the style's - // default substate IS the new target. - uint oldSubstate = state.Substate; - if (oldSubstate != target && (oldSubstate & 0x20000000) != 0) - { - uint styleDefaultSubstate2 = LookupStyleDefault(style); - if (styleDefaultSubstate2 != target) - state.AddModifierNoCheck(oldSubstate, state.SubstateMod); - } - - state.SubstateMod = speed; - state.Substate = target; - ReModify(sequence, state); - - uint ecx45 = (uint)(linkOrHop1?.Anims.Count ?? 0); - uint numAnims1b = (uint)(hop2?.Anims.Count ?? 0); - outTicks = (uint)cyclic.Anims.Count + numAnims1b + ecx45 - 1; - return true; - } - // is_allowed rejected (or no cyclic resolved) -> fall through. - } - - // ================================================================ - // BRANCH 3: target has the ACTION-CLASS bit (0x10000000) set. - // ================================================================ - if ((target & 0x10000000) != 0) - { - MotionData? baseCycle = LookupCycle(style, substate & 0xFFFFFFu); // eax_57 - if (baseCycle is not null) - { - MotionData? directLink = GetLink(style, substate, state.SubstateMod, target, requestedSpeed); // eax_60 - if (directLink is not null) - { - state.AddAction(target, requestedSpeed); - sequence.ClearPhysics(); - sequence.RemoveCyclicAnims(); - AddMotion(sequence, directLink, requestedSpeed); - AddMotion(sequence, baseCycle, state.SubstateMod); - ReModify(sequence, state); - outTicks = (uint)directLink.Anims.Count; - return true; - } - - // No direct link -> route through the style default (double-hop out-and-back). - uint styleDefaultSubstate = LookupStyleDefault(style); - MotionData? outHop = GetLink(style, substate, state.SubstateMod, styleDefaultSubstate, 1f); // eax_66 - if (outHop is not null) - { - MotionData? actionLink = GetLink(style, styleDefaultSubstate, 1f, target, requestedSpeed); // eax_68 - if (actionLink is not null) - { - MotionData? baseCycleRefetch = LookupCycle(style, substate & 0xFFFFFFu); // eax_69 (same key, re-fetched) - if (baseCycleRefetch is not null) - { - MotionData? returnHop = GetLink(style, styleDefaultSubstate, 1f, substate, state.SubstateMod); - - state.AddAction(target, requestedSpeed); - sequence.ClearPhysics(); - sequence.RemoveCyclicAnims(); - AddMotion(sequence, outHop, 1f); - AddMotion(sequence, actionLink, requestedSpeed); - AddMotion(sequence, returnHop, 1f); - AddMotion(sequence, baseCycleRefetch, state.SubstateMod); - ReModify(sequence, state); - - // A4-#1: outTicks = outHop + actionLink [+ returnHop] ONLY — - // never the base cycle, never double-counted (ACE's bug, not retail's). - uint ticks = (uint)outHop.Anims.Count + (uint)actionLink.Anims.Count; - if (returnHop is not null) - ticks += (uint)returnHop.Anims.Count; - outTicks = ticks; - return true; - } - } - } - } - } - - // ================================================================ - // BRANCH 4: target has the MODIFIER-CLASS bit (0x20000000) set. - // ================================================================ - if ((target & 0x20000000) != 0) - { - MotionData? baseCycle = LookupCycle(style, substate & 0xFFFFFFu); // eax_81 - - if (baseCycle is not null && (baseCycle.Bitfield & 1) == 0) - { - uint modKey = target & 0xFFFFFFu; - MotionData? modifierStyled = LookupModifier(style, modKey, styleSpecific: true); // eax_85 - MotionData? modifierGlobal = null; // eax_87 - if (modifierStyled is null) - modifierGlobal = LookupModifier(style, modKey, styleSpecific: false); - MotionData? modifierData = modifierStyled ?? modifierGlobal; - - if (modifierStyled is not null || modifierGlobal is not null) - { - bool added = state.AddModifier(target, requestedSpeed); - if (!added) - { - // Toggle: already active (or == current substate) -> stop then re-add. - StopSequenceMotion(target, 1f, state, sequence, out _); - added = state.AddModifier(target, requestedSpeed); - } - if (added) - { - CombineMotion(sequence, modifierData, requestedSpeed); - return true; - } - } - } - } - - return false; - } - - /// - /// re_modify 0x005222e0 (@298300): after installing a new base - /// cycle/substate, replay every previously-active modifier back onto the - /// sequence by re-invoking for each one. - /// Q0-pins A4-#5: the retail copy-ctor snapshot exists ONLY as a - /// loop-termination bound (both the live list and the snapshot pop one - /// entry per iteration; the loop ends when the snapshot empties) — the - /// C# port deep-copies via MotionState(MotionState other), pops - /// both in lockstep, and terminates on the snapshot's emptiness. - /// - public void ReModify(CSequence sequence, MotionState state) - { - if (!state.Modifiers.Any()) - return; - - var snapshot = new MotionState(state); - - do - { - var head = state.Modifiers.First(); - uint motion = head.Motion; - float speedMod = head.SpeedMod; - state.RemoveModifier(head); - - var snapshotHead = snapshot.Modifiers.First(); - snapshot.RemoveModifier(snapshotHead); - - GetObjectSequence(motion, state, sequence, speedMod, out _, stopCall: false); - } while (snapshot.Modifiers.Any()); - } - - /// - /// StopSequenceMotion 0x00522fc0 (@298954): two independent stop - /// mechanisms. Stopping the active cycle re-drives - /// toward the style's default substate - /// (i.e. "return to idle"). Stopping a modifier just unwinds its physics - /// contribution and removes it from the chain — no animation - /// re-sequencing needed since modifiers don't touch anims[]. - /// Action-class ids are NOT handled here (decomp §7 note — actions - /// complete via the MotionTableManager tick-countdown, Q3 scope). - /// - public bool StopSequenceMotion(uint motion, float speed, MotionState state, CSequence sequence, out uint outTicks) - { - outTicks = 0; - - // Case A: stopping the CYCLE-class substate we are currently in. - if ((motion & 0x40000000) != 0 && motion == state.Substate) - { - uint styleDefaultSubstate = LookupStyleDefault(state.Style); - GetObjectSequence(styleDefaultSubstate, state, sequence, 1f, out outTicks, stopCall: true); - return true; - } - - // Case B: stopping a MODIFIER-class id. - if ((motion & 0x20000000) != 0) - { - foreach (var node in state.Modifiers) - { - if (node.Motion != motion) - continue; - - uint modKey = motion & 0xFFFFFFu; - MotionData? modData = LookupModifier(state.Style, modKey, styleSpecific: true); - modData ??= LookupModifier(state.Style, modKey, styleSpecific: false); - - if (modData is not null) - { - SubtractMotion(sequence, modData, node.SpeedMod); - state.RemoveModifier(node); - return true; - } - - break; // matching motion id found but no MotionData anywhere -> give up - } - } - - return false; - } - - /// - /// SetDefaultState 0x005230a0 (@299004): reset a MotionState to - /// the motion table's baseline (default_style + that style's - /// default substate), clearing all modifiers/actions and installing the - /// base cyclic animation for that (style,substate) fresh via - /// (hard reset, not just - /// RemoveCyclicAnims). - /// - public bool SetDefaultState(MotionState state, CSequence sequence, out uint outTicks) - { - outTicks = 0; - - if (!_table.StyleDefaults.TryGetValue(_table.DefaultStyle, out var defaultSubstateCmd)) - return false; - - uint defaultSubstate = (uint)defaultSubstateCmd; - - state.ClearModifiers(); - state.ClearActions(); - - MotionData? cyclic = LookupCycle((uint)_table.DefaultStyle, defaultSubstate); - if (cyclic is null) - return false; - - state.Style = (uint)_table.DefaultStyle; - state.Substate = defaultSubstate; - state.SubstateMod = 1f; - outTicks = (uint)cyclic.Anims.Count - 1; - - sequence.ClearPhysics(); - sequence.ClearAnimations(); - AddMotion(sequence, cyclic, state.SubstateMod); - - return true; - } - - /// DoObjectMotion 0x00523e90 (@300045): thin wrapper — "do" == - /// with the stop-flag forced to false. - public bool DoObjectMotion(uint motion, MotionState state, CSequence sequence, float speed, out uint outTicks) - => GetObjectSequence(motion, state, sequence, speed, out outTicks, stopCall: false); - - /// StopObjectMotion 0x00523ec0 (@300053): thin wrapper — - /// tailcall straight into . - public bool StopObjectMotion(uint motion, float speed, MotionState state, CSequence sequence, out uint outTicks) - => StopSequenceMotion(motion, speed, state, sequence, out outTicks); - - /// - /// StopObjectCompletely 0x00523ed0 (@300062): the "return to idle - /// from everything" call — strips every active modifier first (each one - /// individually going through the same subtract-and-unlink path as a - /// single call), then stops the base - /// cycle/substate itself (which re-drives - /// toward the style's default substate). Q0-pins A4-#4: return = - /// finalStopOk ? true : anyModifierStopOk, ported verbatim. - /// - public bool StopObjectCompletely(MotionState state, CSequence sequence, out uint outTicks) - { - outTicks = 0; - bool anyModifierStopOk = false; - - // Stop EVERY currently-active modifier first (list shrinks each - // iteration because StopSequenceMotion unlinks the node it stops). - while (state.Modifiers.Any()) - { - var node = state.Modifiers.First(); - float speedMod = node.SpeedMod; - if (StopSequenceMotion(node.Motion, speedMod, state, sequence, out outTicks)) - anyModifierStopOk = true; - else - break; // defensive: avoid infinite loop if a stop can't unlink (shouldn't happen) - } - - // Finally stop the base cycle/substate itself. - if (StopSequenceMotion(state.Substate, state.SubstateMod, state, sequence, out outTicks)) - return true; - - return anyModifierStopOk; - } - - // ── private lookup helpers ────────────────────────────────────────── - - private uint LookupStyleDefault(uint style) - => _table.StyleDefaults.TryGetValue((DRWMotionCommand)style, out var def) ? (uint)def : 0u; - - private MotionData? LookupCycle(uint style, uint substate) - { - int key = (int)((style << 16) | (substate & 0xFFFFFFu)); - return _table.Cycles.TryGetValue(key, out var data) ? data : null; - } - - private MotionData? LookupModifier(uint style, uint modKey, bool styleSpecific) - { - int key = styleSpecific ? (int)((style << 16) | modKey) : (int)modKey; - return _table.Modifiers.TryGetValue(key, out var data) ? data : null; - } -} diff --git a/src/AcDream.Core/Physics/Motion/CSequence.cs b/src/AcDream.Core/Physics/Motion/CSequence.cs deleted file mode 100644 index a9da00b8..00000000 --- a/src/AcDream.Core/Physics/Motion/CSequence.cs +++ /dev/null @@ -1,511 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R1-P4 host seam standing in for retail's CPhysicsObj.anim_hooks -/// SmartArray + the global AnimDoneHook singleton -/// (add_anim_hook 0x00514c20; process_hooks 0x00511550 drains -/// once per physics tick — the drain point stays with the host until R6 -/// places it per retail's UpdateObjectInternal order). -/// -public interface IAnimHookQueue -{ - /// Queue a matched AnimFrame hook (already direction-filtered - /// by execute_hooks). - void AddAnimHook(DatReaderWriter.Types.AnimationHook hook); - - /// Queue the global animation-done hook — retail's - /// AnimDoneHook::Execute → CPhysicsObj::Hook_AnimDone → - /// CPartArray::AnimationDone(1) chain (R2 consumes it as - /// MotionDone). - void AddAnimDoneHook(); -} - -/// -/// R1-P2 — verbatim port of retail's CSequence container + list -/// surgery (Phase R plan stage R1; oracle -/// `docs/research/2026-07-02-r1-csequence/r1-csequence-decomp.md` §1-§17, -/// §20, §24). The per-tick advance (`update`/`update_internal`/ -/// `apply_physics`/hook dispatch) lands in R1-P3/P4. -/// -/// Structure: a doubly-linked animation-node list with two cursors — -/// (the node currently playing) and -/// (where the looping tail begins; everything -/// before it is one-shot "link" animation). Retail invariant (G10): -/// append_animation slides first_cyclic to the JUST-APPENDED -/// node on EVERY call — the cyclic tail is always exactly the last node -/// appended so far. -/// -/// Physics accumulators (/) live on -/// the SEQUENCE, not per node (G16); retail replaces them via -/// set_velocity/set_omega and algebraically blends via -/// combine_physics/subtract_physics (the R2 fast path's mechanism). -/// -/// Divergence register (rows added with this commit): -/// -/// frame_number is x87 long double in retail -/// (acclient.h:30747); C# double is the closest available (G15). -/// The intrusive DLList is a managed ; -/// node identity semantics preserved via -/// references. -/// -/// -public sealed class CSequence -{ - private readonly LinkedList _animList = new(); // anim_list (DLList) - private LinkedListNode? _firstCyclic; // first_cyclic - private LinkedListNode? _currAnim; // curr_anim - private readonly IAnimationLoader _loader; - - /// Fractional frame position within . - /// Retail x87 long double → double (register row, G15). - public double FrameNumber; - - /// Sequence root-motion velocity accumulator (body-local). - public Vector3 Velocity; - - /// Sequence angular-velocity accumulator. - public Vector3 Omega; - - /// Static pose used when no animation node is active - /// (placement_frame, §16). - public AnimationFrame? PlacementFrame { get; private set; } - - public uint PlacementFrameId { get; private set; } - - public CSequence(IAnimationLoader loader) => _loader = loader; - - // ── inspection surface (adapter + tests) ──────────────────────────── - - public AnimSequenceNode? CurrAnim => _currAnim?.Value; - public AnimSequenceNode? FirstCyclic => _firstCyclic?.Value; - public int Count => _animList.Count; - - /// has_anims (0x00524bd0). - public bool HasAnims() => _animList.Count > 0; - - /// TEST SEAM: reposition curr_anim by list index (retail state - /// reached via update_internal, which lands in P4). - public void SetCurrAnimForTest(int index) - { - var n = _animList.First; - for (int i = 0; i < index && n != null; i++) n = n.Next; - _currAnim = n; - } - - // ── append_animation (0x00525510, §24) ────────────────────────────── - - /// - /// Append a MotionData anim entry. A node whose dat animation fails to - /// resolve is discarded. first_cyclic slides to the appended - /// node on EVERY call; curr_anim seeds to the head (with - /// frame_number = get_starting_frame()) only when it was null. - /// - public void AppendAnimation(AnimData animData) - { - var node = new AnimSequenceNode(animData, _loader); - if (!node.HasAnim) - return; // retail deletes the node — discard - - _animList.AddLast(node); - _firstCyclic = _animList.Last; - - if (_currAnim is null) - { - _currAnim = _animList.First; - FrameNumber = _currAnim!.Value.GetStartingFrame(); - } - } - - // ── clear family (§3-§5) ──────────────────────────────────────────── - - /// clear (0x005255b0): full wipe INCLUDING the placement - /// fields — the raw body resets them (the "2-instruction clear" note in - /// the gap map was wrong; raw decomp is authority). - public void Clear() - { - ClearAnimations(); - ClearPhysics(); - PlacementFrame = null; - PlacementFrameId = 0; - } - - /// clear_animations (0x00524dc0): delete every node, - /// null both cursors, zero frame_number. - public void ClearAnimations() - { - _animList.Clear(); - _firstCyclic = null; - _currAnim = null; - FrameNumber = 0.0; - } - - /// clear_physics (0x00524d50). - public void ClearPhysics() - { - Velocity = Vector3.Zero; - Omega = Vector3.Zero; - } - - // ── remove family (§6-§8) ─────────────────────────────────────────── - - /// - /// remove_cyclic_anims (0x00524e40): delete first_cyclic - /// → tail. A removed curr_anim snaps BACK to the previous node - /// with frame_number = prev.get_ending_frame() (or 0.0 when the - /// list emptied). Afterwards first_cyclic = new tail (or null). - /// - public void RemoveCyclicAnims() - { - var node = _firstCyclic; - while (node is not null) - { - var next = node.Next; - if (ReferenceEquals(_currAnim, node)) - { - var prev = node.Previous; - _currAnim = prev; - FrameNumber = prev is null ? 0.0 : prev.Value.GetEndingFrame(); - } - _animList.Remove(node); - node = next; - } - _firstCyclic = _animList.Last; - } - - /// - /// remove_link_animations(count) (0x00524be0): delete up to - /// predecessors of first_cyclic. A - /// removed curr_anim snaps FORWARD to first_cyclic with - /// frame_number = get_starting_frame(). - /// - public void RemoveLinkAnimations(int count) - { - for (int i = 0; i < count; i++) - { - var prev = _firstCyclic?.Previous; - if (prev is null) - break; - - if (ReferenceEquals(_currAnim, prev)) - { - _currAnim = _firstCyclic; - if (_firstCyclic is not null) - FrameNumber = _firstCyclic.Value.GetStartingFrame(); - } - _animList.Remove(prev); - } - } - - /// remove_all_link_animations (0x00524ca0): loop until - /// first_cyclic has no predecessor. - public void RemoveAllLinkAnimations() - { - while (_firstCyclic?.Previous is not null) - RemoveLinkAnimations(1); - } - - /// - /// apricot (0x00524b40; the PDB-verified retail name): trim - /// consumed nodes from the head, bounded by BOTH curr_anim - /// (stop — still live) and first_cyclic (defensive bound — - /// never delete into the cyclic tail). Called after every update (§22). - /// - public void Apricot() - { - var head = _animList.First; - if (head is null || ReferenceEquals(head, _currAnim)) - return; - - while (!ReferenceEquals(head, _firstCyclic)) - { - _animList.Remove(head!); - head = _animList.First; - if (head is null || ReferenceEquals(head, _currAnim)) - break; - } - } - - // ── physics accumulators (§10-§13) ────────────────────────────────── - - public void SetVelocity(Vector3 v) => Velocity = v; // 0x00524880 - public void SetOmega(Vector3 w) => Omega = w; // 0x005248a0 - public void CombinePhysics(Vector3 v, Vector3 w) { Velocity += v; Omega += w; } // 0x005248c0 - public void SubtractPhysics(Vector3 v, Vector3 w) { Velocity -= v; Omega -= w; } // 0x00524900 - - // ── multiply_cyclic_animation_fr (0x00524940, §14) ────────────────── - - /// - /// Scale the framerate of every node from first_cyclic to the - /// tail. Framerates ONLY (G13) — retail rescales the sequence - /// velocity/omega separately via change_cycle_speed's - /// subtract_motion/combine_motion composite (R2). - /// - public void MultiplyCyclicAnimationFramerate(float factor) - { - for (var n = _firstCyclic; n is not null; n = n.Next) - n.Value.MultiplyFramerate(factor); - } - - // ── placement + accessors (§15-§17) ───────────────────────────────── - - /// set_placement_frame (0x005249b0). - public void SetPlacementFrame(AnimationFrame? frame, uint id) - { - PlacementFrame = frame; - PlacementFrameId = id; - } - - /// get_curr_animframe (0x00524970): the floored current - /// part frame, or the placement frame when no node is active. - public AnimationFrame? GetCurrAnimframe() - { - if (_currAnim is null) - return PlacementFrame; - return _currAnim.Value.GetPartFrame((int)Math.Floor(FrameNumber)); - } - - /// get_curr_frame_number (0x005249d0). - public int GetCurrFrameNumber() => (int)Math.Floor(FrameNumber); - - // ── apply_physics (0x00524ab0, §19) ───────────────────────────────── - - /// - /// Accumulated-physics root motion: advance by - /// / over a signed quantum — - /// MAGNITUDE from , SIGN from - /// (retail copysign semantics; call sites - /// pass 1/framerate as magnitude and the signed elapsed time as sign). - /// - public void ApplyPhysics(Frame frame, double quantum, double signSource) - { - double signed = Math.Abs(quantum); - if (signSource < 0.0) - signed = -signed; - - float sq = (float)signed; - frame.Origin += Velocity * sq; - FrameOps.Rotate(frame, Omega * sq); - } - - // ── R1-P4: the frame-advance core ─────────────────────────────────── - - /// Host hook queue (hook_obj); null = hooks dropped - /// (objects without a physics host). - public IAnimHookQueue? HookObj; - - /// - /// CSequence::update (0x00525b80, §22): non-empty list → - /// then ; empty list - /// with a Frame → accumulated-physics free motion. - /// - public void Update(double timeElapsed, Frame? frame) - { - if (_animList.Count > 0 && _currAnim is not null) - { - UpdateInternal(timeElapsed, frame); - Apricot(); - } - else if (frame is not null) - { - ApplyPhysics(frame, timeElapsed, timeElapsed); - } - } - - /// - /// CSequence::update_internal (0x005255d0, §21 — ACE-verified - /// skeleton, P0-pins.md): advance by - /// framerate·dt; on overshoot clamp to the RAW high/low frame, compute - /// the leftover time, and mark animDone; fire the pose/physics/hook - /// triple for EVERY crossed integer frame (ascending forward, - /// descending reverse); queue the global AnimDoneHook when the list - /// HEAD is no longer the cyclic tail (G5's structural gate); advance to - /// the next node and LOOP with the carried leftover (P0 pin). - /// Iterative (retail while-true), NO safety cap (G4), NO boundary - /// epsilon (G1/G3). - /// - public void UpdateInternal(double timeElapsed, Frame? frame) - { - while (true) - { - if (_currAnim is null) - return; - - var currAnim = _currAnim.Value; - double framerate = currAnim.Framerate; - double frametime = framerate * timeElapsed; - int lastFrame = (int)Math.Floor(FrameNumber); - - FrameNumber += frametime; - double frameTimeElapsed = 0.0; - bool animDone = false; - - if (frametime > 0.0) - { - if (currAnim.HighFrame < Math.Floor(FrameNumber)) - { - double frameOffset = FrameNumber - currAnim.HighFrame - 1.0; - if (frameOffset < 0.0) - frameOffset = 0.0; - if (Math.Abs(framerate) > FrameOps.FEpsilon) - frameTimeElapsed = frameOffset / framerate; - FrameNumber = currAnim.HighFrame; - animDone = true; - } - while (Math.Floor(FrameNumber) > lastFrame) - { - if (frame is not null) - { - var pos = currAnim.GetPosFrame(lastFrame); - if (pos is not null) - FrameOps.Combine(frame, pos); - if (Math.Abs(framerate) > FrameOps.FEpsilon) - ApplyPhysics(frame, 1.0 / framerate, timeElapsed); - } - ExecuteHooks(currAnim.GetPartFrame(lastFrame), +1); - lastFrame++; - } - } - else if (frametime < 0.0) - { - if (currAnim.LowFrame > Math.Floor(FrameNumber)) - { - double frameOffset = FrameNumber - currAnim.LowFrame; - if (frameOffset > 0.0) - frameOffset = 0.0; - if (Math.Abs(framerate) > FrameOps.FEpsilon) - frameTimeElapsed = frameOffset / framerate; - FrameNumber = currAnim.LowFrame; - animDone = true; - } - while (Math.Floor(FrameNumber) < lastFrame) - { - if (frame is not null) - { - var pos = currAnim.GetPosFrame(lastFrame); - if (pos is not null) - FrameOps.Subtract1(frame, pos); - if (Math.Abs(framerate) > FrameOps.FEpsilon) - ApplyPhysics(frame, 1.0 / framerate, timeElapsed); - } - ExecuteHooks(currAnim.GetPartFrame(lastFrame), -1); - lastFrame--; - } - } - else - { - if (frame is not null && Math.Abs(timeElapsed) > FrameOps.FEpsilon) - ApplyPhysics(frame, timeElapsed, timeElapsed); - return; - } - - if (!animDone) - return; - - // AnimDone gate: a LIST-STRUCTURE test, not a node flag (G5) — - // fire only when the consumed head is not already the cyclic - // tail (retail 0x00525943-0x00525968). - if (HookObj is not null - && _animList.First is not null - && !ReferenceEquals(_animList.First, _firstCyclic)) - { - HookObj.AddAnimDoneHook(); - } - - AdvanceToNextAnimation(timeElapsed, frame); - timeElapsed = frameTimeElapsed; // the P0-pinned leftover carry - } - } - - /// - /// CSequence::advance_to_next_animation (0x005252b0, §23): four - /// pose operations per transition — un-apply the outgoing node's pose - /// (subtract1 + residual physics), step (forward wraps to - /// first_cyclic; REVERSE wraps to the LIST TAIL — asymmetric by - /// design), reseed from the incoming node's - /// direction-aware boundary, apply the incoming pose (combine + - /// physics). Pose ops run in BOTH directions (ACE's framerate-sign - /// gates are ACE-isms; the decomp is unconditional apart from the - /// degenerate-framerate guard). - /// - public void AdvanceToNextAnimation(double timeElapsed, Frame? frame) - { - if (_currAnim is null) - return; - - var outgoing = _currAnim.Value; - - // (a) un-apply the outgoing node's pose at the CURRENT FrameNumber. - if (frame is not null && Math.Abs(outgoing.Framerate) >= FrameOps.FEpsilon) - { - var pos = outgoing.GetPosFrame((int)FrameNumber); - if (pos is not null) - FrameOps.Subtract1(frame, pos); - ApplyPhysics(frame, 1.0 / outgoing.Framerate, timeElapsed); - } - - // (b) step; (c) reseed FrameNumber from the incoming boundary. - if (timeElapsed >= 0.0) - { - _currAnim = _currAnim.Next ?? _firstCyclic; - if (_currAnim is null) - return; - FrameNumber = _currAnim.Value.GetStartingFrame(); - } - else - { - _currAnim = _currAnim.Previous ?? _animList.Last; - if (_currAnim is null) - return; - FrameNumber = _currAnim.Value.GetEndingFrame(); - } - - // (d) apply the incoming node's pose at the new FrameNumber. - var incoming = _currAnim.Value; - if (frame is not null && Math.Abs(incoming.Framerate) >= FrameOps.FEpsilon) - { - var pos = incoming.GetPosFrame((int)FrameNumber); - if (pos is not null) - FrameOps.Combine(frame, pos); - ApplyPhysics(frame, 1.0 / incoming.Framerate, timeElapsed); - } - } - - /// - /// CSequence::execute_hooks (0x00524830, §18): QUEUE the part - /// frame's hooks whose direction is Both (0) or matches the playback - /// direction (+1 forward / −1 backward) into the host. Null part frame - /// guarded (retail has a latent null-deref here — documented safe - /// divergence, G18). - /// - private void ExecuteHooks(AnimationFrame? partFrame, int direction) - { - if (partFrame is null || HookObj is null) - return; - - foreach (var hook in partFrame.Hooks) - { - if (hook is null) - continue; - int dir = (int)hook.Direction; - if (dir == 0 || dir == direction) - HookObj.AddAnimHook(hook); - } - } - - // ── internal cursors for P4 (update_internal operates on nodes) ───── - - internal LinkedListNode? CurrAnimNode - { - get => _currAnim; - set => _currAnim = value; - } - - internal LinkedListNode? FirstCyclicNode => _firstCyclic; - internal LinkedList AnimList => _animList; -} diff --git a/src/AcDream.Core/Physics/Motion/ConstraintManager.cs b/src/AcDream.Core/Physics/Motion/ConstraintManager.cs deleted file mode 100644 index 72e8ecbf..00000000 --- a/src/AcDream.Core/Physics/Motion/ConstraintManager.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System; -using System.Numerics; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R5 — verbatim port of retail's ConstraintManager (acclient.h:31529, -/// struct #3467; decomp 0x00556090-0x005562xx, -/// r5-constraintmanager-decomp.md). The server-position rubber-band -/// leash: while the owning object is in ground contact it progressively -/// resists movement past a start→max distance band from a pinned anchor, and -/// hard-clamps at the max. Read as a jump gate by -/// CMotionInterp::jump_is_allowed (block jump while -/// ). -/// -/// Arming is UNPORTED in acdream (R5). Retail arms the leash ONLY -/// from SmartBox::HandleReceivedPosition (on every inbound server -/// position packet) with two constants from -/// CPhysicsObj::GetStart/MaxConstraintDistance whose values BN elided -/// (x87 returns — unknown, need a cdb read). acdream's position reconciliation -/// is not SmartBox, so nothing calls — the leash -/// stays disarmed and stays false, matching -/// register TS-35's current stub behavior. The class is ported for structural -/// completeness of ; the leash-arming port + the -/// two unknown constants are a deferred issue (port-plan §Constraint scope). -/// -public sealed class ConstraintManager -{ - private readonly IPhysicsObjHost _host; - - /// +0x04 retail is_constrained. - public bool IsConstrained { get; private set; } - - /// +0x08 retail constraint_pos_offset — recomputed every - /// as the LENGTH of that tick's step (NOT the - /// distance to the anchor — see the ACE correctness note, port-plan §2b); - /// the next tick's contact branch compares it against start/max. - public float ConstraintPosOffset { get; private set; } - - /// +0x0c retail constraint_pos — the leash anchor. Stored - /// by , never read by - /// (retail + ACE — write-only in this class). - public Position ConstraintPos { get; private set; } - - /// +0x48 retail constraint_distance_start — the near edge - /// of the brake band. - public float ConstraintDistanceStart { get; private set; } - - /// +0x4c retail constraint_distance_max — the far edge - /// (full clamp). - public float ConstraintDistanceMax { get; private set; } - - public ConstraintManager(IPhysicsObjHost host) - => _host = host ?? throw new ArgumentNullException(nameof(host)); - - /// - /// Retail ConstraintManager::ConstrainTo (0x00556240). Pin the leash - /// anchor and band; initialize to the - /// CURRENT distance from anchor to the mover (the leash starts already - /// extended to wherever the object is, not at zero). - /// - public void ConstrainTo(Position anchor, float startDistance, float maxDistance) - { - IsConstrained = true; - ConstraintPos = anchor; - ConstraintDistanceStart = startDistance; - ConstraintDistanceMax = maxDistance; - ConstraintPosOffset = Vector3.Distance(anchor.Frame.Origin, _host.Position.Frame.Origin); - } - - /// Retail ConstraintManager::UnConstrain (0x005560c0) — - /// clears the constrained flag only (leaves the band/anchor/offset stale - /// until the next ). - public void UnConstrain() => IsConstrained = false; - - /// - /// Retail ConstraintManager::IsFullyConstrained (0x005560d0): - /// constraint_distance_max * 0.9 < constraint_pos_offset — the - /// object counts as fully constrained once it has strained past 90 % of the - /// max leash. Read by jump_is_allowed to block jumps. Always false - /// while the leash is disarmed (acdream never arms it — see class note). - /// - public bool IsFullyConstrained() - => ConstraintDistanceMax * 0.9f < ConstraintPosOffset; - - /// - /// Retail ConstraintManager::adjust_offset (0x00556180). The last - /// stage of 's chain — clamps the - /// ALREADY-composed per-tick (interp + sticky) - /// while grounded, then records its length for the next tick. No-op unless - /// . See port-plan §2b. - /// - public void AdjustOffset(MotionDeltaFrame offset, double quantum) - { - _ = quantum; // retail body doesn't use the quantum directly. - if (!IsConstrained) - return; - - if (_host.InContact) // transient_state & 1 — clamp only while grounded. - { - if (ConstraintPosOffset < ConstraintDistanceMax) - { - if (ConstraintPosOffset > ConstraintDistanceStart) - { - // Linear brake taper: 1.0 just past start → 0.0 at max. - float taper = (ConstraintDistanceMax - ConstraintPosOffset) - / (ConstraintDistanceMax - ConstraintDistanceStart); - offset.Origin *= taper; - } - } - else - { - offset.Origin = Vector3.Zero; // past max — fully pinned. - } - } - - // Unconditional (grounded OR airborne): track this tick's step length. - ConstraintPosOffset = offset.Origin.Length(); - } -} diff --git a/src/AcDream.Core/Physics/Motion/FrameOps.cs b/src/AcDream.Core/Physics/Motion/FrameOps.cs deleted file mode 100644 index a36a1292..00000000 --- a/src/AcDream.Core/Physics/Motion/FrameOps.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System; -using System.Numerics; -using DatReaderWriter.Types; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R1-P3 — verbatim ports of retail's Frame rotation helpers used by -/// the CSequence physics path (oracle: raw decomp reads 2026-07-02). -/// -/// -/// Frame::grotate (0x005357a0, pc:319782): build the -/// axis-angle quaternion from a GLOBAL rotation vector (angle = |v|, -/// axis = v/|v|, half-angle sin/cos) and PREMULTIPLY it onto the frame's -/// orientation — the incremental rotation is applied in world space. -/// Rotations with |v|² < F_EPSILON² are skipped. -/// Frame::rotate (0x004525b0, pc:91477): map the LOCAL -/// rotation vector through the frame's local→global rotation -/// (m_fl2gv — our quaternion), then grotate. -/// -/// -public static class FrameOps -{ - /// Retail F_EPSILON (0.000199999995f); grotate gates on its - /// square against |v|². - public const float FEpsilon = 0.000199999995f; - - /// Frame::grotate — incremental WORLD-space rotation. - public static void GRotate(Frame frame, Vector3 rotationGlobal) - { - float magSq = rotationGlobal.LengthSquared(); - if (magSq < FEpsilon * FEpsilon) - return; - - float angle = MathF.Sqrt(magSq); - float invMag = 1f / angle; - float half = angle * 0.5f; - float s = MathF.Sin(half); - float c = MathF.Cos(half); - - // Retail's set_rotate receives the raw Hamilton product r ⊗ q - // (r = the new axis-angle quat, q = the current orientation) — - // rotation applied in GLOBAL space. System.Numerics - // Quaternion.Multiply(r, q) is that product with - // Transform(v, r*q) == r-applied-after-q. set_rotate renormalizes. - var r = new Quaternion( - rotationGlobal.X * s * invMag, - rotationGlobal.Y * s * invMag, - rotationGlobal.Z * s * invMag, - c); - frame.Orientation = Quaternion.Normalize(Quaternion.Multiply(r, frame.Orientation)); - } - - /// Frame::rotate — LOCAL rotation vector, mapped to - /// global through the orientation, then . - public static void Rotate(Frame frame, Vector3 rotationLocal) - => GRotate(frame, Vector3.Transform(rotationLocal, frame.Orientation)); - - /// - /// Frame::combine as used by the CSequence pose path (ACE - /// AFrame.Combine, verified against the pre-R1 acdream port): - /// apply the pose — origin += rotate(pos.Origin by orientation), then - /// orientation ∘= pos.Orientation. - /// - public static void Combine(Frame frame, Frame pos) - { - frame.Origin += Vector3.Transform(pos.Origin, frame.Orientation); - frame.Orientation = Quaternion.Normalize(frame.Orientation * pos.Orientation); - } - - /// - /// Frame::subtract1 — un-apply the pose: orientation ∘= - /// conj(pos.Orientation) FIRST, then origin −= rotate(pos.Origin by the - /// UPDATED orientation) (inverse order of ). - /// - public static void Subtract1(Frame frame, Frame pos) - { - frame.Orientation = Quaternion.Normalize( - frame.Orientation * Quaternion.Conjugate(pos.Orientation)); - frame.Origin -= Vector3.Transform(pos.Origin, frame.Orientation); - } -} diff --git a/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs b/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs deleted file mode 100644 index 85b4eb88..00000000 --- a/src/AcDream.Core/Physics/Motion/IPhysicsObjHost.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Numerics; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R5 seam — the acdream stand-in for retail's CPhysicsObj as seen BY -/// its owned managers. Retail's StickyManager / ConstraintManager -/// / TargetManager each hold a raw physics_obj pointer and call -/// back through it (position/velocity/radius accessors, target-tracking -/// registration, the HandleUpdateTarget fan-out) and — for the voyeur -/// system — resolve OTHER physics objects via CObjectMaint::GetObjectA -/// and drive their add_voyeur / receive_target_update / -/// remove_voyeur entry points. This interface is that back-pointer. -/// -/// The App layer implements one host per entity (a remote -/// RemoteMotion or the local player), wiring the accessors to the live -/// and the / -/// / it owns. -/// is backed by the App's live entity table -/// (_entitiesByServerGuid), giving the voyeur round-trip its -/// cross-entity delivery path. -/// -public interface IPhysicsObjHost -{ - /// Retail physics_obj->id — this object's guid. - uint Id { get; } - - /// Retail physics_obj->m_position — world-space cell + - /// frame (acdream seams carry WORLD space; see the MoveToManager binding - /// note). - Position Position { get; } - - /// Retail CPhysicsObj::get_velocity. - Vector3 Velocity { get; } - - /// Retail CPhysicsObj::GetRadius — the mover's cylinder - /// radius. - float Radius { get; } - - /// Retail physics_obj->transient_state & 1 — the - /// CONTACT bit (ConstraintManager's grounded gate). - bool InContact { get; } - - /// Retail CPhysicsObj::get_minterp()->get_max_speed() — - /// the mover's max locomotion speed, or null if it has no motion - /// interpreter yet (StickyManager falls back to a 15.0 constant). - float? MinterpMaxSpeed { get; } - - /// Retail Timer::cur_time — the wall/game clock (seconds). - /// Drives the sticky 1 s timeout and target 10 s staleness deadlines. - double CurTime { get; } - - /// Retail PhysicsTimer::curr_time — the physics-tick clock - /// (seconds). Drives TargetManager::HandleTargetting's 0.5 s - /// throttle. Retail uses a DIFFERENT clock here than ; - /// acdream may bind both to the same source. - double PhysicsTimerTime { get; } - - /// Retail CObjectMaint::GetObjectA(id) — resolve another - /// physics object by guid, or null if not currently known/visible. - /// The cross-entity seam for the voyeur round-trip and sticky live-target - /// resolve. - IPhysicsObjHost? GetObjectA(uint id); - - /// Retail CPhysicsObj::HandleUpdateTarget — fans a - /// to this host's - /// (move-to steering) AND (sticky follow). - /// Called from and the timeout - /// path. - void HandleUpdateTarget(TargetInfo info); - - /// Retail CPhysicsObj::interrupt_current_movement → - /// MovementManager::CancelMoveTo(0x36). - void InterruptCurrentMovement(); - - /// Retail CPhysicsObj::set_target(ctx, objId, radius, - /// quantum) (lazily creating - /// the TargetManager). Called by StickyManager::StickTo and - /// MoveToManager's object-move entry points. - void SetTarget(uint contextId, uint objectId, float radius, double quantum); - - /// Retail CPhysicsObj::clear_target → - /// . - void ClearTarget(); - - /// Retail CPhysicsObj::receive_target_update → - /// . The inbound side a SENDER's - /// SendVoyeurUpdate tail-calls on the watcher. - void ReceiveTargetUpdate(TargetInfo info); - - /// Retail CPhysicsObj::add_voyeur(id, radius, quantum) → - /// (lazily creating the - /// TargetManager). Called on the TARGET when a watcher subscribes. - void AddVoyeur(uint watcherId, float radius, double quantum); - - /// Retail CPhysicsObj::remove_voyeur(id) → - /// . Called on the TARGET when a - /// watcher unsubscribes. - void RemoveVoyeur(uint watcherId); -} diff --git a/src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs b/src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs deleted file mode 100644 index 8386e5a2..00000000 --- a/src/AcDream.Core/Physics/Motion/MotionDeltaFrame.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Numerics; - -namespace AcDream.Core.Physics.Motion; - -/// -/// Mutable stand-in for retail's Frame when it is used as the per-tick -/// delta accumulator that PositionManager::adjust_offset and its -/// three sub-managers (Interpolation / Sticky / Constraint) write into -/// (retail arg2, e.g. StickyManager::adjust_offset 0x00555430, -/// ConstraintManager::adjust_offset 0x00556180). acdream's -/// is an immutable record — retail's per-tick math -/// mutates m_fOrigin in place across the interp→sticky→constraint chain -/// (each sub-manager composes on top of the previous one's write), so the -/// accumulator needs a mutable shape. -/// -/// = retail m_fOrigin (the accumulated -/// position delta, in the mover's LOCAL frame after -/// Position::globaltolocalvec). carries the -/// heading retail's Frame::set_heading writes; read/write it as a -/// compass heading via / -/// (P5 convention, degrees). -/// -public sealed class MotionDeltaFrame -{ - /// Retail m_fOrigin — the accumulated per-tick position - /// delta (mover-local frame). - public Vector3 Origin; - - /// Retail Frame rotation — carries the - /// Frame::set_heading output. - public Quaternion Orientation = Quaternion.Identity; - - /// Retail Frame::get_heading (P5 compass degrees). - public float GetHeading() => MoveToMath.GetHeading(Orientation); - - /// Retail Frame::set_heading(headingDeg) — pure - /// yaw-about-Z setter (P5 compass degrees). - public void SetHeading(float headingDeg) => - Orientation = MoveToMath.SetHeading(Orientation, headingDeg); -} diff --git a/src/AcDream.Core/Physics/Motion/MotionNode.cs b/src/AcDream.Core/Physics/Motion/MotionNode.cs deleted file mode 100644 index 007a6c11..00000000 --- a/src/AcDream.Core/Physics/Motion/MotionNode.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace AcDream.Core.Physics.Motion; - -/// -/// R3-W1 — verbatim port of retail's CMotionInterp::MotionNode -/// (acclient.h:53293, struct #5857): -/// -/// struct __cppobj CMotionInterp::MotionNode : LListData -/// { -/// unsigned int context_id; // +4 (offset from LListData's own +0 next-ptr) -/// unsigned int motion; // +8 — the "action-class" field; bit 0x10000000 = action-class flag -/// unsigned int jump_error_code; // +0xc -/// }; -/// -/// This is the node type queued onto CMotionInterp::pending_motions -/// by add_to_queue (0x00527b80, docs/research/2026-07-02-r3-motioninterp/ -/// r3-motioninterp-decomp.md §1a) and consumed head-first, unconditionally, -/// by MotionDone (0x00527ec0, §1c) / HandleExitWorld (0x00527f30, -/// §1d). W1 ports only the shape — the queue itself (PendingMotions, -/// add_to_queue, MotionDone) is R3-W2 scope (r3-port-plan.md §3). -/// -/// Retail context_id (+4) — the -/// MovementParameters.ContextId that produced this node. -/// Retail motion (+8) — the applied motion id. -/// Bit 0x10000000 marks an "action-class" motion; MotionDone -/// only pops the state action-FIFO head when this bit is set on the queue -/// head it is consuming. -/// Retail jump_error_code (+0xc) — the -/// motion_allows_jump result computed at enqueue time. Peeked by -/// jump_is_allowed's pending-head short-circuit (W0-pins A2). -public readonly record struct MotionNode(uint ContextId, uint Motion, uint JumpErrorCode); diff --git a/src/AcDream.Core/Physics/Motion/MotionState.cs b/src/AcDream.Core/Physics/Motion/MotionState.cs deleted file mode 100644 index 94c96a58..00000000 --- a/src/AcDream.Core/Physics/Motion/MotionState.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Collections.Generic; - -namespace AcDream.Core.Physics.Motion; - -/// -/// One entry of a chain — retail's -/// MotionList node (12 bytes: motion + speed_mod + next; -/// acclient.h, r2-motiontable-decomp.md §14). One struct, two independent -/// chain disciplines: the modifier chain is a PUSH-FRONT STACK, the action -/// chain a TAIL-APPEND FIFO. -/// -public sealed class MotionEntry -{ - public uint Motion; - public float SpeedMod = 1f; - - public MotionEntry(uint motion, float speedMod) - { - Motion = motion; - SpeedMod = speedMod; - } -} - -/// -/// R2-Q1 — verbatim port of retail's MotionState (acclient.h:31081; -/// r2-motiontable-decomp.md §13): the motion-table-facing state block — -/// current style / substate / substate_mod plus the modifier stack and the -/// action FIFO that CMotionTable::GetObjectSequence, -/// StopSequenceMotion, re_modify, and -/// MotionTableManager::AnimationDone read and write. -/// -public sealed class MotionState -{ - /// Current style/stance command word (0 = unset; ctor 0x00525fd0). - public uint Style; - - /// Current base substate command word (0 = unset). - public uint Substate; - - /// Speed modifier of the base substate (default 1.0). - public float SubstateMod = 1f; - - private readonly LinkedList _modifiers = new(); // modifier_head — push-front stack - private readonly LinkedList _actions = new(); // action_head/tail — FIFO - - public MotionState() - { - } - - /// - /// Deep copy (retail copy ctor 0x00526790; Q0-pins A4-#5): both chains - /// CLONED — re_modify's snapshot is a termination bound, never - /// shared state. - /// - public MotionState(MotionState other) - { - Style = other.Style; - Substate = other.Substate; - SubstateMod = other.SubstateMod; - foreach (var m in other._modifiers) - _modifiers.AddLast(new MotionEntry(m.Motion, m.SpeedMod)); - foreach (var a in other._actions) - _actions.AddLast(new MotionEntry(a.Motion, a.SpeedMod)); - } - - /// Modifier chain in retail order (newest first — push-front). - public IEnumerable Modifiers => _modifiers; - - /// Action FIFO in retail order (oldest first). - public IEnumerable Actions => _actions; - - /// add_modifier_no_check (0x00525ff0): unconditional - /// push-front. - public void AddModifierNoCheck(uint motion, float speedMod) - => _modifiers.AddFirst(new MotionEntry(motion, speedMod)); - - /// - /// add_modifier (0x00526340): refuse when the motion is already - /// a modifier (caller must stop-then-re-add to refresh) or already IS - /// the current base substate. - /// - public bool AddModifier(uint motion, float speedMod) - { - for (var n = _modifiers.First; n is not null; n = n.Next) - if (n.Value.Motion == motion) - return false; - - if (Substate == motion) - return false; - - AddModifierNoCheck(motion, speedMod); - return true; - } - - /// remove_modifier (0x00526040) — by node identity - /// (retail's node+predecessor pair collapses in a managed list). - public void RemoveModifier(MotionEntry entry) - { - _modifiers.Remove(entry); - } - - /// clear_modifiers (0x00526070). - public void ClearModifiers() => _modifiers.Clear(); - - /// add_action (0x005260a0): tail append. - public void AddAction(uint motion, float speedMod) - => _actions.AddLast(new MotionEntry(motion, speedMod)); - - /// remove_action_head (0x00526120): pop the FIFO head, - /// returning its motion id (0 when empty). - public uint RemoveActionHead() - { - var head = _actions.First; - if (head is null) - return 0; - _actions.RemoveFirst(); - return head.Value.Motion; - } - - /// clear_actions (0x005260f0). - public void ClearActions() => _actions.Clear(); -} diff --git a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs b/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs deleted file mode 100644 index 8ffb1990..00000000 --- a/src/AcDream.Core/Physics/Motion/MotionTableDispatchSink.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R2-Q5 — the that dispatches the -/// CMotionInterp funnel's retail-ordered motion events STRAIGHT into the -/// entity's motion-table stack via -/// (lazy initialize_state + -/// MotionTableManager::PerformMovement 0x0051c0b0). -/// -/// -/// This replaces the App-side RemoteMotionSink (deleted): no axis -/// collection, no single-cycle priority pick, no Commit pass, no HasCycle -/// probe, no Run→Walk→Ready missing-cycle chain — retail's -/// GetObjectSequence (0x00522860) + is_allowed decide what -/// plays. Forward locomotion installs the substate cycle (Branch 2); -/// sidesteps resolve by the dat (cycle when authored, else modifier); -/// turns are Branch-4 physics-only modifiers blended over the substate via -/// combine_motion/re_modify — the composition the retired -/// AP-73 row approximated with one cycle. -/// -/// -/// -/// The / callbacks are the -/// interim ObservedOmega seam: the App seeds the remote body's angular -/// velocity from the wire turn so rotation starts the same tick (retail -/// rotates the body from the sequence omega inside the per-tick -/// apply_physics chain — R6 scope; register row). They fire BEFORE -/// the dispatch so a consumer sees the seed even if the dat lacks the -/// modifier entry. -/// -/// -public sealed class MotionTableDispatchSink : IInterpretedMotionSink -{ - private readonly AnimationSequencer _sequencer; - - /// Turn-class dispatch observed: (motion, signedSpeed) — - /// TurnLeft arrives either as the explicit 0x0E command or as - /// TurnRight + negative speed (adjust_motion wire convention). - public Action? TurnApplied { get; set; } - - /// Turn-class stop observed. - public Action? TurnStopped { get; set; } - - public MotionTableDispatchSink(AnimationSequencer sequencer) - { - ArgumentNullException.ThrowIfNull(sequencer); - _sequencer = sequencer; - } - - private static bool IsTurn(uint motion) - => (motion & 0xFF000000u) == 0x65000000u && (motion & 0xFFu) is 0x0D or 0x0E; - - public bool ApplyMotion(uint motion, float speed) - { - if (IsTurn(motion)) - TurnApplied?.Invoke(motion, speed); - - uint result = _sequencer.PerformMovement(MotionTableMovement.Interpreted(motion, speed)); - return result == MotionTableManagerError.Success; - } - - public bool StopMotion(uint motion) - { - if (IsTurn(motion)) - TurnStopped?.Invoke(); - - uint result = _sequencer.PerformMovement(MotionTableMovement.StopInterpreted(motion, 1f)); - return result == MotionTableManagerError.Success; - } - - /// - /// R4-V5 wedge fix — retail CPartArray::StopCompletelyInternal - /// (0x00518890): MotionTableManager::PerformMovement(type 5). - /// The manager stops everything and queues its Ready-sentinel - /// pending_animations entry UNCONDITIONALLY — the completable partner - /// of the interp's A9 pending_motions node. A full stop ends any turn - /// cycle, so the ObservedOmega seam is notified like - /// 's turn-class branch. - /// - public bool StopCompletely() - { - TurnStopped?.Invoke(); - - uint result = _sequencer.PerformMovement(MotionTableMovement.StopCompletely()); - return result == MotionTableManagerError.Success; - } -} diff --git a/src/AcDream.Core/Physics/Motion/MotionTableManager.cs b/src/AcDream.Core/Physics/Motion/MotionTableManager.cs deleted file mode 100644 index 271c3ce8..00000000 --- a/src/AcDream.Core/Physics/Motion/MotionTableManager.cs +++ /dev/null @@ -1,508 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using AcDream.Core.Physics; - -namespace AcDream.Core.Physics.Motion; - -// ───────────────────────────────────────────────────────────────────────────── -// R2-Q3 — verbatim port of retail's MotionTableManager (the pending-animation -// queue + tick-countdown completion machinery sitting ABOVE CMotionTable). -// Oracle: docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md §11 -// (all addresses/line numbers below point there); ambiguity pins: -// docs/research/2026-07-02-r2-motiontable/Q0-pins.md; plan: -// docs/research/2026-07-02-r2-motiontable/r2-port-plan.md §3 Q3 + §4 (the -// MotionDone -> R3 boundary contract). -// -// Struct layout (acclient.h:57614/31092/31097; decomp §11 offset map): -// physics_obj @0x0, table @0x4, state @0x8 (24 bytes), animation_counter -// @0x20, pending_animations {head_,tail_} @0x24/0x28. sizeof == 0x2c. -// C# has no physics_obj field — R2 leaves the CPhysicsObj::MotionDone target -// as an injectable seam (IMotionDoneSink, part A of this file) since R3 binds -// the real MotionInterpreter/MovementManager consumer. -// ───────────────────────────────────────────────────────────────────────────── - -/// -/// The R2 seam standing in for retail's CPhysicsObj::MotionDone — the -/// callback and -/// fire for every -/// pending-queue entry whose tick countdown reaches zero. R2 leaves this -/// consumer-injectable (register row: MotionDone observed-not-consumed, -/// r2-port-plan.md §4); R3 binds it to MotionInterpreter.MotionDone -/// (pops CMotionInterp.pending_motions, recomputes IsAnimating). -/// -public interface IMotionDoneSink -{ - /// - /// CPhysicsObj::MotionDone(motion, success). - /// is the CALLER's flag: true from the real per-tick animation-done hook - /// ( invoked with - /// success:true), false from the enter/exit-world drains, - /// hardcoded true from . - /// - void MotionDone(uint motion, bool success); -} - -/// -/// One pending_animations queue node — retail's -/// MotionTableManager::AnimNode (acclient.h:57614; 16 bytes: -/// dllist_next/prev + motion + num_anims). NumAnims doubles as a -/// RELATIVE tick-duration countdown once queued (not an absolute deadline — -/// decomp §11 AnimationDone note), not an anim-array length. -/// -/// Register note (reusing the R1 AD-34 wording): retail's intrusive DLList -/// is ported as a managed of ; -/// node identity semantics (the tail-anchored backward scan in -/// , the in-place -/// truncation in ) are -/// preserved via references rather than raw -/// prev/next pointers. -/// -public sealed class PendingMotion -{ - public uint Motion; - public uint NumAnims; - - public PendingMotion(uint motion, uint numAnims) - { - Motion = motion; - NumAnims = numAnims; - } -} - -/// -/// Retail's MotionTableManager (0x0051bxxx region) — owns the -/// pending-animation queue and the tick-countdown completion machinery that -/// sits ABOVE 's pure selection logic. -/// is the single chokepoint between a wire-level -/// and the motion-table state machine (decomp -/// §11 PerformMovement 0x0051c0b0). -/// -public sealed class MotionTableManager -{ - // Motion-id class bits (decomp §1 / §15). - private const uint CycleClassBit = 0x40000000u; - private const uint ModifierClassBit = 0x20000000u; - private const uint ActionClassBit = 0x10000000u; - - /// - /// Combined block-mask used by 's - /// CYCLE-class tail scan (decomp §11/§15 literal "0xb0000000"). Bit - /// decomposition: 0xb0000000 == 0x80000000 (style) | 0x20000000 - /// (modifier) | 0x10000000 (action) — i.e. STYLE|MODIFIER|ACTION, - /// deliberately EXCLUDING the cycle-class bit (0x40000000) itself. The - /// decomp's own prose gloss ("cycle|action|..." mask) is imprecise; the - /// literal hex constant (ported verbatim below) is the ground truth — - /// an intervening node of a DIFFERENT cycle does not block a cycle-tail - /// collapse (two cycles naturally supersede via the base-cycle rebuild - /// mechanism), but an intervening style-change/modifier/action does. - /// - private const uint CycleTailBlockMask = 0xb0000000u; - - /// - /// Combined block-mask used by 's - /// STYLE-class tail scan (decomp §11/§15 literal "0x70000000"). Bit - /// decomposition: 0x70000000 == 0x40000000 (cycle) | 0x20000000 - /// (modifier) | 0x10000000 (action) — CYCLE|MODIFIER|ACTION, - /// deliberately EXCLUDING the style-class top bit (0x80000000) itself - /// (the match test is already exact-equality on the full style id, so a - /// DIFFERENT style in between doesn't need a separate block check here). - /// - private const uint StyleTailBlockMask = 0x70000000u; - - /// Sentinel "stop-completely / default-state-installed" motion id - /// (decomp §15 "0x41000003"). - public const uint ReadySentinel = 0x41000003u; - - private readonly CMotionTable? _table; - private readonly MotionState _state; - private readonly CSequence _sequence; - private readonly IMotionDoneSink _sink; - private readonly LinkedList _pendingAnimations = new(); // pending_animations - private int _animationCounter; // animation_counter (@0x20) - - /// Current motion state — retail's embedded state member - /// (@0x8, 24 bytes). Exposed for callers that need to read style/substate. - public MotionState State => _state; - - /// - /// Create 0x0051bc50 (@290510). Retail's static factory - /// zero-initializes physics_obj/table, placement-constructs - /// state, zeros animation_counter, and nulls - /// pending_animations's head/tail — all of which the C# field - /// initializers below already give for free. may - /// be null (retail: "no motion table loaded" — - /// returns error 7 in that case, matching SetMotionTableID never - /// having been called). - /// - public MotionTableManager(CMotionTable? table, MotionState state, CSequence sequence, IMotionDoneSink sink) - { - ArgumentNullException.ThrowIfNull(state); - ArgumentNullException.ThrowIfNull(sequence); - ArgumentNullException.ThrowIfNull(sink); - - _table = table; - _state = state; - _sequence = sequence; - _sink = sink; - } - - /// Read-only inspection surface for tests: the pending queue in - /// head-to-tail order. - public IEnumerable PendingAnimations => _pendingAnimations; - - /// Read-only inspection surface for tests: the current tick - /// countdown accumulator. - public int AnimationCounter => _animationCounter; - - // ── add_to_queue / remove_redundant_links / truncate_animation_list ──── - - /// - /// add_to_queue 0x0051bfe0 (@290854): append a new node to the - /// tail of pending_animations, then opportunistically call - /// to collapse any now-superseded - /// earlier entries. - /// - public void AddToQueue(uint motion, uint ticks) - { - _pendingAnimations.AddLast(new PendingMotion(motion, ticks)); - RemoveRedundantLinks(); - } - - /// - /// remove_redundant_links 0x0051bf20 (@290771): retail's - /// TAIL-ANCHORED SINGLE SCAN (ported verbatim — NOT ACE's restructured - /// outer loop, per r2-port-plan.md §3 Q3). - /// - /// 1. Skip backward over trailing zero-NumAnims nodes (already - /// neutered / instant entries). If the list bottoms out, return. - /// 2. If the effective tail's motion is CYCLE-class-and-not-modifier-class - /// (&0x40000000 != 0 && &0x20000000 == 0): scan backward for an - /// EARLIER node with the SAME motion id AND non-zero NumAnims. - /// Blocked (abort, no truncation) by any intervening non-zero node - /// whose motion matches the 0xb0000000 class mask. - /// 3. Else if the effective tail's motion is STYLE-class - /// ((int)motion < 0): same backward scan, EXACT match (no - /// additional class requirement on the match itself), blocked by any - /// intervening non-zero node matching the WIDER 0x70000000 class mask. - /// 4. On a match, from the matched - /// node's successor through the tail (matched node itself untouched). - /// - public void RemoveRedundantLinks() - { - var tail = _pendingAnimations.Last; - if (tail is null) - return; - - // Step 1: skip trailing zero-tick nodes. - while (tail is not null && tail.Value.NumAnims == 0) - { - tail = tail.Previous; - } - if (tail is null) - return; - - uint motion = tail.Value.Motion; - - if ((motion & CycleClassBit) != 0 && (motion & ModifierClassBit) == 0) - { - // CYCLE-class (not modifier-class) tail: match = same motion AND - // non-zero NumAnims; block = non-zero AND matches 0xb0000000. - var scan = tail.Previous; - LinkedListNode? matched = null; - while (scan is not null) - { - if (scan.Value.Motion == motion && scan.Value.NumAnims != 0) - { - matched = scan; - break; - } - if (scan.Value.NumAnims != 0 && (scan.Value.Motion & CycleTailBlockMask) != 0) - return; // blocked by an intervening "important" non-zero node - scan = scan.Previous; - } - if (matched is not null) - TruncateAnimationList(matched); - } - else if ((int)motion < 0) - { - // STYLE-class tail: exact-equality match; block mask is wider - // (0x70000000) with no additional match-side class requirement. - var scan = tail.Previous; - LinkedListNode? matched = null; - while (scan is not null) - { - if (scan.Value.Motion == motion) - { - matched = scan; - break; - } - if (scan.Value.NumAnims != 0 && (scan.Value.Motion & StyleTailBlockMask) != 0) - return; - scan = scan.Previous; - } - if (matched is not null) - TruncateAnimationList(matched); - } - // else: modifier-class or action-class tail -> no redundancy scan (retail: neither branch taken). - } - - /// - /// truncate_animation_list 0x0051bca0 (@290533): walk - /// pending_animations.tail_ BACKWARD toward (but not including) - /// , zeroing each node's - /// NumAnims tick countdown IN PLACE (nodes stay queued — retail - /// does NOT unlink them here) and accumulating the total ticks removed, - /// then strips that many ticks' worth of link animations from the live - /// via . - /// - private void TruncateAnimationList(LinkedListNode stopAtExclusive) - { - uint removedTicks = 0; - var node = _pendingAnimations.Last; - while (!ReferenceEquals(node, stopAtExclusive)) - { - if (node is null) - return; // stopAtExclusive wasn't actually in the list -> abort quietly - - removedTicks += node.Value.NumAnims; - node.Value.NumAnims = 0; - node = node.Previous; - } - - _sequence.RemoveLinkAnimations((int)removedTicks); - } - - // ── AnimationDone / CheckForCompletedMotions / UseTime ───────────────── - - /// - /// AnimationDone 0x0051bce0 (@290558): advance the animation clock - /// by one tick and fire for every - /// queued motion whose relative-duration countdown has elapsed. - /// NumAnims on a queue node is a RELATIVE tick-duration (not an - /// absolute deadline) — subtracted from the running counter after firing, - /// forming a decrementing countdown chain (one AnimationDone call - /// can pop MULTIPLE queued entries via counter rollover). - /// - /// Passed straight through to - /// for every entry popped this - /// call. - public void AnimationDone(bool success) - { - var head = _pendingAnimations.First; - if (head is null) - return; - - _animationCounter += 1; - - while (head is not null && head.Value.NumAnims <= _animationCounter) - { - if ((head.Value.Motion & ActionClassBit) != 0) - _state.RemoveActionHead(); - - _sink.MotionDone(head.Value.Motion, success); - _animationCounter -= (int)head.Value.NumAnims; - - _pendingAnimations.RemoveFirst(); - head = _pendingAnimations.First; - } - - // Drained-list counter reset: avoid drift once the queue is empty. - if (_animationCounter != 0 && head is null) - _animationCounter = 0; - } - - /// - /// CheckForCompletedMotions 0x0051be00 (@290645): pop every head - /// node ALREADY at NumAnims == 0 (zero-tick entries, or ones - /// neutered by ). Unlike - /// : no counter increment, no counter - /// decrement, success is hardcoded true. - /// - public void CheckForCompletedMotions() - { - var head = _pendingAnimations.First; - if (head is null) - return; - - while (head is not null && head.Value.NumAnims == 0) - { - if ((head.Value.Motion & ActionClassBit) != 0) - _state.RemoveActionHead(); - - _sink.MotionDone(head.Value.Motion, true); - - _pendingAnimations.RemoveFirst(); - head = _pendingAnimations.First; - } - } - - /// UseTime 0x0051bfd0 (@290845): per-frame entry point, - /// tailcalls . - public void UseTime() => CheckForCompletedMotions(); - - // ── initialize_state / HandleEnterWorld / HandleExitWorld ────────────── - - /// - /// initialize_state 0x0051c030 (@290875): install the motion - /// table's baseline state () and - /// queue the ("0x41000003" — initial - /// default-state-installed marker) with the resulting tick count, then - /// opportunistically collapse redundant links. - /// - public void InitializeState() - { - uint outTicks = 0; - if (_table is not null) - { - _table.SetDefaultState(_state, _sequence, out outTicks); - } - - AddToQueue(ReadySentinel, outTicks); - } - - /// - /// HandleEnterWorld 0x0051bdd0 (@290634): strip any stale link - /// animations from the live sequence - /// (), then fully drain - /// pending_animations exactly like — - /// each drained entry fires with - /// success:false via repeated calls - /// (decomp: while (head_ != 0) AnimationDone(this, 0)). - /// - public void HandleEnterWorld() - { - _sequence.RemoveAllLinkAnimations(); - DrainQueue(); - } - - /// - /// HandleExitWorld 0x0051bda0 (@290625): fully drain - /// pending_animations, signaling "failure/aborted" - /// (success:false) for every entry via repeated - /// calls. - /// - public void HandleExitWorld() => DrainQueue(); - - private void DrainQueue() - { - while (_pendingAnimations.First is not null) - AnimationDone(false); - } - - // ── PerformMovement ────────────────────────────────────────────────── - - /// - /// PerformMovement 0x0051c0b0 (@290906) — the single chokepoint - /// between a wire-level (interpreted - /// command / stop / stop-completely) and the motion-table state machine. - /// Error codes: 7 = no motion table loaded; 0x43 = - /// DoObjectMotion/StopObjectMotion returned failure; 0 = success. - /// Unhandled s (RawCommand, StopRawCommand, - /// MoveToObject/Position, TurnToObject/Heading) are NOT MotionTableManager's - /// job — decomp's BN artifact returns the CSequence pointer reinterpreted - /// as a code (§11 note: "likely dead/unreachable... never consulted"); the - /// C# port returns instead - /// of leaking a pointer value, since no caller in this codebase depends on - /// that BN quirk. - /// - public uint PerformMovement(MotionTableMovement movement) - { - if (_table is null) - return MotionTableManagerError.NoTable; // 7 - - uint outTicks; - - switch (movement.Type) - { - case MovementType.InterpretedCommand: - if (_table.DoObjectMotion(movement.Motion, _state, _sequence, movement.Speed, out outTicks)) - { - AddToQueue(movement.Motion, outTicks); - return MotionTableManagerError.Success; // 0 - } - return MotionTableManagerError.MotionFailed; // 0x43 - - case MovementType.StopInterpretedCommand: - if (_table.StopObjectMotion(movement.Motion, movement.Speed, _state, _sequence, out outTicks)) - { - AddToQueue(ReadySentinel, outTicks); - return MotionTableManagerError.Success; - } - return MotionTableManagerError.MotionFailed; - - case MovementType.StopCompletely: - _table.StopObjectCompletely(_state, _sequence, out outTicks); - AddToQueue(ReadySentinel, outTicks); // UNCONDITIONAL — queued regardless of return value. - return MotionTableManagerError.Success; - - default: - // RawCommand, StopRawCommand, and the MoveTo*/TurnTo* types are - // not MotionTableManager's job (decomp §11 note). - return MotionTableManagerError.NotHandled; - } - } -} - -/// -/// PerformMovement error/result codes (decomp §15 "PerformMovement -/// error codes"). Named constants standing in for retail's raw hex return -/// values, kept as plain to match -/// 's retail-verbatim return -/// type. -/// -public static class MotionTableManagerError -{ - /// 0 — success. - public const uint Success = 0u; - /// 7 — no motion table loaded. - public const uint NoTable = 7u; - /// 0x43 — DoObjectMotion/StopObjectMotion returned failure. - public const uint MotionFailed = 0x43u; - /// - /// C#-port-only sentinel for the "unhandled MovementType" default case. - /// Retail's BN decompile shows this leaking the CSequence pointer - /// reinterpreted as a return code (decomp §11 note, "likely dead/ - /// unreachable in practice"); no known caller depends on that value, so - /// the port returns this distinguishable constant instead of fabricating - /// a pointer-shaped number. - /// - public const uint NotHandled = 0xFFFFFFFFu; -} - -/// -/// Minimal retail-verbatim MovementStruct subset (acclient.h:38069) -/// needed by : just the -/// dispatch type, the motion id, and the speed scalar -/// (arg2->params->speed). Defined here rather than reusing -/// AcDream.Core.Physics.MotionInterpreter.MovementStruct because that -/// type serves a different (CMotionInterp-level) call site with fields -/// (ObjectId, Position, Autonomous, -/// ModifyInterpretedState/RawState) MotionTableManager never reads — -/// CLAUDE.md/plan instruction: do not modify MotionInterpreter.cs. -/// IS reused (its 5 values -/// match retail's MovementTypes::Type 1:1 for the cases -/// MotionTableManager handles). -/// -public readonly struct MotionTableMovement -{ - public readonly MovementType Type; - public readonly uint Motion; - public readonly float Speed; - - public MotionTableMovement(MovementType type, uint motion, float speed) - { - Type = type; - Motion = motion; - Speed = speed; - } - - public static MotionTableMovement Interpreted(uint motion, float speed) => - new(MovementType.InterpretedCommand, motion, speed); - - public static MotionTableMovement StopInterpreted(uint motion, float speed) => - new(MovementType.StopInterpretedCommand, motion, speed); - - public static MotionTableMovement StopCompletely() => - new(MovementType.StopCompletely, 0u, 1f); -} diff --git a/src/AcDream.Core/Physics/Motion/MotionTablePose.cs b/src/AcDream.Core/Physics/Motion/MotionTablePose.cs deleted file mode 100644 index 6acddd33..00000000 --- a/src/AcDream.Core/Physics/Motion/MotionTablePose.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System; -using System.Collections.Generic; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -namespace AcDream.Core.Physics.Motion; - -/// -/// #175: the motion table's DEFAULT-STATE part pose — the pose an idle -/// entity's parts hold (retail: CMotionTable::SetDefaultState -/// 0x005230a0 installs StyleDefaults[DefaultStyle]'s cycle; the parts -/// then sit at that animation's frames — the live CPhysicsPart pose -/// collision tests against). Used as the BSP shadow-shape part-pose override -/// at server-entity registration (doors: the CLOSED pose). -/// -/// -/// Cycle key arithmetic mirrors 's -/// LookupCycle (CMotionTable.cs:683): (style << 16) | -/// (substate & 0xFFFFFF) — the raw dat Cycles dictionary is -/// keyed by the COMBINED word, not the bare style (the first cut of this -/// helper looked up the bare style, always missed, and silently fell back -/// to placement frames — the #175 "not fixed" report). -/// -/// -public static class MotionTablePose -{ - /// - /// Resolve the default-state pose frames. Returns null (callers fall - /// back to placement frames) when the table has no default-style - /// substate, no matching cycle, or no animation. A pose covering FEWER - /// parts than the Setup is returned as-is — - /// falls back to the placement frame - /// PER PART beyond the override's length (e.g. a door anim that poses - /// only the panel parts, not the BSP-less frame header). - /// - /// The raw dat motion table (wire MotionTableId). - /// Animation loader (production: - /// id => dats.Get<Animation>(id)). - public static IReadOnlyList? DefaultStatePartFrames( - MotionTable mt, - Func loadAnimation) - { - if (mt is null) return null; - - // SetDefaultState: StyleDefaults[DefaultStyle] → the default substate. - if (!mt.StyleDefaults.TryGetValue(mt.DefaultStyle, out var defaultSubstateCmd)) - return null; - - // LookupCycle key (CMotionTable.cs:683 — same wrap semantics). - uint style = (uint)mt.DefaultStyle; - uint substate = (uint)defaultSubstateCmd; - int key = (int)((style << 16) | (substate & 0xFFFFFFu)); - - if (!mt.Cycles.TryGetValue(key, out var cycle) || cycle.Anims.Count == 0) - return null; - - var animRef = cycle.Anims[0]; - var anim = loadAnimation(animRef.AnimId); - if (anim is null || anim.PartFrames.Count == 0) return null; - - int idx = Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1); - var frames = anim.PartFrames[idx].Frames; - return frames.Count > 0 ? frames : null; - } -} diff --git a/src/AcDream.Core/Physics/Motion/MoveToManager.cs b/src/AcDream.Core/Physics/Motion/MoveToManager.cs deleted file mode 100644 index ac943c80..00000000 --- a/src/AcDream.Core/Physics/Motion/MoveToManager.cs +++ /dev/null @@ -1,1634 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; - -namespace AcDream.Core.Physics.Motion; - -// ───────────────────────────────────────────────────────────────────────────── -// MoveToManager — R4-V2 verbatim port of retail's MoveToManager -// (acclient.h:31473, struct #3462; docs/research/2026-07-03-r4-moveto/ -// r4-moveto-decomp.md, all 33 members, addresses 0x00529010-0x0052a987). -// -// Retail chain this class stands in for (r4-port-plan.md §4): -// MovementManager::PerformMovement (types 6-9) → MoveToManager::PerformMovement -// → MoveToManager entry points (MoveToObject/MoveToPosition/TurnToObject/ -// TurnToHeading) → node plan (pending_actions) → BeginNextNode → -// BeginMoveForward/BeginTurnToHeading → _DoMotion/_StopMotion → -// CMotionInterp.adjust_motion → DoInterpretedMotion/StopInterpretedMotion. -// -// MovementManager itself is R5 scope (r4-port-plan.md §5 "do-not-invent") — -// this class is bound DIRECTLY to the owning entity's MotionInterpreter by the -// (future) V4/V5 orchestrator; the type-6..9 dispatch stays at the existing -// GameWindow/controller call sites until R5 grows the relay. -// -// TargetManager / StickyManager / ConstraintManager / PositionManager::StickTo -// bodies were NOT extracted (call shapes only, decomp §9f/§9g) — R5 scope. -// This class exposes them as ctor-injected seams (Action/Func delegates), -// matching the MotionInterpreter Action? seam convention -// (UnstickFromObject/InterruptCurrentMovement/RemoveLinkAnimations/ -// InitializeMotionTables/CheckForCompletedMotions). -// ───────────────────────────────────────────────────────────────────────────── - -/// -/// R4-V2 — verbatim port of retail's MoveToManager (acclient.h:31473, -/// struct #3462). Drives an entity's chase/flee/turn-to/turn-to-heading -/// state machine: builds a small node plan (, -/// pending_actions), steps it every tick via , -/// and issues the resulting motion commands through the SAME -/// / -/// -/// seam every other interpreted motion uses (_DoMotion/_StopMotion, -/// §7a/§7b — MoveToManager NEVER calls DoMotion, set_hold_run, or -/// any raw-state API directly). -/// -/// -/// Construction contract (r4-port-plan.md §4): ctor-injected with the -/// entity's (the _DoMotion target) plus -/// a set of seam delegates standing in for retail's CPhysicsObj -/// position/heading/body-shape accessors and the not-yet-ported -/// TargetManager/StickyManager (R5). All seam delegates are REQUIRED -/// (non-nullable) except the three explicitly retail-optional ones -/// (, , ) -/// which mirror the no-op-until-bound convention already used by -/// / -/// . -/// -/// -/// -/// The "no physics_obj" guard. Retail gates almost every member on -/// this->physics_obj != 0 (a raw pointer that can be null before -/// SetPhysicsObject is called, or after the owning CPhysicsObj -/// is destroyed). This port models the SAME guard via -/// (settable, defaults true) rather than requiring callers to pass a nullable -/// body reference through every seam — the seam delegates themselves are -/// assumed valid whenever is true (matching -/// retail's single non-null pointer covering the whole entry-point family). -/// -/// -public sealed class MoveToManager -{ - private readonly MotionInterpreter _interp; - - // ── seam delegates (ctor-injected; r4-port-plan.md §4) ───────────────── - - /// Retail CPhysicsObj::StopCompletely — routes through - /// MovementStruct{type=5} to the SAME interp - /// ( pre-R5; direct call is - /// the same body). - private readonly Action _stopCompletely; - - /// Retail physics_obj->m_position read (world position - /// + cell id) — the CURRENT position, sampled fresh every call site that - /// reads myPos/curPos. - private readonly Func _getPosition; - - /// Retail CPhysicsObj::get_heading — current compass - /// heading, degrees (P5 convention). - private readonly Func _getHeading; - - /// Retail CPhysicsObj::set_heading(heading, send) — the - /// ONE heading snap in the whole family - /// ('s arrival snap, decomp §6c - /// @0052a146). send flags the outbound network echo (remotes: - /// no-op send per the wiring contract). - private readonly Action _setHeading; - - /// Retail CPhysicsObj::GetRadius — the MOVER's own - /// radius (feeds 's cylinder-distance - /// own-side argument). - private readonly Func _getOwnRadius; - - /// Retail CPhysicsObj::GetHeight — the MOVER's own - /// height. - private readonly Func _getOwnHeight; - - /// Retail physics_obj->transient_state & 1 — - /// CONTACT bit ('s tick gate, decomp §6a). - private readonly Func _contact; - - /// Retail CPhysicsObj::IsInterpolating → - /// PositionManager::IsInterpolating — consumed by the - /// fail-progress stall tests (decomp §6b/§6c). - private readonly Func _isInterpolating; - - /// Retail CPhysicsObj::get_velocity — feeds the Phase-3 - /// TargetManager quantum retune (decomp §6b Phase 3). - private readonly Func _getVelocity; - - /// Retail physics_obj->id — the mover's own object id - /// (self-target detection in MoveToObject/TurnToObject, §3b/§3d). - private readonly Func _getSelfId; - - /// Retail CPhysicsObj::set_target(context_id, object_id, - /// radius, quantum)TargetManager::SetTarget (call shape - /// only, decomp §9f — R5 owns the body). MoveToManager always passes - /// (0, top_level_object_id, 0.5f, 0.0). - private readonly Action _setTarget; - - /// Retail CPhysicsObj::clear_target → - /// TargetManager::ClearTarget (call shape only). - private readonly Action _clearTarget; - - /// Retail CPhysicsObj::get_target_quantum (call shape - /// only; ACE: returns TargetInfo.Quantum, default 0). - private readonly Func _getTargetQuantum; - - /// Retail CPhysicsObj::set_target_quantum → - /// TargetManager::SetTargetQuantum (call shape only). - private readonly Action _setTargetQuantum; - - /// Retail CPhysicsObj::unstick_from_object → - /// PositionManager::UnStick (call shape only, R5 body). Called at - /// the head of every (§3a). Optional — - /// null is a silent no-op, matching - /// 's convention. - public Action? Unstick { get; set; } - - /// Retail PositionManager::StickTo(object_id, radius, - /// height) (call shape only, R5 StickyManager body) — the sticky - /// arrival handoff in (§4b). Optional — - /// null is a silent no-op. - public Action? StickTo { get; set; } - - /// - /// CLIENT ADDITION — NOT retail (decomp §7e / do-not-invent list): - /// CleanUpAndCallWeenie contains no weenie call in this build - /// (the name is vestigial; body ≡ CleanUp + StopCompletely), and retail - /// notifies NOTHING on arrival (§4b's empty-queue completion is inline - /// CleanUp + StopCompletely). This seam stands in for ACE's server-side - /// OnMoveComplete notification so the App layer can re-anchor - /// AD-27 (Use/PickUp re-send on arrival). Fires with - /// on NATURAL COMPLETION — the - /// empty-queue exits (both sticky and - /// non-sticky) and 's instant-success - /// path — and NEVER from /plain - /// (a cancel is not an arrival; AD-27's re-send - /// must not fire on user interrupt). Optional — null is a silent no-op. - /// - public Action? MoveToComplete { get; set; } - - /// Retail Timer::cur_time — injectable clock (tests drive - /// this explicitly; production binds to the real wall/game clock). - /// Defaults to a monotonically-increasing stub if not overridden via the - /// ctor (every call site needs SOME value; production always supplies - /// one). - private readonly Func _curTime; - - /// - /// Retail's physics_obj != 0 guard, modeled as a settable flag - /// (see the class-level "no physics_obj" doc). Defaults true — most - /// production entities always have a body by construction time. - /// - public bool HasPhysicsObj { get; set; } = true; - - public MoveToManager( - MotionInterpreter interp, - Action stopCompletely, - Func getPosition, - Func getHeading, - Action setHeading, - Func getOwnRadius, - Func getOwnHeight, - Func contact, - Func isInterpolating, - Func getVelocity, - Func getSelfId, - Action setTarget, - Action clearTarget, - Func getTargetQuantum, - Action setTargetQuantum, - Func? curTime = null) - { - _interp = interp ?? throw new ArgumentNullException(nameof(interp)); - _stopCompletely = stopCompletely ?? throw new ArgumentNullException(nameof(stopCompletely)); - _getPosition = getPosition ?? throw new ArgumentNullException(nameof(getPosition)); - _getHeading = getHeading ?? throw new ArgumentNullException(nameof(getHeading)); - _setHeading = setHeading ?? throw new ArgumentNullException(nameof(setHeading)); - _getOwnRadius = getOwnRadius ?? throw new ArgumentNullException(nameof(getOwnRadius)); - _getOwnHeight = getOwnHeight ?? throw new ArgumentNullException(nameof(getOwnHeight)); - _contact = contact ?? throw new ArgumentNullException(nameof(contact)); - _isInterpolating = isInterpolating ?? throw new ArgumentNullException(nameof(isInterpolating)); - _getVelocity = getVelocity ?? throw new ArgumentNullException(nameof(getVelocity)); - _getSelfId = getSelfId ?? throw new ArgumentNullException(nameof(getSelfId)); - _setTarget = setTarget ?? throw new ArgumentNullException(nameof(setTarget)); - _clearTarget = clearTarget ?? throw new ArgumentNullException(nameof(clearTarget)); - _getTargetQuantum = getTargetQuantum ?? throw new ArgumentNullException(nameof(getTargetQuantum)); - _setTargetQuantum = setTargetQuantum ?? throw new ArgumentNullException(nameof(setTargetQuantum)); - - double t = 0.0; - _curTime = curTime ?? (() => t += 1.0 / 30.0); - - InitializeLocalVariables(); - } - - // ── state block (retail acclient.h:31473 field-for-field) ────────────── - - /// +0x00 movement_type. - public MovementType MovementTypeState { get; private set; } = MovementType.Invalid; - - /// - /// Retail's default-constructed Position (identity - /// CellFrame, cell id 0) — NOT C#'s default(Position), - /// whose default(Quaternion) is the ZERO quaternion (0,0,0,0), - /// not the identity rotation (0,0,0,1). GetHeading on a zero - /// quaternion is degenerate (does not read as "heading 0" — the - /// underlying Vector3.Transform collapses to zero). Retail's - /// ctor (§1a) and (§1c) both - /// reset these fields to a genuine identity frame. - /// - private static readonly Position IdentityPosition = new(0u, Vector3.Zero, Quaternion.Identity); - - /// +0x04 sought_position. - public Position SoughtPosition { get; private set; } = IdentityPosition; - - /// +0x4C current_target_position. - public Position CurrentTargetPosition { get; private set; } = IdentityPosition; - - /// +0x94 starting_position. - public Position StartingPosition { get; private set; } = IdentityPosition; - - /// +0xDC movement_params (the 10-field copy every entry - /// point performs). - public MovementParameters Params { get; private set; } = new(); - - /// +0x108 previous_heading. - public float PreviousHeading { get; private set; } - - /// +0x10C previous_distance. - public float PreviousDistance { get; private set; } - - /// +0x110 previous_distance_time. - public double PreviousDistanceTime { get; private set; } - - /// +0x118 original_distance. - public float OriginalDistance { get; private set; } - - /// +0x120 original_distance_time. - public double OriginalDistanceTime { get; private set; } - - /// +0x128 fail_progress_count — WRITE-ONLY (decomp §8; - /// do-not-invent: no give-up threshold exists in retail). Exposed for - /// conformance-test inspection only. - public uint FailProgressCount { get; private set; } - - /// +0x12C sought_object_id. - public uint SoughtObjectId { get; private set; } - - /// +0x130 top_level_object_id. - public uint TopLevelObjectId { get; private set; } - - /// +0x134 sought_object_radius. - public float SoughtObjectRadius { get; private set; } - - /// +0x138 sought_object_height. - public float SoughtObjectHeight { get; private set; } - - /// +0x13C current_command. - public uint CurrentCommand { get; private set; } - - /// +0x140 aux_command. - public uint AuxCommand { get; private set; } - - /// +0x144 moving_away. - public bool MovingAway { get; private set; } - - /// +0x148 initialized. - public bool Initialized { get; private set; } - - /// +0x14C/+0x150 pending_actions (DLList) — ported as a - /// managed of , tail-append - /// / head-pop (matching InsertAfter/RemovePendingActionsHead - /// shape). Exposed read-only for conformance tests. - private readonly LinkedList _pendingActions = new(); - - /// Read-only inspection surface (tests): the node queue in - /// head-to-tail order. - public IEnumerable PendingActions => _pendingActions; - - // ── 1. Creation / lifecycle ───────────────────────────────────────────── - - /// - /// Retail MoveToManager::InitializeLocalVariables - /// (00529250, raw 306490-306534). VERBATIM per decomp §1c: zeroes - /// (Invalid) and the params BITFIELD + - /// CONTEXT ID ONLY (movement_params.__inner0 = 0; - /// movement_params.context_id = 0; — NOT ACE's A2/A3 - /// full-struct-reset transposition; the scalar param fields keep their - /// STALE values because every entry point copies all ten fields anyway), - /// resets both progress-clock pairs to FLT_MAX/now ( - /// /), - /// =0, =0, - /// /=0, - /// /=false, resets - /// / to - /// cell 0 + identity frame ( is NOT - /// touched here), and zeroes / - /// // - /// . Does NOT drain - /// — callers (/ - /// ) drain first. - /// - public void InitializeLocalVariables() - { - MovementTypeState = MovementType.Invalid; - - // movement_params.__inner0 = 0 (bitfield CLEARED, not re-defaulted); - // movement_params.context_id = 0. Scalars stay whatever they were — - // model this by clearing only the bitfield-backed bools + ContextId - // on the EXISTING Params instance (do not replace it — a replace - // would also reset the scalars, diverging from retail's field-level - // clear). - Params.CanWalk = false; - Params.CanRun = false; - Params.CanSidestep = false; - Params.CanWalkBackwards = false; - Params.CanCharge = false; - Params.FailWalk = false; - Params.UseFinalHeading = false; - Params.Sticky = false; - Params.MoveAway = false; - Params.MoveTowards = false; - Params.UseSpheres = false; - Params.SetHoldKey = false; - Params.Autonomous = false; - Params.ModifyRawState = false; - Params.ModifyInterpretedState = false; - Params.CancelMoveTo = false; - Params.StopCompletelyFlag = false; - Params.DisableJumpDuringLink = false; - Params.ContextId = 0; - - PreviousDistance = float.MaxValue; - PreviousDistanceTime = _curTime(); - OriginalDistance = float.MaxValue; - OriginalDistanceTime = _curTime(); - - PreviousHeading = 0f; - FailProgressCount = 0; - CurrentCommand = 0; - AuxCommand = 0; - MovingAway = false; - Initialized = false; - - SoughtPosition = IdentityPosition; - CurrentTargetPosition = IdentityPosition; - - SoughtObjectId = 0; - TopLevelObjectId = 0; - SoughtObjectRadius = 0f; - SoughtObjectHeight = 0f; - } - - /// - /// Retail MoveToManager::Destroy (005294b0, raw - /// 306618-306663): drains then tailcalls - /// . - /// - public void Destroy() - { - _pendingActions.Clear(); - InitializeLocalVariables(); - } - - /// - /// Retail MoveToManager::is_moving_to (00529220, raw - /// 306464-306470): movement_type != Invalid. - /// - public bool IsMovingTo() => MovementTypeState != MovementType.Invalid; - - // ── 3. Entry points (movement_type setters) ───────────────────────────── - - /// - /// Retail MoveToManager::PerformMovement (0052a900, raw - /// 307871-307904). VERBATIM per decomp §3a: cancels any in-flight moveto - /// ( with - /// 0x36) and unsticks FIRST, unconditionally, before the 4-way type - /// dispatch (MoveToObject/MoveToPosition/TurnToObject/TurnToHeading). - /// Always returns — moveto errors surface - /// via , never via this return value (decomp - /// §2b note, carried through §3a). - /// - public WeenieError PerformMovement(MovementStruct mvs) - { - CancelMoveTo(WeenieError.ActionCancelled); - Unstick?.Invoke(); - - switch (mvs.Type) - { - case MovementType.MoveToObject: - MoveToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Radius, mvs.Height, mvs.Params ?? new MovementParameters()); - break; - case MovementType.MoveToPosition: - MoveToPosition(mvs.Pos, mvs.Params ?? new MovementParameters()); - break; - case MovementType.TurnToObject: - TurnToObject(mvs.ObjectId, mvs.TopLevelId, mvs.Params ?? new MovementParameters()); - break; - case MovementType.TurnToHeading: - TurnToHeading(mvs.Params ?? new MovementParameters()); - break; - } - - return WeenieError.None; - } - - /// - /// Retail MoveToManager::MoveToObject (00529680, raw - /// 306756-306817). VERBATIM per decomp §3b: StopCompletely, snapshot - /// , store sought object id/radius/height, - /// set =MoveToObject, - /// , copy all ten - /// fields into , =false. - /// Self-target ( == the mover's own id) → - /// + StopCompletely (degenerate instant no-op). - /// Otherwise → (0, topLevelId, 0.5f, 0.0) — the - /// P4 TargetTracker seam. NO nodes are queued yet — object moves are - /// deferred until the first callback - /// (§6). NOTE: is PRESERVED here - /// (unlike position/turn-heading moves, which force it off — §3c/§3e). - /// - public void MoveToObject(uint objectId, uint topLevelId, float radius, float height, MovementParameters p) - { - if (!HasPhysicsObj) return; - - _stopCompletely(); - StartingPosition = _getPosition(); - SoughtObjectId = objectId; - SoughtObjectRadius = radius; - SoughtObjectHeight = height; - MovementTypeState = MovementType.MoveToObject; - TopLevelObjectId = topLevelId; - CopyParams(p); - Initialized = false; - - if (topLevelId == _getSelfId()) - { - CleanUp(); - _stopCompletely(); - } - else - { - _setTarget(0, TopLevelObjectId, 0.5f, 0.0); - } - } - - /// - /// Retail MoveToManager::MoveToPosition (0052a240, raw - /// 307521-307593). VERBATIM per decomp §3c: StopCompletely, snapshot - /// =, - /// =0, compute - /// , compute the heading-to-target minus - /// current heading (epsilon-snapped/wrapped, same [0,360) normalization - /// used throughout), to see - /// if ANY motion is needed — if so, queue - /// [TurnToHeading(face target)] → [MoveToPosition]. If - /// (0x40) is set, ALSO - /// queue a final TurnToHeading(desired_heading) (absolute, unlike - /// 's relative form). Then snapshot - /// /, set - /// =MoveToPosition, copy all ten - /// fields into , CLEAR the - /// sticky bit (0x80 — position moves and heading turns can never stick; - /// only object moves preserve it), then . - /// - /// - /// Reads the ARGUMENT's - /// (), NOT a stale member — the do-not-invent list's - /// "A1 stale-member UseFinalHeading read" ACE-ism is explicitly NOT - /// copied here. - /// - /// - public void MoveToPosition(Position target, MovementParameters p) - { - if (!HasPhysicsObj) return; - - _stopCompletely(); - CurrentTargetPosition = target; - SoughtObjectRadius = 0f; - - float dist = GetCurrentDistance(); - - Vector3 myPos = _getPosition().Frame.Origin; - Vector3 targetPos = target.Frame.Origin; - float toTargetHeading = MoveToMath.PositionHeading(myPos, targetPos); - float headingDiff = toTargetHeading - _getHeading(); - if (MathF.Abs(headingDiff) < MoveToMath.Epsilon) headingDiff = 0f; - if (headingDiff < -MoveToMath.Epsilon) headingDiff += 360f; - - p.GetCommand(dist, headingDiff, out uint cmd, out _, out _); - if (cmd != 0) - { - AddTurnToHeadingNode(toTargetHeading); - AddMoveToPositionNode(); - } - - if (p.UseFinalHeading) - { - AddTurnToHeadingNode(p.DesiredHeading); - } - - SoughtPosition = target; - StartingPosition = _getPosition(); - MovementTypeState = MovementType.MoveToPosition; - CopyParams(p); - Params.Sticky = false; // __inner0 &= 0xffffff7f - - BeginNextNode(); - } - - /// - /// Retail MoveToManager::TurnToObject (005297d0, raw - /// 306820-306882). VERBATIM per decomp §3d, INCLUDING the desired-heading - /// clobber quirk: .StopCompletelyFlag's underlying bit - /// (0x10000) — here, — - /// gates the StopCompletely call CONDITIONALLY (unlike MoveToObject/ - /// MoveToPosition's UNCONDITIONAL stop). Seeds - /// current_target_position.frame's heading from - /// .DesiredHeading — but this write is DISCARDED: - /// later overwrites - /// wholesale and reads - /// 's heading instead (which is 0 after - /// for a fresh manager). This is a - /// RETAIL QUIRK (ACE MoveToManager.cs:246 matches verbatim) — do NOT - /// "fix" it; the effective final heading is simply "face the object". - /// Self-target → + StopCompletely. Otherwise → - /// =false + (0, - /// topLevelId, 0.5f, 0.0). Deferred like MoveToObject — no nodes queued - /// here; queues on the first target - /// callback. - /// - public void TurnToObject(uint objectId, uint topLevelId, MovementParameters p) - { - if (!HasPhysicsObj) return; - - if (p.StopCompletelyFlag) - { - _stopCompletely(); - } - - MovementTypeState = MovementType.TurnToObject; - SoughtObjectId = objectId; - - // Frame::set_heading(¤t_target_position.frame, desired_heading) - // — clobbered before any read; see the quirk note above. - CurrentTargetPosition = CurrentTargetPosition with - { - Frame = new CellFrame( - CurrentTargetPosition.Frame.Origin, - MoveToMath.SetHeading(CurrentTargetPosition.Frame.Orientation, p.DesiredHeading)), - }; - - TopLevelObjectId = topLevelId; - CopyParams(p); - - if (topLevelId == _getSelfId()) - { - CleanUp(); - _stopCompletely(); - } - else - { - Initialized = false; - _setTarget(0, topLevelId, 0.5f, 0.0); - } - } - - /// - /// Retail MoveToManager::TurnToHeading (0052a630, raw - /// 307706-307772). VERBATIM per decomp §3e: conditional StopCompletely - /// (0x10000 stop_completely bit, same shape as - /// ), copy all ten fields, - /// CLEAR sticky (0x80), seed 's frame heading - /// from .DesiredHeading, set - /// =TurnToHeading, queue ONE - /// (type TurnToHeading, heading=DesiredHeading) - /// directly (inlined AddTurnToHeadingNode shape — same effect), - /// then IMMEDIATE — unlike ACE's A4 gap - /// (one-tick-late), retail calls BeginNextNode synchronously - /// inside this entry point. is NOT set here - /// (stays whatever it was) — the gate (§6a) passes - /// anyway because is 0 for non-object - /// moves (TurnToHeading never sets it). - /// - public void TurnToHeading(MovementParameters p) - { - if (!HasPhysicsObj) return; - - if (p.StopCompletelyFlag) - { - _stopCompletely(); - } - - CopyParams(p); - Params.Sticky = false; // __inner0 &= 0xffffff7f - - SoughtPosition = SoughtPosition with - { - Frame = new CellFrame( - SoughtPosition.Frame.Origin, - MoveToMath.SetHeading(SoughtPosition.Frame.Orientation, p.DesiredHeading)), - }; - - MovementTypeState = MovementType.TurnToHeading; - - _pendingActions.AddLast(new MoveToNode(MovementType.TurnToHeading, p.DesiredHeading)); - - BeginNextNode(); - } - - // ── 4. Node stepping ───────────────────────────────────────────────────── - - /// - /// Retail MoveToManager::AddTurnToHeadingNode (00529530, - /// raw 306667-306685): tail-append a TurnToHeading node. - /// - public void AddTurnToHeadingNode(float heading) - => _pendingActions.AddLast(new MoveToNode(MovementType.TurnToHeading, heading)); - - /// - /// Retail MoveToManager::AddMoveToPositionNode (00529580, - /// raw 306689-306706): tail-append a MoveToPosition node (heading - /// unused). - /// - public void AddMoveToPositionNode() - => _pendingActions.AddLast(new MoveToNode(MovementType.MoveToPosition, 0f)); - - /// - /// Retail MoveToManager::RemovePendingActionsHead - /// (00529380, raw 306538-306550): unlink + delete the head node. - /// - public void RemovePendingActionsHead() - { - if (_pendingActions.First is not null) - _pendingActions.RemoveFirst(); - } - - /// - /// Retail MoveToManager::BeginNextNode (00529cb0, raw - /// 307123-307171). VERBATIM per decomp §4b — THE STICKY HANDOFF: if a - /// node exists, tailcall (type - /// MoveToPosition) or (type - /// TurnToHeading); an unknown node type stalls (defensive, matches the - /// raw's if/if shape — no else fallthrough). Empty queue = - /// moveto complete: if (byte0 - /// sign bit 0x80) is set, READ / - /// / - /// BEFORE (which zeroes them), THEN CleanUp, THEN - /// StopCompletely, THEN hand off to (R5 - /// StickyManager seam) with the pre-CleanUp values. Non-sticky empty - /// queue: CleanUp + StopCompletely only. - /// - public void BeginNextNode() - { - if (_pendingActions.First is not null) - { - MovementType type = _pendingActions.First.Value.Type; - if (type == MovementType.MoveToPosition) - { - BeginMoveForward(); - return; - } - if (type == MovementType.TurnToHeading) - { - BeginTurnToHeading(); - return; - } - return; // unknown node type: stall (defensive) - } - - if (Params.Sticky) - { - float height = SoughtObjectHeight; - float radius = SoughtObjectRadius; - uint tlid = TopLevelObjectId; - - CleanUp(); - if (HasPhysicsObj) _stopCompletely(); - - StickTo?.Invoke(tlid, radius, height); - // CLIENT ADDITION (see MoveToComplete doc): natural completion. - // Reentrancy-safe: CleanUp reset movement_type to Invalid BEFORE - // the stop, so the stop's interrupt→CancelMoveTo chain no-oped. - MoveToComplete?.Invoke(WeenieError.None); - return; - } - - CleanUp(); - if (HasPhysicsObj) _stopCompletely(); - // CLIENT ADDITION (see MoveToComplete doc): natural completion. - MoveToComplete?.Invoke(WeenieError.None); - } - - /// - /// Retail MoveToManager::BeginMoveForward (00529a00, raw - /// 306957-307042). VERBATIM per decomp §4c: no physics_obj → - /// (). - /// Compute distance + heading-diff-to-target (same epsilon - /// snap/normalize as ), then - /// . If no motion is needed - /// (already in range) — pop the head node and - /// . Otherwise build a fresh LOCAL params - /// (defaults; CLEARED so - /// _DoMotion doesn't cancel THIS moveto; speed copied from - /// ), issue via — on error, - /// with that error and return. On success, - /// record /, write - /// the CHOSEN hold key BACK to the stored - /// (HoldKeyToApply), and seed BOTH progress-clock pairs - /// (/ = dist, - /// both times = now). - /// - public void BeginMoveForward() - { - if (!HasPhysicsObj) - { - CancelMoveTo(WeenieError.NoPhysicsObject); - return; - } - - float dist = GetCurrentDistance(); - - Vector3 myPos = _getPosition().Frame.Origin; - Vector3 targetPos = CurrentTargetPosition.Frame.Origin; - float heading = MoveToMath.PositionHeading(myPos, targetPos) - _getHeading(); - if (MathF.Abs(heading) < MoveToMath.Epsilon) heading = 0f; - if (heading < -MoveToMath.Epsilon) heading += 360f; - - Params.GetCommand(dist, heading, out uint cmd, out HoldKey holdKey, out bool movingAway); - - if (cmd == 0) - { - RemovePendingActionsHead(); - BeginNextNode(); - return; - } - - var localParams = new MovementParameters - { - HoldKeyToApply = holdKey, - CancelMoveTo = false, // bitfield &= 0xffff7fff - Speed = Params.Speed, - }; - - WeenieError err = _DoMotion(cmd, localParams); - if (err != WeenieError.None) - { - CancelMoveTo(err); - return; - } - - CurrentCommand = cmd; - MovingAway = movingAway; - Params.HoldKeyToApply = holdKey; - PreviousDistance = dist; - PreviousDistanceTime = _curTime(); - OriginalDistance = dist; - OriginalDistanceTime = _curTime(); - } - - /// - /// Retail MoveToManager::BeginTurnToHeading (00529b90, raw - /// 307046-307120). VERBATIM per decomp §4d: empty queue OR no - /// physics_obj → (NoPhysicsObject, per A10 — - /// NOT ACE's throw). If animations are still pending - /// (), WAIT (return — do - /// not start the turn yet). Otherwise read the head node's heading, call - /// with the CONSTANT - /// (mirror explicitly disabled — - /// direction pick uses the raw ≤180 test, not the mirrored value): - /// diff > 180 → check "already there" (diff + eps >= - /// 360) and pop+advance if so, else TurnLeft; diff <= 180 - /// → check "already there" (diff <= eps) and pop+advance if - /// so, else TurnRight. Issue the turn via with a - /// fresh local params (CancelMoveTo cleared, speed + hold_key copied - /// from ) — on error, . On - /// success, record and store the REMAINING - /// DIFF (not a heading!) into — THE QUIRK: - /// 's progress test then compares a - /// live HEADING against this DIFF-shaped seed on its first tick. Keep - /// verbatim (ACE matches: PreviousHeading = diff). - /// - public void BeginTurnToHeading() - { - if (_pendingActions.First is null || !HasPhysicsObj) - { - CancelMoveTo(WeenieError.NoPhysicsObject); - return; - } - - if (_interp.MotionsPending()) - { - return; - } - - float targetHeading = _pendingActions.First.Value.Heading; - float diff = MoveToMath.HeadingDiff(targetHeading, _getHeading(), MotionCommand.TurnRight); - - uint turn; - if (diff > 180f) - { - if (diff + MoveToMath.Epsilon >= 360f) - { - RemovePendingActionsHead(); - BeginNextNode(); - return; - } - turn = MotionCommand.TurnLeft; - } - else - { - if (diff <= MoveToMath.Epsilon) - { - RemovePendingActionsHead(); - BeginNextNode(); - return; - } - turn = MotionCommand.TurnRight; - } - - var localParams = new MovementParameters - { - CancelMoveTo = false, - Speed = Params.Speed, - HoldKeyToApply = Params.HoldKeyToApply, - }; - - WeenieError err = _DoMotion(turn, localParams); - if (err != WeenieError.None) - { - CancelMoveTo(err); - return; - } - - CurrentCommand = turn; - PreviousHeading = diff; // NOTE: the remaining DIFF, not a heading — retail quirk, keep verbatim. - } - - // ── 5. Distance / progress / command-selection helpers ───────────────── - - /// - /// Retail MoveToManager::GetCurrentDistance (005291b0, raw - /// 306435-306460). VERBATIM per decomp §5a: no physics_obj → 0 (retail's - /// x87-garbled void return; modeled as 0 since every caller only uses - /// this when is already known true). When - /// (0x400) is NOT set → plain - /// center (Euclidean) distance to . - /// When SET → using the - /// mover's own radius/height (/ - /// ) vs the stored - /// / — - /// object moves (which set sought radius/height and get use_spheres on - /// the wire) use edge-to-edge distance; position moves use center - /// distance. - /// - public float GetCurrentDistance() - { - if (!HasPhysicsObj) return 0f; - - Vector3 myPos = _getPosition().Frame.Origin; - Vector3 targetPos = CurrentTargetPosition.Frame.Origin; - - if (!Params.UseSpheres) - { - return Vector3.Distance(myPos, targetPos); - } - - return MoveToMath.CylinderDistance( - _getOwnRadius(), _getOwnHeight(), myPos, - SoughtObjectRadius, SoughtObjectHeight, targetPos); - } - - /// - /// Retail MoveToManager::CheckProgressMade (005290f0, raw - /// 306385-306431). VERBATIM per decomp §5b: evaluated only after a - /// 1-SECOND window since (before that, - /// returns true — "OK, too soon to judge"). Progress = distance closed - /// (or opened, when ) since the last checkpoint; - /// requires BOTH the incremental rate (since - /// ) AND the overall rate (since - /// ) to be ≥ 0.25 units/second. The - /// incremental checkpoint (/ - /// ) only advances when the - /// incremental rate passes. - /// - public bool CheckProgressMade(float currentDistance) - { - double elapsed = _curTime() - PreviousDistanceTime; - if (elapsed <= 1.0) return true; - - float progress = MovingAway - ? currentDistance - PreviousDistance - : PreviousDistance - currentDistance; - - if (progress / (float)elapsed >= 0.25f) - { - PreviousDistance = currentDistance; - PreviousDistanceTime = _curTime(); - - float total = MovingAway - ? currentDistance - OriginalDistance - : OriginalDistance - currentDistance; - float totalRate = total / (float)(_curTime() - OriginalDistanceTime); - - if (totalRate >= 0.25f) return true; - } - - return false; - } - - // ── 6. Per-tick drivers + target updates ──────────────────────────────── - - /// - /// Retail MoveToManager::UseTime (0052a780, raw - /// 307776-307798) — THE TICK GATE. VERBATIM per decomp §6a: three gates, - /// ALL must pass: (1) grounded — () (CONTACT - /// transient-state bit; no moveto progress mid-air, - /// resumes on landing); (2) a pending node exists; (3) object-moves - /// ( != 0 AND - /// != Invalid) must be - /// — i.e. the first target update has arrived. - /// Dispatches to (node type 7) or - /// (node type 9). - /// - public void UseTime() - { - if (!HasPhysicsObj || !_contact()) return; - - if (_pendingActions.First is null) return; - - bool objectMoveGate = TopLevelObjectId == 0 || MovementTypeState == MovementType.Invalid || Initialized; - if (!objectMoveGate) return; - - MovementType type = _pendingActions.First.Value.Type; - if (type == MovementType.MoveToPosition) - { - HandleMoveToPosition(); - } - else if (type == MovementType.TurnToHeading) - { - HandleTurnToHeading(); - } - } - - /// - /// Retail MoveToManager::HandleMoveToPosition (00529d80, - /// raw 307187-307438) — the big per-tick driver. VERBATIM per decomp - /// §6b, three phases: - /// - /// Phase 1 — aux turn steering (only when NOT - /// animating, i.e. is - /// false): compute the desired heading - /// ( offset + - /// heading-to-target, wrapped [0,360)) minus current heading - /// (epsilon-snapped/normalized). If inside the [0,20]∪[340,360) deadband, - /// stop any active aux turn. Otherwise pick TurnLeft (diff ≥ 180) or - /// TurnRight, and issue it via ONLY if it differs - /// from the currently-running (no redundant - /// re-issue). While animating, stop any active aux turn instead (can't - /// steer mid-animation). - /// Phase 2 — arrival / progress: compute - /// distance, run . On NO progress - /// (returns false): increment (write-only, - /// §8) ONLY if neither interpolating nor animating (a stall is only - /// "real" when nothing else explains the lack of motion). On progress - /// (true): reset the fail counter, then test arrival — - /// moving_away ? dist >= MinDistance : dist <= DistanceToObject. - /// NOT arrived: check against - /// the distance traveled from — - /// (0x3D) if exceeded. ARRIVED: - /// pop the head node, stop the current+aux motions, clear both command - /// fields, . - /// Phase 3 — TargetManager quantum retune - /// (object moves only, gated the SAME way as the UseTime object-move - /// gate): while chasing an object faster than 0.1 units/s, retune the - /// tracker's update quantum to the estimated time-to-arrival - /// (distance/speed) whenever it drifts ≥1 second from the current - /// quantum. - /// - /// ACE-divergence trap (do-not-invent): retail has NO - /// set_heading call anywhere in this method (ACE's "custom: sync - /// for server ticrate" addition is NOT ported). - /// - public void HandleMoveToPosition() - { - if (!HasPhysicsObj) - { - CancelMoveTo(WeenieError.NoPhysicsObject); - return; - } - - Vector3 curPos = _getPosition().Frame.Origin; - - var localParams = new MovementParameters - { - Speed = Params.Speed, - CancelMoveTo = false, - HoldKeyToApply = Params.HoldKeyToApply, - }; - - // ---- PHASE 1: aux turn steering ---- - if (_interp.MotionsPending()) - { - if (AuxCommand != 0) - { - _StopMotion(AuxCommand, localParams); - AuxCommand = 0; - } - } - else - { - Vector3 targetPos = CurrentTargetPosition.Frame.Origin; - float toTargetHeading = MoveToMath.PositionHeading(curPos, targetPos); - float heading = Params.GetDesiredHeading(CurrentCommand, MovingAway) + toTargetHeading; - if (heading >= 360f) heading -= 360f; - - float diff = heading - _getHeading(); - if (MathF.Abs(diff) < MoveToMath.Epsilon) diff = 0f; - if (diff < -MoveToMath.Epsilon) diff += 360f; - - if (diff <= 20f || diff >= 340f) - { - if (AuxCommand != 0) - { - _StopMotion(AuxCommand, localParams); - AuxCommand = 0; - } - } - else - { - uint turn = diff >= 180f ? MotionCommand.TurnLeft : MotionCommand.TurnRight; - if (turn != AuxCommand) - { - _DoMotion(turn, localParams); - AuxCommand = turn; - } - } - } - - // ---- PHASE 2: arrival / progress ---- - float dist = GetCurrentDistance(); - if (!CheckProgressMade(dist)) - { - if (!_isInterpolating() && !_interp.MotionsPending()) - { - FailProgressCount += 1; - } - } - else - { - FailProgressCount = 0; - - bool arrived = MovingAway - ? dist >= Params.MinDistance - : dist <= Params.DistanceToObject; - - if (!arrived) - { - float startDist = Vector3.Distance(StartingPosition.Frame.Origin, _getPosition().Frame.Origin); - if (startDist > Params.FailDistance) - { - CancelMoveTo(WeenieError.YouChargedTooFar); - } - } - else - { - RemovePendingActionsHead(); - _StopMotion(CurrentCommand, localParams); - CurrentCommand = 0; - if (AuxCommand != 0) - { - _StopMotion(AuxCommand, localParams); - AuxCommand = 0; - } - BeginNextNode(); - } - } - - // ---- PHASE 3: TargetManager quantum retune (object moves only) ---- - if (TopLevelObjectId != 0 && MovementTypeState != MovementType.Invalid) - { - Vector3 v = _getVelocity(); - float speed = v.Length(); - if (speed > 0.1) - { - float eta = dist / speed; - if (MathF.Abs(eta - (float)_getTargetQuantum()) >= 1.0f) - { - _setTargetQuantum(eta); - } - } - } - } - - /// - /// Retail MoveToManager::HandleTurnToHeading (0052a0c0, raw - /// 307442-307517). VERBATIM per decomp §6c: no physics_obj → - /// (NoPhysicsObject). If NOT currently turning - /// ( isn't TurnLeft/TurnRight) — re-enter - /// (arms the turn). Otherwise: if - /// says the current heading has - /// PASSED the node's target heading (direction-aware per - /// ) — reset the fail counter, SNAP the - /// heading to the node's exact value via - /// (send:true — THE ONE heading snap in the whole family, decomp - /// §6c @0052a146), pop the node, stop the current motion, clear - /// , . Otherwise — - /// still turning: compute between - /// the LIVE current heading and (the - /// quirk-seeded DIFF from ), passing the - /// LIVE (can be TurnLeft — the mirror DOES - /// apply here, P3). If the result is inside (eps, 180) — rotational - /// progress was made: reset fail counter, update - /// to the LIVE heading. Otherwise — no - /// progress: update anyway, then increment - /// ONLY if neither interpolating nor - /// animating. - /// - public void HandleTurnToHeading() - { - if (!HasPhysicsObj) - { - CancelMoveTo(WeenieError.NoPhysicsObject); - return; - } - - uint cmd = CurrentCommand; - if (cmd != MotionCommand.TurnLeft && cmd != MotionCommand.TurnRight) - { - BeginTurnToHeading(); - return; - } - - MoveToNode head = _pendingActions.First!.Value; - float heading = _getHeading(); - - if (MoveToMath.HeadingGreater(heading, head.Heading, cmd)) - { - FailProgressCount = 0; - _setHeading(head.Heading, true); - RemovePendingActionsHead(); - - var localParams = new MovementParameters - { - CancelMoveTo = false, - HoldKeyToApply = Params.HoldKeyToApply, - }; - _StopMotion(CurrentCommand, localParams); - CurrentCommand = 0; - BeginNextNode(); - return; - } - - float diff = MoveToMath.HeadingDiff(heading, PreviousHeading, cmd); - if (diff < 180f && diff > MoveToMath.Epsilon) - { - FailProgressCount = 0; - PreviousHeading = heading; - return; - } - - PreviousHeading = heading; - if (!_isInterpolating() && !_interp.MotionsPending()) - { - FailProgressCount += 1; - } - } - - /// - /// Retail MoveToManager::HandleUpdateTarget (0052a7d0, raw - /// 307802-307867). VERBATIM per decomp §6d — the P4 TargetTracker feed - /// point: arrives at CONTEXT 0 only (the caller's - /// responsibility — CPhysicsObj::HandleUpdateTarget gates on - /// context_id == 0 before relaying, decomp §9f; not re-checked - /// here). No physics_obj → (NoPhysicsObject). - /// Ignore updates for any target other than - /// . TWO paths: - /// - /// Deferred start ( - /// false — the FIRST callback): self-target → snapshot both positions to - /// the mover's own current position and - /// (None) — instant success. Non-OK - /// status → ( - /// 0x38 — the target never resolved). Otherwise build the node plan via - /// (MoveToObject) or - /// (TurnToObject). - /// Retarget while running - /// ( true): non-OK status → - /// ( 0x37 — - /// was tracked, now gone). Otherwise, for MoveToObject ONLY: update - /// =interpolated, - /// =target, and RESET both - /// progress-clock pairs to FLT_MAX/now. Does NOT requeue nodes — the - /// running MoveToPosition node keeps steering toward the moved - /// each tick (Phase 1 of - /// ). TurnToObject gets NO retarget - /// handling (heading was frozen at the initial - /// callback). - /// - /// - public void HandleUpdateTarget(TargetInfo info) - { - if (!HasPhysicsObj) - { - CancelMoveTo(WeenieError.NoPhysicsObject); - return; - } - - if (TopLevelObjectId != info.ObjectId) return; - - if (!Initialized) - { - if (TopLevelObjectId == _getSelfId()) - { - Position selfPos = _getPosition(); - SoughtPosition = selfPos; - CurrentTargetPosition = selfPos; - CleanUpAndCallWeenie(WeenieError.None); - return; - } - - if (info.Status != TargetStatus.Ok) - { - CancelMoveTo(WeenieError.NoObject); - return; - } - - if (MovementTypeState == MovementType.MoveToObject) - { - MoveToObject_Internal(info.TargetPosition, info.InterpolatedPosition); - } - else if (MovementTypeState == MovementType.TurnToObject) - { - TurnToObject_Internal(info.TargetPosition); - } - } - else - { - if (info.Status != TargetStatus.Ok) - { - CancelMoveTo(WeenieError.ObjectGone); - return; - } - - if (MovementTypeState == MovementType.MoveToObject) - { - SoughtPosition = info.InterpolatedPosition; - CurrentTargetPosition = info.TargetPosition; - PreviousDistance = float.MaxValue; - PreviousDistanceTime = _curTime(); - OriginalDistance = float.MaxValue; - OriginalDistanceTime = _curTime(); - } - } - } - - /// - /// Retail MoveToManager::HitGround (00529d70, raw - /// 307175-307183): no-op when is Invalid; - /// otherwise tailcall — re-arm the moveto - /// state machine after landing (mirrors the UseTime CONTACT gate, which - /// blocks all progress while airborne). - /// - public void HitGround() - { - if (MovementTypeState == MovementType.Invalid) return; - BeginNextNode(); - } - - // ── 6f/6g. Deferred-start internals (first HandleUpdateTarget callback) ─ - - /// - /// Retail MoveToManager::MoveToObject_Internal (0052a400, - /// raw 307597-307663). VERBATIM per decomp §6f: no physics_obj → - /// (NoPhysicsObject). Snapshot - /// =, - /// =. Compute - /// heading toward the INTERPOLATED position (not the raw target — the - /// smoother tracking point) minus current heading, epsilon-snapped/ - /// normalized. against - /// (now valid — CurrentTargetPosition is - /// set) — if motion is needed, queue - /// [TurnToHeading(face interpolated)] → [MoveToPosition] (SAME - /// node plan shape as , but aimed at the - /// interpolated point). If - /// — queue a FINAL turn to heading-to-interpolated + - /// DesiredHeading (RELATIVE, unlike 's - /// absolute final heading). Set =true, then - /// . - /// - public void MoveToObject_Internal(Position target, Position interpolated) - { - if (!HasPhysicsObj) - { - CancelMoveTo(WeenieError.NoPhysicsObject); - return; - } - - SoughtPosition = interpolated; - CurrentTargetPosition = target; - - Vector3 myPos = _getPosition().Frame.Origin; - Vector3 interpPos = interpolated.Frame.Origin; - float iHeading = MoveToMath.PositionHeading(myPos, interpPos); - - float dist = GetCurrentDistance(); - - float diff = iHeading - _getHeading(); - if (MathF.Abs(diff) < MoveToMath.Epsilon) diff = 0f; - if (diff < -MoveToMath.Epsilon) diff += 360f; - - Params.GetCommand(dist, diff, out uint cmd, out _, out _); - if (cmd != 0) - { - AddTurnToHeadingNode(iHeading); - AddMoveToPositionNode(); - } - - if (Params.UseFinalHeading) - { - float final = iHeading + Params.DesiredHeading; - if (final >= 360f) final -= 360f; - AddTurnToHeadingNode(final); - } - - Initialized = true; - BeginNextNode(); - } - - /// - /// Retail MoveToManager::TurnToObject_Internal (0052a550, - /// raw 307667-307702). VERBATIM per decomp §6g: no physics_obj → - /// (NoPhysicsObject). Snapshot - /// =. Read - /// 's CURRENT heading (with §3d's quirk, this - /// is 0 for a fresh manager — the desired-heading write TurnToObject - /// seeded landed on instead and was - /// overwritten just above). Compute heading-to-target, add the sought - /// heading, fmod 360 — the final heading. Write it back into - /// 's frame, queue ONE TurnToHeading node - /// (inlined AddTurnToHeadingNode shape), set - /// =true, . With the - /// §3d quirk, the effective final heading is simply "face the object" — - /// ACE matches verbatim (MoveToManager.cs:246 + 271-276). - /// - public void TurnToObject_Internal(Position target) - { - if (!HasPhysicsObj) - { - CancelMoveTo(WeenieError.NoPhysicsObject); - return; - } - - CurrentTargetPosition = target; - - float soughtHeading = MoveToMath.GetHeading(SoughtPosition.Frame.Orientation); - - Vector3 myPos = _getPosition().Frame.Origin; - Vector3 targetPos = CurrentTargetPosition.Frame.Origin; - float targetHeading = MoveToMath.PositionHeading(myPos, targetPos); - - float final = (targetHeading + soughtHeading) % 360f; - if (final < 0f) final += 360f; - - SoughtPosition = SoughtPosition with - { - Frame = new CellFrame(SoughtPosition.Frame.Origin, MoveToMath.SetHeading(SoughtPosition.Frame.Orientation, final)), - }; - - _pendingActions.AddLast(new MoveToNode(MovementType.TurnToHeading, final)); - - Initialized = true; - BeginNextNode(); - } - - // ── 7. Motion issuing + cleanup family ────────────────────────────────── - - /// - /// Retail MoveToManager::_DoMotion (00529010, raw - /// 306351-306364). VERBATIM per decomp §7a: no physics_obj → 0x08 - /// (NoPhysicsObject); no motion interpreter → 0x0B - /// (NoMotionInterpreter — modeled as always-present since - /// is a required ctor field, so this branch is - /// unreachable in this port; kept in the doc for parity). Calls - /// EXPLICITLY (mutates - /// /.Speed in place — e.g. - /// promotes WalkForward+HoldKey.Run → RunForward×runRate) THEN tailcalls - /// - /// with the ADJUSTED motion id. THIS IS THE ENTIRE CMotionInterp SEAM — - /// MoveToManager never calls the raw-command DoMotion, - /// set_hold_run, or any raw-state API directly. - /// - private WeenieError _DoMotion(uint motion, MovementParameters p) - { - if (!HasPhysicsObj) return WeenieError.NoPhysicsObject; - - float speed = p.Speed; - _interp.adjust_motion(ref motion, ref speed, p.HoldKeyToApply); - p.Speed = speed; - - return _interp.DoInterpretedMotion(motion, p); - } - - /// - /// Retail MoveToManager::_StopMotion (00529080, raw - /// 306368-306381). Identical shape to : calls - /// then tailcalls - /// - /// with the ADJUSTED motion id. - /// - private WeenieError _StopMotion(uint motion, MovementParameters p) - { - if (!HasPhysicsObj) return WeenieError.NoPhysicsObject; - - float speed = p.Speed; - _interp.adjust_motion(ref motion, ref speed, p.HoldKeyToApply); - p.Speed = speed; - - return _interp.StopInterpretedMotion(motion, p); - } - - /// - /// Retail MoveToManager::CancelMoveTo (00529930, raw - /// 306886-306940). VERBATIM per decomp §7c: no-op when - /// is already Invalid (reentrancy guard — - /// see the class doc's reentrancy invariant). Otherwise: drain - /// , , then - /// StopCompletely. The argument is NEVER READ - /// in retail's body (every call site's error code is dropped in this - /// build, decomp §7c) — kept for parity/logging/tests only; NO behavior - /// depends on its value. - /// - public void CancelMoveTo(WeenieError error) - { - _ = error; // retail: never read in the body (decomp §7c) — kept for parity/logging. - - if (MovementTypeState == MovementType.Invalid) return; - - _pendingActions.Clear(); - CleanUp(); - if (HasPhysicsObj) _stopCompletely(); - } - - /// - /// Retail MoveToManager::CleanUp (005295c0, raw - /// 306710-306736). VERBATIM per decomp §7d: build a local params - /// (hold_key copied from , CancelMoveTo cleared), - /// then if a physics_obj exists: stop the current command (if any), stop - /// the aux command (if any), and — if this was an object move - /// ( != 0 AND - /// != Invalid) — () - /// (the P4 TargetTracker unsubscribe). Finally - /// . Does NOT drain - /// / - /// do that first. - /// - public void CleanUp() - { - var localParams = new MovementParameters - { - HoldKeyToApply = Params.HoldKeyToApply, - CancelMoveTo = false, - }; - - if (HasPhysicsObj) - { - if (CurrentCommand != 0) _StopMotion(CurrentCommand, localParams); - if (AuxCommand != 0) _StopMotion(AuxCommand, localParams); - if (TopLevelObjectId != 0 && MovementTypeState != MovementType.Invalid) - { - _clearTarget(); - } - } - - InitializeLocalVariables(); - } - - /// - /// Retail MoveToManager::CleanUpAndCallWeenie (00529650, - /// raw 306740-306752). Despite the name, contains NO weenie call in this - /// build (decomp §7e — the compiled-out server-side callback; body ≡ - /// + StopCompletely). - /// is a documented CLIENT ADDITION (see its doc) firing here and at - /// 's empty-queue completion, standing in for - /// ACE's server-side OnMoveComplete — do NOT present it as retail - /// behavior. - /// - public void CleanUpAndCallWeenie(WeenieError error) - { - CleanUp(); - if (HasPhysicsObj) _stopCompletely(); - MoveToComplete?.Invoke(error); - } - - // ── internal helper: the 10-field MovementParameters copy every entry - // point performs (§3b/§3c/§3d/§3e all do this identically) ────────── - - private void CopyParams(MovementParameters p) - { - Params.CanWalk = p.CanWalk; - Params.CanRun = p.CanRun; - Params.CanSidestep = p.CanSidestep; - Params.CanWalkBackwards = p.CanWalkBackwards; - Params.CanCharge = p.CanCharge; - Params.FailWalk = p.FailWalk; - Params.UseFinalHeading = p.UseFinalHeading; - Params.Sticky = p.Sticky; - Params.MoveAway = p.MoveAway; - Params.MoveTowards = p.MoveTowards; - Params.UseSpheres = p.UseSpheres; - Params.SetHoldKey = p.SetHoldKey; - Params.Autonomous = p.Autonomous; - Params.ModifyRawState = p.ModifyRawState; - Params.ModifyInterpretedState = p.ModifyInterpretedState; - Params.CancelMoveTo = p.CancelMoveTo; - Params.StopCompletelyFlag = p.StopCompletelyFlag; - Params.DisableJumpDuringLink = p.DisableJumpDuringLink; - - Params.DistanceToObject = p.DistanceToObject; - Params.MinDistance = p.MinDistance; - Params.DesiredHeading = p.DesiredHeading; - Params.Speed = p.Speed; - Params.FailDistance = p.FailDistance; - Params.WalkRunThreshhold = p.WalkRunThreshhold; - Params.ContextId = p.ContextId; - Params.HoldKeyToApply = p.HoldKeyToApply; - Params.ActionStamp = p.ActionStamp; - } -} - -/// -/// Retail TargetInfo (acclient.h:31591, struct #3482) — the callback -/// payload consumes AND the wire -/// record the R5 voyeur system exchanges between -/// hosts (SendVoyeurUpdatereceive_target_update → -/// ). -/// -/// R5 EXTENDED this from the R4 4-field callback shape to the full retail -/// 10-field struct. The extra fields (, -/// , , -/// , , -/// ) default to zero, so the existing 4-argument -/// new TargetInfo(id, status, tp, ip) call sites (MoveToManager tests, -/// the AP-79 adapter pre-V2) still compile unchanged. -/// only reads -/// //; -/// the extra fields are consumed by the voyeur system. -/// -/// Retail object_id — matched against -/// ; a mismatch is silently -/// ignored (decomp §6d). -/// Retail status — see . -/// Retail target_position — the raw -/// (possibly jittery) target position. -/// Retail interpolated_position — -/// the smoothed tracking point -/// steers toward. -/// Retail context_id — the tracking context -/// (0 = the movement context; CPhysicsObj::HandleUpdateTarget only fans -/// out context 0). -/// Retail radius — the voyeur's send-on-move -/// threshold (game units). -/// Retail quantum — the dead-reckoning lookahead -/// horizon (seconds) for GetInterpolatedPosition. -/// Retail interpolated_heading — -/// normalized self→target direction (falls back to +Z when degenerate). -/// Retail velocity — the target's velocity at -/// send time. -/// Retail last_update_time — receipt -/// timestamp (drives the 10 s staleness timeout). -public readonly record struct TargetInfo( - uint ObjectId, - TargetStatus Status, - Position TargetPosition, - Position InterpolatedPosition, - uint ContextId = 0, - float Radius = 0f, - double Quantum = 0.0, - Vector3 InterpolatedHeading = default, - Vector3 Velocity = default, - double LastUpdateTime = 0.0); - -/// -/// Retail TargetStatus (acclient.h:7264). Only vs -/// non- is behaviorally distinguished by -/// in this build (decomp §6d) -/// — the other values are carried for parity with the P4 TargetTracker -/// contract (V0-pins.md §P4). -/// -public enum TargetStatus -{ - /// 0 — undefined/uninitialized. - Undefined = 0, - /// 1 — target resolved and tracked normally. - Ok = 1, - /// 2 — target left the world (despawned). - ExitWorld = 2, - /// 3 — target teleported. - Teleported = 3, - /// 4 — target became contained (picked up / stowed). - Contained = 4, - /// 5 — target became parented (e.g. mounted/wielded). - Parented = 5, - /// 6 — tracker timed out without an update. - TimedOut = 6, -} diff --git a/src/AcDream.Core/Physics/Motion/MoveToMath.cs b/src/AcDream.Core/Physics/Motion/MoveToMath.cs deleted file mode 100644 index e7365edb..00000000 --- a/src/AcDream.Core/Physics/Motion/MoveToMath.cs +++ /dev/null @@ -1,306 +0,0 @@ -using System; -using System.Numerics; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R4-V1 — pure-math free functions consumed by the (future) MoveToManager -/// port: heading_diff, heading_greater, Position::heading -/// / Frame::get_heading / Frame::set_heading, and -/// Position::cylinder_distance. No GL/App dependency — Core-only, -/// per the Code Structure Rules. NAME WATCH: this file (not -/// MoveToManager.cs, per r4-port-plan.md §3 "New code target") is the -/// R4-V1 deliverable; the manager itself is R4-V2. -/// -public static class MoveToMath -{ - /// - /// Universal heading/distance epsilon (same literal as R3's A5/A6 — - /// r4-moveto-decomp.md §12 constants inventory). - /// - public const float Epsilon = 0.000199999995f; - - /// - /// Retail heading_diff (0x00528fb0, free function, raw - /// 306327-306347), PINNED by direct disassembly of the PDB-matched - /// retail binary (ghidra-confirmations.md §P3 — the strongest evidence - /// tier in the R4 pin set): - /// - /// d = h1 - h2; - /// if (fabs(h1 - h2) < F_EPSILON) d = 0; - /// if (d < -F_EPSILON) d += 360; - /// if (F_EPSILON < d && turnCmd != TurnRight) d = 360 - d; // the mirror - /// return d; - /// - /// The mirror gates on the turn command NOT being TurnRight - /// (0x6500000d) — TurnLeft (and any other command) measures the - /// COMPLEMENTARY angle. This CONTRADICTS r4-moveto-decomp.md §5g's - /// "arg3 UNUSED" claim, which the Ghidra disassembly pin overrides - /// (V0-pins.md §P3 adjudication). Call sites: BeginTurnToHeading - /// passes the CONSTANT TurnRight (mirror explicitly disabled — the - /// direction pick stays the ≤180 test elsewhere); HandleTurnToHeading - /// passes the LIVE current_command (can be TurnLeft). - /// - /// First heading, degrees. - /// Second heading, degrees. - /// The active turn command id — gates the mirror. - /// Normalized heading difference, degrees. - public static float HeadingDiff(float h1, float h2, uint turnCmd) - { - float d = h1 - h2; - if (MathF.Abs(h1 - h2) < Epsilon) - { - d = 0f; - } - if (d < -Epsilon) - { - d += 360f; - } - if (Epsilon < d && turnCmd != MotionCommand.TurnRight) - { - d = 360f - d; - } - return d; - } - - /// - /// Retail heading_greater (00528f60, free function, raw - /// 306281-306323), verbatim per r4-moveto-decomp.md §5f: - /// - /// if (fabs(a - b) > 180) greater = (b > a); // wrapped case: compare flipped - /// else greater = (a > b); - /// if (turnCmd == TurnRight) return greater; - /// return !greater; // TurnLeft (and any other cmd): inverted - /// - /// "Has the turn passed the target heading" — direction-aware, - /// 360°-wrap-aware. The visible TurnRight-arg idiom: the gate is - /// == TurnRight (not != TurnRight as in - /// 's mirror) — every OTHER command inverts, - /// not just TurnLeft specifically. - /// - public static bool HeadingGreater(float a, float b, uint turnCmd) - { - bool greater = MathF.Abs(a - b) > 180f - ? b > a - : a > b; - - return turnCmd == MotionCommand.TurnRight ? greater : !greater; - } - - /// - /// Retail Position::heading(from, to) (0x005a9520, raw - /// 438288-438290), PINNED per V0-pins.md §P5: compass degrees, 0 = - /// North (+Y), 90 = East (+X), CLOCKWISE, range [0,360). - /// - /// heading(from, to) = (450 - atan2Deg(dy, dx)) % 360 - /// - /// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270. - /// Horizontal (X/Y) only — Z (height) does not participate, matching - /// retail's compass-heading semantics. An in-tree twin of this formula - /// already exists at SceneryHelpers.cs:75 (render-side, - /// independently verified — not reused directly to keep this file - /// GL-free per the Code Structure Rules, but the formula is identical). - /// - public static float PositionHeading(Vector3 from, Vector3 to) - { - float dx = to.X - from.X; - float dy = to.Y - from.Y; - float headingDeg = 450f - MathF.Atan2(dy, dx) * (180f / MathF.PI); - headingDeg %= 360f; - if (headingDeg < 0f) headingDeg += 360f; - return headingDeg; - } - - /// - /// Retail Frame::get_heading (0x00535760, raw 319781) — - /// extracts the compass heading (P5 convention) from a body orientation - /// quaternion. The packer-reuse trap (V0-pins §P5 correction): - /// acdream's outbound packer (GameWindow.YawToAcQuaternion) is - /// wire-correct at the QUATERNION level but its internal scalar - /// intermediate (headingDeg = 180 - yawDeg) is holtburger's - /// SHIFTED convention, not retail's. This method uses the CORRECT - /// scalar bridge derived from acdream's own body convention - /// (PlayerMovementController.cs:1022-1025: Orientation = - /// AxisAngle(Z, Yaw - PI/2), local-forward = +Y, Yaw=0 faces +X): - /// world-forward = (cos Yaw, sin Yaw), so - /// YawDeg = atan2Deg(forward.Y, forward.X) and - /// heading = (90 - YawDeg) mod 360 — the exact inverse of - /// . Identity quaternion (Yaw=PI/2, i.e. facing - /// +Y/North) → heading 0, matching P5's "identity quaternion faces - /// heading 0" pin. - /// - public static float GetHeading(Quaternion orientation) - { - var forward = Vector3.Transform(new Vector3(0f, 1f, 0f), orientation); - float yawDeg = MathF.Atan2(forward.Y, forward.X) * (180f / MathF.PI); - float headingDeg = 90f - yawDeg; - headingDeg %= 360f; - if (headingDeg < 0f) headingDeg += 360f; - return headingDeg; - } - - /// - /// Retail Frame::set_heading (0x00535e40, raw - /// 320055-320066) — builds a body orientation quaternion facing - /// (P5 compass convention), preserving - /// acdream's body-orientation convention (rotation about world Z only; - /// 's pitch/roll, if any, is - /// discarded — matching retail's set_heading, which is a pure - /// yaw-about-Z setter). Exact inverse of : - /// YawDeg = 90 - headingDeg, then Orientation = - /// AxisAngle(Z, Yaw - PI/2) per - /// PlayerMovementController.cs:1025's convention. - /// - /// Unused beyond signature parity with - /// the render-side SceneryHelpers.SetHeading twin — retail's - /// set_heading is a pure yaw-about-Z setter with no dependency - /// on the prior orientation's roll/pitch component in the body-frame - /// convention this port uses. - /// Desired compass heading, degrees. - public static Quaternion SetHeading(Quaternion baseOrientation, float headingDeg) - { - _ = baseOrientation; - float yawDeg = 90f - headingDeg; - float yaw = yawDeg * (MathF.PI / 180f); - return Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yaw - MathF.PI / 2f); - } - - /// - /// R4-V5: the scalar leg of for bodies whose - /// authoritative facing is a yaw ANGLE rather than a quaternion (the - /// local player: PlayerMovementController.Yaw, radians, Yaw=0 - /// faces +X, re-synced into the body quaternion every Update). Same P5 - /// bridge: heading = (90 - yawDeg) mod 360. - /// - public static float HeadingFromYaw(float yawRad) - { - float headingDeg = 90f - yawRad * (180f / MathF.PI); - headingDeg %= 360f; - if (headingDeg < 0f) headingDeg += 360f; - return headingDeg; - } - - /// - /// R4-V5: exact inverse of — the - /// set_heading seam for yaw-authoritative bodies (the local - /// player's heading snap must write Yaw, NOT the body - /// quaternion, which the controller re-derives from Yaw every frame). - /// Returns radians wrapped to [-π, π] matching the controller's own - /// wrap discipline. - /// - public static float YawFromHeading(float headingDeg) - { - float yaw = (90f - headingDeg) * (MathF.PI / 180f); - while (yaw > MathF.PI) yaw -= 2f * MathF.PI; - while (yaw < -MathF.PI) yaw += 2f * MathF.PI; - return yaw; - } - - /// - /// Retail Position::cylinder_distance, the pure-math shape - /// consumed by MoveToManager::GetCurrentDistance - /// (005291b0, r4-moveto-decomp.md §5a) when use_spheres - /// (wire bit 0x400) is set — object moves use edge-to-edge cylinder - /// distance; position moves use plain center (Euclidean) distance - /// instead (not ported here — Vector3.Distance already covers - /// it). BN garbles the x87 plumbing in the raw, so the exact - /// radius-combination arithmetic is not directly visible; ported per - /// the PDB argument ORDER (own radius/height/position, target - /// radius/height/position) with the standard cylinder-distance shape: - /// planar (X/Y) center distance minus the sum of both radii, clamped - /// at zero (overlapping cylinders report 0, never negative). Height is - /// accepted for signature parity with the retail call (own/target - /// height feed the caller's contact-plane logic elsewhere) but does - /// NOT participate in this edge-to-edge planar computation — matching - /// retail's use of cylinder_distance purely for the horizontal arrival - /// gate. - /// - public static float CylinderDistance( - float ownRadius, float ownHeight, Vector3 ownPos, - float targetRadius, float targetHeight, Vector3 targetPos) - { - _ = ownHeight; - _ = targetHeight; - - float dx = targetPos.X - ownPos.X; - float dy = targetPos.Y - ownPos.Y; - float centerDist = MathF.Sqrt(dx * dx + dy * dy); - - float edgeDist = centerDist - ownRadius - targetRadius; - return edgeDist > 0f ? edgeDist : 0f; - } - - /// - /// Retail Position::cylinder_distance_no_z — the signed - /// horizontal (X/Y) edge-to-edge distance between two cylinders: - /// centerDist − ownRadius − targetRadius. Unlike - /// (the arrival-gate variant, which CLAMPS at - /// 0), this variant is NOT clamped — overlapping cylinders report a NEGATIVE - /// value. StickyManager::adjust_offset (0x00555430) relies on the - /// sign: when the follower is inside the desired 0.3 m stick gap the - /// distance goes negative, the per-tick delta inverts, and the mover backs - /// off to restore the gap (ACE StickyManager.cs:156). - /// - public static float CylinderDistanceNoZ( - float ownRadius, Vector3 ownPos, float targetRadius, Vector3 targetPos) - { - float dx = targetPos.X - ownPos.X; - float dy = targetPos.Y - ownPos.Y; - float centerDist = MathF.Sqrt(dx * dx + dy * dy); - return centerDist - ownRadius - targetRadius; - } - - /// - /// Retail AC1Legacy::Vector3::normalize_check_small — normalize - /// in place, returning true if the vector was - /// too small to normalize (near-zero) and leaving it unchanged in that - /// case. Consumed by StickyManager::adjust_offset (don't chase - /// jitter when already at the target) and - /// (interpolated-heading - /// fallback). A public shared twin of the private helper in - /// ParticleSystem; same 1e-8 near-zero length guard. - /// - /// true = too small (left unchanged); false = - /// normalized. - public static bool NormalizeCheckSmall(ref Vector3 v) - { - float length = v.Length(); - if (length < 1e-8f) - return true; - v /= length; - return false; - } - - /// - /// Retail Position::globaltolocalvec — rotate a WORLD-space vector - /// into a frame's LOCAL coordinates by the inverse of the frame's - /// orientation. Consumed by StickyManager::adjust_offset - /// (0x00555430) to express the self→target offset in the mover's own frame - /// before flattening Z and steering. Pure rotation (no translation) — - /// is a direction/offset, not a point. - /// - public static Vector3 GlobalToLocalVec(Quaternion frameOrientation, Vector3 worldVec) - => Vector3.Transform(worldVec, Quaternion.Conjugate(frameOrientation)); - - /// - /// Landblock-local wire origin → world space (verbatim relocation from - /// the deleted RemoteMoveToDriver.OriginToWorld, R4-V4): MoveTo / - /// TurnTo packets carry positions block-local; acdream's streaming world - /// re-centers on a live-center landblock grid. - /// - public static Vector3 OriginToWorld( - uint originCellId, - float originX, - float originY, - float originZ, - int liveCenterLandblockX, - int liveCenterLandblockY) - { - int lbX = (int)((originCellId >> 24) & 0xFFu); - int lbY = (int)((originCellId >> 16) & 0xFFu); - return new Vector3( - originX + (lbX - liveCenterLandblockX) * 192f, - originY + (lbY - liveCenterLandblockY) * 192f, - originZ); - } -} diff --git a/src/AcDream.Core/Physics/Motion/MoveToNode.cs b/src/AcDream.Core/Physics/Motion/MoveToNode.cs deleted file mode 100644 index 06052c0f..00000000 --- a/src/AcDream.Core/Physics/Motion/MoveToNode.cs +++ /dev/null @@ -1,47 +0,0 @@ -namespace AcDream.Core.Physics.Motion; - -/// -/// R4-V2 — verbatim port of retail's MoveToManager::MovementNode -/// (acclient.h:57702, struct #6350): -/// -/// struct __cppobj MoveToManager::MovementNode : DLListData -/// { // +0 dllist_next, +4 dllist_prev (DLListData) -/// MovementTypes::Type type; // +8 — only 7 (MoveToPosition) and 9 (TurnToHeading) ever queued -/// float heading; // +0xc — only meaningful for type 9 -/// }; -/// -/// -/// -/// NAME WATCH (r4-port-plan.md §3 "New code target"): named -/// MoveToNode, NOT MovementNode, to avoid colliding with R2's -/// (the UNRELATED CMotionInterp::pending_motions -/// node — a different queue on a different class; see the AD-34 register -/// wording on both node types' shared "managed collection standing in for -/// retail's intrusive DLList/LList" pattern). -/// -/// -/// -/// Retail allocates these with operator new(0x10) and links them onto -/// MoveToManager::pending_actions (a DLList — doubly-linked, -/// unlike CMotionInterp's singly-linked LList) via -/// DLListBase::InsertAfter (tail-append; r4-moveto-decomp.md §4a). -/// Ported as a managed -/// of this value type — same pattern as 's port -/// (AD-34 wording): node identity semantics preserved via -/// LinkedListNode<MoveToNode> references rather than raw -/// prev/next pointers, FIFO order preserved via tail-append + -/// RemoveFirst. -/// -/// -/// Retail type (+8) — only -/// (7) and -/// (9) are ever queued -/// (r4-moveto-decomp.md §4a: AddMoveToPositionNode / -/// AddTurnToHeadingNode are the only two producers; -/// 's dispatch is a defensive -/// if/if, not a full switch — an unknown type stalls rather than -/// throwing, matching the raw's shape). -/// Retail heading (+0xc) — only meaningful for -/// nodes; zero/unused for -/// nodes. -public readonly record struct MoveToNode(MovementType Type, float Heading); diff --git a/src/AcDream.Core/Physics/Motion/MovementManager.cs b/src/AcDream.Core/Physics/Motion/MovementManager.cs deleted file mode 100644 index 7666dbd0..00000000 --- a/src/AcDream.Core/Physics/Motion/MovementManager.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R5-V5 — retail MovementManager (acclient.h /* 3463 */, 0x10 -/// bytes / four pointers): the ONE per-entity owner of the movement pipeline's -/// two managers. Decomp extract: -/// docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md. -/// -/// -/// struct MovementManager { -/// CMotionInterp *motion_interpreter; // +0x0 → Minterp -/// MoveToManager *moveto_manager; // +0x4 → MoveTo (lazy) -/// CPhysicsObj *physics_obj; // +0x8 ┐ carried by the children + -/// CWeenieObject *weenie_obj; // +0xc ┘ the MoveToFactory closure -/// }; -/// -/// -/// Construction mapping. Retail lazily creates BOTH children -/// (CMotionInterp::Create + enter_default_state at every entry -/// point; MoveToManager::Create via ). -/// acdream constructs the interp eagerly at entity construction -/// (RemoteMotion / PlayerMovementController ctors — the lazy -/// window is never observable; register TS-38 already covers the -/// Initted side of this) and hands it to this ctor. The moveto side -/// keeps retail's lazy mechanism: retail -/// constructs from the physics_obj/weenie_obj backpointers; -/// acdream's closure is the stand-in for those -/// two fields, carrying the full seam wiring (set once at the bind site — -/// EnsureRemoteMotionBindings / EnterPlayerModeNow / the chase -/// harness — which then calls immediately, -/// preserving the pre-facade eager timing; the ctor is side-effect-free so -/// the timing is unobservable either way). -/// -/// Deliberately NOT absorbed here (the R5-V5 slice keeps these -/// at their current owners): unpack_movement 0x00524440 ≡ the -/// GameWindow UM path + RouteServerMoveTo (Core.Net wire types stay -/// out of Core.Physics); move_to_interpreted_state 0x00524170 ≡ the -/// funnel's MotionInterpreter.MoveToInterpretedState call sites; -/// MotionDone 0x005242d0 ≡ the sequencer's MotionDoneTarget -/// seam (register AD-36); LeaveGround 0x00524320's moveto half is a -/// COMDAT no-op (see PlayerMovementController's landing comment) so -/// call sites keep invoking Minterp.LeaveGround() directly; -/// EnterDefaultState/HandleEnterWorld/ReportExhaustion/ -/// SetWeenieObject/Destroy have no acdream caller yet — -/// get_minterp 0x005242a0 ≡ the property. -/// -/// PerformMovement's set_active(1) head -/// (0x005240d9) is not re-asserted here: acdream bodies assert the Active -/// transient bit at spawn (RemoteMotion ctor) and the pre-facade -/// route never re-asserted it — status quo preserved (zero-behavior-change -/// slice), not a new deviation. -/// -public sealed class MovementManager -{ - /// Retail motion_interpreter (+0x0). Always present in - /// acdream (eager construction — see the class doc); direct child access - /// for interp-specific ops mirrors retail's get_minterp - /// (0x005242a0) callers. - public MotionInterpreter Minterp { get; } - - /// Retail moveto_manager (+0x4) — null until - /// (or a type-6..9 - /// ) creates it. - public MoveToManager? MoveTo { get; private set; } - - /// The acdream stand-in for retail's physics_obj/ - /// weenie_obj backpointers (+0x8/+0xc): the creation recipe - /// invokes. Set exactly once at the bind - /// site; the closure carries the MoveToManager's seam wiring (and the - /// StickTo/Unstick/MoveToComplete binds) that retail reads off - /// CPhysicsObj. - public Func? MoveToFactory { get; set; } - - public MovementManager(MotionInterpreter minterp) - { - Minterp = minterp ?? throw new ArgumentNullException(nameof(minterp)); - } - - /// - /// Retail MovementManager::MakeMoveToManager (0x00524000): - /// lazy-construct if null; no-op if already present. - /// No-op (instead of retail's unconditional create) when no factory has - /// been bound yet — acdream's seams arrive from the bind site, and every - /// consumer path runs after binding. - /// - public void MakeMoveToManager() - { - if (MoveTo is null && MoveToFactory is not null) - MoveTo = MoveToFactory(); - } - - /// - /// Retail MovementManager::PerformMovement (0x005240d0): the - /// type-1..9 two-way dispatch. (type - 1) > 8 (type 0 - /// underflows unsigned) → 0x47; types 1-5 → CMotionInterp:: - /// PerformMovement (return propagated); types 6-9 → - /// + MoveToManager::PerformMovement - /// whose return is NOT propagated (@0052414f return 0) — the - /// facade reports for that path. - /// - public WeenieError PerformMovement(MovementStruct mvs) - { - switch (mvs.Type) - { - case MovementType.RawCommand: - case MovementType.InterpretedCommand: - case MovementType.StopRawCommand: - case MovementType.StopInterpretedCommand: - case MovementType.StopCompletely: - return Minterp.PerformMovement(mvs); - - case MovementType.MoveToObject: - case MovementType.MoveToPosition: - case MovementType.TurnToObject: - case MovementType.TurnToHeading: - MakeMoveToManager(); - if (MoveTo is null) - // acdream-only guard: a type-6..9 event before the bind - // site set MoveToFactory (unreachable in production — - // EnsureRemoteMotionBindings / EnterPlayerModeNow bind - // before any route can fire). Retail would Create here. - return WeenieError.GeneralMovementFailure; - MoveTo.PerformMovement(mvs); - return WeenieError.None; - - default: - return WeenieError.GeneralMovementFailure; // 0x47 - } - } - - /// Retail MovementManager::UseTime (0x005242f0): relay - /// to ONLY (does not touch the interp, does not - /// lazy-create); no-op while null. - public void UseTime() => MoveTo?.UseTime(); - - /// Retail MovementManager::HitGround (0x00524300): fan - /// to BOTH children if present — CMotionInterp::HitGround FIRST - /// (the falling-pose exit re-apply), then MoveToManager::HitGround - /// (re-arms a moveto suspended by the airborne UseTime contact - /// gate). - public void HitGround() - { - Minterp.HitGround(); - MoveTo?.HitGround(); - } - - /// Retail MovementManager::HandleExitWorld (0x00524350): - /// interp ONLY (drains pending_motions); does not touch - /// . - public void HandleExitWorld() => Minterp.HandleExitWorld(); - - /// Retail MovementManager::CancelMoveTo (0x005241b0): - /// relay to ; no-op while null. The retail - /// interrupt_current_movement → MovementManager::CancelMoveTo(0x36) - /// chain lands here. - public void CancelMoveTo(WeenieError error) => MoveTo?.CancelMoveTo(error); - - /// Retail MovementManager::HandleUpdateTarget - /// (0x00524790): relay to ; no-op while null. The - /// CPhysicsObj::HandleUpdateTarget fan (0x00512bc0) calls this - /// leg first, then the PositionManager's (host order — - /// EntityPhysicsHost.HandleUpdateTarget). - public void HandleUpdateTarget(TargetInfo info) => MoveTo?.HandleUpdateTarget(info); - - /// Retail MovementManager::IsMovingTo (0x00524260): - /// true iff exists AND reports an armed - /// move. - public bool IsMovingTo() => MoveTo?.IsMovingTo() ?? false; -} diff --git a/src/AcDream.Core/Physics/Motion/MovementParameters.cs b/src/AcDream.Core/Physics/Motion/MovementParameters.cs deleted file mode 100644 index 8e648f55..00000000 --- a/src/AcDream.Core/Physics/Motion/MovementParameters.cs +++ /dev/null @@ -1,472 +0,0 @@ -using AcDream.Core.Physics; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R3-W1 — verbatim port of retail's MovementParameters -/// (acclient.h:31453, struct #3460; bitfield struct -/// acclient.h:31423-31443): -/// -/// struct __cppobj MovementParameters : PackObj -/// { -/// union { unsigned int bitfield; ... } ___u1; -/// float distance_to_object; -/// float min_distance; -/// float desired_heading; -/// float speed; -/// float fail_distance; -/// float walk_run_threshhold; -/// unsigned int context_id; -/// HoldKey hold_key_to_apply; -/// unsigned int action_stamp; -/// }; -/// -/// -/// -/// The bitfield's absolute mask table is pinned in -/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A4 (bit-for-bit -/// identical to ACE's MovementParamFlags): -/// -/// -/// 0x1CanWalk -/// 0x2CanRun -/// 0x4CanSidestep -/// 0x8CanWalkBackwards -/// 0x10CanCharge -/// 0x20FailWalk -/// 0x40UseFinalHeading -/// 0x80Sticky -/// 0x100MoveAway -/// 0x200MoveTowards -/// 0x400UseSpheres -/// 0x800SetHoldKey -/// 0x1000Autonomous -/// 0x2000ModifyRawState -/// 0x4000ModifyInterpretedState -/// 0x8000CancelMoveTo -/// 0x10000StopCompletely -/// 0x20000DisableJumpDuringLink -/// -/// -/// -/// Ctor default (raw 300510-300534, 0x00524380): -/// (bitfield & 0xfffdee0f) | 0x1ee0f0x1EE0F sets -/// {CanWalk, CanRun, CanSidestep, CanWalkBackwards, MoveTowards, UseSpheres, -/// SetHoldKey, ModifyRawState, ModifyInterpretedState, CancelMoveTo, -/// StopCompletely}; clears {CanCharge, FailWalk, UseFinalHeading, Sticky, -/// MoveAway, Autonomous, DisableJumpDuringLink}. -/// Scalars: min_distance=0, distance_to_object=0.6, -/// fail_distance=FLT_MAX, desired_heading=0, speed=1, -/// walk_run_threshhold=15 (NOT ACE's 1.0 — W0-pins A4 divergence trap), -/// context_id=0, hold_key_to_apply=HoldKey.Invalid, -/// action_stamp=0. -/// -/// -/// -/// ACE-divergence traps (W0-pins A4, do not copy): ACE's -/// MovementParameters ctor sets CanCharge = true -/// (MovementParameters.cs:58) — retail's default has bit 0x10 CLEAR; -/// this port defaults CanCharge = false. ACE also changed -/// Default_WalkRunThreshold to 1.0 (L50) vs retail's literal 15.0 -/// (@300519) — this port defaults WalkRunThreshhold = 15f. -/// -/// -/// -/// Named bool properties per plan (no ToBitfield()/FromBitfield() -/// pair — the wire never carries this struct raw; RawMotionState::Pack -/// serializes the STATE, not this transient parameter block. If a future -/// slice needs the packed form, add the pair then with a cited call site). -/// -/// -public sealed class MovementParameters -{ - // ── bitfield flags (retail 0x1EE0F default; W0-pins A4) ─────────────── - - /// Mask 0x1 — default true. - public bool CanWalk { get; set; } = true; - - /// Mask 0x2 — default true. - public bool CanRun { get; set; } = true; - - /// Mask 0x4 — default true. - public bool CanSidestep { get; set; } = true; - - /// Mask 0x8 — default true. - public bool CanWalkBackwards { get; set; } = true; - - /// - /// Mask 0x10 — default FALSE. ACE-divergence trap (W0-pins A4): ACE's - /// ctor sets this true (MovementParameters.cs:58); retail's 0x1EE0F - /// default has bit 0x10 CLEAR. Do not "fix" this to true. - /// - public bool CanCharge { get; set; } - - /// Mask 0x20 — default false. - public bool FailWalk { get; set; } - - /// Mask 0x40 — default false. - public bool UseFinalHeading { get; set; } - - /// Mask 0x80 — default false. - public bool Sticky { get; set; } - - /// Mask 0x100 — default false. - public bool MoveAway { get; set; } - - /// Mask 0x200 — default true. - public bool MoveTowards { get; set; } = true; - - /// Mask 0x400 — default true. - public bool UseSpheres { get; set; } = true; - - /// Mask 0x800 — default true. DoMotion @306188: byte1&8 - /// requests a SetHoldKey call before adjust_motion. - public bool SetHoldKey { get; set; } = true; - - /// - /// Mask 0x1000 — default FALSE. Not the same virtual as - /// IWeenieObject.IsThePlayer (W0-pins A3) — this is the - /// per-call "was this an autonomous (locally-predicted) action?" flag. - /// - public bool Autonomous { get; set; } - - /// Mask 0x2000 — default true. DoMotion @306213: byte1&0x20 - /// mirrors the applied motion into RawMotionState via - /// ApplyMotion/RemoveMotion. - public bool ModifyRawState { get; set; } = true; - - /// Mask 0x4000 — default true. Mirrors into - /// InterpretedMotionState. - public bool ModifyInterpretedState { get; set; } = true; - - /// Mask 0x8000 — default true. Bitfield high-byte sign bit; - /// DoMotion/StopMotion @306183/@305684: triggers - /// interrupt_current_movement before the rest of the call. - public bool CancelMoveTo { get; set; } = true; - - /// Mask 0x10000 — default true. - public bool StopCompletelyFlag { get; set; } = true; - - /// - /// Mask 0x20000 — default FALSE. DoInterpretedMotion @305597: when set, - /// forces the computed jump_error_code to 0x48 (A1: jump - /// BLOCKED by motion/position) regardless of what - /// motion_allows_jump would have said. - /// - public bool DisableJumpDuringLink { get; set; } - - // ── scalar fields (retail ctor 0x00524380 defaults) ─────────────────── - - /// Retail default 0.6. - public float DistanceToObject { get; set; } = 0.6f; - - /// Retail default 0. - public float MinDistance { get; set; } - - /// Retail default 0. - public float DesiredHeading { get; set; } - - /// Retail default 1. - public float Speed { get; set; } = 1f; - - /// Retail default FLT_MAX. - public float FailDistance { get; set; } = float.MaxValue; - - /// - /// Retail default 15.0 (@300519). ACE-divergence trap (W0-pins A4): ACE - /// changed Default_WalkRunThreshold to 1.0 — do not copy. - /// - public float WalkRunThreshhold { get; set; } = 15f; - - /// Retail default 0. - public uint ContextId { get; set; } - - /// Retail default . - public HoldKey HoldKeyToApply { get; set; } = HoldKey.Invalid; - - /// Retail default 0. - public uint ActionStamp { get; set; } - - // ── R4-V1: command-selection family (closes M2-mechanics) ───────────── - - /// - /// Retail MovementParameters::get_command (0x0052aa00, - /// raw 307946-308012), VERBATIM per - /// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §5c. Picks the - /// motion command + moving-away flag from the towards/away bitfield - /// combination, THEN the walk-vs-run cascade. - /// - /// - /// Command pick (mirrors towards_and_away's bands but is - /// NOT identical — see the asymmetry note on ): - /// - /// MoveTowards && MoveAway → delegate - /// to (the three-band form). - /// MoveTowards only (or neither flag set — - /// retail's else if falls through to the SAME branch): plain - /// towards — dist > DistanceToObject → WalkForward, - /// !movingAway; else idle (cmd 0). - /// MoveAway only: pure away — - /// dist < MinDistance → WalkForward, movingAway=true (the - /// heading flips +180 via — turn-around, - /// NOT WalkBackwards, unlike 's min-band); - /// else idle. - /// - /// - /// - /// - /// THE walk-vs-run rule (confirms - /// feedback_autowalk_cancharge_bit — port RETAIL's version of - /// BOTH the fast-path ACE dropped and the threshold-close-walk pair): - /// HoldKey.RunCanCharge set (the fast-path — wins - /// regardless of CanRun/CanWalk/distance), OR (CanRun set AND - /// (CanWalk clear OR dist - DistanceToObject > - /// WalkRunThreshhold)). HoldKey.None (walk) ⇐ no CanRun, or - /// walk-capable within the threshold (INCLUSIVE ≤ — the raw's - /// test ah,0x41 after the fcom is the not-greater-than reading, - /// §5c @308003). - /// - /// - /// Current distance-to-target (retail's - /// GetCurrentDistance result — center or cylinder distance per - /// ). - /// Heading-to-target minus current heading, - /// normalized [0,360) — UNUSED by get_command itself (the raw - /// signature carries it for parity with the caller's local; retail's - /// body never reads arg3 in this build). Kept as a parameter for - /// call-site symmetry with BeginMoveForward (§4c), which computes - /// it immediately before calling get_command. - /// Chosen motion command id, or 0 if no movement - /// is needed (already in range). - /// Chosen hold key (walk vs run). - /// True if the chosen motion moves the mover - /// AWAY from the target (feeds and the - /// arrival predicate's polarity). - public void GetCommand(float dist, float headingDiff, out uint motion, out HoldKey holdKey, out bool movingAway) - { - _ = headingDiff; // retail's arg3 — unread in this build's body (§5c) - - // ── command + moving_away pick ────────────────────────────────── - if (MoveTowards && MoveAway) - { - TowardsAndAway(dist, out motion, out movingAway); - } - else if (MoveAway && !MoveTowards) - { - // pure AWAY: dist < min_distance → WalkForward, moving away - // (turn-around; heading flips +180 via GetDesiredHeading). - if (dist < MinDistance) - { - motion = MotionCommand.WalkForward; - movingAway = true; - } - else - { - motion = 0u; - movingAway = false; - } - } - else - { - // plain TOWARDS (MoveTowards set, or neither flag set — retail's - // `else if ((flags & 0x100) == 0)` falls to the same label). - if (dist > DistanceToObject) - { - motion = MotionCommand.WalkForward; - movingAway = false; - } - else - { - motion = 0u; - movingAway = false; - } - } - - // ── walk-vs-run HoldKey cascade ───────────────────────────────── - if (CanCharge) - { - // THE fast-path ACE dropped: can_charge short-circuits straight - // to Run regardless of CanRun/CanWalk/distance. - holdKey = HoldKey.Run; - return; - } - if (!CanRun) - { - holdKey = HoldKey.None; - return; - } - if (CanWalk && (dist - DistanceToObject) <= WalkRunThreshhold) - { - holdKey = HoldKey.None; - return; - } - holdKey = HoldKey.Run; - } - - /// - /// Retail MovementParameters::towards_and_away - /// (0x0052a9a0, raw 307917-307942), VERBATIM per - /// r4-moveto-decomp.md §5d. Three bands: - /// - /// dist > DistanceToObject → WalkForward, - /// towards (not moving away). - /// dist - MinDistance < F_EPSILON (inside - /// the min-distance band) → WalkBackward, moving away. NOTE the - /// asymmetry vs 's pure-away branch: this backs - /// up with WalkBackwards (no turn-around), not WalkForward+heading-flip - /// (r4-moveto-decomp.md :656). - /// otherwise (strictly inside [MinDistance, - /// DistanceToObject]) → idle (cmd 0). - /// - /// - /// Current distance-to-target. - /// Chosen motion command, or 0 if already in the dead - /// band. - /// True only for the WalkBackward (min-band) - /// case. - public void TowardsAndAway(float dist, out uint cmd, out bool movingAway) - { - const float epsilon = 0.000199999995f; - - if (dist > DistanceToObject) - { - cmd = MotionCommand.WalkForward; - movingAway = false; - return; - } - if (dist - MinDistance < epsilon) - { - cmd = MotionCommand.WalkBackward; - movingAway = true; - return; - } - cmd = 0u; - movingAway = false; - } - - /// - /// Retail MovementParameters::get_desired_heading - /// (0x0052aad0), PINNED by direct Ghidra decompile of - /// patchmem.gpr (fetched live during V0 — see - /// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P2, - /// ACE-shaped constants CONFIRMED exact, superseding the earlier - /// BN-garble-based "high confidence" pin): - /// - /// __thiscall get_desired_heading(command, movingAway) - /// { - /// if (command == RunForward || command == WalkForward) { - /// if (!movingAway) return 0.0f; - /// } else { - /// if (command != WalkBackward) return 0.0f; - /// if (movingAway) return 0.0f; - /// } - /// return 180.0f; - /// } - /// - /// Truth table: forward/run + towards → 0°; forward/run + away → 180°; - /// backward + towards → 180°; backward + away → 0°; any other command - /// → 0°. This is the heading OFFSET added to the raw heading-to-target - /// so an "away" walk faces away and an "away" backstep faces the - /// target. - /// - public float GetDesiredHeading(uint command, bool movingAway) - { - if (command == MotionCommand.RunForward || command == MotionCommand.WalkForward) - { - if (!movingAway) return 0f; - } - else - { - if (command != MotionCommand.WalkBackward) return 0f; - if (movingAway) return 0f; - } - return 180f; - } - - // ── R4-V1: wire factory (closes M15/wire-exposure groundwork) ───────── - - /// - /// Factory for the retail MovementParameters::UnPackNet 7-dword - /// MoveTo wire form (0x0052ac50, 0x1c bytes, raw 308118-308205 — - /// r4-moveto-decomp.md §2g): bitfield, distance_to_object, - /// min_distance, fail_distance, speed, walk_run_threshhold, - /// desired_heading. Used by MoveToObject (type 6) and - /// MoveToPosition (type 7) wire payloads — the SAME field order - /// UpdateMotion.TryParseMoveToPayload already reads - /// (UpdateMotion.cs:328-341). The bitfield fully OVERWRITES every named - /// flag (UnPackNet does not merge with ctor defaults — every bit not - /// present in resolves to false); the wire - /// bitfield carries can_charge (0x10), the walk-vs-run answer - /// consumed by (cross-ref - /// feedback_autowalk_cancharge_bit). - /// - public static MovementParameters FromWire( - uint bitfield, - float distanceToObject, - float minDistance, - float failDistance, - float speed, - float walkRunThreshhold, - float desiredHeading) - { - var p = new MovementParameters(); - ApplyBitfield(p, bitfield); - p.DistanceToObject = distanceToObject; - p.MinDistance = minDistance; - p.FailDistance = failDistance; - p.Speed = speed; - p.WalkRunThreshhold = walkRunThreshhold; - p.DesiredHeading = desiredHeading; - return p; - } - - /// - /// Factory for the retail MovementParameters::UnPackNet 3-dword - /// TurnTo wire form (0xc bytes, r4-moveto-decomp.md §2g): bitfield, - /// speed, desired_heading. Used by TurnToObject (type 8) and - /// TurnToHeading (type 9) wire payloads. Distance-related scalars - /// (DistanceToObject/MinDistance/FailDistance/ - /// WalkRunThreshhold) are NOT on this wire form and keep the - /// ctor defaults — retail's UnPackNet - /// for this form only ever writes the three fields named here. - /// - public static MovementParameters FromWireTurnTo( - uint bitfield, - float speed, - float desiredHeading) - { - var p = new MovementParameters(); - ApplyBitfield(p, bitfield); - p.Speed = speed; - p.DesiredHeading = desiredHeading; - return p; - } - - /// - /// Decode the A4-pinned bitfield masks onto the named bool properties. - /// Shared by / — the - /// wire bitfield always fully overwrites (retail's UnPackNet assigns - /// the raw dword straight into this->bitfield, no merge). - /// - private static void ApplyBitfield(MovementParameters p, uint bitfield) - { - p.CanWalk = (bitfield & 0x1u) != 0; - p.CanRun = (bitfield & 0x2u) != 0; - p.CanSidestep = (bitfield & 0x4u) != 0; - p.CanWalkBackwards = (bitfield & 0x8u) != 0; - p.CanCharge = (bitfield & 0x10u) != 0; - p.FailWalk = (bitfield & 0x20u) != 0; - p.UseFinalHeading = (bitfield & 0x40u) != 0; - p.Sticky = (bitfield & 0x80u) != 0; - p.MoveAway = (bitfield & 0x100u) != 0; - p.MoveTowards = (bitfield & 0x200u) != 0; - p.UseSpheres = (bitfield & 0x400u) != 0; - p.SetHoldKey = (bitfield & 0x800u) != 0; - p.Autonomous = (bitfield & 0x1000u) != 0; - p.ModifyRawState = (bitfield & 0x2000u) != 0; - p.ModifyInterpretedState = (bitfield & 0x4000u) != 0; - p.CancelMoveTo = (bitfield & 0x8000u) != 0; - p.StopCompletelyFlag = (bitfield & 0x10000u) != 0; - p.DisableJumpDuringLink = (bitfield & 0x20000u) != 0; - } -} diff --git a/src/AcDream.Core/Physics/Motion/PositionManager.cs b/src/AcDream.Core/Physics/Motion/PositionManager.cs deleted file mode 100644 index 0ed02f04..00000000 --- a/src/AcDream.Core/Physics/Motion/PositionManager.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R5 — port of retail's PositionManager facade (acclient.h:30952, -/// struct #3468; decomp 0x00555160-0x005553d0, -/// r5-positionmanager-sticky-decomp.md). A thin fan-out over three -/// sub-managers: Interpolation, Sticky, Constraint. Owned 1:1 by the entity's -/// (retail CPhysicsObj::position_manager, -/// lazily created). -/// -/// Interpolation note: retail's adjust_offset chains -/// Interpolation → Sticky → Constraint. acdream's interpolation stage lives in -/// (the R5-renamed remote-motion combiner, -/// formerly the misnamed Physics.PositionManager) and is NOT chained -/// here in V1 — this facade owns only the two R5 targets (Sticky retires TS-39; -/// Constraint is structural — see ). Folding the -/// combiner in as the interp stage is a wiring-slice cleanup. -/// -public sealed class PositionManager -{ - private readonly IPhysicsObjHost _host; - - // Lazily created (retail: sticky on first StickTo, constraint on first - // ConstrainTo — 0x00555230 / 0x00555280). - private StickyManager? _sticky; - private ConstraintManager? _constraint; - - public PositionManager(IPhysicsObjHost host) - => _host = host ?? throw new ArgumentNullException(nameof(host)); - - /// Exposed for wiring/tests — the lazily-created sub-managers - /// (null until first use). - public StickyManager? Sticky => _sticky; - public ConstraintManager? Constraint => _constraint; - - /// Retail PositionManager::StickTo (0x00555230) — lazily - /// create the and begin following - /// . - public void StickTo(uint objectId, float radius, float height) - { - _sticky ??= new StickyManager(_host); - _sticky.StickTo(objectId, radius, height); - } - - /// Retail PositionManager::UnStick (0x005551e0) — forward - /// to the sticky sub-manager if it exists. - public void UnStick() => _sticky?.UnStick(); - - /// Retail PositionManager::GetStickyObjectID - /// (0x00555270). - public uint GetStickyObjectId() => _sticky?.TargetId ?? 0u; - - /// Retail PositionManager::ConstrainTo (0x00555280) — - /// lazily create the and arm the leash. - /// (Unused in acdream — no arming call site; see - /// .) - public void ConstrainTo(Position anchor, float startDistance, float maxDistance) - { - _constraint ??= new ConstraintManager(_host); - _constraint.ConstrainTo(anchor, startDistance, maxDistance); - } - - /// Retail PositionManager::UnConstrain - /// (0x005552b0). - public void UnConstrain() => _constraint?.UnConstrain(); - - /// Retail PositionManager::IsFullyConstrained - /// (0x005552c0) — false when no constraint sub-manager exists. - public bool IsFullyConstrained() => _constraint?.IsFullyConstrained() ?? false; - - /// - /// Retail PositionManager::HandleUpdateTarget (0x005553d0) — only - /// the sticky sub-manager cares about live target positions (interpolation - /// and constraint don't). Fanned out from - /// CPhysicsObj::HandleUpdateTarget. - /// - public void HandleUpdateTarget(TargetInfo info) => _sticky?.HandleUpdateTarget(info); - - /// - /// Retail PositionManager::adjust_offset (0x00555190) — chains the - /// sub-managers' contributions into the SAME - /// accumulator, in retail order. Retail runs Interpolation → Sticky → - /// Constraint; acdream's interpolation stays in the separate remote-motion - /// combiner (see class note), so this chains Sticky → Constraint only. - /// Constraint is LAST because it clamps the already-composed displacement. - /// - public void AdjustOffset(MotionDeltaFrame offset, double quantum) - { - _sticky?.AdjustOffset(offset, quantum); - _constraint?.AdjustOffset(offset, quantum); - } - - /// - /// Retail PositionManager::UseTime (0x00555160) — per-tick pump. - /// Retail order Interpolation → Constraint → Sticky; acdream runs the two - /// owned managers (constraint's UseTime is a retail no-op, so effectively - /// just the sticky 1 s watchdog). - /// - public void UseTime() => _sticky?.UseTime(); -} diff --git a/src/AcDream.Core/Physics/Motion/StickyManager.cs b/src/AcDream.Core/Physics/Motion/StickyManager.cs deleted file mode 100644 index 1646ce97..00000000 --- a/src/AcDream.Core/Physics/Motion/StickyManager.cs +++ /dev/null @@ -1,276 +0,0 @@ -using System; -using System.Numerics; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R5 — verbatim port of retail's StickyManager (acclient.h:31518, -/// struct #3466; decomp block 0x00555400-0x00555866, -/// r5-positionmanager-sticky-decomp.md). Makes the owning object -/// FOLLOW a target object at a bounded gap: each tick -/// steers the mover horizontally toward the -/// target's live (or last-known) position and turns it to face the target, -/// speed- and turn-rate-limited. A 1-second watchdog () -/// drops the stick if no target-position update arrives. -/// -/// Owned by (lazily created on first -/// ). Establishes its target-tracking -/// subscription through the owning 's -/// set_target (→ ); receives the target's -/// live position back through , fanned out from -/// CPhysicsObj::HandleUpdateTarget. -/// -/// The dense x87 back half of retail's adjust_offset is decoded -/// against ACE's StickyManager.cs (the two constants -/// =0.3 and =1.0 are ACE's, -/// verified against the retail mush structure — see the port-plan §2a). -/// -public sealed class StickyManager -{ - /// Retail StickyRadius const (ACE: 0.3f) — the desired - /// follow gap subtracted from the cylinder distance. - public const float StickyRadius = 0.3f; - - /// Retail StickyTime const (ACE: 1.0f) — the one-shot grace - /// window: if no target update refreshes the stick within this many - /// seconds of , drops it. - public const float StickyTime = 1.0f; - - /// Retail get_max_speed multiplier for the follow speed - /// (ACE: ×5 — the follower catches up faster than a normal walk/run). - private const float FollowSpeedFactor = 5.0f; - - /// Retail fallback follow speed when no motion interpreter exists - /// (ACE: 15.0f, i.e. the MAX_VELOCITY constant the mush loads). - private const float FallbackFollowSpeed = 15.0f; - - private readonly IPhysicsObjHost _host; - - /// +0x00 retail target_id — the object we are stuck to - /// (0 = not stuck). - public uint TargetId { get; private set; } - - /// +0x04 retail target_radius — the target's cylinder - /// radius (from CPartArray::GetRadius of the stuck-to object). - public float TargetRadius { get; private set; } - - /// +0x08 retail target_position — last-known target - /// position (from ), used when the live - /// GetObjectA resolve fails. - public Position TargetPosition { get; private set; } - - /// +0x54 retail initialized — false until the first - /// Ok target update arrives (gates and - /// the timeout). - public bool Initialized { get; private set; } - - /// +0x58 retail sticky_timeout_time — the wall-clock - /// deadline set once at time (now + 1 s). - public double StickyTimeoutTime { get; private set; } - - public StickyManager(IPhysicsObjHost host) - => _host = host ?? throw new ArgumentNullException(nameof(host)); - - /// - /// Retail StickyManager::UnStick (0x00555400). No-op if not stuck; - /// otherwise the standard 4-step teardown: clear + - /// , tell the host to clear_target (drop - /// the voyeur subscription), then interrupt_current_movement. - /// - public void UnStick() - { - if (TargetId == 0) - return; - - if (PhysicsDiagnostics.ProbeStickyEnabled) - Console.WriteLine(FormattableString.Invariant( - $"[sticky] guid=0x{_host.Id:X8} UNSTICK target=0x{TargetId:X8}")); - - TargetId = 0; - Initialized = false; - _host.ClearTarget(); - _host.InterruptCurrentMovement(); - } - - /// - /// Retail StickyManager::StickTo (0x00555710). Begin following - /// . If already stuck, tears the old stick down - /// first (same 4-step sequence as ). Sets the 1 s - /// timeout deadline and registers as a voyeur of the target via the host's - /// set_target(context=0, objectId, radius=0.5, quantum=0.5) — the - /// live target position then arrives asynchronously through - /// . - /// - /// Retail arg2 — target object id. - /// Retail arg3 — the target's cylinder - /// radius (feeds 's distance math). - /// Retail arg4 — accepted for call-shape - /// parity but UNUSED in the body (matches retail + ACE; the height feeds - /// the caller-side geometry only). - public void StickTo(uint objectId, float targetRadius, float targetHeight) - { - _ = targetHeight; // retail/ACE: arg4 is read nowhere in this body. - - if (TargetId != 0) - { - // Inlined 4-step teardown of the previous stick (retail 0x00555716). - TargetId = 0; - Initialized = false; - _host.ClearTarget(); - _host.InterruptCurrentMovement(); - } - - TargetRadius = targetRadius; - TargetId = objectId; - Initialized = false; - StickyTimeoutTime = _host.CurTime + StickyTime; - - if (PhysicsDiagnostics.ProbeStickyEnabled) - Console.WriteLine(FormattableString.Invariant( - $"[sticky] guid=0x{_host.Id:X8} STICK target=0x{objectId:X8} tgtR={targetRadius:F2} ownR={_host.Radius:F2} lease={StickyTime:F1}s")); - - // set_target(context_id=0, objectId, radius=0.5, quantum=0.5). - _host.SetTarget(0, objectId, 0.5f, 0.5); - } - - /// - /// Retail StickyManager::UseTime (0x00555610). The 1 s watchdog: if - /// Timer::cur_time >= sticky_timeout_time, force-unstick (same - /// 4-step teardown). The deadline is set once in and - /// NOT refreshed by (retail + ACE) — a - /// stick survives at most 1 s of wall-clock unless re-issued. - /// - public void UseTime() - { - if (TargetId == 0) - return; - - // Strictly AFTER the deadline (retail 0x00555626 `test ah,0x41` — - // C0|C3 clear = cur_time > timeout; ACE `>` too), not >=. - if (_host.CurTime > StickyTimeoutTime) - { - if (PhysicsDiagnostics.ProbeStickyEnabled) - Console.WriteLine(FormattableString.Invariant( - $"[sticky] guid=0x{_host.Id:X8} LEASE-EXPIRE target=0x{TargetId:X8}")); - - TargetId = 0; - Initialized = false; - _host.ClearTarget(); - _host.InterruptCurrentMovement(); - } - } - - /// - /// Retail StickyManager::HandleUpdateTarget (0x00555780). The - /// target-position callback (fanned out from - /// CPhysicsObj::HandleUpdateTarget). - /// Ignores updates whose doesn't match - /// our . On : cache the - /// target position and mark . On any other status - /// (lost/exit/teleport): tear the stick down (4-step). - /// - public void HandleUpdateTarget(TargetInfo info) - { - if (info.ObjectId != TargetId) - return; - - if (info.Status == TargetStatus.Ok) - { - Initialized = true; - TargetPosition = info.TargetPosition; - return; - } - - if (TargetId != 0) - { - if (PhysicsDiagnostics.ProbeStickyEnabled) - Console.WriteLine(FormattableString.Invariant( - $"[sticky] guid=0x{_host.Id:X8} TARGET-{info.Status} teardown target=0x{TargetId:X8}")); - - TargetId = 0; - Initialized = false; - _host.ClearTarget(); - _host.InterruptCurrentMovement(); - } - } - - /// - /// Retail StickyManager::adjust_offset (0x00555430). Writes this - /// tick's follow steering into the shared - /// accumulator: a speed-clamped horizontal position delta toward the - /// target plus a bounded turn to face it. No-op unless stuck AND - /// initialized. See port-plan §2a for the x87-mush decode. - /// - /// The per-tick delta frame - /// ('s shared accumulator). - /// Elapsed time this tick, seconds. - public void AdjustOffset(MotionDeltaFrame offset, double quantum) - { - if (TargetId == 0 || !Initialized) - return; - - var self = _host.Position; - var target = _host.GetObjectA(TargetId); - var targetPos = target != null ? target.Position : TargetPosition; - - // offset = local-frame, Z-flattened vector from self to target. - Vector3 worldOffset = targetPos.Frame.Origin - self.Frame.Origin; // Position::get_offset - Vector3 local = MoveToMath.GlobalToLocalVec(self.Frame.Orientation, worldOffset); - local.Z = 0f; - offset.Origin = local; - - // Signed horizontal cylinder distance past the 0.3 m stick gap. - float dist = MoveToMath.CylinderDistanceNoZ( - _host.Radius, self.Frame.Origin, TargetRadius, targetPos.Frame.Origin) - StickyRadius; - - if (MoveToMath.NormalizeCheckSmall(ref offset.Origin)) - offset.Origin = Vector3.Zero; - - // Follow speed = 5× own max locomotion speed (catch up), fallback 15. - float speed = 0f; - float? maxSpeed = _host.MinterpMaxSpeed; - if (maxSpeed.HasValue) - speed = maxSpeed.Value * FollowSpeedFactor; - if (speed < MoveToMath.Epsilon) - speed = FallbackFollowSpeed; - - // Don't overshoot: clamp the per-tick step to the remaining (signed) - // distance — a negative dist inverts the direction (back off). - // - // DEEP-OVERLAP SIGN PIN (#171 gate-3, register AP row): ACE's literal - // line is only `if (delta >= |dist|) delta = dist;` — when the - // overlap is DEEPER than one tick's step, delta keeps its positive - // sign and steers TOWARD the target center, a runaway whose - // equilibrium is centers-coincident (gate-3 probe: 1661 deep-overlap - // ticks, all inward, monsters converged to centerDist≈0 — the - // "monster inside the player" report; retail side-by-side shows - // separation). ACE servers essentially never reach that branch - // (quantum ≥1/30 × speed ≈31 → threshold ~1 m); at render-rate - // quanta the threshold is ~0.13 m and pack jostle trips it - // constantly. The BN mush (0x00555554-0x00555597) is unreadable on - // exactly this compare; the sign-correct clamp below is the minimal - // interpretation consistent with the mush structure AND observed - // retail — identical to ACE everywhere except deep-overlap, where it - // backs off rate-limited instead of creeping inward. - float delta = speed * (float)quantum; - if (delta >= MathF.Abs(dist)) - delta = dist; - else if (dist < 0f) - delta = -delta; - offset.Origin *= delta; - - // Bounded turn to face the target (relative heading this tick). - float curHeading = MoveToMath.GetHeading(self.Frame.Orientation); - float targetHeading = MoveToMath.PositionHeading(self.Frame.Origin, targetPos.Frame.Origin); - float heading = targetHeading - curHeading; - if (MathF.Abs(heading) < MoveToMath.Epsilon) - heading = 0f; - if (heading < -MoveToMath.Epsilon) - heading += 360f; - offset.SetHeading(heading); - - if (PhysicsDiagnostics.ProbeStickyEnabled) - Console.WriteLine(FormattableString.Invariant( - $"[sticky] guid=0x{_host.Id:X8} ADJ dist={dist:F3} delta={delta:F3} speed={speed:F1} hdgDelta={heading:F1} live={(target is not null ? 1 : 0)}")); - } -} diff --git a/src/AcDream.Core/Physics/Motion/TargetManager.cs b/src/AcDream.Core/Physics/Motion/TargetManager.cs deleted file mode 100644 index 1567f979..00000000 --- a/src/AcDream.Core/Physics/Motion/TargetManager.cs +++ /dev/null @@ -1,300 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; - -namespace AcDream.Core.Physics.Motion; - -/// -/// R5 — port of retail's TargetManager (acclient.h:31024, struct #3484; -/// decomp 0x0051a370-0x0051ad90, r5-targetmanager-decomp.md). A -/// peer-to-peer voyeur subscription system with two roles per object: -/// -/// -/// Watcher (): -/// registers this object as a voyeur ON a target; the target's per-tick -/// pushes position updates back here via -/// , which fans them to the owning host's -/// MoveToManager + PositionManager (sticky) through -/// . -/// Watched (): other objects' -/// subscribe to THIS object; each tick -/// sends a -/// dead-reckoned update to any subscriber the object has drifted past the -/// subscriber's radius from. -/// -/// -/// This REPLACES the AP-79 minimal TargetTracker adapter (GameWindow -/// polling the entity table). It is a faithful superset: the same -/// move-to tracking (distance > radius → HandleUpdateTarget(Ok)) plus the -/// correct sticky, 10 s timeout, and exit/teleport event handling. -/// -/// Owned 1:1 by an (retail -/// CPhysicsObj::target_manager, lazily created on first -/// set_target/add_voyeur). The two throttle constants -/// (=0.5, =10) are -/// ACE's, verified against the retail x87 mush — port-plan §2d. -/// -public sealed class TargetManager -{ - /// Retail HandleTargetting per-tick throttle (ACE: 0.5s) — - /// the voyeur sweep runs at most this often. - public const double ThrottleSeconds = 0.5; - - /// Retail target-info staleness timeout (ACE: 10.0s) — an - /// Undefined-status target with no update for this long is marked - /// TimedOut. - public const double StalenessSeconds = 10.0; - - private readonly IPhysicsObjHost _host; - - private TargetInfo? _targetInfo; // retail target_info (watcher role) - private Dictionary? _voyeurTable; // retail voyeur_table (watched role) - private double _lastUpdateTime; // retail last_update_time (throttle base) - - public TargetManager(IPhysicsObjHost host) - => _host = host ?? throw new ArgumentNullException(nameof(host)); - - /// The current watched-target info, or null when tracking - /// nothing. - public TargetInfo? TargetInfo => _targetInfo; - - /// The subscriber table (null until the first - /// ). - public IReadOnlyDictionary? VoyeurTable => _voyeurTable; - - /// Retail get_target_quantum — the current target's - /// quantum, 0 when tracking nothing. - public double GetTargetQuantum() => _targetInfo?.Quantum ?? 0.0; - - // ── watcher role ─────────────────────────────────────────────────────── - - /// - /// Retail TargetManager::SetTarget (0x0051ac30). Tear down any - /// existing target, then: if is 0, synthesize a - /// TimedOut clear-update to the host and leave - /// null; otherwise create a fresh (status - /// Undefined) and subscribe as a voyeur ON the target - /// (target.add_voyeur(self.id, radius, quantum)). The target's live - /// position arrives asynchronously via . - /// - public void SetTarget(uint contextId, uint objectId, float radius, double quantum) - { - ClearTarget(); - - if (objectId == 0) - { - // Clear/cancel: report a TimedOut update carrying the context, - // leave _targetInfo null. (Retail var_10_1 = 6 = TimedOut.) - var cleared = new TargetInfo( - ObjectId: 0, Status: TargetStatus.TimedOut, - TargetPosition: default, InterpolatedPosition: default, - ContextId: contextId); - _host.HandleUpdateTarget(cleared); - return; - } - - _targetInfo = new TargetInfo( - ObjectId: objectId, Status: TargetStatus.Undefined, - TargetPosition: default, InterpolatedPosition: default, - ContextId: contextId, Radius: radius, Quantum: quantum, - LastUpdateTime: _host.CurTime); - - var target = _host.GetObjectA(objectId); - target?.AddVoyeur(_host.Id, radius, quantum); - } - - /// - /// Retail TargetManager::SetTargetQuantum (0x0051a4a0). Update the - /// current target's resend interval and re-register the voyeur subscription - /// on the target with the new quantum. - /// - public void SetTargetQuantum(double quantum) - { - if (_targetInfo is not { } ti) - return; - - _targetInfo = ti with { Quantum = quantum }; - var target = _host.GetObjectA(ti.ObjectId); - target?.AddVoyeur(_host.Id, ti.Radius, quantum); - } - - /// - /// Retail TargetManager::ClearTarget (0x0051a7e0). Unsubscribe from - /// the current target's voyeur table and drop . - /// - public void ClearTarget() - { - if (_targetInfo is not { } ti) - return; - - var target = _host.GetObjectA(ti.ObjectId); - target?.RemoveVoyeur(_host.Id); - _targetInfo = null; - } - - /// - /// Retail TargetManager::ReceiveUpdate (0x0051a930). The inbound - /// handler when a target we watch sends us its position (via - /// SendVoyeurUpdatereceive_target_update). Ignores updates - /// for anything but our current target. Copies the payload, stamps receipt - /// time, recomputes the self→target interpolated heading (falls back to +Z - /// when degenerate), fans the snapshot to the host, and drops the - /// subscription on an ExitWorld status. - /// - public void ReceiveUpdate(TargetInfo update) - { - if (_targetInfo is not { } ti || ti.ObjectId != update.ObjectId) - return; - - // Copy radius/quantum/positions/velocity/status from the wire; keep our - // object_id; stamp receipt time. - Vector3 interpHeading = update.InterpolatedPosition.Frame.Origin - - _host.Position.Frame.Origin; - if (MoveToMath.NormalizeCheckSmall(ref interpHeading)) - interpHeading = Vector3.UnitZ; - - var updated = ti with - { - Radius = update.Radius, - Quantum = update.Quantum, - TargetPosition = update.TargetPosition, - InterpolatedPosition = update.InterpolatedPosition, - Velocity = update.Velocity, - Status = update.Status, - InterpolatedHeading = interpHeading, - LastUpdateTime = _host.CurTime, - }; - _targetInfo = updated; - - _host.HandleUpdateTarget(updated); - - if (update.Status == TargetStatus.ExitWorld) - ClearTarget(); - } - - // ── watched role ─────────────────────────────────────────────────────── - - /// - /// Retail TargetManager::AddVoyeur (0x0051a830). A subscriber - /// registers to watch this object. If already subscribed, updates its - /// radius/quantum in place (no immediate send); otherwise creates the entry - /// and pushes an immediate initial snapshot (Ok). - /// - public void AddVoyeur(uint watcherId, float radius, double quantum) - { - _voyeurTable ??= new Dictionary(); - - if (_voyeurTable.TryGetValue(watcherId, out var existing)) - { - existing.Radius = radius; - existing.Quantum = quantum; - return; - } - - var voyeur = new TargettedVoyeurInfo(watcherId, radius, quantum); - _voyeurTable[watcherId] = voyeur; - SendVoyeurUpdate(voyeur, _host.Position, TargetStatus.Ok); - } - - /// Retail TargetManager::RemoveVoyeur (0x0051ad90). - public bool RemoveVoyeur(uint watcherId) - => _voyeurTable?.Remove(watcherId) ?? false; - - /// - /// Retail TargetManager::HandleTargetting (0x0051aa90). THE per-tick - /// driver (no separate UseTime): self-throttled to - /// , promotes a stale target to TimedOut after - /// , then sweeps every subscriber through - /// . - /// - public void HandleTargetting() - { - if (_host.PhysicsTimerTime - _lastUpdateTime < ThrottleSeconds) - return; - - if (_targetInfo is { } ti) - { - if (ti.Status == TargetStatus.Undefined - && ti.LastUpdateTime + StalenessSeconds < _host.CurTime) - { - var timedOut = ti with { Status = TargetStatus.TimedOut }; - _targetInfo = timedOut; - _host.HandleUpdateTarget(timedOut); - } - } - - if (_voyeurTable != null) - { - foreach (var voyeur in _voyeurTable.Values.ToList()) - CheckAndUpdateVoyeur(voyeur); - } - - _lastUpdateTime = _host.PhysicsTimerTime; - } - - /// - /// Retail TargetManager::CheckAndUpdateVoyeur (0x0051a650). Push an - /// update to only if this object's dead-reckoned - /// position has drifted more than the voyeur's radius since the last send. - /// - public void CheckAndUpdateVoyeur(TargettedVoyeurInfo voyeur) - { - Position newPos = GetInterpolatedPosition(voyeur.Quantum); - float drift = Vector3.Distance( - newPos.Frame.Origin, voyeur.LastSentPosition.Frame.Origin); - if (drift > voyeur.Radius) - SendVoyeurUpdate(voyeur, newPos, TargetStatus.Ok); - } - - /// - /// Retail TargetManager::GetInterpolatedPosition (0x0051a5e0). - /// Dead-reckon this object's position forward by - /// seconds using its current velocity. - /// - public Position GetInterpolatedPosition(double quantum) - { - var pos = _host.Position; - Vector3 origin = pos.Frame.Origin + _host.Velocity * (float)quantum; - return new Position(pos.ObjCellId, origin, pos.Frame.Orientation); - } - - /// - /// Retail TargetManager::SendVoyeurUpdate (0x0051a4f0). Record the - /// sent position on the voyeur, build a carrying - /// this object's CURRENT authoritative position + the extrapolated - /// + velocity + status, and deliver it to the - /// subscriber's (via its host). - /// - public void SendVoyeurUpdate(TargettedVoyeurInfo voyeur, Position pos, TargetStatus status) - { - voyeur.LastSentPosition = pos; - - var info = new TargetInfo( - ObjectId: _host.Id, - Status: status, - TargetPosition: _host.Position, // current authoritative - InterpolatedPosition: pos, // the extrapolated position - ContextId: 0, - Radius: voyeur.Radius, - Quantum: voyeur.Quantum, - Velocity: _host.Velocity); - - var voyeurObj = _host.GetObjectA(voyeur.ObjectId); - voyeurObj?.ReceiveTargetUpdate(info); - } - - /// - /// Retail TargetManager::NotifyVoyeurOfEvent (0x0051a6f0). Broadcast - /// a discrete status event (e.g. ExitWorld, Teleported) to every subscriber - /// with this object's CURRENT position — no distance gate. - /// - public void NotifyVoyeurOfEvent(TargetStatus status) - { - if (_voyeurTable == null) - return; - - foreach (var voyeur in _voyeurTable.Values.ToList()) - SendVoyeurUpdate(voyeur, _host.Position, status); - } -} diff --git a/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs b/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs deleted file mode 100644 index 10631681..00000000 --- a/src/AcDream.Core/Physics/Motion/TargettedVoyeurInfo.cs +++ /dev/null @@ -1,36 +0,0 @@ -namespace AcDream.Core.Physics.Motion; - -/// -/// R5 — port of retail's TargettedVoyeurInfo (acclient.h:52807, -/// struct #5801). One entry in a 's voyeur table: -/// a subscriber watching THIS object, the send-on-move -/// threshold it registered, its dead-reckoning , and the -/// already delivered to it (the delta baseline -/// CheckAndUpdateVoyeur compares against). Mutable class (retail heap -/// record updated in place by AddVoyeur/SendVoyeurUpdate). -/// -public sealed class TargettedVoyeurInfo -{ - /// +0x00 retail object_id — the subscriber's guid. - public uint ObjectId { get; } - - /// +0x04 retail quantum — the subscriber's dead-reckoning - /// lookahead horizon (seconds). - public double Quantum { get; set; } - - /// +0x10 retail radius — the send-on-move threshold: an - /// update is pushed only when the tracked object drifts more than this from - /// . - public float Radius { get; set; } - - /// +0x14 retail last_sent_position — the position last - /// delivered to this subscriber (updated by SendVoyeurUpdate). - public Position LastSentPosition { get; set; } - - public TargettedVoyeurInfo(uint objectId, float radius, double quantum) - { - ObjectId = objectId; - Radius = radius; - Quantum = quantum; - } -} diff --git a/src/AcDream.Core/Physics/MotionCommandResolver.cs b/src/AcDream.Core/Physics/MotionCommandResolver.cs index ce16c4b9..016d8e16 100644 --- a/src/AcDream.Core/Physics/MotionCommandResolver.cs +++ b/src/AcDream.Core/Physics/MotionCommandResolver.cs @@ -1,8 +1,12 @@ +using System; +using System.Collections.Generic; +using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; + namespace AcDream.Core.Physics; /// -/// Reconstructs the 32-bit retail MotionCommand value from a 16-bit wire -/// value broadcast in InterpretedMotionState.Commands[]. +/// Reconstructs the 32-bit retail value from +/// a 16-bit wire value broadcast in InterpretedMotionState.Commands[]. /// /// /// The server serializes MotionCommands as u16 (ACE @@ -15,19 +19,11 @@ namespace AcDream.Core.Physics; /// /// /// -/// As of the L.1b command-catalog slice, this static facade delegates to a -/// single shared instance — the -/// runtime-default catalog built from the DatReaderWriter -/// MotionCommand enum (matches ACE + the local DATs). All ~10 -/// existing runtime callers (AnimationCommandRouter, -/// CombatAnimationPlanner, 8x GameWindow) are unaffected by -/// this refactor — the public signature and behavior for the ACE/runtime -/// path is unchanged. A second catalog, -/// , is available for -/// conformance/reference work against the Sept 2013 EoR decomp's own -/// (differently-numbered) command table; callers that need that catalog -/// must construct it directly via the -/// seam — this facade only ever returns ACE/runtime values. +/// This is implemented as an eager lookup table built from all values of +/// via reflection. If the wire value matches +/// more than one enum value (different class bits), we prefer the +/// lowest-class-numbered variant that has a non-zero class byte — roughly +/// matching retail priority (Action < Modifier < SubState < Style). /// /// /// @@ -46,26 +42,66 @@ namespace AcDream.Core.Physics; /// docs/research/deepdives/r03-motion-animation.md §3 — complete /// command catalogue. /// -/// -/// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md — -/// the ACE-vs-2013 catalog divergence that motivated the dual-catalog -/// seam (the shift begins at SnowAngelState, 0x43000115 -> -/// 0x43000118, not at AllegianceHometownRecall). -/// /// /// /// public static class MotionCommandResolver { - private static readonly AceModernCommandCatalog s_aceModern = new(); + // Lookup table built eagerly at type-init. Sparse: only values that + // appear in the DRW enum (which came from the generated protocol XML) + // are present. ~450 entries typical. + private static readonly Dictionary s_lookup = BuildLookup(); /// /// Given a 16-bit wire value, return the full 32-bit MotionCommand - /// (class byte restored) per the ACE/runtime catalog. Returns 0 if no - /// matching value exists. + /// (class byte restored). Returns 0 if no matching enum value exists. /// public static uint ReconstructFullCommand(ushort wireCommand) { - return s_aceModern.ReconstructFullCommand(wireCommand); + if (wireCommand == 0) return 0u; + s_lookup.TryGetValue(wireCommand, out var full); + return full; + } + + private static Dictionary BuildLookup() + { + var result = new Dictionary(512); + var values = Enum.GetValues(typeof(DRWMotionCommand)); + foreach (DRWMotionCommand v in values) + { + uint full = (uint)v; + ushort lo = (ushort)(full & 0xFFFFu); + if (lo == 0) continue; // Invalid / unmappable + + // If a value with this low-16-bit already exists, keep the one + // with the lower class byte (Action=0x10 beats SubState=0x41 + // beats Style=0x80). This matches retail: the server tends to + // emit Actions and ChatEmotes far more often than Styles, so + // the Action-class reconstruction is the common case. + if (!result.TryGetValue(lo, out var existing) + || (full >> 24) < (existing >> 24)) + { + result[lo] = full; + } + } + + ApplyNamedRetailOverrides(result); + return result; + } + + private static void ApplyNamedRetailOverrides(Dictionary result) + { + // The generated DRW enum is shifted by three entries starting at + // AllegianceHometownRecall. The named Sept 2013 retail command_ids + // table is authoritative here: + // named-retail/acclient_2013_pseudo_c.txt lines 1017626-1017658 + // and command-name table lines 1068272-1068313. + // + // These values cover recall, offhand, attack 4-6, and fast/slow punch + // actions. Without the override, wire command 0x0170 reconstructs to + // IssueSlashCommand instead of OffhandSlashHigh, so offhand swing + // animations route as UI commands and never play. + for (ushort lo = 0x016E; lo <= 0x0197; lo++) + result[lo] = 0x10000000u | lo; } } diff --git a/src/AcDream.Core/Physics/MotionInterpreter.cs b/src/AcDream.Core/Physics/MotionInterpreter.cs index d5e1dd73..ec1006e1 100644 --- a/src/AcDream.Core/Physics/MotionInterpreter.cs +++ b/src/AcDream.Core/Physics/MotionInterpreter.cs @@ -1,7 +1,5 @@ using System; -using System.Collections.Generic; using System.Numerics; -using AcDream.Core.Physics.Motion; namespace AcDream.Core.Physics; @@ -15,15 +13,11 @@ namespace AcDream.Core.Physics; // FUN_00528960 get_state_velocity — compute world-space velocity for current motion // FUN_00529210 apply_current_movement — apply interpreted motion as velocity // FUN_00529390 jump — initiate jump: validate, record extent, leave ground -// FUN_005286b0 GetJumpVZ — get vertical jump velocity (R3-W3 rename) -// FUN_00528cd0 GetLeaveGroundVelocity — compose full 3D launch vector (R3-W3 rename) -// FUN_00528ec0 jump_is_allowed — can we jump? (R3-W3: full verbatim chain) +// FUN_005286b0 get_jump_v_z — get vertical jump velocity +// FUN_00528cd0 get_leave_ground_velocity — compose full 3D launch vector +// FUN_00528ec0 jump_is_allowed — can we jump? // FUN_00528dd0 contact_allows_move — slope angle / contact state check // -// R3-W3 jump-family addresses (docs/research/2026-07-02-r3-motioninterp/): -// 0x00527a50 JumpChargeIsAllowed -// 0x005281c0 ChargeJump — the ONLY place StandingLongJump arms (closes J6) -// // Cross-checked against ACE MotionInterp.cs. // ───────────────────────────────────────────────────────────────────────────── @@ -95,31 +89,11 @@ public static class MotionCommand } /// -/// Movement type passed in PerformMovement's switch statement. Matches -/// retail's MovementTypes::Type (acclient.h:2856, enum #229) in full. -/// -/// -/// R4-V1 widening (closes M11). Values 1-5 dispatch through -/// CMotionInterp (the 5-case switch at FUN_00529a90, unchanged since -/// R1-R3); values 6-9 dispatch through MoveToManager -/// (MovementManager::PerformMovement, r4-moveto-decomp.md §2b: -/// (type - 1) > 8 → 0x47, case 0-4 → CMotionInterp, case 5-8 → -/// MoveToManager). Invalid=0 and values > 9 both fail with -/// (0x47) at the -/// MovementManager level — no consumer wiring changes in this slice -/// (mechanical, additive-only; MoveToManager itself is R4-V2+). -/// +/// Movement type passed in PerformMovement's switch statement. +/// Matches the 5-case switch at FUN_00529a90. /// public enum MovementType { - /// - /// 0 — no movement in progress / uninitialized. R4-V1 addition (M11). - /// MoveToManager::InitializeLocalVariables resets - /// movement_type to this value; MovementManager::PerformMovement - /// rejects it with 0x47 (§2b: (type - 1) > 8 underflows to a - /// huge unsigned value for type 0, which is always > 8). - /// - Invalid = 0, /// case 1 — raw motion command (DoMotion). RawCommand = 1, /// case 2 — interpreted motion command (DoInterpretedMotion). @@ -130,190 +104,65 @@ public enum MovementType StopInterpretedCommand = 4, /// case 5 — stop completely (StopCompletely). StopCompletely = 5, - /// - /// 6 — MoveToObject. R4-V1 addition (M11). Dispatches to - /// MoveToManager::MoveToObject (r4-moveto-decomp.md §3b); uses - /// // - /// /. - /// - MoveToObject = 6, - /// - /// 7 — MoveToPosition. R4-V1 addition (M11). Dispatches to - /// MoveToManager::MoveToPosition (§3c); uses - /// . - /// - MoveToPosition = 7, - /// - /// 8 — TurnToObject. R4-V1 addition (M11). Dispatches to - /// MoveToManager::TurnToObject (§3d); uses - /// /. - /// - TurnToObject = 8, - /// - /// 9 — TurnToHeading. R4-V1 addition (M11). Dispatches to - /// MoveToManager::TurnToHeading (§3e); uses - /// 's DesiredHeading. - /// - TurnToHeading = 9, } /// -/// WeenieError-shaped codes returned by CMotionInterp methods. Values are -/// the hex constants used directly in the decompiled C code. -/// -/// -/// R3-W1 renumber (closes J16-codes/A10). Prior to this slice, the -/// names were shuffled relative to retail's actual numeric semantics -/// (GeneralMovementFailure was assigned 0x24 and used at the -/// airborne-jump gate, but retail's 0x24 means "not grounded / no contact" -/// and retail's 0x47 is the general-movement-failure code; 0x48 meant -/// "cannot jump while in the air" here, but retail's 0x48 means "jump -/// blocked by the CURRENT MOTION OR POSITION" — a blocklist check -/// (motion_allows_jump), not an airborne check). Renumbered per the -/// definitive, exhaustively-swept table in -/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A10 (19 -/// return sites + 1 store site over raw 304908-306277 + 300150-300540). -/// These codes are LOCAL-ONLY — never serialized to the wire — so the -/// renumber is safe (verified: no consumer outside -/// MotionInterpreter.cs pattern-matches on the numeric value). -/// +/// WeenieError codes returned by CMotionInterp methods. +/// Values are the hex constants used directly in the decompiled C code. /// public enum WeenieError : uint { /// 0x00 — success. None = 0x00, - /// - /// 0x08 — no physics_obj. Sites (A10): StopCompletely @305214; - /// DoInterpretedMotion @305579; StopInterpretedMotion @305639; - /// StopMotion @305680; jump @305798; DoMotion @306165. - /// + /// 0x08 — PhysicsObj is null. NoPhysicsObject = 0x08, - /// - /// 0x0B — NoMotionInterpreter. R4-V1 addition (M12), per - /// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §12 constants - /// inventory (8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47). ACE name; - /// the retail sites that store this code were not individually - /// extracted in the R4 pass (no MoveToManager consumer in this slice — - /// V1 pins the numeric value only). - /// - NoMotionInterpreter = 0x0B, - /// - /// 0x24 — not grounded / no contact. Sites (A10): - /// jump_is_allowed @305570 (gravity-active creature without - /// Contact+OnWalkable; also the physics_obj == null case, which - /// falls out to this same code per the A10 note — NOT 8); - /// DoInterpretedMotion @305622-305623 (action-class motion - /// blocked by contact_allows_move). - /// - NotGrounded = 0x24, - /// - /// 0x3f — Crouch (0x41000012) rejected while in combat stance. Site: - /// DoMotion @306196. - /// - CrouchInCombatStance = 0x3f, - /// - /// 0x40 — Sitting (0x41000013) rejected while in combat stance. Site: - /// DoMotion @306199. - /// - SitInCombatStance = 0x40, - /// - /// 0x41 — Sleeping (0x41000014) rejected while in combat stance. Site: - /// DoMotion @306202. - /// - SleepInCombatStance = 0x41, - /// - /// 0x42 — motion & 0x2000000 (the chat-emote bit) rejected - /// outside NonCombat (0x8000003d). Site: DoMotion @306205. - /// - ChatEmoteOutsideNonCombat = 0x42, - /// - /// 0x36 — ActionCancelled. R4-V1 addition (M12). Site: - /// MoveToManager::PerformMovement (r4-moveto-decomp.md §3a - /// @0052a901) — every new moveto cancels the previous one with this - /// code before dispatching; also CPhysicsObj::interrupt_current_movement - /// → MovementManager::CancelMoveTo(0x36) (§9e — the TS-36 cancel - /// entry). Per §7c, MoveToManager::CancelMoveTo's WeenieError arg - /// is NEVER READ in this build's body — kept for parity/logging only. - /// - ActionCancelled = 0x36, - /// - /// 0x37 — ObjectGone. R4-V1 addition (M12). Site: - /// MoveToManager::HandleUpdateTarget (§6d @307866-307867) — a - /// RETARGET delivery arrives with a non-OK target status (the target - /// object was already being tracked, then went away). - /// - ObjectGone = 0x37, - /// - /// 0x38 — NoObject. R4-V1 addition (M12). Site: - /// MoveToManager::HandleUpdateTarget (§6d @307857-307858) — the - /// FIRST target callback arrives with a non-OK status (the target never - /// resolved in the first place). - /// - NoObject = 0x38, - /// - /// 0x45 — action-queue depth cap: an action-class motion (bit - /// 0x10000000) with GetNumActions() >= 6 pending. Site: - /// DoMotion @306209. - /// - ActionDepthExceeded = 0x45, - /// - /// 0x47 — general movement failure. Sites (A10): - /// jump_is_allowed @305525 (IsFullyConstrained); - /// @305549+305556 (JumpStaminaCost refusal); - /// CMotionInterp::PerformMovement @306227 (dispatch type-1 > 4); - /// MovementManager::PerformMovement @300201 (dispatch type-1 > 8). - /// - GeneralMovementFailure = 0x47, - /// - /// 0x48 — jump BLOCKED by the current motion or position (A1: the - /// motion_allows_jump literal-range blocklist — NOT an airborne - /// check; airborne is 0x24). Sites (A10): - /// motion_allows_jump @304930; jump_charge_is_allowed - /// @304948 (Fallen or Crouch..Sleeping); charge_jump @305459 - /// (same predicate); STORED (not returned) as the queue node's - /// jump_error_code in DoInterpretedMotion @305605 when - /// disable_jump_during_link is set. - /// - YouCantJumpFromThisPosition = 0x48, - /// - /// 0x49 — the weenie's CanJump(jump_extent) virtual refused - /// (e.g. stamina/burden gate). Sites: jump_charge_is_allowed - /// @304941; charge_jump @305454. - /// + /// 0x24 — general movement failure. + GeneralMovementFailure = 0x24, + /// 0x47 — cannot jump from this position (motion state blocks it). + YouCantJumpFromThisPosition = 0x47, + /// 0x48 — cannot jump while in the air. + YouCantJumpWhileInTheAir = 0x48, + /// 0x49 — loaded down / weenie blocked the jump. CantJumpLoadedDown = 0x49, - /// - /// 0x3D — YouChargedTooFar. R4-V1 addition (M12). Site: - /// MoveToManager::HandleMoveToPosition Phase 2 arrival check - /// (r4-moveto-decomp.md §6b) — the fail_distance progress gate - /// exceeded (CheckProgressMade §5b failing for >1s AND the - /// mover overshot fail_distance). NOTE: numerically out of A10's - /// increasing order (0x3D < 0x3F/0x40/0x41/0x42/0x45) because it was - /// not part of the CMotionInterp jump-family sweep this code sits - /// beside — it belongs to the MoveToManager family instead (§7c, §12). - /// - YouChargedTooFar = 0x3D, } // ── Motion state structs ─────────────────────────────────────────────────────── /// -/// Interpreted motion state, derived from the raw state (retail -/// InterpretedMotionState, ctor 0x0051e8d0, decomp 293418-293431). +/// Raw (network-derived) motion state for the local player. +/// Struct layout in chunk_00520000 starts at offset +0x14 (struct field +0x20 = +/// ForwardCommand, +0x28 = ForwardSpeed, etc.). +/// +public struct RawMotionState +{ + /// Forward/backward motion command (offset +0x20). + public uint ForwardCommand; + /// Speed scalar for forward motion (offset +0x28). + public float ForwardSpeed; + /// Sidestep command (offset +0x2C). + public uint SideStepCommand; + /// Speed scalar for sidestep (offset +0x34, inferred from ACE). + public float SideStepSpeed; + /// Turn command (offset +0x38). + public uint TurnCommand; + /// Speed scalar for turn (offset +0x40, inferred). + public float TurnSpeed; + + /// Initialize to the idle/ready state (1.0 speed, Ready command). + public static RawMotionState Default() => new() + { + ForwardCommand = MotionCommand.Ready, + ForwardSpeed = 1.0f, + SideStepCommand = 0, + SideStepSpeed = 1.0f, + TurnCommand = 0, + TurnSpeed = 1.0f, + }; +} + +/// +/// Interpreted motion state, derived from the raw state. /// Struct layout: starts at offset +0x44 (ForwardCommand at +0x4C, ForwardSpeed at +0x50). -/// -/// -/// R3-W1 (closes J2): gains retail's action FIFO -/// (/// -/// /) — previously this was -/// a flat 6-field struct with no action tracking, so -/// DoMotion's GetNumActions() >= 6 depth cap and -/// MotionDone's action-class RemoveAction pop had nothing to -/// operate on. The action list is backed by a private -/// lazily created on first mutation (defensive -/// against a bare default(InterpretedMotionState), which C# structs -/// permit even though every constructor in this file routes through -/// ). -/// /// public struct InterpretedMotionState { @@ -329,20 +178,6 @@ public struct InterpretedMotionState public uint TurnCommand; /// Speed scalar for turn (offset +0x60). public float TurnSpeed; - /// Current style / stance (retail current_style). Adopted from - /// the raw state's style channel; NOT part of retail's - /// InterpretedMotionState::ApplyMotion dispatch itself (that - /// writes this->current_style only via the negative-motion - /// branch — see ). Default NonCombat - /// 0x8000003D. - public uint CurrentStyle; - - private List? _actions; - - /// Action FIFO in retail order (oldest first). Empty (never - /// null) when read — the private field is lazily created. - public readonly IReadOnlyList Actions - => (IReadOnlyList?)_actions ?? Array.Empty(); /// Initialize to the idle/ready state. public static InterpretedMotionState Default() => new() @@ -353,180 +188,18 @@ public struct InterpretedMotionState SideStepSpeed = 1.0f, TurnCommand = 0, TurnSpeed = 1.0f, - CurrentStyle = 0x8000003Du, }; - - /// - /// InterpretedMotionState::AddAction (0x0051e9e0, decomp - /// 293500-293527): unconditional tail-append of - /// {motion, speed, action_stamp, autonomous}. Identical shape to - /// — retail duplicates the - /// LListData append logic per state type rather than sharing it. - /// - public void AddAction(uint motion, float speed, uint actionStamp, bool autonomous) - { - _actions ??= new List(); - _actions.Add(new RawMotionAction( - Command: (ushort)motion, - Stamp: (ushort)actionStamp, - Autonomous: autonomous, - Speed: speed)); - } - - /// - /// InterpretedMotionState::RemoveAction (0x0051ead0, decomp - /// 293568-293586): pop the FIFO head unconditionally, returning its - /// motion field (0 when empty). - /// - public uint RemoveAction() - { - if (_actions is null || _actions.Count == 0) - return 0; - var head = _actions[0]; - _actions.RemoveAt(0); - return head.Command; - } - - /// - /// InterpretedMotionState::GetNumActions (0x0051eb00, decomp - /// 293590-293603): count the FIFO by walking it (retail has no O(1) - /// count field on the LList). - /// - public readonly uint GetNumActions() => (uint)(_actions?.Count ?? 0); - - /// - /// InterpretedMotionState::ApplyMotion (0x0051ea40, decomp - /// 293531-293564). Verbatim dispatch, quoted from the raw named decomp: - /// - /// if (arg2 == 0x6500000d) { turn_command = arg2; turn_speed = params.speed; return; } - /// if (arg2 == 0x6500000f) { sidestep_command = arg2; sidestep_speed = params.speed; return; } - /// if ((arg2 & 0x40000000) != 0) { forward_command = arg2; forward_speed = params.speed; return; } - /// if (arg2 < 0) { forward_command = 0x41000003; current_style = arg2; return; } - /// if ((arg2 & 0x10000000) != 0) - /// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield>>0xc)&1); - /// - /// Note only TurnRight (0x6500000d) and SideStepRight (0x6500000f) are - /// tested here — unlike , the - /// LEFT variants (TurnLeft/SideStepLeft) are NOT separately cased; - /// retail's adjust_motion normalizes Left→Right upstream before - /// this ever runs, so this asymmetry is verbatim, not a gap. - /// - /// Retail arg2 — the motion id AFTER - /// adjust_motion normalization (the ADJUSTED id, unlike - /// which takes the ORIGINAL). - /// Retail arg3 (MovementParameters const*). - public void ApplyMotion(uint motion, MovementParameters p) - { - if (motion == 0x6500000du) // TurnRight - { - TurnCommand = motion; - TurnSpeed = p.Speed; - return; - } - if (motion == 0x6500000fu) // SideStepRight - { - SideStepCommand = motion; - SideStepSpeed = p.Speed; - return; - } - if ((motion & 0x40000000u) != 0) - { - ForwardCommand = motion; - ForwardSpeed = p.Speed; - return; - } - if (motion >= 0x80000000u) // arg2 < 0 as signed int32 - { - ForwardCommand = 0x41000003u; - CurrentStyle = motion; - return; - } - if ((motion & 0x10000000u) != 0) - AddAction(motion, p.Speed, p.ActionStamp, p.Autonomous); - } - - /// - /// InterpretedMotionState::RemoveMotion (0x0051e790, decomp - /// 293315-293340): - /// - /// if (arg2 == 0x6500000d) { turn_command = 0; return; } - /// if (arg2 == 0x6500000f) { sidestep_command = 0; return; } - /// if ((arg2 & 0x40000000) == 0) { - /// if (arg2 < 0 && arg2 == current_style) current_style = 0x8000003d; - /// } else if (arg2 == forward_command) { - /// forward_command = 0x41000003; forward_speed = 1f; - /// } - /// - /// Note the asymmetric range test vs - /// (which uses (arg2 - 0x6500000d) > 3 covering all four - /// turn/sidestep ids) — this one only special-cases the RIGHT variants - /// by exact equality; TurnLeft/SideStepLeft fall through to the - /// style/forward-command branch below. Verbatim, not a gap (mirrors - /// retail's own asymmetry between the two RemoveMotion bodies). - /// - /// Retail arg2. - public void RemoveMotion(uint motion) - { - if (motion == 0x6500000du) - { - TurnCommand = 0; - return; - } - if (motion == 0x6500000fu) - { - SideStepCommand = 0; - return; - } - if ((motion & 0x40000000u) == 0) - { - if (motion >= 0x80000000u && motion == CurrentStyle) - CurrentStyle = 0x8000003du; - } - else if (motion == ForwardCommand) - { - ForwardCommand = 0x41000003u; - ForwardSpeed = 1f; - } - } } /// /// Lightweight struct passed into PerformMovement. /// Fields correspond to what the retail dispatcher read from param_1 (the movement packet struct). -/// -/// -/// R4-V1 widening (closes M11). Retail's full MovementStruct -/// (acclient.h:38069, struct #4067): -/// -/// struct __cppobj MovementStruct -/// { -/// MovementTypes::Type type; -/// unsigned int motion; // types 1-4 only -/// unsigned int object_id; // types 6, 8 -/// unsigned int top_level_id; // types 6, 8 -/// Position pos; // type 7 -/// float radius; // type 6 -/// float height; // type 6 -/// MovementParameters *params; // types 1-4, 6-9 -/// }; -/// -/// /// -/// // are the -/// R4-V1 additions — additive only, no consumer wiring in this slice -/// (MoveToManager itself is R4-V2). The pre-R4 fields (/ -/// /// -/// /) are -/// untouched. uses acdream's -/// (ObjCellId + CellFrame) rather than retail's block-local Position — -/// V0-pins.md §P5: distances are equivalent after rebase in acdream's -/// streaming-world space. -/// /// public struct MovementStruct { - /// Which movement type to dispatch (retail MovementTypes::Type, full 0-9 range). + /// Which of the 5 motion types to dispatch. public MovementType Type; - /// Motion command ID (e.g. WalkForward). Types 1-4 only. + /// Motion command ID (e.g. WalkForward). public uint Motion; /// Speed scalar for this motion. public float Speed; @@ -536,35 +209,6 @@ public struct MovementStruct public bool ModifyInterpretedState; /// Whether to modify the raw state. public bool ModifyRawState; - - /// - /// R4-V1 — retail object_id. Types 6 (MoveToObject), 8 - /// (TurnToObject) only. - /// - public uint ObjectId; - /// - /// R4-V1 — retail top_level_id. Types 6 (MoveToObject), 8 - /// (TurnToObject) only. - /// - public uint TopLevelId; - /// - /// R4-V1 — retail pos (world position + cell). Type 7 - /// (MoveToPosition) only. - /// - public Position Pos; - /// - /// R4-V1 — retail radius. Type 6 (MoveToObject) only. - /// - public float Radius; - /// - /// R4-V1 — retail height. Type 6 (MoveToObject) only. - /// - public float Height; - /// - /// R4-V1 — retail params (a pointer in retail; a reference - /// here). Types 1-4 and 6-9. - /// - public Motion.MovementParameters? Params; } // ── Optional WeenieObject interface ────────────────────────────────────────── @@ -582,47 +226,6 @@ public interface IWeenieObject bool InqRunRate(out float rate); /// vtable +0x3C — CanJump. Returns true if the weenie can jump at this extent. bool CanJump(float extent); - - /// - /// Retail CWeenieObject::IsCreature. Non-creature weenies bypass - /// the ground-contact gate in contact_allows_move (0x00528240), - /// the run remap in adjust_motion (wired R3-W4, closes J18 — - /// register TS-34 retired), and the / - /// creature gates. Players, NPCs, and - /// monsters are creatures — default true keeps existing implementers - /// retail-correct. - /// - bool IsCreature() => true; - - /// - /// Retail ACCWeenieObject::IsThePlayer (vtable +0x14, - /// 0x0058C3D0): this->id == SmartBox::smartbox->player_id. - /// PINNED (W0-pins.md A3, adversarially verified) as the dual-dispatch - /// gate for apply_current_movement/ReportExhaustion/ - /// SetWeenieObject/SetPhysicsObject — NOT IsCreature - /// (a remote player is a creature but not the player; ACE's server-side - /// IsCreature gate is a genuine divergence, not a reading to - /// copy). Default false; only the local player's weenie returns true. - /// No consumer in R3-W3 — the W4 dual-dispatch port is the first reader. - /// - bool IsThePlayer() => false; - - /// - /// Retail CWeenieObject::JumpStaminaCost (vtable +0x44, referenced - /// from jump_is_allowed raw 305549-305556): given the charge - /// , returns whether the weenie can afford the - /// stamina cost of this jump and writes the cost to - /// . jump_is_allowed treats a false - /// return as (0x47). - /// Default true / cost 0 — real stamina gating stays TS-5-deferred (the - /// same register row that already covers CanJump always-true; - /// this extends it to JumpStaminaCost until stat plumbing lands). - /// - bool JumpStaminaCost(float extent, out int cost) - { - cost = 0; - return true; - } } // ── MotionInterpreter ───────────────────────────────────────────────────────── @@ -632,74 +235,23 @@ public interface IWeenieObject /// /// Owns the raw and interpreted motion states for a physics object and /// translates network movement commands into PhysicsBody velocity calls. -/// -/// -/// R3-W2 (closes J1, J17): implements — -/// the entity's MotionTableManager (R2-Q3) binds its animation-done -/// callback here, standing in for retail's null-guarded relay chain -/// CPhysicsObj::MotionDone 0x0050fdb0 → MovementManager::MotionDone -/// 0x005242d0 → CMotionInterp::MotionDone 0x00527ec0 (r3-port-plan.md -/// §4). This adds the pending_motions queue (retail -/// CMotionInterp::pending_motions, a singly-linked LList — -/// ported as a managed per the AD-34 managed-list -/// register wording) that the funnel's dispatch/stop producers populate and -/// / drain. -/// /// -public sealed class MotionInterpreter : IMotionDoneSink +public sealed class MotionInterpreter { // ── animation speed constants (from ACE / confirmed by decompile globals) ─ - /// Walk animation base speed. Retail-exact (.rdata 0x007c891c = - /// 3.11999989f); unified for both and - /// 's sidestep scale in D6.2. - public const float WalkAnimSpeed = 3.11999989f; + /// Walk animation base speed (_DAT_007c96e4 family). + public const float WalkAnimSpeed = 3.12f; /// Run animation base speed (_DAT_007c96e0 family). public const float RunAnimSpeed = 4.0f; /// Sidestep animation base speed (_DAT_007c96e8 family). public const float SidestepAnimSpeed = 1.25f; - /// - /// Minimum jump extent before get_jump_v_z bothers computing - /// (_DAT_007c9734). R3-W3 (closes J16-epsilons, A5/A6): retail's exact - /// literal is 0.000199999995f (raw @304959: - /// ((long double)0.000199999995f)), NOT the previous 0.001 - /// approximation. Used by both and - /// (three times there, one per - /// axis — A6). - /// - public const float JumpVzEpsilon = 0.000199999995f; + /// Minimum jump extent before get_jump_v_z bothers computing (_DAT_007c9734). + public const float JumpExtentEpsilon = 0.001f; /// Fallback vertical jump velocity when WeenieObj is absent (_DAT_0079c6d4). public const float DefaultJumpVz = 10.0f; /// Maximum jump extent clamped by get_jump_v_z (_DAT_007938b0 = 1.0f). public const float MaxJumpExtent = 1.0f; - /// - /// Backward-speed multiplier applied by when - /// remapping WalkBackward → WalkForward (retail .rdata 0x007c8910). - /// Retail-exact value; do not round to 0.65f. - /// - public const float BackwardsFactor = 0.649999976f; - - /// - /// Turn speed multiplier applied by for - /// TurnRight when the hold key is Run (retail apply_run_to_command, no - /// run-rate scaling and no clamp — just a flat ×1.5). - /// - public const float RunTurnFactor = 1.5f; - - /// - /// Symmetric clamp on sidestep interpreted speed applied by - /// for SideStepRight when running - /// (retail apply_run_to_command SideStepRight clamp, ±3.0). - /// - public const float MaxSidestepAnimRate = 3.0f; - - /// - /// Retail-exact sidestep scale factor used by - /// when remapping to SideStepRight (retail SidestepFactor = 0.5). - /// - public const float SidestepFactor = 0.5f; - - // ── fields (matching struct layout from acclient_function_map.md) ───────── /// The physics body this interpreter controls (struct offset +0x08). @@ -708,17 +260,7 @@ public sealed class MotionInterpreter : IMotionDoneSink /// Optional WeenieObject for stamina / run-rate queries (struct offset +0x04). public IWeenieObject? WeenieObj { get; set; } - /// - /// Raw (network-derived) motion state (struct offsets +0x14..+0x44). - /// - /// - /// R3-W1 fold (closes J2): this now holds the SAME retail-faithful, - /// full-field type the outbound - /// wire packer reads from — the former flat LegacyRawMotionState - /// struct (no action FIFO, no ApplyMotion/RemoveMotion) - /// is deleted. One raw-state type end to end. - /// - /// + /// Raw (network-derived) motion state (struct offsets +0x14..+0x44). public RawMotionState RawState; /// Interpreted motion state derived from raw (struct offsets +0x44..+0x7C). @@ -730,154 +272,9 @@ public sealed class MotionInterpreter : IMotionDoneSink /// Stored run rate from last successful InqRunRate call (offset +0x7C). public float MyRunRate = 1.0f; - /// - /// The physics object's currently-held movement key (retail - /// this->raw_state.current_holdkey). - /// falls back to this property when the per-channel holdKey argument - /// passed in is . R3-W6: an ALIAS of - /// — retail's adjust_motion - /// reads raw_state.current_holdkey directly; the former shadow - /// field only synced on the raw dispatch branch, so an edge-driven - /// set_hold_run followed by DoMotion read a stale None and lost the - /// run promotion (the W6 cutover surfaced this). - /// - public HoldKey CurrentHoldKey => RawState.CurrentHoldKey; - /// True when crouching-in-place for a standing long jump (offset +0x70). public bool StandingLongJump; - /// - /// R3-W2 — retail CMotionInterp::pending_motions (offset last in - /// the struct, a singly-linked LList<MotionNode>). Ported as - /// a managed — the AD-34 register wording - /// ("retail's intrusive DLList/LList is ported as a managed LinkedList of - /// value nodes; node identity semantics are preserved via - /// LinkedListNode<T> references rather than raw prev/next - /// pointers") applies here exactly as it does to - /// MotionTableManager._pendingAnimations — this is the OTHER, - /// never-merged queue (movement side, waiting for a MotionDone callback, - /// vs the manager's animation side, waiting for a tick countdown; see - /// r3-port-plan.md §4 rule 1). Exposed read-only; mutated only through - /// / / - /// . - /// - private readonly LinkedList _pendingMotions = new(); - - /// Read-only inspection surface (tests + future callers): the - /// pending motion queue in head-to-tail order. - public IEnumerable PendingMotions => _pendingMotions; - - /// - /// R3-W2 no-op seam standing in for retail CPhysicsObj::unstick_from_object - /// (called from / when - /// the popped queue head is action-class, i.e. Motion & 0x10000000 - /// is set). Register row: releases a "stuck to object" sticky-manager - /// attachment — R5 wires the real StickyManager; until then this is an - /// optional callback the App layer may bind, matching the existing - /// Action? seam convention (see MotionTableDispatchSink.TurnStopped). - /// - public Action? UnstickFromObject { get; set; } - - /// - /// R3-W3 no-op seam standing in for retail CPhysicsObj::interrupt_current_movement - /// (called unconditionally at the top of , raw @305800, - /// and by per A9). Register row: retail - /// cancels any in-flight MoveToManager transition before starting a new - /// movement action; R4 wires the real cancel_moveto once - /// MoveToManager exists. Until then this is an optional callback the App - /// layer may bind, matching the seam - /// convention. - /// - public Action? InterruptCurrentMovement { get; set; } - - /// - /// R3-W4 seam standing in for retail CPhysicsObj::RemoveLinkAnimations - /// (0x0050fe20, decomp §9 summary table) → CPartArray → - /// CSequence::remove_all_link_animations — called from - /// / (§4a/4b) whenever - /// the ground-transition body actually runs. This is retail's REAL - /// mechanism for "leaving the ground strips any pending link - /// animation so Falling engages instantly" — the K-fix18 App-side - /// forced-SetCycle(skipTransitionLink:true) hack this seam - /// replaces is deleted in the orchestrator's App-side half (register - /// row retirement rides with that deletion, not this commit — Core - /// has no K-fix18 code to remove). The App layer binds this to the - /// entity's AnimationSequencer/CSequence core - /// (RemoveAllLinkAnimations-equivalent) per entity, matching the - /// existing / - /// Action? seam convention. Optional — a null binding is a - /// silent no-op (safe default for tests and for any physics body with - /// no sequencer attached yet). - /// - public Action? RemoveLinkAnimations { get; set; } - - /// - /// R3-W4 no-op seam standing in for retail - /// CPhysicsObj::InitializeMotionTables, called from - /// (§4d, raw @306124: - /// CPhysicsObj::InitializeMotionTables(this->physics_obj);). - /// This re-seeds the physics object's MotionTableManager/motion - /// table stack — a manager-side (App-bound) concern, not something - /// MotionInterpreter itself owns state for. Register row: no-op - /// until the App layer binds it to the entity's motion-table - /// initialization; matches the seam - /// convention. - /// - public Action? InitializeMotionTables { get; set; } - - /// - /// R3-W5 seam standing in for retail CPhysicsObj::CheckForCompletedMotions - /// (0x0050fe30, decomp §7d @277925) → CPartArray::CheckForCompletedMotions - /// → MotionTableManager::CheckForCompletedMotions (the SECOND call - /// site of the shared animation-completion driver, §7b) — called by - /// after EVERY dispatched op (raw - /// 306234/306241/306248/306255/306262, §2/§7d), synchronously draining - /// any already-elapsed (0-duration) animation-table node right after the - /// motion is applied, in case the newly-applied motion table entry - /// completes immediately. The App layer binds this to the entity's - /// MotionTableManager.CheckForCompletedMotions (R2-Q3 object), - /// matching the existing / - /// Action? seam convention. A null - /// binding is a silent no-op (safe default for tests and for any physics - /// body with no MotionTableManager attached yet). - /// - public Action? CheckForCompletedMotions { get; set; } - - /// - /// R3-W4 — retail CMotionInterp::initted (set by - /// , raw @306152: this->initted = - /// 1;). Gates and - /// 's entry (A3: physics_obj != 0 - /// && initted != 0). - /// - /// - /// Constructor default is true, NOT retail's false - /// (see the final-report note on this divergence). Retail's - /// CMotionInterp is never used standalone — every real - /// construction path (MovementManager::PerformMovement's - /// lazy-create, MovementManager::Create) immediately calls - /// (retail's enter_default_state) - /// before the interpreter is exposed to any caller, so initted - /// is always 1 by the time application code can observe it. - /// acdream's MotionInterpreter constructor is used directly by - /// ~40 pre-existing tests AND by both App call sites - /// (PlayerMovementController, GameWindow) as a - /// complete, immediately-usable object with no separate - /// "enter default state" step — porting retail's literal - /// false default would silently no-op every one of those - /// call sites' apply_current_movement/ReportExhaustion - /// calls until something remembers to call - /// (which nothing currently does). - /// Defaulting true here is the C# equivalent of "the - /// constructor already did what enter_default_state would have - /// done to this flag" — remains the - /// verbatim, fully-testable retail method for callers (tests, and a - /// future App reset-to-idle path) that need the REST of its behavior - /// (state reset, sentinel enqueue, tail). - /// - /// - public bool Initted { get; set; } = true; - /// /// Optional accessor for the owning entity's current animation cycle /// velocity (AnimationSequencer.CurrentVelocity, i.e. MotionData.Velocity @@ -921,7 +318,7 @@ public sealed class MotionInterpreter : IMotionDoneSink public MotionInterpreter() { - RawState = new RawMotionState(); + RawState = RawMotionState.Default(); InterpretedState = InterpretedMotionState.Default(); } @@ -929,7 +326,7 @@ public sealed class MotionInterpreter : IMotionDoneSink { PhysicsObj = physicsObj; WeenieObj = weenieObj; - RawState = new RawMotionState(); + RawState = RawMotionState.Default(); InterpretedState = InterpretedMotionState.Default(); } @@ -947,316 +344,193 @@ public sealed class MotionInterpreter : IMotionDoneSink /// case 5: StopCompletely() /// default: return 0x47 /// } - /// FUN_0050fe30 — CPhysicsObj::CheckForCompletedMotions, called after EVERY - /// dispatched op (raw 306234/306241/306248/306255/306262, R3-W5 closes J14). + /// FUN_00510900() — CheckForCompletedMotions (animation flush, not simulated here) /// public WeenieError PerformMovement(MovementStruct mvs) { - var p = new MovementParameters - { - Speed = mvs.Speed, - ModifyInterpretedState = mvs.ModifyInterpretedState, - ModifyRawState = mvs.ModifyRawState, - }; - - bool dispatched = true; WeenieError result = mvs.Type switch { - MovementType.RawCommand => DoMotion(mvs.Motion, p), - MovementType.InterpretedCommand => DoInterpretedMotion(mvs.Motion, p), - MovementType.StopRawCommand => StopMotion(mvs.Motion, p), - MovementType.StopInterpretedCommand => StopInterpretedMotion(mvs.Motion, p), + MovementType.RawCommand => DoMotion(mvs.Motion, mvs.Speed), + MovementType.InterpretedCommand => DoInterpretedMotion(mvs.Motion, mvs.Speed, mvs.ModifyInterpretedState), + MovementType.StopRawCommand => StopMotion(mvs.Motion), + MovementType.StopInterpretedCommand => StopInterpretedMotion(mvs.Motion, mvs.ModifyInterpretedState), MovementType.StopCompletely => StopCompletely(), - _ => Invalid(out dispatched), + _ => WeenieError.GeneralMovementFailure, }; - - // R3-W5 (closes J14): CPhysicsObj::CheckForCompletedMotions fires - // after EVERY dispatched op — a synchronous zero-tick flush so a - // newly-applied motion-table entry that completes IMMEDIATELY - // (0-duration node) gets its MotionDone callback in the same call, - // not on the next tick. The type-dispatch failure path (bad - // MovementStruct.type, 0x47) never reaches a dispatch, so it must - // NOT flush — matches retail's raw 306227 early-return before the - // switch even begins. - if (dispatched) - CheckForCompletedMotions?.Invoke(); - + // FUN_00510900 — CheckForCompletedMotions is an animation system flush; + // no simulation state to update here. return result; - - static WeenieError Invalid(out bool dispatched) - { - dispatched = false; - return WeenieError.GeneralMovementFailure; - } } // ── FUN_00529930 — DoMotion ─────────────────────────────────────────────── /// - /// CMotionInterp::DoMotion (0x00528d20, decomp §2 @306159, FULL - /// BODY, R3-W5, closes J3). App-facing 2-arg compat overload — - /// PlayerMovementController's call sites pass a raw - /// (uint motion, float speed) pair; this builds a - /// default-constructed (retail's own - /// UnPack/CommandInterpreter callers build one the same way for a bare - /// raw command) with overridden, - /// then delegates to the verbatim overload below. + /// Process one raw motion command from a network packet. + /// + /// Decompiled logic (FUN_00529930): + /// Copy packet fields into local variables (at local_24..local_4). + /// If the speed byte in flags is negative → call FUN_00510cc0 (cancel moveto). + /// If 0x800 flag → FUN_005297c0 (set hold key from packet). + /// FUN_00528c20 — adjust_motion (raw→interpreted adjustments). + /// Guard against special mid-animation states (returns 0x3F/0x40/0x41/0x42). + /// If Action bit (0x10000000) set and num_actions ≥ 6 → return 0x45. + /// Call DoInterpretedMotion(motion, movementParams). + /// + /// Our simplified port focuses on the state fields and physics side-effects. /// public WeenieError DoMotion(uint motion, float speed = 1.0f) - => DoMotion(motion, new MovementParameters { Speed = speed }); - - /// - /// CMotionInterp::DoMotion (0x00528d20, decomp §2 @306159, FULL - /// BODY, R3-W5, closes J3). - /// - /// - /// Verbatim (raw 306159-306221): - /// - /// if (physics_obj == 0) return 8; - /// ebp = arg2; // ORIGINAL motion id, unmutated - /// snapshot every MovementParameters field onto the stack; - /// var_2c = fresh local MovementParameters(); // re-default - /// if (bitfield sign bit) interrupt_current_movement(physics_obj); - /// if (bitfield & 0x800 /*SetHoldKey*/) - /// SetHoldKey(hold_key_to_apply, (bitfield>>0xf)&1 /*cancel_moveto bit, A4*/); - /// adjust_motion(&arg2, &speed, hold_key_to_apply); // mutates arg2/speed in place - /// if (interpreted_state.current_style != 0x8000003d) { // combat/special stance - /// if (ebp == 0x41000012) return 0x3f; - /// if (ebp == 0x41000013) return 0x40; - /// if (ebp == 0x41000014) return 0x41; - /// if (ebp & 0x2000000) return 0x42; - /// } - /// if ((ebp & 0x10000000) && GetNumActions() >= 6) return 0x45; - /// result = DoInterpretedMotion(arg2 /*ADJUSTED*/, &var_2c /*fresh local params*/); - /// if (result == 0 && bitfield & 0x2000 /*ModifyRawState*/) - /// RawMotionState::ApplyMotion(ebp /*ORIGINAL*/, arg3 /*CALLER's params, not var_2c*/); - /// return result; - /// - /// - /// - /// - /// Two mirror-discipline details, both verbatim: (1) the - /// call - /// receives a FRESH local (re-defaulted, - /// only Speed/HoldKeyToApply carried through - /// adjust_motion's mutation) — the caller's own - /// distance/heading/fail-distance fields are - /// discarded for the interpreted call, matching retail's explicit - /// re-construction of var_2c. (2) the raw-state mirror at the - /// bottom reads the ORIGINAL arg3 () — NOT - /// var_2c — for / - /// /, - /// per RawMotionState::ApplyMotion's own signature - /// (const MovementParameters*) receiving the same pointer - /// DoMotion was called with. - /// - /// - /// Retail arg2 (the ORIGINAL, pre-adjustment - /// motion id — ebp). - /// Retail arg3. - public WeenieError DoMotion(uint motion, MovementParameters p) { if (PhysicsObj is null) return WeenieError.NoPhysicsObject; - uint originalMotion = motion; - float speed = p.Speed; - var local = new MovementParameters(); // var_2c — fresh re-default + // Record the new raw forward command and speed. + // In the decompile, local_24 = *(param_3+8) = ForwardCommand, + // local_18 = ForwardSpeed, etc. + RawState.ForwardCommand = motion; + RawState.ForwardSpeed = speed; - if (p.CancelMoveTo) // bitfield high-byte sign bit - InterruptCurrentMovement?.Invoke(); + // Delegate to the interpreted path. DoMotion ultimately calls + // DoInterpretedMotion after adjust_motion in the retail client. + return DoInterpretedMotion(motion, speed, modifyInterpretedState: true); + } - if (p.SetHoldKey) // bitfield & 0x800 - SetHoldKey(p.HoldKeyToApply, p.CancelMoveTo); // A4: 2nd arg IS the cancel_moveto bit + // ── DoInterpretedMotion ──────────────────────────────────────────────────── - adjust_motion(ref motion, ref speed, p.HoldKeyToApply); // mutates motion/speed in place + /// + /// Core animation-state-machine entry point (FUN_00528f70). + /// + /// In the full retail engine this runs the animation sequencer. In this + /// physics-only port we update the InterpretedState and call + /// apply_current_movement so that the velocity is immediately reflected. + /// + public WeenieError DoInterpretedMotion(uint motion, float speed = 1.0f, bool modifyInterpretedState = false) + { + if (PhysicsObj is null) + return WeenieError.NoPhysicsObject; - if (InterpretedState.CurrentStyle != 0x8000003du) // not MotionStance_NonCombat + if (!contact_allows_move(motion)) { - if (originalMotion == MotionCommand.Crouch) - return WeenieError.CrouchInCombatStance; // 0x3f - if (originalMotion == MotionCommand.Sitting) - return WeenieError.SitInCombatStance; // 0x40 - if (originalMotion == MotionCommand.Sleeping) - return WeenieError.SleepInCombatStance; // 0x41 - if ((originalMotion & 0x2000000u) != 0) - return WeenieError.ChatEmoteOutsideNonCombat; // 0x42 + // Action commands (bit 0x10000000) are blocked mid-air. + if ((motion & 0x10000000u) != 0) + return WeenieError.YouCantJumpWhileInTheAir; + // Non-action motions are queued silently; state still updates. } - if ((originalMotion & 0x10000000u) != 0 && InterpretedState.GetNumActions() >= 6) - return WeenieError.ActionDepthExceeded; // 0x45 + if (modifyInterpretedState) + ApplyMotionToInterpretedState(motion, speed); - local.Speed = speed; - local.HoldKeyToApply = p.HoldKeyToApply; - - WeenieError result = DoInterpretedMotion(motion, local); // ADJUSTED id, fresh local params - - if (result == WeenieError.None && p.ModifyRawState) // bitfield & 0x2000 - RawState.ApplyMotion(originalMotion, p); // ORIGINAL id, CALLER's params (arg3, not var_2c) - - return result; + apply_current_movement(cancelMoveTo: false, allowJump: true); + return WeenieError.None; } // ── StopMotion ──────────────────────────────────────────────────────────── /// - /// App-facing compat overload for StopMotion (0x00528530, decomp - /// §5b @305674). Builds a default-constructed - /// and delegates to the verbatim overload below. + /// Stop a specific raw motion (FUN_00529140 → StopInterpretedMotion). /// public WeenieError StopMotion(uint motion) - => StopMotion(motion, new MovementParameters()); - - /// - /// CMotionInterp::StopMotion (0x00528530, decomp §5b @305674, FULL - /// BODY, R3-W5, closes J3's StopMotion leg). The DoMotion mirror: - /// same defaulting/reconstruction pattern, same optional - /// interrupt_current_movement, same adjust_motion - /// reinterpretation — but NO combat-stance or action-depth gating (those - /// are DoMotion-only). - /// - /// - /// Verbatim (raw 305674-305706): snapshot fields, - /// build a fresh local , optional - /// interrupt on the sign bit, adjust_motion(&arg3, &speed, - /// hold_key_to_apply) (mutates the motion id in place — note retail - /// reuses the arg3 register slot for the motion id here, not a - /// separate local), delegate to - /// StopInterpretedMotion(adjusted, &local); on success AND - /// bit 0x2000 (ModifyRawState), mirror via - /// RawMotionState::RemoveMotion(ORIGINAL motion) — using the - /// ORIGINAL unmutated id, matching DoMotion's equivalent - /// ApplyMotion(ebp, arg3) mirror discipline. - /// - /// - /// Retail arg2 (the ORIGINAL motion id). - /// Retail arg3. - public WeenieError StopMotion(uint motion, MovementParameters p) { if (PhysicsObj is null) return WeenieError.NoPhysicsObject; - uint originalMotion = motion; - float speed = p.Speed; - var local = new MovementParameters(); // fresh re-default + if (RawState.ForwardCommand == motion) + { + RawState.ForwardCommand = MotionCommand.Ready; + RawState.ForwardSpeed = 1.0f; + } + if (RawState.SideStepCommand == motion) + { + RawState.SideStepCommand = 0; + RawState.SideStepSpeed = 1.0f; + } + if (RawState.TurnCommand == motion) + { + RawState.TurnCommand = 0; + RawState.TurnSpeed = 1.0f; + } - if (p.CancelMoveTo) - InterruptCurrentMovement?.Invoke(); - - adjust_motion(ref motion, ref speed, p.HoldKeyToApply); - - local.Speed = speed; - local.HoldKeyToApply = p.HoldKeyToApply; - - WeenieError result = StopInterpretedMotion(motion, local); // ADJUSTED id - - if (result == WeenieError.None && p.ModifyRawState) - RawState.RemoveMotion(originalMotion); // ORIGINAL id - - return result; + return StopInterpretedMotion(motion, modifyInterpretedState: true); } - // ── FUN_00527e40 — StopCompletely ───────────────────────────────────────── + // ── StopInterpretedMotion ──────────────────────────────────────────────── /// - /// CMotionInterp::StopCompletely (0x00527e40, decomp §5a @305208, - /// FULL BODY, R3-W5, closes J9). W0-pins.md A9 — ported INCLUDING the - /// jump-snapshot quirk. + /// Stop a specific interpreted motion (FUN_00529080). + /// + public WeenieError StopInterpretedMotion(uint motion, bool modifyInterpretedState = false) + { + if (PhysicsObj is null) + return WeenieError.NoPhysicsObject; + + if (modifyInterpretedState) + { + if (InterpretedState.ForwardCommand == motion) + { + InterpretedState.ForwardCommand = MotionCommand.Ready; + InterpretedState.ForwardSpeed = 1.0f; + } + if (InterpretedState.SideStepCommand == motion) + { + InterpretedState.SideStepCommand = 0; + InterpretedState.SideStepSpeed = 1.0f; + } + if (InterpretedState.TurnCommand == motion) + { + InterpretedState.TurnCommand = 0; + InterpretedState.TurnSpeed = 1.0f; + } + } + + apply_current_movement(cancelMoveTo: false, allowJump: false); + return WeenieError.None; + } + + // ── FUN_00528a50 — StopCompletely ───────────────────────────────────────── + + /// + /// Reset both raw and interpreted states to Ready/idle, then push zero velocity. /// - /// - /// Verbatim (raw 305208-305236): - /// - /// if (physics_obj == 0) return 8; - /// interrupt_current_movement(physics_obj); - /// eax_2 = motion_allows_jump(interpreted_state.forward_command); // OLD fwd, BEFORE overwrite - /// raw_state.forward_command = 0x41000003; raw_state.forward_speed = 1f; - /// raw_state.sidestep_command = 0; raw_state.turn_command = 0; - /// interpreted_state.forward_command = 0x41000003; interpreted_state.forward_speed = 1f; - /// interpreted_state.sidestep_command = 0; interpreted_state.turn_command = 0; - /// StopCompletely_Internal(physics_obj); - /// add_to_queue(0, 0x41000003, eax_2); // eax_2 = the PRE-overwrite snapshot - /// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj); - /// return 0; - /// - /// - /// - /// - /// A9 quirk: motion_allows_jump is evaluated on the OLD - /// interpreted_state.forward_command BEFORE it gets overwritten - /// two lines later — the queued node's JumpErrorCode reflects the - /// PRE-stop command, not the post-stop Ready command (which would always - /// pass). J9: retail touches ONLY the forward command/speed plus - /// the sidestep/turn COMMANDS — it does NOT write sidestep_speed - /// or turn_speed on either state. The pre-R3 acdream divergence - /// (unconditional 1.0 speed resets on all four speed fields) is REMOVED - /// here. - /// - /// - /// - /// StopCompletely_Internal (0x0050f5a0, CPhysicsObj-side) stands - /// in as (Zero) — register row, - /// kept (full port is physics-layer scope, r3-port-plan.md §2 KEEP - /// LIST). CurCell proxy: .ObjCellId - /// == 0 (unseeded/detached body — matches cell == 0). - /// + /// Decompiled logic (FUN_00528a50): + /// if (PhysicsObj == null) return 8 + /// FUN_00510cc0() — cancel moveto + /// uVar1 = FUN_005285e0(InterpretedState.ForwardCommand) — motion_allows_jump + /// *(+0x20) = 0x41000003 (RawState.ForwardCommand = Ready) + /// *(+0x28) = 0x3f800000 (RawState.ForwardSpeed = 1.0f) + /// *(+0x2c) = 0 (RawState.SideStepCommand = 0) + /// *(+0x38) = 0 (RawState.TurnCommand = 0) + /// *(+0x4c) = 0x41000003 (InterpretedState.ForwardCommand = Ready) + /// *(+0x50) = 0x3f800000 (InterpretedState.ForwardSpeed = 1.0f) + /// *(+0x54) = 0 (InterpretedState.SideStepCommand = 0) + /// *(+0x5c) = 0 (InterpretedState.TurnCommand = 0) + /// FUN_0050f5a0() — StopCompletely_Internal (zero velocity on PhysicsObj) + /// FUN_00528790(…) — add_to_queue + /// if (PhysicsObj != null && CurCell == null) → FUN_005108f0 (RemoveLinkAnimations) + /// return 0 /// public WeenieError StopCompletely() { if (PhysicsObj is null) return WeenieError.NoPhysicsObject; - InterruptCurrentMovement?.Invoke(); - - // A9: snapshot BEFORE the overwrite below. - WeenieError jumpSnapshot = MotionAllowsJump(InterpretedState.ForwardCommand); - - // J9: forward cmd/speed + sidestep/turn COMMANDS only — speeds untouched. + // Reset raw state RawState.ForwardCommand = MotionCommand.Ready; RawState.ForwardSpeed = 1.0f; - RawState.SidestepCommand = 0; + RawState.SideStepCommand = 0; + RawState.SideStepSpeed = 1.0f; RawState.TurnCommand = 0; + RawState.TurnSpeed = 1.0f; + // Reset interpreted state InterpretedState.ForwardCommand = MotionCommand.Ready; InterpretedState.ForwardSpeed = 1.0f; InterpretedState.SideStepCommand = 0; + InterpretedState.SideStepSpeed = 1.0f; InterpretedState.TurnCommand = 0; + InterpretedState.TurnSpeed = 1.0f; - // StopCompletely_Internal — R4-V5 wedge-fix CORRECTION of the R3 - // misread: retail's body (0x0050ead0, tailcall - // CPartArray::StopCompletelyInternal 0x00518890) is - // MotionTableManager::PerformMovement(type 5) through the ANIMATION - // stack — NOT a physics-side velocity zero (the "0x0050f5a0" the R3 - // note cited is a different function's interior). The manager - // queues its type-5 pending_animations entry UNCONDITIONALLY, and - // that entry's AnimationDone → MotionDone is the matched pop for - // the A9 pending_motions node queued below. Without this dispatch - // every StopCompletely (login/teleport SetPosition + every - // MoveToManager PerformMovement head) left an orphan node, - // MotionsPending never drained at idle, and BeginTurnToHeading's - // wait-for-anims gate wedged every armed moveto (the 2026-07-03 - // live door bug — server walked the player, the local body never - // moved, rubber-band on the next input). - DefaultSink?.StopCompletely(); - - // Immediate velocity zero: kept from the R3 stand-in. Retail - // reaches zero through the Ready state's next velocity apply; - // acdream's controller altitude performs that apply in its grounded - // section-2 write, so the immediate zero preserves the established - // teleport/stop behavior for same-frame readers. + // Zero the body velocity (FUN_0050f5a0 = StopCompletely_Internal) PhysicsObj.set_velocity(Vector3.Zero); - AddToQueue(contextId: 0, MotionCommand.Ready, (uint)jumpSnapshot); - - // R4-V5 door-swing fix (2026-07-03): retail's guard is - // physics_obj->cell == 0 — DETACHED objects only (raw @305627). - // The old proxy (CellPosition.ObjCellId == 0, #145 machinery seeded - // only by the local player's SnapToCell) read every REMOTE body as - // detached, so every dispatch stripped the just-appended transition - // link (door swings snapped; remote walk↔run links died) — see - // PhysicsBody.InWorld (register row). - if (!PhysicsObj.InWorld) - RemoveLinkAnimations?.Invoke(); - return WeenieError.None; } @@ -1361,334 +635,26 @@ public sealed class MotionInterpreter : IMotionDoneSink return velocity; } - // ── FUN_00528010 — adjust_motion ────────────────────────────────────────── + // ── FUN_00529210 — apply_current_movement ───────────────────────────────── /// - /// Normalize one raw motion channel (forward, sidestep, or turn) into its - /// canonical interpreted form: fold the "negative" commands (WalkBackward, - /// TurnLeft, SideStepLeft) into their positive counterpart with a negated/ - /// scaled speed, then apply the Run hold-key promotion. + /// Apply the current interpreted motion state as a local velocity to the PhysicsBody. /// - /// - /// Decompiled logic (FUN_00528010, docs/research/named-retail/ - /// acclient_2013_pseudo_c.txt:305343-305400): - /// - /// if weenie != null and !weenie.IsCreature(): return - /// switch cmd: - /// RunForward: return // no scale, NO holdkey path - /// WalkBackwards: cmd = WalkForward; speed *= -BackwardsFactor - /// TurnLeft: cmd = TurnRight; speed *= -1 - /// SideStepLeft: cmd = SideStepRight; speed *= -1 - /// if cmd == SideStepRight: - /// speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed) // ≈ 1.24799995 - /// if holdKey == Invalid: holdKey = raw.current_holdkey - /// if holdKey == Run: apply_run_to_command(ref cmd, ref speed) - /// - /// + /// Decompiled logic (FUN_00529210): + /// if PhysicsObj == null: return + /// FUN_00524f80() — internal animation state update + /// If ForwardCommand == RunForward: update MyRunRate = ForwardSpeed + /// Then delegates to DoInterpretedMotion for each active command, + /// which ultimately calls set_local_velocity via FUN_00528960. /// - /// - /// GOTCHA: RunForward early-returns before the sidestep scale AND before - /// the hold-key path — a running forward command is already normalized - /// and must not be touched again. The sidestep negate (SideStepLeft → - /// SideStepRight, ×-1) happens BEFORE the ×1.248 anim-rate scale, so the - /// net multiplier for SideStepLeft is -1.248×speed, not -1×(1.248×speed) - /// applied out of order (same result here, but the ORDER matters once - /// apply_run_to_command's sign-dependent clamp is layered on top). - /// - /// - /// - /// R3-W4 (closes J18, retires register TS-34): creature guard WIRED. - /// Retail (raw @305343): if (weenie != 0 && weenie->IsCreature() == 0) - /// return; — a weenie present whose IsCreature() returns - /// false short-circuits the ENTIRE function (no normalization, no - /// hold-key path). has existed - /// since W3; this was the one caller left un-gated. - /// - /// - /// Raw motion command, normalized in place. - /// Raw motion speed, normalized in place. - /// - /// The hold key for THIS channel (forward/sidestep/turn each carry their - /// own hold key in the retail raw state). Pass - /// to fall back to . - /// - public void adjust_motion(ref uint motion, ref float speed, HoldKey holdKey) - { - if (WeenieObj is not null && !WeenieObj.IsCreature()) - return; - - switch (motion) - { - case MotionCommand.RunForward: - // Already normalized; no scale, no hold-key path. - return; - case MotionCommand.WalkBackward: - motion = MotionCommand.WalkForward; - speed *= -BackwardsFactor; - break; - case MotionCommand.TurnLeft: - motion = MotionCommand.TurnRight; - speed *= -1f; - break; - case MotionCommand.SideStepLeft: - motion = MotionCommand.SideStepRight; - speed *= -1f; - break; - } - - if (motion == MotionCommand.SideStepRight) - speed *= SidestepFactor * (WalkAnimSpeed / SidestepAnimSpeed); - - if (holdKey == HoldKey.Invalid) - holdKey = CurrentHoldKey; - - if (holdKey == HoldKey.Run) - apply_run_to_command(ref motion, ref speed); - } - - // ── FUN_00527be0 — apply_run_to_command ─────────────────────────────────── - - /// - /// Apply the Run hold-key promotion/scale to an already-normalized - /// (positive-form) motion command. - /// - /// - /// Decompiled logic (FUN_00527be0, docs/research/named-retail/ - /// acclient_2013_pseudo_c.txt:305062-305123): - /// - /// speedMod = 1.0 - /// if weenie != null: speedMod = weenie.InqRunRate(out r) ? r : MyRunRate - /// switch cmd: - /// WalkForward: - /// if speed > 0: cmd = RunForward // promote ONLY when moving forward - /// speed *= speedMod // UNCONDITIONAL — applies to backward too - /// TurnRight: - /// speed *= RunTurnFactor // ×1.5, no run-rate, no clamp - /// SideStepRight: - /// speed *= speedMod - /// if abs(speed) > MaxSidestepAnimRate: speed = speed > 0 ? Max : -Max - /// - /// - /// - /// - /// GOTCHA: the WalkForward speed *= speedMod is unconditional — - /// it applies even when speed is negative (backward), even though the - /// command is NOT promoted to RunForward in that case (the promotion is - /// sign-gated, the scale is not). - /// - /// - public void apply_run_to_command(ref uint motion, ref float speed) - { - float speedMod = 1.0f; - if (WeenieObj is not null) - speedMod = WeenieObj.InqRunRate(out float rate) ? rate : MyRunRate; - - switch (motion) - { - case MotionCommand.WalkForward: - if (speed > 0f) - motion = MotionCommand.RunForward; - speed *= speedMod; - break; - case MotionCommand.TurnRight: - speed *= RunTurnFactor; - break; - case MotionCommand.SideStepRight: - speed *= speedMod; - if (MathF.Abs(speed) > MaxSidestepAnimRate) - speed = speed > 0f ? MaxSidestepAnimRate : -MaxSidestepAnimRate; - break; - } - } - - // ── 0x005287e0 — apply_raw_movement ─────────────────────────────────────── - - /// - /// Retail CMotionInterp::apply_raw_movement orchestrator - /// (0x005287e0, docs/research/named-retail/acclient_2013_pseudo_c.txt:305817-305834). - /// Copies the raw motion state into the interpreted state, then normalizes - /// each of the three channels (forward / sidestep / turn) IN PLACE via - /// using that channel's own hold key. - /// - /// - /// After this call, yields correct velocity - /// for ALL directions — backward (WalkBackward→WalkForward, ×-0.65) and - /// strafe-left (SideStepLeft→SideStepRight, ×-1×1.248) are normalized here, - /// which is the D6 fix that retires the hand-mirrored controller formulas - /// (register TS-22). Callers must pass RAW speeds (forward_speed = 1.0), NOT - /// pre-run-scaled speeds — applies the run - /// rate, so a pre-scaled input would double-scale. - /// - /// - public void apply_raw_movement(RawMotionState raw) - { - // R3-W6: CurrentHoldKey is now an alias of RawState.CurrentHoldKey; - // keep the interpreter's raw state authoritative when a snapshot - // arrives through this legacy overload. - RawState.CurrentHoldKey = raw.CurrentHoldKey; - - // Copy raw -> interpreted. Retail copies 7 fields; our - // InterpretedMotionState omits current_style (velocity doesn't use it). - InterpretedState.ForwardCommand = raw.ForwardCommand; - InterpretedState.ForwardSpeed = raw.ForwardSpeed; - InterpretedState.SideStepCommand = raw.SidestepCommand; - InterpretedState.SideStepSpeed = raw.SidestepSpeed; - InterpretedState.TurnCommand = raw.TurnCommand; - InterpretedState.TurnSpeed = raw.TurnSpeed; - - // Normalize each channel with its OWN per-channel hold key. - uint fCmd = InterpretedState.ForwardCommand; float fSpd = InterpretedState.ForwardSpeed; - adjust_motion(ref fCmd, ref fSpd, raw.ForwardHoldKey); - InterpretedState.ForwardCommand = fCmd; InterpretedState.ForwardSpeed = fSpd; - - uint sCmd = InterpretedState.SideStepCommand; float sSpd = InterpretedState.SideStepSpeed; - adjust_motion(ref sCmd, ref sSpd, raw.SidestepHoldKey); - InterpretedState.SideStepCommand = sCmd; InterpretedState.SideStepSpeed = sSpd; - - uint tCmd = InterpretedState.TurnCommand; float tSpd = InterpretedState.TurnSpeed; - adjust_motion(ref tCmd, ref tSpd, raw.TurnHoldKey); - InterpretedState.TurnCommand = tCmd; InterpretedState.TurnSpeed = tSpd; - } - - // ── FUN_00528870 — apply_current_movement ───────────────────────────────── - - /// - /// CMotionInterp::apply_current_movement (0x00528870, decomp §4e, - /// W0-pins.md A3, FULL BODY dual dispatch — replaces the pre-W4 - /// direct-velocity approximation). - /// - /// - /// Verbatim (raw 305838-305857): - /// - /// if (physics_obj != 0 && initted != 0) { - /// if (weenie_obj != 0) eax_2 = weenie_obj->IsThePlayer(); - /// if ((weenie_obj == 0 || eax_2 != 0) - /// && movement_is_autonomous(physics_obj) != 0) { - /// apply_raw_movement(this, arg2, arg3); - /// return; - /// } - /// apply_interpreted_movement(this, arg2, arg3); - /// } - /// - /// - /// - /// - /// A3: the dispatch gate is IsThePlayer (no weenie = treat as - /// the player), NOT IsCreature — a remote player weenie - /// (IsThePlayer()==false, IsCreature()==true) routes - /// INTERPRETED even when movement_is_autonomous is true. This is - /// the genuine divergence from ACE's server-side IsCreature gate - /// (ACE MotionInterp.cs:430-438) — do not copy. - /// + /// In our physics-only port we compute the body-local velocity via + /// get_state_velocity() and push it directly to PhysicsBody.set_local_velocity. /// public void apply_current_movement(bool cancelMoveTo, bool allowJump) - { - if (PhysicsObj is null || !Initted) - return; - - bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer(); - if (isThePlayer && PhysicsObj.LastMoveWasAutonomous) - { - apply_raw_movement(cancelMoveTo, allowJump); - return; - } - - ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump); - } - - /// - /// CMotionInterp::apply_raw_movement (0x005287e0, decomp §4e - /// referenced body, raw 305817-305834), the (cancelMoveTo, - /// allowJump)-arg overload reached from - /// 's dual dispatch (A3's - /// "IsThePlayer + autonomous" branch), , - /// , and . - /// - /// - /// Verbatim: copy the SEVEN raw-state fields (style + all three - /// axis command/speed pairs) into , - /// normalize each of the three axes IN PLACE via - /// using THAT axis's own hold key, then - /// tail-call apply_interpreted_movement(this, arg2, arg3) — the - /// SAME retail function (0x00528600) as - /// (the funnel producer). This is NOT a new function: it is the - /// existing overload's - /// body (the D6.2a raw-state-orchestrator port), reached here with - /// itself as the source instead of an externally - /// supplied snapshot, plus the previously-missing - /// apply_interpreted_movement(arg2, arg3) tail call this - /// dispatch site needs. - /// - /// - public void apply_raw_movement(bool cancelMoveTo, bool allowJump) { if (PhysicsObj is null) return; - apply_raw_movement(RawState); - ApplyCurrentMovementInterpreted(cancelMoveTo, allowJump); - } - - /// - /// The retail apply_interpreted_movement tail reached from BOTH - /// dual-dispatch branches of (raw - /// 305855/305832 — CMotionInterp::apply_interpreted_movement, - /// 0x00528600, the SAME function as the funnel's - /// , per the raw's identical call - /// target at every one of its six call sites). - /// - /// - /// Physics-only-port body (pre-W4 approximation, SURVIVES per the - /// plan's "less invasive" choice — see the final-report note): this - /// port has no wired at this call - /// site (retail's real body drives DoInterpretedMotion/ - /// StopInterpretedMotion through the animation-table backend, - /// exactly like already does for - /// the INBOUND funnel path — but that path always has a sink available - /// from its caller, while apply_current_movement's callers - /// (// - /// /hold-key toggles/jump) do not thread - /// one through). Kept as the pre-existing direct - /// get_state_velocity → grounded set_local_velocity write - /// — an acdream adaptation of root-motion-driven velocity, register row - /// added same commit — until R6 (root motion drives the body directly - /// and this local-only fallback retires in favor of a real sink here - /// too). - /// - /// - /// - /// R3-W4 (App-bound): the entity's persistent animation-dispatch sink. - /// When set, routes the - /// REAL (retail - /// apply_interpreted_movement 0x00528600 — the same function the - /// inbound funnel drives), so HitGround/LeaveGround/hold-key re-applies - /// dispatch cycles (the airborne-Falling engage) through the - /// motion-table backend exactly as retail does. Null (the local player - /// until R3-W6, interp-less entities) → the AP-77 physics-only tail. - /// - public IInterpretedMotionSink? DefaultSink { get; set; } - - private void ApplyCurrentMovementInterpreted(bool cancelMoveTo, bool allowJump) - { - if (PhysicsObj is null) - return; - - // R3-W4: with a persistent sink bound (remotes), this IS retail's - // apply_interpreted_movement — full dispatch through the funnel - // body (style → forward-or-Falling → sidestep → turn), which is - // what makes LeaveGround engage Falling without the deleted - // K-fix18 forced-SetCycle. Without a sink, fall through to the - // AP-77 physics-only adaptation below. - if (DefaultSink is not null) - { - ApplyInterpretedMovement(InterpretedState.CurrentStyle, DefaultSink, - cancelMoveTo, allowJump); - return; - } - - _ = cancelMoveTo; - _ = allowJump; - // Decompile writes back MyRunRate when in run state (offset +0x7C). if (InterpretedState.ForwardCommand == MotionCommand.RunForward) MyRunRate = InterpretedState.ForwardSpeed; @@ -1702,317 +668,32 @@ public sealed class MotionInterpreter : IMotionDoneSink if (PhysicsObj.OnWalkable) { var localVelocity = get_state_velocity(); - // AP-77: this adaptation's fallback write has no real opinion on - // "was this move autonomous" (retail's real set_local_velocity - // call for this dispatch path lives deep inside the animation - // backend this stand-in doesn't have — see the seam's doc - // comment). Preserve whatever LastMoveWasAutonomous already - // holds rather than resetting it, so a caller like LeaveGround - // (which just set it true via its OWN direct set_local_velocity - // call, autonomous=1 per raw @305763-305765) isn't clobbered by - // this immediately-following re-sync tail call. - PhysicsObj.set_local_velocity(localVelocity, PhysicsObj.LastMoveWasAutonomous); + PhysicsObj.set_local_velocity(localVelocity); } } - // ── FUN_005288d0 — ReportExhaustion ──────────────────────────────────────── - - /// - /// CMotionInterp::ReportExhaustion (0x005288d0, decomp §4c - /// @305861, FULL BODY, W0-pins.md A3). Identical entry gate and - /// dual-dispatch predicate to , - /// with HARDCODED (0, 0) dispatch args (raw 305874/305878) — - /// the retail caller (CPhysicsObj::report_exhaustion 0x0050fdd0 - /// → MovementManager::ReportExhaustion 0x00524360) passes no - /// per-call parameters through. - /// - /// - /// Verbatim (raw 305861-305880): - /// - /// if (physics_obj != 0 && initted != 0) { - /// if (weenie_obj != 0) eax_2 = weenie_obj->IsThePlayer(); - /// if ((weenie_obj == 0 || eax_2 != 0) - /// && movement_is_autonomous(physics_obj) != 0) { - /// apply_raw_movement(this, 0, 0); - /// return; - /// } - /// apply_interpreted_movement(this, 0, 0); - /// } - /// - /// - /// - public void ReportExhaustion() - { - if (PhysicsObj is null || !Initted) - return; - - bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer(); - if (isThePlayer && PhysicsObj.LastMoveWasAutonomous) - { - // Raw (0, 0): arg3 is DisableJumpDuringLink → allowJump: true - // (#161 polarity decode; consumed since the apply-pass params - // became real). - apply_raw_movement(cancelMoveTo: false, allowJump: true); - return; - } - - ApplyCurrentMovementInterpreted(cancelMoveTo: false, allowJump: true); - } - - // ── FUN_00528920 / FUN_00528970 — SetWeenieObject / SetPhysicsObject ─────── - - /// - /// CMotionInterp::SetWeenieObject (0x00528920, decomp §4f - /// @305884, FULL BODY, W0-pins.md A3). Assigns , - /// then — if already physics-bound AND — re-applies - /// movement via the SAME dual-dispatch predicate as - /// , EXCEPT the weenie tested for - /// IsThePlayer is the INCOMING (not - /// post-assignment — they're the same value - /// here, but retail's raw makes the incoming-arg read explicit: a null - /// incoming weenie takes the SAME branch as a non-null-but-IsThePlayer - /// weenie, both via goto label_528946). Dispatch args are - /// hardcoded (1, 0). - /// - /// - /// Verbatim (raw 305884-305907): - /// - /// bool noPhysics = (physics_obj == 0); - /// weenie_obj = arg2; - /// if (!noPhysics && initted != 0) { - /// if (arg2 == 0) { - /// label_528946: - /// if (movement_is_autonomous(physics_obj) != 0) { - /// apply_raw_movement(this, 1, 0); - /// return; - /// } - /// } else if (arg2->IsThePlayer() != 0) - /// goto label_528946; - /// apply_interpreted_movement(this, 1, 0); - /// } - /// - /// - /// - public void SetWeenieObject(IWeenieObject? weenie) - { - WeenieObj = weenie; - - if (PhysicsObj is null || !Initted) - return; - - bool isThePlayer = weenie is null || weenie.IsThePlayer(); - if (isThePlayer && PhysicsObj.LastMoveWasAutonomous) - { - // Raw (1, 0): cancelMoveTo set, DisableJumpDuringLink clear → - // allowJump: true (#161 polarity decode). - apply_raw_movement(cancelMoveTo: true, allowJump: true); - return; - } - - ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: true); - } - - /// - /// CMotionInterp::SetPhysicsObject (0x00528970, decomp §4g - /// @305911, FULL BODY, W0-pins.md A3). Assigns - /// unconditionally FIRST, then — if the incoming - /// is non-null AND — - /// re-applies movement via the standard dual-dispatch predicate (this - /// time reading , unchanged by this call). - /// Dispatch args are hardcoded (1, 0), matching - /// . - /// - /// - /// Verbatim (raw 305911-305932): - /// - /// this->physics_obj = arg2; - /// if (arg2 != 0 && initted != 0) { - /// if (weenie_obj != 0) eax_1 = weenie_obj->IsThePlayer(); - /// if ((weenie_obj == 0 || eax_1 != 0) - /// && movement_is_autonomous(physics_obj) != 0) { - /// apply_raw_movement(this, 1, 0); - /// return; - /// } - /// apply_interpreted_movement(this, 1, 0); - /// } - /// - /// Note the entry gate is on the INCOMING arg2 (not the - /// just-assigned this->physics_obj — same value, but the raw - /// tests the parameter register directly), unlike - /// 's entry gate which tests the - /// PRE-assignment physics_obj snapshot (noPhysics). - /// - /// - public void SetPhysicsObject(PhysicsBody? physicsObj) - { - PhysicsObj = physicsObj; - - if (physicsObj is null || !Initted) - return; - - bool isThePlayer = WeenieObj is null || WeenieObj.IsThePlayer(); - if (isThePlayer && physicsObj.LastMoveWasAutonomous) - { - // Raw (1, 0) → allowJump: true (#161 polarity decode). - apply_raw_movement(cancelMoveTo: true, allowJump: true); - return; - } - - ApplyCurrentMovementInterpreted(cancelMoveTo: true, allowJump: true); - } - - // ── FUN_00527a50 — jump_charge_is_allowed ───────────────────────────────── - - /// - /// CMotionInterp::jump_charge_is_allowed (0x00527a50, decomp §3b - /// @304935, W0-pins.md A1 polarity). Gate consulted while the jump - /// charge is accumulating (spacebar held) — NOT the same gate as - /// 's ground check. - /// - /// - /// Verbatim (raw 304940-304948): - /// - /// weenie_obj = this->weenie_obj; - /// if (weenie_obj != 0 && weenie_obj.CanJump(this->jump_extent) == 0) - /// return 0x49; - /// forward_command = this->interpreted_state.forward_command; - /// if (forward_command != 0x40000008 - /// && (forward_command <= 0x41000011 || forward_command > 0x41000014)) - /// return 0; - /// return 0x48; - /// - /// - /// - public WeenieError JumpChargeIsAllowed(float extent) - { - if (WeenieObj is not null && !WeenieObj.CanJump(extent)) - return WeenieError.CantJumpLoadedDown; // 0x49 - - uint forward = InterpretedState.ForwardCommand; - if (forward != MotionCommand.Fallen - && (forward <= MotionCommand.CrouchLowerBound || forward > MotionCommand.Sleeping)) - return WeenieError.None; - - return WeenieError.YouCantJumpFromThisPosition; // 0x48 - } - - // ── FUN_005281c0 — charge_jump ──────────────────────────────────────────── - - /// - /// CMotionInterp::charge_jump (0x005281c0, decomp §3e @305448, - /// W0-pins.md A1 polarity). R3-W3 (closes J6): the ONLY place retail - /// arms — the old - /// contact_allows_move side effect (:1139-1148 pre-W3 numbering) - /// is DELETED (see 's doc comment). - /// - /// - /// Verbatim (raw 305453-305466): - /// - /// weenie_obj = this->weenie_obj; - /// if (weenie_obj != 0 && weenie_obj.CanJump(this->jump_extent) == 0) - /// return 0x49; - /// forward_command = this->interpreted_state.forward_command; - /// if (forward_command == 0x40000008 - /// || (forward_command > 0x41000011 && forward_command <= 0x41000014)) - /// return 0x48; - /// transient_state = physics_obj->transient_state; - /// if ((transient_state & 1) != 0 && (transient_state & 2) != 0 - /// && forward_command == 0x41000003 - /// && interpreted_state.sidestep_command == 0 - /// && interpreted_state.turn_command == 0) - /// this->standing_longjump = 1; - /// return 0; - /// - /// - /// - /// - /// Note the inverted-but-equivalent posture test vs - /// : that function's PASS condition is - /// forward <= 0x41000011 || forward > 0x41000014; this - /// function's BLOCK condition is forward == 0x40000008 || - /// (forward > 0x41000011 && forward <= 0x41000014) — the - /// same Fallen-exact / Crouch..Sleeping-range predicate, just phrased as - /// its own negation plus the explicit Fallen exact-match up front. - /// Ported literally rather than reusing - /// to keep each function's control flow traceable to its own raw - /// address. - /// - /// - /// - /// Caller (outside CMotionInterp, raw line 376144, 0x0056afac — the - /// player-input/SmartBox layer, out of R3 scope): drives the charge - /// accumulation loop while spacebar is held. acdream's charge - /// accumulation stays controller-side (AP-24 register row survives); - /// PlayerMovementController may call this once wired (W4/W6 — no - /// regression today since nothing calls ChargeJump yet, so - /// remotes/local both continue unaffected). - /// - /// - public WeenieError ChargeJump() - { - if (WeenieObj is not null && !WeenieObj.CanJump(JumpExtent)) - return WeenieError.CantJumpLoadedDown; // 0x49 - - uint forward = InterpretedState.ForwardCommand; - if (forward == MotionCommand.Fallen - || (forward > MotionCommand.CrouchLowerBound && forward <= MotionCommand.Sleeping)) - return WeenieError.YouCantJumpFromThisPosition; // 0x48 - - if (PhysicsObj is not null) - { - bool onGround = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact) - && PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable); - if (onGround - && forward == MotionCommand.Ready - && InterpretedState.SideStepCommand == 0 - && InterpretedState.TurnCommand == 0) - { - StandingLongJump = true; - } - } - - return WeenieError.None; - } - - // ── FUN_00528780 — jump ─────────────────────────────────────────────────── + // ── FUN_00529390 — jump ─────────────────────────────────────────────────── /// /// Initiate a jump: validate, store jump extent, leave the ground. /// - /// - /// Verbatim (0x00528780, decomp §3f @305792): - /// - /// physics_obj = this->physics_obj; - /// if (physics_obj == 0) return 8; - /// interrupt_current_movement(physics_obj); - /// result = jump_is_allowed(this, arg2, arg3); - /// if (result != 0) { - /// this->standing_longjump = 0; - /// return result; - /// } - /// this->jump_extent = arg2; - /// set_on_walkable(physics_obj, 0); - /// return result; // == 0 - /// - /// - /// - /// - /// R3-W3 (closes J7-interp-side): interrupt_current_movement is - /// now called UNCONDITIONALLY before , via - /// the no-op seam (register row — - /// R4 wires the real cancel_moveto). is - /// cleared ONLY on the failure path — a successful jump leaves it - /// untouched (the caller/LeaveGround owns clearing it on success). - /// + /// Decompiled logic (FUN_00529390): + /// if (PhysicsObj == null) return 8 + /// FUN_00510cc0() — cancel moveto + /// iVar1 = FUN_00528ec0(extent, stamina) ← jump_is_allowed + /// if (iVar1 == 0): + /// *(+0x74) = extent ← JumpExtent + /// FUN_00511de0(0) ← PhysicsObj.set_on_walkable(false) + /// return 0 + /// *(+0x70) = 0 ← StandingLongJump = false + /// return iVar1 /// public WeenieError jump(float extent, int adjustStamina = 0) { if (PhysicsObj is null) return WeenieError.NoPhysicsObject; - InterruptCurrentMovement?.Invoke(); - - var result = jump_is_allowed(extent, out _); + var result = jump_is_allowed(extent, adjustStamina); if (result == WeenieError.None) { JumpExtent = extent; @@ -2024,29 +705,25 @@ public sealed class MotionInterpreter : IMotionDoneSink return result; } - // ── FUN_00527aa0 — get_jump_v_z ────────────────────────────────────────── + // ── FUN_005286b0 — get_jump_v_z ────────────────────────────────────────── /// /// Get the vertical (Z) component of jump velocity. /// - /// - /// Verbatim (0x00527aa0, decomp §3c @304953, W0-pins.md A5 — the BN text - /// is x87-flag garbled; ACE's clean-room reading adjudicates): - /// - /// extent = this->jump_extent; - /// if (extent < 0.000199999995f) return 0.0f; - /// if (extent > 1.0f) extent = 1.0f; - /// if (this->weenie_obj == 0) return 10.0f; - /// if (weenie_obj.InqJumpVelocity(extent, &extent) != 0) return extent; - /// return 0.0f; - /// - /// + /// Decompiled logic (FUN_005286b0): + /// local_4 = *(+0x74) ← JumpExtent + /// if local_4 < _DAT_007c9734 (epsilon): return _DAT_00796344 (0.0) + /// if local_4 > _DAT_007938b0 (1.0): local_4 = 1.0 + /// if WeenieObj == null: return _DAT_0079c6d4 (10.0) — default jump v_z + /// cVar1 = InqJumpVelocity(local_4, &local_4) — vtable +0x30 + /// if (cVar1 != 0): return local_4 + /// return _DAT_00796344 (0.0) /// - public float GetJumpVZ() + public float get_jump_v_z() { float extent = JumpExtent; - if (extent < JumpVzEpsilon) + if (extent < JumpExtentEpsilon) return 0.0f; if (extent > MaxJumpExtent) @@ -2061,31 +738,34 @@ public sealed class MotionInterpreter : IMotionDoneSink return 0.0f; } - // ── FUN_005280c0 — get_leave_ground_velocity ────────────────────────────── + // ── FUN_00528cd0 — get_leave_ground_velocity ────────────────────────────── /// /// Compose the full 3D body-local launch velocity when leaving the ground. /// - /// - /// Verbatim (0x005280c0, decomp §3d @305404, W0-pins.md A6): body order - /// is get_state_velocity(esi)esi.z = get_jump_v_z() → - /// fallback fires ONLY when |x| AND |y| AND |z| are ALL - /// < 0.000199999995f (epsilon tested three times, one per - /// component), and then OVERWRITES ALL THREE components (including the - /// z the function just computed) with - /// globaltolocal(physics_obj->m_velocityVector) — decisively - /// pinned as GLOBAL→LOCAL by the row-linear match against - /// Frame::globaltolocalvec (A6). The existing - /// Vector3.Transform(Velocity, Quaternion.Inverse(Orientation)) - /// transform already IS global→local — kept unchanged. - /// + /// Decompiled logic (FUN_00528cd0): + /// FUN_00528960(velocity) ← get_state_velocity (XY components) + /// velocity.Z = get_jump_v_z() + /// If all three components are < epsilon (nearly zero velocity): + /// Apply the orientation matrix rows of PhysicsObj to the current + /// world-space velocity (rotate world vel into body-local frame). + /// This preserves momentum direction when jumping while stationary. + /// return velocity + /// + /// The "near-zero" fast path uses the body's current velocity transformed + /// back into local space, which in our port is + /// Vector3.Transform(Velocity, Quaternion.Inverse(Orientation)). /// - public Vector3 GetLeaveGroundVelocity() + public Vector3 get_leave_ground_velocity() { var velocity = get_state_velocity(); - velocity.Z = GetJumpVZ(); + velocity.Z = get_jump_v_z(); - float eps = JumpVzEpsilon; + // If the lateral + vertical components are all tiny, fall back to the + // current world velocity projected into body-local space so that an + // airborne nudge preserves direction (retail decompile: matrix multiply + // of the orientation column vectors against the world velocity). + float eps = JumpExtentEpsilon; if (MathF.Abs(velocity.X) < eps && MathF.Abs(velocity.Y) < eps && MathF.Abs(velocity.Z) < eps && PhysicsObj is not null) { @@ -2096,116 +776,46 @@ public sealed class MotionInterpreter : IMotionDoneSink return velocity; } - // ── FUN_005282b0 — jump_is_allowed ──────────────────────────────────────── + // ── FUN_00528ec0 — jump_is_allowed ──────────────────────────────────────── /// - /// Determine whether a jump is currently permitted. R3-W3 (closes J5): - /// FULL verbatim chain replacing the pre-W3 15-line approximation — - /// entry shape, IsFullyConstrained, the pending-head peek (A2), - /// the charge→motion→stamina chain, all now present. + /// Determine whether a jump is currently permitted. /// - /// - /// Verbatim (0x005282b0, decomp §3h, W0-pins.md A2/A10): - /// - /// if (physics_obj != 0) { - /// if (weenie_obj != 0) eax_2 = weenie_obj.IsCreature(); - /// if (weenie_obj != 0 && eax_2 == 0) goto shared_gate; // non-creature weenie skips ground gate - /// if (physics_obj == 0 || (state bit 0x400) == 0) goto shared_gate; // gravity-state off skips ground gate - /// if (Contact && OnWalkable) goto shared_gate; // grounded also reaches shared gate - /// } - /// return 0x24; // gravity-bound creature, not grounded - /// - /// shared_gate: - /// if (IsFullyConstrained(physics_obj) != 0) return 0x47; - /// head = pending_motions.head_; - /// if (head != 0) eax_6 = head.jump_error_code; // +0xc - /// if (head == 0 || eax_6 == 0) { - /// eax_6 = jump_charge_is_allowed(this); - /// if (eax_6 == 0) { - /// eax_7 = motion_allows_jump(this, interpreted_state.forward_command); - /// if (eax_7 != 0) return eax_7; - /// if (weenie_obj_1 == 0) return eax_7; // == 0 (success, no weenie to consult) - /// eax_6 = 0x47; - /// if (weenie_obj_1.JumpStaminaCost(arg2, arg3) != 0) return eax_7; // == 0 (afforded) - /// // JumpStaminaCost returned false -> falls out, eax_6 stays 0x47 - /// } - /// } - /// return eax_6; - /// - /// - /// - /// - /// A10 note: physics_obj == null returns 0x24 (NotGrounded), - /// NOT 8 — the "8 = no physics obj" convention that holds - /// everywhere else in CMotionInterp does not hold here (the - /// if (physics_obj != 0) {...} wrapper falls out to - /// return 0x24 when physics_obj is null, same as the grounded-check - /// failure path). - /// + /// Decompiled logic (FUN_00528ec0): + /// if PhysicsObj == null: return 0x24 + /// if WeenieObj == null: proceed (no weenie check) + /// elif WeenieObj.IsCreature() returns false: proceed + /// iVar2 = PhysicsObj + /// if Gravity flag NOT set OR (Contact AND OnWalkable): ← grounded or no gravity + /// return 0x24 (GeneralMovementFailure) + /// if FUN_0050f730() (IsFullyConstrained) != 0: return 0x47 + /// if pending queue action has non-zero jump error: return that error + /// iVar2 = FUN_00528660() (jump_charge_is_allowed) + /// if iVar2 == 0: + /// iVar2 = FUN_005285e0(InterpretedState.ForwardCommand) (motion_allows_jump) + /// if iVar2 == 0 AND WeenieObj != null: + /// cVar1 = WeenieObj.CanJump(extent, stamina) → vtable +0x40 + /// if cVar1 == 0: return 0x47 + /// return iVar2 /// - /// Jump charge fraction, forwarded to - /// JumpStaminaCost. - /// Out-param mirroring retail's arg3 - /// (int32_t*) — the stamina cost JumpStaminaCost computed, - /// 0 when the chain never reaches that call. - public WeenieError jump_is_allowed(float extent, out int staminaCost) + public WeenieError jump_is_allowed(float extent, int staminaCost) { - staminaCost = 0; + if (PhysicsObj is null) + return WeenieError.GeneralMovementFailure; - if (PhysicsObj is not null) - { - bool nonCreatureWeenie = WeenieObj is not null && !WeenieObj.IsCreature(); - bool gravityStateOff = !PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity); - bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact) - && PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable); + // Must have gravity and be grounded (Contact + OnWalkable) to start a jump. + bool hasGravity = PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity); + bool isGrounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact) + && PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable); - if (nonCreatureWeenie || gravityStateOff || grounded) - return JumpIsAllowedSharedGate(extent, ref staminaCost); - } + if (!hasGravity || !isGrounded) + return WeenieError.YouCantJumpWhileInTheAir; - return WeenieError.NotGrounded; // 0x24 — gravity-bound creature, not grounded (also physics_obj == null) - } + // Delegate jump eligibility to WeenieObj if present. + if (WeenieObj is not null && !WeenieObj.CanJump(extent)) + return WeenieError.CantJumpLoadedDown; - /// - /// The shared_gate label inside - /// (raw 305524-305556) — split out only for C# readability; retail - /// reaches this point via three different goto sites, all folded - /// into one function here since C# has no goto-into-shared-tail idiom - /// as clean as retail's. - /// - private WeenieError JumpIsAllowedSharedGate(float extent, ref int staminaCost) - { - if (PhysicsObj is not null && PhysicsObj.IsFullyConstrained) - return WeenieError.GeneralMovementFailure; // 0x47 - - // A2: peek the pending_motions head WHENEVER non-empty — no Count>1 - // gate. A nonzero head.JumpErrorCode short-circuits the whole - // charge/motion/stamina chain below. - var head = _pendingMotions.First; - uint peeked = head is not null ? head.Value.JumpErrorCode : 0; - - if (head is null || peeked == 0) - { - WeenieError chargeResult = JumpChargeIsAllowed(extent); - if (chargeResult == WeenieError.None) - { - WeenieError motionResult = MotionAllowsJump(InterpretedState.ForwardCommand); - if (motionResult != WeenieError.None) - return motionResult; - - if (WeenieObj is null) - return motionResult; // == None - - if (!WeenieObj.JumpStaminaCost(extent, out staminaCost)) - return WeenieError.GeneralMovementFailure; // 0x47 — can't afford - - return motionResult; // == None (success) - } - - return chargeResult; - } - - return (WeenieError)peeked; + return WeenieError.None; } // ── FUN_00528dd0 — contact_allows_move ──────────────────────────────────── @@ -2213,512 +823,115 @@ public sealed class MotionInterpreter : IMotionDoneSink /// /// Determine whether the current contact state allows this motion command. /// - /// - /// Decompiled logic (0x00528240, pseudo-C 305471): allowed (1) when — - /// motion is TurnRight/TurnLeft (0x6500000D/0E), OR motion is Falling - /// (0x40000015) or Dead-class (0x40000011), OR the weenie exists and is - /// NOT a creature, OR gravity is off, OR the body has Contact + - /// OnWalkable. Everything else (a gravity-bound creature without ground - /// contact) is blocked — this is the real mechanism behind "airborne - /// remotes keep their cycle" (K-fix17's empirical guard). - /// + /// Decompiled logic (FUN_00528dd0): + /// if WeenieObj != null AND WeenieObj.CanJump(JumpExtent) returns false: + /// return 0x49 + /// uVar1 = InterpretedState.ForwardCommand + /// if uVar1 == 0x40000008 (Fallen) OR uVar1 == 0x40000011 (Dead): + /// return 0x48 + /// if 0x41000011 < uVar1 < 0x41000015 (crouch/sit/sleep range): + /// return 0x48 + /// uVar2 = PhysicsObj.TransientState + /// if (Contact AND OnWalkable) AND ForwardCommand == Ready + /// AND SideStepCommand == 0 AND TurnCommand == 0: + /// StandingLongJump = true + /// return 0 /// - /// - /// The return type in the decompile is undefined4 (int), but ACE models - /// it as bool (0 = allowed, non-zero = blocked). We model it as bool - /// here for cleaner call sites, treating any non-zero return as - /// "blocked". - /// - /// - /// - /// R3-W3 (closes J6): the StandingLongJump side effect that used to - /// live here is DELETED. The S2a port had flagged it explicitly as - /// "PRE-EXISTING acdream side effect (not part of 0x00528240)" — a - /// misattribution: retail's contact_allows_move (0x00528240) - /// never reads or writes standing_longjump at all. The real - /// arming site is (0x005281c0, decomp §3e), - /// which now owns the identical grounded+idle predicate exclusively. - /// Consequence of the OLD bug this retires: every grounded idle contact - /// check (i.e. every frame the player stood still, since - /// ApplyInterpretedMovement calls this every dispatch) flipped - /// the flag regardless of whether a jump charge was ever started — - /// is the only path that can arm it now. - /// + /// The return type in the decompile is undefined4 (int), but ACE models it + /// as bool (0 = allowed, non-zero = blocked). We model it as bool here for + /// cleaner call sites, treating any non-zero return as "blocked". /// public bool contact_allows_move(uint motion) { if (PhysicsObj is null) return false; - if (motion > 0x40000015u) - { - if (motion is MotionCommand.TurnRight or MotionCommand.TurnLeft) - return true; - } - else if (motion == MotionCommand.Falling || motion == 0x40000011u) - { - return true; - } - - if (WeenieObj is not null && !WeenieObj.IsCreature()) + // Turn commands are always allowed regardless of ground contact. + // (Decompile doesn't explicitly early-return for turns here, but + // ACE and the general shape of the code confirm they bypass the block.) + if (motion == MotionCommand.TurnRight || motion == MotionCommand.TurnLeft) return true; + // Dead or Fallen forward-command blocks movement. + uint fwd = InterpretedState.ForwardCommand; + if (fwd == MotionCommand.Fallen || fwd == MotionCommand.Dead) + return false; + + // Crouch / sit / sleep range (0x41000011 < fwd < 0x41000015). + if (fwd > MotionCommand.CrouchLowerBound && fwd < MotionCommand.CrouchUpperExclusive) + return false; + + // Need Gravity flag + Contact + OnWalkable for ground-based motion. if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity)) - return true; + return true; // no gravity → object can always move (swimming, flying) - bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact) - && PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable); + bool contact = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact); + bool onWalkable = PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable); - return grounded; - } - - // ── R3-W2 — pending_motions lifecycle ───────────────────────────────── - // docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md §1. - - /// - /// CMotionInterp::add_to_queue (0x00527b80, decomp §1a @305032): - /// allocate a and append it to the tail of - /// (retail: append at tail; if the queue was - /// empty, head and tail both point at the new node — the C# - /// gives this for free via - /// AddLast). - /// - /// Retail arg2MotionNode.context_id. - /// Retail arg3MotionNode.motion. - /// Retail arg4MotionNode.jump_error_code. - public void AddToQueue(uint contextId, uint motion, uint jumpErrorCode) - { - _pendingMotions.AddLast(new MotionNode(contextId, motion, jumpErrorCode)); - } - - /// - /// CMotionInterp::motions_pending (0x00527fe0, decomp §1b - /// @305322): pending_motions.head_ != null. - /// - public bool MotionsPending() => _pendingMotions.First is not null; - - /// - /// CMotionInterp::MotionDone (0x00527ec0, decomp §1c @305238, - /// FULL BODY). A7 (W0-pins.md): and - /// are read into locals by the decompiled - /// relay chain but NEVER actually used by this build's body — the queue - /// head is popped UNCONDITIONALLY, never matched by motion id. Params - /// are kept for R5 signature parity with the real - /// MovementManager::MotionDone relay. - /// - /// - /// Body (verbatim): no-op if is null or the - /// queue is empty. Peek the HEAD: if head.Motion & 0x10000000 - /// (the action-class bit) is set, fire - /// then pop the head of BOTH - /// 's and 's action - /// FIFOs. Then unconditionally dequeue the pending_motions head. - /// - /// - public void MotionDone(uint motion, bool success) - { - if (PhysicsObj is null) - return; - - var head = _pendingMotions.First; - if (head is null) - return; - - if ((head.Value.Motion & 0x10000000u) != 0) - { - UnstickFromObject?.Invoke(); - InterpretedState.RemoveAction(); - RawState.RemoveAction(); - } - - // Re-peek per retail's structure (head__1 = this->pending_motions.head_, - // a fresh read after the action-class branch above) — functionally - // the same node since nothing else can mutate the queue mid-call. - var head1 = _pendingMotions.First; - if (head1 is not null) - { - _pendingMotions.RemoveFirst(); - } - } - - /// - /// CMotionInterp::HandleExitWorld (0x00527f30, decomp §1d): - /// identical body to 's single-pop logic, looped - /// until drains — world exit flushes all - /// pending motion callbacks unconditionally, since no more - /// animation-completion events will arrive. - /// - /// - /// Ambiguity resolved (not in W0-pins.md — found while porting): - /// the raw decompile's loop condition is head_ != 0 - /// (re-read every iteration), but the pop only happens inside a NESTED - /// if (physics_obj != 0 && head_ != 0) guard — a literal - /// translation would infinite-loop if were null - /// with a non-empty queue (the loop variable never advances). This is - /// dead code in retail: a live CMotionInterp::HandleExitWorld call - /// only ever happens on an interp already bound to a physics_obj - /// (retail's CMotionInterp lifetime is physics-object-scoped) — - /// the null check exists defensively, not as a real "still drain, but - /// skip the pop" branch. Porting a genuine infinite loop is not - /// "faithful", it's a bug retail never actually exercises; this port - /// drains unconditionally regardless of (same - /// choice / already - /// make — they too are called before a physics_obj may exist during - /// interp construction/testing). - /// - /// - public void HandleExitWorld() - { - while (_pendingMotions.First is not null) - { - var head = _pendingMotions.First!; - if ((head.Value.Motion & 0x10000000u) != 0) - { - UnstickFromObject?.Invoke(); - InterpretedState.RemoveAction(); - RawState.RemoveAction(); - } - - _pendingMotions.RemoveFirst(); - } - } - - /// - /// CMotionInterp::is_standing_still (0x00527fa0, decomp @305309): - /// on-ground (Contact + OnWalkable) AND ForwardCommand == Ready AND - /// SideStepCommand == 0 AND TurnCommand == 0. - /// - public bool IsStandingStill() - { - if (PhysicsObj is null) + if (!contact) return false; - bool grounded = PhysicsObj.TransientState.HasFlag(TransientStateFlags.Contact) - && PhysicsObj.TransientState.HasFlag(TransientStateFlags.OnWalkable); - if (!grounded) + if (!onWalkable) return false; - return InterpretedState.ForwardCommand == MotionCommand.Ready + // Grounded and idle — flag as standing-long-jump candidate. + if (fwd == MotionCommand.Ready && InterpretedState.SideStepCommand == 0 - && InterpretedState.TurnCommand == 0; - } - - /// - /// CMotionInterp::motion_allows_jump (0x005279e0, decomp §3a - /// @304908, __pure). A1 (W0-pins.md, adversarially verified): - /// PINNED as a BLOCKLIST — 0 = jump allowed (pass), 0x48 = - /// jump BLOCKED. Ported as literal uint range comparisons mirroring the - /// decomp's exact branch algebra (NOT enum-ordinal ranges — the whole - /// point of A1 is that ACE's enum-order dependence is fragile). - /// - /// - /// Blocklist (definitive table, W0-pins.md §A1): - /// - /// [0x1000006f, 0x10000078] — MagicPowerUp01..MagicPowerUp10. - /// [0x10000128, 0x10000131] — TripleThrustLow..MagicPowerUp07Purple. - /// 0x40000008 exact — Fallen (NOT Falling; ACE mis-transcribed this). - /// [0x40000016, 0x40000018] — Reload, Unload, Pickup. - /// [0x4000001e, 0x40000039] — AimLevel..MagicPray. - /// [0x41000012, 0x41000014] — Crouch, Sitting, Sleeping. - /// - /// Everything else — including Falling 0x40000015, Ready - /// 0x41000003, Dead 0x40000011, all turn/sidestep ids — PASSES. - /// - /// - /// - /// This is a pure function (no instance state) — retail's this - /// parameter is unused in the body (the decomp's __pure - /// attribute confirms it doesn't read this). - /// - /// - /// Retail arg2 — the motion id to test. - public static WeenieError MotionAllowsJump(uint motion) - { - // Verbatim branch algebra from raw 304908-304931 (W0-pins.md §A1): - // if (arg2 > 0x40000018) { - // if (arg2 > 0x41000014) return 0; - // if (arg2 < 0x41000012 && (arg2 < 0x4000001e || arg2 > 0x40000039)) return 0; - // } else if (arg2 < 0x40000016) { - // if (arg2 > 0x10000131) { - // if (arg2 != 0x40000008) return 0; - // } else if (arg2 < 0x10000128 && (arg2 < 0x1000006f || arg2 > 0x10000078)) return 0; - // } - // return 0x48; - // - // The middle band [0x40000016, 0x40000018] satisfies NEITHER outer - // branch (not > 0x40000018, not < 0x40000016) and falls straight to - // `return 0x48` — a genuine "no early return" gap in the middle. - if (motion > 0x40000018u) + && InterpretedState.TurnCommand == 0) { - if (motion > 0x41000014u) - return WeenieError.None; - if (motion < 0x41000012u && (motion < 0x4000001eu || motion > 0x40000039u)) - return WeenieError.None; - } - else if (motion < 0x40000016u) - { - if (motion > 0x10000131u) - { - if (motion != 0x40000008u) - return WeenieError.None; - } - else if (motion < 0x10000128u && (motion < 0x1000006fu || motion > 0x10000078u)) - { - return WeenieError.None; - } + StandingLongJump = true; } - return WeenieError.YouCantJumpFromThisPosition; + return true; } - // ── FUN_00528b00 — LeaveGround ──────────────────────────────────────────── + // ── FUN_00529710 — LeaveGround ──────────────────────────────────────────── /// - /// CMotionInterp::LeaveGround (0x00528b00, decomp §4b @306022, - /// FULL BODY — replaces the pre-W4 approximation, closes J8's - /// LeaveGround leg). Called when the body becomes airborne (also - /// tail-called unconditionally from , - /// §4d). + /// Called when the body becomes airborne. Applies the leave-ground velocity + /// and resets the jump state. /// - /// - /// Verbatim (raw 306022-306040, quoted in full in the final-report): - /// creature gate (weenie_obj == 0 || IsCreature() != 0) AND - /// state-0x400 (Gravity) gate — SAME shape as . - /// When both pass: compute the launch velocity via - /// , apply it with - /// set_local_velocity(&v, autonomous=1) (the literal - /// 1 arg — - /// becomes true), reset and - /// to their zero/false defaults (the jump - /// charge is consumed the instant you actually leave the ground), then - /// + apply_current_movement(0, - /// 0) (the same re-sync performs). - /// + /// Decompiled logic (FUN_00529710): + /// if PhysicsObj == null: return + /// velocity = get_leave_ground_velocity() + /// PhysicsObj.set_local_velocity(velocity) + /// StandingLongJump = false + /// JumpExtent = 0 /// public void LeaveGround() { if (PhysicsObj is null) return; - bool isCreature = WeenieObj is null || WeenieObj.IsCreature(); - if (!isCreature) - return; - - if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity)) - return; - - var velocity = GetLeaveGroundVelocity(); - PhysicsObj.set_local_velocity(velocity, autonomous: true); + var velocity = get_leave_ground_velocity(); + PhysicsObj.set_local_velocity(velocity); StandingLongJump = false; JumpExtent = 0f; - - RemoveLinkAnimations?.Invoke(); - // Raw (0, 0) → allowJump: true (#161 polarity decode). - apply_current_movement(cancelMoveTo: false, allowJump: true); } - // ── FUN_00528ac0 — HitGround ────────────────────────────────────────────── + // ── FUN_005296d0 — HitGround ────────────────────────────────────────────── /// - /// CMotionInterp::HitGround (0x00528ac0, decomp §4a @305996, - /// FULL BODY — replaces the pre-W4 approximation, closes J8's - /// HitGround leg). Called when the body lands on a walkable surface. + /// Called when the body lands on a walkable surface. /// - /// - /// Verbatim (raw 305996-306014): - /// - /// if (physics_obj != 0) { - /// if (weenie_obj != 0) eax_2 = weenie_obj->IsCreature(); - /// if (weenie_obj == 0 || eax_2 != 0) { - /// if (physics_obj != 0 && (state & 0x400) != 0) { - /// RemoveLinkAnimations(physics_obj); - /// apply_current_movement(this, 0, 0); - /// } - /// } - /// } - /// - /// - /// - /// - /// A3 note: this creature gate uses IsCreature (vtable +0x2c), - /// NOT IsThePlayer (+0x14) — the anti-artifact proof for A3's - /// pin (BN distinguishes the two slots locally, ~0x250 bytes from the - /// IsThePlayer-gated functions). - /// + /// Decompiled logic (FUN_005296d0): + /// if PhysicsObj == null: return + /// if WeenieObj != null AND NOT creature: return + /// if Gravity flag not set: return + /// apply_current_movement(false, true) /// public void HitGround() { if (PhysicsObj is null) return; - bool isCreature = WeenieObj is null || WeenieObj.IsCreature(); - if (!isCreature) - return; - if (!PhysicsObj.State.HasFlag(PhysicsStateFlags.Gravity)) return; - RemoveLinkAnimations?.Invoke(); - // Raw (0, 0) → allowJump: true (#161 polarity decode). The - // re-apply dispatches the PRESERVED interpreted forward command - // (the apply pass never writes it — ModifyInterpretedState=false) - // — this IS the falling-pose exit on landing. apply_current_movement(cancelMoveTo: false, allowJump: true); } - // ── FUN_00528c80 — enter_default_state ──────────────────────────────────── - - /// - /// CMotionInterp::enter_default_state (0x00528c80, decomp §4d - /// @306124, FULL BODY, W0-pins.md A8, closes J10). Resets both motion - /// states to fresh defaults, re-initializes the physics object's - /// motion tables, APPENDS the canonical {0, Ready, 0} sentinel - /// to (NO drain — pre-existing queued - /// nodes survive, A8), marks , then tail-calls - /// unconditionally. - /// - /// - /// Verbatim (raw 306124-306154): - /// - /// raw_state = RawMotionState(); // fresh ctor defaults - /// interpreted_state = InterpretedMotionState(); // fresh ctor defaults - /// CPhysicsObj::InitializeMotionTables(physics_obj); - /// node = new MotionNode { context_id=0, motion=0x41000003, jump_error_code=0 }; - /// pending_motions.append(node); // tail-splice, NO clear - /// initted = 1; - /// LeaveGround(this); - /// - /// - /// - /// - /// Retail's real construction paths (MovementManager::Create + - /// the lazy-create guard at every MovementManager entry point) - /// call this exactly once (or twice on the lazy-create double-call — - /// §6g, genuine retail, not "fixed") before exposing the interpreter to - /// any caller. acdream's constructors default to - /// true directly (see that property's doc comment) rather than - /// routing every construction through this method, so this port is the - /// verbatim, fully-testable "reset to default/idle state" operation — - /// callers that want retail's FULL reset semantics (state defaults + - /// sentinel + LeaveGround tail), not just the Initted flag, call - /// this explicitly. - /// - /// - public void EnterDefaultState() - { - RawState = new RawMotionState(); - InterpretedState = InterpretedMotionState.Default(); - - InitializeMotionTables?.Invoke(); - - AddToQueue(contextId: 0, MotionCommand.Ready, jumpErrorCode: 0); - - Initted = true; - - LeaveGround(); - } - - // ── FUN_00528b70 / FUN_00528bb0 — set_hold_run / SetHoldKey ──────────────── - - /// - /// CMotionInterp::set_hold_run (0x00528b70, decomp §5c @306053, - /// FULL BODY, closes J13's set_hold_run leg). XOR toggle guard: only - /// acts when actually FLIPS the current - /// effective hold-run state (skip redundant re-application). - /// - /// - /// Verbatim (raw 306053-306070): - /// - /// eax = (arg2 == 0); // "run key is up" - /// edx = (raw_state.current_holdkey != Run); - /// if (eax != edx) { // XOR: state actually changes - /// raw_state.current_holdkey = (arg2 != 0) + 1; // false->1(None), true->2(Run) - /// apply_current_movement(this, arg3, 0); - /// } - /// - /// - /// - /// Retail arg2 — nonzero = the run key - /// is currently held down. - /// Retail arg3 — passed through as - /// apply_current_movement's cancelMoveTo arg. - public void set_hold_run(bool holdingRun, bool interrupt) - { - bool runKeyUp = !holdingRun; - bool notCurrentlyRun = RawState.CurrentHoldKey != HoldKey.Run; - - if (runKeyUp != notCurrentlyRun) - { - RawState.CurrentHoldKey = holdingRun ? HoldKey.Run : HoldKey.None; - // Raw (arg3, 0) → allowJump: true (#161 polarity decode). - apply_current_movement(cancelMoveTo: interrupt, allowJump: true); - } - } - - /// - /// CMotionInterp::SetHoldKey (0x00528bb0, decomp §5d @306072, - /// FULL BODY, closes J13's SetHoldKey leg). No-op if - /// already equals . - /// Setting only takes effect (and - /// re-applies movement) if the CURRENT hold key is - /// — requesting None while already something - /// else (e.g. Invalid) is silently ignored. Setting - /// takes effect whenever the current key - /// isn't already Run. - /// - /// - /// Verbatim (raw 306072-306095): - /// - /// current = raw_state.current_holdkey; - /// if (arg2 != current) { - /// if (arg2 == HoldKey_None) { - /// if (current == HoldKey_Run) { - /// raw_state.current_holdkey = HoldKey_None; - /// apply_current_movement(this, arg3, 0); - /// } - /// } else if (arg2 == HoldKey_Run && current != HoldKey_Run) { - /// raw_state.current_holdkey = HoldKey_Run; - /// apply_current_movement(this, arg3, 0); - /// } - /// } - /// - /// - /// - /// - /// This is the function DoMotion (W5) calls when bit - /// 0x800 (SetHoldKey) of the incoming MovementParameters - /// requests a hold-key change, and what - /// reads back via (mirrored - /// onto — see the field doc) to decide - /// whether fires. - /// - /// - /// Retail arg2. - /// Retail arg3 — passed through as - /// apply_current_movement's cancelMoveTo arg. - public void SetHoldKey(HoldKey key, bool cancelMoveTo) - { - HoldKey current = RawState.CurrentHoldKey; - if (key == current) - return; - - if (key == HoldKey.None) - { - if (current == HoldKey.Run) - { - RawState.CurrentHoldKey = HoldKey.None; - // Raw (arg3, 0) → allowJump: true (#161 polarity decode). - apply_current_movement(cancelMoveTo, allowJump: true); - } - } - else if (key == HoldKey.Run && current != HoldKey.Run) - { - RawState.CurrentHoldKey = HoldKey.Run; - apply_current_movement(cancelMoveTo, allowJump: true); - } - } - // ── CMotionInterp::get_max_speed (0x00527cb0) ───────────────────────────── /// @@ -2766,541 +979,40 @@ public sealed class MotionInterpreter : IMotionDoneSink return RunAnimSpeed * rate; } - // R3-W5: the former `ApplyMotionToInterpretedState` private helper - // (a hand-rolled switch approximating InterpretedMotionState.ApplyMotion) - // is DELETED per the plan (closes J3/J4) — the merged DoInterpretedMotion - // below calls the verbatim InterpretedMotionState.ApplyMotion(motion, p) - // directly (already ported, W1). - - // ══ L.2g S2 — the inbound CMotionInterp funnel (DEV-1) ════════════════ - // Spec: docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md. - // Dispatch order validated against the LIVE retail-observer cdb trace - // (tools/cdb/l2g-observer.cdb): style → forward → sidestep(-stop) → - // turn(-stop) → actions, wholesale per UM. + // ── private helper ──────────────────────────────────────────────────────── /// - /// True when this interpreter belongs to the LOCAL player. Retail skips - /// replaying AUTONOMOUS actions on the local player (they are its own - /// echo — move_to_interpreted_state 305977) but applies them on remotes. + /// Apply a motion command to the interpreted state fields. + /// Mirrors the InterpretedState.ApplyMotion logic in ACE. /// - public bool IsLocalPlayer; - - /// - /// CMotionInterp::server_action_stamp — 15-bit wraparound stamp - /// of the last applied inbound action (move_to_interpreted_state - /// 305953-305989). - /// - public int ServerActionStamp; - - /// - /// CMotionInterp::move_to_interpreted_state (0x005289c0, - /// pseudo-C 305936): adopt the style into the raw state, FLAT-copy the - /// incoming interpreted state (copy_movement_from 0x0051e750 — - /// every axis overwritten, defaults included), re-apply the whole - /// movement through the sink, then replay fresh actions under the - /// 15-bit stamp gate. - /// - /// - /// R3-W4 (Adjacent finding, W0-pins.md): raw 305946-305949 - /// computes eax_2 = motion_allows_jump(this->interpreted_state.forward_command) - /// — the OLD forward command, BEFORE copy_movement_from - /// overwrites two lines later — then - /// calls apply_current_movement(this, 1, allowJump) where - /// allowJump = (eax_2 == 0) (the raw's own - /// -((esi_1 - esi_1)) is decompiler noise for that same - /// boolean). Ported here as on the - /// PRE-overwrite .ForwardCommand, - /// snapshotted before the flat-copy below. Passed through to - /// per its TODO(W5) — this - /// funnel calls the interpreted body directly (not the - /// dual-dispatch gate), matching - /// the existing S2 architecture: inbound funnel state is always - /// server-authoritative, so it always wants the interpreted path - /// regardless of IsThePlayer/autonomous. - /// - /// - public int MoveToInterpretedState(in InboundInterpretedState ims, IInterpretedMotionSink? sink = null) + private void ApplyMotionToInterpretedState(uint motion, float speed) { - if (PhysicsObj is null) return 0; - - RawState.CurrentStyle = ims.CurrentStyle; - - // motion_allows_jump on the OLD forward_command, BEFORE the - // flat-copy below overwrites it (Adjacent finding). - bool allowJump = MotionAllowsJump(InterpretedState.ForwardCommand) == WeenieError.None; - - // copy_movement_from — flat overwrite, no per-field presence checks. - // current_style is the FIRST copied field (raw 0051e757) — it is - // what HitGround/LeaveGround re-applies as the style dispatch - // (#161: previously omitted, so the landing re-apply read a stale - // interpreted style). - InterpretedState.CurrentStyle = ims.CurrentStyle; - InterpretedState.ForwardCommand = ims.ForwardCommand; - InterpretedState.ForwardSpeed = ims.ForwardSpeed; - InterpretedState.SideStepCommand = ims.SideStepCommand; - InterpretedState.SideStepSpeed = ims.SideStepSpeed; - InterpretedState.TurnCommand = ims.TurnCommand; - InterpretedState.TurnSpeed = ims.TurnSpeed; - - ApplyInterpretedMovement(ims.CurrentStyle, sink, cancelMoveTo: true, allowJump: allowJump); - - if (ims.Actions is { } actions) + switch (motion) { - foreach (var a in actions) - { - // 15-bit wraparound "newer" gate vs server_action_stamp: - // abs diff > 0x3fff flips the comparison (305955-305969). - int incoming = a.Stamp & 0x7FFF; - int stored = ServerActionStamp & 0x7FFF; - int diff = incoming >= stored ? incoming - stored : stored - incoming; - bool newer = diff <= 0x3FFF ? stored < incoming : incoming < stored; - if (!newer) continue; - - // Local player skips its own autonomous echoes (305977). - if (IsLocalPlayer && a.Autonomous) continue; - - ServerActionStamp = incoming; - DispatchInterpretedMotion(a.Command, a.Speed, a.Autonomous, sink); - } + case MotionCommand.WalkForward: + case MotionCommand.RunForward: + case MotionCommand.WalkBackward: + InterpretedState.ForwardCommand = motion; + InterpretedState.ForwardSpeed = speed; + break; + case MotionCommand.SideStepRight: + case MotionCommand.SideStepLeft: + InterpretedState.SideStepCommand = motion; + InterpretedState.SideStepSpeed = speed; + break; + case MotionCommand.TurnRight: + case MotionCommand.TurnLeft: + InterpretedState.TurnCommand = motion; + InterpretedState.TurnSpeed = speed; + break; + case MotionCommand.Ready: + InterpretedState.ForwardCommand = MotionCommand.Ready; + InterpretedState.ForwardSpeed = 1.0f; + InterpretedState.SideStepCommand = 0; + InterpretedState.SideStepSpeed = 1.0f; + InterpretedState.TurnCommand = 0; + InterpretedState.TurnSpeed = 1.0f; + break; } - - return 1; } - - /// - /// CMotionInterp::apply_interpreted_movement (0x00528600, - /// pseudo-C 305713): cache my_run_rate from a RunForward speed, - /// then dispatch style / forward-or-Falling / sidestep-or-stop / - /// turn-or-stop in retail order. A non-zero turn EARLY-RETURNS (no - /// turn-stop, no idle bookkeeping). - /// - /// - /// R3-W2 producer (closes J1's apply_interpreted_movement leg): - /// the tail turn-stop call (raw 305766-305785) is retail's - /// CPhysicsObj::StopInterpretedMotion(physics_obj, 0x6500000d, &var_2c) - /// — our call below IS - /// that dispatch. On success (eax_10 == 0, i.e. the sink accepted - /// the stop — this port has no failure signal from - /// , so it is treated as - /// always-succeeding, matching every real sink implementation which is - /// void), retail immediately re-queues the canonical "return to none" - /// node: add_to_queue(this, var_c /*uninitialized local in the raw - /// decompile — see the final-report contextId note*/, 0x41000003, eax_10) - /// where eax_10 (the stop's own return value) becomes the queued - /// node's jump_error_code — since the stop succeeded, that value - /// is 0. - /// - /// - /// - /// #161 (2026-07-03, closes R3-W4's TODO(W5)): retail builds ONE - /// MovementParameters for the whole pass - /// (MovementParameters::MovementParameters(&var_2c) @305719) - /// and then rewrites its bitfield: the "garbled tail expression" at raw - /// 305778 is the BN decompiler smearing that store into its single read - /// site — (word & 0x37ff) | (arg2&1)<<15 | - /// (arg3&1)<<17 clears bits 11/14/15 (SetHoldKey / - /// ModifyInterpretedState / CancelMoveTo), re-sets bit 15 - /// from arg2 (= ) and bit 17 from - /// arg3 (= DisableJumpDuringLink, so - /// = arg3 == 0). ACE - /// MotionInterp.cs:444-449 confirms independently. The load-bearing bit - /// is ModifyInterpretedState = false: NO dispatch issued by this - /// pass writes — the airborne Falling - /// substitution leaves the wire's forward command intact, which is what - /// lets 's re-apply dispatch the PRESERVED - /// pre-fall command (the motion table then plays the Falling→X landing - /// link — the falling-pose exit needs no wire input). The previous - /// revision dispatched with ctor-default params - /// (ModifyInterpretedState = true) — the #161 stuck-falling-pose - /// root cause, and the true mechanism behind the W6 "press W and stop - /// instantly" regression (the style dispatch could never have clobbered - /// forward_command in retail; the entry-cache workaround this body used - /// to carry is deleted — with no mid-pass state writes possible, live - /// field reads ARE retail's semantics). - /// - /// - public void ApplyInterpretedMovement( - uint currentStyle, IInterpretedMotionSink? sink, - bool cancelMoveTo = false, bool allowJump = false) - { - if (PhysicsObj is null) return; - - // Retail's rewritten var_2c (raw 305778 decoded; ACE 444-449): - // Speed is re-set per axis below, everything else rides the pass. - var p = new MovementParameters - { - SetHoldKey = false, - ModifyInterpretedState = false, - CancelMoveTo = cancelMoveTo, - DisableJumpDuringLink = !allowJump, - }; - - if (InterpretedState.ForwardCommand == MotionCommand.RunForward) - MyRunRate = InterpretedState.ForwardSpeed; - - p.Speed = 1.0f; - DoInterpretedMotion(currentStyle, p, sink); - - if (!contact_allows_move(InterpretedState.ForwardCommand)) - { - p.Speed = 1.0f; // raw 305728: var_18_2 = 0x3f800000 - DoInterpretedMotion(MotionCommand.Falling, p, sink); - } - else if (StandingLongJump) - { - p.Speed = 1.0f; - DoInterpretedMotion(MotionCommand.Ready, p, sink); - StopInterpretedMotion(MotionCommand.SideStepRight, p, sink); - } - else - { - p.Speed = InterpretedState.ForwardSpeed; - DoInterpretedMotion(InterpretedState.ForwardCommand, p, sink); - if (InterpretedState.SideStepCommand == 0) - { - StopInterpretedMotion(MotionCommand.SideStepRight, p, sink); - } - else - { - p.Speed = InterpretedState.SideStepSpeed; - DoInterpretedMotion(InterpretedState.SideStepCommand, p, sink); - } - } - - if (InterpretedState.TurnCommand != 0) - { - p.Speed = InterpretedState.TurnSpeed; - DoInterpretedMotion(InterpretedState.TurnCommand, p, sink); - return; // retail early return — no idle-stop this call - } - - // Tail (raw 305766-305786): unconditional StopInterpretedMotion(TurnRight, - // params) — the merged StopInterpretedMotion ITSELF performs the - // add_to_queue(context, Ready, 0) on success; with the pass params' - // ModifyInterpretedState=false the RemoveMotion(TurnRight) state - // clear does NOT fire (retail identical — ACE 497-498). - StopInterpretedMotion(MotionCommand.TurnRight, p, sink); - } - - /// - /// CMotionInterp::DoInterpretedMotion (0x00528360, raw - /// 305575-305631, FULL BODY, R3-W5, closes J4). THE ONE - /// DoInterpretedMotion — merges the former legacy - /// (uint, float, bool) overload's state-management with the S2a - /// funnel's DispatchInterpretedMotion sink-dispatch backend. - /// - /// - /// Verbatim (raw 305577-305631): - /// - /// if (physics_obj == 0) return 8; - /// if (contact_allows_move(arg2)) { - /// if (standing_longjump != 0 - /// && (arg2 == WalkForward || arg2 == RunForward || arg2 == SideStepRight)) - /// goto label_528440; // StandingLongJump: state-only, no dispatch, no queue - /// if (arg2 == 0x40000011 /*Dead*/) RemoveLinkAnimations(physics_obj); - /// result = sink.ApplyMotion(arg2, arg3); // CPhysicsObj::DoInterpretedMotion stand-in - /// if (result == 0) { - /// if ((arg3->bitfield & 0x20000) == 0) { // NOT DisableJumpDuringLink - /// eax_5 = motion_allows_jump(arg2); - /// if (eax_5 == 0 && (arg2 & 0x10000000) == 0) - /// eax_5 = motion_allows_jump(interpreted_state.forward_command); - /// } else eax_5 = 0x48; // DisableJumpDuringLink FORCES blocked - /// add_to_queue(arg3->context_id, arg2, eax_5); - /// if (arg3->bitfield & 0x4000 /*ModifyInterpretedState*/) - /// InterpretedMotionState::ApplyMotion(arg2, arg3); - /// } - /// } else if ((arg2 & 0x10000000) == 0) { - /// label_528440: - /// if (arg3->bitfield & 0x4000) InterpretedMotionState::ApplyMotion(arg2, arg3); - /// result = 0; - /// } else result = 0x24; - /// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj); - /// return result; - /// - /// - /// - /// - /// R3-W5 finding (not in the original W0 pins — discovered while - /// porting): 's - /// bool return IS retail's result == 0 test gating the - /// queue/state-write block. This is load-bearing, not cosmetic: the - /// VERY FIRST dispatch of every apply_interpreted_movement call - /// is the style/stance id (current_style, always - /// >= 0x80000000), which has no locomotion MotionData - /// entry in the dat — CMotionTable::DoObjectMotion genuinely - /// fails for it. If that failure isn't propagated (an earlier revision - /// of this port treated the sink as a void, always-succeeding - /// call), InterpretedMotionState::ApplyMotion's negative-motion - /// branch (arg2 < 0 → forward_command = 0x41000003) clobbers - /// BEFORE the very - /// next line reads it for the real forward dispatch — regressed 74/183 - /// cases of the live retail-observer conformance suite - /// (RetailObserverTraceConformanceTests) until the interface was - /// fixed to return the real result. - /// - /// - /// - /// Note the ACTUAL animation-table dispatch (the sink call) happens - /// through — the entity's persistent - /// IInterpretedMotionSink binding (R2-Q5's - /// MotionTableDispatchSink in production). This is the SAME sink - /// already routes through - /// for the apply_current_movement dual-dispatch tail — retail has - /// exactly one CPhysicsObj per CMotionInterp, so there is - /// only ever one dispatch target per entity, matching - /// 's single-binding shape. - /// - /// - /// Retail arg2. - /// Retail arg3. - public WeenieError DoInterpretedMotion(uint motion, MovementParameters p) - => DoInterpretedMotion(motion, p, DefaultSink); - - /// - /// Sink-parameterized core shared by the public - /// (which - /// always dispatches through , matching - /// retail's one-sink-per-CPhysicsObj shape) and the internal - /// - /// primitive (which needs an EXPLICIT per-call sink for - /// 's remote-entity callers). Same - /// verbatim body either way — only the dispatch target varies. - /// - private WeenieError DoInterpretedMotion(uint motion, MovementParameters p, IInterpretedMotionSink? sink) - { - if (PhysicsObj is null) - return WeenieError.NoPhysicsObject; - - WeenieError result; - - if (contact_allows_move(motion)) - { - bool standingLongJumpStateOnly = StandingLongJump - && (motion == MotionCommand.WalkForward - || motion == MotionCommand.RunForward - || motion == MotionCommand.SideStepRight); - - if (standingLongJumpStateOnly) - { - // label_528440 — state-only: no dispatch, no queue. - if (p.ModifyInterpretedState) - InterpretedState.ApplyMotion(motion, p); - return WeenieError.None; - } - - if (motion == MotionCommand.Dead) - RemoveLinkAnimations?.Invoke(); - - // sink.ApplyMotion stands in for CPhysicsObj::DoInterpretedMotion - // — its bool return IS retail's `result == 0` (a null sink, no - // dispatch backend wired, is treated as succeeding — matches - // every call site that doesn't care about the animation-table - // backend, e.g. bare unit tests exercising queue/state behavior - // only). A FAILED dispatch (the motion table found no cycle for - // this id — genuinely the case for style/stance ids) skips BOTH - // the queue AND the state-write, exactly like the "blocked, - // non-action" apply-only path below — but WITHOUT writing state. - bool dispatchOk = sink?.ApplyMotion(motion, p.Speed) ?? true; - - if (!dispatchOk) - { - // Retail: `result = CPhysicsObj::DoInterpretedMotion(...)` is - // whatever the failed dispatch returned (raw 305591-305593; - // no `else` branch — `result` simply isn't overwritten by - // the `if (result == 0)` block), so DoInterpretedMotion's - // OWN return value on a failed sink dispatch is that - // nonzero code, NOT 0. The exact numeric value is sink- - // internal (MotionTableManagerError.MotionFailed = 0x43, - // not one of CMotionInterp's own WeenieError codes) — no - // CMotionInterp-level WeenieError constant maps to it - // 1:1, so this port surfaces the closest existing local - // analog (GeneralMovementFailure, 0x47) rather than - // inventing a new enum member for a sink-internal code - // that's never queued or otherwise observed by any - // CMotionInterp caller. Flagged as an ambiguity — no test - // in this repo asserts DoInterpretedMotion's return value - // on a failed dispatch (only the queue/state SIDE EFFECTS, - // which correctly do NOT run here). - result = WeenieError.GeneralMovementFailure; - } - else - { - WeenieError jumpErr; - if (!p.DisableJumpDuringLink) - { - jumpErr = MotionAllowsJump(motion); - if (jumpErr == WeenieError.None && (motion & 0x10000000u) == 0) - jumpErr = MotionAllowsJump(InterpretedState.ForwardCommand); - } - else - { - jumpErr = WeenieError.YouCantJumpFromThisPosition; // 0x48 — forced BLOCKED - } - - AddToQueue(p.ContextId, motion, (uint)jumpErr); - - if (p.ModifyInterpretedState) - InterpretedState.ApplyMotion(motion, p); - - result = WeenieError.None; - } - } - else if ((motion & 0x10000000u) == 0) - { - // label_528440 (reached without the StandingLongJump goto) — - // apply-only: state kept, no cycle change, no queue. This is - // retail's real mechanism behind "airborne remotes keep their - // Falling cycle". - if (p.ModifyInterpretedState) - InterpretedState.ApplyMotion(motion, p); - result = WeenieError.None; - } - else - { - result = WeenieError.NotGrounded; // 0x24 (A10) — blocked action-class motion - } - - // R4-V5 door-swing fix (2026-07-03): retail's guard is - // physics_obj->cell == 0 — DETACHED objects only (raw @305627). - // The old proxy (CellPosition.ObjCellId == 0, #145 machinery seeded - // only by the local player's SnapToCell) read every REMOTE body as - // detached, so every dispatch stripped the just-appended transition - // link (door swings snapped; remote walk↔run links died) — see - // PhysicsBody.InWorld (register row). - if (!PhysicsObj.InWorld) - RemoveLinkAnimations?.Invoke(); - - return result; - } - - /// - /// CMotionInterp::StopInterpretedMotion (0x00528470, raw - /// 305635-305670, FULL BODY, R3-W5, closes J4's StopInterpretedMotion - /// leg). THE ONE StopInterpretedMotion — merges the former legacy - /// overload with the funnel's bare sink.StopMotion call. - /// - /// - /// Verbatim (raw 305638-305670): - /// - /// if (physics_obj == 0) return 8; - /// if (contact_allows_move(arg2) == 0 - /// || (standing_longjump != 0 - /// && (arg2 == WalkForward || arg2 == RunForward || arg2 == SideStepRight))) { - /// if (arg3->bitfield & 0x4000) InterpretedMotionState::RemoveMotion(arg2); - /// result = 0; - /// } else { - /// result = sink.StopMotion(arg2, arg3); // CPhysicsObj::StopInterpretedMotion stand-in - /// if (result == 0) { - /// add_to_queue(arg3->context_id, 0x41000003, result); // result == 0 here - /// if (arg3->bitfield & 0x4000) InterpretedMotionState::RemoveMotion(arg2); - /// } - /// } - /// if (physics_obj != 0 && physics_obj->cell == 0) RemoveLinkAnimations(physics_obj); - /// return result; - /// - /// - /// - /// - /// R3-W5 (post-fix): - /// returns a real success signal (see that interface member's doc for - /// why a void sink broke the 183-case live retail conformance - /// suite on the analogous DoInterpretedMotion path) — a FAILED - /// stop dispatch skips both the add_to_queue node and the - /// InterpretedMotionState::RemoveMotion state clear, matching - /// retail's if (result == 0) { ... } gate exactly. On success, - /// the queued node's JumpErrorCode is always 0 (the stop's - /// own "return value" that becomes the queued error code is - /// result, which is 0 on that path). - /// - /// - /// Retail arg2. - /// Retail arg3. - public WeenieError StopInterpretedMotion(uint motion, MovementParameters p) - => StopInterpretedMotion(motion, p, DefaultSink); - - /// - /// Sink-parameterized core shared by the public - /// and the - /// internal primitive — same - /// split rationale as - /// . - /// - private WeenieError StopInterpretedMotion(uint motion, MovementParameters p, IInterpretedMotionSink? sink) - { - if (PhysicsObj is null) - return WeenieError.NoPhysicsObject; - - WeenieError result; - - bool standingLongJumpStateOnly = StandingLongJump - && (motion == MotionCommand.WalkForward - || motion == MotionCommand.RunForward - || motion == MotionCommand.SideStepRight); - - if (!contact_allows_move(motion) || standingLongJumpStateOnly) - { - if (p.ModifyInterpretedState) - InterpretedState.RemoveMotion(motion); - result = WeenieError.None; - } - else - { - // sink.StopMotion stands in for CPhysicsObj::StopInterpretedMotion - // — its bool return IS retail's `result == 0`. A null sink (no - // dispatch backend wired) is treated as succeeding, matching - // DoInterpretedMotion's identical posture. - bool dispatchOk = sink?.StopMotion(motion) ?? true; - - if (!dispatchOk) - { - // See DoInterpretedMotion's identical ambiguity note: retail - // returns the sink's own nonzero code here (raw 305652-305653, - // no `else` — `result` keeps whatever CPhysicsObj::StopInterpretedMotion - // returned); ported as the closest local analog. - result = WeenieError.GeneralMovementFailure; - } - else - { - result = WeenieError.None; - - AddToQueue(p.ContextId, MotionCommand.Ready, (uint)result); - - if (p.ModifyInterpretedState) - InterpretedState.RemoveMotion(motion); - } - } - - // R4-V5 door-swing fix (2026-07-03): retail's guard is - // physics_obj->cell == 0 — DETACHED objects only (raw @305627). - // The old proxy (CellPosition.ObjCellId == 0, #145 machinery seeded - // only by the local player's SnapToCell) read every REMOTE body as - // detached, so every dispatch stripped the just-appended transition - // link (door swings snapped; remote walk↔run links died) — see - // PhysicsBody.InWorld (register row). - if (!PhysicsObj.InWorld) - RemoveLinkAnimations?.Invoke(); - - return result; - } - - /// - /// R3-W5 internal dispatch primitive (formerly the S2a funnel's public - /// surface), now used ONLY by 's - /// action-replay loop — retail's action dispatches (raw 305983) use the - /// UM apply's ctor-default var_2c (notably - /// ModifyInterpretedState = true, so replayed actions DO enter the - /// interpreted actions list via InterpretedMotionState::AddAction), - /// unlike the apply pass's rewritten params (see - /// ). R5-V4 (#164): the params' - /// Autonomous bit (0x1000) is spliced from the ACTION's autonomy - /// flag — retail raw 305982: - /// var_28 ^= ((action.autonomous << 0xc) ^ var_28) & 0x1000 - /// — so a replayed action enters the state lists - /// (AddAction(motion, speed, stamp, autonomous)) with its real - /// autonomy instead of the ctor-default false. - /// - private WeenieError DispatchInterpretedMotion( - uint motion, float speed, bool autonomous, IInterpretedMotionSink? sink) - => DoInterpretedMotion( - motion, new MovementParameters { Speed = speed, Autonomous = autonomous }, sink); } diff --git a/src/AcDream.Core/Physics/MotionSequenceGate.cs b/src/AcDream.Core/Physics/MotionSequenceGate.cs deleted file mode 100644 index 55461768..00000000 --- a/src/AcDream.Core/Physics/MotionSequenceGate.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; - -namespace AcDream.Core.Physics; - -/// -/// Per-entity staleness gate for inbound movement events (0xF74C), ported -/// from retail (L.2g S1, closes deviation DEV-6 for the UM path). -/// -/// Retail keeps a update_times[NUM_PHYSICS_TS] array of u16 -/// stamps on every CPhysicsObj (acclient.h:6084 — -/// PhysicsTimeStamp) and rejects out-of-order network events with a -/// wraparound-aware compare. Three of those stamps gate movement events: -/// -/// -/// INSTANCE_TS (8) — checked at dispatch -/// (ACSmartBox::DispatchSmartBoxEvent case 0xF74C, -/// acclient_2013_pseudo_c.txt:357214-357239): an event stamped with an -/// OLDER object incarnation than the one we know is dropped before any -/// other stamp is touched. Retail additionally QUEUES events for a NEWER -/// incarnation (SmartBox::QueueBlobForObject) until that object -/// version exists; acdream adopts-and-applies instead — register row -/// AD-32 records the divergence. -/// MOVEMENT_TS (1)CPhysics::SetObjectMovement -/// (0x00509690, acclient_2013_pseudo_c.txt:271370) applies an event only -/// when its movement sequence is STRICTLY newer than the stored stamp -/// (equal = duplicate delivery = drop), and stamps BEFORE evaluating the -/// server-control gate — a movement sequence is consumed even when the -/// event is subsequently dropped for stale server control. -/// SERVER_CONTROLLED_MOVE_TS (5) — same function: the event is -/// dropped when the STORED server-control stamp is strictly newer than -/// the incoming one (a newer server-control era has already been seen); -/// equal passes and re-stamps. -/// -/// -/// The compare itself is CPhysicsObj::is_newer (0x00451ad0); -/// the Binary Ninja pseudo-C mangles its setcc returns, so the port follows -/// ACE's verbatim PhysicsObj.is_newer (PhysicsObj.cs:2853-2859), -/// cross-checked against the decomp's branch structure. -/// -public sealed class MotionSequenceGate -{ - private ushort _instanceTs; // update_times[INSTANCE_TS = 8] - private ushort _movementTs; // update_times[MOVEMENT_TS = 1] - private ushort _serverControlTs; // update_times[SERVER_CONTROLLED_MOVE_TS = 5] - private bool _seeded; - - /// - /// CPhysicsObj::is_newer (0x00451ad0): true when - /// is newer than - /// under u16 wraparound — when the absolute difference exceeds 0x7fff - /// the numerically SMALLER value is the newer one (the counter wrapped). - /// - public static bool IsNewer(ushort oldStamp, ushort newStamp) - { - if (Math.Abs(newStamp - oldStamp) > short.MaxValue) - return newStamp < oldStamp; - return oldStamp < newStamp; - } - - /// - /// Seed the stamps from the entity's CreateObject PhysicsDesc timestamp - /// block. Retail initializes update_times wholesale from the 9 - /// u16s at the tail of PhysicsDesc (ACE - /// WorldObject_Networking.cs:411-420 writes them in - /// PhysicsTimeStamp enum order); without this, an entity whose movement - /// sequence is already past 0x8000 at spawn would have every subsequent - /// movement event judged stale against a zero stamp. - /// - /// The first Seed adopts the stamps wholesale (fresh object); - /// subsequent Seeds only move stamps FORWARD. This makes the #138 - /// rehydrate path (which replays a RETAINED CreateObject through the - /// spawn handler) a no-op instead of a stamp regression, while a genuine - /// wire re-create (server sequences only advance) still seeds correctly. - /// Retail has no equivalent replay path — its objects keep their stamps - /// for their whole lifetime — so advance-only is the faithful mapping. - /// - public void Seed(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq) - { - if (!_seeded) - { - _instanceTs = instanceSeq; - _movementTs = movementSeq; - _serverControlTs = serverControlSeq; - _seeded = true; - return; - } - - if (IsNewer(_instanceTs, instanceSeq)) _instanceTs = instanceSeq; - if (IsNewer(_movementTs, movementSeq)) _movementTs = movementSeq; - if (IsNewer(_serverControlTs, serverControlSeq)) _serverControlTs = serverControlSeq; - } - - /// - /// Apply the retail three-stamp gate to an inbound movement event. - /// Returns true when the event should be applied (stamps updated), - /// false when it must be dropped as stale/duplicate/superseded. - /// - public bool TryAcceptMovementEvent(ushort instanceSeq, ushort movementSeq, ushort serverControlSeq) - { - // Dispatch-level incarnation gate — runs before any other stamp is - // touched (retail drops at DispatchSmartBoxEvent, never reaching - // SetObjectMovement). - if (IsNewer(instanceSeq, _instanceTs)) - return false; - _instanceTs = instanceSeq; // adopt equal-or-newer (AD-32; retail queues newer) - - // Gate 1: movement sequence must be strictly newer. - if (!IsNewer(_movementTs, movementSeq)) - return false; - _movementTs = movementSeq; // stamped BEFORE the server-control gate, per retail - - // Gate 2: drop when a newer server-control era has already been seen. - if (IsNewer(serverControlSeq, _serverControlTs)) - return false; - _serverControlTs = serverControlSeq; - - return true; - } -} diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs index 897e90dc..67882f0b 100644 --- a/src/AcDream.Core/Physics/PhysicsBody.cs +++ b/src/AcDream.Core/Physics/PhysicsBody.cs @@ -91,90 +91,8 @@ public sealed class PhysicsBody // ── struct fields ────────────────────────────────────────────────────── // Offsets from acclient_function_map.md §PhysicsObj Struct Layout. - private Vector3 _position; - - /// World-space position (frame origin). #145: the setter mirrors its - /// delta into so the cell-relative frame stays exact - /// through integration + the resolve-apply (both write here). Placement uses - /// instead (no delta). - public Vector3 Position - { - get => _position; - set - { - Vector3 delta = value - _position; - _position = value; - SyncCellPositionDelta(delta); - } - } - - /// - /// Cell-relative position (retail Position): the (cell, local∈[0,192)) pair. - /// #145 Slice 2 — rides alongside , becomes authoritative - /// in later slices. Default (ObjCellId==0) until seeds it. - /// - public Position CellPosition { get; private set; } - - /// - /// R4-V5 (the door-swing snap fix, 2026-07-03): the retail - /// physics_obj->cell != 0 "placed in the world" truth (register - /// row). CMotionInterp's dispatch tails strip link animations for - /// DETACHED objects only (if (cell == 0) RemoveLinkAnimations, - /// raw @305627); the previous proxy — .ObjCellId - /// == 0 — was seeded ONLY by the local player's - /// (#145 machinery), so every REMOTE body read "detached" forever and - /// every dispatched transition link (door swings, walk↔run links) was - /// stripped the same tick it was appended. Set by - /// (local player placement) and by RemoteMotion construction (remotes - /// exist only for world entities). Default false = detached, matching - /// retail's pre-enter_world state. - /// - public bool InWorld { get; set; } - - /// - /// Placement: set the world position AND seed from the - /// wire's (cell, local) — NO streaming center, NO delta. For an OUTDOOR cell the - /// seed is canonicalized via (retail - /// SetPositionInternal/adjust_to_outside @0x00504A40): the cell index - /// is re-derived from the landblock-local position (low word = floor(local/24)) and - /// the origin wrapped into [0,192). This is the #107 protection — never trust a - /// server (cell, pos) pair without re-deriving the cell. Indoor EnvCell claims - /// (low word >= 0x100) are validated by the BSP/spawn-gate path — seeded verbatim. - /// Caller (Slice 2b) supplies the wire (cell, localX/Y/Z) from the inbound position - /// update / teleport arrival. stays authoritative for - /// the world frame; canonicalizing the (cell, local) decomposition leaves the world - /// point unchanged for an in-range local. - /// - public void SnapToCell(uint cellId, Vector3 worldPos, Vector3 cellLocal) - { - _position = worldPos; - uint cell = cellId; - Vector3 local = cellLocal; - if ((cellId & 0xFFFFu) is >= 1u and <= 0x40u) - LandDefs.AdjustToOutside(ref cell, ref local); - CellPosition = new Position(cell, new CellFrame(local, Orientation)); - InWorld = true; // retail: enter_world / set_cell assigns physics_obj->cell - } - - // Mirror a world-position translation into the cell-relative frame. Velocity is - // frame-invariant under translation, so the same delta applies to the local origin. - // AdjustToOutside then recomputes the cell index from the local (intra-landblock 24 m - // cell crossings) AND wraps + bumps the landblock on a 192 m crossing — the outdoor - // membership + canonicalization in one call (get_outside_lcoord + lcoord_to_gid + - // [0,192) wrap). Idempotent within a cell. Runs only for a SEEDED OUTDOOR cell; - // unseeded bodies (ObjCellId==0, e.g. remote entities) and indoor cells are skipped. - private void SyncCellPositionDelta(Vector3 delta) - { - uint cell = CellPosition.ObjCellId; - if ((cell & 0xFFFFu) is not (>= 1u and <= 0x40u)) - return; - Vector3 local = CellPosition.Frame.Origin + delta; - uint adjusted = cell; - if (LandDefs.AdjustToOutside(ref adjusted, ref local)) - CellPosition = new Position(adjusted, new CellFrame(local, CellPosition.Frame.Orientation)); - // else: map edge (AdjustToOutside failed) — leave CellPosition unchanged. - // Slice 3 owns proper map-edge membership; this slice is behaviour-neutral. - } + /// World-space position (no offset in struct — frame origin). + public Vector3 Position { get; set; } /// Orientation quaternion (struct offsets 0x60–0x80 column matrix). public Quaternion Orientation { get; set; } = Quaternion.Identity; @@ -248,38 +166,6 @@ public sealed class PhysicsBody /// Last simulation time used to compute dt (+0xD8). public double LastUpdateTime { get; set; } - /// - /// R3-W3 stub for retail CPhysicsObj::IsFullyConstrained - /// (0x0050f730), read by CMotionInterp::jump_is_allowed (raw - /// 305524-305525: if (IsFullyConstrained(physics_obj) != 0) return - /// 0x47;). Retail's body walks per-cell contact-plane constraints - /// (a mover pinned between opposing walkable surfaces / doorway - /// jamming); acdream has no equivalent constraint-tracking yet. - /// Register row: stubbed false (never fires) — a real port needs the - /// per-cell shadow-list contact accounting the physics digest tracks. - /// See docs/architecture/retail-divergence-register.md (added same - /// commit as this field). - /// - public bool IsFullyConstrained { get; set; } - - /// - /// R3-W4 — retail CPhysicsObj::last_move_was_autonomous, read by - /// CPhysicsObj::movement_is_autonomous (0x0050eb30, decomp §7a - /// @276443: return this->last_move_was_autonomous;). Gates the - /// A3 dual-dispatch predicate in MotionInterpreter.apply_current_movement/ - /// ReportExhaustion/SetWeenieObject/SetPhysicsObject: - /// true means the last motion on this body was locally-simulated - /// (player input / local prediction), false means it was a - /// server-driven dead-reckoning update. Set true at the local-player - /// input chokepoint (App layer — PlayerMovementController); - /// left false (the safe default — routes to - /// apply_interpreted_movement) for DR-applied remote updates. - /// also sets this true - /// itself: retail's set_local_velocity(&var_c, 1) call passes - /// the autonomous flag literal 1 (raw @305763-305765). - /// - public bool LastMoveWasAutonomous { get; set; } - // ── convenience helpers ──────────────────────────────────────────────── public bool HasGravity => State.HasFlag(PhysicsStateFlags.Gravity); @@ -359,23 +245,10 @@ public sealed class PhysicsBody /// worldY = col0.y*localX + col1.y*localY + col2.y*localZ /// worldZ = col0.z*localX + col1.z*localY + col2.z*localZ /// We replicate this as a Quaternion rotation, which is equivalent. - /// - /// - /// R3-W4: retail's set_local_velocity takes a second - /// autonomous arg (CPhysicsObj::set_local_velocity, - /// stores it to — read by - /// CPhysicsObj::movement_is_autonomous, the A3 dual-dispatch - /// predicate). Defaults to false to preserve every pre-W4 call - /// site's behavior (server/interpreted-driven callers never asserted - /// autonomy); is the one - /// caller that passes true (raw @305763-305765, - /// set_local_velocity(&var_c, 1)). - /// /// - public void set_local_velocity(Vector3 localVelocity, bool autonomous = false) + public void set_local_velocity(Vector3 localVelocity) { var worldVelocity = Vector3.Transform(localVelocity, Orientation); - LastMoveWasAutonomous = autonomous; set_velocity(worldVelocity); } diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs index f90808fa..deec7ed3 100644 --- a/src/AcDream.Core/Physics/PhysicsDataCache.cs +++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs @@ -26,16 +26,8 @@ public sealed class PhysicsDataCache private readonly ConcurrentDictionary _buildings = new(); /// - /// The unified cell graph (UCG): the active id->cell resolver and registry. - /// Populated unconditionally in — BEFORE the - /// idempotency + null-BSP guards, so BSP-less cells are registered too — and - /// consumed across the engine: the player render/lighting root - /// (CellGraph.CurrCell, written at the player chokepoint - /// PhysicsEngine.UpdatePlayerCurrCell and read by the renderer), the - /// universal id->cell lookup (GetVisible), the 3rd-person camera cell - /// (FindVisibleChildCell), and the block-local terrain origin - /// (TryGetTerrainOrigin, read by CellTransit's pick + transit - /// paths). No longer inert. + /// UCG Stage 1: the unified cell graph, built alongside the legacy cell caches. + /// Consumed by nobody this stage (zero behavior change). /// public UcgCellGraph CellGraph { get; } = new(); @@ -384,6 +376,24 @@ public sealed class PhysicsDataCache public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null; + /// + /// Issue #101 (2026-05-25): retail-faithful phantom check for + /// GfxObj-only entity sources. Returns true when the entity's + /// SourceGfxObjOrSetupId is a GfxObj (high byte + /// 0x01) AND has no cached — + /// meaning the underlying GfxObj had HasPhysics=False or + /// a null PhysicsBSP.Root, so + /// short-circuited at the early-return on line 45/46. Retail's + /// CPartArray::InitParts emits NO collision shapes for + /// these — acdream's mesh-aabb-fallback synthesis at + /// GameWindow.cs:6127 must do the same. + /// + public bool IsPhantomGfxObjSource(uint sourceId) + { + if ((sourceId & 0xFF000000u) != 0x01000000u) return false; + return GetGfxObj(sourceId)?.BSP?.Root is null; + } + public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null; public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null; public int GfxObjCount => _gfxObj.Count; @@ -437,46 +447,6 @@ public sealed class PhysicsDataCache }; } - /// - /// #146 (2026-06-24): drop every cached building belonging to a landblock so - /// the next pass re-bases their STREAMING-RELATIVE - /// WorldTransform against the CURRENT _liveCenter. Without this, - /// CacheBuilding's per-cell first-wins guard LOCKS the transform at whatever - /// frame it was first cached with; a teleport recenter then leaves the shell - /// BSP at a stale world offset (~the source↔dest landblock distance), so the - /// foot-sphere walks through where the wall visually is and collision is lost - /// (login at Arwic → portal to Holtburg → house walls clip; bldOrigin probed - /// ~5.5 km off). Terrain avoids this because AddLandblock overwrites its - /// WorldOffset every apply; buildings get the equivalent per-apply re-base via - /// the caller clearing-then-repopulating (which keeps first-wins within each - /// fresh pass — retail CSortCell::add_building semantics preserved). - /// - public void RemoveBuildingsForLandblock(uint landblockId) - { - uint prefix = landblockId & 0xFFFF0000u; - foreach (var key in _buildings.Keys) - if ((key & 0xFFFF0000u) == prefix) - _buildings.TryRemove(key, out _); - } - - /// - /// D8 (2026-06-24): drop every cached cell struct belonging to a landblock so - /// the next re-bases its STREAMING-RELATIVE - /// WorldTransform against the current _liveCenter — symmetric with - /// (#146). Without this, the - /// _cellStruct first-wins guard LOCKS a dungeon cell's transform at its - /// first streaming frame; a teleport recenter then leaves the cell BSP at a stale - /// world offset (~the source↔dest landblock distance), so foot-sphere collision - /// queries walk through where the wall geometry is. - /// - public void RemoveCellsForLandblock(uint landblockId) - { - uint prefix = landblockId & 0xFFFF0000u; - foreach (var key in _cellStruct.Keys) - if ((key & 0xFFFF0000u) == prefix) - _cellStruct.TryRemove(key, out _); - } - public BuildingPhysics? GetBuilding(uint landcellId) => _buildings.TryGetValue(landcellId, out var b) ? b : null; diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index c8a1ba3f..06a3180c 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -80,20 +80,6 @@ public static class PhysicsDiagnostics public static bool ProbeCellSetEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELLSET") == "1"; - /// - /// R5-V3 #171 residuals (2026-07-04) — sticky-melee timeline probe. - /// One [sticky] line per StickyManager lifecycle event (STICK / - /// UNSTICK / LEASE-EXPIRE / TARGET-status teardown) and per armed - /// AdjustOffset tick (guid, signed gap distance, applied delta, - /// heading delta), plus [sticky-snap-skip] lines at the NPC - /// UpdatePosition handler when a server hard-snap is suppressed because - /// the entity is stuck. Heavy while a pack is stuck (~60 Hz × stuck - /// count); capture-session only. All lines carry the guid - /// (feedback_probe_identity_attribution). - /// - public static bool ProbeStickyEnabled { get; set; } - = Environment.GetEnvironmentVariable("ACDREAM_PROBE_STICKY") == "1"; - public static void LogCellSetBuild( uint seedCellId, System.Numerics.Vector3 sphereCenter, @@ -437,31 +423,6 @@ public static class PhysicsDiagnostics public static bool ProbeSweptEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_SWEPT") == "1"; - /// - /// Teleport-foundation timing probe (2026-06-22 — REMOVABLE diagnostic). - /// Emits one [tp-probe] line per teleport-pipeline event with a - /// cross-thread monotonic timestamp () - /// so the offline reader can order AIM / ENQ / BUILD / APPLY / PLACED across - /// the render thread and the streamer worker. Disambiguates the three - /// candidate roots for "destination not resident fast": apply-THROTTLE - /// (APPLY lands before PLACED), _datLock CONTENTION (BUILD waited= - /// large), and a streaming-command GATE (ENQ never fires for the dest). - /// Initial state from ACDREAM_PROBE_TELEPORT=1. Strip after capture. - /// - public static bool ProbeTeleportEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_TELEPORT") == "1"; - - /// - /// One [tp-probe] line. Self-guards on , - /// so callers need not pre-check (the cost when off is a single bool read). - /// - public static void LogTeleport(string point, uint id, string extra = "") - { - if (!ProbeTeleportEnabled) return; - Console.WriteLine(System.FormattableString.Invariant( - $"[tp-probe] {point,-6} id=0x{id:X8} t={Environment.TickCount64} {extra}")); - } - /// /// A6.P3 issue #98 step-walk investigation (2026-05-23). When true, /// emits one [step-walk] line at selected points in the transition @@ -576,7 +537,6 @@ public static class PhysicsDiagnostics ProbeCellEnabled = false; ProbeBuildingEnabled = false; ProbeCellSetEnabled = false; - ProbeStickyEnabled = false; ProbeAutoWalkEnabled = false; ProbeUseabilityFallbackEnabled= false; DumpSteepRoofEnabled = false; @@ -589,7 +549,6 @@ public static class PhysicsDiagnostics ProbePlacementFailEnabled = false; ProbeSweptEnabled = false; ProbeStepWalkEnabled = false; - ProbeTeleportEnabled = false; // Side-channel fields LastBspHitPoly = null; diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index da52716b..80a76cf8 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -31,48 +31,6 @@ public sealed class PhysicsEngine /// Number of registered landblocks (diagnostic). public int LandblockCount => _landblocks.Count; - /// - /// True once the landblock covering has had its - /// terrain + cells registered via . Accepts a canonical - /// (0xFFFF) id, a cell-resolved id, or a bare landblock id — compares on the high 16 - /// bits. This is the teleport "worldReady" gate (the destination is grounded). - /// - public bool IsLandblockTerrainResident(uint cellOrLandblockId) - { - uint prefix = cellOrLandblockId & 0xFFFF0000u; - foreach (var key in _landblocks.Keys) - if ((key & 0xFFFF0000u) == prefix) return true; - return false; - } - - /// - /// True once EVERY in-bounds landblock within Chebyshev of the - /// landblock covering has had its terrain registered. - /// This is the teleport "surroundings are loaded" gate: holding the fade until the player's - /// own landblock AND its immediate neighbours are resident means they arrive standing on a - /// loaded, collidable world (their cell-walk can root into neighbour cells) instead of a - /// single landblock floating in the void. Off-map neighbours (coords outside 0..254) are - /// skipped — they never load, so requiring them would hang the hold until the wall-clock - /// timeout. radius 0 is equivalent to . - /// - public bool IsNeighborhoodTerrainResident(uint cellOrLandblockId, int radius) - { - var resident = new HashSet(); - foreach (var key in _landblocks.Keys) resident.Add(key & 0xFFFF0000u); - - int cx = (int)((cellOrLandblockId >> 24) & 0xFFu); - int cy = (int)((cellOrLandblockId >> 16) & 0xFFu); - for (int dx = -radius; dx <= radius; dx++) - for (int dy = -radius; dy <= radius; dy++) - { - int nx = cx + dx, ny = cy + dy; - if (nx < 0 || nx > 254 || ny < 0 || ny > 254) continue; // off-map: skip - uint prefix = ((uint)nx << 24) | ((uint)ny << 16); - if (!resident.Contains(prefix)) return false; - } - return true; - } - /// /// Cell-based spatial index for static object collision. /// Populated during landblock streaming; queried by the Transition system. @@ -122,20 +80,6 @@ public sealed class PhysicsEngine { _landblocks.Remove(landblockId); ShadowObjects.RemoveLandblock(landblockId); - DataCache?.RemoveCellsForLandblock(landblockId); // D8: rebase cell BSP transforms on next apply - - // #145: if the player's current cell belonged to the landblock being removed (a teleport - // drops the stale source center via OnLivePositionUpdated), clear it. Otherwise CurrCell - // dangles on an orphaned cell and the dungeon-streaming gate — keyed on CurrCell — keeps - // streaming collapsed onto the gone landblock, so the destination never streams in and - // only the skybox renders. Clearing it lets the gate read "not in a dungeon" → the - // controller ExitDungeonExpands to the destination, and CurrCell re-acquires once the - // destination landblock hydrates and the per-frame resolve roots into it. - if (DataCache?.CellGraph is { } cg && cg.CurrCell is { } cur - && (cur.Id & 0xFFFF0000u) == (landblockId & 0xFFFF0000u)) - { - cg.CurrCell = null; - } // UCG Stage 1: mirror removal into the unified graph (inert this stage). DataCache?.CellGraph.RemoveLandblock(landblockId); @@ -694,23 +638,9 @@ public sealed class PhysicsEngine { Console.WriteLine(System.FormattableString.Invariant( $"[snap] claim=0x{cellId:X8} pos=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) VALIDATED -> grounded to its walkable floor z={claimFloorZ.Value:F3}")); - // #133 (2026-06-13): return the VALIDATED claim's OWN full cell id, - // NOT lbPrefix | (cellId & 0xFFFF). lbPrefix is found by scanning - // resident landblocks for one whose [0,192) local bounds contain - // the candidate XY — but a dungeon EnvCell's local Y can be NEGATIVE - // (server teleport to 0x00070143 at local (70,-60,0.01)). The dungeon - // landblock fails the localY>=0 bounds test, so the loop matches a - // neighbouring still-resident block (e.g. Holtburg 0xA9B3), re-stamping - // the validated claim 0x00070143 -> 0xA9B30143. The client then - // mis-resolves the player into the wrong landblock and spams ACE with - // rejected moves. The validated claim's prefix is AUTHORITATIVE; a - // position falling in a neighbouring resident landblock must not - // re-stamp it. Byte-identical for the login case (the position lies in - // the claim's own landblock, so lbPrefix == cellId & 0xFFFF0000); - // diverges only — and correctly — in the far-teleport dungeon case. return new ResolveResult( new Vector3(candidatePos.X, candidatePos.Y, claimFloorZ.Value), - cellId, + lbPrefix | (cellId & 0xFFFFu), IsOnGround: true); } } @@ -1017,22 +947,6 @@ public sealed class PhysicsEngine transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight); - // #145: supply the carried cell-relative frame anchor to the outdoor - // membership pick. body.Position - body.CellPosition.Frame.Origin is the TRUE - // landblock world origin, correct even for an UNSTREAMED neighbour — replacing - // the terrain-registry origin that returns (0,0) and marches the cell id one - // landblock per tick (the #145 far-town cascade). Engaged only for a SEEDED - // OUTDOOR body whose carried landblock matches the resolve cell (the controller - // passes body.CellPosition.ObjCellId for the outdoor case, so they agree); - // null otherwise → legacy TryGetTerrainOrigin for NPCs/tests/indoor. - transition.SpherePath.CarriedBlockOrigin = - body is not null - && (cellId & 0xFFFFu) is >= 1u and <= 0x40u // resolve cell is an outdoor landcell - && (body.CellPosition.ObjCellId & 0xFFFFu) is >= 1u and <= 0x40u // carried cell is outdoor (seeded) - && (cellId >> 16) == (body.CellPosition.ObjCellId >> 16) // same landblock → anchor consistent - ? body.Position - body.CellPosition.Frame.Origin - : null; - if (isOnGround && body is not null && body.WalkablePolygonValid && body.WalkableVertices is { Length: >= 3 }) @@ -1085,27 +999,16 @@ public sealed class PhysicsEngine body.WalkableVertices = null; } - // Retail persists sliding state to the body ONLY on transition - // SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at - // 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's - // final sliding_normal_valid at 0x005154e1 — and SetPositionInternal - // is unreachable when find_valid_position fails (the transition is - // discarded whole; the body keeps its prior state). #137 mechanism - // 2: an unconditional writeback here could persist a normal retail - // would discard. - if (ok) + if (ci.SlidingNormalValid + && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) { - if (ci.SlidingNormalValid - && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) - { - body.SlidingNormal = ci.SlidingNormal; - body.TransientState |= TransientStateFlags.Sliding; - } - else - { - body.SlidingNormal = Vector3.Zero; - body.TransientState &= ~TransientStateFlags.Sliding; - } + body.SlidingNormal = ci.SlidingNormal; + body.TransientState |= TransientStateFlags.Sliding; + } + else + { + body.SlidingNormal = Vector3.Zero; + body.TransientState &= ~TransientStateFlags.Sliding; } // L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity. diff --git a/src/AcDream.Core/Physics/PlayerWeenie.cs b/src/AcDream.Core/Physics/PlayerWeenie.cs index 3d1ab62d..014c96a8 100644 --- a/src/AcDream.Core/Physics/PlayerWeenie.cs +++ b/src/AcDream.Core/Physics/PlayerWeenie.cs @@ -43,24 +43,6 @@ public sealed class PlayerWeenie : IWeenieObject public bool CanJump(float extent) => true; // burden/stamina checks deferred - /// - /// R3-W3 (W0-pins.md A3): the local player's weenie is THE player. - /// Feeds W4's apply_current_movement/ReportExhaustion - /// dual-dispatch gate — no consumer yet in W3. - /// - public bool IsThePlayer() => true; - - /// - /// TS-5 (extended): stamina cost gating deferred pending stat plumbing — - /// always affordable, cost 0. Matches 's existing - /// always-true stance. - /// - public bool JumpStaminaCost(float extent, out int cost) - { - cost = 0; - return true; - } - /// /// RunRate = (burdenMod * (runSkill / (runSkill + 200)) * 11 + 4) / 4 /// Capped at 4.5 when runSkill >= 800. diff --git a/src/AcDream.Core/Physics/Position.cs b/src/AcDream.Core/Physics/Position.cs deleted file mode 100644 index 8c9b045a..00000000 --- a/src/AcDream.Core/Physics/Position.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Numerics; - -namespace AcDream.Core.Physics; - -/// -/// Retail Frame (acclient.h:30647) — named CellFrame here to avoid -/// colliding with DatReaderWriter.Types.Frame (the dat placement/animation -/// frame, used in physics-adjacent code such as ShadowShapeBuilder). -/// is LOCAL to the owning cell: outdoor → X/Y ∈ [0,192) within -/// the landblock, Z = height; indoor → the EnvCell-relative placement. -/// replaces retail's m_fl2gv 3×3 local→global -/// matrix (equivalent rotation). -/// -public readonly record struct CellFrame(Vector3 Origin, Quaternion Orientation); - -/// -/// Retail Position (acclient.h:30658): the (which-cell, where-inside) pair. -/// One type for indoor and outdoor. The full 32-bit cell id (high 16 = landblock -/// prefix, low 16 = cell index) plus the cell-local . Neither -/// half is ever reconstructed from a streaming center — see #145 design spec. The -/// Frame member keeps retail's name. -/// -public readonly record struct Position(uint ObjCellId, CellFrame Frame) -{ - public Position(uint objCellId, Vector3 origin, Quaternion orientation) - : this(objCellId, new CellFrame(origin, orientation)) { } -} diff --git a/src/AcDream.Core/Physics/RemoteMotionCombiner.cs b/src/AcDream.Core/Physics/PositionManager.cs similarity index 91% rename from src/AcDream.Core/Physics/RemoteMotionCombiner.cs rename to src/AcDream.Core/Physics/PositionManager.cs index eaf86fe1..e3ff2aec 100644 --- a/src/AcDream.Core/Physics/RemoteMotionCombiner.cs +++ b/src/AcDream.Core/Physics/PositionManager.cs @@ -17,16 +17,8 @@ namespace AcDream.Core.Physics; /// active locomotion cycle). We rotate that by the body's orientation /// to get a world-space delta, then add the InterpolationManager's /// world-space correction. -/// -/// Renamed R5 (was PositionManager): this class is only the -/// InterpolationManager-composition portion of retail's -/// PositionManager::adjust_offset — NOT the retail PositionManager -/// facade. The faithful facade (Sticky/Constraint, owned per entity) is -/// . The name was freed to remove the -/// ambiguity that broke every file importing both -/// AcDream.Core.Physics and AcDream.Core.Physics.Motion. /// -public sealed class RemoteMotionCombiner +public sealed class PositionManager { /// /// Compute the per-frame world-space delta to add to body.Position. diff --git a/src/AcDream.Core/Physics/RawMotionState.cs b/src/AcDream.Core/Physics/RawMotionState.cs deleted file mode 100644 index 315ee1ff..00000000 --- a/src/AcDream.Core/Physics/RawMotionState.cs +++ /dev/null @@ -1,322 +0,0 @@ -using AcDream.Core.Physics.Motion; - -namespace AcDream.Core.Physics; - -/// -/// Retail hold-key enum (acclient.h enum HoldKey, line 3394). -/// -public enum HoldKey : uint -{ - Invalid = 0x0, - None = 0x1, - Run = 0x2, -} - -/// -/// One entry in — a discrete motion -/// event (jump, attack, etc.) layered on top of the continuous -/// forward/sidestep/turn axes. -/// -/// -/// Retail's in-memory action node (RawMotionState::AddAction, -/// 0x0051e840, and InterpretedMotionState::AddAction, 0x0051e9e0 — -/// both a 0x14-byte LListData-derived node) carries FOUR fields: -/// motion (+4), speed (+8), action_stamp (+0xc), -/// autonomous (+0x10). is the in-memory-only -/// field the R3-W1 action FIFO needs for ApplyMotion's velocity -/// bookkeeping; it is NOT part of the wire encoding. -/// -/// -/// -/// Retail packs each action ON THE WIRE as u16 command then -/// u16 ((stamp & 0x7FFF) | (autonomous ? 0x8000 : 0)) -/// (RawMotionState::Pack, 0x0051ed10, decomp lines ~293998-294010) -/// — // are -/// exactly the packed triple; reads only -/// those three. -/// -/// -public readonly record struct RawMotionAction( - ushort Command, - ushort Stamp, - bool Autonomous, - float Speed = 1f); - -/// -/// R3-W1 — verbatim, full-field port of retail's RawMotionState -/// (acclient.h RawMotionState::PackBitfield, line 46474; ctor -/// RawMotionState::RawMotionState, 0x0051e7f0, decomp lines -/// 293344-293361; packer at RawMotionState::Pack, 0x0051ed10). -/// -/// -/// R3-W1 fold (closes J2): this type now ALSO serves as -/// MotionInterpreter.RawState — the former LegacyRawMotionState -/// (a flat 7-field struct with no action FIFO) is deleted. One raw-state -/// type end to end: the physics-side motion interpreter mutates it via -/// // -/// /, and the SAME -/// instance's is read by -/// when building the outbound wire packet. Previously the packer's action -/// list was always empty (register TS-24) because nothing populated it — -/// starting R3-W2 (the add_to_queue/MotionDone port), -/// CMotionInterp populates this list locally exactly like retail -/// does, so the packed bytes will start reflecting real queued actions. -/// W1 itself does not change what gets packed (no caller invokes -/// yet) — only adds the capability + tests. -/// -/// -/// -/// PURE DATA: no PacketWriter / GL / Net dependency. Lives in -/// AcDream.Core.Physics so the motion interpreter, which is -/// also Core.Physics, can populate it without taking a Core.Net dependency -/// (Code Structure Rule #2). -/// -/// -public sealed class RawMotionState -{ - /// Retail current_holdkey (ctor default HoldKey_None). - public HoldKey CurrentHoldKey { get; set; } = HoldKey.None; - /// Retail current_style (ctor default 0x8000003D, NonCombat). - public uint CurrentStyle { get; set; } = 0x8000003Du; - /// Retail forward_command (ctor default 0x41000003, Ready). - public uint ForwardCommand { get; set; } = 0x41000003u; - /// Retail forward_holdkey (ctor default HoldKey_Invalid). - public HoldKey ForwardHoldKey { get; set; } = HoldKey.Invalid; - /// Retail forward_speed (ctor default 1.0). - public float ForwardSpeed { get; set; } = 1.0f; - /// Retail sidestep_command (ctor default 0). - public uint SidestepCommand { get; set; } - /// Retail sidestep_holdkey (ctor default HoldKey_Invalid). - public HoldKey SidestepHoldKey { get; set; } = HoldKey.Invalid; - /// Retail sidestep_speed (ctor default 1.0). - public float SidestepSpeed { get; set; } = 1.0f; - /// Retail turn_command (ctor default 0). - public uint TurnCommand { get; set; } - /// Retail turn_holdkey (ctor default HoldKey_Invalid). - public HoldKey TurnHoldKey { get; set; } = HoldKey.Invalid; - /// Retail turn_speed (ctor default 1.0). - public float TurnSpeed { get; set; } = 1.0f; - - private readonly List _actions = new(); - - /// - /// Discrete action FIFO (retail actions, an LListData - /// tail-append queue). Retail packs num_actions (count, not the - /// values) into bits 11-15 of the flags dword, then emits each action's - /// command+stamp pair unconditionally after the continuous-axis fields - /// (). - /// - /// - /// Settable via object-initializer for test fixtures / the wire-packer - /// call sites that build a one-shot snapshot; assigning replaces the - /// whole FIFO. / are - /// the retail-faithful mutators for live FIFO discipline. - /// - /// - public IReadOnlyList Actions - { - get => _actions; - set - { - _actions.Clear(); - _actions.AddRange(value); - } - } - - /// - /// Retail defaults — a field bit is set in the packed flags dword ONLY - /// when the live value differs from these (RawMotionState::Pack, - /// 0x0051ed10). NEVER mutate this shared instance. - /// - public static readonly RawMotionState Default = new(); - - /// - /// RawMotionState::AddAction (0x0051e840, decomp 293365-293392): - /// unconditional tail-append of - /// {motion, speed, action_stamp, autonomous}. - /// - /// Retail arg2 — the action motion id. - /// Retail arg3. - /// Retail arg4. - /// Retail arg5 (nonzero = autonomous). - public void AddAction(uint motion, float speed, uint actionStamp, bool autonomous) - { - _actions.Add(new RawMotionAction( - Command: (ushort)motion, - Stamp: (ushort)actionStamp, - Autonomous: autonomous, - Speed: speed)); - } - - /// - /// RawMotionState::RemoveAction (0x0051e8a0, decomp 293396-293414): - /// pop the FIFO head unconditionally, returning its motion field - /// (0 when empty — retail returns *(head+4), i.e. the stored - /// arg2/Command, widened; the C# port returns the widened - /// as a uint). - /// - public uint RemoveAction() - { - if (_actions.Count == 0) - return 0; - var head = _actions[0]; - _actions.RemoveAt(0); - return head.Command; - } - - /// - /// RawMotionState::ApplyMotion (0x0051eb60, decomp 293630-293703). - /// Verbatim dispatch, quoted from the raw named decomp: - /// - /// if ((arg2 - 0x6500000d) > 3) { // outside [0x6500000d, 0x65000010] - /// if ((arg2 & 0x40000000) == 0) { // NOT forward-class - /// if (arg2 >= 0) { // NOT style-class (high bit clear) - /// if ((arg2 & 0x10000000) != 0) - /// AddAction(arg2, params.speed, params.action_stamp, (params.bitfield>>0xc)&1); - /// } else if (current_style != arg2) { - /// forward_command = 0x41000003; current_style = arg2; - /// } - /// } else if (arg2 != 0x44000007) { // forward-class, NOT RunForward - /// forward_command = arg2; - /// if (params.bitfield byte1 & 8 != 0) { forward_holdkey = Invalid; forward_speed = params.speed; } - /// else { forward_holdkey = params.hold_key_to_apply; forward_speed = params.speed; } - /// } - /// // arg2 == 0x44000007 (RunForward) with the forward-class bit set: falls through, no write. - /// return; - /// } - /// switch (arg2) { - /// case 0x6500000d: case 0x6500000e: // TurnRight/TurnLeft - /// turn_command = arg2; - /// if (SetHoldKey bit) { turn_holdkey = Invalid; turn_speed = params.speed; } - /// else { turn_holdkey = params.hold_key_to_apply; turn_speed = params.speed; } - /// return; - /// case 0x6500000f: case 0x65000010: // SideStepRight/SideStepLeft - /// sidestep_command = arg2; - /// if (SetHoldKey bit) { sidestep_holdkey = Invalid; sidestep_speed = params.speed; } - /// else { sidestep_holdkey = params.hold_key_to_apply; sidestep_speed = params.speed; } - /// return; - /// } - /// - /// The arg2 == 0x44000007 (RunForward) no-write fallthrough on the - /// forward-class branch is a genuine retail quirk (RunForward bit - /// 0x40000000 IS set, but the `else if (arg2 != 0x44000007)` guard - /// excludes it) — ported verbatim, not "fixed". - /// - /// Retail arg2 — the ORIGINAL (pre-adjustment) - /// motion id per the DoMotion/StopMotion mirror-discipline callers. - /// Retail arg3 (MovementParameters const*). - public void ApplyMotion(uint motion, MovementParameters p) - { - if (motion - 0x6500000du > 3u) - { - if ((motion & 0x40000000u) == 0) - { - if (motion < 0x80000000u) // arg2 >= 0 as signed int32 - { - if ((motion & 0x10000000u) != 0) - AddAction(motion, p.Speed, p.ActionStamp, p.Autonomous); - } - else if (CurrentStyle != motion) - { - ForwardCommand = 0x41000003u; - CurrentStyle = motion; - } - } - else if (motion != 0x44000007u) - { - ForwardCommand = motion; - if (p.SetHoldKey) - { - ForwardHoldKey = HoldKey.Invalid; - ForwardSpeed = p.Speed; - } - else - { - ForwardHoldKey = p.HoldKeyToApply; - ForwardSpeed = p.Speed; - } - } - return; - } - - switch (motion) - { - case 0x6500000du: // TurnRight - case 0x6500000eu: // TurnLeft - TurnCommand = motion; - if (p.SetHoldKey) - { - TurnHoldKey = HoldKey.Invalid; - TurnSpeed = p.Speed; - } - else - { - TurnHoldKey = p.HoldKeyToApply; - TurnSpeed = p.Speed; - } - return; - case 0x6500000fu: // SideStepRight - case 0x65000010u: // SideStepLeft - SidestepCommand = motion; - if (p.SetHoldKey) - { - SidestepHoldKey = HoldKey.Invalid; - SidestepSpeed = p.Speed; - } - else - { - SidestepHoldKey = p.HoldKeyToApply; - SidestepSpeed = p.Speed; - } - return; - } - } - - /// - /// RawMotionState::RemoveMotion (0x0051e6e0, decomp 293252-293288): - /// - /// if ((arg2 - 0x6500000d) > 3) { - /// if ((arg2 & 0x40000000) == 0) { - /// if (arg2 < 0 && arg2 == current_style) current_style = 0x8000003d; - /// } else if (arg2 == forward_command) { - /// forward_command = 0x41000003; forward_speed = 1f; - /// } - /// return; - /// } - /// switch (arg2) { - /// case 0x6500000d: case 0x6500000e: turn_command = 0; return; - /// case 0x6500000f: case 0x65000010: sidestep_command = 0; return; - /// } - /// - /// - /// Retail arg2 — the ORIGINAL motion id. - public void RemoveMotion(uint motion) - { - if (motion - 0x6500000du > 3u) - { - if ((motion & 0x40000000u) == 0) - { - if (motion >= 0x80000000u && motion == CurrentStyle) - CurrentStyle = 0x8000003du; - } - else if (motion == ForwardCommand) - { - ForwardCommand = 0x41000003u; - ForwardSpeed = 1f; - } - return; - } - - switch (motion) - { - case 0x6500000du: - case 0x6500000eu: - TurnCommand = 0; - return; - case 0x6500000fu: - case 0x65000010u: - SidestepCommand = 0; - return; - } - } -} diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs new file mode 100644 index 00000000..bc1e6d51 --- /dev/null +++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs @@ -0,0 +1,340 @@ +using System; +using System.Numerics; + +namespace AcDream.Core.Physics; + +/// +/// Per-tick steering for server-controlled remote creatures while a +/// MoveToObject (movementType 6) or MoveToPosition (movementType 7) packet +/// is the active locomotion source. +/// +/// +/// Replaces the 882a07c-era "hold body Velocity at zero during MoveTo" +/// stabilizer. With the full MoveTo path payload now captured on +/// , +/// the body solver has the destination + heading + thresholds it needs to +/// run the retail per-tick loop instead of waiting for sparse +/// UpdatePosition snap corrections. +/// +/// +/// +/// Retail references: +/// +/// +/// MoveToManager::HandleMoveToPosition (0x00529d80) — the +/// per-tick driver. Computes heading-to-target, fires an aux +/// TurnLeft/TurnRight command when |delta| > 20°, snaps +/// orientation when within tolerance, and tests arrival via +/// dist <= min_distance (chase) or +/// dist >= distance_to_object (flee). +/// +/// +/// MoveToManager::_DoMotion / _StopMotion route turn +/// commands through CMotionInterp::DoInterpretedMotion — i.e. +/// MoveToManager itself does NOT touch the body. The body's actual +/// velocity comes from CMotionInterp::apply_current_movement +/// reading InterpretedState.ForwardCommand = RunForward and +/// emitting velocity.Y = RunAnimSpeed × speedMod, transformed by +/// the body's orientation. +/// +/// +/// +/// +/// +/// Acdream port scope: minimum viable subset. We skip target re-tracking +/// (server re-emits MoveTo every ~1 s with refreshed Origin), sticky/ +/// StickTo, fail-distance progress detector, and the sphere-cylinder +/// distance variant — all server-side concerns the local body doesn't need +/// to model. We DO port heading-to-target, the ±20° aux-turn tolerance +/// (with ACE's set_heading(true) snap-on-aligned fudge), and +/// arrival detection via min_distance. +/// +/// +/// +/// ACE divergence: ACE swaps the chase/flee arrival predicates +/// (dist <= DistanceToObject vs retail's dist <= MinDistance). +/// We follow retail. +/// +/// +public static class RemoteMoveToDriver +{ + /// + /// Heading tolerance below which we snap orientation directly to the + /// target heading (ACE's set_heading(target, true) + /// server-tic-rate fudge). Above tolerance we rotate at + /// . Retail value (line 307251 of + /// acclient_2013_pseudo_c.txt) is 20°. + /// + public const float HeadingSnapToleranceRad = 20.0f * MathF.PI / 180.0f; + + /// + /// Default angular rate for in-motion heading correction when delta + /// exceeds . Picked to match + /// ACE's TurnSpeed default of π/2 rad/s for monsters; + /// when the per-creature value differs, the future port can wire it + /// in via the TurnSpeed field on InterpretedMotionState. + /// + public const float TurnRateRadPerSec = MathF.PI / 2.0f; + + /// + /// Retail base turn rate for the player Humanoid when turn_speed + /// scalar = 1.0. Convention default omega.z = ±π/2 rad/s + /// derived from add_motion at 0x005224b0 + the + /// HasOmega-cleared MotionData fallback documented in + /// AnimationSequencer.cs:734-741. ~90°/s. + /// + public const float BaseTurnRateRadPerSec = MathF.PI / 2.0f; + + /// + /// Retail's run_turn_factor constant at 0x007c8914 + /// (PDB-named). CMotionInterp::apply_run_to_command + /// equivalent (decomp 0x00527be0, line 305098 of + /// acclient_2013_pseudo_c.txt) multiplies turn_speed + /// by 1.5 when HoldKey.Run is active on a TurnRight/TurnLeft + /// command. Effect: running rotation is 50 % faster than walking. + /// + public const float RunTurnFactor = 1.5f; + + /// + /// Retail-faithful local-player turn rate. + /// + /// Walking: BaseTurnRateRadPerSec ≈ 90°/s. + /// Running: BaseTurnRateRadPerSec × RunTurnFactor + /// ≈ 135°/s. + /// + /// Replaces the fixed TurnRateRadPerSec for paths that have + /// access to the player's run/walk state (keyboard A/D, auto-walk + /// overlay turn-first). NPC/monster remotes that lack the + /// information continue to use the constant which equals + /// BaseTurnRateRadPerSec. + /// + public static float TurnRateFor(bool running) + => running ? BaseTurnRateRadPerSec * RunTurnFactor + : BaseTurnRateRadPerSec; + + /// + /// Float-comparison slack for the arrival predicate. With + /// min_distance == 0 in a chase packet, exact equality is + /// unreachable due to integration wobble; this epsilon prevents the + /// driver from over-shooting by a sub-meter and snap-flipping back. + /// + public const float ArrivalEpsilon = 0.05f; + + /// + /// Maximum staleness (seconds) of the most recent MoveTo packet + /// before the driver gives up steering. ACE re-emits MoveTo at ~1 Hz + /// during active chase; if no fresh packet arrives for this long, + /// the entity has likely either left our streaming view, switched + /// to a non-MoveTo motion the server's broadcast didn't reach us + /// for, or had its move cancelled server-side without our seeing + /// the cancel UM. In any of those cases, continuing to drive the + /// body toward a stale destination produces the "monster runs in + /// place after popping back into view" symptom (2026-04-28). + /// 1.5 s gives us comfortable margin over the ~1 s emit cadence + /// while still failing fast on real loss-of-state. + /// + public const double StaleDestinationSeconds = 1.5; + + public enum DriveResult + { + /// Within arrival window — caller should zero velocity. + Arrived, + /// Steering active — caller should let + /// apply_current_movement set body velocity from the cycle. + Steering, + } + + /// + /// Steer body orientation toward + /// and report whether the body has arrived or should keep running. + /// Pure function — emits the updated orientation via + /// (the input is not mutated; the + /// caller assigns the new value back to its body). + /// + /// + /// min_distance from the wire's MovementParameters block — + /// retail's HandleMoveToPosition chase-arrival threshold. + /// + /// + /// distance_to_object from the wire — ACE's chase-arrival + /// threshold (default 0.6 m, the melee range). The actual arrival + /// gate is max(minDistance, distanceToObject): retail-faithful + /// when retail sends min_distance > 0, ACE-compatible when + /// ACE puts the value in distance_to_object with + /// min_distance == 0. Without this, ACE's min_distance==0 + /// chase packets never arrive — the body keeps re-targeting around + /// the player at melee range and visibly oscillates between facings, + /// which is the user-reported "monster keeps running in different + /// directions when it should be attacking" symptom (2026-04-28). + /// + public static DriveResult Drive( + Vector3 bodyPosition, + Quaternion bodyOrientation, + Vector3 destinationWorld, + float minDistance, + float distanceToObject, + float dt, + bool moveTowards, + out Quaternion newOrientation) + { + // Horizontal distance only — server owns Z, our body Z is + // hard-snapped to the latest UpdatePosition. + float dx = destinationWorld.X - bodyPosition.X; + float dy = destinationWorld.Y - bodyPosition.Y; + float dist = MathF.Sqrt(dx * dx + dy * dy); + + // Arrival predicate per retail MoveToManager::HandleMoveToPosition + // (acclient_2013_pseudo_c.txt:307289-307320) and ACE + // MoveToManager.cs:476: + // + // chase (MoveTowards): dist <= distance_to_object + // flee (MoveAway): dist >= min_distance + // + // (My earlier max(MinDistance, DistanceToObject) was a + // defensive guess; cross-checked with two independent research + // agents against the named retail decomp + ACE port + holtburger, + // the chase threshold is unambiguously DistanceToObject — + // MinDistance is the FLEE arrival threshold. ACE's wire defaults + // give MinDistance=0, DistanceToObject=0.6 — the body should stop + // at melee range, not run to zero.) + float arrivalThreshold = moveTowards ? distanceToObject : minDistance; + if (moveTowards && dist <= arrivalThreshold + ArrivalEpsilon) + { + newOrientation = bodyOrientation; + return DriveResult.Arrived; + } + if (!moveTowards && dist >= arrivalThreshold - ArrivalEpsilon) + { + newOrientation = bodyOrientation; + return DriveResult.Arrived; + } + + // Degenerate — already on target horizontally; preserve heading. + if (dist < 1e-4f) + { + newOrientation = bodyOrientation; + return DriveResult.Steering; + } + + // Body's local-forward is +Y (see MotionInterpreter.get_state_velocity + // at line 605-616: velocity.Y = (Walk/Run)AnimSpeed × ForwardSpeed). + // World forward = Transform((0,1,0), orientation). Yaw extracted + // via atan2(-worldFwd.X, worldFwd.Y) so yaw = 0 ↔ orientation = Identity. + var localForward = new Vector3(0f, 1f, 0f); + var worldForward = Vector3.Transform(localForward, bodyOrientation); + float currentYaw = MathF.Atan2(-worldForward.X, worldForward.Y); + + // Desired heading: face the target. (dx, dy) is the world-space + // offset to the target. With local-forward=+Y we want yaw such + // that Transform((0,1,0), R_Z(yaw)) = (dx, dy)/dist; that solves + // to yaw = atan2(-dx, dy). + float desiredYaw = MathF.Atan2(-dx, dy); + float delta = WrapPi(desiredYaw - currentYaw); + + if (MathF.Abs(delta) <= HeadingSnapToleranceRad) + { + // ACE's set_heading(target, true) — sync to server-tic-rate. + // We have the same sparse-UP problem ACE does, so the same + // fudge applies. + newOrientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, desiredYaw); + } + else + { + // Retail BeginTurnToHeading / HandleMoveToPosition aux turn: + // rotate at TurnRate clamped to dt, in the shorter direction. + float maxStep = TurnRateRadPerSec * dt; + float step = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep); + // Apply incremental yaw around world +Z (preserving any + // server-supplied pitch/roll from the latest UpdatePosition). + var deltaQuat = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, step); + newOrientation = Quaternion.Normalize(deltaQuat * bodyOrientation); + } + + return DriveResult.Steering; + } + + /// + /// Convert a landblock-local Origin from a MoveTo packet + /// () + /// into acdream's render world space using the same arithmetic as + /// OnLivePositionUpdated: shift by the landblock-grid offset + /// from the live-mode center. + /// + public static Vector3 OriginToWorld( + uint originCellId, + float originX, + float originY, + float originZ, + int liveCenterLandblockX, + int liveCenterLandblockY) + { + int lbX = (int)((originCellId >> 24) & 0xFFu); + int lbY = (int)((originCellId >> 16) & 0xFFu); + return new Vector3( + originX + (lbX - liveCenterLandblockX) * 192f, + originY + (lbY - liveCenterLandblockY) * 192f, + originZ); + } + + /// + /// Cap horizontal velocity so the body lands exactly at + /// rather than overshooting past + /// it during the final tick of approach. Without this clamp, a body + /// running at RunAnimSpeed × speedMod ≈ 4 m/s can overshoot + /// the 0.6 m arrival window by up to one tick's advance (~6 cm at + /// 60 fps) — visible as the creature "running slightly through" the + /// player it's about to attack (user-reported 2026-04-28). + /// + /// + /// The clamp is a strict scale-down of the horizontal component + /// (X/Y); the vertical component (Z) is left to gravity / terrain + /// handling. false (flee branch) is a + /// no-op since fleeing has no overshoot risk — the body wants to + /// move AWAY from the destination. + /// + /// + public static Vector3 ClampApproachVelocity( + Vector3 bodyPosition, + Vector3 currentVelocity, + Vector3 destinationWorld, + float arrivalThreshold, + float dt, + bool moveTowards) + { + if (!moveTowards || dt <= 0f) return currentVelocity; + + float dx = destinationWorld.X - bodyPosition.X; + float dy = destinationWorld.Y - bodyPosition.Y; + float dist = MathF.Sqrt(dx * dx + dy * dy); + float remaining = MathF.Max(0f, dist - arrivalThreshold); + + float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X + + currentVelocity.Y * currentVelocity.Y); + if (vxy < 1e-3f) return currentVelocity; + + float advance = vxy * dt; + if (advance <= remaining) return currentVelocity; + + // Already inside or right at the threshold: zero horizontal + // velocity, keep Z. (The arrival predicate in Drive() should + // have fired this tick, but this is the belt-and-braces guard.) + if (remaining < 1e-3f) + return new Vector3(0f, 0f, currentVelocity.Z); + + float scale = remaining / advance; + return new Vector3( + currentVelocity.X * scale, + currentVelocity.Y * scale, + currentVelocity.Z); + } + + /// Wrap an angle in radians to [-π, π]. + private static float WrapPi(float r) + { + const float TwoPi = MathF.PI * 2f; + r %= TwoPi; + if (r > MathF.PI) r -= TwoPi; + if (r < -MathF.PI) r += TwoPi; + return r; + } +} diff --git a/src/AcDream.Core/Physics/RemoteWeenie.cs b/src/AcDream.Core/Physics/RemoteWeenie.cs deleted file mode 100644 index 6d6aedb9..00000000 --- a/src/AcDream.Core/Physics/RemoteWeenie.cs +++ /dev/null @@ -1,54 +0,0 @@ -namespace AcDream.Core.Physics; - -/// -/// R4-V5 (#160 fix, 2026-07-03): the minimal weenie every REMOTE entity's -/// carries — standing in for retail's -/// per-object ACCWeenieObject, which every placed physics object has. -/// -/// -/// The load-bearing behavior is the RUN-RATE FALLBACK CHAIN: -/// apply_run_to_command (0x00527be0, raw 305062-305076) reads -/// weenie ? (InqRunRate() ?: my_run_rate) : 1.0. Remote weenies in -/// retail FAIL InqRunRate (no local skill data), landing on -/// my_run_rate — exactly the field the mt-6/7 unpack writes with the -/// wire's MoveToRunRate (M13). Our remote interps previously had NO -/// weenie at all, taking the degenerate else 1.0 branch retail -/// reserves for detached objects — so an observer-side moveto dispatched -/// RunForward at speed 1.0 instead of the wire rate: the run cycle played -/// in slow motion and the chase velocity crawled (walk was unaffected — -/// walk speed isn't rate-scaled). -/// -/// -/// -/// stays default false — the A3 -/// dual dispatch routes remotes through apply_interpreted_movement, ending -/// the fragile "null weenie counts as the player" reading of that gate. -/// stays default true; static -/// animated objects (doors) also carry this weenie — retail would say -/// non-creature there, but their RemoteMotion bodies force-assert Contact, -/// so the creature ground-gate is satisfied either way (noted, not a -/// behavior difference today). -/// -/// -public sealed class RemoteWeenie : IWeenieObject -{ - /// Remote objects have no local skill data — fail, so the - /// retail fallback chain lands on - /// (the wire-fed rate). - public bool InqRunRate(out float rate) - { - rate = 0f; - return false; - } - - /// Remotes never locally compute jump arcs (server-broadcast - /// VectorUpdate velocities drive them). - public bool InqJumpVelocity(float extent, out float vz) - { - vz = 0f; - return false; - } - - /// Remotes never locally initiate jumps. - public bool CanJump(float extent) => true; -} diff --git a/src/AcDream.Core/Physics/Retail2013CommandCatalog.cs b/src/AcDream.Core/Physics/Retail2013CommandCatalog.cs deleted file mode 100644 index 78a48c51..00000000 --- a/src/AcDream.Core/Physics/Retail2013CommandCatalog.cs +++ /dev/null @@ -1,113 +0,0 @@ -namespace AcDream.Core.Physics; - -/// -/// Conformance/reference built from the -/// Sept 2013 EoR retail decomp's full command_ids[0x198] table — a -/// direct index from 16-bit wire low-word to the 32-bit MotionCommand the -/// 2013 client itself would reconstruct. NOT the runtime default (ACE / the -/// local DATs use a later-client numbering — see -/// ); this catalog exists to prove and -/// pin the 2013 decomp's behavior for conformance tests. -/// -/// -/// Provenance: docs/research/named-retail/acclient_2013_pseudo_c.txt, -/// uint32_t const command_ids[0x198] at address 0x007c73e8, -/// table body starting at source line 1017259 -/// ([0x000] = 0x80000000) through line 1017667 -/// ([0x197] = 0x10000197), closing brace at line 1017668. Extracted -/// programmatically (regex over [0xNNN] = 0xVVVVVVVV, not hand-typed) -/// and verified against the anchor values cited inline below. -/// -/// -/// -/// Caution — two look-alike tables exist elsewhere in the same file -/// (around source lines 1017962 and 1068315, both also declared -/// command_ids[0x198]). Only the table at line 1017259 / -/// address 0x007c73e8 is used here; do not conflate with the others. -/// -/// -public sealed class Retail2013CommandCatalog : IMotionCommandCatalog -{ - public uint ReconstructFullCommand(ushort wireCommand) - { - return wireCommand < CommandIds.Length ? CommandIds[wireCommand] : 0u; - } - - // acclient_2013_pseudo_c.txt:1017259-1017667, address 0x007c73e8. - // 0x198 (408) entries, indices 0x000..0x197, verbatim from the decomp. - // Anchors verified at extraction time: - // [0x000]=0x80000000 [0x003]=0x41000003 [0x005]=0x45000005 - // [0x007]=0x44000007 [0x00d]=0x6500000d [0x150]=0x10000150 - // [0x153]=0x09000153 - private static readonly uint[] CommandIds = - { - 0x80000000u, 0x85000001u, 0x85000002u, 0x41000003u, 0x40000004u, 0x45000005u, - 0x45000006u, 0x44000007u, 0x40000008u, 0x40000009u, 0x4000000Au, 0x4000000Bu, - 0x4000000Cu, 0x6500000Du, 0x6500000Eu, 0x6500000Fu, 0x65000010u, 0x40000011u, - 0x41000012u, 0x41000013u, 0x41000014u, 0x40000015u, 0x40000016u, 0x40000017u, - 0x40000018u, 0x40000019u, 0x4000001Au, 0x4000001Bu, 0x4000001Cu, 0x4000001Du, - 0x4000001Eu, 0x4000001Fu, 0x40000020u, 0x40000021u, 0x40000022u, 0x40000023u, - 0x40000024u, 0x40000025u, 0x40000026u, 0x40000027u, 0x40000028u, 0x40000029u, - 0x4000002Au, 0x4000002Bu, 0x4000002Cu, 0x4000002Du, 0x4000002Eu, 0x4000002Fu, - 0x40000030u, 0x40000031u, 0x40000032u, 0x40000033u, 0x40000034u, 0x40000035u, - 0x40000036u, 0x40000037u, 0x40000038u, 0x40000039u, 0x2000003Au, 0x2500003Bu, - 0x8000003Cu, 0x8000003Du, 0x8000003Eu, 0x8000003Fu, 0x80000040u, 0x80000041u, - 0x80000042u, 0x80000043u, 0x80000044u, 0x80000045u, 0x80000046u, 0x80000047u, - 0x80000048u, 0x80000049u, 0x1000004Au, 0x1000004Bu, 0x1300004Cu, 0x1000004Du, - 0x1000004Eu, 0x1000004Fu, 0x10000050u, 0x10000051u, 0x10000052u, 0x10000053u, - 0x10000054u, 0x10000055u, 0x10000056u, 0x10000057u, 0x10000058u, 0x10000059u, - 0x1000005Au, 0x1000005Bu, 0x1000005Cu, 0x1000005Du, 0x1000005Eu, 0x1000005Fu, - 0x10000060u, 0x10000061u, 0x10000062u, 0x10000063u, 0x10000064u, 0x10000065u, - 0x10000066u, 0x10000067u, 0x10000068u, 0x10000069u, 0x1000006Au, 0x1000006Bu, - 0x1000006Cu, 0x1000006Du, 0x1000006Eu, 0x1000006Fu, 0x10000070u, 0x10000071u, - 0x10000072u, 0x10000073u, 0x10000074u, 0x10000075u, 0x10000076u, 0x10000077u, - 0x10000078u, 0x13000079u, 0x1300007Au, 0x1300007Bu, 0x1300007Cu, 0x1300007Du, - 0x1300007Eu, 0x1300007Fu, 0x13000080u, 0x13000081u, 0x13000082u, 0x13000083u, - 0x13000084u, 0x13000085u, 0x13000086u, 0x13000087u, 0x13000088u, 0x13000089u, - 0x1300008Au, 0x1300008Bu, 0x1300008Cu, 0x1300008Du, 0x1300008Eu, 0x1300008Fu, - 0x13000090u, 0x13000091u, 0x13000092u, 0x13000093u, 0x13000094u, 0x13000095u, - 0x13000096u, 0x13000097u, 0x13000098u, 0x13000099u, 0x1300009Au, 0x1200009Bu, - 0x1000009Cu, 0x1000009Du, 0x1000009Eu, 0x1000009Fu, 0x100000A0u, 0x100000A1u, - 0x080000A2u, 0x090000A3u, 0x090000A4u, 0x090000A5u, 0x090000A6u, 0x090000A7u, - 0x090000A8u, 0x080000A9u, 0x090000AAu, 0x090000ABu, 0x090000ACu, 0x090000ADu, - 0x090000AEu, 0x090000AFu, 0x090000B0u, 0x090000B1u, 0x0D0000B2u, 0x0D0000B3u, - 0x0D0000B4u, 0x080000B5u, 0x080000B6u, 0x080000B7u, 0x090000B8u, 0x090000B9u, - 0x0D0000BAu, 0x0D0000BBu, 0x0D0000BCu, 0x0D0000BDu, 0x0D0000BEu, 0x0D0000BFu, - 0x090000C0u, 0x0C0000C1u, 0x090000C2u, 0x090000C3u, 0x090000C4u, 0x0D0000C5u, - 0x090000C6u, 0x090000C7u, 0x090000C8u, 0x090000C9u, 0x130000CAu, 0x130000CBu, - 0x130000CCu, 0x100000CDu, 0x100000CEu, 0x100000CFu, 0x100000D0u, 0x100000D1u, - 0x100000D2u, 0x400000D3u, 0x120000D4u, 0x090000D5u, 0x090000D6u, 0x090000D7u, - 0x090000D8u, 0x090000D9u, 0x090000DAu, 0x090000DBu, 0x090000DCu, 0x090000DDu, - 0x090000DEu, 0x120000DFu, 0x400000E0u, 0x400000E1u, 0x100000E2u, 0x100000E3u, - 0x400000E4u, 0x400000E5u, 0x400000E6u, 0x090000E7u, 0x800000E8u, 0x800000E9u, - 0x430000EAu, 0x430000EBu, 0x430000ECu, 0x430000EDu, 0x430000EEu, 0x430000EFu, - 0x430000F0u, 0x430000F1u, 0x430000F2u, 0x430000F3u, 0x430000F4u, 0x430000F5u, - 0x430000F6u, 0x430000F7u, 0x430000F8u, 0x420000F9u, 0x430000FAu, 0x430000FBu, - 0x430000FCu, 0x430000FDu, 0x090000FEu, 0x090000FFu, 0x09000100u, 0x09000101u, - 0x09000102u, 0x09000103u, 0x09000104u, 0x09000105u, 0x09000106u, 0x09000107u, - 0x09000108u, 0x09000109u, 0x0900010Au, 0x0900010Bu, 0x0900010Cu, 0x0900010Du, - 0x1000010Eu, 0x0900010Fu, 0x09000110u, 0x09000111u, 0x09000112u, 0x09000113u, - 0x09000114u, 0x43000115u, 0x13000116u, 0x43000117u, 0x43000118u, 0x43000119u, - 0x0900011Au, 0x1000011Bu, 0x1000011Cu, 0x1000011Du, 0x1000011Eu, 0x1000011Fu, - 0x10000120u, 0x10000121u, 0x10000122u, 0x10000123u, 0x10000124u, 0x10000125u, - 0x10000126u, 0x10000127u, 0x10000128u, 0x10000129u, 0x1000012Au, 0x1000012Bu, - 0x1000012Cu, 0x1000012Du, 0x1000012Eu, 0x1000012Fu, 0x10000130u, 0x10000131u, - 0x13000132u, 0x40000133u, 0x40000134u, 0x40000135u, 0x40000136u, 0x10000137u, - 0x80000138u, 0x80000139u, 0x4300013Au, 0x4300013Bu, 0x4300013Cu, 0x4300013Du, - 0x4300013Eu, 0x4300013Fu, 0x43000140u, 0x43000141u, 0x43000142u, 0x43000143u, - 0x43000144u, 0x43000145u, 0x43000146u, 0x13000147u, 0x13000148u, 0x13000149u, - 0x1300014Au, 0x1300014Bu, 0x1300014Cu, 0x1300014Du, 0x1300014Eu, 0x1300014Fu, - 0x10000150u, 0x09000151u, 0x09000152u, 0x09000153u, 0x09000154u, 0x09000155u, - 0x09000156u, 0x09000157u, 0x09000158u, 0x09000159u, 0x0900015Au, 0x0900015Bu, - 0x0900015Cu, 0x0900015Du, 0x0900015Eu, 0x0900015Fu, 0x09000160u, 0x09000161u, - 0x10000162u, 0x10000163u, 0x10000164u, 0x09000165u, 0x09000166u, 0x09000167u, - 0x09000168u, 0x09000169u, 0x0900016Au, 0x0900016Bu, 0x0900016Cu, 0x0900016Du, - 0x1000016Eu, 0x1000016Fu, 0x10000170u, 0x10000171u, 0x10000172u, 0x10000173u, - 0x10000174u, 0x10000175u, 0x10000176u, 0x10000177u, 0x10000178u, 0x10000179u, - 0x1000017Au, 0x1000017Bu, 0x1000017Cu, 0x1000017Du, 0x1000017Eu, 0x1000017Fu, - 0x10000180u, 0x10000181u, 0x10000182u, 0x10000183u, 0x10000184u, 0x10000185u, - 0x10000186u, 0x10000187u, 0x10000188u, 0x10000189u, 0x1000018Au, 0x1000018Bu, - 0x1000018Cu, 0x1000018Du, 0x1000018Eu, 0x1000018Fu, 0x10000190u, 0x10000191u, - 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u, 0x10000196u, 0x10000197u, - }; -} diff --git a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs index 6444b3da..af4d14dd 100644 --- a/src/AcDream.Core/Physics/ServerControlledLocomotion.cs +++ b/src/AcDream.Core/Physics/ServerControlledLocomotion.cs @@ -32,10 +32,24 @@ public static class ServerControlledLocomotion public const float MinSpeedMod = 0.25f; public const float MaxSpeedMod = 3.00f; - // R4-V4: PlanMoveToStart DELETED - MoveTo UMs route to the verbatim - // MoveToManager (BeginMoveForward -> get_command -> _DoMotion produces - // the cycle through the same sink every other motion uses). - // PlanFromVelocity below survives (M16 register row, retires in R6). + // Retail MoveToManager::BeginMoveForward -> MovementParameters::get_command + // (0x0052AA00) seeds forward motion before the next position update. + public static LocomotionCycle PlanMoveToStart( + float moveToSpeed = 1f, + float runRate = 1f, + bool canRun = true) + { + moveToSpeed = SanitizePositive(moveToSpeed); + runRate = SanitizePositive(runRate); + + if (!canRun) + return new LocomotionCycle(MotionCommand.WalkForward, moveToSpeed, true); + + return new LocomotionCycle( + MotionCommand.RunForward, + moveToSpeed * runRate, + true); + } public static LocomotionCycle PlanFromVelocity(Vector3 worldVelocity) { diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs index ee31f9a0..fb78aad3 100644 --- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs +++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs @@ -532,10 +532,9 @@ public sealed class ShadowObjectRegistry /// /// Collision type for a shadow entry. BSP uses full polygon collision. -/// Cylinder uses a cylinder-sphere intersection test (XY distance + height clamp). -/// Sphere uses a true 3-D sphere-sphere intersection test (no height clamp). +/// Cylinder uses a simple cylinder-sphere intersection test. /// -public enum ShadowCollisionType : byte { BSP, Cylinder, Sphere } +public enum ShadowCollisionType : byte { BSP, Cylinder } public readonly record struct ShadowEntry( uint EntityId, diff --git a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs index 8cb30123..4fff4106 100644 --- a/src/AcDream.Core/Physics/ShadowShapeBuilder.cs +++ b/src/AcDream.Core/Physics/ShadowShapeBuilder.cs @@ -38,22 +38,10 @@ public static class ShadowShapeBuilder /// every radius, height, and local offset. /// Predicate: does the GfxObj with this id /// have a non-null PhysicsBSP? Production: id => cache.GetGfxObj(id)?.BSP?.Root is not null. - /// #175: per-part pose override for the - /// BSP part shapes — the entity's motion-table DEFAULT-STATE pose (the - /// closed pose for doors). Retail collision tests each part's LIVE - /// CPhysicsPart pose, which for an idle entity is the motion - /// table's default state, NOT the Setup's placement frame — the two - /// differ on e.g. the Facility Hub double door (Setup 0x02000C9D: - /// placement poses the panels AJAR at yaw −150°/−30°, y −0.44 m; the - /// closed pose is straight). Null / short lists fall back to the - /// placement frame per part (entities with no motion table, and the - /// CylSphere/Sphere shapes, are unaffected — retail poses those from - /// the setup too). public static IReadOnlyList FromSetup( Setup setup, float entScale, - Func hasPhysicsBsp, - IReadOnlyList? partPoseOverride = null) + Func hasPhysicsBsp) { if (setup is null) throw new ArgumentNullException(nameof(setup)); if (hasPhysicsBsp is null) throw new ArgumentNullException(nameof(hasPhysicsBsp)); @@ -76,9 +64,7 @@ public static class ShadowShapeBuilder } // 2. Spheres — only when no CylSpheres (matches landblock-static convention - // at GameWindow.cs:6034). Each becomes a true Sphere (no height clamping). - // Retail anchor: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance - // for the overlap check, unlike CCylSphere which clips to [low_pt, high_pt]. + // at GameWindow.cs:6034). Each becomes a short Cylinder. if (setup.CylSpheres.Count == 0) { foreach (var sph in setup.Spheres) @@ -89,28 +75,22 @@ public static class ShadowShapeBuilder LocalPosition: new Vector3(sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale, LocalRotation: Quaternion.Identity, Scale: entScale, - CollisionType: ShadowCollisionType.Sphere, + CollisionType: ShadowCollisionType.Cylinder, Radius: sph.Radius * entScale, - CylHeight: 0f)); + CylHeight: sph.Radius * 2f * entScale)); } } // 3. Parts — one BSP shape per part with a non-null PhysicsBSP. - // Pose priority per part: partPoseOverride (the motion-table - // default-state pose, #175) → placement frame → identity. AnimationFrame? placementFrame = ResolvePlacementFrame(setup); for (int i = 0; i < setup.Parts.Count; i++) { uint gfxId = (uint)setup.Parts[i]; if (!hasPhysicsBsp(gfxId)) continue; - Frame partFrame; - if (partPoseOverride is not null && i < partPoseOverride.Count) - partFrame = partPoseOverride[i]; - else if (placementFrame is not null && i < placementFrame.Frames.Count) - partFrame = placementFrame.Frames[i]; - else - partFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; + Frame partFrame = placementFrame is not null && i < placementFrame.Frames.Count + ? placementFrame.Frames[i] + : new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; // BSP radius default; caller substitutes the real BoundingSphere.Radius // at registration time when available. Loose-but-safe broadphase value. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 20dcdef1..4e6d4b6e 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -335,15 +335,6 @@ public sealed class SpherePath public uint CurCellId; public uint CheckCellId; - // #145: the carried cell-relative frame anchor (body.Position - - // body.CellPosition.Frame.Origin) — the TRUE landblock world origin, set at - // resolve entry from a SEEDED OUTDOOR player body. Threaded into the outdoor - // membership pick (CellTransit.FindCellSet) so it no longer derives the origin - // from the terrain registry (which returns (0,0) for an unstreamed neighbour → - // the far-town cell-march cascade). Null for unseeded movers (NPCs/tests) and - // indoor seeds → legacy TryGetTerrainOrigin. - public Vector3? CarriedBlockOrigin; - // Per-step offset public Vector3 GlobalOffset; @@ -382,30 +373,6 @@ public sealed class SpherePath public bool CheckWalkable; public InsertType InsertType = InsertType.Transition; - /// - /// Retail SPHEREPATH.obstruction_ethereal: set to 1 per-target - /// inside CPhysicsObj::FindObjCollisions (pc:276806 / 0x0050f0c9) when - /// the target object is ETHEREAL-alone (ETHEREAL_PS=0x4 set, IGNORE_COLLISIONS_PS - /// 0x10 NOT set). Cleared after each per-object shape test (pc:276989 / 0x0050f31e) - /// and also cleared at the top of CEnvCell::find_env_collisions - /// (pc:309580 / 0x0052c144 — the "D5 clear") so ENV walls are never weakened. - /// - /// - /// Consume site: BSPTREE::find_collisions pc:323742 / 0x0053a496 — Path 1 - /// fires (sphere_intersects_solid) when - /// insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0. - /// The sphere_intersects_solid test only returns COLLIDED if the player - /// sphere is fully inside a BSP solid leaf. For an open door (no solid wall at the - /// opening) it returns OK → player passes through. - /// - /// - /// - /// Task 3 of the collision-inclusion verbatim-retail port (2026-06-24). - /// Retires divergence register row AD-7. - /// - /// - public bool ObstructionEthereal; - /// /// BR-7 / A6.P4 (2026-06-11). Retail SPHEREPATH.bldg_check: set /// around the building-shell part test by @@ -642,54 +609,9 @@ public sealed class Transition Environment.GetEnvironmentVariable("ACDREAM_DUMP_EDGE_SLIDE") == "1"; // ----------------------------------------------------------------------- - // A6.P7 (2026-05-25) + W1 (2026-06-24) — retail-binary dispatch rule + // A6.P7 (2026-05-25) — retail-binary dispatch rule // ----------------------------------------------------------------------- - /// - /// W1: Named predicate for the PvP-exemption term in retail's - /// CPhysicsObj::FindObjCollisions dispatch (pc:276861, - /// acclient_2013_pseudo_c.txt:276808–276841). - /// - /// - /// In retail ebp_1 is non-null when the mover is a player AND - /// the target is not impenetrable AND PK/PKLite flags indicate a - /// PvP-exempt pairing — i.e. the mover should pass through rather than - /// collide. While this is non-null the dispatch takes the cyl+sphere - /// path regardless of HAS_PHYSICS_BSP_PS, effectively making - /// PvP-exempt players transparent to BSP-only objects. - /// - /// - /// - /// M1.5 scope has no PK. Returns false always. - /// Wire through mover + target state when PK ships (M2+ / phase TBD). - /// Retail oracle: pc:276808–276841. - /// - /// - internal static bool PvpExempt() => false; // wire when PK ships (M2+) — pc:276808–276841 - - /// - /// W1: Named predicate for the OBJECTINFO::missile_ignore term - /// in retail's CPhysicsObj::FindObjCollisions dispatch - /// (pc:276861, acclient_2013_pseudo_c.txt:274385 + :276858–276861). - /// - /// - /// In retail eax_12 = OBJECTINFO::missile_ignore(ebx, this) - /// returns non-zero when either the TARGET has MISSILE_PS (0x40) - /// set, or the mover has MISSILE_PS set and the target is a - /// creature that is not the mover's designated target. When non-zero the - /// dispatch takes the cyl+sphere path regardless of - /// HAS_PHYSICS_BSP_PS — missiles pass through BSP-only objects. - /// - /// - /// - /// M1.5 scope has no missiles. Returns false always. - /// Wire through mover OBJECTINFO + target state when missiles ship (F.3). - /// Retail oracle: OBJECTINFO::missile_ignore pc:274385; - /// dispatch pc:276858–276861. - /// - /// - internal static bool MissileIgnore() => false; // wire when missiles ship (F.3) — pc:274385 - /// /// A6.P7 retail-binary dispatch predicate. Returns true when an /// entity's collision queries should go to its BSP exclusively, @@ -705,24 +627,23 @@ public sealed class Transition /// else /// // BSP-only via CPartArray::FindObjCollisions /// - /// where ebp_1 is the PvP-target-player flag (lines 276808– - /// 276841, see ) and eax_12 is the - /// OBJECTINFO::missile_ignore result (line 274385, see - /// ). The flag is named + /// where ebp_1 is the PvP-target-player flag (lines 276808- + /// 276841) and eax_12 is the OBJECTINFO::missile_ignore + /// result (line 274385). The flag is named /// HAS_PHYSICS_BSP_PS = 0x10000 in acclient.h:2833 and /// PhysicsState.HasPhysicsBSP in ACE /// (references/ACE/Source/ACE.Entity/Enum/PhysicsState.cs:24). /// /// /// - /// BSP-only iff: - /// HAS_PHYSICS_BSP_PS is set on the entity - /// AND the mover is not PvP-exempt for this target ( = false) - /// AND the entity is not missile-ignore exempt ( = false). - /// In M1.5 and both - /// return false, so the predicate reduces to - /// (state & HAS_PHYSICS_BSP_PS) != 0 — no behavior change - /// from before W1. + /// M1.5 scope (walking-vs-static, no PK, no missiles) treats both + /// ebp_1 and eax_12 as false. The predicate + /// reduces to (state & HAS_PHYSICS_BSP_PS) != 0. When + /// PK ships (M2+ phase) and missiles ship (F.3), wire the + /// PvP-exemption and missile_ignore checks through as additional + /// parameters following retail's + /// references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:412 + /// dispatch shape. /// /// /// @@ -734,13 +655,10 @@ public sealed class Transition /// PhysicsState value (as stored on /// ShadowEntry.State). /// True when retail would dispatch BSP-only — i.e. when - /// the entity has HAS_PHYSICS_BSP_PS set AND neither the PvP - /// exemption nor the missile-ignore exemption applies; cyl/sphere - /// shapes must be skipped at the per-entry dispatch site. + /// the entity has HAS_PHYSICS_BSP_PS set; cyl/sphere shapes + /// must be skipped at the per-entry dispatch site. public static bool BspOnlyDispatch(uint entityState) - => (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0 - && !PvpExempt() - && !MissileIgnore(); + => (entityState & (uint)PhysicsStateFlags.HasPhysicsBsp) != 0; // ----------------------------------------------------------------------- // Public entry point @@ -1804,21 +1722,6 @@ public sealed class Transition { if (cellId == sp.CheckCellId) continue; - // #137 seam shake (2026-07-06): a mid-loop query can MOVE the - // sphere — a Path-5 full hit dispatches step_sphere_up, and a - // successful climb lifts the foot (the 0.6 mm ramp-slab lift at - // the Facility Hub boundaries) yet returns OK, so the loop - // continues. Retail's check_other_cells reads the LIVE - // sphere_path.global_sphere for every cell (each cell's - // find_collisions receives the transition itself, pc:272717+); - // querying the remaining cells with the pre-climb center re-tests - // the boundary ~0.4 mm inside the floor slab and grazes the - // under-room's CEILING (the slab underside) — the neg-poly - // step-up-with-a-downward-normal chain that shook the player at - // every corridor seam. Same lesson as the P2 cellar-lip fix - // (RunCheckOtherCellsAndAdvance's entry refresh), one loop deeper. - footCenter = sp.GlobalSphere[0].Origin; - if ((cellId & 0xFFFFu) < 0x0100u) { var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y); @@ -2152,13 +2055,6 @@ public sealed class Transition var sp = SpherePath; var ci = CollisionInfo; - // D5 clear (Task 3, 2026-06-24): retail CEnvCell::find_env_collisions - // pc:309580 / 0x0052c144 clears obstruction_ethereal to 0 before any - // ENV BSP dispatch. ENV walls are always solid — the flag only applies - // to per-object (CPhysicsObj) tests in FindObjCollisions. This prevents - // a stale flag from a prior object loop from weakening cell wall collision. - sp.ObstructionEthereal = false; - Vector3 footCenter = sp.GlobalSphere[0].Origin; float sphereRadius = sp.GlobalSphere[0].Radius; @@ -2393,8 +2289,7 @@ public sealed class Transition sp.HitsInteriorCell = false; uint containingCellId = CellTransit.FindCellSet( - engine.DataCache, sp.GlobalSphere, sp.NumSphere, sp.CheckCellId, out var cellSet, - sp.CarriedBlockOrigin); + engine.DataCache, sp.GlobalSphere, sp.NumSphere, sp.CheckCellId, out var cellSet); LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius); if ((sp.CheckCellId & 0xFFFFu) >= 0x0100u @@ -2614,33 +2509,6 @@ public sealed class Transition if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State)) continue; - // Retail CPhysicsObj::FindObjCollisions ethereal branch - // (pc:276795-276806 / 0x0050f0a2-0x0050f0c9). A target counts as - // "ethereal for this test" when it has ETHEREAL_PS (0x4) OR the mover - // is ethereal against a NON-static target ((state & 1) == 0): - // - // if ((state & 4) || (mover.ethereal && (state & 1) == 0)) { - // var_c = 1; - // if (sphere_path.step_down == 0) goto do_test; // main pass - // // step_down != 0 → fall through = SKIP this target entirely - // } else { var_c = 0; do_test: obstruction_ethereal = var_c; ...test... } - // - // The step-down SKIP (pc:276799 — no `goto` when step_down != 0) is what - // makes an OPEN DOOR fully passable: the main pass tests it with - // obstruction_ethereal=1 (BSPQuery Path 1 + the Layer-2 override below - // force it to OK), and the foot-sphere step-down sub-pass NEVER TESTS IT - // at all. acdream previously tested ethereal targets in BOTH passes with - // no step-down escape, so the step-down pass left a residual threshold - // block ("can" at the open door's sill) that retail does not have - // (#137 / live capture 2026-06-25: 217 blocks all while door state=0x1000C). - // obstruction_ethereal (= retail var_c) is consumed by BSPQuery Path 1 - // (BSPTREE::find_collisions pc:323742); cleared after each test (pc:276989). - bool etherealForTest = (obj.State & 0x4u) != 0 - || (ObjectInfo.Ethereal && (obj.State & 0x1u) == 0); - if (etherealForTest && sp.StepDown) - continue; // retail pc:276799 — ethereal target not tested in step-down - sp.ObstructionEthereal = etherealForTest; - // L.2a slice 3 (2026-05-12): snapshot collision-normal state so // we can tell whether THIS object's BSP/CylSphere test produced a // new collision (BSPQuery sets the normal but may still return OK @@ -2679,14 +2547,7 @@ public sealed class Transition $"[bsp-test] obj=0x{obj.EntityId:X8} gfx=0x{obj.GfxObjId:X8} state=0x{obj.State:X8} radius={obj.Radius:F3} pos=({obj.Position.X:F2},{obj.Position.Y:F2},{obj.Position.Z:F2}) distXY={distXY:F3} cacheHit={cacheHit}")); } - if (physics?.BSP?.Root is null) - { - // Clear obstruction_ethereal before skipping — retail's per-object - // clear (pc:276989) fires after shape tests; we clear early here to - // leave the flag clean for the next iteration. - sp.ObstructionEthereal = false; - continue; - } + if (physics?.BSP?.Root is null) continue; // Transform player spheres to object-local space. // For a scaled object (scenery tree, etc.), we need to @@ -2733,28 +2594,6 @@ public sealed class Transition engine, worldOrigin: obj.Position); } - else if (obj.CollisionType == ShadowCollisionType.Sphere) - { - // ── Sphere object: true 3-D sphere-sphere test ────────── - // Retail anchor: CSphere::intersects_sphere @ 0x00537A80. - // Unlike CCylSphere, CSphere uses 3-D distance (no height - // clamp). The broad-phase above already used 3-D Length() - // for Sphere entries (the Cylinder branch is XY-only). - // - // HAS_PHYSICS_BSP_PS dispatch (A6.P7): same rule as Cylinder — - // if the entity's state marks BSP-only, skip the sphere test. - if (BspOnlyDispatch(obj.State)) - { - if (PhysicsDiagnostics.ProbeBuildingEnabled) - { - Console.WriteLine(System.FormattableString.Invariant( - $"[sph-skip-bsp] obj=0x{obj.EntityId:X8} state=0x{obj.State:X8} — HAS_PHYSICS_BSP_PS dispatches BSP-only")); - } - continue; - } - - result = SphereCollision(obj, sp); - } else { // ── Cylinder object: swept-sphere cylinder test ────────── @@ -2807,40 +2646,6 @@ public sealed class Transition } } - // Layer-2 ethereal override — retail CPhysicsObj::FindObjCollisions - // pc:276961-276989 (0x0050f1ac-0x0050f31e). After the shape dispatch - // (Layer 1), retail force-resets the result to OK for a NON-STATIC - // ethereal target when step_down is NOT set. Layer 1 (BSPQuery Path-1 - // sphere_intersects_solid) handles the no-solid-leaf case (open door - // gap) already, but a thin ethereal slab (closed-but-ethereal / the - // player's sphere CENTER crosses the slab) can still return Collided. - // Layer 2 suppresses that so the door is FULLY passable once ethereal. - // - // Gate conditions (pc:276963-276973): - // 276963: state_3 != OK_TS ← result is a block - // 276967: sphere_path.step_down == 0 ← not a step-down query - // 276969: (target->state & 1) == 0 ← NOT STATIC_PS (0x1) - // 276971: var_c != 0 || ... ← obstruction_ethereal set - // (the || ... is the IGNORE_CREATURES+creature case — handled - // upstream in CollisionExemption; Layer 2 only needs the var_c term) - // Effect (pc:276973-276977): - // state_3 = OK_TS ← force passable - // collision_normal_valid = 0 ← clear stale slide normal - // Note: the BSP branch AND (since the 2026-07-05 CCylSphere family - // port) the Cylinder branch rely on this clause for ethereal - // passability — CylinderCollision's branch 1 returns Collided on - // overlap like retail, and THIS override clears it for non-static - // targets. Only the Sphere branch still early-outs on - // obstruction_ethereal (consume site 1). - if (result != TransitionState.OK - && sp.ObstructionEthereal - && !sp.StepDown - && (obj.State & 0x1u) == 0) // STATIC_PS=0x1 (acclient.h:2815): static ethereal keeps Layer-1 only - { - result = TransitionState.OK; - ci.CollisionNormalValid = false; // collision_normal_valid = 0 (pc:276975) - } - // L.2a slice 3: attribute the collision (if any) to this entity. // Two cases: // - result != OK: the object stopped the transition (hard-block). @@ -2916,11 +2721,9 @@ public sealed class Transition // a BSP hit with null side-channel indicates a BSPQuery code // path that didn't write (a bug; we should fix it, not // pretend the entity was a cylinder). - if (obj.CollisionType == ShadowCollisionType.Cylinder || - obj.CollisionType == ShadowCollisionType.Sphere) + if (obj.CollisionType == ShadowCollisionType.Cylinder) { - sb.Append(System.FormattableString.Invariant( - $"\n hitPoly: n/a ({obj.CollisionType.ToString().ToLowerInvariant()})")); + sb.Append("\n hitPoly: n/a (cylinder)"); } else if (poly is null) { @@ -2945,12 +2748,6 @@ public sealed class Transition Console.WriteLine(sb.ToString()); } - // Task 3 (2026-06-24): clear obstruction_ethereal after the per-object - // shape dispatch. Mirrors retail pc:276989 / 0x0050f31e: - // `ebx->sphere_path.obstruction_ethereal = 0` at the end of the - // per-object test body before the outer loop continues. - sp.ObstructionEthereal = false; - if (result != TransitionState.OK) { if (airborneDiag) @@ -3067,7 +2864,7 @@ public sealed class Transition if (PhysicsDiagnostics.ProbeBuildingEnabled) { Console.WriteLine(System.FormattableString.Invariant( - $"[bldg-channel] cell=0x{cellId:X8} model=0x{building.ModelId:X8} wpos=({sp.GlobalSphere[0].Origin.X:F3},{sp.GlobalSphere[0].Origin.Y:F3},{sp.GlobalSphere[0].Origin.Z:F3}) bldOrigin=({bldOrigin.X:F3},{bldOrigin.Y:F3},{bldOrigin.Z:F3}) hitsInterior={sp.HitsInteriorCell} result={result}")); + $"[bldg-channel] cell=0x{cellId:X8} model=0x{building.ModelId:X8} wpos=({sp.GlobalSphere[0].Origin.X:F3},{sp.GlobalSphere[0].Origin.Y:F3},{sp.GlobalSphere[0].Origin.Z:F3}) hitsInterior={sp.HitsInteriorCell} result={result}")); } // 0x006b5338: non-OK + non-Contact mover → environment attribution. @@ -3078,108 +2875,153 @@ public sealed class Transition } /// - /// Sphere collision test for objects. - /// Uses a true 3-D sphere-sphere overlap test — no height clamp, no XY-only - /// distance — matching retail's CSphere::intersects_sphere @ 0x00537A80. + /// Cylinder collision test for CylSphere objects (tree trunks, rock pillars, NPCs, + /// door foot-colliders). For Contact-grounded movers, attempts to step over short + /// cylinders (retail-faithful CCylSphere::step_sphere_up). For airborne movers, + /// movers already stepping, or cylinders too tall to step over, applies a + /// horizontal wall-slide response. /// /// - /// Implements the subset of the 6-path dispatcher needed for static/placed - /// Sphere objects: static overlap check (obstruction_ethereal / check_walkable / - /// Contact-grounded paths), plus a 3-D outward push-back for the slide response. - /// The swept quadratic from - /// is used for the narrow-phase; the slide response mirrors the cylinder's - /// wall-slide but pushes outward in 3-D (not XY-only). - /// - /// - /// - /// ACE oracle: Sphere.IntersectsSphere in - /// ACE.Server/Physics/Sphere.cs — particularly the - /// ObstructionEthereal/Placement, CheckWalkable, and - /// Contact branches. Retail decomp cross-reference: - /// acclient_2013_pseudo_c.txt:321678. + /// A6.P6 (2026-05-25): the step-over path matches retail's + /// CCylSphere::step_sphere_up at + /// acclient_2013_pseudo_c.txt:324516-324538. The door's foot + /// cylinder (h=0.20m, r=0.10m) is too tall for the static slide to + /// produce smooth sliding along the slab — the radial push-out + /// fires as a "phantom collision" at the door's center when the + /// sphere is touching the slab face and the cyl is just within reach. + /// Retail steps the sphere over the cyl (succeeds when + /// step_up_height >= sphere.radius + cyl.height - offset.z), + /// which lets the sphere walk past the cyl without the radial push. + /// On step-up failure (cyl too tall, no walkable surface beyond), + /// falls back to step_up_slide — the same crease-projection + /// slide the BSP path uses, which produces smoother behavior than + /// the radial push. /// /// - private TransitionState SphereCollision(ShadowEntry obj, SpherePath sp) + private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine) { - // Consume site 1 — CSphere::intersects_sphere @ 0x00537ae4 (pc:321692). - // When obstruction_ethereal is set (target is ETHEREAL-alone, state & 0x4), - // the retail function is void and all paths in the ethereal branch return - // without producing a COLLIDED/Slid result — the player is fully passable. - // We mirror this by returning OK immediately, skipping all blocking paths. - // Retail ref: acclient_2013_pseudo_c.txt:321692. - if (sp.ObstructionEthereal) + var ci = CollisionInfo; + var oi = ObjectInfo; + Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin; + Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin; + float sphRadius = sp.GlobalSphere[0].Radius; + Vector3 sphMovement = sphereCheckPos - sphereCurrPos; + + // Vertical check: does sphere reach the cylinder's height range at all? + float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f; + float checkZ = sphereCheckPos.Z; + if (checkZ - sphRadius > obj.Position.Z + cylTop || + checkZ + sphRadius < obj.Position.Z) return TransitionState.OK; - var ci = CollisionInfo; - Vector3 sphereCurrPos = sp.GlobalCurrCenter[0].Origin; - Vector3 sphereCheckPos = sp.GlobalSphere[0].Origin; - float sphRadius = sp.GlobalSphere[0].Radius; - Vector3 sphMovement = sphereCheckPos - sphereCurrPos; - - // 3-D distance from check position to target sphere centre. - // Unlike CCylSphere (which clips to a height range and uses XY-only - // distance), CSphere uses the full 3-D Euclidean distance. - // Retail anchor: CSphere::intersects_sphere @ 0x00537A80 — - // the displacement vector is the full (x,y,z) delta, not XY-only. - float dx = sphereCheckPos.X - obj.Position.X; - float dy = sphereCheckPos.Y - obj.Position.Y; - float dz = sphereCheckPos.Z - obj.Position.Z; - float distSq = dx * dx + dy * dy + dz * dz; - float combinedR = sphRadius + obj.Radius; + // XY distance from sphere check position to cylinder axis. + float dxCheck = sphereCheckPos.X - obj.Position.X; + float dyCheck = sphereCheckPos.Y - obj.Position.Y; + float distSqCheck = dxCheck * dxCheck + dyCheck * dyCheck; + float combinedR = sphRadius + obj.Radius; float combinedRSq = combinedR * combinedR; - if (distSq >= combinedRSq) + if (distSqCheck >= combinedRSq) return TransitionState.OK; // not overlapping at check position - // ── Overlap detected — compute 3-D outward collision normal ────── - float dist = MathF.Sqrt(distSq); + // ─── Overlap detected ───────────────────────────────────── + // Horizontal outward normal from the cylinder axis to the sphere + // check position. For the degenerate case where the sphere center + // is exactly on the axis, use the movement direction as a fallback + // (pushes the sphere back out along the way it came in). + float distCheck = MathF.Sqrt(distSqCheck); Vector3 collisionNormal; - if (dist < PhysicsGlobals.EPSILON) + if (distCheck < PhysicsGlobals.EPSILON) { - // Sphere centers coincide — push back along reverse movement. - float mLen = sphMovement.Length(); - if (mLen > PhysicsGlobals.EPSILON) - collisionNormal = -sphMovement / mLen; + // Sphere center on cylinder axis — push along reverse movement. + float mxy = MathF.Sqrt(sphMovement.X * sphMovement.X + sphMovement.Y * sphMovement.Y); + if (mxy > PhysicsGlobals.EPSILON) + collisionNormal = new Vector3(-sphMovement.X / mxy, -sphMovement.Y / mxy, 0f); else collisionNormal = Vector3.UnitX; } else { - collisionNormal = new Vector3(dx / dist, dy / dist, dz / dist); + collisionNormal = new Vector3(dxCheck / distCheck, dyCheck / distCheck, 0f); } - // ── Wall-slide response (mirrors CylinderCollision but in 3-D) ─── - // Project movement onto the plane perpendicular to the collision normal, - // then push the slid position outside the combined-radius shell. - float movementIntoWall = Vector3.Dot(sphMovement, collisionNormal); - Vector3 projectedMovement = sphMovement - collisionNormal * movementIntoWall; + // A6.P6 (2026-05-25): retail-faithful CCylSphere::step_sphere_up for + // Contact-grounded movers. acclient_2013_pseudo_c.txt:324516-324538. + // + // Retail check: step_up_height must clear (sphere.radius + cyl.height + // - offset.z) where offset.z is sphere center Z minus cyl low_pt Z. + // Geometrically: the height we need to lift the sphere to clear the + // cyl's top, less the sphere center's current height above the cyl + // base, equals cyl top minus sphere bottom (positive when sphere + // currently below cyl top). + // + // For the door's foot cyl (h=0.20m, sphere radius 0.48m, step_up 0.60m) + // at standing height (offset.z ~0.38m): cyl_clearance = + // 0.48 + 0.20 - 0.38 = 0.30m, step_up_height = 0.60m → step over OK. + if (oi.Contact && !sp.StepUp && !sp.StepDown && engine is not null) + { + float offsetZ = sphereCheckPos.Z - obj.Position.Z; + float cylClearance = sphRadius + cylTop - offsetZ; + + if (oi.StepUpHeight >= cylClearance) + { + // Try step-up over the cyl (DoStepUp probes upward by + // step_up_height, then step-down for walkable surface). + // On success: sphere is repositioned past/over the cyl, + // ContactPlane updated, returns OK. + if (DoStepUp(collisionNormal, engine)) + return TransitionState.OK; + + // Step-up failed — sphere couldn't find a walkable surface + // beyond the cyl (e.g., a wall behind it). Fall back to + // step_up_slide which uses the SlideSphereInternal crease + // projection — smoother than the radial push-out below + // because it follows the contact-plane / cyl-normal crease + // direction. + return sp.StepUpSlide(this); + } + // else: cyl too tall to step over — fall through to radial slide + } + + // ─── Fallback: airborne / non-Contact / cyl-too-tall — wall-slide ─── + + // Wall-slide position (in world space): + // curr = sphereCurrPos (pre-step) + // movement = sphMovement + // projected = movement - (movement · normal) * normal + // slidPos = curr + projected + // Then push outward if still inside the cylinder radius. + Vector3 horizMovement = new Vector3(sphMovement.X, sphMovement.Y, 0f); + float movementIntoWall = Vector3.Dot(horizMovement, collisionNormal); + Vector3 projectedMovement = horizMovement - collisionNormal * movementIntoWall; + // Preserve vertical movement component (jumping/falling). + projectedMovement.Z = sphMovement.Z; Vector3 slidPos = sphereCurrPos + projectedMovement; - // Ensure slid position is outside combined radius (3-D push). + // Ensure slid position is outside the cylinder radius horizontally. float sdx = slidPos.X - obj.Position.X; float sdy = slidPos.Y - obj.Position.Y; - float sdz = slidPos.Z - obj.Position.Z; - float sDistSq = sdx * sdx + sdy * sdy + sdz * sdz; + float sDistSq = sdx * sdx + sdy * sdy; float minDist = combinedR + 0.01f; if (sDistSq < minDist * minDist) { float sDist = MathF.Sqrt(sDistSq); if (sDist < PhysicsGlobals.EPSILON) { + // Degenerate: push out along collisionNormal slidPos.X = obj.Position.X + collisionNormal.X * minDist; slidPos.Y = obj.Position.Y + collisionNormal.Y * minDist; - slidPos.Z = obj.Position.Z + collisionNormal.Z * minDist; } else { - float pushDist = minDist - sDist; + float pushDist = (minDist - sDist); slidPos.X += (sdx / sDist) * pushDist; slidPos.Y += (sdy / sDist) * pushDist; - slidPos.Z += (sdz / sDist) * pushDist; } } + // Apply the offset (difference between slid and current CheckPos) Vector3 delta = slidPos - sphereCheckPos; sp.AddOffsetToCheckPos(delta); @@ -3188,456 +3030,6 @@ public sealed class Transition return TransitionState.Slid; } - /// - /// Retail CCylSphere::intersects_sphere dispatcher — verbatim port - /// of 0x0053b440 (acclient_2013_pseudo_c.txt:324558). Full family: - /// collides_with_sphere 0x0053a880, normal_of_collision - /// 0x0053ab50, collide_with_point 0x0053acb0, slide_sphere - /// 0x0053b2a0, step_sphere_up 0x0053b310, land_on_cylinder - /// 0x0053b3d0, step_sphere_down 0x0053a9b0. Pseudocode + settled - /// BN ambiguities + ACE cross-reference notes: - /// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md. - /// - /// - /// Replaces the pre-2026-07-05 hand-rolled approximation (A6.P6 step-up - /// gate + radial wall-slide) which had NO cylinder-TOP support: a - /// grounded mover stepping up onto a WIDE cylinder (the Holtburg - /// town-network portal platform, Setup 0x020019E3, r=2.597 m h=0.256 m) - /// could never validate a landing — the step-up's internal step-down - /// probe needs branch 2 (step_sphere_down → contact plane ON the flat - /// top) — so DoStepUp failed into StepUpSlide and the player orbited the - /// rim forever. Airborne landings on tops (land_on_cylinder + the - /// collide-flag exact-TOI branch) were likewise missing. - /// - /// - /// - /// The already carries the wrapper overload's - /// (0x0053b8f0) work: Position = globalized low_pt (entity frame applied - /// at registration), Radius/CylHeight pre-scaled; the cylinder axis stays - /// world-Z. Ethereal targets: branch 1 returns Collided on overlap and - /// the caller's Layer-2 override (pc:276961-276989) clears it for - /// non-static targets — the retail passability mechanism (#150); the - /// step-down pass never reaches here for ethereal targets (pc:276799 - /// skip). - /// - /// - private TransitionState CylinderCollision(ShadowEntry obj, SpherePath sp, PhysicsEngine engine) - { - // Degenerate dat heights: registration sites apply the same fallback; - // kept for entries registered before it (pre-dates this port). - float cylHeight = obj.CylHeight > 0f ? obj.CylHeight : obj.Radius * 4f; - - var s0 = sp.GlobalSphere[0]; - Vector3 disp0 = s0.Origin - obj.Position; - // radsum: ε shaved ONCE in the dispatcher preamble (0x0053b48e) — - // this is what lets a sphere REST exactly on the top without - // re-colliding every frame. - float radsum = obj.Radius - PhysicsGlobals.EPSILON + s0.Radius; - - bool hasHead = sp.NumSphere > 1; - Vector3 disp1 = default; - float headRadius = 0f; - if (hasHead) - { - disp1 = sp.GlobalSphere[1].Origin - obj.Position; - headRadius = sp.GlobalSphere[1].Radius; - } - - // ── Branch 1 (0x0053b4a0): placement / ethereal — detection only. ── - if (sp.InsertType == InsertType.Placement || sp.ObstructionEthereal) - { - if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius)) - return TransitionState.Collided; - if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius)) - return TransitionState.Collided; - return TransitionState.OK; - } - - // ── Branch 2 (0x0053b4c0): step-down probe — land on the top. ── - if (sp.StepDown) - return CylStepSphereDown(obj, sp, cylHeight, disp0, radsum); - - // ── Branch 3 (0x0053b4d7): walkable probe — occupancy blocks. ── - if (sp.CheckWalkable) - { - if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius)) - return TransitionState.Collided; - if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius)) - return TransitionState.Collided; - return TransitionState.OK; - } - - var oi = ObjectInfo; - - if (!sp.Collide) - { - // ── Branch 4 (0x0053b701): normal transition sweep. ── - if ((oi.State & (ObjectInfoState.Contact | ObjectInfoState.OnWalkable)) != 0) - { - // Grounded mover: foot hit → step over / onto; head hit → slide. - if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius)) - return CylStepSphereUp(obj, sp, engine, cylHeight, disp0, radsum); - if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius)) - // Retail 0x0053b843 passes the HEAD disp (its x_2); ACE's - // port passes the foot disp here — retail wins (pseudocode - // doc §8.2). - return CylSlideSphere(obj, sp, cylHeight, disp1, radsum, 1); - } - else if ((oi.State & ObjectInfoState.PathClipped) != 0) - { - if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius)) - return CylCollideWithPoint(obj, sp, cylHeight, s0, disp0, radsum, 0); - } - else - { - // Airborne: foot hit → land on the top; head hit → point hit. - if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius)) - return CylLandOnCylinder(obj, sp, cylHeight, disp0, radsum); - if (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius)) - return CylCollideWithPoint(obj, sp, cylHeight, sp.GlobalSphere[1], disp1, radsum, 1); - } - return TransitionState.OK; - } - - // ── Branch 5 (0x0053b525): collide-flag re-test — exact-TOI cap landing. ── - // Runs on the attempt after land_on_cylinder set sp.Collide: rewinds - // the sphere along the REVERSE movement to rest exactly on the top, - // sets the contact plane, and consumes walk_interp. - if (CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius) - || (hasHead && CylCollidesWithSphere(disp1, radsum, cylHeight, headRadius))) - { - // movement = curr − check. Retail subtracts the cur→check - // landblock offset (get_curr_pos_check_pos_block_offset); our - // physics frame is continuous world-space → zero (same standing - // adaptation as SlideSphere's gDelta). - Vector3 movement = sp.GlobalCurrCenter[0].Origin - s0.Origin; - if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON) - return TransitionState.Collided; - - float timecheck = (cylHeight + s0.Radius - disp0.Z) / movement.Z; - Vector3 offset = movement * timecheck; - - Vector3 sum = offset + disp0; - if (radsum * radsum < sum.X * sum.X + sum.Y * sum.Y) - return TransitionState.OK; // rewound point is off the cap — not a top landing - - float t = (1f - timecheck) * sp.WalkInterp; - if (t >= sp.WalkInterp || t < -0.1f) - return TransitionState.Collided; - - Vector3 pt = s0.Origin + offset; - pt.Z -= s0.Radius; - // is_water=1 is verbatim retail (0x0053b6b9 → set_contact_plane - // arg3 = contact_plane_is_water, 0x00509db1). Do not "fix". - var contactPlane = new Plane(Vector3.UnitZ, -pt.Z); - CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true); - sp.WalkInterp = t; - sp.AddOffsetToCheckPos(offset); - return TransitionState.Adjusted; - } - - return TransitionState.OK; - } - - /// - /// Retail CCylSphere::collides_with_sphere (0x0053a880, - /// pc:323943): XY overlap against radsum, then Z-band overlap against the - /// cylinder's [0, height] extent. = sphere center - /// − cylinder base point. - /// - private static bool CylCollidesWithSphere(Vector3 disp, float radsum, float cylHeight, float sphereRadius) - { - if (disp.X * disp.X + disp.Y * disp.Y <= radsum * radsum) - { - float halfH = cylHeight * 0.5f; - if (sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - disp.Z)) - return true; - } - return false; - } - - /// - /// Retail CCylSphere::normal_of_collision (0x0053ab50, pc:324102). - /// Discriminates on where the sphere was at the START of the step - /// (GlobalCurrCenter): XY-outside the combined radius → radial side - /// normal; XY-inside the footprint → cap normal (descending → top +Z, - /// ascending → underside −Z; polarity settled by ACE + geometry — BN's - /// x87 branch rendering is untrustworthy here). Returns "definite": - /// false when a radial contact could actually be a diagonal cap hit - /// (curr was outside the Z band AND there is vertical movement) — - /// consumed only by . - /// - private bool CylNormalOfCollision(ShadowEntry obj, SpherePath sp, float cylHeight, - Vector3 dispCheck, float radsum, float sphereRadius, int sphereNum, out Vector3 normal) - { - Vector3 dispCurr = sp.GlobalCurrCenter[sphereNum].Origin - obj.Position; - if (radsum * radsum < dispCurr.X * dispCurr.X + dispCurr.Y * dispCurr.Y) - { - normal = new Vector3(dispCurr.X, dispCurr.Y, 0f); - float halfH = cylHeight * 0.5f; - bool zBandOverlapAtCurr = - sphereRadius - PhysicsGlobals.EPSILON + halfH >= MathF.Abs(halfH - dispCurr.Z); - bool noZMovement = MathF.Abs(dispCurr.Z - dispCheck.Z) <= PhysicsGlobals.EPSILON; - return zBandOverlapAtCurr || noZMovement; - } - normal = new Vector3(0f, 0f, dispCheck.Z - dispCurr.Z <= 0f ? 1f : -1f); - return true; - } - - /// - /// Retail AC1Legacy::Vector3::normalize_check_small: returns true - /// (degenerate — caller yields Collided) when |v| < F_EPSILON, else - /// normalizes in place. - /// - private static bool NormalizeCheckSmall(ref Vector3 v) - { - float mag = v.Length(); - if (mag < PhysicsGlobals.EPSILON) - return true; - v /= mag; - return false; - } - - /// - /// Retail CCylSphere::step_sphere_up (0x0053b310, pc:324516). - /// Too-tall cylinders slide; otherwise the generic step-up - /// ( = CTransition::step_up) runs — its internal - /// step-down probe lands on the cylinder top via - /// (branch 2), which is what makes - /// stepping ONTO a wide cylinder possible. - /// - private TransitionState CylStepSphereUp(ShadowEntry obj, SpherePath sp, PhysicsEngine engine, - float cylHeight, Vector3 disp0, float radsum) - { - var s0 = sp.GlobalSphere[0]; - - // step_up_height must clear (sphere.radius + height − disp.z) — the - // lift needed so the sphere bottom rests on the top (0x0053b323). - if (ObjectInfo.StepUpHeight < s0.Radius + cylHeight - disp0.Z) - return CylSlideSphere(obj, sp, cylHeight, disp0, radsum, 0); - - // Retail computes the normal and ignores the definite flag here. - CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum, s0.Radius, 0, out var n); - if (NormalizeCheckSmall(ref n)) - return TransitionState.Collided; - - // Retail rotates the normal by the target OBJECT's frame - // (localtoglobalvec via the wrapper's cached localspace_pos, - // 0x0053b38d) before CTransition::step_up. Yaw-only AC frames leave - // vertical normals unchanged; radial normals pick up the yaw. - var nWorld = Vector3.Transform(n, obj.Rotation); - - // engine==null only in bare unit-test transitions — no step-up - // machinery available; the retail-faithful fallback is the slide. - if (engine is not null && DoStepUp(nWorld, engine)) - return TransitionState.OK; - - return sp.StepUpSlide(this); - } - - /// - /// Retail CCylSphere::step_sphere_down (0x0053a9b0, pc:324032): - /// during a step-down probe, land the foot sphere ON the cylinder's flat - /// top — contact plane (0,0,1) through the top, walk_interp consumed, - /// CheckPos lifted so the sphere bottom rests exactly on it. THE piece - /// whose absence made every step-up onto a wide cylinder fail (the - /// portal-platform rim orbit). - /// - private TransitionState CylStepSphereDown(ShadowEntry obj, SpherePath sp, - float cylHeight, Vector3 disp0, float radsum) - { - var s0 = sp.GlobalSphere[0]; - - bool hit = CylCollidesWithSphere(disp0, radsum, cylHeight, s0.Radius); - if (!hit && sp.NumSphere > 1) - { - Vector3 disp1 = sp.GlobalSphere[1].Origin - obj.Position; - hit = CylCollidesWithSphere(disp1, radsum, cylHeight, sp.GlobalSphere[1].Radius); - } - if (!hit) - return TransitionState.OK; - - float stepScale = sp.StepDownAmt * sp.WalkInterp; - if (MathF.Abs(stepScale) < PhysicsGlobals.EPSILON) - return TransitionState.Collided; - - // Lift so the foot sphere's bottom rests on the top disc. The - // (1 − deltaZ/stepScale) divisor is stepScale — BN garbled it; - // settled via ACE CylSphere.StepSphereDown (pseudocode doc §4). - float deltaZ = cylHeight + s0.Radius - disp0.Z; - float interp = (1f - deltaZ / stepScale) * sp.WalkInterp; - if (interp >= sp.WalkInterp || interp < -0.1f) - return TransitionState.Collided; - - float topZ = s0.Origin.Z + deltaZ - s0.Radius; - // is_water=1 verbatim retail (0x0053aae2). Do not "fix". - var contactPlane = new Plane(Vector3.UnitZ, -topZ); - CollisionInfo.SetContactPlane(contactPlane, sp.CheckCellId, isWater: true); - sp.WalkInterp = interp; - sp.AddOffsetToCheckPos(new Vector3(0f, 0f, deltaZ)); - return TransitionState.Adjusted; - } - - /// - /// Retail CCylSphere::slide_sphere (0x0053b2a0, pc:324502): - /// normal_of_collision → CSphere::slide_sphere (our - /// ) with the sliding sphere's own curr center. - /// - private TransitionState CylSlideSphere(ShadowEntry obj, SpherePath sp, - float cylHeight, Vector3 disp, float radsum, int sphereNum) - { - CylNormalOfCollision(obj, sp, cylHeight, disp, radsum, - sp.GlobalSphere[sphereNum].Radius, sphereNum, out var n); - if (NormalizeCheckSmall(ref n)) - return TransitionState.Collided; - - return SlideSphere(n, sp.GlobalCurrCenter[sphereNum].Origin, sphereNum); - } - - /// - /// Retail CCylSphere::land_on_cylinder (0x0053b3d0, pc:324542): - /// airborne foot hit — arm the Collide re-test (backup + flag) and relax - /// the walkable allowance to LandingZ. The NEXT attempt's branch 5 then - /// rests the sphere on the top with the exact time-of-impact. - /// - private TransitionState CylLandOnCylinder(ShadowEntry obj, SpherePath sp, - float cylHeight, Vector3 disp0, float radsum) - { - CylNormalOfCollision(obj, sp, cylHeight, disp0, radsum, - sp.GlobalSphere[0].Radius, 0, out var n); - if (NormalizeCheckSmall(ref n)) - return TransitionState.Collided; - - sp.SetCollide(n); - sp.WalkableAllowance = PhysicsGlobals.LandingZ; // 0.0871557 (0x0053b41f) - return TransitionState.Adjusted; - } - - /// - /// Retail CCylSphere::collide_with_point (0x0053acb0, pc:324173): - /// PathClipped movers + airborne head-sphere hits. Non-PerfectClip movers - /// record the collision normal and hard-stop; PerfectClip movers get the - /// exact time-of-impact reposition. TOI sub-branches ported per ACE - /// CylSphere.CollideWithPoint (BN mush too heavy in 0x0053adb6+); no - /// PerfectClip mover exists in M1.5 (players never set it), so only the - /// Collided path is load-bearing today — revisit against Ghidra if - /// missiles ever arm PerfectClip (pseudocode doc §7). - /// - private TransitionState CylCollideWithPoint(ShadowEntry obj, SpherePath sp, - float cylHeight, Sphere checkSphere, Vector3 disp, float radsum, int sphereNum) - { - bool definite = CylNormalOfCollision(obj, sp, cylHeight, disp, radsum, - checkSphere.Radius, sphereNum, out var n); - if (NormalizeCheckSmall(ref n)) - return TransitionState.Collided; - - if (!ObjectInfo.State.HasFlag(ObjectInfoState.PerfectClip)) - { - CollisionInfo.SetCollisionNormal(n); - return TransitionState.Collided; - } - - // Retail reads global_curr_center[0] even for the head hit - // (0x0053ad26; ACE agrees) — verbatim. - Vector3 globCenter = sp.GlobalCurrCenter[0].Origin; - // Block offset = 0 (continuous world frame; see branch 5 note). - Vector3 movement = checkSphere.Origin - globCenter; - Vector3 oldDisp = globCenter - obj.Position; - float radsumEps = radsum + PhysicsGlobals.EPSILON; - - float xyMoveLenSq = movement.X * movement.X + movement.Y * movement.Y; - float dot2d = movement.X * oldDisp.X + movement.Y * oldDisp.Y; - float xyDiff = -dot2d; - float oldDispXYSq = oldDisp.X * oldDisp.X + oldDisp.Y * oldDisp.Y; - float diffSq = xyDiff * xyDiff - (oldDispXYSq - radsumEps * radsumEps) * xyMoveLenSq; - - float time; - Vector3 scaledMovement; - - if (!definite) - { - if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON) - return TransitionState.Collided; - if (movement.Z > 0f) - { - n = new Vector3(0f, 0f, -1f); - time = (movement.Z + checkSphere.Radius) / movement.Z * -1f; - } - else - { - n = new Vector3(0f, 0f, 1f); - time = (checkSphere.Radius + cylHeight - movement.Z) / movement.Z; - } - scaledMovement = movement * time; - - Vector3 landed = scaledMovement + oldDisp; - if (landed.X * landed.X + landed.Y * landed.Y >= radsumEps * radsumEps) - { - if (MathF.Abs(xyMoveLenSq) < PhysicsGlobals.EPSILON) - return TransitionState.Collided; - if (diffSq >= 0f && xyMoveLenSq > PhysicsGlobals.EPSILON) - { - float diff = MathF.Sqrt(diffSq); - time = xyDiff - diff < 0f - ? (diff - dot2d) / xyMoveLenSq - : (xyDiff - diff) / xyMoveLenSq; - scaledMovement = movement * time; - } - n = (scaledMovement + globCenter - obj.Position) / radsumEps; - n.Z = 0f; - } - - if (time < 0f || time > 1f) - return TransitionState.Collided; - - Vector3 offsetOut = globCenter - scaledMovement - checkSphere.Origin; - sp.AddOffsetToCheckPos(offsetOut); - CollisionInfo.SetCollisionNormal(n); - return TransitionState.Adjusted; - } - - if (n.Z != 0f) - { - if (MathF.Abs(movement.Z) < PhysicsGlobals.EPSILON) - return TransitionState.Collided; - - time = movement.Z > 0f - ? -((oldDisp.Z + checkSphere.Radius) / movement.Z) - : (checkSphere.Radius + cylHeight - oldDisp.Z) / movement.Z; - scaledMovement = movement * time; - - if (time < 0f || time > 1f) - return TransitionState.Collided; - - Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin; - sp.AddOffsetToCheckPos(offsetOut); - CollisionInfo.SetCollisionNormal(n); - return TransitionState.Adjusted; - } - - if (diffSq < 0f || xyMoveLenSq < PhysicsGlobals.EPSILON) - return TransitionState.Collided; - - { - float diff = MathF.Sqrt(diffSq); - time = xyDiff - diff < 0f - ? (diff - dot2d) / xyMoveLenSq - : (xyDiff - diff) / xyMoveLenSq; - scaledMovement = movement * time; - - if (time < 0f || time > 1f) - return TransitionState.Collided; - - n = (scaledMovement + globCenter - obj.Position) / radsumEps; - n.Z = 0f; - - Vector3 offsetOut = globCenter + scaledMovement - checkSphere.Origin; - sp.AddOffsetToCheckPos(offsetOut); - CollisionInfo.SetCollisionNormal(n); - return TransitionState.Adjusted; - } - } - // ----------------------------------------------------------------------- // SlideSphere — wall slide projection // ----------------------------------------------------------------------- @@ -3659,11 +3051,7 @@ public sealed class Transition internal TransitionState SlideSphereInternal(Vector3 collisionNormal, Vector3 currPos) => SlideSphere(collisionNormal, currPos); - /// Which path sphere is sliding (retail - /// CSphere::slide_sphere's this is the sphere instance — the head - /// sphere slides by its OWN displacement, 0x0053b843 passes - /// global_sphere[1]). Default 0 preserves every existing call site. - private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos, int sphereNum = 0) + private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos) { var sp = SpherePath; var ci = CollisionInfo; @@ -3671,7 +3059,7 @@ public sealed class Transition // Degenerate case: zero collision normal — nudge halfway. if (collisionNormal.LengthSquared() < PhysicsGlobals.EpsilonSq) { - Vector3 halfOffset = (currPos - sp.GlobalSphere[sphereNum].Origin) * 0.5f; + Vector3 halfOffset = (currPos - sp.GlobalSphere[0].Origin) * 0.5f; sp.AddOffsetToCheckPos(halfOffset); return TransitionState.Adjusted; } @@ -3681,7 +3069,7 @@ public sealed class Transition // gDelta: displacement from currPos to the current check sphere center. // In the retail code this includes a block offset for cross-landblock // transitions; for outdoor single-landblock movement this is zero. - Vector3 gDelta = sp.GlobalSphere[sphereNum].Origin - currPos; + Vector3 gDelta = sp.GlobalSphere[0].Origin - currPos; // Get the contact plane (prefer current, fall back to last known). System.Numerics.Plane contactPlane; @@ -3745,25 +3133,15 @@ public sealed class Transition return TransitionState.Slid; } - // Opposing normals (collision normal anti-parallel to the contact - // plane, e.g. a ceiling-facing normal while grounded): record the - // REVERSED displacement as the collision normal and return COLLIDED. - // Retail CSphere::slide_sphere 0x00537440 @0x005375d7-0x0053762c: - // `*normal = -gDelta; normalize_check_small; set_collision_normal; - // return 2 (COLLIDED_TS)`. #137 (2026-07-06): this previously - // returned OK ("to allow retry with the reversed normal" — a decomp - // misread), which let the step complete as-is carrying a SYNTHETIC - // reversed-movement collision normal — the live corridor hit's - // `n=(-1.00,0.03,-0.03)` (= the negated run direction) matched no - // dat polygon; validate's epilogue then turned it into a persisted - // sliding normal and wedged all forward motion. + // Opposing normals: give up, reverse direction. + // Retail returns OK here to allow retry with the reversed normal. Vector3 reversed = -gDelta; if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq) { reversed = Vector3.Normalize(reversed); ci.SetCollisionNormal(reversed); } - return TransitionState.Collided; + return TransitionState.OK; } // ----------------------------------------------------------------------- diff --git a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs index 7142e61e..ba081f71 100644 --- a/src/AcDream.Core/Rendering/RenderingDiagnostics.cs +++ b/src/AcDream.Core/Rendering/RenderingDiagnostics.cs @@ -243,61 +243,6 @@ public static class RenderingDiagnostics public static bool ProbePhantomEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_PHANTOM") == "1"; - /// - /// #133 A7 (2026-06-13) dungeon-lighting objective probe. When true, - /// the per-frame scene-lighting build emits ONE [light] line - /// roughly every second (wall-clock rate-limited like WB-DIAG) via - /// : - /// - /// [light] insideCell=<bool> ambient=(r,g,b) sun=<intensity> - /// registeredLights=<N> activeLights=<uCellAmbient.w> playerCell=0x<id> - /// - /// This is the self-verification signal for the dungeon-dim question: - /// - /// insideCell=true ambient=(0.20,0.20,0.20) sun=0 - /// confirms the indoor branch fired (retail flat ambient, sun killed). - /// registeredLights is the count of dat-baked - /// point/spot lights (Setup.Lights) registered with the - /// LightManager — if this is 0 in a dungeon, the cell's static - /// objects carry no baked torches (so the only illumination IS the - /// 0.2 ambient → dim). - /// activeLights is uCellAmbient.w — the - /// shader's active-slot count, which INCLUDES the (zeroed) sun slot - /// indoors. So activeLights=1 registeredLights=0 = "only the dead - /// sun slot, no torches in range". - /// - /// Output-only, inert when off. Initial state from ACDREAM_PROBE_LIGHT=1. - /// - public static bool ProbeLightEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_LIGHT") == "1"; - - /// - /// A7.L1 (2026-07-06) per-cell light SET-COMPOSITION probe — the apparatus the - /// [light] counts could not provide (the #176/#177 discriminator: the bug - /// lived in set MEMBERSHIP, not counts). When true, the scoped - /// LightManager.BuildPointLightSnapshot emits ONE rate-limited - /// [indoor-light] line describing the visible-cell-scoped point-light pool - /// (see ): - /// - /// [indoor-light] visibleCells=<N> pool=<M> cellLess=<K> registered=<R> - /// droppedNonVisible=<R-M> byCell=[0x<id>:<count>,...] - /// - /// This validates the A7 fix's load-bearing assumption end-to-end: - /// - /// cellLess==pool (every pool light is CellId 0) ⇒ - /// cell tagging FAILED (ParentCellId not flowing) — scoping is a silent no-op. - /// pool==cellLess while registered is large in a - /// LIT room ⇒ tagged CellIds never match the visible set (wrong id form) — the - /// room would go dark. - /// droppedNonVisible>0 with byCell tracking the - /// visible rooms ⇒ scoping WORKING (the under-room/through-floor lights are the - /// dropped ones). - /// - /// Output-only, inert when off. Initial state from ACDREAM_PROBE_INDOOR_LIGHT=1. - /// - public static bool ProbeIndoorLightEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LIGHT") == "1"; - // Cell-change gate for EmitVis. The probe fires once per distinct root cell // so launch.log stays readable under motion (the per-frame call is a no-op // when the root is unchanged). Sentinel 0 = "no root yet" — the first real @@ -391,153 +336,6 @@ public static class RenderingDiagnostics /// internal static void ResetVisibilityProbeForTests() => _lastVisRootCellId = 0; - // Wall-clock rate-limit gate for EmitLight. Ticks (100 ns) is plenty — - // we only need ~1 Hz and avoid a Stopwatch allocation/field. Sentinel 0 - // = "never emitted" so the first call always fires. - private static long _lastLightEmitTicks; - private const long LightEmitIntervalTicks = 10_000_000; // 1 s in 100-ns ticks - - /// - /// #133 A7 — emit ONE rate-limited [light] line describing the - /// current scene-lighting state, followed (when - /// is supplied) by up to three [light-detail] lines for the nearest - /// ACTIVE point/spot lights. Cheap no-op when - /// is false; otherwise fires at most - /// once per second. Pull the values from the spot where - /// GameWindow.UpdateSunFromSky set Lighting.CurrentAmbient - /// / Lighting.Sun and where SceneLightingUbo.Build computed - /// the active-slot count. - /// - /// The [light-detail] lines are the answer to the "candle-spotlight" - /// question — they expose each torch's REAL dat-derived runtime values - /// (range= Falloff metres, intensity=, cone= radians, - /// color=, distToViewer=) so it is visible in launch.log - /// whether dungeon torches are tiny-range points or wide cones and at what - /// intensity — without a screenshot: - /// - /// [light-detail] kind=Point range=<Falloff m> intensity=<I> cone=<rad> color=(r,g,b) distToViewer=<m> - /// - /// - /// - /// The playerInsideCell value driving the indoor branch. - /// Cell ambient red (xyz of uCellAmbient). - /// Cell ambient green. - /// Cell ambient blue. - /// The sun LightSource.Intensity (0 indoors). - /// Total point/spot lights registered with the LightManager. - /// uCellAmbient.w — shader active-slot count (includes the zeroed sun slot indoors). - /// The player's current cell id (0 if unresolved → outside). - /// The ticked LightManager (its Active list, sorted nearest-first by the - /// just-completed Tick). When non-null, drives the [light-detail] lines. Optional so existing call - /// sites / tests that only want the aggregate line keep compiling. - public static void EmitLight(bool insideCell, - float ambientR, float ambientG, float ambientB, - float sunIntensity, - int registeredLights, - int activeLights, - uint playerCellId, - AcDream.Core.Lighting.LightManager? lights = null) - { - if (!ProbeLightEnabled) return; - - long now = DateTime.UtcNow.Ticks; - if (_lastLightEmitTicks != 0 && (now - _lastLightEmitTicks) < LightEmitIntervalTicks) - return; - _lastLightEmitTicks = now; - - var ci = System.Globalization.CultureInfo.InvariantCulture; - Console.WriteLine(string.Format(ci, - "[light] insideCell={0} ambient=({1:0.###},{2:0.###},{3:0.###}) sun={4:0.###} registeredLights={5} activeLights={6} playerCell=0x{7:X8}", - insideCell, ambientR, ambientG, ambientB, sunIntensity, - registeredLights, activeLights, playerCellId)); - - // #133 A7 (2026-06-13) — per-light detail for the "spotlight bubble" - // question. Dump the actual runtime dat-derived values of the nearest - // ~3 ACTIVE point/spot lights so the real Falloff/Intensity/ConeAngle - // are visible in launch.log (are torch ranges 1m or 10m? points or - // spots? what intensity?). The sun (Directional, slot 0) is skipped — - // it carries no Range/cone meaning. DistSq is already cached by - // LightManager.Tick this frame, so the active list is sorted nearest- - // first; we just take the first few non-directional entries. - if (lights is null) return; - var active = lights.Active; - int shown = 0; - const int MaxDetail = 3; - for (int i = 0; i < active.Length && shown < MaxDetail; i++) - { - var ls = active[i]; - if (ls is null) continue; - if (ls.Kind == AcDream.Core.Lighting.LightKind.Directional) continue; - - float dist = ls.DistSq >= 0f ? MathF.Sqrt(ls.DistSq) : 0f; - Console.WriteLine(string.Format(ci, - "[light-detail] kind={0} range={1:0.###} intensity={2:0.###} cone={3:0.####} color=({4:0.###},{5:0.###},{6:0.###}) distToViewer={7:0.###}", - ls.Kind, ls.Range, ls.Intensity, ls.ConeAngle, - ls.ColorLinear.X, ls.ColorLinear.Y, ls.ColorLinear.Z, dist)); - shown++; - } - } - - // Wall-clock rate-limit gate for EmitIndoorLight (shares the 1 s interval). - private static long _lastIndoorLightEmitTicks; - - /// - /// A7.L1 — emit ONE rate-limited [indoor-light] line describing the - /// visible-cell-scoped point-light pool: the SET COMPOSITION the [light] - /// counts can't show. Cheap no-op when is - /// false; otherwise fires at most once per second. Called from the scoped - /// LightManager.BuildPointLightSnapshot (visibleCells != null path). - /// - /// Size of the portal-flood visible-cell set this frame. - /// Every registered light (LightManager._all). - /// The visible-cell-scoped point-light pool just built. - public static void EmitIndoorLight(int visibleCellCount, - IReadOnlyList allRegistered, - IReadOnlyList scopedSnapshot) - { - if (!ProbeIndoorLightEnabled) return; - - long now = DateTime.UtcNow.Ticks; - if (_lastIndoorLightEmitTicks != 0 && (now - _lastIndoorLightEmitTicks) < LightEmitIntervalTicks) - return; - _lastIndoorLightEmitTicks = now; - - int registeredLitPoints = 0; - foreach (var l in allRegistered) - if (l.IsLit && l.Kind != AcDream.Core.Lighting.LightKind.Directional) registeredLitPoints++; - - int pool = scopedSnapshot.Count; - int cellLess = 0; - var hist = new Dictionary(); - foreach (var l in scopedSnapshot) - { - if (l.CellId == 0) cellLess++; - hist.TryGetValue(l.CellId, out var c); - hist[l.CellId] = c + 1; - } - - var sb = new StringBuilder(220); - sb.Append("[indoor-light] visibleCells=").Append(visibleCellCount); - sb.Append(" pool=").Append(pool); - sb.Append(" cellLess=").Append(cellLess); - sb.Append(" registered=").Append(registeredLitPoints); - // Lights excluded by visibility scoping (retail: cells not in visible_cell_table - // contribute nothing) — the through-floor/under-room lights kept out of the pool. - sb.Append(" droppedNonVisible=").Append(registeredLitPoints - pool); - sb.Append(" byCell=["); - const int MaxCells = 12; - int shown = 0; - foreach (var kv in hist) - { - if (shown >= MaxCells) { sb.Append(",..."); break; } - if (shown > 0) sb.Append(','); - sb.Append("0x").Append(kv.Key.ToString("X8")).Append(':').Append(kv.Value); - shown++; - } - sb.Append(']'); - Console.WriteLine(sb.ToString()); - } - private static bool _probeEnvCellEnabled = Environment.GetEnvironmentVariable("ACDREAM_PROBE_ENVCELL") == "1"; @@ -654,21 +452,4 @@ public static class RenderingDiagnostics /// public static bool ShouldRenderIndoor(uint playerCellId, bool renderRootResolved) => renderRootResolved && IsEnvCellId(playerCellId); - - /// - /// MP0 (2026-07-05) — master toggle for the permanent frame profiler - /// (AcDream.App.Diagnostics.FrameProfiler): CPU frame time - /// (swap-to-swap), whole-frame GPU time, per-stage CPU attribution, - /// per-frame allocation counters, reported as one [frame-prof] - /// line every ~5 s. Permanent apparatus (every MP-track gate reads it) — - /// do NOT strip with session probes. The whole-frame GPU query is - /// self-disabled while ACDREAM_WB_DIAG=1 (GL forbids nested - /// TimeElapsed queries; WbDrawDispatcher owns per-pass queries under - /// that flag — the 2026-06-23 "separate flags" measurement lesson). - /// Initial state from ACDREAM_FRAME_PROF=1; runtime-toggleable - /// via the DebugPanel mirror (DebugVM.FrameProf). - /// Spec: docs/superpowers/specs/2026-07-05-modern-pipeline-design.md §5. - /// - public static bool FrameProfEnabled { get; set; } = - Environment.GetEnvironmentVariable("ACDREAM_FRAME_PROF") == "1"; } diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index f727a59c..49cfe199 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -80,11 +80,6 @@ public static class SurfaceDecoder /// public static DecodedTexture DecodeSolidColor(DatReaderWriter.Types.ColorARGB color, float translucency) { - // Malformed Base1Solid (or OrigTextureId==0) surface with no color value: - // signal undecodable (Magenta) instead of NRE. This method is called - // directly from TextureCache.DecodeFromDats, OUTSIDE DecodeRenderSurface's - // try/catch, so it must be null-safe itself. - if (color is null) return DecodedTexture.Magenta; float opacity = Math.Clamp(1f - translucency, 0f, 1f); byte alpha = (byte)Math.Clamp(color.Alpha * opacity, 0f, 255f); return new DecodedTexture( diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs index 40090d92..53c5d700 100644 --- a/src/AcDream.Core/Vfx/ParticleSystem.cs +++ b/src/AcDream.Core/Vfx/ParticleSystem.cs @@ -158,104 +158,17 @@ public sealed class ParticleSystem : IParticleSystem } } - /// - /// Enumerate every live particle across every active emitter as - /// (emitter, particle-index) pairs, in emitter-spawn order. - /// - /// - /// MP-Alloc (2026-07-05): this used to be a C# iterator block (a - /// compiler-generated heap-allocated state machine, `yield return`), - /// allocated fresh on every call. - /// calls this once per pass and there are up to ~11 passes per frame - /// (sky pre/post, scene, per-visible-cell, dynamics, unattached), so - /// this was 11 iterator allocations per frame even with zero particles - /// on screen. Returns a struct - /// instead: `foreach` over it uses the struct enumerator directly (no - /// allocation), while LINQ / test callers that need - /// (`.ToList()`, `.Single()`, etc.) still - /// work via the explicit interface implementation — those call sites - /// are test-only, not the per-frame render path this task targets. - /// - /// - public LiveParticleEnumerable EnumerateLive() => new(this); - - /// - /// Struct enumerable returned by . Wraps the - /// owning so foreach gets a - /// zero-allocation struct enumerator; falls back to a boxed iterator - /// only when consumed through the surface - /// (LINQ, test helpers). - /// - public readonly struct LiveParticleEnumerable : IEnumerable<(ParticleEmitter Emitter, int Index)> + public IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLive() { - private readonly ParticleSystem _owner; - internal LiveParticleEnumerable(ParticleSystem owner) => _owner = owner; - - public Enumerator GetEnumerator() => new(_owner); - - IEnumerator<(ParticleEmitter Emitter, int Index)> IEnumerable<(ParticleEmitter Emitter, int Index)>.GetEnumerator() - => EnumerateLiveBoxed(_owner).GetEnumerator(); - - System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() - => EnumerateLiveBoxed(_owner).GetEnumerator(); - - private static IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLiveBoxed(ParticleSystem owner) + foreach (var handle in _handleOrder) { - foreach (var handle in owner._handleOrder) + if (!_byHandle.TryGetValue(handle, out var em)) + continue; + + for (int i = 0; i < em.Particles.Length; i++) { - if (!owner._byHandle.TryGetValue(handle, out var em)) - continue; - - for (int i = 0; i < em.Particles.Length; i++) - { - if (em.Particles[i].Alive) - yield return (em, i); - } - } - } - - /// Zero-allocation struct enumerator for the `foreach` fast path. - public struct Enumerator - { - private readonly ParticleSystem _owner; - private int _handleIdx; - private ParticleEmitter? _currentEmitter; - private int _particleIdx; - - internal Enumerator(ParticleSystem owner) - { - _owner = owner; - _handleIdx = -1; - _currentEmitter = null; - _particleIdx = -1; - } - - public (ParticleEmitter Emitter, int Index) Current => (_currentEmitter!, _particleIdx); - - public bool MoveNext() - { - while (true) - { - if (_currentEmitter is not null) - { - for (_particleIdx++; _particleIdx < _currentEmitter.Particles.Length; _particleIdx++) - { - if (_currentEmitter.Particles[_particleIdx].Alive) - return true; - } - _currentEmitter = null; - } - - _handleIdx++; - if (_handleIdx >= _owner._handleOrder.Count) - return false; - - if (!_owner._byHandle.TryGetValue(_owner._handleOrder[_handleIdx], out var em)) - continue; - - _currentEmitter = em; - _particleIdx = -1; - } + if (em.Particles[i].Alive) + yield return (em, i); } } } diff --git a/src/AcDream.Core/World/Cells/CellGraph.cs b/src/AcDream.Core/World/Cells/CellGraph.cs index 00b19ce9..fb6269fd 100644 --- a/src/AcDream.Core/World/Cells/CellGraph.cs +++ b/src/AcDream.Core/World/Cells/CellGraph.cs @@ -6,26 +6,17 @@ using AcDream.Core.Physics; // TerrainSurface namespace AcDream.Core.World.Cells; /// -/// The unified cell graph: the active, authoritative id->cell resolver and registry. -/// Populated unconditionally from -/// (before its -/// idempotency + null-BSP guards, so BSP-less cells are included) and consumed across -/// the engine: resolves any cell id, is -/// the player render/lighting root, resolves the -/// 3rd-person camera cell, and supplies the block-local -/// terrain origin for the LandDefs lcoord math. Retail anchor: CObjCell::GetVisible -/// (pseudo_c:308209). Worker-thread populated; reads are concurrency-safe. +/// The unified cell graph: the authoritative id->cell resolver and registry. +/// Built alongside the legacy render/physics cell systems in Stage 1 and consumed +/// by nobody (zero behavior change). Retail anchor: CObjCell::GetVisible (pseudo_c:308209). +/// Worker-thread populated; reads are concurrency-safe. /// public sealed class CellGraph { private readonly ConcurrentDictionary _envCells = new(); private readonly ConcurrentDictionary _terrain = new(); - /// The player's current cell — the render/lighting root. Written ONLY at the - /// player chokepoint - /// (NPCs never touch it — a per-entity writer was the cottage-doorway "blue-hole" - /// cause); read by the renderer for the player root (GameWindow). Left unchanged when - /// the id isn't yet resolvable in the graph (stale beats null). + /// Player's current cell. Defined for Stage 2; INERT in Stage 1 (no writer). public ObjCell? CurrCell { get; internal set; } public bool Contains(uint envCellId) => _envCells.ContainsKey(envCellId); diff --git a/src/AcDream.Core/World/LoadedLandblock.cs b/src/AcDream.Core/World/LoadedLandblock.cs index a4f7b376..492b1f3e 100644 --- a/src/AcDream.Core/World/LoadedLandblock.cs +++ b/src/AcDream.Core/World/LoadedLandblock.cs @@ -5,5 +5,4 @@ namespace AcDream.Core.World; public sealed record LoadedLandblock( uint LandblockId, LandBlock Heightmap, - IReadOnlyList Entities, - PhysicsDatBundle? PhysicsDats = null); + IReadOnlyList Entities); diff --git a/src/AcDream.Core/World/PhysicsDatBundle.cs b/src/AcDream.Core/World/PhysicsDatBundle.cs deleted file mode 100644 index 3c7b68ed..00000000 --- a/src/AcDream.Core/World/PhysicsDatBundle.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Collections.Generic; -using DatReaderWriter.DBObjs; -// Environment collides with System.Environment under implicit usings — alias it. -using DatEnvironment = DatReaderWriter.DBObjs.Environment; - -namespace AcDream.Core.World; - -/// -/// The parsed dat objects ApplyLoadedTerrainLocked needs, pre-read by the -/// streaming worker under _datLock so the apply makes ZERO DatCollection -/// calls and the update thread never blocks on the worker's lock (the FPS -/// 30↔200 swing was that lock-wait). Keyed by the same dat id the apply would -/// have passed to _dats.Get<T>. -/// -public sealed record PhysicsDatBundle( - LandBlockInfo? Info, - IReadOnlyDictionary EnvCells, - IReadOnlyDictionary Environments, - IReadOnlyDictionary Setups, - IReadOnlyDictionary GfxObjs) -{ - /// Empty bundle for far-tier landblocks (no cells / buildings / - /// entities) and for non-streaming construction. - public static readonly PhysicsDatBundle Empty = new( - null, - new Dictionary(), - new Dictionary(), - new Dictionary(), - new Dictionary()); -} diff --git a/src/AcDream.Core/World/SkyState.cs b/src/AcDream.Core/World/SkyState.cs index 0120e84a..5acf2d39 100644 --- a/src/AcDream.Core/World/SkyState.cs +++ b/src/AcDream.Core/World/SkyState.cs @@ -74,15 +74,22 @@ public readonly record struct SkyKeyframe( /// (see ). /// /// - /// |sunVec| is retail's D3DLIGHT9.Diffuse = DirColor × sqrt(x²+y²+z²) - /// scaling (PrimD3DRender::UpdateLightsInternal 0x0059b57c, decomp - /// 424118-424119) of the WORLD-space sun vector (LScape::sunlight). - /// Because is now the - /// DirBright-scaled spherical vector (magnitude == DirBright, cdb-verified — - /// see that method), |sunVec| == DirBright, so this is effectively - /// SunColor = DirColor × DirBright. (A prior bug used the un-transformed - /// y=cos(P) vector ⇒ |sunVec|≈1.06 ⇒ the sun was ~4–5× too bright at dawn/dusk; - /// [[reference-retail-ambient-values]].) + /// Why |sunVec| instead of DirBright directly: retail's + /// PrimD3DRender::UpdateLightsInternal at 0x0059b57c + /// (decomp line 424118-424119) computes + /// D3DLIGHT9.Diffuse.r = sunlight_color.r × sqrt(x²+y²+z²) + /// from the sun vector SkyDesc::GetLighting built at + /// 0x00500ac9 (decomp lines 261343-261353): + /// + /// sunVec.x = sin(H) × DirBright × cos(P) + /// sunVec.y = cos(P) // NOT scaled by DirBright + /// sunVec.z = DirBright × sin(P) + /// + /// Because Y is unscaled by DirBright, |sunVec| ≠ + /// DirBright in general — it varies with sun pitch and heading. + /// Using DirBright alone underweighted the warm directional + /// term, letting the cool ambient/fog dominate ⇒ acdream rendered + /// blue-white at keyframes where retail looked warm-gray. /// /// public Vector3 SunColor => DirColor * SkyStateProvider.RetailSunVector(this).Length(); @@ -294,35 +301,21 @@ public sealed class SkyStateProvider } /// - /// Retail's world-space sun vector (NOT normalized): the standard - /// spherical-to-cartesian direction (East=x, North=y, Up=z) scaled by - /// DirBright: + /// Retail's raw sun vector (NOT normalized) — the same vector + /// SkyDesc::GetLighting writes at 0x00500ac9 + /// (decomp lines 261343, 261352, 261353): /// - /// sunVec.x = DirBright × cos(P) × sin(H) - /// sunVec.y = DirBright × cos(P) × cos(H) - /// sunVec.z = DirBright × sin(P) + /// sunVec.x = sin(H_rad) × DirBright × cos(P_rad) + /// sunVec.y = cos(P_rad) // NOT scaled by DirBright + /// sunVec.z = DirBright × sin(P_rad) /// - /// so |sunVec| == DirBright exactly (cos²P·(sin²H+cos²H)+sin²P = 1). - /// - /// - /// GROUNDED IN A LIVE cdb CAPTURE (2026-06-18, [[reference-retail-ambient-values]]): - /// retail's LScape::sunlight read at a dawn keyframe (H=90°, P=0.9°, - /// DirBright≈0.224) = (0.2238, ~0, 0.00352) — y≈0, magnitude 0.224 = - /// DirBright. That fed level = 0.2·|sunlight| + ambient_level = 0.2·0.224 + - /// 0.40 = 0.445, matching the captured SetWorldAmbientLight level. - /// - /// - /// PRIOR BUG: an earlier version returned y = cos(P) (≈1) — the raw - /// PRE-transform value the decomp's SkyDesc::GetLighting writes to its - /// arg5 (0x00500ac9, before LScape::set_sky_position's world - /// transform). Porting that un-transformed vector inflated |sunVec| to - /// ~1.06 instead of ~0.22, over-brightening BOTH the ambient boost - /// () AND the sun colour - /// () by ~30% vs retail. The world-space - /// form above is what LScape::sunlight actually holds at runtime. - /// - /// The shader uses the NORMALIZED vector for N·L; the magnitude (= DirBright) - /// feeds the sun-colour intensity and the ambient brightness boost. + /// Y is unscaled by brightness on purpose — that's what makes + /// |sunVec|DirBright in general (the magnitude varies + /// with pitch/heading, which is the basis for retail's "sun is brighter + /// in some configurations than others" lighting behavior). The shader's + /// uSunDir uniform uses the NORMALIZED vector for N·L; the + /// magnitude feeds intensity and + /// the ambient brightness boost in . /// public static Vector3 RetailSunVector(SkyKeyframe kf) { @@ -332,9 +325,9 @@ public sealed class SkyStateProvider float sinP = MathF.Sin(p); float B = kf.DirBright; return new Vector3( - B * cosP * MathF.Sin(h), // x = DirBright × cos(P) × sin(H) - B * cosP * MathF.Cos(h), // y = DirBright × cos(P) × cos(H) - B * sinP); // z = DirBright × sin(P) + MathF.Sin(h) * B * cosP, // x = sin(H) × B × cos(P) + cosP, // y = cos(P) ← unscaled by B + B * sinP); // z = B × sin(P) } /// diff --git a/src/AcDream.Core/World/TeleportAnimSequencer.cs b/src/AcDream.Core/World/TeleportAnimSequencer.cs deleted file mode 100644 index 4280ea73..00000000 --- a/src/AcDream.Core/World/TeleportAnimSequencer.cs +++ /dev/null @@ -1,193 +0,0 @@ -namespace AcDream.Core.World; - -// acclient.h:6871 — TeleportAnimState enum, verbatim order. -public enum TeleportAnimState -{ - Off = 0, - WorldFadeOut = 1, - TunnelFadeIn = 2, - Tunnel = 3, - TunnelContinue = 4, - TunnelFadeOut = 5, - WorldFadeIn = 6, -} - -/// Why the teleport was triggered — drives per-entry start state (spec §2.5). -public enum TeleportEntryKind { Portal, Login, Death, Logout } - -/// -/// Edge-triggered events the sequencer emits on the tick they first occur. -/// Consumers drive audio / placement / LoginComplete from these; the sequencer -/// has no dependency on any of those systems. -/// -public enum TeleportAnimEvent -{ - PlayEnterSound, // Begin(): sound_ui_enter_portal - EnterTunnel, // Off/WorldFade* -> Tunnel: world is now hidden - Place, // Tunnel -> TunnelContinue: world loaded; place the player - PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal - FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1 -} - -/// Immutable per-frame snapshot from the sequencer. -public readonly record struct TeleportAnimSnapshot( - TeleportAnimState State, - float FadeAlpha, // 0 = clear world, 1 = full black - bool ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut - bool ShowPleaseWait); // true during TunnelContinue only - -/// -/// Pure 7-state teleport animation machine (spec §2.1, acclient gmSmartBoxUI::UseTime 0x004d6e30). -/// No GL / dat / network dependency — fully unit-testable. -/// -public sealed class TeleportAnimSequencer -{ - // Golden constants (spec §2.1, verified VAs): - // TELEPORT_ANIM_FADE_TIME 0x007BD278 = 1.0s - // TELEPORT_ANIM_MIN_CONTINUE_TIME 0x007BD268 = 2.0s - // TELEPORT_ANIM_MAX_CONTINUE_TIME 0x007BD270 = 5.0s - public const float FadeTime = 1.0f; - public const float MinContinue = 2.0f; - public const float MaxContinue = 5.0f; - - private TeleportAnimState _state = TeleportAnimState.Off; - private float _elapsed = 0f; // time in current state - private float _continueElapsed = 0f; // tracks time inside TunnelContinue - private bool _enterSoundPending = false; - private bool _enterTunnelPending = false; - - public bool IsActive => _state != TeleportAnimState.Off; - public TeleportAnimState State => _state; - - /// - /// Start the animation. Portal/Login/Death enter at Tunnel (skipping world-fade-out); - /// Logout enters at WorldFadeOut (spec §2.5). - /// - public void Begin(TeleportEntryKind kind) - { - _state = kind == TeleportEntryKind.Logout - ? TeleportAnimState.WorldFadeOut - : TeleportAnimState.Tunnel; - _elapsed = 0f; - _continueElapsed = 0f; - _enterSoundPending = true; - _enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death - } - - /// - /// Advance the machine by seconds. - /// = destination collision landblock is resident. - /// Returns the current snapshot + edge-triggered events fired THIS tick. - /// - public (TeleportAnimSnapshot snapshot, IReadOnlyList events) - Tick(float dt, bool worldReady) - { - var evts = new List(); - - // Edge events queued by Begin or a prior transition (enter-sound + enter-tunnel only; - // exit-sound and login-complete are emitted inline at their transitions below): - if (_enterSoundPending) { evts.Add(TeleportAnimEvent.PlayEnterSound); _enterSoundPending = false; } - if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; } - - _elapsed += dt; - - // State-machine transitions: - switch (_state) - { - case TeleportAnimState.WorldFadeOut: - if (_elapsed >= FadeTime) - Advance(TeleportAnimState.TunnelFadeIn, enterTunnel: false); - break; - - case TeleportAnimState.TunnelFadeIn: - if (_elapsed >= FadeTime) - Advance(TeleportAnimState.Tunnel, enterTunnel: true); - break; - - case TeleportAnimState.Tunnel: - // Hold here until worldReady (EndTeleportAnimation analogue). - if (worldReady) - { - evts.Add(TeleportAnimEvent.Place); - Advance(TeleportAnimState.TunnelContinue, enterTunnel: false); - _continueElapsed = 0f; - } - break; - - case TeleportAnimState.TunnelContinue: - _continueElapsed += dt; - // worldReady stays true once the landblock loads, so this advances at the 2s - // minimum in the normal case; gating min-advance on worldReady keeps the - // MaxContinue safety net authoritative if readiness ever regresses mid-continue. - bool minMet = worldReady && _continueElapsed >= MinContinue; - bool maxForce = _continueElapsed >= MaxContinue; - if (minMet || maxForce) - Advance(TeleportAnimState.TunnelFadeOut, enterTunnel: false); - break; - - case TeleportAnimState.TunnelFadeOut: - if (_elapsed >= FadeTime) - { - Advance(TeleportAnimState.WorldFadeIn, enterTunnel: false); - evts.Add(TeleportAnimEvent.PlayExitSound); - } - break; - - case TeleportAnimState.WorldFadeIn: - if (_elapsed >= FadeTime) - { - Advance(TeleportAnimState.Off, enterTunnel: false); - evts.Add(TeleportAnimEvent.FireLoginComplete); - } - break; - - case TeleportAnimState.Off: - default: - break; - } - - return (BuildSnapshot(), evts); - } - - private void Advance(TeleportAnimState next, bool enterTunnel) - { - _state = next; - _elapsed = 0f; - if (enterTunnel) _enterTunnelPending = true; - } - - private TeleportAnimSnapshot BuildSnapshot() - { - float alpha = ComputeFadeAlpha(_state, _elapsed); - bool showTunnel = _state is TeleportAnimState.TunnelFadeIn - or TeleportAnimState.Tunnel - or TeleportAnimState.TunnelContinue - or TeleportAnimState.TunnelFadeOut; - bool pleaseWait = _state == TeleportAnimState.TunnelContinue; - return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait); - } - - private static float Smoothstep(float t) - { - t = Math.Clamp(t, 0f, 1f); - return t * t * (3f - 2f * t); - } - - private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed) - { - float t = Math.Clamp(elapsed / FadeTime, 0f, 1f); - return state switch - { - // Fading TO black (alpha 0→1): - TeleportAnimState.WorldFadeOut => Smoothstep(t), - TeleportAnimState.TunnelFadeIn => 1f - Smoothstep(t), // tunnel fades IN: overlay goes clear - // Full black / fully clear inside tunnel states: - TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed - TeleportAnimState.TunnelContinue => 0f, - // Fading back out of tunnel: - TeleportAnimState.TunnelFadeOut => Smoothstep(t), // tunnel fades out: overlay goes black - TeleportAnimState.WorldFadeIn => 1f - Smoothstep(t), // world fades in: overlay clears - _ => 0f, - }; - } -} diff --git a/src/AcDream.Plugin.Abstractions/IPluginHost.cs b/src/AcDream.Plugin.Abstractions/IPluginHost.cs index dca64d7b..7374ea91 100644 --- a/src/AcDream.Plugin.Abstractions/IPluginHost.cs +++ b/src/AcDream.Plugin.Abstractions/IPluginHost.cs @@ -10,5 +10,4 @@ public interface IPluginHost IPluginLogger Log { get; } IGameState State { get; } IEvents Events { get; } - IUiRegistry Ui { get; } } diff --git a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs b/src/AcDream.Plugin.Abstractions/IUiRegistry.cs deleted file mode 100644 index 1b724f1a..00000000 --- a/src/AcDream.Plugin.Abstractions/IUiRegistry.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace AcDream.Plugin.Abstractions; - -/// -/// Plugin-facing UI registration. A plugin ships a markup file (KSML-style) + -/// a binding object exposing the data properties the markup binds to, and -/// registers it from Enable(). Calls made before the GL window opens are -/// buffered and drained once the UI host exists. -/// -public interface IUiRegistry -{ - /// Absolute path to the plugin's panel markup file. - /// Object whose properties the markup's {Bindings} resolve against. - void AddMarkupPanel(string markupPath, object binding); -} diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs index e62dc5e2..84bafce3 100644 --- a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs +++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs @@ -141,12 +141,6 @@ public sealed class InputDispatcher public bool IsActionHeld(InputAction action) { if (action == InputAction.None) return false; - // While a text field owns the keyboard ("write mode"), held game actions read as - // released: typing "swd" must not move the character. This is the polling-path twin - // of the WantCaptureKeyboard gate on Fired actions. NOTE: this suppresses KEY-driven - // movement only — latched state that isn't a key (e.g. autorun, ORed into Forward at - // the call site) keeps driving the character, so chat doesn't cancel autorun. - if (_mouse.WantCaptureKeyboard) return false; foreach (var b in _bindings.ForAction(action)) { if (IsChordHeld(b.Chord)) return true; diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs deleted file mode 100644 index 9158d2d0..00000000 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; - -namespace AcDream.UI.Abstractions.Panels.Chat; - -/// What a submit did, so the caller can clear its input + give feedback. -public enum SubmitOutcome { Empty, ClientHandled, UnknownCommand, Sent, Dropped } - -/// -/// Shared chat-submit pipeline (retail ChatInterface::ProcessCommand @0x4f5100 -/// analogue). Both the ImGui devtools and the retail -/// chat window route through here so command handling stays in one place. -/// -/// Order mirrors the prior inline flow: -/// client-command intercept → unknown-slash-verb guard → -/// → Publish(SendChatCmd). -/// -public static class ChatCommandRouter -{ - public static SubmitOutcome Submit( - string raw, ChatVM vm, ICommandBus bus, ChatChannelKind defaultChannel) - { - ArgumentNullException.ThrowIfNull(vm); - ArgumentNullException.ThrowIfNull(bus); - var trimmed = (raw ?? string.Empty).Trim(); - if (trimmed.Length == 0) return SubmitOutcome.Empty; - - if (TryHandleClientCommand(trimmed, vm)) return SubmitOutcome.ClientHandled; - - if (trimmed[0] == '/') - { - var verb = ChatInputParser.GetVerbToken(trimmed); - if (!ChatInputParser.IsKnownVerb(verb)) - { - vm.ShowSystemMessage( - $"Unknown command: {verb}. Type /help for the list of supported commands."); - return SubmitOutcome.UnknownCommand; - } - } - - var parsed = ChatInputParser.Parse( - trimmed, defaultChannel, vm.LastIncomingTellSender, vm.LastOutgoingTellTarget); - if (parsed is { } p) - { - bus.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); - return SubmitOutcome.Sent; - } - return SubmitOutcome.Dropped; - } - - private static bool TryHandleClientCommand(string trimmed, ChatVM vm) - { - if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) - { vm.ShowSystemMessage(BuildHelpText()); return true; } - if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) - { vm.Clear(); return true; } - if (EqAny(trimmed, "/framerate", "@framerate")) - { vm.ShowFps(); return true; } - if (EqAny(trimmed, "/loc", "@loc")) - { vm.ShowLocation(); return true; } - return false; - } - - private static bool EqAny(string s, params string[] options) - { - for (int i = 0; i < options.Length; i++) - if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; - return false; - } - - private static string BuildHelpText() => - "Note: / and @ are equivalent prefixes.\n" + - "Chat: /say (default), /tell , /reply, /retell\n" + - "Channels: /general /trade /fellowship /allegiance\n" + - " /patron /vassals /monarch /covassals\n" + - " /lfg /roleplay /society /olthoi\n" + - "Client: /help (this) /clear /framerate /loc\n" + - "Server: type @acehelp or @acecommands for ACE's full list."; -} diff --git a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs index 9cb8cb1f..c8ece999 100644 --- a/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs @@ -191,7 +191,53 @@ public sealed class ChatPanel : IPanel if (renderer.InputTextSubmit("##chatinput", ref _input, InputBufferMaxLen, out var submitted) && submitted is not null) { - ChatCommandRouter.Submit(submitted, _vm, ctx.Commands, ChatChannelKind.Say); + var trimmed = submitted.Trim(); + // Phase J follow-up: client-side commands intercepted before + // the server-bound parse path. Avoids the /help round-trip + // that produced "Unknown command: help" duplicates from + // ACE's command-error replies, AND gives users a discoverable + // local cheat-sheet of acdream's own slash prefixes. + if (TryHandleClientCommand(trimmed)) + { + _input = string.Empty; + renderer.EndChild(); // outer ##chatbody + renderer.End(); + return; + } + + // Phase J Tier 4: any /-prefixed input that ISN'T one of our + // known verbs gets a local "Unknown command" message instead + // of being broadcast to the server as plain speech. The + // user reported "/ls" / "/mp /path" leaking out as chat — + // a / prefix is a command, never speech. (@-prefixed unknown + // verbs still pass through to ACE because ACE's + // CommandManager intercepts @ server-side and replies with + // its own "Unknown command" / valid command output.) + if (trimmed.Length > 0 && trimmed[0] == '/') + { + string verb = ChatInputParser.GetVerbToken(trimmed); + if (!ChatInputParser.IsKnownVerb(verb)) + { + _vm.ShowSystemMessage( + $"Unknown command: {verb}. Type /help for the list of supported commands."); + _input = string.Empty; + renderer.EndChild(); // outer ##chatbody + renderer.End(); + return; + } + } + + var parsed = ChatInputParser.Parse( + trimmed, + ChatChannelKind.Say, + _vm.LastIncomingTellSender, + _vm.LastOutgoingTellTarget); + if (parsed is { } p) + { + ctx.Commands.Publish(new SendChatCmd(p.Channel, p.TargetName, p.Text)); + } + // Defensive: if the backend ever forgot to clear on submit, + // do it here. Cheap; no harm if already empty. _input = string.Empty; } @@ -212,4 +258,79 @@ public sealed class ChatPanel : IPanel _ => new Vector4(1f, 1f, 1f, 1f), }; + /// + /// Phase J follow-up: handle client-side slash commands before + /// the parser passes anything to the server bus. Returns true + /// when the input was consumed (and the caller should clear the + /// buffer + skip the SendChatCmd path); false otherwise. + /// + /// + /// Recognised client-side commands: + /// + /// /help, /?, /h — render the slash-prefix + /// cheat-sheet locally. Avoids the server's "Unknown command" + /// round-trip when the user just wants to know what they can + /// type. + /// /clear, /cls — drain the chat log so the + /// panel starts empty. + /// + /// + private bool TryHandleClientCommand(string trimmed) + { + if (trimmed.Length == 0) return false; + + // /help, /?, /h — also @help, @?, @h per ACE's "/ ↔ @" equivalence. + if (EqAny(trimmed, "/help", "/?", "/h", "@help", "@?", "@h")) + { + _vm.ShowSystemMessage(BuildHelpText()); + return true; + } + + // /clear, /cls — also @clear, @cls. + if (EqAny(trimmed, "/clear", "/cls", "@clear", "@cls")) + { + _vm.Clear(); + return true; + } + + // /framerate — also @framerate. Prints current FPS to chat. + if (EqAny(trimmed, "/framerate", "@framerate")) + { + _vm.ShowFps(); + return true; + } + + // /loc — also @loc. Prints current player position to chat. + // ACE has a server-side @loc too; client-side wins here + // (instantaneous + uses our local interpolated position). + if (EqAny(trimmed, "/loc", "@loc")) + { + _vm.ShowLocation(); + return true; + } + + return false; + } + + /// Case-insensitive multi-string equality test. + private static bool EqAny(string s, params string[] options) + { + for (int i = 0; i < options.Length; i++) + if (s.Equals(options[i], StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + + /// + /// Multi-line cheat-sheet text rendered by /help. ImGui's + /// Text path flows embedded newlines naturally so this lands + /// as one ChatLog entry that visually wraps to several lines. + /// + private static string BuildHelpText() => + "Note: / and @ are equivalent prefixes.\n" + + "Chat: /say (default), /tell , /reply, /retell\n" + + "Channels: /general /trade /fellowship /allegiance\n" + + " /patron /vassals /monarch /covassals\n" + + " /lfg /roleplay /society /olthoi\n" + + "Client: /help (this) /clear /framerate /loc\n" + + "Server: type @acehelp or @acecommands for ACE's full list."; } diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs index 01292581..6593f90c 100644 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs @@ -254,12 +254,6 @@ public sealed class DebugPanel : IPanel if (r.Checkbox("Probe auto-walk (ACDREAM_PROBE_AUTOWALK)", ref probeAutoWalk)) _vm.ProbeAutoWalk = probeAutoWalk; - // MP0 (2026-07-05): permanent frame profiler toggle — not a - // throwaway investigation probe, so it lives with the other - // always-available diagnostics rather than a dated section. - bool frameProf = _vm.FrameProf; - if (r.Checkbox("Frame profiler ([frame-prof])", ref frameProf)) _vm.FrameProf = frameProf; - // ── Indoor rendering diagnostics (2026-05-19) ─────────────── // Pinpoint where the EnvCell rendering chain breaks for // hypothesis-driven Phase 2 fix. Spec: diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs index bf4123f8..d7801f46 100644 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs @@ -291,17 +291,6 @@ public sealed class DebugVM set => PhysicsDiagnostics.ProbeAutoWalkEnabled = value; } - /// - /// Runtime mirror of RenderingDiagnostics.FrameProfEnabled - /// (env var ACDREAM_FRAME_PROF). Toggling here starts/stops the - /// [frame-prof] 5-second report live — no relaunch required. - /// - public bool FrameProf - { - get => RenderingDiagnostics.FrameProfEnabled; - set => RenderingDiagnostics.FrameProfEnabled = value; - } - // ── Indoor rendering diagnostics (2026-05-19) ─────────────────── // Mirror RenderingDiagnostics statics so DebugPanel checkbox toggles // take effect on the next render frame without relaunching. diff --git a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj index 272953e3..5ab79928 100644 --- a/tests/AcDream.App.Tests/AcDream.App.Tests.csproj +++ b/tests/AcDream.App.Tests/AcDream.App.Tests.csproj @@ -22,10 +22,4 @@ - - - PreserveNewest - - - diff --git a/tests/AcDream.App.Tests/FrameProfilerReportTests.cs b/tests/AcDream.App.Tests/FrameProfilerReportTests.cs deleted file mode 100644 index 1a087074..00000000 --- a/tests/AcDream.App.Tests/FrameProfilerReportTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using AcDream.App.Diagnostics; -using Xunit; - -namespace AcDream.App.Tests; - -public class FrameProfilerReportTests -{ - [Fact] - public void FormatReport_IsInvariantAndComplete() - { - var cpu = new FrameStatsBuffer(16); - var gpu = new FrameStatsBuffer(16); - var alloc = new FrameStatsBuffer(16); - var stages = new[] { new FrameStatsBuffer(16), new FrameStatsBuffer(16), new FrameStatsBuffer(16) }; - for (long i = 1; i <= 10; i++) - { - cpu.Push(i * 1000); // 1..10 ms in µs - gpu.Push(i * 100); - alloc.Push(i * 1024); // bytes - stages[0].Push(i * 200); - stages[1].Push(i * 50); - stages[2].Push(i * 10); - } - - string line = FrameProfiler.FormatReport( - frameCount: 10, cpu: cpu, gpu: gpu, gpuActive: true, - alloc: alloc, gc0: 3, gc1: 1, gc2: 0, stages: stages); - - Assert.StartsWith("[frame-prof]", line); - Assert.Contains("n=10", line); - Assert.Contains("cpu_ms p50=5.0 p95=10.0 p99=10.0 max=10.0", line); - Assert.Contains("gpu_ms p50=0.5", line); - Assert.Contains("alloc_kb p50=5.0 max=10.0", line); - Assert.Contains("gc=3/1/0", line); - Assert.Contains("upd p50=1.0", line); // stage 0: 200µs·5 = 1.0 ms - Assert.DoesNotContain(",0", line.Replace("gc=3/1/0", "")); // no comma decimals (invariant culture) - } - - [Fact] - public void FormatReport_GpuInactive_SaysWhy() - { - var empty = new FrameStatsBuffer(4); - string line = FrameProfiler.FormatReport( - frameCount: 0, cpu: empty, gpu: empty, gpuActive: false, - alloc: empty, gc0: 0, gc1: 0, gc2: 0, - stages: new[] { empty, empty, empty }); - Assert.Contains("gpu=off(wbdiag)", line); - } -} diff --git a/tests/AcDream.App.Tests/FrameStatsBufferTests.cs b/tests/AcDream.App.Tests/FrameStatsBufferTests.cs deleted file mode 100644 index 725f370c..00000000 --- a/tests/AcDream.App.Tests/FrameStatsBufferTests.cs +++ /dev/null @@ -1,59 +0,0 @@ -using AcDream.App.Diagnostics; -using Xunit; - -namespace AcDream.App.Tests; - -public class FrameStatsBufferTests -{ - [Fact] - public void Percentiles_OnKnownDistribution_AreExact() - { - var buf = new FrameStatsBuffer(capacity: 100); - // 1..100 µs — p50 = 50, p95 = 95, p99 = 99, max = 100. - for (long i = 1; i <= 100; i++) buf.Push(i); - - Assert.Equal(50, buf.Percentile(0.50)); - Assert.Equal(95, buf.Percentile(0.95)); - Assert.Equal(99, buf.Percentile(0.99)); - Assert.Equal(100, buf.Max()); - } - - [Fact] - public void Push_PastCapacity_KeepsOnlyNewestWindow() - { - var buf = new FrameStatsBuffer(capacity: 4); - foreach (long v in new long[] { 1000, 1000, 1000, 1000, 1, 2, 3, 4 }) - buf.Push(v); - // The four 1000s were overwritten; window is {1,2,3,4}. - Assert.Equal(4, buf.Count); - Assert.Equal(4, buf.Max()); - Assert.Equal(2, buf.Percentile(0.50)); - } - - [Fact] - public void Percentile_Empty_ReturnsZero() - { - var buf = new FrameStatsBuffer(capacity: 8); - Assert.Equal(0, buf.Percentile(0.95)); - Assert.Equal(0, buf.Max()); - Assert.Equal(0, buf.Count); - } - - [Fact] - public void Reset_ClearsWindow() - { - var buf = new FrameStatsBuffer(capacity: 8); - buf.Push(5); buf.Push(7); - buf.Reset(); - Assert.Equal(0, buf.Count); - Assert.Equal(0, buf.Percentile(0.5)); - } - - [Fact] - public void Max_AllNegative_ReturnsTrueMax() - { - var buf = new FrameStatsBuffer(capacity: 4); - buf.Push(-5); buf.Push(-2); buf.Push(-9); - Assert.Equal(-2, buf.Max()); - } -} diff --git a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs b/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs deleted file mode 100644 index 6e22e17f..00000000 --- a/tests/AcDream.App.Tests/Plugins/BufferedUiRegistryTests.cs +++ /dev/null @@ -1,21 +0,0 @@ -using AcDream.App.Plugins; - -namespace AcDream.App.Tests.Plugins; - -public class BufferedUiRegistryTests -{ - [Fact] - public void Drain_YieldsBufferedRegistrationsOnceThenEmpty() - { - var reg = new BufferedUiRegistry(); - reg.AddMarkupPanel("a.xml", new object()); - reg.AddMarkupPanel("b.xml", new object()); - - var drained = reg.Drain(); - Assert.Equal(2, drained.Count); - Assert.Equal("a.xml", drained[0].MarkupPath); - Assert.Equal("b.xml", drained[1].MarkupPath); - - Assert.Empty(reg.Drain()); // consumed - } -} diff --git a/tests/AcDream.App.Tests/Rendering/DollCameraTests.cs b/tests/AcDream.App.Tests/Rendering/DollCameraTests.cs deleted file mode 100644 index 134a4c5e..00000000 --- a/tests/AcDream.App.Tests/Rendering/DollCameraTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Numerics; -using AcDream.App.Rendering; -using Xunit; - -namespace AcDream.App.Tests.Rendering; - -public class DollCameraTests -{ - [Fact] - public void Eye_position_is_retail_verbatim() - { - var cam = new DollCamera { Aspect = 100f / 214f }; - Assert.True(Matrix4x4.Invert(cam.View, out var inv)); - var eye = inv.Translation; - // Retail UIElement_Viewport::SetCamera position (decomp 0x004a5a51-0x004a5a61). - Assert.Equal(0.12f, eye.X, 3); - Assert.Equal(-2.4f, eye.Y, 3); - Assert.Equal(0.88f, eye.Z, 3); - } - - [Fact] - public void Look_axis_is_pure_plus_y_zero_yaw() - { - // retail SetCameraDirection(0,0,0) ⇒ IDENTITY view frame ⇒ camera looks straight down +Y. - // System.Numerics CreateLookAt: forward = -(M13, M23, M33). Any yaw (Target.x≠Eye.x) would put a - // non-zero X here and turn the doll's face away — the bug this guards against. - var cam = new DollCamera { Aspect = 100f / 214f }; - var forward = -new Vector3(cam.View.M13, cam.View.M23, cam.View.M33); - Assert.Equal(0f, forward.X, 4); - Assert.Equal(1f, forward.Y, 4); - Assert.Equal(0f, forward.Z, 4); - } - - [Fact] - public void Projection_is_finite_and_uses_aspect() - { - var cam = new DollCamera { Aspect = 1.5f }; - Assert.True(float.IsFinite(cam.Projection.M11)); - Assert.NotEqual(0f, cam.Projection.M34); // perspective w = -z term - } -} diff --git a/tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs deleted file mode 100644 index 192b0d69..00000000 --- a/tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.Rendering; -using AcDream.Core.World; -using Xunit; - -namespace AcDream.App.Tests.Rendering; - -public class DollEntityBuilderTests -{ - [Fact] - public void Builds_doll_entity_with_synthetic_guid_and_player_setup() - { - // SubPaletteRange uses: SubPaletteId, Offset (byte), Length (byte) - var doll = DollEntityBuilder.Build( - setupId: 0x0200_0001u, - meshRefs: new List(), - basePaletteId: 0x04000ABCu, - subPalettes: new (uint SubPaletteId, byte Offset, byte Length)[] { (0x0F00_0001u, 0, 8) }, - partOverrides: new (byte PartIndex, uint GfxObjId)[] { (2, 0x0100_0042u) }); - - Assert.Equal(0x0200_0001u, doll.SourceGfxObjOrSetupId); - Assert.Equal(DollEntityBuilder.DollServerGuid, doll.ServerGuid); - Assert.NotEqual(0u, doll.ServerGuid); - Assert.Single(doll.PartOverrides); - Assert.Equal((byte)2, doll.PartOverrides[0].PartIndex); - Assert.Equal(0x0100_0042u, doll.PartOverrides[0].GfxObjId); - Assert.NotNull(doll.PaletteOverride); - Assert.Equal(0x04000ABCu, doll.PaletteOverride!.BasePaletteId); - Assert.Single(doll.PaletteOverride.SubPalettes); - Assert.Equal(0x0F00_0001u, doll.PaletteOverride.SubPalettes[0].SubPaletteId); - } - - [Fact] - public void Null_overrides_give_empty_collections_not_null() - { - var doll = DollEntityBuilder.Build(0x0200_0001u, new List(), null, null, null); - // No subpalettes => no PaletteOverride (mirrors GameWindow: only built when Count > 0) - Assert.Null(doll.PaletteOverride); - Assert.Empty(doll.PartOverrides); - } - - [Fact] - public void Empty_subpalettes_give_null_palette_override() - { - // Mirror GameWindow: SubPalettes with Count == 0 => no override - var doll = DollEntityBuilder.Build( - 0x0200_0001u, - new List(), - basePaletteId: 0x04000001u, - subPalettes: System.Array.Empty<(uint, byte, byte)>(), - partOverrides: null); - Assert.Null(doll.PaletteOverride); - } - - [Fact] - public void Heading_is_normalized_quaternion_facing_viewer() - { - var doll = DollEntityBuilder.Build(0x0200_0001u, new List(), null, null, null); - Assert.True(System.MathF.Abs(doll.Rotation.LengthSquared() - 1f) < 1e-3f); - } - - [Fact] - public void Position_is_world_origin() - { - var doll = DollEntityBuilder.Build(0x0200_0001u, new List(), null, null, null); - Assert.Equal(Vector3.Zero, doll.Position); - } - - [Fact] - public void ParentCellId_is_null_for_doll_scene() - { - var doll = DollEntityBuilder.Build(0x0200_0001u, new List(), null, null, null); - Assert.Null(doll.ParentCellId); - } - - [Fact] - public void MeshRefs_are_passed_through() - { - var refs = new List { new MeshRef(0x01000001u, Matrix4x4.Identity) }; - var doll = DollEntityBuilder.Build(0x0200_0001u, refs, null, null, null); - Assert.Same(refs, doll.MeshRefs); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs b/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs deleted file mode 100644 index 0bc52627..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue176177FacilityHubFloodReplayTests.cs +++ /dev/null @@ -1,280 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using AcDream.App.Rendering; -using DatReaderWriter; -using DatReaderWriter.Options; -using Xunit; -using Xunit.Abstractions; - -namespace AcDream.App.Tests.Rendering; - -/// -/// #176 (purple flashing at corridor seams, camera-angle dependent) + #177 -/// (stairs pop in/out) — headless portal-flood replay in the Facility Hub -/// landblock 0x8A02. The unified hypothesis after the dat + draw-path reads: -/// both artifacts are FLOOD ADMISSION instability (a cell dropping out of -/// PortalVisibilityBuilder's admitted set paints the fog-purple clear color -/// where its geometry was; stair cells failing admission = the pop). -/// -/// Production-matched inputs: Build(root, eye, lookup, viewProj, -/// buildingMembership: null, drawLiftZ: ShellDrawLiftZ) — the drawLiftZ -/// mirrors RetailPViewRenderer.DrawInside. -/// -/// Scenarios: -/// A. #177 approach — stand in corridor 0x0178, look +X at the stair ramp -/// (0x0182) and the lower cell (0x0183): are they admitted? -/// B. #177 descent — eye path down the ramp crossing into 0x0183: does -/// 0x0182 (the ramp geometry's owner) drop near the transit? -/// C. #176 gaze sweep — eye parked in 0x016E near the 0x017A seam, yaw -/// sweep at several pitches: any cell admitted at angle k, gone at k+1, -/// back at k+2 (the bistability signature)? -/// D. #176 walk — eye tracks down the corridor across two seams, gaze -/// locked +X: per-step admitted-set diffs (drop-for-one-step churn). -/// -public class Issue176177FacilityHubFloodReplayTests -{ - private const uint FacilityHub = 0x8A020000u; - - private readonly ITestOutputHelper _out; - public Issue176177FacilityHubFloodReplayTests(ITestOutputHelper output) => _out = output; - - private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 gazeDir) - { - var view = Matrix4x4.CreateLookAt(eye, eye + gazeDir, Vector3.UnitZ); - var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f); - return view * proj; - } - - private static List Flood( - Dictionary cells, uint rootId, Vector3 eye, Vector3 gazeDir) - { - Func lookup = id => cells.TryGetValue(id, out var c) ? c : null; - var frame = PortalVisibilityBuilder.Build( - cells[rootId], eye, lookup, ViewProjFor(eye, gazeDir), - buildingMembership: null, - drawLiftZ: PortalVisibilityBuilder.ShellDrawLiftZ); - var result = new List(frame.OrderedVisibleCells); - result.Sort(); - return result; - } - - private static string CellSetString(IEnumerable ids) - => string.Join(" ", ids.Select(id => $"{id & 0xFFFFu:X4}")); - - [Fact] - public void ScenarioA_StairApproach_AdmissionsFromCorridor() - { - var datDir = CornerFloodReplayTests.ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub); - Assert.True(cells.ContainsKey(FacilityHub | 0x0178u), "0x0178 not loaded"); - - // 0x0178 spans x<=95 at z −6..−3 (floor −6); the ramp cell 0x0182 runs - // x 95→98.33 descending; 0x0183 continues at z −9 beyond x=98.33. - // Eye at standing height (~1.7 m above the −6 floor), approaching the - // stair portal at x=95, gazing +X with a slight downward pitch (the - // natural look at a descending stair). - foreach (float eyeX in new[] { 88f, 90f, 92f, 94f, 94.9f }) - { - var eye = new Vector3(eyeX, -40f, -4.3f); - foreach (var (gaze, label) in new (Vector3, string)[] - { - (new Vector3(1f, 0f, 0f), "level"), - (Vector3.Normalize(new Vector3(1f, 0f, -0.35f)), "pitch-19"), - (Vector3.Normalize(new Vector3(1f, 0f, -0.7f)), "pitch-35"), - }) - { - var visible = Flood(cells, FacilityHub | 0x0178u, eye, gaze); - bool ramp = visible.Contains(FacilityHub | 0x0182u); - bool lower = visible.Contains(FacilityHub | 0x0183u); - _out.WriteLine($"eyeX={eyeX,5:F1} gaze={label,-8} flood={visible.Count,2} " + - $"ramp0182={(ramp ? "Y" : "MISSING")} lower0183={(lower ? "Y" : "MISSING")} " + - $"[{CellSetString(visible)}]"); - } - } - } - - [Fact] - public void ScenarioB_StairDescent_RampCellRetention() - { - var datDir = CornerFloodReplayTests.ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub); - - // Descend the ramp: floor z goes −6 (x=95) → −9 (x=98.33), then flat. - // Eye rides ~1.7 m above the local floor. Root = the cell containing - // the eye by x-range (0x0178 x<95, ramp 0x0182 95..98.33, 0x0183 after). - // Gaze: forward and slightly down (running down stairs). - var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.4f)); - List? prev = null; - for (float x = 94.0f; x <= 100.5f; x += 0.1f) - { - float floorZ = x < 95f ? -6f - : x < 98.333f ? -6f - 3f * (x - 95f) / 3.333f - : -9f; - var eye = new Vector3(x, -40f, floorZ + 1.7f); - uint rootId = x < 95f ? FacilityHub | 0x0178u - : x < 98.333f ? FacilityHub | 0x0182u - : FacilityHub | 0x0183u; - - var visible = Flood(cells, rootId, eye, gazeDir); - bool ramp = visible.Contains(FacilityHub | 0x0182u); - bool upper = visible.Contains(FacilityHub | 0x0178u); - - string diff = ""; - if (prev is not null) - { - var removed = prev.Except(visible).ToList(); - var added = visible.Except(prev).ToList(); - if (removed.Count > 0) diff += $" REMOVED=[{CellSetString(removed)}]"; - if (added.Count > 0) diff += $" added=[{CellSetString(added)}]"; - } - _out.WriteLine($"x={x,6:F1} root={rootId & 0xFFFFu:X4} eyeZ={eye.Z,6:F2} flood={visible.Count,2} " + - $"ramp0182={(ramp ? "Y" : "MISSING")} up0178={(upper ? "Y" : "-")}{diff}"); - prev = visible; - } - } - - [Fact] - public void ScenarioC_CorridorSeamGazeSweep_Bistability() - { - var datDir = CornerFloodReplayTests.ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub); - - // Parked eye in 0x016E near the 0x017A seam (x=85), standing height. - var eye = new Vector3(83.5f, -40f, -4.3f); - uint rootId = FacilityHub | 0x016Eu; - - int churnEvents = 0; - foreach (float pitchZ in new[] { 0f, -0.35f, -0.75f, -1.2f }) - { - List? prevSet = null; - float prevYaw = 0f; - var perYawSets = new List<(float yaw, List set)>(); - for (float yawDeg = -180f; yawDeg <= 180f; yawDeg += 2f) - { - float rad = yawDeg * MathF.PI / 180f; - var gaze = Vector3.Normalize(new Vector3(MathF.Cos(rad), MathF.Sin(rad), pitchZ)); - var visible = Flood(cells, rootId, eye, gaze); - perYawSets.Add((yawDeg, visible)); - - if (prevSet is not null) - { - var removed = prevSet.Except(visible).ToList(); - var added = visible.Except(prevSet).ToList(); - if (removed.Count > 0 || added.Count > 0) - { - _out.WriteLine($"pitch={pitchZ,5:F2} yaw {prevYaw,6:F0}->{yawDeg,6:F0}: " + - $"flood {prevSet.Count}->{visible.Count}" + - (removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") + - (added.Count > 0 ? $" added=[{CellSetString(added)}]" : "")); - } - } - prevSet = visible; - prevYaw = yawDeg; - } - - // Bistability: a cell present at yaw k, absent at k+1, present at k+2. - for (int i = 2; i < perYawSets.Count; i++) - { - var flicker = perYawSets[i - 2].set - .Intersect(perYawSets[i].set) - .Except(perYawSets[i - 1].set) - .ToList(); - if (flicker.Count > 0) - { - churnEvents++; - _out.WriteLine($">>> BISTABLE pitch={pitchZ:F2} yaw={perYawSets[i - 1].yaw:F0}: " + - $"cells [{CellSetString(flicker)}] dropped for ONE 2-degree step"); - } - } - } - _out.WriteLine($"bistable one-step drop events: {churnEvents}"); - } - - /// - /// THE production lag-window scenario (from launch-137-gate2.log - /// [cell-transit] lines): membership transits fire 0.1–0.6 m PAST the - /// portal plane in the travel direction (016E→017A at x=85.33–85.47 vs - /// the plane at x=85.00; 0182→0183 at 98.56–98.64 vs 98.33). The render - /// root (viewer cell, same membership machinery) therefore holds the OLD - /// cell while the camera eye is already beyond the boundary portal's - /// plane. This scenario reproduces exactly that window: root=old cell, - /// eye stepped across and past the plane, gaze forward. If the forward - /// chain (the next corridor cells) drops inside the window, that is #176 - /// (purple = fog clear color where the forward cells' geometry was) and - /// #177(a)/(c) at the stair transit. - /// - [Theory] - [InlineData(0x016Eu, 0x017Au, 85.00f, -4.3f)] // corridor seam, plane x=85 - [InlineData(0x0182u, 0x0183u, 98.333f, -7.3f)] // stair-bottom transit, plane x=98.33 - public void ScenarioE_RootLagWindow_ForwardChainRetention( - uint rootLow, uint forwardLow, float planeX, float eyeZ) - { - var datDir = CornerFloodReplayTests.ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub); - - uint rootId = FacilityHub | rootLow; - uint forwardId = FacilityHub | forwardLow; - var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f)); - - _out.WriteLine($"root=0x{rootLow:X4} forward=0x{forwardLow:X4} plane x={planeX:F2} " + - "(eye sweeps across; root HELD at the old cell = the production lag window)"); - foreach (float dx in new[] { -0.30f, -0.10f, -0.02f, 0.00f, 0.02f, 0.05f, 0.10f, 0.20f, 0.30f, 0.45f, 0.60f }) - { - var eye = new Vector3(planeX + dx, -40f, eyeZ); - var visible = Flood(cells, rootId, eye, gazeDir); - bool fwd = visible.Contains(forwardId); - _out.WriteLine($" eyeX=plane{(dx >= 0 ? "+" : "")}{dx:F2} flood={visible.Count,2} " + - $"forward={(fwd ? "Y" : ">>> DROPPED <<<")} [{CellSetString(visible)}]"); - } - } - - [Fact] - public void ScenarioD_CorridorWalk_PerStepChurn() - { - var datDir = CornerFloodReplayTests.ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - var cells = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, FacilityHub); - - // Walk 0x0165 → 0x016E → 0x017A along y=−40 (seams at x=75 and x=85), - // gaze locked +X, slight downward pitch (the running view). Root flips - // by x-range at the seams (the camera transits the same portals). - var gazeDir = Vector3.Normalize(new Vector3(1f, 0f, -0.3f)); - List? prev = null; - int churn = 0; - for (float x = 71f; x <= 89f; x += 0.05f) - { - uint rootId = x < 75f ? FacilityHub | 0x0165u - : x < 85f ? FacilityHub | 0x016Eu - : FacilityHub | 0x017Au; - if (!cells.ContainsKey(rootId)) continue; - var eye = new Vector3(x, -40f, -4.3f); - var visible = Flood(cells, rootId, eye, gazeDir); - - if (prev is not null) - { - var removed = prev.Except(visible).ToList(); - var added = visible.Except(prev).ToList(); - if (removed.Count > 0 || added.Count > 0) - { - churn++; - _out.WriteLine($"x={x,6:F2} root={rootId & 0xFFFFu:X4} flood={visible.Count,2}" + - (removed.Count > 0 ? $" REMOVED=[{CellSetString(removed)}]" : "") + - (added.Count > 0 ? $" added=[{CellSetString(added)}]" : "")); - } - } - prev = visible; - } - _out.WriteLine($"admitted-set change events over the walk: {churn}"); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs b/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs deleted file mode 100644 index 5e5f1228..00000000 --- a/tests/AcDream.App.Tests/Rendering/Issue95DungeonFloodDiagnosticTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using AcDream.App.Rendering; -using DatReaderWriter; -using DatReaderWriter.Options; -using Xunit; -using Xunit.Abstractions; -using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; - -namespace AcDream.App.Tests.Rendering; - -/// -/// #95 MEASUREMENT (2026-06-13): entering the 0x0007 dungeon (Town Network) explodes -/// WB-DIAG to ~9.1M instances/frame. Suspected cause: -/// floods the dungeon's portal graph WITHOUT the retail grab_visible_cells stab_list bounding -/// (decomp:311878). A dungeon cell has seen_outside==0; retail's PVS for it is just the -/// cell's stab_list () — typically a small bounded -/// set. If our flood instead visits ~all cells of the landblock, that is the blowup. -/// -/// This is a DIAGNOSTIC, not a fix: it loads the real 0x0007 interior cells, runs the real -/// production flood from representative dungeon-cell roots, and PRINTS the ground-truth numbers — -/// flood visited-cell-set size () vs the -/// root's stab_list size (), plus how many visited cells -/// cross landblocks. The single assertion just guarantees the test ran; the VALUE is the output. -/// -public class Issue95DungeonFloodDiagnosticTests -{ - private const uint TownNetwork = 0x00070000u; - - private readonly ITestOutputHelper _out; - public Issue95DungeonFloodDiagnosticTests(ITestOutputHelper output) => _out = output; - - // Production-ish projection (mirrors the sibling harnesses): FovY ~1.2, 1280x720, - // near 0.1, far 5000. The flood's clip is near-independent, so exactness is not - // load-bearing for cell-count measurement. - private static Matrix4x4 ViewProjFor(Vector3 eye, Vector3 lookAt) - { - var view = Matrix4x4.CreateLookAt(eye, lookAt, Vector3.UnitZ); - var proj = Matrix4x4.CreatePerspectiveFieldOfView(1.2f, 1280f / 720f, 0.1f, 5000f); - return view * proj; - } - - [Fact] - public void Measure_DungeonFlood_VisibleCellCount() - { - var datDir = CornerFloodReplayTests.ResolveDatDir(); - if (datDir is null) - { - _out.WriteLine("SKIP: dat dir did not resolve (ACDREAM_DAT_DIR unset and " - + "%USERPROFILE%\\Documents\\Asheron's Call absent). No numbers measured."); - // Diagnostic test: do not hard-fail when dats are absent (matches sibling harnesses). - return; - } - _out.WriteLine($"dat dir resolved: {datDir}"); - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - // 1) LandBlockInfo header — NumCells for 0x0007. - var lbi = dats.Get(TownNetwork | 0xFFFEu); - if (lbi is null) - { - _out.WriteLine($"SKIP: LandBlockInfo 0x{TownNetwork | 0xFFFEu:X8} not found in the dat " - + "(0x0007 may not exist in this client_cell_1.dat)."); - return; - } - _out.WriteLine($"=== 0x0007 (Town Network) LandBlockInfo ==="); - _out.WriteLine($"NumCells (DatLandBlockInfo.NumCells) = {lbi.NumCells}"); - - // 2) Load ALL interior cells (sparse ids tolerated — see LoadAllInteriorCells). - var loaded = Issue120ReciprocalPingPongTests.LoadAllInteriorCells(dats, TownNetwork); - _out.WriteLine($"cells actually loaded = {loaded.Count}"); - Assert.True(loaded.Count > 0, "no interior cells loaded for 0x0007 — cannot measure"); - - Func lookup = id => loaded.TryGetValue(id, out var c) ? c : null; - - // 3) Per-cell stab_list (VisibleCells) distribution across ALL loaded cells. - // This is the bounded retail PVS size we expect the flood to roughly match. - var stabSizes = loaded.Values.Select(c => c.VisibleCells.Count).ToList(); - int seenOutsideCount = loaded.Values.Count(c => c.SeenOutside); - int interiorCount = loaded.Count - seenOutsideCount; - _out.WriteLine(""); - _out.WriteLine("=== stab_list (LoadedCell.VisibleCells) distribution over ALL loaded cells ==="); - _out.WriteLine($"cells with SeenOutside==true (entrance/exterior-facing) = {seenOutsideCount}"); - _out.WriteLine($"cells with SeenOutside==false (interior dungeon) = {interiorCount}"); - if (stabSizes.Count > 0) - _out.WriteLine(FormattableString.Invariant( - $"VisibleCells.Count min={stabSizes.Min()} max={stabSizes.Max()} avg={stabSizes.Average():F1} sum={stabSizes.Sum()}")); - int emptyStab = stabSizes.Count(s => s == 0); - _out.WriteLine($"cells with EMPTY stab_list (no dat PVS) = {emptyStab}"); - - // 4) Pick representative DUNGEON roots: the first interior (SeenOutside==false) cells in - // ascending id order. If none exist, fall back to 0x00070100 and report that. - var interiorRoots = loaded - .Where(kv => !kv.Value.SeenOutside) - .OrderBy(kv => kv.Key) - .Select(kv => kv.Value) - .Take(5) - .ToList(); - - if (interiorRoots.Count == 0) - { - _out.WriteLine(""); - _out.WriteLine("NOTE: NO cell has SeenOutside==false (all cells see the exterior). " - + "Falling back to root 0x00070100 for the flood measurement."); - if (loaded.TryGetValue(TownNetwork | 0x0100u, out var fallback)) - interiorRoots.Add(fallback); - else - { - _out.WriteLine("WARN: 0x00070100 not loaded either; using the lowest-id loaded cell."); - interiorRoots.Add(loaded.OrderBy(kv => kv.Key).First().Value); - } - } - - _out.WriteLine(""); - _out.WriteLine("=== PER-ROOT FLOOD MEASUREMENT (PortalVisibilityBuilder.Build) ==="); - _out.WriteLine("property read for the visited-cell set: PortalVisibilityFrame.OrderedVisibleCells"); - _out.WriteLine("root | seenOut | stab(VisibleCells) | flood(OrderedVisibleCells) | crossLB | dir"); - - var floodSizes = new List(); - foreach (var root in interiorRoots) - { - // Eye at the root cell's world origin, looking toward its first portal (or +X if none), - // so the flood actually fires through an opening. Sweep all 6 axis directions and KEEP - // the maximum visited-set — the blowup is a worst-case-over-orientation quantity. - var eye = root.WorldPosition; - int bestFlood = -1; - string bestDir = "?"; - int bestCrossLb = -1; - List? bestVisited = null; - - // Direction candidates: toward each portal's polygon centroid (the natural look-through), - // plus the 6 cardinal axes as a fallback sweep. - var lookTargets = new List<(Vector3 target, string label)>(); - for (int pi = 0; pi < root.Portals.Count && pi < root.PortalPolygons.Count; pi++) - { - var poly = root.PortalPolygons[pi]; - if (poly is { Length: >= 1 }) - { - var cl = Vector3.Zero; - foreach (var v in poly) cl += v; - cl /= poly.Length; - lookTargets.Add((Vector3.Transform(cl, root.WorldTransform), - $"portal{pi}->0x{root.Portals[pi].OtherCellId:X4}")); - } - } - foreach (var (d, lbl) in new (Vector3, string)[] - { - (Vector3.UnitX, "+X"), (-Vector3.UnitX, "-X"), - (Vector3.UnitY, "+Y"), (-Vector3.UnitY, "-Y"), - (Vector3.UnitZ, "+Z"), (-Vector3.UnitZ, "-Z"), - }) - lookTargets.Add((eye + d * 5f, lbl)); - - foreach (var (target, label) in lookTargets) - { - if (Vector3.DistanceSquared(target, eye) < 1e-6f) continue; - var frame = PortalVisibilityBuilder.Build(root, eye, lookup, ViewProjFor(eye, target)); - int floodN = frame.OrderedVisibleCells.Count; - if (floodN > bestFlood) - { - bestFlood = floodN; - bestDir = label; - bestVisited = frame.OrderedVisibleCells; - bestCrossLb = frame.OrderedVisibleCells.Count(id => (id & 0xFFFF0000u) != TownNetwork); - } - } - - floodSizes.Add(bestFlood); - _out.WriteLine(FormattableString.Invariant( - $"0x{root.CellId:X8} | {(root.SeenOutside ? "Y" : "N"),5} | {root.VisibleCells.Count,18} | {bestFlood,26} | {bestCrossLb,7} | {bestDir}")); - - // For the FIRST root, also print the actual visited set + stab set for eyeballing. - if (ReferenceEquals(root, interiorRoots[0]) && bestVisited is not null) - { - _out.WriteLine(" first-root visited (OrderedVisibleCells, low ids): " - + string.Join(" ", bestVisited.Select(id => $"{id & 0xFFFFu:X4}"))); - _out.WriteLine(" first-root stab_list (VisibleCells, low ids): " - + string.Join(" ", root.VisibleCells.Select(id => $"{id & 0xFFFFu:X4}"))); - } - } - - // 5) Aggregate flood-size stats across the sampled roots — the headline numbers. - _out.WriteLine(""); - _out.WriteLine("=== AGGREGATE over sampled roots ==="); - if (floodSizes.Count > 0) - _out.WriteLine(FormattableString.Invariant( - $"flood visited-set size (OrderedVisibleCells): min={floodSizes.Min()} max={floodSizes.Max()} avg={floodSizes.Average():F1} (NumCells={lbi.NumCells}, loaded={loaded.Count})")); - var sampledStab = interiorRoots.Select(r => r.VisibleCells.Count).ToList(); - if (sampledStab.Count > 0) - _out.WriteLine(FormattableString.Invariant( - $"sampled roots' stab_list size (VisibleCells): min={sampledStab.Min()} max={sampledStab.Max()} avg={sampledStab.Average():F1}")); - _out.WriteLine(""); - _out.WriteLine("INTERPRETATION: if flood max ~= loaded.Count (visits ~all cells) while stab " - + "is small, that is the #95 blowup — the flood is unbounded by the retail stab_list PVS."); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherIndoorFlagTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherIndoorFlagTests.cs deleted file mode 100644 index c9ef2b80..00000000 --- a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherIndoorFlagTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using AcDream.App.Rendering.Wb; -using Xunit; - -namespace AcDream.App.Tests.Rendering.Wb; - -/// -/// #142 — pins the per-instance "indoor" flag predicate used by both the torch -/// gate (AP-43) and the new sun gate (binding=6 instanceIndoor[]). -/// The SSBO flag written by -/// equals IndoorObjectReceivesTorches(entity.ParentCellId) ? 1u : 0u. -/// This test pins that the shared predicate returns the correct value for the -/// representative cell ids used in the spec acceptance gate (§5). -/// -/// -/// Retail decomp anchor: useSunlightSet 0x0054d450 (sun only, never -/// ambient); PView::DrawCells 0x005a4840 — outdoor stage runs -/// useSunlightSet(1) (0x005a485a), interior stage runs -/// useSunlightSet(0) (0x005a49f3). Indoor objects skip the sun, not -/// because the player is inside, but because the OBJECT is in an interior cell. -/// -/// -public sealed class WbDrawDispatcherIndoorFlagTests -{ - // ── Spec §5 representative ids ───────────────────────────────────────── - - [Fact] - public void EnvCell_AgenOfArcanum_IndoorFlag1() - { - // 0xA9B40172: low word 0x0172 >= 0x0100 → indoor (EnvCell) - // This is the exact cell from the in-game probe (agent-arcanum-probe.log - // 2026-06-20) that triggered #142 — the Agent of Arcanum interior. - uint envCell = 0xA9B40172u; - Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(envCell), - "EnvCell 0xA9B40172 must be indoor (flag=1): objects here skip the sun."); - } - - [Fact] - public void LandSubCell_OutdoorFlag0() - { - // 0xA9B40031: low word 0x0031 < 0x0100 → outdoor land sub-cell - uint landSubCell = 0xA9B40031u; - Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(landSubCell), - "Land sub-cell 0xA9B40031 must be outdoor (flag=0): objects here get the sun."); - } - - [Fact] - public void LandblockId_OutdoorFlag0() - { - // 0xA9B4FFFF: low word 0xFFFF — a landblock pseudo-id, not an indoor cell - uint landblockId = 0xA9B4FFFFu; - Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(landblockId), - "Landblock id 0xA9B4FFFF must be outdoor (flag=0): the 0xFFFF low-word is excluded."); - } - - [Fact] - public void NullParentCellId_OutdoorFlag0() - { - // Outdoor scenery / building exterior shells have no ParentCellId → outdoor - Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(null), - "Null ParentCellId must be outdoor (flag=0): building shells get the sun, not torches."); - } - - // ── Boundary: first valid EnvCell low word ────────────────────────────── - - [Fact] - public void CellId_LowWord0x0100_IsIndoor() - { - // Low word exactly 0x0100 is the first indoor EnvCell id — just on the boundary - uint firstEnvCell = 0xA9B40100u; - Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(firstEnvCell), - "Low word 0x0100 is the lowest valid EnvCell id (inclusive boundary → indoor)."); - } - - [Fact] - public void CellId_LowWord0x00FF_IsOutdoor() - { - // Low word 0x00FF is one below the EnvCell range — still outdoor - uint lastLandSubCell = 0xA9B400FFu; - Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(lastLandSubCell), - "Low word 0x00FF is just below the EnvCell range (exclusive boundary → outdoor)."); - } -} diff --git a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs deleted file mode 100644 index cb1ffd7c..00000000 --- a/tests/AcDream.App.Tests/Rendering/Wb/WbDrawDispatcherTorchGateTests.cs +++ /dev/null @@ -1,42 +0,0 @@ -using AcDream.App.Rendering.Wb; -using Xunit; - -namespace AcDream.App.Tests.Rendering.Wb; - -/// -/// A7 Fix D round 2 — pins retail's useSunlight gate for per-object torch -/// lighting (WbDrawDispatcher.IndoorObjectReceivesTorches). Retail enables -/// the static wall-torches on an object ONLY in the indoor stage -/// (DrawMeshInternal 0x0059f398: if (useSunlight == 0) minimize_object_lighting()), -/// so OUTDOOR objects — building exterior shells (null ParentCellId) and outdoor -/// scenery (land sub-cell 0x0001..0x00FF) — get the sun, never torches. Only -/// EnvCell-parented (indoor, low word >= 0x0100) objects receive torches. -/// -public sealed class WbDrawDispatcherTorchGateTests -{ - [Fact] - public void BuildingShell_NullParent_IsOutdoor_NoTorches() - { - // Building exterior shells are top-level landblock stabs with no - // ParentCellId (LandblockLoader sets BuildingShellAnchorCellId, not Parent). - Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(null)); - } - - [Theory] - [InlineData(0xA9B4_0001u)] // outdoor land sub-cell - [InlineData(0xA9B4_0020u)] // outdoor land sub-cell - [InlineData(0xA9B4_0040u)] // last outdoor land sub-cell (0x40) - public void OutdoorLandCell_NoTorches(uint parentCellId) - { - Assert.False(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)); - } - - [Theory] - [InlineData(0xA9B4_0100u)] // first EnvCell - [InlineData(0xA9B4_0164u)] // interior EnvCell - [InlineData(0x0007_0143u)] // dungeon EnvCell - public void IndoorEnvCell_GetsTorches(uint parentCellId) - { - Assert.True(WbDrawDispatcher.IndoorObjectReceivesTorches(parentCellId)); - } -} diff --git a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs b/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs deleted file mode 100644 index b18590ae..00000000 --- a/tests/AcDream.App.Tests/RuntimeOptionsRetailUiTests.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Collections.Generic; -using AcDream.App; - -namespace AcDream.App.Tests; - -public class RuntimeOptionsRetailUiTests -{ - [Fact] - public void Parse_ReadsRetailUiAndAcDir() - { - var env = new Dictionary - { - ["ACDREAM_RETAIL_UI"] = "1", - ["ACDREAM_AC_DIR"] = @"C:\Turbine\Asheron's Call", - }; - var opts = RuntimeOptions.Parse("dats", k => env.GetValueOrDefault(k)); - Assert.True(opts.RetailUi); - Assert.Equal(@"C:\Turbine\Asheron's Call", opts.AcDir); - } - - [Fact] - public void Parse_DefaultsRetailUiOffAndAcDirNull() - { - var opts = RuntimeOptions.Parse("dats", _ => null); - Assert.False(opts.RetailUi); - Assert.Null(opts.AcDir); - } -} diff --git a/tests/AcDream.App.Tests/Studio/CanvasCoordMappingTests.cs b/tests/AcDream.App.Tests/Studio/CanvasCoordMappingTests.cs deleted file mode 100644 index 9d007bf3..00000000 --- a/tests/AcDream.App.Tests/Studio/CanvasCoordMappingTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using AcDream.App.Studio; - -namespace AcDream.App.Tests.Studio; - -/// -/// Pure-math tests for the canvas → panel-local coordinate mapping used by -/// . -/// -/// No GL context required — we're just verifying the formula: -/// panel_local = (raw_mouse_screen) - (image_screen_top_left) -/// -/// The image_screen_top_left is what ImGui.GetItemRectMin() returns after -/// ImGui.Image: the sub-window top-left + title-bar height + inner padding + any -/// scrolling. We model it as a constant offset in these tests. -/// -/// V-flip: the image is drawn with uv0=(0,1) / uv1=(1,0) so GL's bottom-left -/// origin is flipped to top-left on screen. After the flip, screen Y=0 (top of image) -/// = panel Y=0 (top of the UI), so NO additional Y inversion is applied. -/// -public class CanvasCoordMappingTests -{ - // Simulates the mapping DrawCanvas performs: - // panel pixel = mouse_screen - image_rectMin - // Returns null when the result falls outside [0, width) x [0, height). - private static (int px, int py)? Map( - float mouseScreenX, float mouseScreenY, - float imageOriginX, float imageOriginY, - int panelWidth, int panelHeight) - { - int ix = (int)(mouseScreenX - imageOriginX); - int iy = (int)(mouseScreenY - imageOriginY); - if (ix < 0 || ix >= panelWidth || iy < 0 || iy >= panelHeight) - return null; - return (ix, iy); - } - - [Fact] - public void TopLeft_of_image_maps_to_panel_origin() - { - // The canvas image starts at screen (300, 50) (after sub-window chrome). - // A click exactly at the image's screen top-left → panel (0, 0). - var result = Map(mouseScreenX: 300f, mouseScreenY: 50f, - imageOriginX: 300f, imageOriginY: 50f, - panelWidth: 1280, panelHeight: 720); - Assert.Equal((0, 0), result); - } - - [Fact] - public void Interior_point_maps_correctly() - { - // Image origin at screen (300, 50). Mouse at screen (780, 230). - // Expected panel coord: (780-300, 230-50) = (480, 180). - var result = Map(mouseScreenX: 780f, mouseScreenY: 230f, - imageOriginX: 300f, imageOriginY: 50f, - panelWidth: 1280, panelHeight: 720); - Assert.Equal((480, 180), result); - } - - [Fact] - public void Bottom_right_corner_maps_to_last_valid_pixel() - { - // Image is 1280×720, origin at screen (300, 50). - // Last pixel in bottom-right is panel (1279, 719) → screen (1579, 769). - var result = Map(mouseScreenX: 1579f, mouseScreenY: 769f, - imageOriginX: 300f, imageOriginY: 50f, - panelWidth: 1280, panelHeight: 720); - Assert.Equal((1279, 719), result); - } - - [Fact] - public void Mouse_on_ImGui_chrome_above_image_returns_null() - { - // The ImGui window title-bar / padding is above rectMin, i.e. at screenY < 50. - // The mouse there should NOT produce a panel event. - var result = Map(mouseScreenX: 400f, mouseScreenY: 40f, // 10px above image origin - imageOriginX: 300f, imageOriginY: 50f, - panelWidth: 1280, panelHeight: 720); - Assert.Null(result); - } - - [Fact] - public void Mouse_below_image_returns_null() - { - // screenY = 50 + 720 = 770 → iy = 720 which is >= panelHeight (720). - var result = Map(mouseScreenX: 400f, mouseScreenY: 770f, - imageOriginX: 300f, imageOriginY: 50f, - panelWidth: 1280, panelHeight: 720); - Assert.Null(result); - } - - [Fact] - public void Y_is_not_inverted_after_vflip() - { - // Confirm the "no extra Y inversion" contract: - // The image is V-flipped in ImGui (uv0.Y=1, uv1.Y=0), so screen top row = panel Y=0. - // A click near the TOP of the image should give a SMALL panel Y, not a large one. - // Image origin at (300, 50). Click at (400, 55) → panel (100, 5). Y is small (near top). - var result = Map(mouseScreenX: 400f, mouseScreenY: 55f, - imageOriginX: 300f, imageOriginY: 50f, - panelWidth: 1280, panelHeight: 720); - Assert.Equal((100, 5), result); - // NOT (100, 715) — which would be the result if Y were incorrectly inverted. - Assert.True(result!.Value.py < 720 / 2, "Y near screen top should map to small panel Y, not near bottom"); - } - - [Fact] - public void LargeChrome_offset_is_fully_absorbed() - { - // Simulate a canvas sub-window with large chrome: ImGui title (20px) + padding (8px) - // puts the image origin at screenY = menuBar(22) + titleBar(20) + padding(8) = 50. - // Also a wide tree pane puts imageOriginX = 280 + padding. - // A click at screen (400, 110) with origin (290, 50) → panel (110, 60). - var result = Map(mouseScreenX: 400f, mouseScreenY: 110f, - imageOriginX: 290f, imageOriginY: 50f, - panelWidth: 1280, panelHeight: 720); - Assert.Equal((110, 60), result); - } -} diff --git a/tests/AcDream.App.Tests/Studio/DumpLayoutTests.cs b/tests/AcDream.App.Tests/Studio/DumpLayoutTests.cs deleted file mode 100644 index 2b635778..00000000 --- a/tests/AcDream.App.Tests/Studio/DumpLayoutTests.cs +++ /dev/null @@ -1,188 +0,0 @@ -using AcDream.App.Studio; -using AcDream.App.UI; - -namespace AcDream.App.Tests.Studio; - -/// -/// Tests for — parsing the retail UI dump JSON and -/// building a tree from it. -/// -/// These tests load the real dump file from the source tree -/// (docs/research/2026-06-25-retail-ui-layout-dump.json). The test -/// skips cleanly when the file is absent (should not happen in a normal dev -/// checkout, but guards against stripped CI machines). -/// -public class DumpLayoutTests -{ - private static string DumpPath() - { - // Walk up from the test output directory to the solution root, - // mirroring ConformanceDats.SolutionRoot(). - var dir = AppContext.BaseDirectory; - while (!string.IsNullOrEmpty(dir)) - { - if (File.Exists(Path.Combine(dir, "AcDream.slnx"))) - return Path.Combine(dir, "docs", "research", - "2026-06-25-retail-ui-layout-dump.json"); - dir = Path.GetDirectoryName(dir); - } - // Fallback: try a relative path (won't find it but skip rather than throw) - return Path.Combine(AppContext.BaseDirectory, - "docs", "research", "2026-06-25-retail-ui-layout-dump.json"); - } - - private static (uint, int, int) NoTex(uint _) => (1u, 1, 1); - - // ── Helpers ────────────────────────────────────────────────────────── - - /// Depth-first search for an element with the given EventId. - private static UiElement? FindById(UiElement root, uint id) - { - if (root.EventId == id) return root; - foreach (var c in root.Children) - { - var found = FindById(c, id); - if (found is not null) return found; - } - return null; - } - - /// Count the total elements in the tree (self + all descendants). - private static int CountAll(UiElement root) - { - int n = 1; - foreach (var c in root.Children) n += CountAll(c); - return n; - } - - // ── Tests ───────────────────────────────────────────────────────────── - - /// - /// Loading the "inventory" slug should succeed and the returned tree should - /// contain an element with EventId == 0x100001D5 (the doll viewport node) - /// and at least 40 elements in total. - /// - [Fact] - public void Load_Inventory_ReturnsTreeWithDollViewport() - { - var path = DumpPath(); - if (!File.Exists(path)) - return; // Skip: dump not available. - - var root = DumpLayout.Load(path, "inventory", NoTex, out var err); - - Assert.NotNull(root); - Assert.Null(err); - - // The doll viewport element must appear somewhere in the tree. - const uint dollViewportId = 0x100001D5u; - var found = FindById(root!, dollViewportId); - Assert.NotNull(found); - - // The full tree must be reasonably deep — 59 dump nodes → >= 40 elements. - int total = CountAll(root!); - Assert.True(total >= 40, - $"Expected >= 40 elements in inventory tree; got {total}"); - } - - /// - /// Loading an unknown slug must return null and a non-empty error string. - /// - [Fact] - public void Load_UnknownSlug_ReturnsNullWithError() - { - var path = DumpPath(); - if (!File.Exists(path)) - return; // Skip. - - var root = DumpLayout.Load(path, "this_slug_does_not_exist", NoTex, out var err); - - Assert.Null(root); - Assert.NotNull(err); - Assert.NotEmpty(err!); - } - - /// - /// The root element's Left/Top should be (0,0) (the panel's rect offset has - /// been stripped so the tree sits at the window origin), and its Width/Height - /// should match the dump panel dimensions. - /// - [Fact] - public void Load_Inventory_RootAtOrigin() - { - var path = DumpPath(); - if (!File.Exists(path)) - return; // Skip. - - var root = DumpLayout.Load(path, "inventory", NoTex, out _); - Assert.NotNull(root); - - // Root always placed at (0,0) by DumpLayout (origin of the UiHost). - Assert.Equal(0f, root!.Left); - Assert.Equal(0f, root.Top); - // Width/Height come from the panel record in the dump. - Assert.True(root.Width > 0, "Root width must be > 0"); - Assert.True(root.Height > 0, "Root height must be > 0"); - } - - /// - /// Children must use parent-relative coordinates (the dump rects are absolute; - /// DumpLayout subtracts the parent rect to produce parent-local offsets). - /// Verify that at least the direct children of the root have Left/Top values - /// that are NOT equal to the absolute rect they had in the dump (since the root - /// was at x>0 in screen space but we place it at 0,0). - /// - [Fact] - public void Load_Inventory_ChildrenAreParentRelative() - { - var path = DumpPath(); - if (!File.Exists(path)) - return; // Skip. - - var root = DumpLayout.Load(path, "inventory", NoTex, out _); - Assert.NotNull(root); - - // If the dump has children at absolute x>=500 but the root is at 0, - // a correct parent-relative placement will give children x < 500. - // (The inventory panel root is at absolute x=500; children in the dump - // also start at x=500 — after subtraction they should land near x=0.) - bool anyChildAtAbsoluteX = false; - foreach (var child in root!.Children) - { - if (child.Left >= 490f) // would indicate absolute not relative - { - anyChildAtAbsoluteX = true; - break; - } - } - Assert.False(anyChildAtAbsoluteX, - "Children appear to have absolute coords (Left >= 490) — " + - "DumpLayout must subtract the parent rect."); - } - - /// - /// Every panel slug known in the dump must load without error. - /// This is a smoke test that the JSON parse + tree build does not - /// crash on any of the 26 panels. - /// - [Fact] - public void Load_AllSlugs_Succeed() - { - var path = DumpPath(); - if (!File.Exists(path)) - return; // Skip. - - var slugs = UiDumpModel.ListSlugs(path); - Assert.True(slugs.Count >= 20, - $"Expected >= 20 panel slugs in dump; got {slugs.Count}"); - - foreach (var slug in slugs) - { - var root = DumpLayout.Load(path, slug, NoTex, out var err); - Assert.True(root is not null || err is not null, - $"Load('{slug}') returned both null root AND null error — one must be set."); - if (root is null) - Assert.Fail($"Slug '{slug}' failed with error: {err}"); - } - } -} diff --git a/tests/AcDream.App.Tests/Studio/FixtureProviderTests.cs b/tests/AcDream.App.Tests/Studio/FixtureProviderTests.cs deleted file mode 100644 index 32794876..00000000 --- a/tests/AcDream.App.Tests/Studio/FixtureProviderTests.cs +++ /dev/null @@ -1,160 +0,0 @@ -using AcDream.App.Studio; -using AcDream.Core.Items; - -namespace AcDream.App.Tests.Studio; - -/// -/// Unit tests for and . -/// These tests have NO GL/dat dependency — they only exercise the in-memory -/// ClientObjectTable population that FixtureProvider uses. -/// -public class FixtureProviderTests -{ - /// - /// SampleData.BuildObjectTable() must place at least 6 items in the - /// player's main pack (ContainerId == PlayerGuid) and the player object - /// itself must exist with the right capacities. - /// - [Fact] - public void SampleTable_hasPackContents() - { - var t = SampleData.BuildObjectTable(); - - // Player object must exist. - var player = t.Get(SampleData.PlayerGuid); - Assert.NotNull(player); - Assert.Equal(102, player!.ItemsCapacity); - Assert.Equal(7, player.ContainersCapacity); - - // At least 6 loose items must be in the main pack. - var contents = t.GetContents(SampleData.PlayerGuid); - Assert.True(contents.Count >= 6, - $"Expected >= 6 items in player pack; got {contents.Count}"); - - // Verify the first item has a non-zero IconId and a recognised Type. - var firstId = contents[0]; - var first = t.Get(firstId); - Assert.NotNull(first); - Assert.NotEqual(0u, first!.IconId); - Assert.NotEqual(ItemType.None, first.Type); - } - - /// - /// Spot-check that the sample table also seeds a sword-like item - /// (MeleeWeapon) and a piece of armor so icon resolution has - /// recognisable item types to work with. - /// - [Fact] - public void SampleTable_hasWeaponAndArmor() - { - var t = SampleData.BuildObjectTable(); - var contents = t.GetContents(SampleData.PlayerGuid); - - bool hasMelee = false, hasArmor = false; - foreach (var guid in contents) - { - var obj = t.Get(guid); - if (obj is null) continue; - if ((obj.Type & ItemType.MeleeWeapon) != 0) hasMelee = true; - if ((obj.Type & ItemType.Armor) != 0) hasArmor = true; - } - - Assert.True(hasMelee, "Expected at least one MeleeWeapon in sample pack"); - Assert.True(hasArmor, "Expected at least one Armor item in sample pack"); - } - - /// - /// Sample table must include at least 1 equipped item whose - /// CurrentlyEquippedLocation is non-None. - /// - [Fact] - public void SampleTable_hasEquippedItems() - { - var t = SampleData.BuildObjectTable(); - - bool anyEquipped = false; - foreach (var obj in t.Objects) - { - if (obj.CurrentlyEquippedLocation != EquipMask.None) - { anyEquipped = true; break; } - } - - Assert.True(anyEquipped, "Expected at least one equipped item in sample table"); - } - - /// - /// Sample table must include at least 2 side-bag containers in the - /// player's pack (ContainerId == PlayerGuid, Type has Container bit). - /// - [Fact] - public void SampleTable_hasSideBags() - { - var t = SampleData.BuildObjectTable(); - - int bagCount = 0; - foreach (var guid in t.GetContents(SampleData.PlayerGuid)) - { - var obj = t.Get(guid); - if (obj is not null && (obj.Type & ItemType.Container) != 0) - bagCount++; - } - - Assert.True(bagCount >= 2, - $"Expected >= 2 side-bag containers in player pack; got {bagCount}"); - } - - /// - /// Side bags must match the InventoryController filter: ItemType.Container OR ItemsCapacity > 0. - /// This ensures they appear in the side-bag column even if the exact Type flag changes. - /// - [Fact] - public void SampleTable_sideBags_matchInventoryControllerFilter() - { - var t = SampleData.BuildObjectTable(); - - int bagCount = 0; - foreach (var guid in t.GetContents(SampleData.PlayerGuid)) - { - var obj = t.Get(guid); - if (obj is null) continue; - // InventoryController.Populate line ~203: Type.HasFlag(Container) OR ItemsCapacity > 0 - bool isBag = obj.Type.HasFlag(ItemType.Container) || obj.ItemsCapacity > 0; - if (isBag) bagCount++; - } - - Assert.True(bagCount >= 2, - $"Expected >= 2 items matching the side-bag filter; got {bagCount}"); - } - - /// - /// Equipped items must BOTH (a) retain CurrentlyEquippedLocation != None after - /// seeding AND (b) appear in GetContents(PlayerGuid) so PaperdollController.Populate - /// can find them. These are the two conditions PaperdollController checks on every - /// equipped item (PaperdollController.Populate: ContainerId == playerGuid AND - /// CurrentlyEquippedLocation != None). - /// - [Fact] - public void SampleTable_equippedItems_retainLocationAndAreInContents() - { - var t = SampleData.BuildObjectTable(); - var contents = new System.Collections.Generic.HashSet(t.GetContents(SampleData.PlayerGuid)); - - int equippedCount = 0; - foreach (var obj in t.Objects) - { - if (obj.CurrentlyEquippedLocation == EquipMask.None) continue; - equippedCount++; - - // Must appear in GetContents so the controller can pick them up. - Assert.True(contents.Contains(obj.ObjectId), - $"Equipped item 0x{obj.ObjectId:X8} ('{obj.Name}', loc={obj.CurrentlyEquippedLocation}) " + - $"is NOT in GetContents(PlayerGuid). PaperdollController will miss it."); - - // The equip location must survive the MoveItem call. - Assert.NotEqual(EquipMask.None, obj.CurrentlyEquippedLocation); - } - - Assert.True(equippedCount >= 3, - $"Expected >= 3 equipped items in sample table; got {equippedCount}"); - } -} diff --git a/tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs b/tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs deleted file mode 100644 index 5d478223..00000000 --- a/tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs +++ /dev/null @@ -1,60 +0,0 @@ -using AcDream.App.Studio; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using DatReaderWriter; -using DatReaderWriter.Options; - -namespace AcDream.App.Tests.Studio; - -/// -/// Unit tests for . The dat-backed test skips cleanly -/// when the real dats are not present (CI / dev machines without AC installed). -/// -public class LayoutSourceTests -{ - private static (uint handle, int width, int height) NoTex(uint _) => (1u, 1, 1); - - /// Resolve the client dat directory, or null if unavailable (skip the test). - private static string? ResolveDatDir() - { - var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) - return fromEnv; - var def = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - return Directory.Exists(def) ? def : null; - } - - /// - /// Load the vitals LayoutDesc (0x2100006C) from the real dats and verify - /// that LayoutSource returns a non-null root with the layout id findable. - /// Skips when the dats are unavailable. - /// - /// Note: the spec asserts FindElement(0x2100006Cu) — that is the layout dat - /// id, not an element id in the widget tree. The actual vitals root element id - /// is 0x100005F9 (confirmed from vitals_2100006C.json fixture). We assert - /// FindElement(0x100005F9) here which verifies the same integration path: the - /// layout was successfully loaded and the element dict was populated. - /// - [Fact] - public void LoadsDatLayout_byId() - { - var dir = ResolveDatDir(); - if (dir is null) - return; // Skip: dats not available on this machine / CI. - - using var dats = new DatCollection(dir, DatAccessType.Read); - - var src = new LayoutSource(dats, NoTex, datFont: null); - var root = src.Load(new StudioOptions(dir, 0x2100006Cu, null)); - - // root must be non-null: Import found the LayoutDesc and built the tree. - Assert.NotNull(root); - Assert.NotNull(src.CurrentLayout); - // The vitals root element id is 0x100005F9 (layout dat id 0x2100006C ≠ element id). - // Asserting FindElement verifies the byId dict was populated by the importer. - Assert.NotNull(src.CurrentLayout!.FindElement(0x100005F9u)); - Assert.Equal(LayoutSourceKind.DatLayout, src.Kind); - } -} diff --git a/tests/AcDream.App.Tests/UI/ControlsIniTests.cs b/tests/AcDream.App.Tests/UI/ControlsIniTests.cs deleted file mode 100644 index d4802e27..00000000 --- a/tests/AcDream.App.Tests/UI/ControlsIniTests.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System.Numerics; -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class ControlsIniTests -{ - [Fact] - public void Parse_ReadsSectionTokens() - { - var ini = ControlsIni.Parse( - "[title]\nheight=19\ncolor=#FFFFFFFF\nfont=font://Verdana-10-bold\n" + - "[body]\nbgcolor=#00000000\ncolor_border=#FF4F657D\n"); - - Assert.Equal("19", ini.Get("title", "height")); - Assert.Equal("font://Verdana-10-bold", ini.Get("title", "font")); - Assert.Null(ini.Get("title", "missing")); - Assert.Null(ini.Get("nosuch", "height")); - } - - [Fact] - public void TryColor_ParsesAlphaFirstHex() - { - var ini = ControlsIni.Parse("[body]\ncolor_border=#FF4F657D\n"); - Assert.True(ini.TryColor("body", "color_border", out Vector4 c)); - Assert.Equal(0xFF / 255f, c.W, 5); // alpha - Assert.Equal(0x4F / 255f, c.X, 5); // red - Assert.Equal(0x65 / 255f, c.Y, 5); // green - Assert.Equal(0x7D / 255f, c.Z, 5); // blue - } - - [Fact] - public void Load_MissingFileReturnsEmptyNotThrow() - { - var ini = ControlsIni.Load(@"Z:\does\not\exist\controls.ini"); - Assert.Null(ini.Get("title", "height")); // empty, no throw - } -} diff --git a/tests/AcDream.App.Tests/UI/DatWidgetFactoryZOrderTests.cs b/tests/AcDream.App.Tests/UI/DatWidgetFactoryZOrderTests.cs deleted file mode 100644 index 52ba5332..00000000 --- a/tests/AcDream.App.Tests/UI/DatWidgetFactoryZOrderTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI; - -public class DatWidgetFactoryZOrderTests -{ - private static (uint, int, int) NoChrome(uint id) => (0u, 0, 0); - - [Fact] - public void ZOrder_HigherZLevel_DrawsBehind() - { - // The gmInventoryUI backdrop (ZLevel 100, ReadOrder 4) must draw BEHIND the panels - // (ZLevel 0, ReadOrder 1): lower ZOrder = drawn first = behind. Before the fix, ZOrder - // came from ReadOrder only, so the backdrop (4) painted over the panels (1). - var backdrop = new ElementInfo { Type = 3, ReadOrder = 4, ZLevel = 100 }; - var panel = new ElementInfo { Type = 3, ReadOrder = 1, ZLevel = 0 }; - - var b = DatWidgetFactory.Create(backdrop, NoChrome, null); - var p = DatWidgetFactory.Create(panel, NoChrome, null); - - Assert.True(b!.ZOrder < p!.ZOrder, $"backdrop ZOrder {b.ZOrder} should be < panel ZOrder {p.ZOrder}"); - } - - [Fact] - public void ZOrder_AllZeroZLevel_EqualsReadOrder() - { - // Regression guard: a vitals-style window (every element ZLevel 0) keeps ZOrder == ReadOrder. - var e = new ElementInfo { Type = 3, ReadOrder = 5, ZLevel = 0 }; - var w = DatWidgetFactory.Create(e, NoChrome, null); - Assert.Equal(5, w!.ZOrder); - } - - [Fact] - public void ZOrder_SameZLevel_OrderedByReadOrder() - { - // Within one ZLevel, ReadOrder is the tiebreaker (higher ReadOrder draws on top). - var lo = new ElementInfo { Type = 3, ReadOrder = 2, ZLevel = 2 }; - var hi = new ElementInfo { Type = 3, ReadOrder = 7, ZLevel = 2 }; - var l = DatWidgetFactory.Create(lo, NoChrome, null); - var h = DatWidgetFactory.Create(hi, NoChrome, null); - Assert.True(l!.ZOrder < h!.ZOrder); - } -} diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs deleted file mode 100644 index 6448256e..00000000 --- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs +++ /dev/null @@ -1,294 +0,0 @@ -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class DragDropSpineTests -{ - // A spy handler used across the spine tests. - private sealed class SpyHandler : IItemListDragHandler - { - public bool AcceptResult = true; - public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastOver; - public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastDrop; - public (UiItemList list, UiItemSlot cell, ItemDragPayload payload)? LastLift; - public void OnDragLift(UiItemList list, UiItemSlot cell, ItemDragPayload p) - { LastLift = (list, cell, p); } - - public bool OnDragOver(UiItemList list, UiItemSlot cell, ItemDragPayload p) - { LastOver = (list, cell, p); return AcceptResult; } - - public void HandleDropRelease(UiItemList list, UiItemSlot cell, ItemDragPayload p) - { LastDrop = (list, cell, p); } - } - - [Fact] - public void Payload_holdsAllFields() - { - var src = new UiItemSlot(); - var p = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, src); - Assert.Equal(0x5001u, p.ObjId); - Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind); - Assert.Equal(3, p.SourceSlot); - Assert.Same(src, p.SourceCell); - } - - [Fact] - public void UiItemList_registerDragHandler_roundtrips() - { - var list = new UiItemList(_ => (0u, 0, 0)); - Assert.Null(list.DragHandler); - var h = new SpyHandler(); - list.RegisterDragHandler(h); - Assert.Same(h, list.DragHandler); - } - - // ── UiItemSlot drag-source payload/ghost ──────────────────────────────── - [Fact] - public void GetDragPayload_emptyCell_isNull() - => Assert.Null(new UiItemSlot().GetDragPayload()); - - [Fact] - public void GetDragPayload_boundCell_snapshotsFields() - { - var cell = new UiItemSlot { SlotIndex = 4, SourceKind = ItemDragSource.ShortcutBar }; - cell.SetItem(0x5001u, 0x99u); - var p = Assert.IsType(cell.GetDragPayload()); - Assert.Equal(0x5001u, p.ObjId); - Assert.Equal(ItemDragSource.ShortcutBar, p.SourceKind); - Assert.Equal(4, p.SourceSlot); - Assert.Same(cell, p.SourceCell); - } - - [Fact] - public void GetDragGhost_emptyCell_isNull() - => Assert.Null(new UiItemSlot().GetDragGhost()); - - [Fact] - public void GetDragGhost_boundCell_returnsIconTuple() - { - var cell = new UiItemSlot { Width = 32, Height = 32 }; - cell.SetItem(0x5001u, 0x99u); - var g = cell.GetDragGhost(); - Assert.NotNull(g); - Assert.Equal(0x99u, g!.Value.tex); - Assert.Equal(32, g.Value.w); - Assert.Equal(32, g.Value.h); - } - - // ── cell drop-target: DragEnter overlay + DropReleased dispatch ────────── - private static (UiItemList list, UiItemSlot cell, SpyHandler h) ListWithHandler() - { - var list = new UiItemList(_ => (1u, 1, 1)); // non-zero resolve so overlay draw is harmless - var h = new SpyHandler(); - list.RegisterDragHandler(h); - return (list, list.Cell, h); - } - - private static ItemDragPayload SomePayload() - => new(0x5001u, ItemDragSource.ShortcutBar, 0, new UiItemSlot()); - - [Fact] - public void DragEnter_setsAcceptOverlay_whenHandlerAccepts() - { - var (_, cell, h) = ListWithHandler(); - h.AcceptResult = true; - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); - Assert.Equal(UiItemSlot.DragAcceptState.Accept, cell.DragAcceptVisual); - } - - [Fact] - public void DragEnter_setsRejectOverlay_whenHandlerRejects() - { - var (_, cell, h) = ListWithHandler(); - h.AcceptResult = false; - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); - Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual); - } - - [Fact] - public void DragOver_resetsOverlayToNeutral() - { - var (_, cell, h) = ListWithHandler(); - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragOver, Payload: SomePayload())); - Assert.Equal(UiItemSlot.DragAcceptState.None, cell.DragAcceptVisual); - } - - [Fact] - public void DropReleased_accepted_dispatchesToHandler() - { - var (list, cell, h) = ListWithHandler(); - var p = SomePayload(); - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 1, Payload: p)); - Assert.NotNull(h.LastDrop); - Assert.Same(list, h.LastDrop!.Value.list); - Assert.Same(cell, h.LastDrop.Value.cell); - Assert.Same(p, h.LastDrop.Value.payload); - } - - [Fact] - public void DropReleased_dispatchesToHandler_regardlessOfData0() - { - // Retail model: reaching the cell means a real slot was hit (FinishDrag only delivers on a - // hit), so the handler is authoritative — it dispatches whether or not Data0 is set. - var (list, cell, h) = ListWithHandler(); - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload())); - Assert.NotNull(h.LastDrop); - } - - [Fact] - public void DragBegin_callsHandlerOnDragLift() - { - var (list, cell, h) = ListWithHandler(); - var p = SomePayload(); - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragBegin, Payload: p)); - Assert.NotNull(h.LastLift); - Assert.Same(list, h.LastLift!.Value.list); - Assert.Same(cell, h.LastLift.Value.cell); - Assert.Same(p, h.LastLift.Value.payload); - } - - [Fact] - public void Ghost_isSnapshottedAtBeginDrag_survivesSourceCellClearing() - { - var (root, _, cell) = RootWithBoundSlot(0x5001u); // icon tex 0x99 - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseMove(20, 10); // BeginDrag → snapshot ghost - cell.Clear(); // simulate the lift emptying the source - Assert.Equal((0x99u, 32, 32), root.DragGhostForTest); - } - - [Fact] - public void FinishDrag_overNothing_deliversNoDrop_butLiftStands() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; - list.Cell.Width = 32; list.Cell.Height = 32; - list.Cell.SetItem(0x5001u, 0x99u); - var h = new SpyHandler(); - list.RegisterDragHandler(h); - root.AddChild(list); - - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseMove(20, 10); // BeginDrag → OnDragLift - root.OnMouseUp(UiMouseButton.Left, 600, 500); // release over empty space - Assert.NotNull(h.LastLift); // lift happened - Assert.Null(h.LastDrop); // no drop dispatched (off-bar) - Assert.Null(root.DragSource); // cleaned up - } - - // ── Full UiRoot chain: arming + use-vs-drag ───────────────────────────── - // A bound, hit-testable slot inside a list, sized for the hit-test. - private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId) - { - var root = new UiRoot { Width = 800, Height = 600 }; - var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 }; - // Tests don't run OnDraw (which sizes the cell), so size the cell explicitly. - list.Cell.Width = 32; list.Cell.Height = 32; - if (itemId != 0) list.Cell.SetItem(itemId, 0x99u); - root.AddChild(list); - return (root, list, list.Cell); - } - - [Fact] - public void BeginDrag_arms_whenPayloadNonNull() - { - var (root, _, cell) = RootWithBoundSlot(0x5001u); - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseMove(20, 10); // >3px → promote to drag - Assert.Same(cell, root.DragSource); - Assert.IsType(root.DragPayload); - } - - [Fact] - public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot() - { - var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseMove(20, 10); - Assert.Null(root.DragSource); // never armed - } - - [Fact] - public void Click_withoutDrag_firesUse() - { - var (root, _, cell) = RootWithBoundSlot(0x5001u); - bool used = false; - cell.Clicked = () => used = true; - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted - Assert.True(used); - } - - [Fact] - public void CompletedDrag_doesNotFireUse() - { - var (root, _, cell) = RootWithBoundSlot(0x5001u); - bool used = false; - cell.Clicked = () => used = true; - root.OnMouseDown(UiMouseButton.Left, 10, 10); - root.OnMouseMove(20, 10); // promote to drag - root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click - Assert.False(used); - } - - // ── no-handler / orphan-cell DragEnter defaults to Reject (review carry-forward) ── - [Fact] - public void DragEnter_orphanCell_noList_defaultsToReject() - { - var cell = new UiItemSlot(); // no parent list → FindList() null - cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload())); - Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual); - } - - [Fact] - public void DragEnter_listWithoutHandler_defaultsToReject() - { - var list = new UiItemList(_ => (1u, 1, 1)); // no RegisterDragHandler - list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload())); - Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual); - } - - // ── item drag inside a Draggable window (the LIVE toolbar topology) ────── - // Regression (visual gate 2026-06-20): the slot sits inside the Draggable toolbar - // frame, so FindWindow returns the frame. An OCCUPIED slot must start an ITEM drag - // (IsDragSource), NOT move the window; an EMPTY slot falls through to whole-window - // drag (IA-12) so the bar stays movable by its empty cells / chrome. The earlier - // RootWithBoundSlot tests put the slot directly under the root (no draggable - // ancestor), so they could not catch this. - private static (UiRoot root, UiPanel frame, UiItemList list) DraggableFrameWithSlot(uint itemId) - { - var root = new UiRoot { Width = 800, Height = 600 }; - var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true }; - var list = new UiItemList(_ => (1u, 1, 1)) { Left = 5, Top = 5, Width = 32, Height = 32 }; - list.Cell.Width = 32; list.Cell.Height = 32; - if (itemId != 0) list.Cell.SetItem(itemId, 0x99u); - frame.AddChild(list); - root.AddChild(frame); - return (root, frame, list); - } - - [Fact] - public void OccupiedSlotInsideDraggableWindow_armsItemDrag_doesNotMoveWindow() - { - var (root, frame, list) = DraggableFrameWithSlot(0x5001u); - // Slot screen rect = frame(10,300)+list(5,5) → (15,305)..(47,337). Press inside, drag >3px. - root.OnMouseDown(UiMouseButton.Left, 20, 310); - root.OnMouseMove(40, 310); - Assert.Same(list.Cell, root.DragSource); // item drag armed - Assert.Equal(10f, frame.Left); // window did NOT move - Assert.Equal(300f, frame.Top); - } - - [Fact] - public void EmptySlotInsideDraggableWindow_movesWindow_notItemDrag() - { - var (root, frame, _) = DraggableFrameWithSlot(0u); // empty slot → not a drag source - root.OnMouseDown(UiMouseButton.Left, 20, 310); - root.OnMouseMove(40, 310); - Assert.Null(root.DragSource); // no item drag - Assert.Equal(30f, frame.Left); // window moved (offX=20-10=10; new Left=40-10=30) - Assert.Equal(300f, frame.Top); // y unchanged (310-10=300) - } -} diff --git a/tests/AcDream.App.Tests/UI/IconComposerTests.cs b/tests/AcDream.App.Tests/UI/IconComposerTests.cs deleted file mode 100644 index ba35d60d..00000000 --- a/tests/AcDream.App.Tests/UI/IconComposerTests.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System; -using System.IO; -using AcDream.App.UI; -using AcDream.Core.Items; -using DatReaderWriter; -using DatReaderWriter.Options; - -namespace AcDream.App.Tests.UI; - -public class IconComposerTests -{ - private static byte[] Solid(int w, int h, byte r, byte g, byte b, byte a) - { - var px = new byte[w * h * 4]; - for (int i = 0; i < w * h; i++) { px[i*4]=r; px[i*4+1]=g; px[i*4+2]=b; px[i*4+3]=a; } - return px; - } - - [Fact] - public void Compose_alphaOver_topOpaqueLayerWins() - { - var bottom = (Solid(2, 2, 255, 0, 0, 255), 2, 2); // red, opaque - var top = (Solid(2, 2, 0, 0, 255, 255), 2, 2); // blue, opaque - var (rgba, w, h) = IconComposer.Compose(new[] { bottom, top }); - Assert.Equal(2, w); Assert.Equal(2, h); - Assert.Equal(0, rgba[0]); // R - Assert.Equal(0, rgba[1]); // G - Assert.Equal(255, rgba[2]); // B — top layer won - Assert.Equal(255, rgba[3]); // A - } - - [Fact] - public void Compose_alphaOver_transparentTopKeepsBottom() - { - var bottom = (Solid(1, 1, 255, 0, 0, 255), 1, 1); - var top = (Solid(1, 1, 0, 0, 255, 0), 1, 1); // fully transparent blue - var (rgba, _, _) = IconComposer.Compose(new[] { bottom, top }); - Assert.Equal(255, rgba[0]); // bottom red preserved - Assert.Equal(0, rgba[2]); - } - - /// - /// Dat-free: when an opaque type-default underlay is prepended as layer 0, - /// Compose yields a fully-opaque result even when the base icon is semi-transparent. - /// This validates the bottom-up ordering that makes filled toolbar slots non-transparent - /// (retail IconData::RenderIcons 407524: underlay is OPAQUE Blit_Normal first). - /// - [Fact] - public void Compose_opaqueUnderlayFirst_resultIsFullyOpaque() - { - var underlay = (Solid(2, 2, 128, 64, 32, 255), 2, 2); // opaque tawny - var baseIcon = (Solid(2, 2, 0, 0, 0, 128), 2, 2); // semi-transparent black - var (rgba, w, h) = IconComposer.Compose(new[] { underlay, baseIcon }); - Assert.Equal(2, w); Assert.Equal(2, h); - // All pixels fully opaque: underlay A=255, baseIcon blends over it. - for (int i = 0; i < w * h; i++) - Assert.Equal(255, rgba[i * 4 + 3]); - } - - // ── Dat-gated golden tests ──────────────────────────────────────────────── - // These tests open the real Asheron's Call dats (ACDREAM_DAT_DIR or the default - // Documents path) and verify the EnumIDMap 0x10000004 resolve chain against the - // known golden DIDs from the dat (confirmed 2026-06-17 research). - // Golden values: IconData::RenderIcons 0058d214 + DBCache::GetDIDFromEnum 0x413940. - - private static string? ResolveDatDir() - { - var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) - return fromEnv; - var def = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - return Directory.Exists(def) ? def : null; - } - - [Fact] - public void ResolveUnderlayDid_goldenValues_matchDat() - { - var datDir = ResolveDatDir(); - if (datDir is null) - return; // dats absent (CI) — skip cleanly - - using var dats = new DatCollection(datDir, DatAccessType.Read); - // TextureCache is not needed for the resolve path; pass a null-safe stub - // via IconComposer — the underlay-resolve methods only touch _dats. - // We cannot construct TextureCache without GL, so use a bare IconComposer - // with a null cache guard: ResolveUnderlayDid is internal and pure-dat. - var composer = new IconComposer(dats, null!); - - // Golden values confirmed against C:/Users/erikn/Documents/Asheron's Call - // (IconData::RenderIcons decomp 407524; DBCache::GetDIDFromEnum 0x413940): - // MeleeWeapon (0x1) → index 1 → 0x060011CB - // Armor (0x2) → index 2 → 0x060011CF - // Clothing (0x4) → index 3 → 0x060011F3 - // Jewelry (0x8) → index 4 → 0x060011D5 - // None (0x0) → index 0x21 (fallback) → 0x060011D4 - Assert.Equal(0x060011CBu, composer.ResolveUnderlayDid(ItemType.MeleeWeapon)); - Assert.Equal(0x060011CFu, composer.ResolveUnderlayDid(ItemType.Armor)); - Assert.Equal(0x060011F3u, composer.ResolveUnderlayDid(ItemType.Clothing)); - Assert.Equal(0x060011D5u, composer.ResolveUnderlayDid(ItemType.Jewelry)); - Assert.Equal(0x060011D4u, composer.ResolveUnderlayDid(ItemType.None)); - } - - [Fact] - public void ResolveEffectDid_goldenValues_matchDat() - { - var datDir = ResolveDatDir(); - if (datDir is null) return; // dats absent (CI) — skip cleanly - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var composer = new IconComposer(dats, null!); - - // Golden values (live dat, MasterMap 0x25000000 → effect submap 0x25000009; - // index = LowestSetBit(UiEffects)+1, fallback 0x21): - // Magical (0x0001) → idx 1 → 0x060011CA - // Poisoned (0x0002) → idx 2 → 0x060011C6 - // BoostHealth (0x0004) → idx 3 → 0x06001B05 - // BoostStamina (0x0010) → idx 5 → 0x06001B06 - // Nether (0x1000) → idx 13 (absent) → fallback 0x21 → 0x060011C5 - // none (0x0000) → idx 0 (zero) → fallback 0x21 → 0x060011C5 - Assert.Equal(0x060011CAu, composer.ResolveEffectDid(0x0001u)); - Assert.Equal(0x060011C6u, composer.ResolveEffectDid(0x0002u)); - Assert.Equal(0x06001B05u, composer.ResolveEffectDid(0x0004u)); - Assert.Equal(0x06001B06u, composer.ResolveEffectDid(0x0010u)); - Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x1000u)); - Assert.Equal(0x060011C5u, composer.ResolveEffectDid(0x0000u)); - } - - [Fact] - public void TryGetEffectTile_noEffectBlack_magicalTextured() - { - var datDir = ResolveDatDir(); - if (datDir is null) return; // dats absent (CI) — skip cleanly - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var composer = new IconComposer(dats, null!); - - // effects==0 → 0x21 fallback → 0x060011C5, a 32x32 SOLID-BLACK tile. Copying it - // per-pixel blackens an icon's pure-white pixels (retail-faithful no-mana scroll edge). - Assert.True(composer.TryGetEffectTile(0u, out var black)); - Assert.Equal(32, black.Width); - Assert.Equal(32, black.Height); - Assert.True(black.Rgba8[0] <= 8 && black.Rgba8[1] <= 8 && black.Rgba8[2] <= 8); - - // Magical (0x1) → 0x060011CA, a TEXTURED blue tile (NOT a flat color) — this is the - // gradient retail copies per-pixel into the icon's white pixels (the Energy Crystal - // blue). Assert the tile is non-uniform so a future flat-color regression fails here. - Assert.True(composer.TryGetEffectTile(0x1u, out var magic)); - bool uniform = true; - for (int i = 4; i < magic.Width * magic.Height * 4 && uniform; i += 4) - if (magic.Rgba8[i] != magic.Rgba8[0] || magic.Rgba8[i + 1] != magic.Rgba8[1] || - magic.Rgba8[i + 2] != magic.Rgba8[2]) - uniform = false; - Assert.False(uniform); // textured → gradient, not flat - } - - [Fact] - public void ReplaceWhiteFromSurface_copiesSourcePixelForPureWhiteOpaque() - { - // 2x2 dest: [white-opaque, red-opaque, white-transparent, white-opaque] - var dst = new byte[] - { - 255,255,255,255, // pure white opaque → takes src(0,0) - 255, 0, 0,255, // red → untouched - 255,255,255, 0, // white but alpha 0 → untouched (not 0xFFFFFFFF) - 255,255,255,255, // pure white opaque → takes src(1,1) - }; - // 2x2 src — distinct per-pixel colors (a "gradient"). - var src = new byte[] - { - 10, 20, 30,255, // (0,0) - 40, 50, 60,255, // (1,0) - 70, 80, 90,255, // (0,1) - 100,110,120,255, // (1,1) - }; - IconComposer.ReplaceWhiteFromSurface(dst, 2, 2, src, 2, 2); - Assert.Equal(new byte[] { 10, 20, 30, 255 }, dst[0..4]); // copied src(0,0) - Assert.Equal(new byte[] { 255, 0, 0, 255 }, dst[4..8]); // untouched (not white) - Assert.Equal(new byte[] { 255, 255, 255, 0 }, dst[8..12]); // untouched (transparent) - Assert.Equal(new byte[] { 100, 110, 120, 255 }, dst[12..16]); // copied src(1,1) — per-pixel - } - - [Fact] - public void TwoStageWithEffect_copiesTilePixelBeforeUnderlay() - { - // drag = base (white pixel); copy the effect tile's pixel into the white; then over - // an opaque tawny underlay. The white pixel must become the tile's pixel in the final. - var baseIcon = (new byte[] { 255,255,255,255 }, 1, 1); // 1x1 white opaque - var drag = IconComposer.Compose(new[] { baseIcon }); - var tile = new byte[] { 0, 0, 255, 255 }; // 1x1 blue tile pixel - IconComposer.ReplaceWhiteFromSurface(drag.rgba, drag.w, drag.h, tile, 1, 1); - var underlay = (new byte[] { 105, 70, 50, 255 }, 1, 1); // tawny opaque - var final = IconComposer.Compose(new[] { underlay, (drag.rgba, drag.w, drag.h) }); - Assert.Equal(new byte[] { 0, 0, 255, 255 }, final.rgba); // tile pixel on top - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs deleted file mode 100644 index 0f92ca07..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterControllerTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using AcDream.App.Studio; -using AcDream.App.UI; -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Unit tests for and . -/// The controller CREATES the m_pMainText report element (0x1000011d) at bind time — retail's -/// gm*UI builds it at runtime; it is not a static dat element — and attaches it to the layout root. -/// No dats, no GL — pure data-wiring + report-content tests. -/// -public class CharacterControllerTests -{ - /// Bind on an empty layout, then return the created m_pMainText element. - private static UiText BindAndGetText(System.Func data) - { - var layout = FakeLayout(); - CharacterController.Bind(layout, data); - return layout.Root.Children.OfType() - .First(t => t.EventId == CharacterController.MainTextId); - } - - private static string Report(System.Func data) - => string.Join("\n", BindAndGetText(data).LinesProvider().Select(l => l.Text)); - - // ── Test 1: Bind creates m_pMainText with a non-empty report ────────────── - - [Fact] - public void Bind_CreatesMainTextWithReport() - { - var text = BindAndGetText(SampleData.SampleCharacter); - Assert.True(text.LinesProvider().Count > 0, "Expected non-empty report after Bind"); - } - - // ── Test 2: Report contains all six attribute names (decomp order 1,2,4,3,5,6) ─── - - [Fact] - public void Bind_ReportContainsAllSixAttributes() - { - var allText = Report(SampleData.SampleCharacter); - Assert.Contains("Strength", allText); - Assert.Contains("Endurance", allText); - Assert.Contains("Quickness", allText); - Assert.Contains("Coordination", allText); - Assert.Contains("Focus", allText); - Assert.Contains("Self", allText); - } - - // ── Test 3: Report contains vitals section ──────────────────────────────── - - [Fact] - public void Bind_ReportContainsVitals() - { - var allText = Report(SampleData.SampleCharacter); - Assert.Contains("Health", allText); - Assert.Contains("Stamina", allText); - Assert.Contains("Mana", allText); - } - - // ── Test 4: Report contains encumbrance / burden section ───────────────── - - [Fact] - public void Bind_ReportContainsBurden() - { - var allText = Report(SampleData.SampleCharacter); - Assert.Contains("Burden", allText); - Assert.Contains("Capacity", allText); - } - - // ── Test 5: Report contains the sample character's name ────────────────── - - [Fact] - public void Bind_ReportContainsSampleName() - => Assert.Contains("Studio Player", Report(SampleData.SampleCharacter)); - - // ── Test 6: Bind on a layout WITHOUT the element creates it (no throw) ──── - - [Fact] - public void Bind_OnEmptyLayout_CreatesElement() - { - var layout = FakeLayout(); // no MainTextId present - CharacterController.Bind(layout, SampleData.SampleCharacter); - Assert.Contains(layout.Root.Children.OfType(), - t => t.EventId == CharacterController.MainTextId); - } - - // ── Test 7: The created element carries the m_pMainText id ─────────────── - - [Fact] - public void Bind_CreatedElementHasMainTextId() - => Assert.Equal(CharacterController.MainTextId, BindAndGetText(SampleData.SampleCharacter).EventId); - - // ── Test 8: SampleCharacter fixture has non-zero attribute values ───────── - - [Fact] - public void SampleCharacter_AttributesAreNonZero() - { - var s = SampleData.SampleCharacter(); - Assert.True(s.Strength > 0, "Strength must be > 0"); - Assert.True(s.Endurance > 0, "Endurance must be > 0"); - Assert.True(s.Quickness > 0, "Quickness must be > 0"); - Assert.True(s.Coordination > 0, "Coordination must be > 0"); - Assert.True(s.Focus > 0, "Focus must be > 0"); - Assert.True(s.Self > 0, "Self must be > 0"); - } - - // ── Test 9: SampleCharacter fixture has coherent vitals ────────────────── - - [Fact] - public void SampleCharacter_VitalsAreCoherent() - { - var s = SampleData.SampleCharacter(); - Assert.True(s.HealthCurrent > 0 && s.HealthCurrent <= s.HealthMax, "Health cur must be in [1, max]"); - Assert.True(s.StaminaCurrent > 0 && s.StaminaCurrent <= s.StaminaMax, "Stamina cur must be in [1, max]"); - Assert.True(s.ManaCurrent > 0 && s.ManaCurrent <= s.ManaMax, "Mana cur must be in [1, max]"); - } - - // ── Test 10: Report contains attribute VALUES from the fixture ──────────── - - [Fact] - public void Bind_ReportContainsSampleAttributeValues() - { - var sheet = SampleData.SampleCharacter(); - var allText = Report(() => sheet); - Assert.Contains(sheet.Strength.ToString(), allText); - Assert.Contains(sheet.Endurance.ToString(), allText); - Assert.Contains(sheet.Quickness.ToString(), allText); - Assert.Contains(sheet.Coordination.ToString(), allText); - Assert.Contains(sheet.Focus.ToString(), allText); - Assert.Contains(sheet.Self.ToString(), allText); - } - - // ── Helpers ─────────────────────────────────────────────────────────────── - - private static ImportedLayout FakeLayout(params (uint id, UiElement e)[] items) - { - var dict = new Dictionary(); - var root = new UiPanel(); - foreach (var (id, e) in items) - { - root.AddChild(e); - dict[id] = e; - } - return new ImportedLayout(root, dict); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs deleted file mode 100644 index 18338f8d..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/CharacterStatControllerTests.cs +++ /dev/null @@ -1,920 +0,0 @@ -using AcDream.App.Studio; -using AcDream.App.UI; -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Unit tests for — the Attributes-tab controller that -/// binds the REAL importer-mounted header + 9-row list + footer elements (no created overlay). -/// Pure data wiring against fake layouts; no dats, no GL. -/// -/// Pass 1 tests verify header/XP meter/attribute list/footer State-A binding. -/// Pass 2 tests verify: (a) row click → selection toggle; (b) footer State-B content; -/// (c) raise-button affordability; (d) tab button states. -/// -public class CharacterStatControllerTests -{ - // ── Header labels bind to the sheet ────────────────────────────────────── - - [Fact] - public void Bind_SetsNameLabel() - { - var name = new UiText(); - var layout = Fake((CharacterStatController.NameId, name)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - Assert.Equal("Studio Player", name.LinesProvider()[0].Text); - } - - [Fact] - public void Bind_SetsLevelLabel() - { - var level = new UiText(); - var layout = Fake((CharacterStatController.LevelId, level)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - Assert.Equal("126", level.LinesProvider()[0].Text); - } - - [Fact] - public void Bind_SetsHeritageAndPkLabels() - { - var heritage = new UiText(); - var pk = new UiText(); - var layout = Fake((CharacterStatController.HeritageId, heritage), - (CharacterStatController.PkStatusId, pk)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - Assert.Equal("Aluvian Heritage", heritage.LinesProvider()[0].Text); - Assert.Equal("Non-Player Killer", pk.LinesProvider()[0].Text); - } - - // ── XP meter fill ──────────────────────────────────────────────────────── - - [Fact] - public void Bind_SetsXpMeterFill() - { - var meter = new UiMeter(); - var layout = Fake((CharacterStatController.XpMeterId, meter)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var fill = meter.Fill(); - Assert.NotNull(fill); - Assert.True(System.MathF.Abs(fill!.Value - 0.63f) < 0.001f, $"expected ~0.63, got {fill}"); - } - - // ── Attribute list — 9 rows in list box 0x1000023D ─────────────────────── - - [Fact] - public void Bind_AttributeList_Has9Rows() - { - var list = new UiPanel(); - var layout = Fake((CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - // Rows are UiClickablePanel which inherits UiPanel, so OfType matches. - var rows = list.Children.OfType().ToList(); - Assert.Equal(9, rows.Count); - } - - [Fact] - public void Bind_AttributeList_RowsAreClickablePanels() - { - var list = new UiPanel(); - var layout = Fake((CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var rows = list.Children.OfType().ToList(); - Assert.Equal(9, rows.Count); - // All rows must have an OnClick wired (not null) and ClickThrough = false. - foreach (var row in rows) - { - Assert.NotNull(row.OnClick); - Assert.False(row.ClickThrough, "clickable row must accept pointer hits"); - } - } - - [Fact] - public void Bind_AttributeList_EachRowHasRightAlignedValueLabel() - { - var list = new UiPanel(); - var layout = Fake((CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var rows = list.Children.OfType().ToList(); - Assert.Equal(9, rows.Count); - foreach (var row in rows) - { - var texts = row.Children.OfType().ToList(); - Assert.True(texts.Count >= 2, "each row must have at least name + value UiText"); - var valueEl = texts[^1]; - Assert.True(valueEl.RightAligned, "value label must be RightAligned"); - } - } - - [Fact] - public void Bind_AttributeList_RowNamesInRetailOrder() - { - var list = new UiPanel(); - var layout = Fake((CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var rows = list.Children.OfType().ToList(); - Assert.Equal(9, rows.Count); - - string[] expectedNames = - { - "Strength", "Endurance", "Coordination", "Quickness", "Focus", "Self", - "Health", "Stamina", "Mana", - }; - - for (int i = 0; i < 9; i++) - { - var texts = rows[i].Children.OfType().ToList(); - Assert.True(texts.Count >= 2, $"row {i} must have name + value"); - string rowName = texts[1].LinesProvider()[0].Text; - Assert.Equal(expectedNames[i], rowName); - } - } - - [Fact] - public void Bind_AttributeList_RowValues_AttributeIntegersAndVitalsCurMax() - { - var list = new UiPanel(); - var layout = Fake((CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var rows = list.Children.OfType().ToList(); - - string ValueOf(UiPanel row) => row.Children.OfType().ToList()[^1].LinesProvider()[0].Text; - - Assert.Equal("200", ValueOf(rows[0])); // Strength - Assert.Equal("10", ValueOf(rows[1])); // Endurance - Assert.Equal("10", ValueOf(rows[2])); // Coordination - Assert.Equal("200", ValueOf(rows[3])); // Quickness - Assert.Equal("10", ValueOf(rows[4])); // Focus - Assert.Equal("10", ValueOf(rows[5])); // Self - Assert.Equal("5/5", ValueOf(rows[6])); // Health - Assert.Equal("10/10", ValueOf(rows[7])); // Stamina - Assert.Equal("10/10", ValueOf(rows[8])); // Mana - } - - [Fact] - public void Bind_AttributeList_IconHasBackgroundSpriteWhenResolverProvided() - { - var list = new UiPanel(); - var layout = Fake((CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter, - spriteResolve: id => (id, 16, 16)); - - var rows = list.Children.OfType().ToList(); - Assert.Equal(9, rows.Count); - - var iconEl = rows[0].Children.OfType().First(); - Assert.Equal(0x060002C8u, iconEl.BackgroundSprite); - - var healthIcon = rows[6].Children.OfType().First(); - Assert.Equal(0x06004C3Bu, healthIcon.BackgroundSprite); - } - - [Fact] - public void Bind_AttributeList_IconBackgroundSprite_ZeroWhenNoResolver() - { - var list = new UiPanel(); - var layout = Fake((CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null); - - var rows = list.Children.OfType().ToList(); - Assert.Equal(9, rows.Count); - foreach (var row in rows) - { - var iconEl = row.Children.OfType().First(); - Assert.Equal(0u, iconEl.BackgroundSprite); - } - } - - // ── Footer State A ──────────────────────────────────────────────────────── - - [Fact] - public void Bind_FooterStateA_TitleIsSelectPrompt() - { - var title = new UiText(); - var layout = Fake((CharacterStatController.FooterTitleId, title)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - // Initial state (nothing selected): State-A title. - Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text); - } - - [Fact] - public void Bind_FooterStateA_Line1LabelIsSkillCreditsAvailable() - { - var lbl = new UiText(); - var layout = Fake((CharacterStatController.FooterLine1Label, lbl)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - Assert.Equal("Skill Credits Available:", lbl.LinesProvider()[0].Text); - } - - [Fact] - public void Bind_FooterStateA_Line1ValueIsSkillCredits() - { - var val = new UiText(); - var layout = Fake((CharacterStatController.FooterLine1Value, val)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - Assert.Equal("96", val.LinesProvider()[0].Text); - } - - [Fact] - public void Bind_FooterStateA_Line2LabelIsUnassignedExperience() - { - var lbl = new UiText(); - var layout = Fake((CharacterStatController.FooterLine2Label, lbl)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text); - } - - [Fact] - public void Bind_FooterStateA_Line2ValueIsUnassignedXp() - { - var val = new UiText(); - var layout = Fake((CharacterStatController.FooterLine2Value, val)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var expected = (87_757_321_741L).ToString("N0"); - Assert.Equal(expected, val.LinesProvider()[0].Text); - } - - // ── Pass 2: Row selection → Footer State B ─────────────────────────────── - - [Fact] - public void RowClick_SelectRow4Focus_FooterStateBShowsFocusTitle() - { - // Focus is index 4 in AttrRows. SampleData Focus = 10. Cost = 110. - var title = new UiText(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.FooterTitleId, title), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - // Simulate a click on row 4 (Focus). - var rows = list.Children.OfType().ToList(); - Assert.Equal(9, rows.Count); - rows[4].OnClick!(); - - // Footer title should now be "Focus: 10". - Assert.Equal("Focus: 10", title.LinesProvider()[0].Text); - } - - [Fact] - public void RowClick_SelectRow4Focus_FooterLine1LabelIsExperienceToRaise() - { - var lbl = new UiText(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.FooterLine1Label, lbl), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[4].OnClick!(); - - Assert.Equal("Experience To Raise:", lbl.LinesProvider()[0].Text); - } - - [Fact] - public void RowClick_SelectRow4Focus_FooterLine1ValueIsRaiseCost() - { - var val = new UiText(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.FooterLine1Value, val), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[4].OnClick!(); - - // Focus raise cost = 110 (SampleData fixture). - Assert.Equal((110L).ToString("N0"), val.LinesProvider()[0].Text); - } - - [Fact] - public void RowClick_SelectRow4Focus_FooterLine2LabelIsUnassignedExperience() - { - var lbl = new UiText(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.FooterLine2Label, lbl), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[4].OnClick!(); - - Assert.Equal("Unassigned Experience:", lbl.LinesProvider()[0].Text); - } - - [Fact] - public void RowClick_SelectRow4Focus_FooterLine2ValueIsUnassignedXp() - { - var val = new UiText(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.FooterLine2Value, val), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[4].OnClick!(); - - // UnassignedXp = 87_757_321_741L - var expected = (87_757_321_741L).ToString("N0"); - Assert.Equal(expected, val.LinesProvider()[0].Text); - } - - // ── Pass 2: Toggle deselects ────────────────────────────────────────────── - - [Fact] - public void RowClick_ToggleSameRow_ReturnsToFooterStateA() - { - var title = new UiText(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.FooterTitleId, title), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var rows = list.Children.OfType().ToList(); - - // Select Focus (row 4). - rows[4].OnClick!(); - Assert.Equal("Focus: 10", title.LinesProvider()[0].Text); - - // Click the same row again → deselect. - rows[4].OnClick!(); - Assert.Equal("Select an Attribute to Improve", title.LinesProvider()[0].Text); - } - - [Fact] - public void RowClick_SwitchRow_UpdatesToNewRow() - { - var title = new UiText(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.FooterTitleId, title), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var rows = list.Children.OfType().ToList(); - - // Select Endurance (row 1, value=10). - rows[1].OnClick!(); - Assert.Equal("Endurance: 10", title.LinesProvider()[0].Text); - - // Select Self (row 5, value=10). - rows[5].OnClick!(); - Assert.Equal("Self: 10", title.LinesProvider()[0].Text); - } - - // ── Pass 2: Row highlight ───────────────────────────────────────────────── - - [Fact] - public void RowClick_SelectRow_HighlightsSelectedAndClearsOthers() - { - var list = new UiPanel(); - var layout = Fake((CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var rows = list.Children.OfType().ToList(); - - // All rows start transparent. - Assert.All(rows, r => Assert.Equal(0f, r.BackgroundColor.W)); - - // Select row 2 (Coordination). - rows[2].OnClick!(); - Assert.NotEqual(0f, rows[2].BackgroundColor.W); // highlighted - Assert.Equal(0f, rows[0].BackgroundColor.W); // others cleared - Assert.Equal(0f, rows[1].BackgroundColor.W); - } - - [Fact] - public void RowClick_Deselect_ClearsHighlight() - { - var list = new UiPanel(); - var layout = Fake((CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var rows = list.Children.OfType().ToList(); - rows[2].OnClick!(); // select - rows[2].OnClick!(); // deselect - - Assert.Equal(0f, rows[2].BackgroundColor.W); - } - - [Fact] - public void RowClick_WithSpriteResolve_SelectedRowHasHighlightSprite() - { - // When spriteResolve is provided, the selected row must use sprite 0x06001397 - // (retail Button-state-6 dark bar) instead of the translucent gold BackgroundColor. - var list = new UiPanel(); - var layout = Fake((CharacterStatController.ListBoxId, list)); - - // Minimal sprite resolver — returns a fake non-zero handle so UiPanel draws it. - static (uint, int, int) FakeResolve(uint id) => (1u, 32, 8); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter, - spriteResolve: FakeResolve); - - var rows = list.Children.OfType().ToList(); - rows[2].OnClick!(); - - Assert.Equal(0x06001397u, rows[2].BackgroundSprite); // selected → sprite - Assert.Equal(0f, rows[2].BackgroundColor.W); // no tint - Assert.Equal(0u, rows[0].BackgroundSprite); // others cleared - Assert.Equal(0u, rows[1].BackgroundSprite); - } - - [Fact] - public void RowClick_WithSpriteResolve_Deselect_ClearsSprite() - { - var list = new UiPanel(); - var layout = Fake((CharacterStatController.ListBoxId, list)); - static (uint, int, int) FakeResolve(uint id) => (1u, 32, 8); - CharacterStatController.Bind(layout, SampleData.SampleCharacter, - spriteResolve: FakeResolve); - - var rows = list.Children.OfType().ToList(); - rows[2].OnClick!(); // select - rows[2].OnClick!(); // deselect - - Assert.Equal(0u, rows[2].BackgroundSprite); - } - - // ── Pass 2: Raise button affordability ─────────────────────────────────── - - [Fact] - public void RaiseButtons_InitiallyHidden() - { - var btn1 = MakeButton(); - var btn10 = MakeButton(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.RaiseOneId, btn1), - (CharacterStatController.RaiseTenId, btn10), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - Assert.False(btn1.Visible, "raise×1 must start hidden"); - Assert.False(btn10.Visible, "raise×10 must start hidden"); - } - - [Fact] - public void RaiseButtons_AffordableRow_ShowsNormalState() - { - // Focus row (index 4) cost=110, UnassignedXp=87_757_321_741 → affordable. - var btn1 = MakeButton(); - var btn10 = MakeButton(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.RaiseOneId, btn1), - (CharacterStatController.RaiseTenId, btn10), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - list.Children.OfType().ToList()[4].OnClick!(); // select Focus - - Assert.True(btn1.Visible, "raise×1 visible on selection"); - Assert.True(btn10.Visible, "raise×10 visible on selection"); - Assert.Equal("Normal", btn1.ActiveState); - Assert.Equal("Normal", btn10.ActiveState); - } - - [Fact] - public void RaiseButtons_MaxedRow_ShowsGhostedState() - { - // Strength row (index 0) cost=0 → disabled. UnassignedXp is irrelevant. - var btn1 = MakeButton(); - var btn10 = MakeButton(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.RaiseOneId, btn1), - (CharacterStatController.RaiseTenId, btn10), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - list.Children.OfType().ToList()[0].OnClick!(); // select Strength (cost=0) - - Assert.True(btn1.Visible, "raise button visible even when disabled"); - Assert.Equal("Ghosted", btn1.ActiveState); - Assert.Equal("Ghosted", btn10.ActiveState); - } - - [Fact] - public void RaiseButtons_Deselect_HidesButtons() - { - var btn1 = MakeButton(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.RaiseOneId, btn1), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var rows = list.Children.OfType().ToList(); - rows[4].OnClick!(); // select - Assert.True(btn1.Visible); - rows[4].OnClick!(); // deselect - Assert.False(btn1.Visible, "raise button hidden after deselect"); - } - - // ── Pass 2: Tab button states ───────────────────────────────────────────── - // Tab sprites are added to layout.Root (NOT to the tab group elements), so - // the tab groups keep Children.Count==0 and survive the page-visibility pass. - // AddTabSpritesToRoot() adds 3 sprite UiTexts + 1 label UiText per tab to the - // root when a spriteResolve is provided; nothing is added when spriteResolve=null. - - [Fact] - public void TabButtons_NoSpriteResolve_AddsNoSpriteChildrenToRoot() - { - // When spriteResolve is null, AddTabSpritesToRoot is not called — no sprites added. - var layout = Fake(); // empty fake layout with just the root UiPanel - int rootChildCountBefore = layout.Root.Children.Count; - - CharacterStatController.Bind(layout, SampleData.SampleCharacter, spriteResolve: null); - - // No extra children added to root when spriteResolve is null. - Assert.Equal(rootChildCountBefore, layout.Root.Children.Count); - } - - [Fact] - public void TabButtons_AttributesGroup_AddsOpenSpriteIdsToRoot() - { - // Attributes tab (isOpen=true): 3 sprite children with Open sprite ids on ROOT. - // The tab group element itself has 0 children (sprites go to root, not the group). - var layout = Fake(); // minimal fake - - CharacterStatController.Bind(layout, SampleData.SampleCharacter, - spriteResolve: id => (id, 16, 16)); - - // Root should contain the Open sprite ids among all tab sprite children. - var sprites = layout.Root.Children.OfType() - .Where(t => t.BackgroundSprite != 0u).ToList(); - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D92u); // Open left-cap - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D94u); // Open center - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D96u); // Open right-cap - } - - [Fact] - public void TabButtons_SkillsAndTitles_AddClosedSpriteIdsToRoot() - { - // Skills + Titles tabs (isOpen=false): Closed sprite ids appear on root. - var layout = Fake(); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter, - spriteResolve: id => (id, 16, 16)); - - var sprites = layout.Root.Children.OfType() - .Where(t => t.BackgroundSprite != 0u).ToList(); - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D93u); // Closed left-cap - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D95u); // Closed center - Assert.Contains(sprites, t => t.BackgroundSprite == 0x06005D97u); // Closed right-cap - } - - [Fact] - public void TabButtons_WithSpriteResolve_AddsAllThreeTabsToRoot() - { - // With spriteResolve: all 3 tabs inject 4 children each (3 sprites + 1 label) - // = 12 sprite children total across 3 tabs on the root. - var layout = Fake(); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter, - spriteResolve: id => (id, 16, 16)); - - // 3 tabs × 3 sprites = 9 sprite UiTexts on root. - var sprites = layout.Root.Children.OfType() - .Where(t => t.BackgroundSprite != 0u).ToList(); - Assert.Equal(9, sprites.Count); - } - - // ── Affordability helpers (GetRaiseCost) ────────────────────────────────── - - [Fact] - public void GetRaiseCost_Index4Focus_Returns110() - { - var sheet = SampleData.SampleCharacter(); - Assert.Equal(110L, CharacterStatController.GetRaiseCost(sheet, 4)); - } - - [Fact] - public void GetRaiseCost_Index0Strength_Returns0() - { - var sheet = SampleData.SampleCharacter(); - Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 0)); - } - - [Fact] - public void GetRaiseCost_OutOfRange_Returns0() - { - var sheet = SampleData.SampleCharacter(); - Assert.Equal(0L, CharacterStatController.GetRaiseCost(sheet, 99)); - } - - // ── GetRowName helper ───────────────────────────────────────────────────── - - [Fact] - public void GetRowName_Index0_ReturnsStrength() - => Assert.Equal("Strength", CharacterStatController.GetRowName(0)); - - [Fact] - public void GetRowName_Index4_ReturnsFocus() - => Assert.Equal("Focus", CharacterStatController.GetRowName(4)); - - [Fact] - public void GetRowName_Index6_ReturnsHealth() - => Assert.Equal("Health", CharacterStatController.GetRowName(6)); - - [Fact] - public void GetRowName_NegativeIndex_ReturnsEmpty() - => Assert.Equal(string.Empty, CharacterStatController.GetRowName(-1)); - - // ── SampleData sanity ───────────────────────────────────────────────────── - - [Fact] - public void SampleCharacter_SkillCredits_Is96() - => Assert.Equal(96, SampleData.SampleCharacter().SkillCredits); - - [Fact] - public void SampleCharacter_UnassignedXp_IsSet() - => Assert.Equal(87_757_321_741L, SampleData.SampleCharacter().UnassignedXp); - - [Fact] - public void SampleCharacter_AttributeRaiseCosts_HasNineEntries() - { - var costs = SampleData.SampleCharacter().AttributeRaiseCosts; - Assert.NotNull(costs); - Assert.Equal(9, costs.Length); - } - - [Fact] - public void SampleCharacter_AttributeRaiseCosts_FocusAt110() - => Assert.Equal(110L, SampleData.SampleCharacter().AttributeRaiseCosts[4]); - - [Fact] - public void SampleCharacter_AttributeRaiseCosts_StrengthAt0() - => Assert.Equal(0L, SampleData.SampleCharacter().AttributeRaiseCosts[0]); - - // ── UiText flag sanity ──────────────────────────────────────────────────── - - [Fact] - public void UiText_RightAligned_DefaultFalse() - { - var t = new UiText(); - Assert.False(t.RightAligned); - } - - [Fact] - public void UiText_RightAligned_CanBeSetTrue() - { - var t = new UiText { RightAligned = true }; - Assert.True(t.RightAligned); - } - - // ── Header captions (new — LevelCaptionId + TotalXpLabelId) ───────────── - - [Fact] - public void Bind_LevelCaptionId_SetsCharacterLevelText() - { - var caption = new UiText(); - var layout = Fake((CharacterStatController.LevelCaptionId, caption)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - // 2-line caption: "Character" (top) / "Level" (bottom) so it fits the 65px element. - var lines = caption.LinesProvider(); - Assert.True(lines.Count >= 1, "LevelCaption must provide at least one line"); - Assert.Equal("Character", lines[0].Text); - Assert.False(caption.Centered, "LevelCaption must be left-justified (Centered=false)"); - Assert.False(caption.RightAligned, "LevelCaption must be left-justified (RightAligned=false)"); - } - - [Fact] - public void Bind_TotalXpLabelId_SetsTotalExperienceXpText() - { - var lbl = new UiText(); - var layout = Fake((CharacterStatController.TotalXpLabelId, lbl)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - Assert.Equal("Total Experience (XP):", lbl.LinesProvider()[0].Text); - Assert.False(lbl.Centered, "TotalXpLabel must be left-justified (Centered=false)"); - Assert.False(lbl.RightAligned, "TotalXpLabel must be left-justified (RightAligned=false)"); - } - - // ── Polish Commit 1: name white, Infinity!, white footer title ───────────── - - [Fact] - public void Bind_NameColor_IsWhite() - { - // Retail: name "Horan" is WHITE, not gold. (2026-06-26 ref) - var name = new UiText(); - var layout = Fake((CharacterStatController.NameId, name)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var color = name.LinesProvider()[0].Color; - Assert.Equal(1f, color.X, precision: 3); - Assert.Equal(1f, color.Y, precision: 3); - Assert.Equal(1f, color.Z, precision: 3); - Assert.Equal(1f, color.W, precision: 3); - } - - [Fact] - public void RowClick_MaxedRow_FooterLine1ValueIsInfinity() - { - // Strength (index 0) cost=0 → "Infinity!" per retail spec. - var val = new UiText(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.FooterLine1Value, val), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[0].OnClick!(); // Strength - - Assert.Equal("Infinity!", val.LinesProvider()[0].Text); - } - - [Fact] - public void RowClick_SelectedFooterTitle_IsWhite() - { - // State B footer title must be WHITE (retail 2026-06-26 ref). - var title = new UiText(); - var list = new UiPanel(); - var layout = Fake( - (CharacterStatController.FooterTitleId, title), - (CharacterStatController.ListBoxId, list)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - list.Children.OfType().ToList()[4].OnClick!(); // Focus - - var color = title.LinesProvider()[0].Color; - Assert.Equal(1f, color.X, precision: 3); - Assert.Equal(1f, color.Y, precision: 3); - Assert.Equal(1f, color.Z, precision: 3); - Assert.Equal(1f, color.W, precision: 3); - } - - [Fact] - public void Bind_FooterStateA_TitleColor_IsBodyNotWhite() - { - // State A title is body (parchment), not white. - var title = new UiText(); - var layout = Fake((CharacterStatController.FooterTitleId, title)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - // Body = (0.92, 0.90, 0.82, 1.0) — check it's not pure white. - var color = title.LinesProvider()[0].Color; - Assert.True(color.X < 1f || color.Y < 1f || color.Z < 1f, - "State-A title should be body/parchment color, not pure white"); - } - - [Fact] - public void Bind_LevelCaptionId_SetsTwoLines() - { - // Retail: level caption is "Character" / "Level" on two lines (not truncated). - var caption = new UiText(); - var layout = Fake((CharacterStatController.LevelCaptionId, caption)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - var lines = caption.LinesProvider(); - Assert.Equal(2, lines.Count); - Assert.Equal("Character", lines[0].Text); - Assert.Equal("Level", lines[1].Text); - } - - // ── Robustness ──────────────────────────────────────────────────────────── - - [Fact] - public void Bind_MissingElements_DoesNotThrow() - => CharacterStatController.Bind(Fake(), SampleData.SampleCharacter); - - // ── Fix 5: XP meter text children bound via FindElement ────────────────── - - /// - /// Fix 5: the XP label (0x10000237) is a dat-origin UiText child of the XP meter - /// (now built by the importer). After Bind(), FindElement must return a UiText with - /// a LinesProvider that emits "XP for next level:". - /// - [Fact] - public void Bind_XpMeter_XpNextLabel_IsBoundViaFindElement() - { - var meter = new UiMeter(); - var xpLabel = new UiText(); - // Attach the label as a child of the meter so it matches the real importer layout. - meter.AddChild(xpLabel); - var layout = Fake( - (CharacterStatController.XpMeterId, meter), - (CharacterStatController.XpNextLabelId, xpLabel)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - Assert.NotNull(xpLabel.LinesProvider); - var lines = xpLabel.LinesProvider(); - Assert.Single(lines); - Assert.Equal("XP for next level:", lines[0].Text); - } - - /// - /// Fix 5: the XP value (0x10000238) is bound to the XpToNextLevel string. - /// ClickThrough=true and RightAligned=true must be set by the controller. - /// - [Fact] - public void Bind_XpMeter_XpNextValue_IsBoundViaFindElement() - { - var meter = new UiMeter(); - var xpValue = new UiText(); - meter.AddChild(xpValue); - var layout = Fake( - (CharacterStatController.XpMeterId, meter), - (CharacterStatController.XpNextValueId, xpValue)); - - CharacterStatController.Bind(layout, SampleData.SampleCharacter); - - Assert.NotNull(xpValue.LinesProvider); - Assert.True(xpValue.ClickThrough, "XP value overlay must be ClickThrough"); - Assert.True(xpValue.RightAligned, "XP value overlay must be RightAligned"); - - var lines = xpValue.LinesProvider(); - Assert.Single(lines); - // XpToNextLevel from SampleData = 42_000_000L formatted as "42,000,000" - Assert.Equal((42_000_000L).ToString("N0"), lines[0].Text); - } - - /// - /// Fix 5: if XpNextLabelId / XpNextValueId are absent from the layout (the old - /// ConsumesDatChildren path, or a test layout that doesn't include them), Bind() - /// must not throw — the meter Fill is still bound. - /// - [Fact] - public void Bind_XpMeter_MissingTextChildren_DoesNotThrow() - { - var meter = new UiMeter(); - var layout = Fake((CharacterStatController.XpMeterId, meter)); - - // No XpNextLabelId or XpNextValueId in the layout. - var ex = Record.Exception(() => - CharacterStatController.Bind(layout, SampleData.SampleCharacter)); - - Assert.Null(ex); - // Fill must still be bound. - Assert.NotNull(meter.Fill()); - } - - // ── Helpers ────────────────────────────────────────────────────────────── - - /// Manufacture a minimal UiButton with a fake ElementInfo (no dat sprites). - private static UiButton MakeButton() - { - var info = new ElementInfo { Id = 0, Type = 1 }; - return new UiButton(info, static _ => (0u, 0, 0)); - } - - private static ImportedLayout Fake(params (uint id, UiElement e)[] items) - { - var dict = new Dictionary(); - var root = new UiPanel(); - foreach (var (id, e) in items) - { - root.AddChild(e); - dict[id] = e; - } - return new ImportedLayout(root, dict); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs deleted file mode 100644 index 836adbdc..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutConformanceTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Dat-free conformance tests for the committed chat_21000006.json golden fixture. -/// Verifies that LayoutImporter.ImportInfos correctly resolves the BaseElement / -/// BaseLayoutId inheritance chain for the chat window (LayoutDesc 0x21000006). -/// -public class ChatLayoutConformanceTests -{ - private static ElementInfo? Find(ElementInfo n, uint id) - { - if (n.Id == id) return n; - foreach (var c in n.Children) - { - var f = Find(c, id); - if (f is not null) return f; - } - return null; - } - - [Fact] - public void ChatFixture_ResolvesKnownElements() - { - var root = FixtureLoader.LoadChatInfos(); - Assert.NotNull(Find(root, 0x10000011u)); // transcript - Assert.NotNull(Find(root, 0x10000016u)); // input - Assert.NotNull(Find(root, 0x10000012u)); // scrollbar track - Assert.NotNull(Find(root, 0x10000014u)); // channel menu - Assert.NotNull(Find(root, 0x10000019u)); // send button - Assert.NotNull(Find(root, 0x1000046Fu)); // max/min button - } - - [Fact] - public void ChatFixture_ResolvedTypes_MatchRetailRegistry() - { - var root = FixtureLoader.LoadChatInfos(); - Assert.Equal(6u, Find(root, 0x10000014u)!.Type); // Menu - Assert.Equal(11u, Find(root, 0x10000012u)!.Type); // Scrollbar - Assert.Equal(1u, Find(root, 0x10000019u)!.Type); // Button (Send) - Assert.Equal(1u, Find(root, 0x1000046Fu)!.Type); // Button (Max/Min) - Assert.Equal(12u, Find(root, 0x10000011u)!.Type); // Text/style-prototype (transcript) - Assert.Equal(12u, Find(root, 0x10000016u)!.Type); // Text/style-prototype (input) - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs b/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs deleted file mode 100644 index cdc89c5f..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/ChatLayoutFixtureGenerator.cs +++ /dev/null @@ -1,39 +0,0 @@ -using System; -using System.IO; -using System.Runtime.CompilerServices; -using System.Text.Json; -using AcDream.App.UI.Layout; -using DatReaderWriter; -using DatReaderWriter.Options; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// One-off generator for the committed chat golden fixture. Skipped by default — -/// run manually with the real dats present (set ACDREAM_DAT_DIR) to regenerate -/// chat_21000006.json, then commit it. Mirrors how vitals_2100006C.json was made. -/// -public class ChatLayoutFixtureGenerator -{ - [Fact(Skip = "manual: regenerates the committed chat fixture; needs the real dats (ACDREAM_DAT_DIR)")] - public void GenerateChatFixture() - { - var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - using var dats = new DatCollection(datDir, DatAccessType.Read); - var info = LayoutImporter.ImportInfos(dats, 0x21000006u); - Assert.NotNull(info); - - var json = JsonSerializer.Serialize(info, new JsonSerializerOptions - { - IncludeFields = true, - WriteIndented = true, - }); - File.WriteAllText(FixturePath(), json); - } - - // Resolve the SOURCE fixtures dir (not bin/) from this file's compile-time path. - private static string FixturePath([CallerFilePath] string thisFile = "") - => Path.Combine(Path.GetDirectoryName(thisFile)!, "fixtures", "chat_21000006.json"); -} diff --git a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs deleted file mode 100644 index aab080cd..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/ChatWindowControllerTests.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System.Collections.Generic; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using AcDream.Core.Chat; -using AcDream.UI.Abstractions; -using AcDream.UI.Abstractions.Panels.Chat; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Smoke tests for — no dats, no GL. -/// -/// Building the Type-12 "skipped" elements via the pure -/// path is the correct approach: we build a synthetic info tree that reflects the -/// real chat layout hierarchy (root → transcript panel + input bar as Type-3 -/// containers, with Type-12 children for transcript + input, plus a Type-3 track -/// and menu), call to get the widget tree -/// (Type-12 children skipped, Type-3 parents created), then call -/// which reads rects from the info tree -/// and places behavioral widgets under the parent containers. -/// -public class ChatWindowControllerTests -{ - // ── Null-resolve helper (no GL needed) ───────────────────────────────── - private static (uint, int, int) NoTex(uint _) => (0u, 0, 0); - - // ── Capture bus — records every Publish call ──────────────────────────── - private sealed class CaptureBus : ICommandBus - { - public readonly List Published = new(); - public void Publish(T cmd) where T : notnull => Published.Add(cmd!); - } - - // ── Synthetic element tree matching the real chat layout topology ──────── - - /// - /// Build a minimal synthetic ElementInfo tree that mirrors the real chat - /// layout (0x21000006) with enough fidelity for Bind to succeed: - /// root (Type-3) - /// transcriptPanel (Type-3) [0x10000010] - /// transcript (Type-12, no media) [0x10000011] ← built as UiText by factory; Bind binds in place - /// track (Type-3) [0x10000012] ← Type-3 in test (not Type-11); Bind skips scrollbar bind - /// inputBar (Type-3) [0x10000013] - /// menu (Type-6) [0x10000014] - /// input (Type-12, no media) [0x10000016] ← built as UiText by factory; Bind removes + replaces with UiField - /// send (Type-3) [0x10000019] - /// maxmin (Type-3) [0x1000046F] - /// - private static (ElementInfo rootInfo, ImportedLayout layout, ChatVM vm) BuildTestTree() - { - var transcriptNode = new ElementInfo - { - Id = 0x10000011u, Type = 12, // Type-12, no media → skipped by factory - X = 16, Y = 0, Width = 458, Height = 74, - }; - var trackNode = new ElementInfo - { - Id = 0x10000012u, Type = 3, - X = 474, Y = 6, Width = 16, Height = 68, - }; - var transcriptPanel = new ElementInfo - { - Id = 0x10000010u, Type = 3, X = 0, Y = 9, Width = 490, Height = 74, - }; - transcriptPanel.Children.Add(transcriptNode); - transcriptPanel.Children.Add(trackNode); - - var menuNode = new ElementInfo - { - Id = 0x10000014u, Type = 6, X = 0, Y = 0, Width = 46, Height = 17, - }; - var inputNode = new ElementInfo - { - Id = 0x10000016u, Type = 12, // Type-12, no media → skipped by factory - X = 46, Y = 0, Width = 398, Height = 17, - }; - var sendNode = new ElementInfo - { - Id = 0x10000019u, Type = 3, X = 444, Y = 0, Width = 46, Height = 17, - }; - var inputBar = new ElementInfo - { - Id = 0x10000013u, Type = 3, X = 0, Y = 83, Width = 490, Height = 17, - }; - inputBar.Children.Add(menuNode); - inputBar.Children.Add(inputNode); - inputBar.Children.Add(sendNode); - - var maxMinNode = new ElementInfo - { - Id = 0x1000046Fu, Type = 3, X = 474, Y = 0, Width = 16, Height = 16, - }; - - var root = new ElementInfo - { - Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100, - }; - root.Children.Add(transcriptPanel); - root.Children.Add(inputBar); - root.Children.Add(maxMinNode); - - var layout = LayoutImporter.Build(root, NoTex, null); - var vm = new ChatVM(new ChatLog()); - return (root, layout, vm); - } - - // ── Test 1: Bind returns non-null with the minimal tree ────────────────── - - [Fact] - public void Bind_Returns_NonNull_OnValidTree() - { - var (rootInfo, layout, vm) = BuildTestTree(); - var bus = new CaptureBus(); - - var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); - - Assert.NotNull(ctrl); - } - - // ── Test 2: Transcript is placed as a child of the transcript panel ────── - - [Fact] - public void Bind_Transcript_IsChildOfTranscriptPanel() - { - var (rootInfo, layout, vm) = BuildTestTree(); - var bus = new CaptureBus(); - - var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); - - Assert.NotNull(ctrl); - var panel = layout.FindElement(0x10000010u); - Assert.NotNull(panel); - // The transcript widget must be a child of the transcript panel. - Assert.Contains(ctrl!.Transcript, panel!.Children); - } - - // ── Test 3: Input is placed as a child of the input bar ───────────────── - - [Fact] - public void Bind_Input_IsChildOfInputBar() - { - var (rootInfo, layout, vm) = BuildTestTree(); - var bus = new CaptureBus(); - - var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); - - Assert.NotNull(ctrl); - var bar = layout.FindElement(0x10000013u); - Assert.NotNull(bar); - Assert.Contains(ctrl!.Input, bar!.Children); - } - - // ── Test 4: Input.OnSubmit publishes SendChatCmd via the capture bus ───── - - [Fact] - public void Bind_InputSubmit_PublishesSendChatCmd() - { - var (rootInfo, layout, vm) = BuildTestTree(); - var bus = new CaptureBus(); - - var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); - - Assert.NotNull(ctrl); - ctrl!.Input.OnSubmit!.Invoke("hello world"); - - // ChatCommandRouter.Submit should have published a SendChatCmd. - Assert.Single(bus.Published); - var cmd = Assert.IsType(bus.Published[0]); - Assert.Equal("hello world", cmd.Text); - } - - // ── Test 5: Channel change updates the channel used by subsequent submits ─ - - [Fact] - public void Bind_ChannelChange_UpdatesSubmitChannel() - { - var (rootInfo, layout, vm) = BuildTestTree(); - var bus = new CaptureBus(); - - var ctrl = ChatWindowController.Bind(rootInfo, layout, vm, () => bus, null, null, NoTex); - - Assert.NotNull(ctrl); - // Switch channel to General via the generic OnSelect (payload is ChatChannelKind). - ctrl!.Menu.OnSelect!.Invoke((object?)ChatChannelKind.General); - ctrl.Input.OnSubmit!.Invoke("hey all"); - - Assert.Single(bus.Published); - var cmd = Assert.IsType(bus.Published[0]); - Assert.Equal(ChatChannelKind.General, cmd.Channel); - } - - // ── Test 6: Bind returns null when required elements are absent ────────── - - [Fact] - public void Bind_Returns_Null_WhenTranscriptPanelMissing() - { - // Build a layout that is missing the transcript panel entirely. - var root = new ElementInfo { Id = 0x1000000Eu, Type = 3, Width = 490, Height = 100 }; - // No children → TranscriptPanelId and InputBarId are absent from the widget tree. - - var layout = LayoutImporter.Build(root, NoTex, null); - var vm = new ChatVM(new ChatLog()); - var bus = new CaptureBus(); - - var ctrl = ChatWindowController.Bind(root, layout, vm, () => bus, null, null, NoTex); - - Assert.Null(ctrl); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryFontResolveTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryFontResolveTests.cs deleted file mode 100644 index 8b148736..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryFontResolveTests.cs +++ /dev/null @@ -1,194 +0,0 @@ -using AcDream.App.UI; -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Unit tests for Fix C: per-element dat FontDid resolver in . -/// -/// -/// cannot be constructed in pure unit tests (GL + dat required), -/// so these tests verify the plumbing using null/non-null identity and sentinel resolvers: -/// -/// Null resolver → DatFont unchanged (backward-compat: same as before Fix C). -/// Resolver present, FontDid == 0 → resolver NOT called; DatFont == global datFont. -/// Resolver returns null for an id (font missing) → DatFont falls back to global datFont. -/// Controller sets DatFont after build → controller value wins. -/// threads fontResolve to the factory. -/// -/// -/// -public class DatWidgetFactoryFontResolveTests -{ - private static (uint, int, int) NoTex(uint _) => (0, 0, 0); - - // ── Test 1: null fontResolve → DatFont == datFont (backward-compat) ───── - - /// - /// When fontResolve is null (the live GameWindow path), BuildText must NOT - /// touch DatFont beyond setting it to the global datFont. Null in → null out. - /// - [Fact] - public void NullFontResolve_DatFont_EqualsGlobalFont() - { - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = 0x40000001u }; - // datFont=null, fontResolve=null → backward-compat: DatFont == null - var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null, fontResolve: null)); - Assert.Null(t.DatFont); - } - - // ── Test 2: FontDid == 0 → resolver NOT called ──────────────────────────── - - /// - /// When the element has FontDid == 0, the fontResolve delegate must NOT be - /// invoked (the element has no dat font; fall through to global datFont). - /// - [Fact] - public void FontDidZero_ResolverNotCalled() - { - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = 0u }; - bool called = false; - UiDatFont? Resolver(uint id) { called = true; return null; } - - // FontDid=0 → resolver should never fire, even though one is provided. - Assert.IsType(DatWidgetFactory.Create(info, NoTex, null, fontResolve: Resolver)); - Assert.False(called, "fontResolve must NOT be called when FontDid == 0"); - } - - // ── Test 3: resolver returns null (font missing) → fallback to datFont ─── - - /// - /// When the element has a non-zero FontDid but the resolver returns null - /// (font not found in dats), DatFont must fall back to the shared global datFont. - /// Since datFont is null in unit tests, this verifies the fallback chain. - /// - [Fact] - public void ResolverReturnsNull_FallsBackToGlobalFont() - { - const uint SomeFontDid = 0x40000005u; - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = SomeFontDid }; - int callCount = 0; - UiDatFont? Resolver(uint id) { callCount++; return null; } // always returns null (font missing) - - var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver)); - - // Resolver was called exactly once for this element. - Assert.Equal(1, callCount); - // Fallback: DatFont == global datFont (null in unit tests). - Assert.Null(t.DatFont); - } - - // ── Test 4: resolver called with correct FontDid ────────────────────────── - - /// - /// When fontResolve is provided and FontDid is non-zero, the resolver is called - /// with the element's exact FontDid value. - /// - [Fact] - public void ResolverCalledWithElementFontDid() - { - const uint ExpectedFontDid = 0x40000002u; - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = ExpectedFontDid }; - uint? capturedId = null; - UiDatFont? Resolver(uint id) { capturedId = id; return null; } - - DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver); - - Assert.Equal(ExpectedFontDid, capturedId); - } - - // ── Test 5: controller DatFont override wins over build-time value ─────── - - /// - /// A controller that calls FindElement and sets DatFont afterward MUST override - /// whatever the factory set at build time. This is the backward-compat guarantee. - /// The DatFont property is settable — a controller can override it after build. - /// - [Fact] - public void ControllerDatFontOverride_WinsOverBuildTimeFont() - { - const uint SomeFontDid = 0x40000003u; - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontDid = SomeFontDid }; - UiDatFont? Resolver(uint _) => null; // returns null → build-time DatFont == null - - var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, datFont: null, fontResolve: Resolver)); - Assert.Null(t.DatFont); // build-time value set by factory - - // Controller simulated override: set DatFont to null explicitly. - // The real guarantee is that DatFont is a settable property. - // (We can't construct a real UiDatFont here — it requires GL + dats.) - t.DatFont = null; // controller overrides after build - Assert.Null(t.DatFont); - // ✓ DatFont is settable — controller override mechanism is in place. - } - - // ── Test 6: LayoutImporter.Build threads fontResolve to factory ────────── - - /// - /// When is given a fontResolve, it must pass - /// it to for EVERY element in the tree. - /// Verified by checking that a Type-12 child with FontDid triggers the resolver. - /// - [Fact] - public void LayoutImporter_Build_ThreadsFontResolve_ToFactory() - { - const uint FontDid = 0x40000004u; - var root = new ElementInfo { Id = 1u, Type = 3, Width = 200, Height = 100 }; - var child = new ElementInfo { Id = 2u, Type = 12, Width = 100, Height = 20, FontDid = FontDid }; - root.Children.Add(child); - - int resolveCallCount = 0; - UiDatFont? Resolver(uint id) { resolveCallCount++; return null; } - - var layout = LayoutImporter.Build(root, NoTex, datFont: null, fontResolve: Resolver); - - // The child element has a non-zero FontDid — resolver must have been called for it. - Assert.True(resolveCallCount > 0, "fontResolve was not called for the child element"); - // The child widget was built (findable in the layout). - Assert.NotNull(layout.FindElement(2u)); - } - - // ── Test 7: Meter element also receives element font from resolver ──────── - - /// - /// Type-7 (UiMeter) elements with a FontDid must also go through the resolver. - /// Verified by checking the resolver fires for a meter element with a non-zero FontDid. - /// - [Fact] - public void FontResolve_CalledForMeter_WhenFontDidPresent() - { - const uint MeterFontDid = 0x40000006u; - var meter = new ElementInfo { Type = 7, Width = 150, Height = 16, FontDid = MeterFontDid }; - int callCount = 0; - UiDatFont? Resolver(uint id) { callCount++; return null; } - - var w = DatWidgetFactory.Create(meter, NoTex, datFont: null, fontResolve: Resolver); - - var m = Assert.IsType(w); - // Resolver was called for the meter element's FontDid. - Assert.True(callCount > 0, "fontResolve was not called for meter element"); - // Meter DatFont: resolver returned null, so DatFont falls back to global datFont (null). - Assert.Null(m.DatFont); - } - - // ── Test 8: LayoutImporter.BuildFromInfos signature is backward-compat ──── - - /// - /// without fontResolve (default null) - /// must still work for all callers that don't pass it — verifies the optional - /// parameter default is correct and no existing callers broke. - /// - [Fact] - public void BuildFromInfos_WithoutFontResolve_WorksAsBeforeFix() - { - var root = new ElementInfo { Id = 10u, Type = 3, Width = 200, Height = 100 }; - var child = new ElementInfo { Id = 11u, Type = 12, Width = 100, Height = 20, FontDid = 0x40000001u }; - - // Call without fontResolve (the old signature shape). - var layout = LayoutImporter.BuildFromInfos(root, new[] { child }, NoTex, datFont: null); - - var t = Assert.IsType(layout.FindElement(11u)); - // No resolver → DatFont == global datFont (null in unit tests). - Assert.Null(t.DatFont); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs deleted file mode 100644 index 12871f40..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ /dev/null @@ -1,354 +0,0 @@ -using System.Numerics; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI.Layout; - -public class DatWidgetFactoryTests -{ - private static (uint, int, int) NoTex(uint _) => (0, 0, 0); - - // ── Test 1: Type 7 → UiMeter ───────────────────────────────────────────── - - [Fact] - public void Type7_Meter_MakesUiMeter() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 7, Width = 150, Height = 16 }, NoTex, null); - Assert.IsType(e); - } - - // ── Test 2: Unknown type → UiDatElement fallback ───────────────────────── - - [Fact] - public void UnknownType_FallsBackToGeneric() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 999 }, NoTex, null); - Assert.IsType(e); - } - - // ── Test 3: Type 12 → UiText (behavioral text widget) ──────────────────── - - [Fact] - public void Type12_Text_MakesUiText() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 12, Width = 100, Height = 40 }, NoTex, null); - Assert.IsType(e); - } - - // ── Test 4: Rect + anchors set from ElementInfo ─────────────────────────── - - /// - /// A Type-3 element with X=5,Y=21,W=150,H=16, Left=1,Top=1,Right=1 should have - /// its rect + anchors copied onto the returned widget. - /// Per UIElement::UpdateForParentSizeChange @0x00462640: - /// Left=1 → AnchorEdges.Left (near-pin); Top=1 → AnchorEdges.Top; - /// Right=1 → AnchorEdges.Right (stretch / track parent right); Bottom=0 → neither. - /// Combined: Left | Top | Right. - /// - [Fact] - public void RectAndAnchors_SetFromElementInfo() - { - var info = new ElementInfo - { - Type = 3, - X = 5, Y = 21, - Width = 150, Height = 16, - Left = 1, Top = 1, - Right = 1, Bottom = 0, - }; - var e = DatWidgetFactory.Create(info, NoTex, null)!; - Assert.Equal(5f, e.Left); - Assert.Equal(21f, e.Top); - Assert.Equal(150f, e.Width); - Assert.Equal(16f, e.Height); - Assert.Equal(AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, e.Anchors); - } - - // ── Test 5: ReadOrder propagated to ZOrder ─────────────────────────────── - - [Fact] - public void Create_PropagatesReadOrderToZOrder() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, ReadOrder = 7 }, NoTex, null); - Assert.Equal(7, e!.ZOrder); - } - - // ── Test G1a: Type 12 always produces UiText (with or without own sprites) ── - - [Fact] - public void DatWidgetFactory_Type12_AlwaysMakesUiText() - { - var withMedia = new ElementInfo { Type = 12, Width = 32, Height = 16, - StateMedia = { ["Normal"] = (0x00001234u, 1) } }; - Assert.IsType(DatWidgetFactory.Create(withMedia, NoTex, null)); - Assert.IsType(DatWidgetFactory.Create(new ElementInfo { Type = 12 }, NoTex, null)); - } - - // ── Test 5c: Type 1 → UiButton ────────────────────────────────────────── - - [Fact] - public void Type1_Button_MakesUiButton() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex, null); - Assert.IsType(e); - } - - // ── Test 5b: Type 11 → UiScrollbar ────────────────────────────────────── - - [Fact] - public void Type11_Scrollbar_MakesUiScrollbar() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 11, Width = 16, Height = 68 }, NoTex, null); - Assert.IsType(e); - } - - // ── Test 5e: Type 3 is NOT registered — chrome/containers stay generic ──── - // - // Retail Type 3 = UIElement_Field, but acdream's Type-3 dat elements (vitals/chat - // bevel chrome + the transcript/input container panels) are inert sprite-bearing - // chrome, not editable fields. They stay on the UiDatElement fallback so their - // sprites render and they gain no spurious focus/edit affordance. The one true - // editable field (the chat input, 0x10000016) resolves to Type 12 and is - // controller-placed as a UiField. Register Type 3 → UiField only when a window - // carries a factory-built editable Type-3 field. - - [Fact] - public void Type3_NotRegistered_FallsBackToGeneric() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 3, Width = 200, Height = 16 }, NoTex, null); - Assert.IsType(e); - } - - // ── Test 5d: Type 6 → UiMenu ───────────────────────────────────────────── - - [Fact] - public void Type6_Menu_MakesUiMenu() - { - var e = DatWidgetFactory.Create(new ElementInfo { Type = 6, Width = 46, Height = 18 }, NoTex, null); - Assert.IsType(e); - } - - // ── Test 7: Type 0x10000031 → UiItemList ──────────────────────────────── - - [Fact] - public void Create_buildsUiItemList_forItemListClassId() - { - var info = new AcDream.App.UI.Layout.ElementInfo { Id = 0x100001A7u, Type = 0x10000031u, Width = 32, Height = 32 }; - var w = AcDream.App.UI.Layout.DatWidgetFactory.Create(info, _ => (0u, 0, 0), null); - Assert.IsType(w); - } - - // ── Test M1: Single-image meter (toolbar selected-object meters) ──────── - // - // The toolbar health/mana meters (0x100001A1 / 0x100001A2) use a DIFFERENT - // shape from the vitals 3-slice meters: the back-track sprite lives on the - // meter ELEMENT's own DirectState ("" key), and there is exactly ONE Type-3 - // child whose own DirectState ("" key) carries the fill sprite. That child - // has no image grandchildren, so SliceIds would return all-zero — the new - // Count==1 branch reads the StateMedia entries directly instead. - // The sprites go in the TILE slot (Back/FrontTile), NOT the cap slot: DrawMode=Normal - // tiles at native width across the full bar geometry (UIElement_Meter::DrawChildren), - // so the back spans all 140px and the fill clips to 140*fraction for any native width. - // Back/FrontLeft + Back/FrontRight must be 0 (no caps on a single-image bar). - - [Fact] - public void BuildMeter_SingleImageShape_ReadsDirectStateFromElementAndFillChild() - { - const uint BackFile = 0x0600193Eu; // health back-track (from toolbar dump) - const uint FillFile = 0x0600193Fu; // health fill (from toolbar dump) - - // Meter element: Type 7, own DirectState = back-track sprite. - var meter = new ElementInfo { Type = 7, Id = 0x100001A1u, Width = 140, Height = 31 }; - meter.StateMedia[""] = (BackFile, 1); - - // Single Type-3 fill container: own DirectState = fill sprite, no grandchildren. - var fillContainer = new ElementInfo { Type = 3, ReadOrder = 1 }; - fillContainer.StateMedia[""] = (FillFile, 1); - meter.Children.Add(fillContainer); - - var e = DatWidgetFactory.Create(meter, NoTex, null); - - var m = Assert.IsType(e); - // Back-track on the meter element's own DirectState, fill on the single child — - // both in the TILE slot so they tile across the full 140px bar (DrawMode=Normal). - Assert.Equal(BackFile, m.BackTile); - Assert.Equal(0u, m.BackLeft); - Assert.Equal(0u, m.BackRight); - Assert.Equal(FillFile, m.FrontTile); - Assert.Equal(0u, m.FrontLeft); - Assert.Equal(0u, m.FrontRight); - } - - // ── Test 6: Meter slice extraction (the important one) ─────────────────── - - /// - /// A meter (Type 7) whose two Type-3 containers each carry 3 image children - /// (ordered by X, bearing a DirectState "" sprite), plus the front container - /// has a fourth expand-overlay child with ONLY a named "ShowDetail" state — - /// that overlay must be excluded from the slice count. - /// - [Fact] - public void MeterSliceExtraction_ReadsGrandchildImageIds_IgnoresOverlay() - { - // Slice ids sourced from format doc §11 — real health-bar ids. - const uint BackL = 0x0600747Eu, BackT = 0x0600747Fu, BackR = 0x06007480u; - const uint FrontL = 0x06007481u, FrontT = 0x06007482u, FrontR = 0x06007483u; - const uint OverlayFile = 0x06007490u; - - // Back container (ReadOrder 0 — drawn first / behind) - var backChild = new ElementInfo { Type = 3, ReadOrder = 0 }; - backChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (BackL, 1) } }); - backChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (BackT, 1) } }); - backChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (BackR, 1) } }); - - // Front container (ReadOrder 1 — drawn on top) - var frontChild = new ElementInfo { Type = 3, ReadOrder = 1 }; - frontChild.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (FrontL, 1) } }); - frontChild.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (FrontT, 1) } }); - frontChild.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (FrontR, 1) } }); - // Expand-detail overlay: named state only — NO DirectState "" — must be ignored. - frontChild.Children.Add(new ElementInfo - { - X = 0, - StateMedia = { ["ShowDetail"] = (OverlayFile, 3) } - }); - - var meter = new ElementInfo { Type = 7, Width = 150, Height = 16 }; - meter.Children.Add(backChild); - meter.Children.Add(frontChild); - - var e = DatWidgetFactory.Create(meter, NoTex, null); - - var m = Assert.IsType(e); - Assert.Equal(BackL, m.BackLeft); - Assert.Equal(BackT, m.BackTile); - Assert.Equal(BackR, m.BackRight); - Assert.Equal(FrontL, m.FrontLeft); - Assert.Equal(FrontT, m.FrontTile); - Assert.Equal(FrontR, m.FrontRight); - // Overlay (ShowDetail-only, no DirectState "") must not leak into any slice slot. - Assert.NotEqual(OverlayFile, m.FrontRight); - Assert.NotEqual(OverlayFile, m.FrontTile); - } - - // ── Justification build-time application (new for importer Fix A) ──────── - - /// - /// A Type-12 text element with HJustify=Center (the default) must produce a - /// UiText with Centered=true and RightAligned=false at build time. - /// This proves BuildText applies the dat's HJustify at construction without a - /// controller binding step. - /// - [Fact] - public void BuildText_HJustifyCenter_SetsCentered() - { - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center }; - var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); - Assert.True(t.Centered); - Assert.False(t.RightAligned); - Assert.Equal(VJustify.Center, t.VerticalJustify); - } - - /// - /// A Type-12 text element with HJustify=Right must produce a UiText with - /// Centered=false and RightAligned=true at build time. - /// - [Fact] - public void BuildText_HJustifyRight_SetsRightAligned() - { - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Right }; - var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); - Assert.False(t.Centered); - Assert.True(t.RightAligned); - } - - /// - /// A Type-12 text element with HJustify=Left must produce a UiText with - /// Centered=false and RightAligned=false at build time. - /// - [Fact] - public void BuildText_HJustifyLeft_SetsNeitherCenteredNorRight() - { - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Left }; - var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); - Assert.False(t.Centered); - Assert.False(t.RightAligned); - } - - /// - /// A Type-12 text element with VJustify=Top must produce a UiText with - /// VerticalJustify=Top at build time. - /// - [Fact] - public void BuildText_VJustifyTop_SetsVerticalJustifyTop() - { - var info = new ElementInfo { Type = 12, Width = 100, Height = 55, VJustify = VJustify.Top }; - var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); - Assert.Equal(VJustify.Top, t.VerticalJustify); - } - - /// - /// A controller override: build-time sets Centered=true (HJustify=Center), then - /// a controller sets Centered=false afterward. The controller's override wins — - /// this is the backward-compatibility guarantee. - /// - [Fact] - public void BuildText_ControllerOverrideWinsAfterBuild() - { - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, HJustify = HJustify.Center }; - var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); - Assert.True(t.Centered); // build-time value - t.Centered = false; // controller override - Assert.False(t.Centered); // override wins - } - - // ── DefaultColor from dat FontColor (importer Fix B) ───────────────────── - - /// - /// When ElementInfo.FontColor is set (dat carried 0x1B ColorBaseProperty), - /// DatWidgetFactory.BuildText must copy it onto UiText.DefaultColor. - /// - [Fact] - public void BuildText_FontColorPresent_SetsDefaultColor() - { - var gold = new Vector4(1f, 0.82f, 0.36f, 1f); - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = gold }; - var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); - Assert.Equal(gold, t.DefaultColor); - } - - /// - /// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must - /// stay at white (Vector4.One) — the backward-compatible default. - /// - [Fact] - public void BuildText_FontColorAbsent_DefaultColorIsWhite() - { - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = null }; - var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); - Assert.Equal(Vector4.One, t.DefaultColor); - } - - /// - /// Backward-compat guarantee: a controller that sets LinesProvider with an - /// explicit per-line color AFTER build is unaffected by DefaultColor. - /// The controller's per-line color is stored in the Line record, not DefaultColor, - /// so DefaultColor changing from dat does not touch existing controller bindings. - /// - [Fact] - public void BuildText_ControllerExplicitLineColor_UnaffectedByDefaultColor() - { - // Dat sets a parchment color. - var parchment = new Vector4(0.92f, 0.90f, 0.82f, 1f); - var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = parchment }; - var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); - - // Controller overrides with an explicit gold color in the Line record. - var gold = new Vector4(1f, 0.82f, 0.36f, 1f); - t.LinesProvider = () => new[] { new UiText.Line("text", gold) }; - - // The line's color is gold (controller wins), NOT the dat parchment. - Assert.Equal(gold, t.LinesProvider()[0].Color); - // DefaultColor still holds the dat parchment (unmodified by the controller binding). - Assert.Equal(parchment, t.DefaultColor); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs deleted file mode 100644 index 387767bb..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs +++ /dev/null @@ -1,259 +0,0 @@ -using System.Numerics; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI.Layout; - -public class ElementReaderTests -{ - // ── ToAnchors (decomp-backed: UIElement::UpdateForParentSizeChange @0x00462640) ───────────── - - /// - /// Top edge (L=1,T=1,R=1,B=2): LeftEdge==1 → Left; RightEdge==1 → Right (stretch); - /// TopEdge==1 → Top; BottomEdge==2 (not 1/4, top≠2) → no Bottom. - /// This is the top chrome edge — it pins left, stretches width, pins top, fixed height. - /// Real vitals values from format doc §11 (0x10000634). - /// - [Fact] - public void ToAnchors_TopEdge_StretchesWidth() - { - var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 2); - Assert.True(a.HasFlag(AnchorEdges.Left)); - Assert.True(a.HasFlag(AnchorEdges.Top)); - Assert.True(a.HasFlag(AnchorEdges.Right)); - Assert.False(a.HasFlag(AnchorEdges.Bottom)); - } - - /// - /// TL corner (L=1,T=1,R=2,B=2): LeftEdge==1 → Left; RightEdge==2 (not 1/4), left≠2 → no Right; - /// TopEdge==1 → Top; BottomEdge==2, top≠2 → no Bottom. Fixed size, pinned top-left. - /// Real vitals values from format doc §11 (0x10000633). - /// - [Fact] - public void ToAnchors_TlCorner_PinsTopLeftFixed() - { - var a = ElementReader.ToAnchors(left: 1, top: 1, right: 2, bottom: 2); - Assert.True(a.HasFlag(AnchorEdges.Left)); - Assert.True(a.HasFlag(AnchorEdges.Top)); - Assert.False(a.HasFlag(AnchorEdges.Right)); - Assert.False(a.HasFlag(AnchorEdges.Bottom)); - } - - /// - /// TR corner (L=2,T=1,R=1,B=2): LeftEdge==2 → triggers Right (track-right); RightEdge==1 → Right; - /// left≠1 → no Left; TopEdge==1 → Top; BottomEdge==2, top≠2 → no Bottom. - /// Fixed-width element whose left and right both track the parent's right edge. - /// Real vitals values from format doc §11 (0x10000635). - /// - [Fact] - public void ToAnchors_TrCorner_TracksRight() - { - var a = ElementReader.ToAnchors(left: 2, top: 1, right: 1, bottom: 2); - Assert.False(a.HasFlag(AnchorEdges.Left)); - Assert.True(a.HasFlag(AnchorEdges.Top)); - Assert.True(a.HasFlag(AnchorEdges.Right)); - Assert.False(a.HasFlag(AnchorEdges.Bottom)); - } - - /// - /// Left edge (L=1,T=1,R=2,B=1): LeftEdge==1 → Left; RightEdge==2, left≠2 → no Right; - /// TopEdge==1 → Top; BottomEdge==1 → Bottom. Pins left+top+bottom, fixed width, stretches height. - /// Real vitals values from format doc §11 (0x10000636). - /// - [Fact] - public void ToAnchors_LeftEdge_StretchesHeight() - { - var a = ElementReader.ToAnchors(left: 1, top: 1, right: 2, bottom: 1); - Assert.True(a.HasFlag(AnchorEdges.Left)); - Assert.True(a.HasFlag(AnchorEdges.Top)); - Assert.False(a.HasFlag(AnchorEdges.Right)); - Assert.True(a.HasFlag(AnchorEdges.Bottom)); - } - - /// - /// All-ones (L=1,T=1,R=1,B=1): all four flags fire — Left, Right, Top, Bottom. - /// A piece pinned to all four sides stretches both horizontally and vertically. - /// - [Fact] - public void ToAnchors_Meter_StretchesBoth() - { - var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 1); - Assert.True(a.HasFlag(AnchorEdges.Left)); - Assert.True(a.HasFlag(AnchorEdges.Top)); - Assert.True(a.HasFlag(AnchorEdges.Right)); - Assert.True(a.HasFlag(AnchorEdges.Bottom)); - } - - /// - /// All-zero edge flags (prototype-only elements) fall back to Left|Top default. - /// - [Fact] - public void EdgeFlagsToAnchors_AllZero_DefaultsToTopLeft() - { - var a = ElementReader.ToAnchors(left: 0, top: 0, right: 0, bottom: 0); - Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a); - } - - /// - /// Value 3 on left and right axes contributes no Left/Right anchor; - /// TopEdge==1 → Top; BottomEdge==1 → Bottom. - /// left=3 (not 1/4) → no Left; right=3 (not 1/4), left≠2 → no Right; - /// top=1 → Top; bottom=1 → Bottom. Result: Top|Bottom. - /// - [Fact] - public void EdgeFlagsToAnchors_ValueThree_HorizAxes_YieldsTopBottom() - { - var a = ElementReader.ToAnchors(left: 3, top: 1, right: 3, bottom: 1); - Assert.False(a.HasFlag(AnchorEdges.Left)); - Assert.True(a.HasFlag(AnchorEdges.Top)); - Assert.False(a.HasFlag(AnchorEdges.Right)); - Assert.True(a.HasFlag(AnchorEdges.Bottom)); - } - - // ── Merge ──────────────────────────────────────────────────────────────── - - [Fact] - public void Merge_BaseThenOverride_DerivedWins() - { - var base_ = new ElementInfo { Type = 0, FontDid = 0x40000000, Width = 150, Height = 16 }; - var derived = new ElementInfo { Type = 0, Width = 200 }; // overrides width, inherits font + height - var merged = ElementReader.Merge(base_, derived); - Assert.Equal(200, merged.Width); // override - Assert.Equal(16, merged.Height); // inherited - Assert.Equal(0x40000000u, merged.FontDid);// inherited - } - - [Fact] - public void Merge_DerivedHasFontDid_OverridesBase() - { - var base_ = new ElementInfo { FontDid = 0x40000000, Width = 100, Height = 10 }; - var derived = new ElementInfo { FontDid = 0x40000001, Width = 100 }; - var merged = ElementReader.Merge(base_, derived); - Assert.Equal(0x40000001u, merged.FontDid); - } - - [Fact] - public void Merge_DerivedStateMediaOverridesBase() - { - var base_ = new ElementInfo(); - base_.StateMedia[""] = (0x06001000u, 1); - base_.StateMedia["HideDetail"] = (0x06001001u, 1); - - var derived = new ElementInfo(); - derived.StateMedia[""] = (0x06002000u, 3); // overrides base default state - - var merged = ElementReader.Merge(base_, derived); - // derived's "" overrides base's "" - Assert.Equal((0x06002000u, 3), merged.StateMedia[""]); - // base's "HideDetail" is kept (derived didn't provide it) - Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]); - } - - [Fact] - public void Merge_ChildrenComeFromDerived() - { - var base_ = new ElementInfo(); - base_.Children.Add(new ElementInfo { Id = 0x1u }); - - var derived = new ElementInfo(); - derived.Children.Add(new ElementInfo { Id = 0x2u }); - - var merged = ElementReader.Merge(base_, derived); - // children must come from derived only - Assert.Single(merged.Children); - Assert.Equal(0x2u, merged.Children[0].Id); - } - - // ── HJustify / VJustify — Merge propagation ───────────────────────────── - - /// - /// When the derived element has a non-Center HJustify, the derived value wins - /// (same "non-default wins" rule as FontDid). - /// - [Fact] - public void Merge_DerivedHJustifyRight_OverridesBaseCenter() - { - var base_ = new ElementInfo { HJustify = HJustify.Center }; - var derived = new ElementInfo { HJustify = HJustify.Right }; - var merged = ElementReader.Merge(base_, derived); - Assert.Equal(HJustify.Right, merged.HJustify); - } - - /// - /// When the derived element has the default HJustify (Center), the base value - /// is inherited — Center from the derived does NOT override a Left base. - /// - [Fact] - public void Merge_DerivedHJustifyCenter_InheritsBaseLeft() - { - var base_ = new ElementInfo { HJustify = HJustify.Left }; - var derived = new ElementInfo { HJustify = HJustify.Center }; // default — no explicit dat property - var merged = ElementReader.Merge(base_, derived); - Assert.Equal(HJustify.Left, merged.HJustify); - } - - /// - /// VJustify=Top from the base propagates when the derived element has no explicit - /// (Center) vertical justification. - /// - [Fact] - public void Merge_BaseVJustifyTop_InheritedWhenDerivedIsCenter() - { - var base_ = new ElementInfo { VJustify = VJustify.Top }; - var derived = new ElementInfo { VJustify = VJustify.Center }; // default - var merged = ElementReader.Merge(base_, derived); - Assert.Equal(VJustify.Top, merged.VJustify); - } - - /// - /// VJustify=Bottom from the derived element wins over a Center base. - /// - [Fact] - public void Merge_DerivedVJustifyBottom_OverridesBaseCenter() - { - var base_ = new ElementInfo { VJustify = VJustify.Center }; - var derived = new ElementInfo { VJustify = VJustify.Bottom }; - var merged = ElementReader.Merge(base_, derived); - Assert.Equal(VJustify.Bottom, merged.VJustify); - } - - // ── FontColor — Merge propagation (Fix B) ──────────────────────────────── - - /// - /// When the derived element has an explicit (non-null) FontColor, the derived value - /// wins in Merge — same "non-null derived wins" rule used for FontDid and HJustify. - /// - [Fact] - public void Merge_DerivedFontColor_OverridesBaseNull() - { - var base_ = new ElementInfo { FontColor = null }; - var derived = new ElementInfo { FontColor = new Vector4(1f, 0f, 0f, 1f) }; - var merged = ElementReader.Merge(base_, derived); - Assert.Equal(new Vector4(1f, 0f, 0f, 1f), merged.FontColor); - } - - /// - /// When the derived element has no FontColor (null), the base's FontColor is inherited. - /// A null-derived must NOT override a non-null base. - /// - [Fact] - public void Merge_DerivedFontColorNull_InheritsBaseColor() - { - var gold = new Vector4(1f, 0.82f, 0.36f, 1f); - var base_ = new ElementInfo { FontColor = gold }; - var derived = new ElementInfo { FontColor = null }; // default — no dat property - var merged = ElementReader.Merge(base_, derived); - Assert.Equal(gold, merged.FontColor); - } - - /// - /// When neither base nor derived carries a FontColor, the merged result is null. - /// - [Fact] - public void Merge_BothFontColorNull_MergedIsNull() - { - var base_ = new ElementInfo { FontColor = null }; - var derived = new ElementInfo { FontColor = null }; - var merged = ElementReader.Merge(base_, derived); - Assert.Null(merged.FontColor); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs deleted file mode 100644 index c7338ba1..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.IO; -using System.Text.Json; -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Loads the committed layout ElementInfo fixtures and builds widget trees — -/// no dats required. Fixtures were generated from the real portal.dat and -/// serialized with . -/// -public static class FixtureLoader -{ - private static readonly JsonSerializerOptions _opts = new() - { - IncludeFields = true, - }; - - /// - /// Deserializes the committed vitals_2100006C.json fixture (copied to - /// the test output directory via the csproj CopyToOutputDirectory item) - /// into an tree, then builds and returns the - /// using a null-returning sprite resolver and no - /// dat font — sufficient for conformance checks on tree structure and slice ids. - /// - public static ImportedLayout LoadVitals() - { - var root = LoadVitalsInfos(); - return LayoutImporter.Build(root, _ => (0u, 0, 0), null); - } - - /// - /// Deserializes the committed vitals_2100006C.json fixture into a raw - /// tree WITHOUT calling . - /// Use this when the test needs to inspect the resolved - /// tree directly (e.g. inheritance-resolution checks) without exercising the - /// widget factory. - /// - public static AcDream.App.UI.Layout.ElementInfo LoadVitalsInfos() - => LoadInfos("vitals_2100006C.json"); - - /// - /// Deserializes the committed chat_21000006.json fixture into a raw - /// tree and builds the - /// using a null-returning sprite resolver and no dat font — sufficient for - /// conformance checks on tree structure and resolved types. - /// - public static ImportedLayout LoadChat() - => LayoutImporter.Build(LoadChatInfos(), _ => (0u, 0, 0), null); - - /// - /// Deserializes the committed chat_21000006.json fixture into a raw - /// tree WITHOUT calling . - /// Use this when the test needs to inspect the resolved - /// tree directly (e.g. resolved Type values per element id). - /// - public static AcDream.App.UI.Layout.ElementInfo LoadChatInfos() - => LoadInfos("chat_21000006.json"); - - // ── Shared loader ──────────────────────────────────────────────────────── - - private static AcDream.App.UI.Layout.ElementInfo LoadInfos(string fileName) - { - var path = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", fileName); - if (!File.Exists(path)) throw new FileNotFoundException($"fixture not found at: {path}"); - var bytes = File.ReadAllBytes(path); - // Strip UTF-8 BOM (EF BB BF) if present so JsonSerializer.Deserialize(ReadOnlySpan) - // does not reject the first byte. - ReadOnlySpan span = bytes; - if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF) - span = span[3..]; - return JsonSerializer.Deserialize(span, _opts) - ?? throw new InvalidOperationException($"fixture deserialized to null: {path}"); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs deleted file mode 100644 index efa69730..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ /dev/null @@ -1,528 +0,0 @@ -using System.Collections.Generic; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.App.Tests.UI.Layout; - -public class InventoryControllerTests -{ - private const uint Player = 0x50000001u; - - // UiElement is abstract — a concrete, parameterless stand-in for the root + caption hosts - // (the real captions are Type-0 → UiDatElement; in tests we only need a host that holds children). - private sealed class TestElement : UiElement { } - - // Element ids (spec §1). - private const uint ContentsGrid = 0x100001C6u; - private const uint ContainerList = 0x100001CAu; - private const uint TopContainer = 0x100001C9u; - private const uint BurdenMeter = 0x100001D9u; - private const uint BurdenText = 0x100001D8u; - private const uint BurdenCaption = 0x100001D7u; - private const uint ContentsCaption = 0x100001C5u; - private const uint ContentsScrollbar = 0x100001C7u; - - private static (ImportedLayout layout, UiItemList grid, UiItemList containers, - UiItemList top, UiMeter meter, UiElement burdenText, - UiElement burdenCap, UiElement contentsCap) BuildLayout() - { - var grid = new UiItemList { Width = 192, Height = 96 }; - var containers = new UiItemList { Width = 36, Height = 252 }; - var top = new UiItemList { Width = 36, Height = 36 }; - var meter = new UiMeter { Width = 11, Height = 58 }; - var burdenText = new TestElement { Width = 36, Height = 15 }; - var burdenCap = new TestElement { Width = 36, Height = 15 }; - var contentsCap = new TestElement { Width = 192, Height = 15 }; - var scrollbar = new UiScrollbar { Width = 16, Height = 96 }; - var root = new TestElement { Width = 300, Height = 362 }; - root.AddChild(grid); root.AddChild(containers); root.AddChild(top); - root.AddChild(meter); root.AddChild(burdenText); root.AddChild(burdenCap); - root.AddChild(contentsCap); root.AddChild(scrollbar); - var byId = new Dictionary - { - [ContentsGrid] = grid, [ContainerList] = containers, [TopContainer] = top, - [BurdenMeter] = meter, [BurdenText] = burdenText, [BurdenCaption] = burdenCap, - [ContentsCaption] = contentsCap, [ContentsScrollbar] = scrollbar, - }; - return (new ImportedLayout(root, byId), grid, containers, top, meter, - burdenText, burdenCap, contentsCap); - } - - private static InventoryController Bind(ImportedLayout layout, ClientObjectTable objects, - int? strength = 100, List? uses = null, List? closes = null, - List<(uint item, uint container, int placement)>? puts = null) - => InventoryController.Bind(layout, objects, () => Player, - iconIds: (_, _, _, _, _) => 0u, - strength: () => strength, datFont: null, - sendUse: uses is null ? null : g => uses.Add(g), - sendNoLongerViewing: closes is null ? null : g => closes.Add(g), - sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p))); - - // Seed a side bag (a container) in the player's pack, plus optionally its own contents. - private static void SeedBag(ClientObjectTable t, uint bag, int slot, int itemsCapacity = 24) - { - t.AddOrUpdate(new ClientObject { ObjectId = bag, Type = ItemType.Container, ItemsCapacity = itemsCapacity }); - t.MoveItem(bag, Player, slot); - } - - // Place an object in a container at a slot via the faithful production path: - // AddOrUpdate registers it, MoveItem sets ContainerId+ContainerSlot and calls Reindex - // (the same machinery server-driven moves use), so GetContents returns it slot-ordered. - private static void SeedContained(ClientObjectTable t, uint guid, uint container, int slot, - int burden = 0, ItemType type = ItemType.None) - { - t.AddOrUpdate(new ClientObject { ObjectId = guid, Burden = burden, Type = type }); - t.MoveItem(guid, container, slot); - } - - [Fact] - public void Populate_fills_contents_grid_with_loose_items_only() - { - var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - // Seed via the indexed path: AddOrUpdate registers the object, MoveItem places it in - // the container at a slot (the faithful "server moved item into container" flow that - // calls Reindex). GetContents — retail's per-container list — then returns them ordered. - SeedContained(objects, 0xA, Player, slot: 0, burden: 10); // loose - SeedContained(objects, 0xB, Player, slot: 1, burden: 20); // loose - SeedContained(objects, 0xC, Player, slot: 2, type: ItemType.Container); // side bag - - Bind(layout, objects); - - Assert.Equal(102, grid.GetNumUIItems()); // 2 loose + 100 empty (main-pack capacity) - Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); - Assert.Equal(0xBu, grid.GetItem(1)!.ItemId); - Assert.Equal(0u, grid.GetItem(2)!.ItemId); // padded empty - Assert.Equal(7, containers.GetNumUIItems()); // 1 side bag + 6 empty (no capacity → 7-slot column) - Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); - Assert.Equal(0u, containers.GetItem(1)!.ItemId); // padded empty frame - } - - [Fact] - public void Equipped_items_are_excluded_from_the_grid_and_selector() - { - var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); // loose pack item - // A self-wielded item: MoveItem with an equip location indexes it under the player - // (the live wield path), but it must NOT show in the pack grid or the selector. - objects.AddOrUpdate(new ClientObject { ObjectId = 0xD }); - objects.MoveItem(0xD, Player, newSlot: 1, newEquipLocation: EquipMask.MeleeWeapon); - - Bind(layout, objects); - - Assert.Equal(102, grid.GetNumUIItems()); // 1 loose + 101 empty (main-pack capacity) - Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); - Assert.Equal(7, containers.GetNumUIItems()); // 7 empty slots (no bags; the equipped item isn't here) - Assert.Equal(0u, containers.GetItem(0)!.ItemId); - } - - [Fact] - public void Grid_is_six_columns_thirtytwo_px() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - Bind(layout, new ClientObjectTable()); - Assert.Equal(6, grid.Columns); - Assert.Equal(32f, grid.CellWidth); - Assert.Equal(32f, grid.CellHeight); - } - - [Fact] - public void ObjectAdded_for_player_item_rebuilds_grid() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - Bind(layout, objects); - Assert.Equal(102, grid.GetNumUIItems()); // empty grid still shows the full 102-slot pack - Assert.Equal(0u, grid.GetItem(0)!.ItemId); // slot 0 empty before the add - - objects.Ingest(new WeenieData(0xA, "Sword", ItemType.MeleeWeapon, 1, 0, 0, 0, 0, - null, null, null, 5, Player, null, null, null, null, null, null, null, null, null)); - - Assert.Equal(102, grid.GetNumUIItems()); // still 102; the item fills slot 0 - Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); - } - - [Fact] - public void Burden_meter_fill_and_percent_from_load() - { - var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - // Str 100 → capacity 15000. Carried 7500 → load 0.5 → fill 0.1667, "50%". - objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 7500 }); - Bind(layout, objects, strength: 100); - - Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3); - Assert.True(meter.Vertical); - // The % text caption child reads "50%". - Assert.Contains("50%", CaptionText(burdenText)); - } - - [Fact] - public void Captions_render_known_strings() - { - var (layout, _, _, _, _, _, burdenCap, contentsCap) = BuildLayout(); - Bind(layout, new ClientObjectTable()); - Assert.Contains("Burden", CaptionText(burdenCap)); - Assert.Contains("Contents of Backpack", CaptionText(contentsCap)); - } - - [Fact] - public void Burden_reads_wire_EncumbranceVal_over_carried_sum() - { - // B-Wire: the burden bar must read the server's wire EncumbranceVal (PropertyInt 5), - // NOT the client-side carried-Burden sum (retires AP-48 — the sum is only the fallback). - var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - // Str 100 → capacity 15000. A carried item sums to 3000 (would read 20%), but the - // server says EncumbranceVal = 7500 → load 0.5 → fill 0.1667 → "50%". Wire wins. - objects.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 3000 }); - var bundle = new PropertyBundle(); - bundle.Ints[5] = 7500; // EncumbranceVal (PropertyInt 5) - objects.UpsertProperties(Player, bundle); - - Bind(layout, objects, strength: 100); - - Assert.Equal(0.16667f, meter.Fill() ?? -1f, 3); // 7500/15000/3 (wire), not 3000-based 0.0667 - Assert.Contains("50%", CaptionText(burdenText)); // not "20%" - } - - [Fact] - public void Live_player_int_update_refreshes_burden() - { - // B-Wire C1d: a live PrivateUpdatePropertyInt (0x02CD) for the player's EncumbranceVal - // fires ObjectUpdated on the PLAYER object; Concerns now includes o.ObjectId == player, - // so the burden bar repaints. Without the C1d branch this update would be ignored. - var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - var bundle = new PropertyBundle(); - bundle.Ints[5] = 3000; // initial EncumbranceVal → load 0.2 → "20%" - objects.UpsertProperties(Player, bundle); - Bind(layout, objects, strength: 100); - Assert.Contains("20%", CaptionText(burdenText)); - - objects.UpdateIntProperty(Player, 5u, 9000); // live 0x02CD: → load 0.6 → "60%" - - Assert.Contains("60%", CaptionText(burdenText)); - Assert.Equal(0.2f, meter.Fill() ?? -1f, 3); // 9000/15000/3 - } - - [Fact] - public void Contents_grid_scrollbar_binds_to_the_grid_scroll_model() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - Bind(layout, new ClientObjectTable()); - var bar = (UiScrollbar)layout.FindElement(ContentsScrollbar)!; - Assert.Same(grid.Scroll, bar.Model); // the bar drives the grid's scroll - } - - [Fact] - public void Contents_grid_pads_empty_slots_to_main_pack_capacity() - { - // The grid shows the full main-pack capacity (default 102) — occupied + empty — so the - // pack reads like retail's fixed 102-slot grid you scroll, not just the loose items. - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - objects.AddOrUpdate(new ClientObject { ObjectId = Player, ItemsCapacity = 102 }); - SeedContained(objects, 0xA, Player, slot: 0, burden: 5); // one loose item - - Bind(layout, objects); - - Assert.Equal(102, grid.GetNumUIItems()); // 1 item + 101 empty frames = 102 - Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); - Assert.Equal(0u, grid.GetItem(50)!.ItemId); // a padded empty slot - } - - [Fact] - public void Side_bag_column_pads_empty_slots_up_to_capacity() - { - var (layout, _, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - objects.AddOrUpdate(new ClientObject { ObjectId = Player, ContainersCapacity = 3 }); - SeedContained(objects, 0xC, Player, slot: 0, type: ItemType.Container); // one side bag - - Bind(layout, objects); - - Assert.Equal(3, containers.GetNumUIItems()); // 1 bag + 2 empty = capacity 3 - Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); // the bag - Assert.Equal(0u, containers.GetItem(1)!.ItemId); // empty frame - Assert.Equal(0u, containers.GetItem(2)!.ItemId); // empty frame - } - - [Fact] - public void Empty_sprites_are_applied_to_the_three_lists() - { - var (layout, grid, containers, top, _, _, _, _) = BuildLayout(); - InventoryController.Bind(layout, new ClientObjectTable(), () => Player, - iconIds: (_, _, _, _, _) => 0u, strength: () => 100, datFont: null, - contentsEmptySprite: 0x06004D20u, sideBagEmptySprite: 0x06005D9Cu, mainPackEmptySprite: 0x06005D9Cu); - - Assert.Equal(0x06004D20u, grid.GetItem(0)!.EmptySprite); // a padded empty contents cell - Assert.Equal(0x06005D9Cu, containers.GetItem(0)!.EmptySprite); // a padded empty side-bag cell - Assert.Equal(0x06005D9Cu, top.GetItem(0)!.EmptySprite); // the main-pack cell - } - - [Fact] - public void ClickSideBag_sendsUse_andSwapsGridToBagContents() - { - var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); // a loose main-pack item - SeedBag(objects, 0xC, slot: 1); // side bag (player slot 1) - SeedContained(objects, 0xB1, 0xC, slot: 0); // a thing already known to be in the bag - var uses = new List(); - Bind(layout, objects, uses: uses); - - // The side-bag column's first cell holds the bag guid; click it. - containers.GetItem(0)!.Clicked!(); - - Assert.Contains(0xCu, uses); // Use(bag) sent - Assert.Equal(0xB1u, grid.GetItem(0)!.ItemId); // grid swapped to the bag's contents - Assert.Equal(24, grid.GetNumUIItems()); // padded to the bag's ItemsCapacity - } - - [Fact] - public void ClickMainPackCell_afterBag_closesBag_returnsToMainPack() - { - var (layout, grid, containers, top, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); // loose main-pack item - SeedBag(objects, 0xC, slot: 1); - SeedContained(objects, 0xB1, 0xC, slot: 0); - var uses = new List(); - var closes = new List(); - var ctrl = Bind(layout, objects, uses: uses, closes: closes); - - containers.GetItem(0)!.Clicked!(); // open the bag - top.GetItem(0)!.Clicked!(); // click the main-pack cell - - Assert.Contains(0xCu, closes); // NoLongerViewingContents(bag) sent - Assert.DoesNotContain(Player, uses); // no Use for the main pack - Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); // grid back to the main pack - Assert.Equal(102, grid.GetNumUIItems()); - } - - [Fact] - public void SwitchBetweenTwoBags_closesPrevious_opensNext() - { - var (layout, _, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedBag(objects, 0xC1, slot: 0); - SeedBag(objects, 0xC2, slot: 1); - var uses = new List(); - var closes = new List(); - Bind(layout, objects, uses: uses, closes: closes); - - containers.GetItem(0)!.Clicked!(); // open bag A (column index 0 → 0xC1) - containers.GetItem(1)!.Clicked!(); // open bag B (column index 1 → 0xC2) - - Assert.Equal(new[] { 0xC1u, 0xC2u }, uses.ToArray()); // Use(A) then Use(B) - Assert.Equal(new[] { 0xC1u }, closes.ToArray()); // close A when switching to B (not the main pack) - } - - [Fact] - public void OpenBag_marksTriangleAndSquare_onTheBagCell() - { - var (layout, _, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedBag(objects, 0xC, slot: 0); - Bind(layout, objects, uses: new List()); - - containers.GetItem(0)!.Clicked!(); - - Assert.True(containers.GetItem(0)!.IsOpenContainer); // triangle on the open bag - Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item - } - - [Fact] - public void ClickGridItem_movesSquareOnly_noWire_keepsOpenContainer() - { - var (layout, grid, _, top, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); - var uses = new List(); - var closes = new List(); - Bind(layout, objects, uses: uses, closes: closes); - - grid.GetItem(0)!.Clicked!(); // select the loose item - - Assert.True(grid.GetItem(0)!.Selected); // square on the selected grid item - Assert.True(top.GetItem(0)!.IsOpenContainer); // main pack still the open container (unchanged) - Assert.Empty(uses); // selection sends no wire - Assert.Empty(closes); - } - - [Fact] - public void Default_mainPackCell_isOpenContainer() - { - var (layout, _, _, top, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - Bind(layout, objects); - Assert.True(top.GetItem(0)!.IsOpenContainer); // default open container = main pack - } - - [Fact] - public void MainPackCell_requestsConstantBackpackIcon_notPlayerBodyIcon() // AP-51 retire - { - var (layout, _, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - // Give the player a (wrong) body icon to prove the main-pack cell does NOT use it. - objects.AddOrUpdate(new ClientObject { ObjectId = Player, IconId = 0x06001234u }); - (ItemType type, uint icon)? mainPackCall = null; - InventoryController.Bind(layout, objects, () => Player, - iconIds: (t, icon, _, _, _) => { if (icon == 0x0600127Eu) mainPackCall = (t, icon); return 0u; }, - strength: () => 100, datFont: null); - - // Retail draws a constant backpack over the Container type-underlay (IconData::RenderIcons - // IsThePlayer branch). The backpack RenderSurface 0x0600127E is visually confirmed (2026-06-22). - Assert.NotNull(mainPackCall); - Assert.Equal(ItemType.Container, mainPackCall!.Value.type); - Assert.Equal(0x0600127Eu, mainPackCall.Value.icon); - } - - [Fact] - public void SideBagCell_capacityBar_reflectsContents() - { - var (layout, _, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); // a side bag (cap 24) - for (uint i = 0; i < 6; i++) SeedContained(objects, 0xB0u + i, 0xC, slot: (int)i); // 6 items inside - Bind(layout, objects); - - // Retail UpdateCapacityDisplay: fill = contained / itemsCapacity = 6/24 = 0.25 (exact in float). - Assert.Equal(0.25f, containers.GetItem(0)!.CapacityFill); - } - - [Fact] - public void LooseGridItem_hasNoCapacityBar() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); // a loose, non-container item - Bind(layout, objects); - Assert.Equal(-1f, grid.GetItem(0)!.CapacityFill); // hidden — not a container - } - - private static ItemDragPayload Payload(uint obj) => new(obj, ItemDragSource.Inventory, 0, new UiItemSlot()); - - [Fact] - public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); - SeedContained(objects, 0xB, Player, slot: 1); - var puts = new List<(uint, uint, int)>(); - var ctrl = Bind(layout, objects, puts: puts); - - // Drag a fresh item 0xFFFF from elsewhere; drop on the grid cell holding 0xB (slot 1). - objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu }); - var bCell = grid.GetItem(1)!; // ItemId == 0xB, SlotIndex 1 - ((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xFFFFu)); - - Assert.Contains((0xFFFFu, Player, 1), puts); // insert-before slot 1, into the open container - Assert.Equal(Player, objects.Get(0xFFFFu)!.ContainerId); // moved locally (instant) - } - - [Fact] - public void Drop_onEmptyGridCell_appendsToFirstEmpty() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); // 1 loose item → first empty = slot 1 - var puts = new List<(uint, uint, int)>(); - var ctrl = Bind(layout, objects, puts: puts); - - objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu }); - var emptyCell = grid.GetItem(5)!; // an empty padded cell (ItemId 0) - ((IItemListDragHandler)ctrl).HandleDropRelease(grid, emptyCell, Payload(0xFFFFu)); - - Assert.Contains((0xFFFFu, Player, 1), puts); // placement = occupied count (1) = first empty - } - - [Fact] - public void Drop_onSideBagCell_movesIntoThatBag() - { - var (layout, _, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); - var puts = new List<(uint, uint, int)>(); - var ctrl = Bind(layout, objects, puts: puts); - - objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu }); - var bagCell = containers.GetItem(0)!; // ItemId == 0xC (the bag) - ((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xFFFFu)); - - Assert.Contains((0xFFFFu, 0xCu, 0), puts); // into the bag, append (placement 0) - Assert.Equal(0xCu, objects.Get(0xFFFFu)!.ContainerId); - } - - [Fact] - public void OnDragOver_fullSideBag_rejects_butGridAccepts() - { - var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedBag(objects, 0xC, slot: 0, itemsCapacity: 1); // capacity 1... - SeedContained(objects, 0xB0, 0xC, slot: 0); // ...and already full - objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu }); - var ctrl = (IItemListDragHandler)Bind(layout, objects); - - Assert.False(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); // full bag → red - Assert.True(ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green - } - - [Fact] - public void OnDragLift_isNoOp_itemStaysUntilServerConfirms() - { - var (layout, grid, _, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 0); - var ctrl = (IItemListDragHandler)Bind(layout, objects); - ((IItemListDragHandler)ctrl).OnDragLift(grid, grid.GetItem(0)!, Payload(0xAu)); - Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // NOT removed on lift (unlike the toolbar) - } - - [Fact] - public void OnDragOver_closedBag_unknownCount_advisoryAccepts() // AP-61 - { - var (layout, _, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); // a bag with capacity but NO indexed contents (closed) - objects.AddOrUpdate(new ClientObject { ObjectId = 0xFFFFu }); - var ctrl = (IItemListDragHandler)Bind(layout, objects); - - // GetContents(0xC) is empty (a closed bag's contents aren't indexed until opened) → not known-full → accept. - Assert.True(ctrl.OnDragOver(containers, containers.GetItem(0)!, Payload(0xFFFFu))); - } - - [Fact] - public void Drop_thenServerRollback_revertsTheMove() // optimistic + InventoryServerSaveFailed snap-back - { - var (layout, _, containers, _, _, _, _, _) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedContained(objects, 0xA, Player, slot: 3); // item starts in the main pack at slot 3 - SeedBag(objects, 0xC, slot: 0, itemsCapacity: 24); - var ctrl = Bind(layout, objects); - - ((IItemListDragHandler)ctrl).HandleDropRelease(containers, containers.GetItem(0)!, Payload(0xAu)); - Assert.Equal(0xCu, objects.Get(0xAu)!.ContainerId); // moved into the bag optimistically (instant) - - objects.RollbackMove(0xAu); // server rejected (InventoryServerSaveFailed) - Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // snapped back to the main pack - Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot); // and the original slot - } - - // Reads the text of the UiText caption child attached by the controller. - private static string CaptionText(UiElement host) - { - foreach (var c in host.Children) - if (c is UiText t) - { - var lines = t.LinesProvider(); - if (lines.Count > 0) return lines[0].Text; - } - return ""; - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs deleted file mode 100644 index 57269580..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryFrameImportProbe.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.IO; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using DatReaderWriter; -using DatReaderWriter.Options; -using Xunit; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Real-dat smoke test for the gmInventoryUI frame (0x21000023) import + the sub-window -/// mount. Locks the #145-continuation fix: the mount keeps each mounted panel slot's OWN -/// frame ZLevel instead of inheriting the sub-window root's ZLevel 1000. Before the fix the -/// merge's zero-wins-base rule gave the slots ZLevel 1000 → the #145 ZOrder fold -/// (ReadOrder − ZLevel·10000) sank them to ≈ −10,000,000, BEHIND the frame's Alphablend -/// backdrop (ZLevel 100 → ≈ −1,000,000), and the backdrop washed out the panels' captions, -/// burden meter, and item cells. Skips when the live dat directory is absent (CI). -/// -public class InventoryFrameImportProbe -{ - private const uint Frame = 0x21000023u; - private const uint Backdrop = 0x100001D0u; // full-window Alphablend backdrop (ZLevel 100) - private const uint BackpackPanel = 0x100001CEu; // mounted gmBackpackUI slot - private const uint ItemsPanel = 0x100001CFu; // mounted gm3DItemsUI slot - private const uint PaperdollPanel = 0x100001CDu; // mounted gmPaperDollUI slot - private const uint BurdenMeter = 0x100001D9u; // backpack content (proves the mount attached it) - private const uint ContentsGrid = 0x100001C6u; // 3D-items content - - private static string? DatDir() - { - var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - return Directory.Exists(d) ? d : null; - } - - [Fact] - public void Paperdoll_equip_slots_resolve_to_item_lists() - { - var datDir = DatDir(); - if (datDir is null) return; // CI: no live dat — skip - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null); - Assert.NotNull(layout); - - // A representative spread across the slot grid (head, shield, the weapon composite, cloak, - // trinket, a finger). All inherit base 0x100001E4 → 0x2100003D (Type 0x10000031 = UIElement_ItemList). - foreach (uint id in new[] { 0x100005ABu, 0x100001E1u, 0x100001DFu, 0x100005E9u, 0x1000058Eu, 0x100001DCu }) - { - var el = layout!.FindElement(id); - Assert.True(el is UiItemList, - $"equip slot 0x{id:X8} resolved to {el?.GetType().Name ?? "null"}, expected UiItemList"); - } - } - - [Fact] - public void Mounted_panels_sit_in_front_of_the_backdrop() - { - var datDir = DatDir(); - if (datDir is null) return; // CI: no live dat — skip (this is a smoke test) - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var layout = LayoutImporter.Import(dats, Frame, _ => (0u, 0, 0), null); - Assert.NotNull(layout); - - var backdrop = layout!.FindElement(Backdrop); - Assert.NotNull(backdrop); // the full-window backdrop is a direct child of the frame - - // The sub-window mount brings each panel's content into the by-id index. - Assert.NotNull(layout.FindElement(BurdenMeter)); // backpack burden meter - Assert.NotNull(layout.FindElement(ContentsGrid)); // 3D-items contents grid - - // The fix: every mounted panel slot must draw IN FRONT of the backdrop - // (higher ZOrder = painted later = on top), so the backdrop sits behind the content. - foreach (var (id, name) in new[] - { - (BackpackPanel, "backpack"), (ItemsPanel, "3D-items"), (PaperdollPanel, "paperdoll"), - }) - { - var panel = layout.FindElement(id); - Assert.True(panel is not null, $"{name} panel 0x{id:X8} missing from the imported tree"); - Assert.True(panel!.ZOrder > backdrop!.ZOrder, - $"{name} panel ZOrder {panel.ZOrder} must be > backdrop ZOrder {backdrop.ZOrder} " + - "(else the Alphablend backdrop overpaints/washes out the panel content)"); - } - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs b/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs deleted file mode 100644 index dc837367..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/ItemListCellTemplateTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System; -using System.IO; -using AcDream.App.UI.Layout; -using DatReaderWriter; -using DatReaderWriter.Options; -using Xunit; -using Xunit.Abstractions; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Real-dat test for : the inventory -/// item-lists must resolve their empty-slot sprite from the dat cell template (attribute -/// 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty), NOT the generic toolbar -/// square 0x060074CF. Also PRINTS the resolved ids — the "pin the exact asset" record. -/// Skips when the live dat directory is absent (CI). -/// -public class ItemListCellTemplateTests -{ - private const uint Generic = 0x060074CFu; // generic toolbar-shortcut empty square (the bug) - - private const uint ContentsLayout = 0x21000021u; private const uint ContentsGrid = 0x100001C6u; - private const uint BackpackLayout = 0x21000022u; private const uint SideBag = 0x100001CAu; - private const uint MainPack = 0x100001C9u; - - private readonly ITestOutputHelper _out; - public ItemListCellTemplateTests(ITestOutputHelper o) => _out = o; - - private static string? DatDir() - { - var d = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - return Directory.Exists(d) ? d : null; - } - - [Fact] - public void Inventory_lists_resolve_a_real_nongeneric_empty_sprite() - { - var datDir = DatDir(); - if (datDir is null) return; // CI: no live dat — skip (smoke test) - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - foreach (var (name, layout, elem) in new[] - { - ("contents", ContentsLayout, ContentsGrid), - ("side-bag", BackpackLayout, SideBag), - ("main-pack", BackpackLayout, MainPack), - }) - { - uint sprite = ItemListCellTemplate.ResolveEmptySprite(dats, layout, elem); - _out.WriteLine($"{name}: list 0x{elem:X8} (layout 0x{layout:X8}) -> empty sprite 0x{sprite:X8}"); - Assert.True(sprite != 0u, $"{name}: resolved 0 (no 0x1000000e attr / prototype / media)"); - Assert.True(sprite != Generic, $"{name}: resolved the generic 0x060074CF — the bug, not the fix"); - Assert.True((sprite & 0xFF000000u) == 0x06000000u, $"{name}: 0x{sprite:X8} is not a 0x06 RenderSurface id"); - } - } - - [Fact] - public void Inventory_lists_resolve_the_pinned_retail_sprites() - { - var datDir = DatDir(); - if (datDir is null) return; // CI: no live dat — skip - - using var dats = new DatCollection(datDir, DatAccessType.Read); - // Pinned from the live dat + the 2026-06-22 visual gate. Contents = the 32x32 prototype's - // ItemSlot_Empty; the 36x36 container/main-pack resolve THROUGH inheritance to their inner - // ItemSlot_Empty (NOT the 0x06005D9C open-container triangle the first cut grabbed — a - // structural-only assertion let that wrong-but-valid 0x06 sprite pass, so we pin exacts). - Assert.Equal(0x06004D20u, ItemListCellTemplate.ResolveEmptySprite(dats, ContentsLayout, ContentsGrid)); - Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, SideBag)); - Assert.Equal(0x06000F6Eu, ItemListCellTemplate.ResolveEmptySprite(dats, BackpackLayout, MainPack)); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs deleted file mode 100644 index ba336aac..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs +++ /dev/null @@ -1,198 +0,0 @@ -using AcDream.App.UI; -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Golden conformance tests for the vitals LayoutDesc importer. -/// Uses the committed JSON fixture (vitals_2100006C.json) — no dats, no GL. -/// -/// These tests lock the importer's tree-building (factory dispatch, meter slice -/// extraction, rects) against the real portal.dat values captured when the -/// fixture was generated. Any regression in , -/// , or will surface here. -/// -/// Sprite ids sourced from docs/research/2026-06-15-layoutdesc-format.md §11. -/// -[Trait("Category", "Conformance")] -public class LayoutConformanceTests -{ - // ── Test 1: Three meters at expected rects ──────────────────────────────── - - /// - /// The three vital bars must be UiMeters positioned at x=5, width=150, height=16, - /// at y=5 (health), y=21 (stamina), y=37 (mana). - /// - [Fact] - public void VitalsTree_HasThreeMetersAtExpectedRects() - { - var layout = FixtureLoader.LoadVitals(); - - (uint Id, float Y)[] expected = - [ - (0x100000E6u, 5f), // health - (0x100000ECu, 21f), // stamina - (0x100000EEu, 37f), // mana - ]; - - foreach (var (id, y) in expected) - { - var elem = layout.FindElement(id); - Assert.NotNull(elem); - var meter = Assert.IsType(elem); - Assert.Equal(5f, meter.Left); - Assert.Equal(y, meter.Top); - Assert.Equal(150f, meter.Width); - Assert.Equal(16f, meter.Height); - } - } - - // ── Test 2: All 18 slice ids ────────────────────────────────────────────── - - /// - /// The six back+front 3-slice sprite ids for each of the three meters must - /// match the values confirmed from the dat dump (format doc §11). - /// This proves the factory's grandchild slice extraction against committed data. - /// - [Fact] - public void VitalsTree_MetersHaveExpectedSliceIds() - { - var layout = FixtureLoader.LoadVitals(); - - // Columns: MeterId, then 6 slice ids in order: - // BackLeft, BackTile, BackRight, FrontLeft, FrontTile, FrontRight - (uint MeterId, uint[] Slices)[] cases = - [ - (0x100000E6u, [0x0600747Eu, 0x0600747Fu, 0x06007480u, 0x06007481u, 0x06007482u, 0x06007483u]), // health - (0x100000ECu, [0x06007484u, 0x06007485u, 0x06007486u, 0x06007487u, 0x06007488u, 0x06007489u]), // stamina - (0x100000EEu, [0x0600748Au, 0x0600748Bu, 0x0600748Cu, 0x0600748Du, 0x0600748Eu, 0x0600748Fu]), // mana - ]; - - foreach (var (meterId, s) in cases) - { - var m = Assert.IsType(layout.FindElement(meterId)); - Assert.Equal(s[0], m.BackLeft); Assert.Equal(s[1], m.BackTile); Assert.Equal(s[2], m.BackRight); - Assert.Equal(s[3], m.FrontLeft); Assert.Equal(s[4], m.FrontTile); Assert.Equal(s[5], m.FrontRight); - } - } - - // ── Test 3: Chrome TL corner sprite ─────────────────────────────────────── - // - // NOTE: Type 3 is retail UIElement_Field, but acdream's Type-3 elements here are - // sprite-bearing CHROME (the 8-piece bevel corners), so they stay on the generic - // UiDatElement fallback (NOT registered as UiField in the factory — see - // DatWidgetFactory.Create). This test guards that the chrome corner keeps drawing - // its dat sprite; if a future change routes Type 3 → UiField, the corner sprite - // would vanish and this assertion fails — which is the intended early warning. - - /// - /// The top-left chrome corner element (id 0x10000633) must be a - /// whose active media file id is 0x060074C3. - /// - [Fact] - public void VitalsTree_ChromeCornerHasExpectedSprite() - { - var layout = FixtureLoader.LoadVitals(); - - var elem = layout.FindElement(0x10000633u); - Assert.NotNull(elem); - var datElem = Assert.IsType(elem); - var (file, _) = datElem.ActiveMedia(); - Assert.Equal(0x060074C3u, file); - } - - // ── Test 4 (N4): Inheritance resolution — FontDid propagated from base ─── - - /// - /// Proves that Resolve()'s inheritance merge fired against real dat data: - /// at least one element in the fixture tree must have FontDid == 0x40000000 - /// (the vitals font), inherited from the base-layout prototype 0x10000376 - /// in 0x2100003F via the BaseElement / BaseLayoutId chain. - /// - /// - /// The three text labels (0x100000EB health, 0x100000ED stamina, - /// 0x100000EF mana) are Type=0 derived elements with no own font property. - /// The base element 0x10000376 carries Properties[0x1A] → - /// ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ]. - /// propagates this via the "FontDid: derived wins - /// if non-zero, otherwise inherit" rule. - /// - /// - /// - /// This test verifies end-to-end inheritance resolution against the committed fixture - /// (format doc §10, docs/research/2026-06-15-layoutdesc-format.md). - /// It operates on the raw tree, NOT the widget tree, - /// so the factory dispatch (Type 12 → skip) does not interfere. - /// - /// - [Fact] - public void VitalsTree_TextLabel_InheritsFontDidFromBaseLayout() - { - var root = FixtureLoader.LoadVitalsInfos(); - - // Walk the full ElementInfo tree and collect all FontDid values. - var fontDids = new System.Collections.Generic.List(); - CollectFontDids(root, fontDids); - - // At least one element must carry FontDid == 0x40000000 (the vitals font). - // In practice, the three text labels (health/stamina/mana) all inherit it. - Assert.Contains(0x40000000u, fontDids); - } - - private static void CollectFontDids(ElementInfo node, System.Collections.Generic.List acc) - { - if (node.FontDid != 0) acc.Add(node.FontDid); - foreach (var child in node.Children) - CollectFontDids(child, acc); - } - - // ── Test 5: Horizontal resize conformance (160→200) ────────────────────── - - /// - /// Proves end-to-end reflow for a 160→200 width change using the corrected - /// ToAnchors mapping (UIElement::UpdateForParentSizeChange @0x00462640). - /// - /// For each piece, margins are computed from the 160-wide design rect and then - /// is applied at parentW=200. - /// - /// Expected outcomes: - /// - TL corner (L=1,R=2): Left only → fixed at x=0, w=5 - /// - top edge (L=1,R=1): Left+Right → stretches to w=190 at x=5 - /// - TR corner (L=2,R=1): Right only → tracks right at x=195, w=5 - /// - meter (L=1,R=1): Left+Right → stretches to w=190 at x=5 - /// - [Fact] - public void HorizontalResize_160to200_ReflowsCorrectly() - { - const float designParentW = 160f; - const float newParentW = 200f; - const float parentH = 58f; - - // (piece, designX, designW, LeftEdge, RightEdge, expectedX, expectedW) - (string Piece, float DesignX, float DesignW, uint L, uint R, float ExpX, float ExpW)[] cases = - [ - ("TL corner", 0f, 5f, 1u, 2u, 0f, 5f ), - ("top edge", 5f, 150f, 1u, 1u, 5f, 190f), - ("TR corner", 155f, 5f, 2u, 1u, 195f, 5f ), - ("meter", 5f, 150f, 1u, 1u, 5f, 190f), - ]; - - foreach (var (piece, dX, dW, l, r, expX, expW) in cases) - { - // T/B values don't affect x/w; use real vitals values (top=1, bottom=2) - var anchors = ElementReader.ToAnchors(l, top: 1u, r, bottom: 2u); - - // Margins from the design rect at parentW=160 - float mL = dX; - float mR = designParentW - (dX + dW); - - // Reflow at parentW=200 (parentH irrelevant for x/w assertions) - var (x, _, w, _) = UiElement.ComputeAnchoredRect( - anchors, mL, mT: 0f, mR, mB: 0f, w0: dW, h0: 5f, parentW: newParentW, parentH); - - // xUnit 2.x Assert.Equal(float,float,int) = decimal-place precision - Assert.True(Math.Abs(x - expX) < 0.5f, $"{piece}: expected x={expX} got {x}"); - Assert.True(Math.Abs(w - expW) < 0.5f, $"{piece}: expected w={expW} got {w}"); - } - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs deleted file mode 100644 index 34657064..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/LayoutImporterTests.cs +++ /dev/null @@ -1,247 +0,0 @@ -using AcDream.App.UI; -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Pure unit tests for — no dats, no GL. -/// Verifies the tree-builder: widget dispatch, Type-12 skipping, and meter child consumption. -/// -public class LayoutImporterTests -{ - private static (uint, int, int) NoTex(uint _) => (0, 0, 0); - - // ── Test 1: Health meter element → UiMeter with correct rect ───────────── - - /// - /// A Type-7 (meter) child element with X=5,Y=5,W=150,H=16 must produce a UiMeter - /// that is findable by its id, positioned at Left=5, Width=150. - /// The resolve lambda is a 1-arg Func<uint,(uint,int,int)>. - /// - [Fact] - public void BuildFromInfos_HealthMeter_IsUiMeterAtRect() - { - var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 }; - var health = new ElementInfo { Id = 0x100000E6, Type = 7, X = 5, Y = 5, Width = 150, Height = 16 }; - - var tree = LayoutImporter.BuildFromInfos(root, new[] { health }, NoTex, null); - - var found = tree.FindElement(0x100000E6); - Assert.IsType(found); - Assert.Equal(5f, found!.Left); - Assert.Equal(150f, found.Width); - } - - // ── Test 2: Type-12 child builds a UiText; Type-3 sibling is also present ── - - /// - /// A root with two children: one Type-12 UIElement_Text and one Type-3 container. - /// The Type-12 must appear as a in the tree (transparent, - /// draws nothing until a controller binds its LinesProvider); - /// the Type-3 must also be present. - /// - [Fact] - public void BuildFromInfos_Type12Child_IsSkipped_Type3Present() - { - var root = new ElementInfo { Id = 0x10000001, Type = 3, Width = 160, Height = 58 }; - var prototype = new ElementInfo { Id = 0x20000001, Type = 12, Width = 0, Height = 0 }; - var container = new ElementInfo { Id = 0x20000002, Type = 3, Width = 100, Height = 20 }; - - var tree = LayoutImporter.BuildFromInfos(root, new[] { prototype, container }, NoTex, null); - - // Type-12 is now a UiText (transparent, no lines) — present in the tree. - Assert.IsType(tree.FindElement(0x20000001)); - // Type-3 must also be present. - Assert.NotNull(tree.FindElement(0x20000002)); - } - - // ── Test 3: Meter consumes Type-3 slice children — child ids not in byId ──── - - /// - /// A meter (Type 7) whose children are ONLY the 3-slice back/front containers (Type 3). - /// The meter itself must be findable; its Type-3 children must NOT appear as separate - /// nodes in the tree (Fix 5: only non-Type-3 children are built as separate widgets). - /// - [Fact] - public void BuildFromInfos_MeterWithSliceChildren_MeterPresent_SliceChildrenNotInTree() - { - const uint MeterId = 0x100000E6u; - const uint BackLayerId = 0x100000E7u; - const uint FrontLayerId = 0x00000002u; - - // Build a minimal meter with back + front containers, each with 3 slice children. - var backContainer = BuildSliceContainer(BackLayerId, ReadOrder: 0, - l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u); - var frontContainer = BuildSliceContainer(FrontLayerId, ReadOrder: 1, - l: 0x06007481u, t: 0x06007482u, r: 0x06007483u); - - var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 }; - meter.Children.Add(backContainer); - meter.Children.Add(frontContainer); - - var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 }; - - var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null); - - // The meter widget is present. - Assert.IsType(tree.FindElement(MeterId)); - // The meter's Type-3 slice children are NOT separate UiElement nodes. - Assert.Null(tree.FindElement(BackLayerId)); - Assert.Null(tree.FindElement(FrontLayerId)); - // The UiMeter itself has no UiElement children (all children were Type-3, consumed). - var uiMeter = (UiMeter)tree.FindElement(MeterId)!; - Assert.Empty(uiMeter.Children); - } - - // ── Test 5: Fix 5 — meter text children (Type-12) are built and registered ─ - - /// - /// Fix 5: a meter (Type 7) with BOTH Type-3 slice containers AND a Type-12 text - /// child. The Type-3 containers must be consumed (not in byId); the Type-12 text - /// child must be built as a UiText, registered in byId, and attached as a UiElement - /// child of the meter (so it renders as an overlay). - /// - [Fact] - public void BuildFromInfos_MeterWithTextChild_TextChildInTreeAsUiTextChildOfMeter() - { - const uint MeterId = 0x10000236u; - const uint BackId = 0x10000237u; // Type-3 slice - const uint FrontId = 0x10000238u; // Type-3 slice - const uint LabelId = 0x10000239u; // Type-12 text child - const uint ValueId = 0x1000023Au; // Type-12 text child - - var backContainer = BuildSliceContainer(BackId, ReadOrder: 0, - l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u); - var frontContainer = BuildSliceContainer(FrontId, ReadOrder: 1, - l: 0x06007481u, t: 0x06007482u, r: 0x06007483u); - - // Two Type-12 text children (the XP "label" and "value" overlay). - var labelInfo = new ElementInfo { Id = LabelId, Type = 12, X = 0, Y = 0, Width = 120, Height = 13 }; - var valueInfo = new ElementInfo { Id = ValueId, Type = 12, X = 0, Y = 0, Width = 200, Height = 13, - HJustify = HJustify.Right }; - - var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 200, Height = 13 }; - meter.Children.Add(backContainer); - meter.Children.Add(frontContainer); - meter.Children.Add(labelInfo); - meter.Children.Add(valueInfo); - - var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 210, Height = 20 }; - - var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null); - - // Meter is present. - Assert.IsType(tree.FindElement(MeterId)); - - // Type-3 slice containers are NOT in byId (consumed by BuildMeter). - Assert.Null(tree.FindElement(BackId)); - Assert.Null(tree.FindElement(FrontId)); - - // Type-12 text children ARE in byId as UiText. - Assert.IsType(tree.FindElement(LabelId)); - Assert.IsType(tree.FindElement(ValueId)); - - // The UiMeter has exactly 2 UiElement children (the two text overlays). - var uiMeter = (UiMeter)tree.FindElement(MeterId)!; - Assert.Equal(2, uiMeter.Children.Count); - Assert.All(uiMeter.Children, c => Assert.IsType(c)); - - // The value child should have RightAligned=true (HJustify.Right in the dat). - var valueWidget = (UiText)tree.FindElement(ValueId)!; - Assert.True(valueWidget.RightAligned, "Type-12 text child with HJustify.Right must build as RightAligned=true"); - } - - // ── Test 6: Fix 5 — vitals meters (Type-3 only) are unaffected ──────────── - - /// - /// Fix 5 regression guard: vitals health/stamina/mana meters have ONLY Type-3 slice - /// children (no Type-12 text children). The meter must have zero UiElement children - /// after build — VitalsController injects its number overlay at runtime, not the importer. - /// - [Fact] - public void BuildFromInfos_VitalsMeter_NoTextChildren_MeterHasNoUiChildren() - { - const uint MeterId = 0x100000E6u; // vitals health meter - const uint BackId = 0x100000E7u; - const uint FrontId = 0x100000E8u; - - var backContainer = BuildSliceContainer(BackId, ReadOrder: 0, - l: 0x0600747Eu, t: 0x0600747Fu, r: 0x06007480u); - var frontContainer = BuildSliceContainer(FrontId, ReadOrder: 1, - l: 0x06007481u, t: 0x06007482u, r: 0x06007483u); - - var meter = new ElementInfo { Id = MeterId, Type = 7, Width = 150, Height = 16 }; - meter.Children.Add(backContainer); - meter.Children.Add(frontContainer); - - var root = new ElementInfo { Id = 0x100005F9, Type = 3, Width = 160, Height = 58 }; - - var tree = LayoutImporter.BuildFromInfos(root, new[] { meter }, NoTex, null); - - Assert.IsType(tree.FindElement(MeterId)); - - // Vitals meter has no UiElement children — only Type-3 children were present, - // all consumed, none built as UiText overlays. - var uiMeter = (UiMeter)tree.FindElement(MeterId)!; - Assert.Empty(uiMeter.Children); - } - - // ── Test 4: Prototype-skip in BuildFromInfos ───────────────────────────── - - /// - /// When one top-level element is referenced as a BaseElement by a sibling - /// (mirroring the toolbar slot prototype pattern), and the prototype element - /// has no own state media, the importer must NOT produce a widget for the - /// prototype id (FindElement returns null), but MUST produce the derived element. - /// - /// NOTE: This test exercises (the pure - /// layer), where prototype detection is done by inspecting the pre-resolved - /// ElementInfo tree rather than the raw dat ElementDesc. The pure layer skips - /// an element if its Id is in a sibling's (or child's) Children chain - /// as a BaseElement — but actually the pure layer has no BaseElement knowledge - /// at this stage (that's resolved before Build). The prototype-skip in the real - /// world occurs in ImportInfos (the dat shell), BEFORE calling Build. - /// - /// This test verifies the INVARIANT that holds AFTER ImportInfos filters prototypes: - /// a pure template element that was skipped is absent from FindElement, while the - /// derived element (which inherited from it) IS present. - /// - /// We model this by simply NOT adding the prototype to the ElementInfo tree passed - /// to BuildFromInfos — as if ImportInfos already filtered it out. - /// - [Fact] - public void BuildFromInfos_PrototypeSkipped_DerivedPresent_PrototypeAbsent() - { - // Simulate what ImportInfos does AFTER filtering: the prototype 0xBBB00001 is - // absent (already skipped by ImportInfos), the derived element 0xCCC00001 is - // present with its own media inherited from the prototype. - var root = new ElementInfo { Id = 0x10000001, Type = 3, Width = 200, Height = 100 }; - // The derived element has its own size + media (prototype was merged into it already). - var derived = new ElementInfo - { - Id = 0xCCC00001u, - Type = 0x10000031u, // UIElement_ItemList (toolbar slot type) - X = 10, Y = 10, Width = 32, Height = 32, - }; - derived.StateMedia[""] = (0x06001234u, 1); - - // Only the derived element appears in the tree (prototype was filtered by ImportInfos). - var tree = LayoutImporter.BuildFromInfos(root, new[] { derived }, NoTex, null); - - // The derived element is present in the built tree. - Assert.NotNull(tree.FindElement(0xCCC00001u)); - // The prototype id is NOT in the tree (was never added). - Assert.Null(tree.FindElement(0xBBB00001u)); - } - - // ── Helpers ─────────────────────────────────────────────────────────────── - - private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r) - { - var c = new ElementInfo { Id = id, Type = 3, ReadOrder = ReadOrder }; - c.Children.Add(new ElementInfo { X = 0, StateMedia = { [""] = (l, 1) } }); - c.Children.Add(new ElementInfo { X = 10, StateMedia = { [""] = (t, 1) } }); - c.Children.Add(new ElementInfo { X = 140, StateMedia = { [""] = (r, 1) } }); - return c; - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs deleted file mode 100644 index add55b4f..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollControllerTests.cs +++ /dev/null @@ -1,148 +0,0 @@ -using System.Collections.Generic; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.App.Tests.UI.Layout; - -public class PaperdollControllerTests -{ - private const uint Player = 0x50000001u; - private const uint Pack = 0x40000005u; - private const uint HeadSlot = 0x100005ABu; // HeadWear 0x1 - private const uint ShieldSlot = 0x100001E1u; // Shield 0x200000 - private const uint WeaponSlot = 0x100001DFu; // composite 0x3500000 - private const uint FingerLSlot= 0x100001DCu; // FingerWearLeft 0x40000 - - private sealed class RootElement : UiElement { } - - private static (ImportedLayout layout, Dictionary lists) BuildLayout() - { - var ids = new[] { HeadSlot, ShieldSlot, WeaponSlot, FingerLSlot }; - var lists = new Dictionary(); - var byId = new Dictionary(); - var root = new RootElement { Width = 224, Height = 214 }; - foreach (var id in ids) - { - var list = new UiItemList { Width = 32, Height = 32 }; - lists[id] = list; byId[id] = list; root.AddChild(list); - } - return (new ImportedLayout(root, byId), lists); - } - - private static PaperdollController Bind(ImportedLayout layout, ClientObjectTable objects, - List<(uint item, uint mask)>? wields = null, uint emptySlot = 0u) - => PaperdollController.Bind(layout, objects, () => Player, - iconIds: (_, _, _, _, _) => 0x1234u, - sendWield: wields is null ? null : (i, m) => wields.Add((i, m)), - emptySlotSprite: emptySlot); - - private static void SeedEquipped(ClientObjectTable t, uint guid, EquipMask loc) - { - t.AddOrUpdate(new ClientObject { ObjectId = guid, WielderId = Player }); - t.MoveItem(guid, Player, newSlot: -1, newEquipLocation: loc); - } - - private static void SeedPackItem(ClientObjectTable t, uint guid, EquipMask validLocations) - { - t.AddOrUpdate(new ClientObject { ObjectId = guid, ValidLocations = validLocations }); - t.MoveItem(guid, Pack, newSlot: 0); - } - - [Fact] - public void Populate_shows_equipped_item_in_its_slot() - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedEquipped(objects, 0xA01u, EquipMask.HeadWear); - Bind(layout, objects); - Assert.Equal(0xA01u, lists[HeadSlot].Cell.ItemId); // helm in the head slot - Assert.Equal(0u, lists[ShieldSlot].Cell.ItemId); // shield slot empty - } - - [Fact] - public void Populate_matches_a_weapon_into_the_composite_slot() - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedEquipped(objects, 0xB01u, EquipMask.MeleeWeapon); // a sword's actual equip loc - Bind(layout, objects); - Assert.Equal(0xB01u, lists[WeaponSlot].Cell.ItemId); // matched via (loc & composite) != 0 - } - - [Fact] - public void OnDragOver_accepts_only_valid_locations() - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedPackItem(objects, 0xC01u, EquipMask.HeadWear); // a helm in the pack - var ctrl = Bind(layout, objects); - var payload = new ItemDragPayload(0xC01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell); - Assert.True(ctrl.OnDragOver(lists[HeadSlot], lists[HeadSlot].Cell, payload)); // helm → head OK - Assert.False(ctrl.OnDragOver(lists[ShieldSlot], lists[ShieldSlot].Cell, payload)); // helm → shield NO - } - - [Fact] - public void HandleDropRelease_wields_optimistically_and_sends_wire() - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedPackItem(objects, 0xD01u, EquipMask.HeadWear); - var wields = new List<(uint item, uint mask)>(); - var ctrl = Bind(layout, objects, wields); - var payload = new ItemDragPayload(0xD01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell); - ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload); - Assert.Equal(EquipMask.HeadWear, objects.Get(0xD01u)!.CurrentlyEquippedLocation); // equipped instantly - Assert.Equal(Player, objects.Get(0xD01u)!.ContainerId); // contained-by-wielder (the optimistic wield is ContainerId-based; it does NOT write WielderId) - Assert.Single(wields); - Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire - } - - [Fact] - public void HandleDropRelease_resolves_the_finger_bit_for_a_dual_finger_ring() - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - SeedPackItem(objects, 0xE01u, EquipMask.FingerWearLeft | EquipMask.FingerWearRight); - var wields = new List<(uint item, uint mask)>(); - var ctrl = Bind(layout, objects, wields); - var payload = new ItemDragPayload(0xE01u, ItemDragSource.Inventory, 0, lists[FingerLSlot].Cell); - ctrl.HandleDropRelease(lists[FingerLSlot], lists[FingerLSlot].Cell, payload); - Assert.Equal((uint)EquipMask.FingerWearLeft, wields[0].mask); // ValidLocations & slotMask = left finger only - } - - [Fact] - public void Empty_equip_slot_shows_the_configured_frame() // visible frame so all positions are usable (Slice 1; the doll is Slice 2) - { - var (layout, lists) = BuildLayout(); - Bind(layout, new ClientObjectTable(), emptySlot: 0x06004D20u); - Assert.Equal(0x06004D20u, lists[HeadSlot].Cell.EmptySprite); - } - - [Fact] - public void Live_player_wield_repaints_the_slot() // event-driven: ObjectMoved → Concerns → Populate - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - Bind(layout, objects); // slots empty at bind - SeedPackItem(objects, 0xF01u, EquipMask.HeadWear); // a helm appears in the pack (not on the doll) - Assert.Equal(0u, lists[HeadSlot].Cell.ItemId); - objects.WieldItemOptimistic(0xF01u, Player, EquipMask.HeadWear); // player wields it → ObjectMoved(to=player) - Assert.Equal(0xF01u, lists[HeadSlot].Cell.ItemId); // the head slot repainted with the helm - } - - [Fact] - public void Live_npc_equip_does_not_appear_on_the_doll() // player-scoped: a remote creature's wielded item never leaks - { - var (layout, lists) = BuildLayout(); - var objects = new ClientObjectTable(); - Bind(layout, objects); - const uint npc = 0x60000001u; - objects.AddOrUpdate(new ClientObject - { - ObjectId = 0xF02u, WielderId = npc, CurrentlyEquippedLocation = EquipMask.HeadWear, - }); - Assert.Equal(0u, lists[HeadSlot].Cell.ItemId); // the NPC's helm is NOT on the player's doll - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs b/tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs deleted file mode 100644 index 7241dd01..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs +++ /dev/null @@ -1,44 +0,0 @@ -using AcDream.App.UI.Layout; -using Xunit; - -namespace AcDream.App.Tests.UI.Layout; - -public class PaperdollToggleTests -{ - // The 9 armor element-ids that gmPaperDollUI::ListenToElementMessage flips - // (decomp 175674-175706). Doll-view hides these; slot-view shows them. - private static readonly uint[] ArmorIds = - { - 0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu, - 0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u, - }; - - [Fact] - public void ArmorSlotIds_match_the_decomp_nine() - { - Assert.Equal(ArmorIds, PaperdollController.ArmorSlotElementIds); - } - - [Fact] - public void DollView_default_shows_doll_hides_armor() - { - var v = new PaperdollController.PaperdollViewState(); - Assert.False(v.SlotView); - Assert.True(v.DollVisible); - Assert.False(v.ArmorSlotsVisible); - } - - [Fact] - public void Toggle_to_slotview_hides_doll_shows_armor_and_back() - { - var v = new PaperdollController.PaperdollViewState(); - v.Toggle(); - Assert.True(v.SlotView); - Assert.False(v.DollVisible); - Assert.True(v.ArmorSlotsVisible); - v.Toggle(); - Assert.False(v.SlotView); - Assert.True(v.DollVisible); - Assert.False(v.ArmorSlotsVisible); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs deleted file mode 100644 index cdefebc0..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs +++ /dev/null @@ -1,406 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using Xunit; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Unit tests for — the -/// gmToolbarUI::HandleSelectionChanged + RecvNotice_UpdateObjectHealth -/// analogue (acclient_2013_pseudo_c.txt:198635 / :196213). -/// -/// -/// Key behavior under test: the Health meter is UpdateHealth-driven — it becomes -/// visible only when real health is known for the selected guid (a HealthChanged -/// fires for it, or it is already cached at select time via hasHealth). Selecting a -/// target does NOT show the meter on its own. This matches retail: a friendly NPC you have -/// not assessed shows name-only; a monster's bar appears after damage / assess. -/// -/// -public class SelectedObjectControllerTests -{ - // ── Shared layout ──────────────────────────────────────────────────────── - - private static ( - ImportedLayout layout, - UiPanel nameEl, - UiDatElement overlayEl, - UiMeter healthMeterEl) - FakeLayout() - { - var dict = new Dictionary(); - var root = new UiPanel(); - - var nameEl = new UiPanel { Width = 100, Height = 20 }; - dict[SelectedObjectController.NameId] = nameEl; - root.AddChild(nameEl); - - var overlayInfo = new ElementInfo - { - Id = SelectedObjectController.OverlayId, - Type = 3, - StateMedia = - { - [""] = (0x06000001u, 3), - ["ObjectSelected"] = (0x06001937u, 3), - ["StackedItemSelected"] = (0x06004CF4u, 3), - }, - }; - var overlayEl = new UiDatElement(overlayInfo, _ => (0u, 0, 0)); - dict[SelectedObjectController.OverlayId] = overlayEl; - root.AddChild(overlayEl); - - var healthMeterEl = new UiMeter { Width = 100, Height = 10, Visible = true }; - dict[SelectedObjectController.HealthMeterId] = healthMeterEl; - root.AddChild(healthMeterEl); - - return (new ImportedLayout(root, dict), nameEl, overlayEl, healthMeterEl); - } - - // ── Recording delegates ────────────────────────────────────────────────── - - private sealed class Harness - { - public Action? SelectionHandler; - public Action? HealthHandler; - public readonly List QueryHealthCalls = new(); - - public readonly Dictionary HealthTargetMap = new(); - public readonly Dictionary NameMap = new(); - public readonly Dictionary HealthMap = new(); - public readonly Dictionary HasHealthMap = new(); - public readonly Dictionary StackMap = new(); - - public void FireSelection(uint? g) => SelectionHandler?.Invoke(g); - public void FireHealth(uint g, float pct) => HealthHandler?.Invoke(g, pct); - - public SelectedObjectController Bind(ImportedLayout layout, UiDatFont? datFont = null) - => SelectedObjectController.Bind( - layout, - subscribeSelectionChanged: h => SelectionHandler = h, - subscribeHealthChanged: h => HealthHandler = h, - isHealthTarget: g => HealthTargetMap.TryGetValue(g, out var v) && v, - name: g => NameMap.TryGetValue(g, out var v) ? v : null, - healthPercent: g => HealthMap.TryGetValue(g, out var v) ? v : 1f, - hasHealth: g => HasHealthMap.TryGetValue(g, out var v) && v, - stackSize: g => StackMap.TryGetValue(g, out var v) ? v : 0u, - sendQueryHealth: g => QueryHealthCalls.Add(g), - datFont: datFont); - } - - // ── B1: Bind initialisation ────────────────────────────────────────────── - - [Fact] - public void Bind_healthMeterHidden_nameTextChildAttached_nameFloatedOnTop() - { - var (layout, nameEl, _, healthMeterEl) = FakeLayout(); - new Harness().Bind(layout); - - Assert.False(healthMeterEl.Visible, "health meter must be Visible=false immediately after Bind"); - - var textChild = nameEl.Children.OfType().SingleOrDefault(); - Assert.NotNull(textChild); - Assert.True(textChild!.Centered, "name UiText must be Centered"); - Assert.True(textChild.ClickThrough, "name UiText must be ClickThrough"); - Assert.False(textChild.AcceptsFocus, "AcceptsFocus must be false on name label"); - Assert.False(textChild.IsEditControl, "IsEditControl must be false on name label"); - Assert.False(textChild.CapturesPointerDrag, "CapturesPointerDrag must be false on name label"); - - // The name element must be floated to the top of the strip's z-order so it draws - // OVER the overlay frame and the health bar (retail draws the name over the bar). - Assert.True(nameEl.ZOrder > 1000, "name element must be floated above the overlay/meter z-order"); - } - - [Fact] - public void Bind_nameLinesProvider_yieldsEmpty_whenNothingSelected() - { - var (layout, nameEl, _, _) = FakeLayout(); - new Harness().Bind(layout); - - var textChild = nameEl.Children.OfType().Single(); - Assert.Empty(textChild.LinesProvider()); - } - - // ── H1: Select a health target — meter does NOT show on select alone ───── - - [Fact] - public void SelectHealthTarget_unknownHealth_meterStaysHidden_queryFired_nameAndOverlaySet() - { - const uint Guid = 0xAA01u; - const string ExpectedName = "Drudge Prowler"; - - var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); - var h = new Harness(); - h.HealthTargetMap[Guid] = true; - h.NameMap[Guid] = ExpectedName; - h.StackMap[Guid] = 1u; // ObjectSelected - // HasHealthMap[Guid] not set → false (no health known yet) - h.Bind(layout); - - h.FireSelection(Guid); - - // Health not yet known → meter must stay hidden (retail: shows on UpdateHealth). - Assert.False(healthMeterEl.Visible, - "meter must stay hidden on select when no health is known yet"); - // But QueryHealth is sent (retail Event_QueryHealth on select for a health target). - Assert.Single(h.QueryHealthCalls); - Assert.Equal(Guid, h.QueryHealthCalls[0]); - Assert.Equal("ObjectSelected", overlayEl.ActiveState); - - var lines = nameEl.Children.OfType().Single().LinesProvider(); - Assert.Single(lines); - Assert.Equal(ExpectedName, lines[0].Text); - Assert.Equal(new Vector4(1f, 1f, 1f, 1f), lines[0].Color); - } - - // ── H1b: Health arrives for the selected guid → meter appears ─────────── - - [Fact] - public void HealthChanged_forSelectedGuid_showsMeter() - { - const uint Guid = 0xAA02u; - - var (layout, _, _, healthMeterEl) = FakeLayout(); - var h = new Harness(); - h.HealthTargetMap[Guid] = true; - h.NameMap[Guid] = "Drudge Slinker"; - h.Bind(layout); - - h.FireSelection(Guid); - Assert.False(healthMeterEl.Visible, "hidden until health arrives"); - - // Simulate UpdateHealth (0x01C0) for the selected guid. - h.FireHealth(Guid, 0.6f); - Assert.True(healthMeterEl.Visible, "meter must appear when health arrives for the selected guid"); - } - - [Fact] - public void HealthChanged_forOtherGuid_doesNotShowMeter() - { - const uint Sel = 0xAA03u, Other = 0xBB03u; - - var (layout, _, _, healthMeterEl) = FakeLayout(); - var h = new Harness(); - h.HealthTargetMap[Sel] = true; - h.HealthTargetMap[Other] = true; - h.NameMap[Sel] = "Selected"; - h.Bind(layout); - - h.FireSelection(Sel); - h.FireHealth(Other, 0.5f); // health for a DIFFERENT entity - - Assert.False(healthMeterEl.Visible, "health for a non-selected guid must not show the meter"); - } - - // ── H1c: Already-known health → meter shows immediately on select ─────── - - [Fact] - public void SelectHealthTarget_alreadyKnownHealth_meterVisibleImmediately() - { - const uint Guid = 0xAA04u; - - var (layout, _, _, healthMeterEl) = FakeLayout(); - var h = new Harness(); - h.HealthTargetMap[Guid] = true; - h.HasHealthMap[Guid] = true; // health already cached (e.g. previously assessed) - h.HealthMap[Guid] = 0.9f; - h.NameMap[Guid] = "Olthoi"; - h.Bind(layout); - - h.FireSelection(Guid); - Assert.True(healthMeterEl.Visible, - "meter must show immediately when health is already known for the target"); - } - - // ── H2: Stacked item ───────────────────────────────────────────────────── - - [Fact] - public void SelectStackedItem_overlayStackedItemSelected_meterHidden() - { - const uint Guid = 0xBB02u; - - var (layout, _, overlayEl, healthMeterEl) = FakeLayout(); - var h = new Harness(); - h.HealthTargetMap[Guid] = false; - h.NameMap[Guid] = "Heal Kits"; - h.StackMap[Guid] = 5u; // stackSize > 1 - h.Bind(layout); - - h.FireSelection(Guid); - - Assert.Equal("StackedItemSelected", overlayEl.ActiveState); - Assert.False(healthMeterEl.Visible); - } - - // ── H3: Non-health target (friendly NPC / scenery / Door) ─────────────── - - [Fact] - public void SelectNonHealthTarget_meterHidden_noQuery_nameSet() - { - const uint Guid = 0xCC03u; - const string ExpectedName = "Town Crier"; - - var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); - var h = new Harness(); - h.HealthTargetMap[Guid] = false; - h.NameMap[Guid] = ExpectedName; - h.Bind(layout); - - h.FireSelection(Guid); - - Assert.False(healthMeterEl.Visible, "meter must stay hidden for a non-health target"); - Assert.Empty(h.QueryHealthCalls); - Assert.Equal("ObjectSelected", overlayEl.ActiveState); - - var lines = nameEl.Children.OfType().Single().LinesProvider(); - Assert.Single(lines); - Assert.Equal(ExpectedName, lines[0].Text); - } - - // ── H4: Deselect clears the strip ──────────────────────────────────────── - - [Fact] - public void SelectNull_clearsStrip() - { - const uint Guid = 0xDD04u; - - var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); - var h = new Harness(); - h.HealthTargetMap[Guid] = true; - h.HasHealthMap[Guid] = true; // so the meter is shown on select - h.HealthMap[Guid] = 0.5f; - h.NameMap[Guid] = "Wolf"; - h.Bind(layout); - - h.FireSelection(Guid); - Assert.True(healthMeterEl.Visible); - - h.FireSelection(null); - - Assert.False(healthMeterEl.Visible, "meter must be hidden after deselect"); - Assert.Equal("", overlayEl.ActiveState); - Assert.Empty(nameEl.Children.OfType().Single().LinesProvider()); - } - - // ── H5: Re-select a different guid ─────────────────────────────────────── - - [Fact] - public void ReSelect_differentGuid_clearsFirstThenAppliesSecond() - { - const uint GuidA = 0xEE05u, GuidB = 0xFF06u; - - var (layout, nameEl, overlayEl, healthMeterEl) = FakeLayout(); - var h = new Harness(); - h.HealthTargetMap[GuidA] = true; h.HealthTargetMap[GuidB] = false; - h.HasHealthMap[GuidA] = true; // A shows its bar on select - h.NameMap[GuidA] = "Bandit"; h.NameMap[GuidB] = "Chest"; - h.HealthMap[GuidA] = 1.0f; - h.Bind(layout); - - h.FireSelection(GuidA); - Assert.True(healthMeterEl.Visible); - Assert.Single(h.QueryHealthCalls); - - h.FireSelection(GuidB); - - Assert.False(healthMeterEl.Visible, "meter must clear when switching to a non-health target"); - Assert.Equal("ObjectSelected", overlayEl.ActiveState); - Assert.Single(h.QueryHealthCalls); // B is not a health target → no extra query - - var lines = nameEl.Children.OfType().Single().LinesProvider(); - Assert.Single(lines); - Assert.Equal("Chest", lines[0].Text); - } - - // ── H6: Overlay flash reverts after the flash window (Tick) ───────────── - - [Fact] - public void Tick_revertsOverlayFlash_afterDuration() - { - const uint Guid = 0xAB06u; - - var (layout, _, overlayEl, _) = FakeLayout(); - var h = new Harness(); - h.HealthTargetMap[Guid] = false; - h.NameMap[Guid] = "Lever"; - var c = h.Bind(layout); - - h.FireSelection(Guid); - Assert.Equal("ObjectSelected", overlayEl.ActiveState); - - // A small tick before the window elapses → still flashing. - c.Tick(0.1); - Assert.Equal("ObjectSelected", overlayEl.ActiveState); - - // Tick past the 0.25s window → overlay reverts to blank. - c.Tick(0.2); - Assert.Equal("", overlayEl.ActiveState); - } - - // ── H7: Partial layout (missing elements) ──────────────────────────────── - - [Fact] - public void PartialLayout_noElements_doesNotThrow() - { - var root = new UiPanel(); - var layout = new ImportedLayout(root, new Dictionary()); - - var h = new Harness(); - h.HealthTargetMap[0x12345678u] = true; - h.NameMap[0x12345678u] = "Something"; - var c = h.Bind(layout); - - Assert.NotNull(h.SelectionHandler); - Assert.Null(Record.Exception(() => h.FireSelection(0x12345678u))); - Assert.Null(Record.Exception(() => h.FireHealth(0x12345678u, 0.5f))); - Assert.Null(Record.Exception(() => c.Tick(0.5))); - Assert.Null(Record.Exception(() => h.FireSelection(null))); - - Assert.Single(h.QueryHealthCalls); - Assert.Equal(0x12345678u, h.QueryHealthCalls[0]); - } - - // ── H8: Fill reflects live health; returns 0 when nothing selected ────── - - [Fact] - public void HealthMeterFill_reflectsLiveHealthPercent() - { - const uint Guid = 0xAA07u; - - var (layout, _, _, healthMeterEl) = FakeLayout(); - var h = new Harness(); - h.HealthTargetMap[Guid] = true; - h.NameMap[Guid] = "Arwic Banderling"; - h.HealthMap[Guid] = 0.5f; - h.Bind(layout); - - h.FireSelection(Guid); - Assert.Equal(0.5f, healthMeterEl.Fill()); - - h.HealthMap[Guid] = 0.25f; // server updates health - Assert.Equal(0.25f, healthMeterEl.Fill()); - } - - [Fact] - public void HealthMeterFill_returnsZero_whenNothingSelected() - { - const uint Guid = 0xAA08u; - - var (layout, _, _, healthMeterEl) = FakeLayout(); - var h = new Harness(); - h.HealthTargetMap[Guid] = true; - h.NameMap[Guid] = "Spider"; - h.HealthMap[Guid] = 0.8f; - h.Bind(layout); - - h.FireSelection(Guid); - Assert.Equal(0.8f, healthMeterEl.Fill()); - - h.FireSelection(null); - Assert.Equal(0f, healthMeterEl.Fill() ?? 0f); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs deleted file mode 100644 index 6f590738..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/ToolbarControllerTests.cs +++ /dev/null @@ -1,597 +0,0 @@ -using System; -using System.Collections.Generic; -using AcDream.App.UI; -using AcDream.App.UI.Layout; -using AcDream.Core.Combat; -using AcDream.Core.Items; -using AcDream.Core.Net.Messages; -using Xunit; - -namespace AcDream.App.Tests.UI.Layout; - -public class ToolbarControllerTests -{ - private static readonly uint[] Row1 = - { 0x100001A7,0x100001A8,0x100001A9,0x100001AA,0x100001AB,0x100001AC,0x100001AD,0x100001AE,0x100001AF }; - private static readonly uint[] Row2 = - { 0x100006B7,0x100006B8,0x100006B9,0x100006BA,0x100006BB,0x100006BC,0x100006BD,0x100006BE,0x100006BF }; - - // The four mutually-exclusive combat-mode indicator element ids (must match ToolbarController's list). - private static readonly uint[] CombatIds = { 0x10000192u, 0x10000193u, 0x10000194u, 0x10000195u }; - - private static (ImportedLayout layout, Dictionary slots, - Dictionary indicators) FakeToolbar() - { - var dict = new Dictionary(); - var slots = new Dictionary(); - var indicators = new Dictionary(); - var root = new UiPanel(); - foreach (var id in Row1) AddSlot(id); - foreach (var id in Row2) AddSlot(id); - // Add combat indicator elements as plain UiPanels keyed by id. - foreach (var id in CombatIds) - { - var e = new UiPanel { Visible = true }; - dict[id] = e; indicators[id] = e; root.AddChild(e); - } - return (new ImportedLayout(root, dict), slots, indicators); - - void AddSlot(uint id) - { - var list = new UiItemList(_ => (0u, 0, 0)) { Width = 32, Height = 32 }; - dict[id] = list; slots[id] = list; root.AddChild(list); - } - } - - [Fact] - public void Populate_bindsShortcutToCorrectSlot() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new List - { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; - - ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }); - - Assert.Equal(0x5001u, slots[Row1[0]].Cell.ItemId); - Assert.Equal(0x77u, slots[Row1[0]].Cell.IconTexture); - Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); // others empty - } - - [Fact] - public void DeferredRebind_whenItemArrivesLate() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); // item NOT present yet - var shortcuts = new List - { new(Index: 2, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; - - ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => 0x88u, useItem: _ => { }); - Assert.Equal(0u, slots[Row1[2]].Cell.ItemId); // not bound yet - - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); - - Assert.Equal(0x5002u, slots[Row1[2]].Cell.ItemId); // rebound on ItemAdded - } - - [Fact] - public void Click_emitsUseForBoundItem() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new List - { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; - uint used = 0; - - ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => 0x77u, useItem: g => used = g); - // Use now fires on Click (mouse-up), not MouseDown — drag/click disambiguation. - slots[Row1[0]].Cell.OnEvent(new UiEvent(0u, null, UiEventType.Click)); - - Assert.Equal(0x5001u, used); - } - - // ── C1: combat-mode indicator tests ───────────────────────────────────── - - /// - /// At bind time (default NonCombat), only the peace indicator (0x10000192) is visible; - /// the melee/missile/magic indicators (0x10000193/4/5) are hidden. - /// Port of gmToolbarUI::RecvNotice_SetCombatMode (acclient_2013_pseudo_c.txt:196632-196669). - /// - [Fact] - public void CombatIndicator_defaultNonCombat_onlyPeaceVisible() - { - var (layout, _, indicators) = FakeToolbar(); - var repo = new ClientObjectTable(); - - ToolbarController.Bind(layout, repo, - () => Array.Empty(), - iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }); - - // Only peace indicator (index 0 = 0x10000192) is visible. - Assert.True (indicators[0x10000192u].Visible, "peace indicator should be visible after bind"); - Assert.False(indicators[0x10000193u].Visible, "melee indicator should be hidden after bind"); - Assert.False(indicators[0x10000194u].Visible, "missile indicator should be hidden after bind"); - Assert.False(indicators[0x10000195u].Visible, "magic indicator should be hidden after bind"); - } - - /// - /// SetCombatMode(Melee) hides peace/missile/magic and shows only the melee indicator. - /// - [Fact] - public void CombatIndicator_setCombatModeMelee_onlyMeleeVisible() - { - var (layout, _, indicators) = FakeToolbar(); - var repo = new ClientObjectTable(); - - var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), - iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }); - - ctrl.SetCombatMode(CombatMode.Melee); - - Assert.False(indicators[0x10000192u].Visible, "peace indicator should be hidden in melee mode"); - Assert.True (indicators[0x10000193u].Visible, "melee indicator should be visible in melee mode"); - Assert.False(indicators[0x10000194u].Visible, "missile indicator should be hidden in melee mode"); - Assert.False(indicators[0x10000195u].Visible, "magic indicator should be hidden in melee mode"); - } - - /// - /// CombatModeChanged event on CombatState automatically updates the indicator. - /// - [Fact] - public void CombatIndicator_liveSignal_updatesWhenCombatStateChanges() - { - var (layout, _, indicators) = FakeToolbar(); - var repo = new ClientObjectTable(); - var combat = new CombatState(); - - ToolbarController.Bind(layout, repo, - () => Array.Empty(), - iconIds: (_,_,_,_,_) =>0u, useItem: _ => { }, - combatState: combat); - - // Initially NonCombat after bind. - Assert.True(indicators[0x10000192u].Visible, "peace should be visible initially"); - - // Server fires CombatModeChanged → Magic. - combat.SetCombatMode(CombatMode.Magic); - - Assert.False(indicators[0x10000192u].Visible, "peace should be hidden in magic mode"); - Assert.False(indicators[0x10000193u].Visible, "melee should be hidden in magic mode"); - Assert.False(indicators[0x10000194u].Visible, "missile should be hidden in magic mode"); - Assert.True (indicators[0x10000195u].Visible, "magic indicator should be visible"); - } - - // ── D1: Shortcut number (slot label) tests ─────────────────────────────── - // Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465); - // gmToolbarUI::RecvNotice_SetCombatMode (196610-196621). - - // Fake digit arrays: 9 peace entries (0x10..0x18), 9 war entries (0x20..0x28), - // 9 empty (background) entries (0x30..0x38). - private static readonly uint[] FakePeace = { 0x10u,0x11u,0x12u,0x13u,0x14u,0x15u,0x16u,0x17u,0x18u }; - private static readonly uint[] FakeWar = { 0x20u,0x21u,0x22u,0x23u,0x24u,0x25u,0x26u,0x27u,0x28u }; - private static readonly uint[] FakeEmpty = { 0x30u,0x31u,0x32u,0x33u,0x34u,0x35u,0x36u,0x37u,0x38u }; - - /// - /// After Bind with peace/war digit arrays, top-row cells (indices 0–8) have - /// ShortcutNum == i (the slot position) and ShortcutPeace == true (default NonCombat). - /// Bottom-row cells (indices 9–17) have ShortcutNum == -1 (no label). - /// Retail: numbers are slot LABELS — shown on ALL top-row slots including empty ones. - /// - [Fact] - public void ShortcutNumbers_afterBind_topRowHasNumbers_bottomRowEmpty() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - - ToolbarController.Bind(layout, repo, - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, - peaceDigits: FakePeace, warDigits: FakeWar); - - // Top row: ShortcutNum == slot index, peace == true. - for (int i = 0; i < Row1.Length; i++) - { - var cell = slots[Row1[i]].Cell; - Assert.Equal(i, cell.ShortcutNum); - Assert.True(cell.ShortcutPeace, $"top-row slot {i} should be peace at NonCombat"); - } - // Bottom row: no shortcut number. - foreach (var id in Row2) - Assert.Equal(-1, slots[id].Cell.ShortcutNum); - } - - /// - /// After SetCombatMode(Melee), top-row cells switch to ShortcutPeace == false (war). - /// - [Fact] - public void ShortcutNumbers_setCombatModeWar_topRowUsesWarDigits() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, - peaceDigits: FakePeace, warDigits: FakeWar); - - ctrl.SetCombatMode(CombatMode.Melee); - - // Top row: still ShortcutNum == i, but now peace == false. - for (int i = 0; i < Row1.Length; i++) - { - var cell = slots[Row1[i]].Cell; - Assert.Equal(i, cell.ShortcutNum); - Assert.False(cell.ShortcutPeace, $"top-row slot {i} should be war after Melee"); - } - // Bottom row still has no number. - foreach (var id in Row2) - Assert.Equal(-1, slots[id].Cell.ShortcutNum); - } - - /// - /// After SetCombatMode back to NonCombat, top-row switches back to peace (ShortcutPeace == true). - /// - [Fact] - public void ShortcutNumbers_backToNonCombat_restoresPeaceDigits() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, - peaceDigits: FakePeace, warDigits: FakeWar); - - ctrl.SetCombatMode(CombatMode.Melee); - ctrl.SetCombatMode(CombatMode.NonCombat); - - for (int i = 0; i < Row1.Length; i++) - Assert.True(slots[Row1[i]].Cell.ShortcutPeace, - $"top-row slot {i} should be peace after returning to NonCombat"); - } - - /// - /// Digit arrays are correctly injected into each cell (PeaceDigits + WarDigits references). - /// - [Fact] - public void ShortcutNumbers_digitArraysInjected() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - - ToolbarController.Bind(layout, repo, - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, - peaceDigits: FakePeace, warDigits: FakeWar); - - foreach (var id in Row1) - { - Assert.Same(FakePeace, slots[id].Cell.PeaceDigits); - Assert.Same(FakeWar, slots[id].Cell.WarDigits); - } - } - - /// - /// EmptyDigits (0x1000005e background digit) is injected into every slot cell. - /// Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — empty-slot branch. - /// - [Fact] - public void ShortcutNumbers_emptyDigitArrayInjected() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - - ToolbarController.Bind(layout, repo, - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, - peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: FakeEmpty); - - foreach (var id in Row1) - Assert.Same(FakeEmpty, slots[id].Cell.EmptyDigits); - foreach (var id in Row2) - Assert.Same(FakeEmpty, slots[id].Cell.EmptyDigits); - } - - /// - /// When emptyDigits is null, cells have EmptyDigits == null (no digit on empty slots). - /// This is the safe fallback when the dat property 0x1000005e is absent. - /// - [Fact] - public void ShortcutNumbers_nullEmptyDigits_cellsHaveNullEmptyDigits() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - - ToolbarController.Bind(layout, repo, - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }, - peaceDigits: FakePeace, warDigits: FakeWar, emptyDigits: null); - - foreach (var id in Row1) - Assert.Null(slots[id].Cell.EmptyDigits); - } - - // ── E1: Guid filter + ObjectRemoved tests (D.5.4) ─────────────────────── - - /// - /// ObjectAdded for a guid NOT in the shortcut list does NOT call iconIds again - /// (no spurious Populate on creature/NPC spawns in a busy zone). - /// D.5.4: ToolbarController filters to shortcut guids only. - /// The iconIds spy lets us count how many times Populate actually ran. - /// - [Fact] - public void ObjectAdded_nonShortcutGuid_doesNotCallIconIds() - { - var (layout, _, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new List - { new(Index: 0, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; - - int iconCallCount = 0; - ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x77u; }, useItem: _ => { }); - - int callsAfterBind = iconCallCount; // 1 call from initial Populate - - // Fire ObjectAdded with a completely unrelated guid (a creature, NOT a shortcut). - repo.AddOrUpdate(new ClientObject { ObjectId = 0xDEADBEEFu, WeenieClassId = 42u, IconId = 0u }); - - // iconIds must NOT have been called again — the filter blocked Populate. - Assert.Equal(callsAfterBind, iconCallCount); - } - - /// - /// ObjectAdded for a guid that IS in the shortcut list calls iconIds again (deferred bind). - /// This is the filtered-path counterpart of DeferredRebind_whenItemArrivesLate. - /// - [Fact] - public void ObjectAdded_shortcutGuid_callsIconIds() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); // item NOT present yet - var shortcuts = new List - { new(Index: 1, ObjectGuid: 0x5003u, SpellId: 0, Layer: 0) }; - - int iconCallCount = 0; - ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => { iconCallCount++; return 0x99u; }, useItem: _ => { }); - - Assert.Equal(0, iconCallCount); // not called — item absent during initial Populate - Assert.Equal(0u, slots[Row1[1]].Cell.ItemId); - - // Now the shortcut item arrives — filter must PASS and Populate re-run. - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5003u, WeenieClassId = 1u, IconId = 0x06005678u }); - - Assert.Equal(1, iconCallCount); // iconIds called exactly once for the deferred bind - Assert.Equal(0x5003u, slots[Row1[1]].Cell.ItemId); - } - - /// - /// ObjectRemoved for a guid that IS in the shortcut list clears the slot. - /// D.5.4: subscribes to ObjectRemoved so a removed item evicts its icon. - /// - [Fact] - public void ObjectRemoved_shortcutGuid_clearsSlot() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5004u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new List - { new(Index: 3, ObjectGuid: 0x5004u, SpellId: 0, Layer: 0) }; - - ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => 0xAAu, useItem: _ => { }); - - Assert.Equal(0x5004u, slots[Row1[3]].Cell.ItemId); // bound - - // Remove the item from the session (server despawn / trade away). - // Populate re-runs: item is gone from repo → slot clears. - repo.Remove(0x5004u); - - Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); - } - - /// - /// ObjectRemoved for a guid NOT in the shortcut list does NOT call iconIds again. - /// D.5.4: the ObjectRemoved subscription also filters to shortcut guids. - /// - [Fact] - public void ObjectRemoved_nonShortcutGuid_doesNotCallIconIds() - { - var (layout, _, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5005u, WeenieClassId = 1u, IconId = 0x06001234u }); - repo.AddOrUpdate(new ClientObject { ObjectId = 0xCAFEBABEu, WeenieClassId = 99u, IconId = 0u }); - var shortcuts = new List - { new(Index: 4, ObjectGuid: 0x5005u, SpellId: 0, Layer: 0) }; - - int iconCallCount = 0; - ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => { iconCallCount++; return 0xBBu; }, useItem: _ => { }); - - int callsAfterBind = iconCallCount; // 1 call for the shortcut item - - // Remove an unrelated object — filter must block Populate. - repo.Remove(0xCAFEBABEu); - - Assert.Equal(callsAfterBind, iconCallCount); // unchanged - } - - // ── B.1: drag-drop spine wiring ────────────────────────────────────────── - - /// Bind registers the controller as each slot list's drag handler and - /// stamps every cell's SlotIndex + SourceKind=ShortcutBar. - [Fact] - public void Bind_registersDragHandler_andStampsSlots() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - - var ctrl = ToolbarController.Bind(layout, repo, - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); - - for (int i = 0; i < Row1.Length; i++) - { - Assert.Same(ctrl, slots[Row1[i]].DragHandler); - Assert.Equal(i, slots[Row1[i]].Cell.SlotIndex); - Assert.Equal(ItemDragSource.ShortcutBar, slots[Row1[i]].Cell.SourceKind); - } - // Bottom row slots are indices 9..17; same handler + source kind as the top row. - for (int j = 0; j < Row2.Length; j++) - { - Assert.Same(ctrl, slots[Row2[j]].DragHandler); - Assert.Equal(9 + j, slots[Row2[j]].Cell.SlotIndex); - Assert.Equal(ItemDragSource.ShortcutBar, slots[Row2[j]].Cell.SourceKind); - } - } - - /// OnDragOver accepts a real item (ObjId != 0). Eligibility (IsShortcutEligible) - /// is Stream B.2; the stub accepts any non-empty payload. - [Fact] - public void OnDragOver_acceptsRealItem() - { - var (layout, slots, _) = FakeToolbar(); - var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); - - var list = slots[Row1[0]]; - var payload = new ItemDragPayload(0x5001u, ItemDragSource.Inventory, 0, new UiItemSlot()); - Assert.True(ctrl.OnDragOver(list, list.Cell, payload)); - } - - /// OnDragOver rejects a ghost payload with ObjId 0 (the guard against an - /// empty cell that somehow produced a payload). Complements OnDragOver_acceptsRealItem. - [Fact] - public void OnDragOver_rejectsZeroObjId() - { - var (layout, slots, _) = FakeToolbar(); - var ctrl = ToolbarController.Bind(layout, new ClientObjectTable(), - () => Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); - var list = slots[Row1[0]]; - var payload = new ItemDragPayload(0u, ItemDragSource.Inventory, 0, new UiItemSlot()); - Assert.False(ctrl.OnDragOver(list, list.Cell, payload)); - } - - // ── B.2: live drag handler (store + reorder/remove + wire) ─────────────── - private static (System.Collections.Generic.List<(uint i,uint g)> adds, - System.Collections.Generic.List removes) NewSpies(out System.Action add, out System.Action rem) - { - var adds = new System.Collections.Generic.List<(uint,uint)>(); - var removes = new System.Collections.Generic.List(); - add = (i, g) => adds.Add((i, g)); - rem = i => removes.Add(i); - return (adds, removes); - } - - [Fact] - public void OnDragLift_removesSourceSlot_sendsRemove_emptiesCell() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new System.Collections.Generic.List - { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; - var (adds, removes) = NewSpies(out var add, out var rem); - - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, - sendAddShortcut: add, sendRemoveShortcut: rem); - Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); - - var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); - ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); - - Assert.Contains(3u, removes); - Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); - } - - [Fact] - public void HandleDropRelease_ontoOccupied_swaps_andSendsRetailSequence() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); // A - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5002u, WeenieClassId = 1u, IconId = 0x06005678u }); // B - var shortcuts = new System.Collections.Generic.List - { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0), - new(Index: 5, ObjectGuid: 0x5002u, SpellId: 0, Layer: 0) }; - var (adds, removes) = NewSpies(out var add, out var rem); - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, - sendAddShortcut: add, sendRemoveShortcut: rem); - - var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); - ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); - ctrl.HandleDropRelease(slots[Row1[5]], slots[Row1[5]].Cell, payload); - - Assert.Equal(0x5001u, slots[Row1[5]].Cell.ItemId); - Assert.Equal(0x5002u, slots[Row1[3]].Cell.ItemId); - Assert.Equal(new[] { 3u, 5u }, removes.ToArray()); - Assert.Contains((5u, 0x5001u), adds); - Assert.Contains((3u, 0x5002u), adds); - } - - [Fact] - public void HandleDropRelease_ontoEmpty_placesOnly_sourceStaysEmpty() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new System.Collections.Generic.List - { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; - var (adds, removes) = NewSpies(out var add, out var rem); - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, - sendAddShortcut: add, sendRemoveShortcut: rem); - - var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); - ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); - ctrl.HandleDropRelease(slots[Row1[7]], slots[Row1[7]].Cell, payload); - - Assert.Equal(0x5001u, slots[Row1[7]].Cell.ItemId); - Assert.Equal(0u, slots[Row1[3]].Cell.ItemId); - Assert.Contains((7u, 0x5001u), adds); - Assert.DoesNotContain(adds, a => a.i == 3u); - } - - [Fact] - public void HandleDropRelease_ontoSelf_reAddsToSource() - { - var (layout, slots, _) = FakeToolbar(); - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x5001u, WeenieClassId = 1u, IconId = 0x06001234u }); - var shortcuts = new System.Collections.Generic.List - { new(Index: 3, ObjectGuid: 0x5001u, SpellId: 0, Layer: 0) }; - var (adds, removes) = NewSpies(out var add, out var rem); - var ctrl = ToolbarController.Bind(layout, repo, () => shortcuts, - iconIds: (_,_,_,_,_) => 0x77u, useItem: _ => { }, - sendAddShortcut: add, sendRemoveShortcut: rem); - - var payload = new ItemDragPayload(0x5001u, ItemDragSource.ShortcutBar, 3, slots[Row1[3]].Cell); - ctrl.OnDragLift(slots[Row1[3]], slots[Row1[3]].Cell, payload); // slot 3 emptied - ctrl.HandleDropRelease(slots[Row1[3]], slots[Row1[3]].Cell, payload); // drop back on slot 3 - - Assert.Equal(0x5001u, slots[Row1[3]].Cell.ItemId); // re-added to source (net no-op) - Assert.Contains((3u, 0x5001u), adds); // AddShortcut(3, A) sent - Assert.Single(removes); // exactly RemoveShortcut(3) [from the lift] - Assert.Single(adds); // exactly AddShortcut(3, A) [the re-place] - } - - [Fact] - public void ToolbarSlots_useGreenCrossAcceptSprite() - { - var (layout, slots, _) = FakeToolbar(); - ToolbarController.Bind(layout, new ClientObjectTable(), - () => System.Array.Empty(), - iconIds: (_,_,_,_,_) => 0u, useItem: _ => { }); - Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9 - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs deleted file mode 100644 index 3f3ef20b..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/UiDatElementTests.cs +++ /dev/null @@ -1,90 +0,0 @@ -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI.Layout; - -public class UiDatElementTests -{ - [Fact] - public void ActiveMedia_PrefersNamedStateOverDirect() - { - var info = new ElementInfo(); - info.StateMedia[""] = (0x06000001, 1); // DirectState (DrawMode Normal=1) - info.StateMedia["ShowDetail"] = (0x06000002, 3); // named (Alphablend=3) - var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "ShowDetail" }; - Assert.Equal(0x06000002u, e.ActiveMedia().File); - Assert.Equal(3, e.ActiveMedia().DrawMode); - e.ActiveState = ""; - Assert.Equal(0x06000001u, e.ActiveMedia().File); - Assert.Equal(1, e.ActiveMedia().DrawMode); - } - - [Fact] - public void ActiveMedia_NoMedia_ReturnsZero() - { - var e = new UiDatElement(new ElementInfo(), _ => (0, 0, 0)); - Assert.Equal(0u, e.ActiveMedia().File); - Assert.Equal(0, e.ActiveMedia().DrawMode); - } - - [Fact] - public void ActiveMedia_MissingNamedState_FallsBackToDirect() - { - var info = new ElementInfo(); - info.StateMedia[""] = (0x06000005, 1); - var e = new UiDatElement(info, _ => (0, 0, 0)) { ActiveState = "NoSuchState" }; - Assert.Equal(0x06000005u, e.ActiveMedia().File); - } - - // ── G1 tests: DefaultStateName + "Normal" implicit default ─────────────── - - /// - /// Task G1 change 5: when an element has no DefaultStateName but does have a "Normal" - /// state sprite, the ctor should default ActiveState to "Normal" so the element - /// renders its normal-state sprite without requiring explicit state assignment. - /// - [Fact] - public void UiDatElement_DefaultsActiveStateToNormal_WhenNormalPresent() - { - var info = new ElementInfo(); - info.StateMedia["Normal"] = (0x0000AAAAu, 1); - info.StateMedia["Hover"] = (0x0000BBBBu, 1); - - var e = new UiDatElement(info, _ => (0, 0, 0)); - - // Should have defaulted to "Normal" state. - Assert.Equal(0x0000AAAAu, e.ActiveMedia().File); - } - - /// - /// Task G1 change 5: when DefaultStateName is set (e.g. "Minimized"), - /// it takes priority over the "Normal" implicit default. - /// - [Fact] - public void UiDatElement_DefaultsActiveStateToDefaultStateName_WhenSet() - { - var info = new ElementInfo { DefaultStateName = "Minimized" }; - info.StateMedia["Minimized"] = (0x0000BBBBu, 1); - info.StateMedia["Maximized"] = (0x0000CCCCu, 1); - info.StateMedia["Normal"] = (0x0000DDDDu, 1); - - var e = new UiDatElement(info, _ => (0, 0, 0)); - - // DefaultStateName "Minimized" wins over "Normal" implicit default. - Assert.Equal(0x0000BBBBu, e.ActiveMedia().File); - } - - /// - /// Task G1 change 5: elements with only a DirectState sprite and no "Normal" state - /// should still default to "" (DirectState) — no regression for chrome/grip elements. - /// - [Fact] - public void UiDatElement_NoDefaultStateName_NoNormal_DefaultsToDirectState() - { - var info = new ElementInfo(); - info.StateMedia[""] = (0x06007777u, 1); // DirectState only (e.g. vitals chrome corner) - - var e = new UiDatElement(info, _ => (0, 0, 0)); - - // No DefaultStateName, no "Normal" state → ActiveState stays "" (DirectState). - Assert.Equal(0x06007777u, e.ActiveMedia().File); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs deleted file mode 100644 index 03a4873c..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -using AcDream.App.UI; -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI.Layout; - -public class UiViewportFactoryTests -{ - private static (uint, int, int) NoTex(uint _) => (0, 0, 0); - - [Fact] - public void Factory_builds_UiViewport_for_dat_type_0xD() - { - var info = new ElementInfo { Type = 0xD, Width = 200, Height = 300 }; - var widget = DatWidgetFactory.Create(info, NoTex, null); - var viewport = Assert.IsType(widget); - Assert.True(viewport.ConsumesDatChildren); - } - - [Fact] - public void UiViewport_rect_set_from_ElementInfo() - { - var info = new ElementInfo { Type = 0xD, X = 10, Y = 20, Width = 180, Height = 240 }; - var widget = DatWidgetFactory.Create(info, NoTex, null)!; - Assert.Equal(10f, widget.Left); - Assert.Equal(20f, widget.Top); - Assert.Equal(180f, widget.Width); - Assert.Equal(240f, widget.Height); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs b/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs deleted file mode 100644 index a0baad8e..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/VitalsBindingTests.cs +++ /dev/null @@ -1,113 +0,0 @@ -using AcDream.App.UI; -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI.Layout; - -/// -/// Unit tests for : verifies that the controller -/// correctly maps element ids to UiMeter instances and wires the Fill / Label providers. -/// No dats, no GL — pure data-wiring tests. -/// -public class VitalsBindingTests -{ - // ── Test 1: Health meter Fill + Label providers are bound ───────────────── - - [Fact] - public void Bind_SetsHealthMeterFillFromProvider() - { - var health = new UiMeter(); - var layout = FakeLayout((VitalsController.Health, health)); - float hp = 0.42f; - - VitalsController.Bind(layout, - healthPct: () => hp, - staminaPct: () => 1f, - manaPct: () => 1f, - healthText: () => "42/100", - staminaText: () => "", - manaText: () => ""); - - Assert.Equal(0.42f, health.Fill()!.Value); - // The meter no longer draws its own label; the cur/max is a centered UiText child. - Assert.Null(health.Label()); - Assert.Equal("42/100", NumberText(health)); - } - - // ── Test 2: All three meters wired to distinct providers ────────────────── - - [Fact] - public void Bind_AllThreeMeters_EachBoundToOwnProvider() - { - var health = new UiMeter(); - var stamina = new UiMeter(); - var mana = new UiMeter(); - var layout = FakeLayout( - (VitalsController.Health, health), - (VitalsController.Stamina, stamina), - (VitalsController.Mana, mana)); - - VitalsController.Bind(layout, - healthPct: () => 0.25f, - staminaPct: () => 0.50f, - manaPct: () => 0.75f, - healthText: () => "25/100", - staminaText: () => "50/100", - manaText: () => "75/100"); - - // Each meter should reflect its own provider, not another's. - Assert.Equal(0.25f, health.Fill()!.Value); - Assert.Equal("25/100", NumberText(health)); - - Assert.Equal(0.50f, stamina.Fill()!.Value); - Assert.Equal("50/100", NumberText(stamina)); - - Assert.Equal(0.75f, mana.Fill()!.Value); - Assert.Equal("75/100", NumberText(mana)); - } - - // ── Test 3: Missing meter ids are silently skipped (no throw) ───────────── - - [Fact] - public void Bind_MissingMeterIds_DoesNotThrow() - { - // Only Health is present; Stamina and Mana are absent from the layout. - var health = new UiMeter(); - var layout = FakeLayout((VitalsController.Health, health)); - - // Should not throw even though Stamina/Mana are missing. - VitalsController.Bind(layout, - healthPct: () => 1f, - staminaPct: () => 1f, - manaPct: () => 1f, - healthText: () => "100/100", - staminaText: () => "100/100", - manaText: () => "100/100"); - - // Health was present — it should be wired. - Assert.Equal(1f, health.Fill()!.Value); - } - - // ── Helpers ─────────────────────────────────────────────────────────────── - - /// The cur/max text from the centered number that - /// attaches as the meter's child. - private static string NumberText(UiMeter m) - { - var num = Assert.IsType(m.Children[0]); - Assert.True(num.Centered); - var lines = num.LinesProvider(); - return lines.Count > 0 ? lines[0].Text : ""; - } - - private static ImportedLayout FakeLayout(params (uint id, UiElement e)[] items) - { - var dict = new Dictionary(); - var root = new UiPanel(); - foreach (var (id, e) in items) - { - root.AddChild(e); - dict[id] = e; - } - return new ImportedLayout(root, dict); - } -} diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json deleted file mode 100644 index 37783bb7..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/fixtures/chat_21000006.json +++ /dev/null @@ -1,542 +0,0 @@ -{ - "Id": 0, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 0, - "Height": 0, - "Left": 0, - "Top": 0, - "Right": 0, - "Bottom": 0, - "ReadOrder": 0, - "FontDid": 0, - "StateMedia": {}, - "DefaultStateName": "", - "Children": [ - { - "Id": 268435484, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 382, - "Height": 104, - "Left": 1, - "Top": 2, - "Right": 2, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100667980, - "Item2": 1 - } - }, - "DefaultStateName": "", - "Children": [ - { - "Id": 268435485, - "Type": 5, - "X": 0, - "Y": 2, - "Width": 382, - "Height": 102, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": {}, - "DefaultStateName": "", - "Children": [] - } - ] - }, - { - "Id": 268436774, - "Type": 1, - "X": 2, - "Y": 0, - "Width": 16, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 2, - "ReadOrder": 3, - "FontDid": 1073741861, - "StateMedia": { - "Normal": { - "Item1": 100688408, - "Item2": 1 - }, - "Highlight": { - "Item1": 100688409, - "Item2": 1 - } - }, - "DefaultStateName": "Normal", - "Children": [] - }, - { - "Id": 268435486, - "Type": 12, - "X": 0, - "Y": 0, - "Width": 191, - "Height": 17, - "Left": 0, - "Top": 0, - "Right": 0, - "Bottom": 0, - "ReadOrder": 2, - "FontDid": 1073741825, - "StateMedia": { - "Normal": { - "Item1": 100667982, - "Item2": 1 - }, - "Ghosted": { - "Item1": 100667982, - "Item2": 1 - }, - "Talkfocus_highlight": { - "Item1": 100667981, - "Item2": 1 - } - }, - "DefaultStateName": "", - "Children": [] - }, - { - "Id": 268435470, - "Type": 268435521, - "X": 0, - "Y": 0, - "Width": 800, - "Height": 100, - "Left": 1, - "Top": 2, - "Right": 1, - "Bottom": 1, - "ReadOrder": 0, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100667725, - "Item2": 1 - } - }, - "DefaultStateName": "", - "Children": [ - { - "Id": 268436772, - "Type": 1, - "X": 0, - "Y": 46, - "Width": 16, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 2, - "ReadOrder": 6, - "FontDid": 1073741861, - "StateMedia": { - "Normal": { - "Item1": 100688408, - "Item2": 1 - }, - "Highlight": { - "Item1": 100688409, - "Item2": 1 - } - }, - "DefaultStateName": "Normal", - "Children": [] - }, - { - "Id": 268436773, - "Type": 1, - "X": 0, - "Y": 64, - "Width": 16, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 2, - "ReadOrder": 7, - "FontDid": 1073741861, - "StateMedia": { - "Normal": { - "Item1": 100688408, - "Item2": 1 - }, - "Highlight": { - "Item1": 100688409, - "Item2": 1 - } - }, - "DefaultStateName": "Normal", - "Children": [] - }, - { - "Id": 268436591, - "Type": 1, - "X": 474, - "Y": 0, - "Width": 16, - "Height": 16, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 2, - "ReadOrder": 3, - "FontDid": 0, - "StateMedia": { - "Maximized": { - "Item1": 100687460, - "Item2": 1 - }, - "Minimized": { - "Item1": 100687461, - "Item2": 1 - } - }, - "DefaultStateName": "Minimized", - "Children": [] - }, - { - "Id": 268435471, - "Type": 9, - "X": 0, - "Y": 0, - "Width": 800, - "Height": 9, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 2, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100667685, - "Item2": 1 - } - }, - "DefaultStateName": "", - "Children": [] - }, - { - "Id": 268435472, - "Type": 3, - "X": 0, - "Y": 9, - "Width": 490, - "Height": 74, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100667669, - "Item2": 1 - } - }, - "DefaultStateName": "", - "Children": [ - { - "Id": 268435473, - "Type": 12, - "X": 16, - "Y": 0, - "Width": 458, - "Height": 74, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 1073741824, - "StateMedia": {}, - "DefaultStateName": "", - "Children": [ - { - "Id": 268436620, - "Type": 1, - "X": 0, - "Y": 58, - "Width": 16, - "Height": 16, - "Left": 3, - "Top": 2, - "Right": 3, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "Normal": { - "Item1": 100687630, - "Item2": 1 - }, - "Normal_pressed": { - "Item1": 100687630, - "Item2": 1 - } - }, - "DefaultStateName": "Ghosted", - "Children": [] - } - ] - }, - { - "Id": 268435474, - "Type": 11, - "X": 474, - "Y": 6, - "Width": 16, - "Height": 68, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100682847, - "Item2": 3 - } - }, - "DefaultStateName": "", - "Children": [] - } - ] - }, - { - "Id": 268435475, - "Type": 3, - "X": 0, - "Y": 83, - "Width": 490, - "Height": 17, - "Left": 1, - "Top": 2, - "Right": 1, - "Bottom": 1, - "ReadOrder": 8, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100667706, - "Item2": 1 - } - }, - "DefaultStateName": "", - "Children": [ - { - "Id": 268435476, - "Type": 6, - "X": 0, - "Y": 0, - "Width": 46, - "Height": 17, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "Normal": { - "Item1": 100683109, - "Item2": 3 - }, - "Normal_pressed": { - "Item1": 100683110, - "Item2": 3 - } - }, - "DefaultStateName": "Normal", - "Children": [ - { - "Id": 268435477, - "Type": 12, - "X": 0, - "Y": 0, - "Width": 46, - "Height": 17, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 1073741826, - "StateMedia": {}, - "DefaultStateName": "", - "Children": [] - } - ] - }, - { - "Id": 268435478, - "Type": 12, - "X": 46, - "Y": 0, - "Width": 398, - "Height": 17, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 1073741824, - "StateMedia": { - "Normal_focussed": { - "Item1": 100667819, - "Item2": 1 - } - }, - "DefaultStateName": "", - "Children": [ - { - "Id": 268435479, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 1, - "Height": 17, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "Normal_focussed": { - "Item1": 100683111, - "Item2": 1 - } - }, - "DefaultStateName": "", - "Children": [] - }, - { - "Id": 268435480, - "Type": 3, - "X": 397, - "Y": 0, - "Width": 1, - "Height": 17, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": { - "Normal_focussed": { - "Item1": 100683111, - "Item2": 1 - } - }, - "DefaultStateName": "", - "Children": [] - } - ] - }, - { - "Id": 268435481, - "Type": 1, - "X": 444, - "Y": 0, - "Width": 46, - "Height": 17, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 3, - "FontDid": 1073741826, - "StateMedia": { - "Normal": { - "Item1": 100669717, - "Item2": 1 - }, - "Normal_pressed": { - "Item1": 100669718, - "Item2": 1 - }, - "Ghosted": { - "Item1": 100669748, - "Item2": 1 - } - }, - "DefaultStateName": "Normal", - "Children": [] - } - ] - }, - { - "Id": 268436770, - "Type": 1, - "X": 0, - "Y": 10, - "Width": 16, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 2, - "ReadOrder": 4, - "FontDid": 1073741861, - "StateMedia": { - "Normal": { - "Item1": 100688408, - "Item2": 1 - }, - "Highlight": { - "Item1": 100688409, - "Item2": 1 - } - }, - "DefaultStateName": "Normal", - "Children": [] - }, - { - "Id": 268436771, - "Type": 1, - "X": 0, - "Y": 28, - "Width": 16, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 2, - "ReadOrder": 5, - "FontDid": 1073741861, - "StateMedia": { - "Normal": { - "Item1": 100688408, - "Item2": 1 - }, - "Highlight": { - "Item1": 100688409, - "Item2": 1 - } - }, - "DefaultStateName": "Normal", - "Children": [] - } - ] - } - ] -} \ No newline at end of file diff --git a/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json b/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json deleted file mode 100644 index ff372638..00000000 --- a/tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json +++ /dev/null @@ -1,1058 +0,0 @@ -{ - "Id": 268436985, - "Type": 268435533, - "X": 0, - "Y": 0, - "Width": 160, - "Height": 58, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 2, - "ReadOrder": 0, - "FontDid": 0, - "StateMedia": {}, - "Children": [ - { - "Id": 268437048, - "Type": 3, - "X": 5, - "Y": 53, - "Width": 150, - "Height": 5, - "Left": 1, - "Top": 2, - "Right": 1, - "Bottom": 1, - "ReadOrder": 6, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693185, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435692, - "Type": 7, - "X": 5, - "Y": 21, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 18, - "FontDid": 0, - "StateMedia": {}, - "Children": [ - { - "Id": 268435693, - "Type": 12, - "X": 0, - "Y": 0, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 3, - "FontDid": 1073741824, - "StateMedia": {}, - "Children": [] - }, - { - "Id": 2, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": {}, - "Children": [ - { - "Id": 268436649, - "Type": 3, - "X": 32, - "Y": 0, - "Width": 85, - "Height": 28, - "Left": 3, - "Top": 1, - "Right": 3, - "Bottom": 1, - "ReadOrder": 4, - "FontDid": 0, - "StateMedia": { - "ShowDetail": { - "Item1": 100693139, - "Item2": 3 - } - }, - "Children": [] - }, - { - "Id": 268435688, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693127, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435689, - "Type": 3, - "X": 10, - "Y": 0, - "Width": 130, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693128, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435690, - "Type": 3, - "X": 140, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 3, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693129, - "Item2": 1 - } - }, - "Children": [] - } - ] - }, - { - "Id": 268435687, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": {}, - "Children": [ - { - "Id": 268436649, - "Type": 3, - "X": 32, - "Y": 0, - "Width": 85, - "Height": 16, - "Left": 3, - "Top": 1, - "Right": 3, - "Bottom": 1, - "ReadOrder": 4, - "FontDid": 0, - "StateMedia": { - "ShowDetail": { - "Item1": 100693138, - "Item2": 3 - } - }, - "Children": [] - }, - { - "Id": 268435688, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693124, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435689, - "Type": 3, - "X": 10, - "Y": 0, - "Width": 130, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693125, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435690, - "Type": 3, - "X": 140, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 3, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693126, - "Item2": 1 - } - }, - "Children": [] - } - ] - } - ] - }, - { - "Id": 268437049, - "Type": 3, - "X": 155, - "Y": 53, - "Width": 5, - "Height": 5, - "Left": 2, - "Top": 2, - "Right": 1, - "Bottom": 1, - "ReadOrder": 7, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693190, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437050, - "Type": 3, - "X": 155, - "Y": 5, - "Width": 5, - "Height": 48, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 8, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693186, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435694, - "Type": 7, - "X": 5, - "Y": 37, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 19, - "FontDid": 0, - "StateMedia": {}, - "Children": [ - { - "Id": 2, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": {}, - "Children": [ - { - "Id": 268436649, - "Type": 3, - "X": 25, - "Y": 0, - "Width": 100, - "Height": 16, - "Left": 3, - "Top": 1, - "Right": 3, - "Bottom": 1, - "ReadOrder": 4, - "FontDid": 0, - "StateMedia": { - "ShowDetail": { - "Item1": 100693141, - "Item2": 3 - } - }, - "Children": [] - }, - { - "Id": 268435688, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693133, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435689, - "Type": 3, - "X": 10, - "Y": 0, - "Width": 130, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693134, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435690, - "Type": 3, - "X": 140, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 3, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693135, - "Item2": 1 - } - }, - "Children": [] - } - ] - }, - { - "Id": 268435695, - "Type": 12, - "X": 0, - "Y": 0, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 3, - "FontDid": 1073741824, - "StateMedia": {}, - "Children": [] - }, - { - "Id": 268435687, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": {}, - "Children": [ - { - "Id": 268436649, - "Type": 3, - "X": 25, - "Y": 0, - "Width": 100, - "Height": 16, - "Left": 3, - "Top": 1, - "Right": 3, - "Bottom": 1, - "ReadOrder": 4, - "FontDid": 0, - "StateMedia": { - "ShowDetail": { - "Item1": 100693140, - "Item2": 3 - } - }, - "Children": [] - }, - { - "Id": 268435688, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693130, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435689, - "Type": 3, - "X": 10, - "Y": 0, - "Width": 130, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693131, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435690, - "Type": 3, - "X": 140, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 3, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693132, - "Item2": 1 - } - }, - "Children": [] - } - ] - } - ] - }, - { - "Id": 268437051, - "Type": 9, - "X": 0, - "Y": 0, - "Width": 5, - "Height": 5, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 2, - "ReadOrder": 9, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100688169, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437052, - "Type": 2, - "X": 5, - "Y": 0, - "Width": 150, - "Height": 5, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 2, - "ReadOrder": 10, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100688170, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437053, - "Type": 9, - "X": 155, - "Y": 0, - "Width": 5, - "Height": 5, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 2, - "ReadOrder": 11, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100688169, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437054, - "Type": 9, - "X": 0, - "Y": 5, - "Width": 5, - "Height": 48, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 1, - "ReadOrder": 12, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100688171, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437055, - "Type": 9, - "X": 0, - "Y": 53, - "Width": 5, - "Height": 5, - "Left": 1, - "Top": 2, - "Right": 2, - "Bottom": 1, - "ReadOrder": 13, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100688169, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437056, - "Type": 2, - "X": 5, - "Y": 53, - "Width": 150, - "Height": 5, - "Left": 1, - "Top": 2, - "Right": 1, - "Bottom": 1, - "ReadOrder": 14, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100688172, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437057, - "Type": 9, - "X": 155, - "Y": 53, - "Width": 5, - "Height": 5, - "Left": 2, - "Top": 2, - "Right": 1, - "Bottom": 1, - "ReadOrder": 15, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100688169, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437058, - "Type": 9, - "X": 155, - "Y": 5, - "Width": 5, - "Height": 48, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 16, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100688173, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435686, - "Type": 7, - "X": 5, - "Y": 5, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 17, - "FontDid": 0, - "StateMedia": {}, - "Children": [ - { - "Id": 268435691, - "Type": 12, - "X": 0, - "Y": 0, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 3, - "FontDid": 1073741824, - "StateMedia": {}, - "Children": [] - }, - { - "Id": 2, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": {}, - "Children": [ - { - "Id": 268436649, - "Type": 3, - "X": 66, - "Y": 0, - "Width": 18, - "Height": 16, - "Left": 3, - "Top": 1, - "Right": 3, - "Bottom": 1, - "ReadOrder": 4, - "FontDid": 0, - "StateMedia": { - "ShowDetail": { - "Item1": 100693137, - "Item2": 3 - } - }, - "Children": [] - }, - { - "Id": 268435688, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693121, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435689, - "Type": 3, - "X": 10, - "Y": 0, - "Width": 130, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693122, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435690, - "Type": 3, - "X": 140, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 3, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693123, - "Item2": 1 - } - }, - "Children": [] - } - ] - }, - { - "Id": 268435687, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 150, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": {}, - "Children": [ - { - "Id": 268436649, - "Type": 3, - "X": 66, - "Y": 0, - "Width": 18, - "Height": 16, - "Left": 3, - "Top": 1, - "Right": 3, - "Bottom": 1, - "ReadOrder": 4, - "FontDid": 0, - "StateMedia": { - "ShowDetail": { - "Item1": 100693136, - "Item2": 3 - } - }, - "Children": [] - }, - { - "Id": 268435688, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 1, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693118, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435689, - "Type": 3, - "X": 10, - "Y": 0, - "Width": 130, - "Height": 16, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693119, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268435690, - "Type": 3, - "X": 140, - "Y": 0, - "Width": 10, - "Height": 16, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 1, - "ReadOrder": 3, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693120, - "Item2": 1 - } - }, - "Children": [] - } - ] - } - ] - }, - { - "Id": 268437043, - "Type": 3, - "X": 0, - "Y": 0, - "Width": 5, - "Height": 5, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 2, - "ReadOrder": 1, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693187, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437044, - "Type": 3, - "X": 5, - "Y": 0, - "Width": 150, - "Height": 5, - "Left": 1, - "Top": 1, - "Right": 1, - "Bottom": 2, - "ReadOrder": 2, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693183, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437045, - "Type": 3, - "X": 155, - "Y": 0, - "Width": 5, - "Height": 5, - "Left": 2, - "Top": 1, - "Right": 1, - "Bottom": 2, - "ReadOrder": 3, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693188, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437046, - "Type": 3, - "X": 0, - "Y": 5, - "Width": 5, - "Height": 48, - "Left": 1, - "Top": 1, - "Right": 2, - "Bottom": 1, - "ReadOrder": 4, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693184, - "Item2": 1 - } - }, - "Children": [] - }, - { - "Id": 268437047, - "Type": 3, - "X": 0, - "Y": 53, - "Width": 5, - "Height": 5, - "Left": 1, - "Top": 2, - "Right": 2, - "Bottom": 1, - "ReadOrder": 5, - "FontDid": 0, - "StateMedia": { - "": { - "Item1": 100693189, - "Item2": 1 - } - }, - "Children": [] - } - ] -} \ No newline at end of file diff --git a/tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs b/tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs deleted file mode 100644 index eca936e9..00000000 --- a/tests/AcDream.App.Tests/UI/LayoutImporterMountTests.cs +++ /dev/null @@ -1,22 +0,0 @@ -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI; - -public class LayoutImporterMountTests -{ - [Fact] - public void Mounts_ChildlessMediaLessInheritor_WithContentfulBase() - => Assert.True(LayoutImporter.ShouldMountBaseChildren(derivedChildCount: 0, derivedMediaCount: 0, baseChildCount: 5)); - - [Fact] - public void DoesNotMount_WhenDerivedHasOwnMedia() // close button / title - => Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 1, 5)); - - [Fact] - public void DoesNotMount_WhenDerivedHasOwnChildren() - => Assert.False(LayoutImporter.ShouldMountBaseChildren(2, 0, 5)); - - [Fact] - public void DoesNotMount_WhenBaseIsChildless() // vitals/chat/toolbar style prototypes - => Assert.False(LayoutImporter.ShouldMountBaseChildren(0, 0, 0)); -} diff --git a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs b/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs deleted file mode 100644 index d45aa374..00000000 --- a/tests/AcDream.App.Tests/UI/MarkupDocumentTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class MarkupDocumentTests -{ - private sealed class FakeBinding - { - public float HealthPercent => 0.5f; - public uint? HealthCurrent => 109; - public uint? HealthMax => 218; - public float? ManaPercent => null; - public uint? ManaCurrent => null; - public uint? ManaMax => null; - } - - [Fact] - public void Build_CreatesPanelWithMeterFillLabelAndGeometry() - { - const string xml = - "" + - " " + - ""; - - var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32)); - - Assert.IsType(panel); - Assert.Equal(10f, panel.Left); - Assert.Equal(220f, panel.Width); - Assert.Equal(2, panel.Children.Count); // title UiLabel + 1 meter - var meter = Assert.IsType(panel.Children[1]); - Assert.Equal(8f, meter.Left); - Assert.Equal(200f, meter.Width); - Assert.Equal(0.5f, meter.Fill()); - Assert.Equal("109/218", meter.Label()); - } - - [Fact] - public void Build_NullBindingValuesYieldNullFillAndLabel() - { - const string xml = - "" + - " " + - ""; - var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)1, 32, 32)); - var meter = Assert.IsType(panel.Children[1]); - Assert.Null(meter.Fill()); - Assert.Null(meter.Label()); - } - - [Fact] - public void Build_ResizeAttrX_SetsHorizontalOnly() - { - const string xml = ""; - var panel = MarkupDocument.Build(xml, new object(), _ => ((uint)1, 32, 32)); - Assert.True(panel.ResizeX); - Assert.False(panel.ResizeY); - } - - [Fact] - public void Build_ParsesNineSliceBarSpriteIds() - { - const string xml = "" + - "" + - ""; - var panel = MarkupDocument.Build(xml, new FakeBinding(), _ => ((uint)7, 32, 32)); - var meter = Assert.IsType(panel.Children[1]); - Assert.Equal(0x06001141u, meter.BackLeft); - Assert.Equal(0x06001140u, meter.BackTile); - Assert.Equal(0x0600113Fu, meter.BackRight); - Assert.Equal(0x06001131u, meter.FrontLeft); - Assert.Equal(0x06001132u, meter.FrontTile); - Assert.Equal(0x06001133u, meter.FrontRight); - Assert.NotNull(meter.SpriteResolve); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiButtonTests.cs b/tests/AcDream.App.Tests/UI/UiButtonTests.cs deleted file mode 100644 index 8bbadae2..00000000 --- a/tests/AcDream.App.Tests/UI/UiButtonTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using AcDream.App.UI; -using AcDream.App.UI.Layout; -namespace AcDream.App.Tests.UI; - -public class UiButtonTests -{ - private static (uint, int, int) NoTex(uint _) => (0, 0, 0); - private bool _clicked; - - [Fact] - public void Click_InvokesOnClick() - { - var b = new UiButton(new ElementInfo { Type = 1, Width = 46, Height = 18 }, NoTex) - { OnClick = () => _clicked = true }; - b.OnEvent(new UiEvent(0, null, UiEventType.Click)); - Assert.True(_clicked); - } - - [Fact] - public void NotClickThrough_SoItReceivesClicks() - { - var b = new UiButton(new ElementInfo { Type = 1 }, NoTex); - Assert.False(b.ClickThrough); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs b/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs deleted file mode 100644 index bc7abf5b..00000000 --- a/tests/AcDream.App.Tests/UI/UiCollapsibleFrameTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class UiCollapsibleFrameTests -{ - private static UiCollapsibleFrame MakeFrame(out UiPanel row2a, out UiPanel row2b) - { - var f = new UiCollapsibleFrame(_ => (1u, 1, 1)) - { - CollapsedHeight = 96f, - ExpandedHeight = 128f, - }; - row2a = new UiPanel(); row2b = new UiPanel(); - f.SecondRow = new UiElement[] { row2a, row2b }; - return f; - } - - [Fact] - public void Tick_belowMidpoint_snapsCollapsed_hidesSecondRow() - { - var f = MakeFrame(out var a, out var b); - f.Height = 100f; // nearer the collapsed stop (midpoint 112) - f.TickForTest(0.016); - Assert.Equal(96f, f.Height); - Assert.False(a.Visible); - Assert.False(b.Visible); - Assert.False(f.IsExpanded); - } - - [Fact] - public void Tick_aboveMidpoint_snapsExpanded_showsSecondRow() - { - var f = MakeFrame(out var a, out var b); - f.Height = 120f; // nearer the expanded stop - f.TickForTest(0.016); - Assert.Equal(128f, f.Height); - Assert.True(a.Visible); - Assert.True(b.Visible); - Assert.True(f.IsExpanded); - } - - [Fact] - public void Tick_notConfigured_isNoOp() - { - var f = new UiCollapsibleFrame(_ => (1u, 1, 1)); // Collapsed==Expanded==0 - f.Height = 50f; - f.TickForTest(0.016); - Assert.Equal(50f, f.Height); // unchanged, no divide/no forced height - } - - [Fact] - public void HitEdges_respectsResizableEdgesMask_bottomOnly() - { - var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, - Resizable = true, ResizableEdges = ResizeEdges.Bottom }; - // bottom edge (y=200) → Bottom only - Assert.Equal(ResizeEdges.Bottom, UiRoot.HitEdges(panel, 200, 200, 5)); - // top edge (y=100) → masked out → None - Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 100, 5)); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiDatFontTests.cs b/tests/AcDream.App.Tests/UI/UiDatFontTests.cs deleted file mode 100644 index 55a6457a..00000000 --- a/tests/AcDream.App.Tests/UI/UiDatFontTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using System.Collections.Generic; -using AcDream.App.UI; -using DatReaderWriter.Types; - -namespace AcDream.App.Tests.UI; - -/// -/// Pure pen-advance / MeasureWidth math for the retail dat font (no GL, no dat). -/// The advance per glyph is the retail -/// HorizontalOffsetBefore + Width + HorizontalOffsetAfter -/// (SurfaceWindow::DrawCharacter, acclient 0x00442c3a), accumulated across the -/// string the way the retail string loop does (0x00467ed4 edi_3 += var_98). -/// -public class UiDatFontTests -{ - private static FontCharDesc Glyph( - ushort unicode, byte width, - sbyte before = 0, sbyte after = 0, - ushort offsetX = 0, ushort offsetY = 0, byte height = 16, sbyte vBefore = 0) - => new() - { - Unicode = unicode, - Width = width, - Height = height, - OffsetX = offsetX, - OffsetY = offsetY, - HorizontalOffsetBefore = before, - HorizontalOffsetAfter = after, - VerticalOffsetBefore = vBefore, - }; - - [Fact] - public void GlyphAdvance_SumsBeforeWidthAfter() - { - var g = Glyph('A', width: 8, before: 1, after: 2); - Assert.Equal(11f, UiDatFont.GlyphAdvance(g)); - } - - [Fact] - public void GlyphAdvance_HandlesNegativeBearings() - { - // Kerned glyph: a negative left-bearing pulls it leftward; the advance - // still nets out to before + width + after. - var g = Glyph('j', width: 4, before: -1, after: 0); - Assert.Equal(3f, UiDatFont.GlyphAdvance(g)); - } - - [Fact] - public void MeasureWidth_SumsEachGlyphAdvance() - { - var table = new Dictionary - { - ['2'] = Glyph('2', width: 7, before: 1, after: 1), // advance 9 - ['9'] = Glyph('9', width: 7, before: 1, after: 1), // advance 9 - ['1'] = Glyph('1', width: 3, before: 2, after: 1), // advance 6 - ['/'] = Glyph('/', width: 4, before: 0, after: 1), // advance 5 - }; - FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null; - - // "291/291" = 9 + 9 + 6 + 5 + 9 + 9 + 6 = 53 - Assert.Equal(53f, UiDatFont.MeasureWidth("291/291", Lookup)); - } - - [Fact] - public void MeasureWidth_SkipsCharactersNotInFont() - { - var table = new Dictionary - { - ['5'] = Glyph('5', width: 6, before: 1, after: 1), // advance 8 - }; - FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null; - - // 'X' has no glyph → contributes nothing; only the two '5's count. - Assert.Equal(16f, UiDatFont.MeasureWidth("5X5", Lookup)); - } - - [Fact] - public void MeasureWidth_EmptyOrNullIsZero() - { - FontCharDesc? Lookup(char c) => null; - Assert.Equal(0f, UiDatFont.MeasureWidth("", Lookup)); - Assert.Equal(0f, UiDatFont.MeasureWidth(null, Lookup)); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiFieldTests.cs b/tests/AcDream.App.Tests/UI/UiFieldTests.cs deleted file mode 100644 index 5e6d405f..00000000 --- a/tests/AcDream.App.Tests/UI/UiFieldTests.cs +++ /dev/null @@ -1,72 +0,0 @@ -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class UiFieldTests -{ - [Fact] - public void InsertChar_AdvancesCaret() - { - var input = new UiField(); - input.InsertChar('h'); input.InsertChar('i'); - Assert.Equal("hi", input.Text); - Assert.Equal(2, input.CaretPos); - } - - [Fact] - public void Backspace_DeletesBeforeCaret() - { - var input = new UiField(); - foreach (var c in "abc") input.InsertChar(c); - input.MoveCaret(-1); - input.Backspace(); - Assert.Equal("ac", input.Text); - Assert.Equal(1, input.CaretPos); - } - - [Fact] - public void Submit_FiresCallback_ClearsText_PushesHistory() - { - string? sent = null; - var input = new UiField { OnSubmit = t => sent = t }; - foreach (var c in "hello") input.InsertChar(c); - input.Submit(); - Assert.Equal("hello", sent); - Assert.Equal("", input.Text); - Assert.Equal(0, input.CaretPos); - } - - [Fact] - public void EmptySubmit_DoesNotFire() - { - int n = 0; - var input = new UiField { OnSubmit = _ => n++ }; - input.Submit(); - Assert.Equal(0, n); - } - - [Fact] - public void History_UpDownBrowsesPreviousSubmissions() - { - var input = new UiField { OnSubmit = _ => {} }; - foreach (var c in "first") input.InsertChar(c); input.Submit(); - foreach (var c in "second") input.InsertChar(c); input.Submit(); - input.HistoryPrev(); - Assert.Equal("second", input.Text); - input.HistoryPrev(); - Assert.Equal("first", input.Text); - input.HistoryNext(); - Assert.Equal("second", input.Text); - input.HistoryNext(); - Assert.Equal("", input.Text); - } - - [Fact] - public void History_CapsAt100() - { - var input = new UiField { OnSubmit = _ => {} }; - for (int i = 0; i < 150; i++) { input.InsertChar('x'); input.Submit(); } - Assert.True(input.HistoryCount <= 100); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiItemListGridTests.cs b/tests/AcDream.App.Tests/UI/UiItemListGridTests.cs deleted file mode 100644 index 4053eac1..00000000 --- a/tests/AcDream.App.Tests/UI/UiItemListGridTests.cs +++ /dev/null @@ -1,43 +0,0 @@ -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class UiItemListGridTests -{ - [Fact] - public void CellOffset_RowMajor() - { - Assert.Equal((0f, 0f), UiItemList.CellOffset(0, 3, 36, 36)); - Assert.Equal((72f, 0f), UiItemList.CellOffset(2, 3, 36, 36)); - Assert.Equal((36f, 36f), UiItemList.CellOffset(4, 3, 36, 36)); // col 1, row 1 - } - - [Fact] - public void GridMode_PositionsCellsInColumns() - { - var list = new UiItemList { Columns = 3, CellWidth = 36, CellHeight = 36 }; - list.Flush(); // drop the ctor's default cell - for (int i = 0; i < 7; i++) list.AddItem(new UiItemSlot()); - - var c4 = list.GetItem(4)!; - Assert.Equal(36f, c4.Left); - Assert.Equal(36f, c4.Top); - Assert.Equal(36f, c4.Width); - Assert.Equal(36f, c4.Height); - } - - [Fact] - public void FillMode_SizesSingleCellToList() - { - // CellWidth defaults to 0 = "fill the list" (single-cell toolbar legacy). - var list = new UiItemList { Width = 36, Height = 36 }; - list.Flush(); - list.AddItem(new UiItemSlot()); - - var c = list.Cell; - Assert.Equal(0f, c.Left); - Assert.Equal(0f, c.Top); - Assert.Equal(36f, c.Width); - Assert.Equal(36f, c.Height); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs b/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs deleted file mode 100644 index e8fa7917..00000000 --- a/tests/AcDream.App.Tests/UI/UiItemListScrollTests.cs +++ /dev/null @@ -1,83 +0,0 @@ -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public sealed class UiItemListScrollTests -{ - private static UiItemList Grid(int items) - { - var list = new UiItemList { Columns = 6, CellWidth = 32, CellHeight = 32, Width = 192, Height = 96 }; - list.Flush(); // drop the default single cell - for (int i = 0; i < items; i++) list.AddItem(new UiItemSlot()); - list.LayoutCells(); // drive the scroll model + position cells - return list; - } - - [Fact] - public void RowCount_ceils_to_whole_rows() - { - Assert.Equal(0, UiItemList.RowCount(0, 6)); - Assert.Equal(1, UiItemList.RowCount(1, 6)); - Assert.Equal(7, UiItemList.RowCount(41, 6)); - } - - [Fact] - public void Grid_drives_scroll_model_from_content() - { - var list = Grid(30); // 5 rows × 32 = 160 content, 96 view - Assert.Equal(160, list.Scroll.ContentHeight); - Assert.Equal(96, list.Scroll.ViewHeight); - Assert.Equal(64, list.Scroll.MaxScroll); - Assert.True(list.Scroll.HasOverflow); - } - - [Fact] - public void At_top_first_three_rows_visible_rest_clipped() - { - var list = Grid(30); - Assert.True(list.GetItem(0)!.Visible); // row 0, top 0 - Assert.True(list.GetItem(12)!.Visible); // row 2, top 64 (+32 == 96) - Assert.False(list.GetItem(18)!.Visible); // row 3, top 96 (off the bottom) - } - - [Fact] - public void Scrolled_one_row_shifts_window_and_clips_top_row() - { - var list = Grid(30); - list.Scroll.SetScrollY(32); - list.LayoutCells(); - Assert.False(list.GetItem(0)!.Visible); // row 0 scrolled above - Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 now at the view top - Assert.True(list.GetItem(18)!.Visible); // row 3 now visible - } - - [Fact] - public void Scroll_survives_the_per_frame_anchor_pass() - { - // Regression (the escaping-grid bug): UiElement.DrawSelfAndChildren runs ApplyAnchor - // on every child AFTER OnDraw/LayoutCells. Grid cells must be exempt (Anchors=None) so - // the anchor pass can't reset the scroll offset LayoutCells applied — otherwise the - // grid translates out of the view when scrolled. - var list = Grid(30); // cells added (Anchors=None) + laid out - for (int i = 0; i < list.GetNumUIItems(); i++) // first anchor pass (would capture base) - list.GetItem(i)!.ApplyAnchor(list.Width, list.Height); - list.Scroll.SetScrollY(32); // scroll one row - list.LayoutCells(); - for (int i = 0; i < list.GetNumUIItems(); i++) // second anchor pass (the draw traversal) - list.GetItem(i)!.ApplyAnchor(list.Width, list.Height); - Assert.Equal(0f, list.GetItem(6)!.Top); // row 1 stays at the view top - Assert.False(list.GetItem(0)!.Visible); // row 0 stays clipped above - } - - [Fact] - public void Wheel_down_scrolls_toward_the_bottom() - { - var list = Grid(30); // max scroll 64, LineHeight 32 - // Silk wheel +Y = up/older; UiText negates Data0. Wheel DOWN (Data0 < 0) → +ScrollY. - var e = new UiEvent(0u, null, UiEventType.Scroll, Data0: -1); - bool handled = list.OnEvent(e); - Assert.True(handled); - Assert.Equal(32, list.Scroll.ScrollY); // one line (32px) down - } -} diff --git a/tests/AcDream.App.Tests/UI/UiItemListTests.cs b/tests/AcDream.App.Tests/UI/UiItemListTests.cs deleted file mode 100644 index b03e9ed6..00000000 --- a/tests/AcDream.App.Tests/UI/UiItemListTests.cs +++ /dev/null @@ -1,46 +0,0 @@ -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class UiItemListTests -{ - [Fact] - public void IsLeafWidget() => Assert.True(new UiItemList().ConsumesDatChildren); - - [Fact] - public void StartsWithOneCell_forSingleCellSlot() - { - var list = new UiItemList(); - Assert.Equal(1, list.GetNumUIItems()); - Assert.NotNull(list.GetItem(0)); - } - - [Fact] - public void Cell_returnsTheFirstSlot() - { - var list = new UiItemList(); - Assert.Same(list.GetItem(0), list.Cell); - } - - [Fact] - public void CellEmptySprite_stamps_existing_and_future_cells() - { - var list = new UiItemList(); // ctor adds one default cell - Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // UiItemSlot default before set - - list.CellEmptySprite = 0x06004D20u; // a pack-slot sprite - Assert.Equal(0x06004D20u, list.GetItem(0)!.EmptySprite); // existing cell re-stamped - - list.AddItem(new UiItemSlot()); - Assert.Equal(0x06004D20u, list.GetItem(1)!.EmptySprite); // new cell stamped - } - - [Fact] - public void CellEmptySprite_zero_leaves_the_UiItemSlot_default() - { - var list = new UiItemList(); - list.CellEmptySprite = 0u; - Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // unchanged default - } -} diff --git a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs b/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs deleted file mode 100644 index 773f3039..00000000 --- a/tests/AcDream.App.Tests/UI/UiItemSlotTests.cs +++ /dev/null @@ -1,170 +0,0 @@ -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class UiItemSlotTests -{ - [Fact] - public void IsLeafWidget() - => Assert.True(new UiItemSlot().ConsumesDatChildren); - - [Fact] - public void DefaultEmptySprite_isToolbarBorder() - => Assert.Equal(0x060074CFu, new UiItemSlot().EmptySprite); - - [Fact] - public void Empty_whenNoItem() - { - var s = new UiItemSlot(); - Assert.Equal(0u, s.ItemId); - Assert.Equal(0u, s.IconTexture); - } - - [Fact] - public void SetItem_setsIdAndTexture() - { - var s = new UiItemSlot(); - s.SetItem(0x5001u, 0x99u); - Assert.Equal(0x5001u, s.ItemId); - Assert.Equal(0x99u, s.IconTexture); - } - - [Fact] - public void Clear_afterSetItem_resetsToEmpty() - { - var s = new UiItemSlot(); - s.SetItem(0x5001u, 0x99u); - s.Clear(); - Assert.Equal(0u, s.ItemId); - Assert.Equal(0u, s.IconTexture); - } - - // ── Shortcut number tests ──────────────────────────────────────────────── - // Port of UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465). - - [Fact] - public void ShortcutNum_defaultIsMinusOne() - { - var s = new UiItemSlot(); - Assert.Equal(-1, s.ShortcutNum); - } - - [Fact] - public void ShortcutPeace_defaultIsTrue() - { - var s = new UiItemSlot(); - Assert.True(s.ShortcutPeace); - } - - [Fact] - public void SetShortcutNum_setsIndexAndPeace() - { - var s = new UiItemSlot(); - s.SetShortcutNum(3, peace: false); - Assert.Equal(3, s.ShortcutNum); - Assert.False(s.ShortcutPeace); - } - - [Fact] - public void SetShortcutNum_peaceTrue() - { - var s = new UiItemSlot(); - s.SetShortcutNum(0, peace: true); - Assert.Equal(0, s.ShortcutNum); - Assert.True(s.ShortcutPeace); - } - - [Fact] - public void ClearShortcutNum_setsMinusOne() - { - var s = new UiItemSlot(); - s.SetShortcutNum(5, peace: true); - s.ClearShortcutNum(); - Assert.Equal(-1, s.ShortcutNum); - } - - // ── ActiveDigitArray occupancy gating (decomp UIElement_UIItem::SetShortcutNum:229481) ── - - private static readonly uint[] Peace = { 0x10u, 0x11u, 0x12u }; - private static readonly uint[] War = { 0x20u, 0x21u, 0x22u }; - private static readonly uint[] Empty = { 0x30u, 0x31u, 0x32u }; - - /// - /// When ItemId == 0 (empty slot), ActiveDigitArray returns EmptyDigits regardless - /// of ShortcutPeace. Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481) — - /// else branch when m_elem_Icon->m_state == 0x1000001c (empty). - /// - [Fact] - public void ActiveDigitArray_emptySlot_returnsEmptyDigits() - { - var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; - s.SetShortcutNum(0, peace: true); - // ItemId == 0 → EmptyDigits - Assert.Same(Empty, s.ActiveDigitArray()); - } - - [Fact] - public void ActiveDigitArray_emptySlot_warStance_stillReturnsEmptyDigits() - { - var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; - s.SetShortcutNum(0, peace: false); - // ItemId == 0 → EmptyDigits regardless of stance - Assert.Same(Empty, s.ActiveDigitArray()); - } - - /// - /// When ItemId != 0 (occupied), ActiveDigitArray returns PeaceDigits or WarDigits - /// depending on ShortcutPeace. Retail ref: UIElement_UIItem::SetShortcutNum (decomp 229481/229493). - /// - [Fact] - public void ActiveDigitArray_occupiedSlot_peaceStance_returnsPeaceDigits() - { - var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; - s.SetItem(0x5001u, 0x99u); - s.SetShortcutNum(0, peace: true); - Assert.Same(Peace, s.ActiveDigitArray()); - } - - [Fact] - public void ActiveDigitArray_occupiedSlot_warStance_returnsWarDigits() - { - var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = Empty }; - s.SetItem(0x5001u, 0x99u); - s.SetShortcutNum(0, peace: false); - Assert.Same(War, s.ActiveDigitArray()); - } - - [Fact] - public void ActiveDigitArray_emptySlot_nullEmptyDigits_returnsNull() - { - var s = new UiItemSlot { PeaceDigits = Peace, WarDigits = War, EmptyDigits = null }; - s.SetShortcutNum(0, peace: true); - Assert.Null(s.ActiveDigitArray()); - } - - // ── Container-switching indicator overlays (decomp SetOpenContainerState 0x004e1200 / - // SetSelectedState 0x004e1240) ─────────────────────────────────────────────────────── - [Fact] - public void Selected_defaultsFalse() => Assert.False(new UiItemSlot().Selected); - - [Fact] - public void IsOpenContainer_defaultsFalse() => Assert.False(new UiItemSlot().IsOpenContainer); - - [Fact] - public void SelectedSprite_default_isGreenYellowSquare() - => Assert.Equal(0x06004D21u, new UiItemSlot().SelectedSprite); - - [Fact] - public void OpenContainerSprite_default_isTriangle() - => Assert.Equal(0x06005D9Cu, new UiItemSlot().OpenContainerSprite); - - // ── Container capacity bar (decomp UpdateCapacityDisplay 0x004e16e0, element 0x10000347) ── - [Fact] - public void CapacityFill_defaultsHidden() => Assert.Equal(-1f, new UiItemSlot().CapacityFill); - - [Fact] - public void CapacityBackSprite_default() => Assert.Equal(0x06004D22u, new UiItemSlot().CapacityBackSprite); - - [Fact] - public void CapacityFrontSprite_default() => Assert.Equal(0x06004D23u, new UiItemSlot().CapacityFrontSprite); -} diff --git a/tests/AcDream.App.Tests/UI/UiMenuTests.cs b/tests/AcDream.App.Tests/UI/UiMenuTests.cs deleted file mode 100644 index 4b1a16fe..00000000 --- a/tests/AcDream.App.Tests/UI/UiMenuTests.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using AcDream.App.UI; -using AcDream.UI.Abstractions; - -namespace AcDream.App.Tests.UI; - -public class UiMenuTests -{ - // PopupH = RowsPerColumn(7) * RowHeight(17) = 119; popup opens upward so top = -119. - // Item idx -> col = idx/7, row = idx%7; row band y in [top+row*17, top+(row+1)*17). - // Right column needs lx >= ColumnWidth(191) + Border(5) = lx >= 196 after bevel offset, - // but the original tests used lx=200 which maps ix=195 -> col=(int)(195/191)=1. OK. - - // The 14 channel items verbatim (matches ChannelItems in ChatWindowController). - private static readonly UiMenu.MenuItem[] ChannelItems = - { - new("Squelch (ignore)", (object?)null), - new("Tell to Selected", (object?)null), - new("Chat to All", (object?)ChatChannelKind.Say), - new("Tell to Fellows", (object?)ChatChannelKind.Fellowship), - new("Tell to General Chat", (object?)ChatChannelKind.General), - new("Tell to LFG Chat", (object?)ChatChannelKind.Lfg), - new("Tell to Society Chat", (object?)ChatChannelKind.Society), - new("Tell to Monarch", (object?)ChatChannelKind.Monarch), - new("Tell to Patron", (object?)ChatChannelKind.Patron), - new("Tell to Vassals", (object?)ChatChannelKind.Vassals), - new("Tell to Allegiance", (object?)ChatChannelKind.Allegiance), - new("Tell to Trade Chat", (object?)ChatChannelKind.Trade), - new("Tell to Roleplay Chat", (object?)ChatChannelKind.Roleplay), - new("Tell to Olthoi Chat", (object?)ChatChannelKind.Olthoi), - }; - - // Availability gate identical to ChatWindowController's EnabledProvider: the null-payload - // specials (Squelch/Tell-to-Selected) are ENABLED/white like retail; only talk-CHANNEL - // items grey when unavailable. (The widget reports any enabled pick via OnSelect; the - // controller decides whether to update Selected, so specials are inert no-ops anyway.) - private static bool ChannelAvailable(object? p) - => p is not ChatChannelKind ch - || ch is ChatChannelKind.Say or ChatChannelKind.General - or ChatChannelKind.Trade or ChatChannelKind.Lfg; - - private UiMenu MakeMenu() => new UiMenu - { - Width = 80f, Height = 18f, - Items = ChannelItems, - Selected = (object?)ChatChannelKind.Say, - EnabledProvider = ChannelAvailable, - }; - - [Fact] - public void Items_HasExpected14Entries() - { - Assert.Equal(14, ChannelItems.Length); - } - - [Fact] - public void Items_FirstEntry_IsSquelch_Special() - { - Assert.Equal("Squelch (ignore)", ChannelItems[0].Label); - Assert.Null(ChannelItems[0].Payload); - } - - [Fact] - public void Items_LastEntry_IsOlthoi() - { - var last = ChannelItems[^1]; - Assert.Equal("Tell to Olthoi Chat", last.Label); - Assert.Equal(ChatChannelKind.Olthoi, last.Payload); - } - - [Fact] - public void Items_ContainAll12ChannelKinds() - { - var kinds = new HashSet( - ChannelItems.Where(i => i.Payload is ChatChannelKind).Select(i => (ChatChannelKind)i.Payload!)); - foreach (var k in new[] - { - ChatChannelKind.Say, ChatChannelKind.General, ChatChannelKind.Trade, ChatChannelKind.Lfg, - ChatChannelKind.Fellowship, ChatChannelKind.Allegiance, ChatChannelKind.Patron, - ChatChannelKind.Vassals, ChatChannelKind.Monarch, ChatChannelKind.Roleplay, - ChatChannelKind.Society, ChatChannelKind.Olthoi, - }) - Assert.Contains(k, kinds); - } - - [Fact] - public void DefaultSelected_IsNull_OnBlankMenu() - { - // A freshly constructed UiMenu has no Selected by default (controller sets it). - Assert.Null(new UiMenu().Selected); - } - - [Fact] - public void Select_AvailableLeftColumnItem_FiresOnSelect() - { - var menu = MakeMenu(); - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - - object? fired = null; - // Mirror the controller: the widget reports the pick, the controller sets Selected. - menu.OnSelect = p => { fired = p; if (p is ChatChannelKind) menu.Selected = p; }; - - // "Chat to All" (Say) is index 2 = left col, row 2: y in [-85,-68). Say is available. - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -76))); - Assert.Equal(ChatChannelKind.Say, fired); - Assert.Equal(ChatChannelKind.Say, menu.Selected); - } - - [Fact] - public void Select_AvailableRightColumnItem_FiresOnSelect() - { - var menu = MakeMenu(); - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - - object? fired = null; - // Mirror the controller: the widget reports the pick, the controller sets Selected. - menu.OnSelect = p => { fired = p; if (p is ChatChannelKind) menu.Selected = p; }; - - // "Tell to Trade Chat" (Trade) is index 11 = right col (lx>=191), row 4: y in [-51,-34). - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 200, -42))); - Assert.Equal(ChatChannelKind.Trade, fired); - Assert.Equal(ChatChannelKind.Trade, menu.Selected); - } - - [Fact] - public void Select_SpecialItem_FiresNull_LeavesSelectionUnchanged() - { - var menu = MakeMenu(); // Selected = Say - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - - // Mirror the controller: only channel payloads update Selected; the null-payload - // specials are deferred no-ops that leave the active channel + highlight unchanged. - bool fired = false; object? firedPayload = "sentinel"; - menu.OnSelect = p => { fired = true; firedPayload = p; if (p is ChatChannelKind) menu.Selected = p; }; - - // "Squelch (ignore)" is index 0 = left col, row 0 (null payload), white/enabled. - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -110))); - Assert.True(fired); // the pick IS reported... - Assert.Null(firedPayload); // ...with the special's null payload - Assert.Equal(ChatChannelKind.Say, menu.Selected); // ...but selection is unchanged (deferred no-op) - } - - [Fact] - public void Select_UnavailableChannel_DoesNotFire() - { - var menu = MakeMenu(); - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - int fired = 0; - menu.OnSelect = _ => fired++; - - // "Tell to Fellows" (Fellowship) is index 3 = left col, row 3: y in [-68,-51). - // Fellowship is unavailable by the default static gate, so the click is inert. - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60))); - Assert.Equal(0, fired); - } - - [Fact] - public void EnabledProvider_Overrides_DefaultGate() - { - // Override: all items enabled (even Fellowship which is normally greyed). - var menu = new UiMenu - { - Width = 80f, Height = 18f, - Items = ChannelItems, - Selected = (object?)ChatChannelKind.Say, - EnabledProvider = _ => true, - }; - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open - - object? fired = null; - menu.OnSelect = p => fired = p; - - // With every item enabled, "Tell to Fellows" (idx 3, row 3) now fires. - Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, -60))); - Assert.Equal(ChatChannelKind.Fellowship, fired); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiMeterTests.cs b/tests/AcDream.App.Tests/UI/UiMeterTests.cs deleted file mode 100644 index 9e7637e9..00000000 --- a/tests/AcDream.App.Tests/UI/UiMeterTests.cs +++ /dev/null @@ -1,25 +0,0 @@ -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class UiMeterTests -{ - [Fact] - public void ComputeFillRect_HalfFillIsHalfWidth() - { - var (x, y, w, h) = UiMeter.ComputeFillRect(0.5f, 200f, 12f); - Assert.Equal(0f, x); Assert.Equal(0f, y); - Assert.Equal(100f, w); Assert.Equal(12f, h); - } - - [Theory] - [InlineData(-1f, 0f)] // clamps below 0 - [InlineData(2f, 200f)] // clamps above 1 - [InlineData(0f, 0f)] - [InlineData(1f, 200f)] - public void ComputeFillRect_ClampsFraction(float pct, float expectedW) - { - var (_, _, w, _) = UiMeter.ComputeFillRect(pct, 200f, 12f); - Assert.Equal(expectedW, w); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs b/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs deleted file mode 100644 index 6f130e24..00000000 --- a/tests/AcDream.App.Tests/UI/UiMeterVerticalTests.cs +++ /dev/null @@ -1,35 +0,0 @@ -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class UiMeterVerticalTests -{ - [Fact] - public void Vertical_fill_from_bottom_occupies_lower_fraction() - { - // 11x58 bar, 50% → fill rect is the bottom 29px. - var (x, y, w, h) = UiMeter.ComputeVFillRect(0.5f, 11f, 58f, fromBottom: true); - Assert.Equal(0f, x); - Assert.Equal(29f, y, 3); // h - h*p = 58 - 29 - Assert.Equal(11f, w); - Assert.Equal(29f, h, 3); - } - - [Fact] - public void Vertical_fill_from_top_starts_at_zero() - { - var (_, y, _, h) = UiMeter.ComputeVFillRect(0.25f, 11f, 58f, fromBottom: false); - Assert.Equal(0f, y); - Assert.Equal(14.5f, h, 3); - } - - [Theory] - [InlineData(-1f, 0f)] - [InlineData(2f, 58f)] - public void Vertical_fill_clamps_fraction(float pct, float expectedH) - { - var (_, _, _, h) = UiMeter.ComputeVFillRect(pct, 11f, 58f, fromBottom: true); - Assert.Equal(expectedH, h, 3); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs b/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs deleted file mode 100644 index 8a2b3d0a..00000000 --- a/tests/AcDream.App.Tests/UI/UiNineSlicePanelTests.cs +++ /dev/null @@ -1,27 +0,0 @@ -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class UiNineSlicePanelTests -{ - [Fact] - public void ComputeFrameRects_PlacesCornersEdgesAndCenter() - { - var r = UiNineSlicePanel.ComputeFrameRects(100, 80, 5); - - // 5x5 corners at the four corners - Assert.Equal(new UiNineSlicePanel.Rect(0, 0, 5, 5), r.TL); - Assert.Equal(new UiNineSlicePanel.Rect(95, 0, 5, 5), r.TR); - Assert.Equal(new UiNineSlicePanel.Rect(0, 75, 5, 5), r.BL); - Assert.Equal(new UiNineSlicePanel.Rect(95, 75, 5, 5), r.BR); - - // edges span the interior (100-2*5 = 90 wide, 80-2*5 = 70 tall) - Assert.Equal(new UiNineSlicePanel.Rect(5, 0, 90, 5), r.Top); - Assert.Equal(new UiNineSlicePanel.Rect(5, 75, 90, 5), r.Bottom); - Assert.Equal(new UiNineSlicePanel.Rect(0, 5, 5, 70), r.Left); - Assert.Equal(new UiNineSlicePanel.Rect(95, 5, 5, 70), r.Right); - - // center fills the interior - Assert.Equal(new UiNineSlicePanel.Rect(5, 5, 90, 70), r.Center); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs b/tests/AcDream.App.Tests/UI/UiRootInputTests.cs deleted file mode 100644 index 49799ae1..00000000 --- a/tests/AcDream.App.Tests/UI/UiRootInputTests.cs +++ /dev/null @@ -1,380 +0,0 @@ -using System.Numerics; -using AcDream.App.UI; - -namespace AcDream.App.Tests.UI; - -public class UiRootInputTests -{ - [Fact] - public void UiNineSlicePanel_IsNotAnchorManaged_SoUserMoveResizeSticks() - { - // Regression: the per-frame anchor pass must NOT reset a window's rect, - // or move/resize get undone every frame. Windows are user-positioned. - var panel = new UiNineSlicePanel(_ => ((uint)1, 32, 32)); - Assert.Equal(AnchorEdges.None, panel.Anchors); - } - - private sealed class ClickRecorder : UiElement - { - private readonly bool _handlesClick; - public bool Clicked; - public ClickRecorder(bool handlesClick) => _handlesClick = handlesClick; - public override bool HandlesClick => _handlesClick; - public override bool OnEvent(in UiEvent e) - { - if (e.Type == UiEventType.Click) { Clicked = true; return true; } - return false; - } - } - - [Fact] - public void HandlesClickWidget_insideDraggableWindow_stillEmitsClick() - { - // Regression (paperdoll "Slots" toggle): a HandlesClick widget (e.g. a UiButton) inside a - // whole-window-Draggable frame (the inventory window, IA-12 whole-window-drag) must still - // receive its Click. Before the fix the window-move branch captured the press and OnMouseUp - // returned before emitting Click, so the toggle button did nothing. - var root = new UiRoot { Width = 800, Height = 600 }; - var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true }; - var btn = new ClickRecorder(handlesClick: true) { Left = 5, Top = 5, Width = 120, Height = 14 }; - frame.AddChild(btn); - root.AddChild(frame); - - root.OnMouseDown(UiMouseButton.Left, 20, 310); // press over the button (screen rect 15,305..135,319) - root.OnMouseUp(UiMouseButton.Left, 20, 310); // release same spot → Click - Assert.True(btn.Clicked); - } - - [Fact] - public void PlainWidget_insideDraggableWindow_doesNotEmitClick() - { - // Contrast that locks the distinction: a NON-HandlesClick child inside the Draggable frame is - // captured as a whole-window-drag, so no Click is emitted (the frame is draggable by it). - var root = new UiRoot { Width = 800, Height = 600 }; - var frame = new UiPanel { Left = 10, Top = 300, Width = 200, Height = 60, Draggable = true }; - var plain = new ClickRecorder(handlesClick: false) { Left = 5, Top = 5, Width = 120, Height = 14 }; - frame.AddChild(plain); - root.AddChild(frame); - - root.OnMouseDown(UiMouseButton.Left, 20, 310); - root.OnMouseUp(UiMouseButton.Left, 20, 310); - Assert.False(plain.Clicked); - } - - private sealed class CoordRecorder : UiElement - { - public (int x, int y)? Down, Move; - public CoordRecorder() { CapturesPointerDrag = true; } - public override bool OnEvent(in UiEvent e) - { - if (e.Type == UiEventType.MouseDown) { Down = (e.Data1, e.Data2); return true; } - if (e.Type == UiEventType.MouseMove) { Move = (e.Data1, e.Data2); return true; } - return false; - } - } - - [Fact] - public void MouseDown_And_MouseMove_DeliverSameTargetLocalFrame_ForNestedChild() - { - // Regression (adversarial review): a nested child must receive target-LOCAL - // coords on MouseDown AND MouseMove for the same physical point — otherwise - // drag-select anchors ~(child offset) px off from where you click. Before the - // fix MouseDown used HitTestTopDown's window-relative coords (50,40) while - // MouseMove used target-local (42,32). - var root = new UiRoot { Width = 800, Height = 600 }; - var panel = new UiPanel { Left = 50, Top = 60, Width = 200, Height = 100 }; - var child = new CoordRecorder { Left = 8, Top = 8, Width = 150, Height = 80 }; - panel.AddChild(child); - root.AddChild(panel); - - // child ScreenPosition = (58,68). Click screen (100,100) -> local (42,32). - root.OnMouseDown(UiMouseButton.Left, 100, 100); - Assert.Equal((42, 32), child.Down); - - // drag to (120,110) -> local (62,42); MUST share the MouseDown frame. - root.OnMouseMove(120, 110); - Assert.Equal((62, 42), child.Move); - } - - [Fact] - public void ApplyAnchor_None_IsNoOp() - { - var e = new UiPanel { Left = 50, Top = 60, Width = 100, Height = 40, Anchors = AnchorEdges.None }; - e.ApplyAnchor(800, 600); - Assert.Equal(50f, e.Left); - Assert.Equal(60f, e.Top); - Assert.Equal(100f, e.Width); - Assert.Equal(40f, e.Height); - } - - [Fact] - public void WantsMouse_TrueOverWidget_FalseOverEmptySpace() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 }; - root.AddChild(panel); - - root.OnMouseMove(50, 30); // inside the panel - Assert.True(root.WantsMouse); - - root.OnMouseMove(500, 400); // empty space - Assert.False(root.WantsMouse); - } - - [Fact] - public void WindowDrag_RepositionsDraggablePanel_StopsOnRelease() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50, Draggable = true }; - root.AddChild(panel); - - root.OnMouseDown(UiMouseButton.Left, 20, 20); // grab at (10,10) into the panel - root.OnMouseMove(120, 90); // drag - Assert.Equal(110f, panel.Left); // 120 - 10 - Assert.Equal(80f, panel.Top); // 90 - 10 - - root.OnMouseUp(UiMouseButton.Left, 120, 90); - root.OnMouseMove(300, 300); // released — must not move - Assert.Equal(110f, panel.Left); - Assert.Equal(80f, panel.Top); - } - - [Fact] - public void NonDraggablePanel_DoesNotMoveOnDrag() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var panel = new UiPanel { Left = 10, Top = 10, Width = 100, Height = 50 }; // Draggable defaults false - root.AddChild(panel); - - root.OnMouseDown(UiMouseButton.Left, 20, 20); - root.OnMouseMove(120, 90); - Assert.Equal(10f, panel.Left); - Assert.Equal(10f, panel.Top); - } - - [Fact] - public void CapturesPointerDragChild_DoesNotMoveDraggableAncestor_OnInteriorDrag() - { - // A child that captures pointer drags (text selection) must NOT move its - // draggable ancestor window when the user drags inside it. - var root = new UiRoot { Width = 800, Height = 600 }; - var window = new UiPanel { Left = 10, Top = 10, Width = 200, Height = 100, Draggable = true }; - var child = new UiPanel { Left = 20, Top = 20, Width = 120, Height = 60, CapturesPointerDrag = true }; - window.AddChild(child); - root.AddChild(window); - - // Press deep inside the child, then drag. - root.OnMouseDown(UiMouseButton.Left, 60, 60); - root.OnMouseMove(160, 160); - - // Window stays put; the captured child receives the drag itself. - Assert.Equal(10f, window.Left); - Assert.Equal(10f, window.Top); - Assert.Same(child, root.Captured); - - root.OnMouseUp(UiMouseButton.Left, 160, 160); - Assert.Equal(10f, window.Left); - Assert.Equal(10f, window.Top); - } - - [Fact] - public void CapturesPointerDragChild_StillAllowsEdgeResizeOfResizableWindow() - { - // Edge resize must still win even when a CapturesPointerDrag child covers - // the frame: a resizable chat window can be resized from its border. - var root = new UiRoot { Width = 800, Height = 600 }; - var window = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, - Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 }; - // Child fills the whole window (anchored) and captures interior drags. - var child = new UiPanel { Left = 0, Top = 0, Width = 200, Height = 100, - CapturesPointerDrag = true, - Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom }; - window.AddChild(child); - root.AddChild(window); - - // Grab within ResizeGrip(5) of the right edge (x=298 of right edge x=300) → resize. - root.OnMouseDown(UiMouseButton.Left, 298, 150); - root.OnMouseMove(338, 150); - Assert.Equal(240f, window.Width); - Assert.Equal(100f, window.Left); - root.OnMouseUp(UiMouseButton.Left, 338, 150); - } - - [Fact] - public void ResizeRect_RightBottom_GrowsSizeOnly() - { - var (x, y, w, h) = UiRoot.ResizeRect(10, 20, 100, 50, - ResizeEdges.Right | ResizeEdges.Bottom, dx: 30, dy: 15, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue); - Assert.Equal(10f, x); Assert.Equal(20f, y); - Assert.Equal(130f, w); Assert.Equal(65f, h); - } - - [Fact] - public void ResizeRect_LeftTop_MovesOriginAndClampsToMin() - { - // Drag left edge right by 80 on a 100-wide / min-40 window: width clamps to 40, - // origin shifts so the RIGHT edge (110) stays put → x = 70. - var (x, _, w, _) = UiRoot.ResizeRect(10, 20, 100, 50, - ResizeEdges.Left, dx: 80, dy: 0, minW: 40, minH: 40, maxW: float.MaxValue, maxH: float.MaxValue); - Assert.Equal(40f, w); - Assert.Equal(70f, x); - } - - [Fact] - public void ResizeRect_Bottom_ClampsToMaxH() - { - // dy=1000 on a 50-tall window with maxH=128 → height clamps to 128, origin unchanged. - var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50, - ResizeEdges.Bottom, dx: 0, dy: 1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f); - Assert.Equal(128f, h); - Assert.Equal(20f, y); - } - - [Fact] - public void ResizeRect_Top_ClampsToMaxH() - { - // Drag the top edge UP by 1000 on a 50-tall window with maxH=128 → height clamps to 128, - // origin shifts so the bottom edge (70) stays put → y = 20 + 50 - 128. - var (_, y, _, h) = UiRoot.ResizeRect(10, 20, 100, 50, - ResizeEdges.Top, dx: 0, dy: -1000, minW: 40, minH: 40, maxW: float.MaxValue, maxH: 128f); - Assert.Equal(128f, h); - Assert.Equal(20f + 50f - 128f, y); - } - - [Fact] - public void HitEdges_DetectsCornerAndInteriorNone() - { - var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100 }; - // bottom-right corner (300,200) - Assert.Equal(ResizeEdges.Right | ResizeEdges.Bottom, UiRoot.HitEdges(panel, 300, 200, 5)); - // deep interior → no edges - Assert.Equal(ResizeEdges.None, UiRoot.HitEdges(panel, 200, 150, 5)); - } - - [Fact] - public void EdgeDrag_ResizesPanel_InteriorDragMoves() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, - Draggable = true, Resizable = true, MinWidth = 40, MinHeight = 40 }; - root.AddChild(panel); - - // grab just inside the right edge (x=298, within ResizeGrip=5 of x=300) and drag right → wider, same origin - root.OnMouseDown(UiMouseButton.Left, 298, 150); - root.OnMouseMove(338, 150); - Assert.Equal(240f, panel.Width); - Assert.Equal(100f, panel.Left); - root.OnMouseUp(UiMouseButton.Left, 338, 150); - - // grab the interior and drag → moves - root.OnMouseDown(UiMouseButton.Left, 200, 150); - root.OnMouseMove(220, 170); - Assert.Equal(120f, panel.Left); - Assert.Equal(120f, panel.Top); - root.OnMouseUp(UiMouseButton.Left, 220, 170); - } - - [Fact] - public void HitEdges_RespectsResizeAxisLock() - { - var panel = new UiPanel { Left = 100, Top = 100, Width = 200, Height = 100, ResizeY = false }; - // right edge still detected (X allowed) - Assert.True((UiRoot.HitEdges(panel, 300, 150, 5) & ResizeEdges.Right) != 0); - // bottom edge masked out (Y locked) - Assert.True((UiRoot.HitEdges(panel, 200, 200, 5) & ResizeEdges.Bottom) == 0); - } - - [Fact] - public void ComputeAnchoredRect_LeftRight_StretchesWidth() - { - // bar at x=8,w=200 in a 220-wide parent (right margin 12). Parent grows to 300. - var (x, _, w, _) = UiElement.ComputeAnchoredRect( - AnchorEdges.Left | AnchorEdges.Right | AnchorEdges.Top, - mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96); - Assert.Equal(8f, x); - Assert.Equal(280f, w); // 300 - 12 - 8 - } - - [Fact] - public void ComputeAnchoredRect_LeftTopOnly_KeepsFixedSizeAndOrigin() - { - var (x, y, w, h) = UiElement.ComputeAnchoredRect( - AnchorEdges.Left | AnchorEdges.Top, - mL: 8, mT: 24, mR: 12, mB: 58, w0: 200, h0: 14, parentW: 300, parentH: 96); - Assert.Equal(8f, x); Assert.Equal(24f, y); - Assert.Equal(200f, w); Assert.Equal(14f, h); - } - - [Fact] - public void ToggleWindow_FlipsVisible_AndReturnsNewState() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var win = new UiPanel { Width = 100, Height = 100, Visible = false }; - root.AddChild(win); - root.RegisterWindow("inventory", win); - - Assert.True(root.ToggleWindow("inventory")); // hidden -> shown - Assert.True(win.Visible); - Assert.False(root.ToggleWindow("inventory")); // shown -> hidden - Assert.False(win.Visible); - } - - [Fact] - public void ShowHideWindow_SetVisibility_UnknownNameIsNoOp() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var win = new UiPanel { Width = 100, Height = 100, Visible = false }; - root.AddChild(win); - root.RegisterWindow("inventory", win); - - Assert.True(root.ShowWindow("inventory")); - Assert.True(win.Visible); - Assert.True(root.HideWindow("inventory")); - Assert.False(win.Visible); - - Assert.False(root.ShowWindow("nope")); - Assert.False(root.HideWindow("nope")); - Assert.False(root.ToggleWindow("nope")); - } - - [Fact] - public void ShowWindow_RaisesAbovePeers() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var a = new UiPanel { Width = 100, Height = 100 }; - var b = new UiPanel { Width = 100, Height = 100 }; - var win = new UiPanel { Width = 100, Height = 100, Visible = false }; - root.AddChild(a); root.AddChild(b); root.AddChild(win); - root.RegisterWindow("inventory", win); - - root.ShowWindow("inventory"); - Assert.True(win.ZOrder > a.ZOrder); - Assert.True(win.ZOrder > b.ZOrder); - } - - [Fact] - public void BringToFront_SetsStrictlyGreatestZOrder() - { - var root = new UiRoot { Width = 800, Height = 600 }; - var a = new UiPanel { Width = 100, Height = 100, ZOrder = 5 }; - var b = new UiPanel { Width = 100, Height = 100, ZOrder = 9 }; - root.AddChild(a); root.AddChild(b); - - root.BringToFront(a); - Assert.True(a.ZOrder > b.ZOrder); // 10 > 9 - } - - [Fact] - public void MouseDown_OnWindow_RaisesItAbovePeers() - { - var root = new UiRoot { Width = 800, Height = 600 }; - // 'peer' has a higher ZOrder but does NOT cover (50,50); 'clicked' does. - var peer = new UiPanel { Left = 300, Top = 0, Width = 100, Height = 100, ZOrder = 9 }; - var clicked = new UiPanel { Left = 0, Top = 0, Width = 100, Height = 100, ZOrder = 0, Draggable = true }; - root.AddChild(peer); root.AddChild(clicked); - - root.OnMouseDown(UiMouseButton.Left, 50, 50); // only 'clicked' occupies (50,50) - Assert.True(clicked.ZOrder > peer.ZOrder); - root.OnMouseUp(UiMouseButton.Left, 50, 50); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiScrollableTests.cs b/tests/AcDream.App.Tests/UI/UiScrollableTests.cs deleted file mode 100644 index 27804b1c..00000000 --- a/tests/AcDream.App.Tests/UI/UiScrollableTests.cs +++ /dev/null @@ -1,73 +0,0 @@ -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class UiScrollableTests -{ - [Fact] - public void Clamp_KeepsScrollWithinContent() - { - var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; - s.SetScrollY(500); - Assert.Equal(200, s.ScrollY); - s.SetScrollY(-50); - Assert.Equal(0, s.ScrollY); - } - - [Fact] - public void FitsView_PinsToZero() - { - var s = new UiScrollable { ContentHeight = 80, ViewHeight = 100 }; - s.SetScrollY(40); - Assert.Equal(0, s.ScrollY); - Assert.False(s.HasOverflow); - } - - [Fact] - public void ThumbRatio_IsViewOverContent_ClampedToOne() - { - var s = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; - Assert.Equal(0.25f, s.ThumbRatio, 3); - var full = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; - Assert.Equal(1f, full.ThumbRatio, 3); - } - - [Fact] - public void PositionRatio_MapsScrollToZeroOne() - { - var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; - s.SetScrollY(100); - Assert.Equal(0.5f, s.PositionRatio, 3); - s.SetScrollY(200); - Assert.Equal(1f, s.PositionRatio, 3); - } - - [Fact] - public void SetPositionRatio_IsInverseOfPositionRatio() - { - var s = new UiScrollable { ContentHeight = 300, ViewHeight = 100 }; - s.SetPositionRatio(0.5f); - Assert.Equal(100, s.ScrollY); - } - - [Fact] - public void ScrollByLines_AdvancesByLineHeight() - { - var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; - s.ScrollByLines(-2); - Assert.Equal(0, s.ScrollY); - s.SetScrollY(50); - s.ScrollByLines(2); - Assert.Equal(82, s.ScrollY); - } - - [Fact] - public void ScrollByPage_AdvancesByViewHeight() - { - var s = new UiScrollable { ContentHeight = 1000, ViewHeight = 100, LineHeight = 16 }; - s.SetScrollY(200); - s.ScrollByPage(1); - Assert.Equal(300, s.ScrollY); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs deleted file mode 100644 index c2239732..00000000 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ /dev/null @@ -1,81 +0,0 @@ -using AcDream.App.UI; -using Xunit; - -namespace AcDream.App.Tests.UI; - -/// -/// Pure unit tests for — no GL dependency. -/// -public class UiScrollbarTests -{ - // Model: content=400, view=100, trackLen=200. - // ThumbRatio = 100/400 = 0.25 → thumbH = max(8, 200*0.25) = 50. - // Travel = 200 - 50 = 150. - - [Fact] - public void ThumbRect_AtStart_HasCorrectSizeAndZeroOffset() - { - var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; - // PositionRatio = 0 (start). - var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); - Assert.Equal(50f, h, 3f); - Assert.Equal(0f, y, 3f); - } - - [Fact] - public void ThumbRect_AtEnd_PinsToBottomOfTrack() - { - var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; - m.ScrollToEnd(); // PositionRatio = 1. - float trackTop = 16f, trackLen = 200f; - var (y, h) = UiScrollbar.ThumbRect(m, trackTop, trackLen); - Assert.Equal(50f, h, 3f); - // y = trackTop + travel * 1 = 16 + 150 = 166. - Assert.Equal(166f, y, 3f); - } - - [Fact] - public void ThumbRect_WithButtonH_CorrectlyOffsetsFromTrackTop() - { - // Matches task spec: content=400, view=100, trackLen=200, PositionRatio=1. - // thumbH=50; travel=150; y = trackTop + 150 = trackTop + 150. - var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; - m.ScrollToEnd(); - var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 200f); - Assert.Equal(50f, h, 3f); - Assert.Equal(166f, y, 3f); // 16 + 150 - } - - [Fact] - public void ThumbRect_MidScroll_InterpolatesPosition() - { - // content=400 view=100 → MaxScroll=300; ScrollY=150 → PositionRatio=0.5. - var m = new UiScrollable { ContentHeight = 400, ViewHeight = 100 }; - m.SetScrollY(150); - Assert.Equal(0.5f, m.PositionRatio, 3); - - var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); - Assert.Equal(50f, h, 3f); - // y = 0 + 150 * 0.5 = 75. - Assert.Equal(75f, y, 3f); - } - - [Fact] - public void ThumbRect_SmallContent_EnforcesMinThumb() - { - // content=1000, view=10, trackLen=200 → ThumbRatio=0.01 → raw=2 < 8 → clamp to 8. - var m = new UiScrollable { ContentHeight = 1000, ViewHeight = 10 }; - var (_, h) = UiScrollbar.ThumbRect(m, trackTop: 0f, trackLen: 200f); - Assert.Equal(8f, h, 3f); - } - - [Fact] - public void ThumbRect_NoOverflow_ThumbFillsTrack() - { - // content <= view → ThumbRatio = 1 → thumbH = trackLen. - var m = new UiScrollable { ContentHeight = 50, ViewHeight = 100 }; - var (y, h) = UiScrollbar.ThumbRect(m, trackTop: 16f, trackLen: 100f); - Assert.Equal(100f, h, 3f); - Assert.Equal(16f, y, 3f); // travel = 0 → y = trackTop - } -} diff --git a/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs b/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs deleted file mode 100644 index 11e6d1eb..00000000 --- a/tests/AcDream.App.Tests/UI/UiTextDatFontTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using AcDream.App.UI; -using DatReaderWriter.Types; -using Xunit; - -namespace AcDream.App.Tests.UI; - -public class UiTextDatFontTests -{ - // Synthetic per-char advance: each glyph 10px (Before=2,Width=6,After=2). - private static FontCharDesc Glyph(char c) => new() - { - Unicode = c, HorizontalOffsetBefore = 2, Width = 6, HorizontalOffsetAfter = 2, - OffsetX = 0, OffsetY = 0, Height = 12, VerticalOffsetBefore = 0, - }; - - [Fact] - public void CharIndexAt_UsesDatGlyphAdvance() - { - float Adv(char c) => UiDatFont.GlyphAdvance(Glyph(c)); - Assert.Equal(0, UiText.CharIndexAt("abc", Adv, 4f)); - Assert.Equal(1, UiText.CharIndexAt("abc", Adv, 12f)); - Assert.Equal(3, UiText.CharIndexAt("abc", Adv, 100f)); - } - - [Fact] - public void GlyphAdvance_MatchesRetailFormula() - { - Assert.Equal(10f, UiDatFont.GlyphAdvance(Glyph('x'))); - } -} diff --git a/tests/AcDream.App.Tests/UI/UiTextTests.cs b/tests/AcDream.App.Tests/UI/UiTextTests.cs deleted file mode 100644 index 946f7fad..00000000 --- a/tests/AcDream.App.Tests/UI/UiTextTests.cs +++ /dev/null @@ -1,197 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.App.UI; -using AcDream.App.UI.Layout; - -namespace AcDream.App.Tests.UI; - -public class UiTextTests -{ - [Fact] - public void ClampScroll_PinsToZero_WhenContentFitsView() - { - // 5 lines of content in a taller view → nothing to scroll, pinned at 0. - Assert.Equal(0f, UiText.ClampScroll(50f, contentHeight: 80f, viewHeight: 200f)); - Assert.Equal(0f, UiText.ClampScroll(0f, contentHeight: 80f, viewHeight: 200f)); - } - - [Fact] - public void ClampScroll_CapsAtContentMinusView_WhenOverflowing() - { - // Content 500, view 200 → max scrollback is 300px (oldest line at top). - Assert.Equal(300f, UiText.ClampScroll(1000f, contentHeight: 500f, viewHeight: 200f)); - Assert.Equal(120f, UiText.ClampScroll(120f, contentHeight: 500f, viewHeight: 200f)); - } - - [Fact] - public void ClampScroll_NeverNegative() - { - Assert.Equal(0f, UiText.ClampScroll(-50f, contentHeight: 500f, viewHeight: 200f)); - } - - // ── Char-index hit-testing (x → col) with a synthetic 10px monospace advance ── - - private static readonly Func Mono10 = static _ => 10f; - - [Fact] - public void CharIndexAt_ZeroOrNegative_IsColumnZero() - { - Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 0f)); - Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, -5f)); - } - - [Fact] - public void CharIndexAt_SnapsToGlyphMidpoint() - { - // glyph[0] spans 0..10 (midpoint 5), glyph[1] 10..20 (midpoint 15), ... - Assert.Equal(0, UiText.CharIndexAt("hello", Mono10, 4f)); // before mid of glyph 0 - Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 6f)); // past mid of glyph 0 - Assert.Equal(1, UiText.CharIndexAt("hello", Mono10, 14f)); // before mid of glyph 1 - Assert.Equal(2, UiText.CharIndexAt("hello", Mono10, 16f)); // past mid of glyph 1 - } - - [Fact] - public void CharIndexAt_PastEnd_IsLength() - { - Assert.Equal(5, UiText.CharIndexAt("hello", Mono10, 1000f)); - } - - [Fact] - public void CharIndexAt_EmptyString_IsZero() - { - Assert.Equal(0, UiText.CharIndexAt("", Mono10, 50f)); - } - - // ── SelectedText assembly ──────────────────────────────────────────── - - private static IReadOnlyList Lines(params string[] texts) - { - var list = new List(texts.Length); - foreach (var t in texts) - list.Add(new UiText.Line(t, new Vector4(1, 1, 1, 1))); - return list; - } - - [Fact] - public void SelectedText_SingleLine_Substring() - { - var lines = Lines("hello world"); - var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(0, 11)); - Assert.Equal("world", s); - } - - [Fact] - public void SelectedText_SingleLine_ReversedAnchorCaret_IsNormalised() - { - var lines = Lines("hello world"); - // caret BEFORE anchor — Order() must normalise. - var s = UiText.SelectedText(lines, new UiText.Pos(0, 11), new UiText.Pos(0, 6)); - Assert.Equal("world", s); - } - - [Fact] - public void SelectedText_SamePosition_IsEmpty() - { - var lines = Lines("hello"); - Assert.Equal("", UiText.SelectedText(lines, new UiText.Pos(0, 3), new UiText.Pos(0, 3))); - } - - [Fact] - public void SelectedText_MultiLine_JoinsWithNewline() - { - var lines = Lines("first line", "second line", "third line"); - // from col 6 of line 0 ("line") through col 5 of line 2 ("third") - var s = UiText.SelectedText(lines, new UiText.Pos(0, 6), new UiText.Pos(2, 5)); - Assert.Equal("line\nsecond line\nthird", s); - } - - [Fact] - public void SelectedText_MultiLine_TwoLines_NoMiddle() - { - var lines = Lines("alpha", "bravo"); - var s = UiText.SelectedText(lines, new UiText.Pos(0, 2), new UiText.Pos(1, 3)); - Assert.Equal("pha\nbra", s); - } - - [Fact] - public void SelectedText_MultiLine_ReversedAnchorCaret_IsNormalised() - { - var lines = Lines("alpha", "bravo"); - // end before start → Order() swaps them. - var s = UiText.SelectedText(lines, new UiText.Pos(1, 3), new UiText.Pos(0, 2)); - Assert.Equal("pha\nbra", s); - } - - [Fact] - public void SelectedText_EmptyLineList_IsEmpty() - { - Assert.Equal("", UiText.SelectedText(Array.Empty(), - new UiText.Pos(0, 0), new UiText.Pos(0, 0))); - } - - [Fact] - public void Order_SortsByLineThenColumn() - { - var (s1, e1) = UiText.Order(new UiText.Pos(2, 1), new UiText.Pos(0, 5)); - Assert.Equal(new UiText.Pos(0, 5), s1); - Assert.Equal(new UiText.Pos(2, 1), e1); - - var (s2, e2) = UiText.Order(new UiText.Pos(1, 8), new UiText.Pos(1, 2)); - Assert.Equal(new UiText.Pos(1, 2), s2); - Assert.Equal(new UiText.Pos(1, 8), e2); - } - - // ── VOffset: vertical positioning for single-line mode ─────────────────── - - /// - /// VJustify.Center: vertical offset = (height - lineHeight) / 2. - /// This is the original formula used by both the Centered and RightAligned paths - /// before VerticalJustify was introduced. Must remain unchanged (backward compat). - /// - [Fact] - public void VOffset_Center_ReturnsHalfHeightMinusLineHeight() - { - float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Center); - Assert.Equal((55f - 12f) * 0.5f, y); - } - - /// - /// VJustify.Top: vertical offset = padding (top of the content area). - /// Used for footer title elements whose dat box is the full footer height (55 px) - /// but the text should render near the top. - /// - [Fact] - public void VOffset_Top_ReturnsPadding() - { - float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 0f, VJustify.Top); - Assert.Equal(0f, y); - - float yWithPadding = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 4f, VJustify.Top); - Assert.Equal(4f, yWithPadding); - } - - /// - /// VJustify.Bottom: vertical offset = height - lineHeight - padding. - /// - [Fact] - public void VOffset_Bottom_ReturnsHeightMinusLineHeightMinusPadding() - { - float y = UiText.VOffset(height: 55f, lineHeight: 12f, padding: 2f, VJustify.Bottom); - Assert.Equal(55f - 12f - 2f, y); - } - - /// - /// Center with non-zero padding: padding does NOT affect the center formula — - /// VJustify.Center always uses (height - lineHeight) / 2 regardless of padding, - /// matching the original behavior. - /// - [Fact] - public void VOffset_Center_IgnoresPadding() - { - float yNoPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 0f, VJustify.Center); - float yWithPad = UiText.VOffset(height: 40f, lineHeight: 12f, padding: 4f, VJustify.Center); - Assert.Equal(yNoPad, yWithPad); // Center is padding-independent - Assert.Equal((40f - 12f) * 0.5f, yNoPad); - } -} diff --git a/tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj b/tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj deleted file mode 100644 index d67af0e8..00000000 --- a/tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj +++ /dev/null @@ -1,29 +0,0 @@ - - - - net10.0 - enable - enable - false - latest - true - - - - - - - - - - - - - - - - - - - diff --git a/tests/AcDream.Bake.Tests/BakeDeterminismTests.cs b/tests/AcDream.Bake.Tests/BakeDeterminismTests.cs deleted file mode 100644 index 5902fd68..00000000 --- a/tests/AcDream.Bake.Tests/BakeDeterminismTests.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using AcDream.Bake; - -namespace AcDream.Bake.Tests; - -/// -/// Dat-gated byte-reproducibility gate for the bake pipeline (review finding -/// 1): the plan's "bakes must be byte-reproducible run-to-run" is a whole- -/// FILE property, so it must be tested through the REAL BakeRunner — the -/// serializer-level determinism tests in Content.Tests can't see thread- -/// completion-order effects in the blob region. Skips cleanly when the dats -/// are absent (CI), same convention as -/// tests/AcDream.Core.Tests/Conformance/DatConcurrencyStressTests. -/// -public sealed class BakeDeterminismTests : IDisposable { - private readonly List _tempFiles = new(); - - private string NewTempPakPath() { - var path = Path.Combine(Path.GetTempPath(), $"acdream-baketest-{Guid.NewGuid():N}.pak"); - _tempFiles.Add(path); - return path; - } - - public void Dispose() { - foreach (var f in _tempFiles) { - try { if (File.Exists(f)) File.Delete(f); } catch { /* best effort cleanup */ } - } - } - - private static string? ResolveDatDir() { - // Mirrors ConformanceDats.ResolveDatDir (env var, then the well-known - // Documents fallback); duplicated because test projects can't - // reference each other's helpers. - var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return fromEnv; - - var def = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - return Directory.Exists(def) ? def : null; - } - - [Fact] - public void Bake_SameIdSet_DifferentThreadCounts_ByteIdenticalPaks() { - var datDir = ResolveDatDir(); - if (datDir is null) return; // dats absent (CI) — skip, matching suite convention - - // Small mixed fixture: GfxObjs (incl. the #119 tricky ids), Setups - // (door setup carries emitters -> exercises the side-staged preload - // path), and a Holtburg EnvCell. Big enough to span multiple asset - // types; small enough to run in seconds. - var ids = new HashSet { - 0x01000001u, 0x010002B4u, 0x010008A8u, 0x010014C3u, - 0x02000001u, 0x020019FFu, 0x020005D8u, - 0xA9B40100u, 0xA9B40101u, - }; - - var pathA = NewTempPakPath(); - var pathB = NewTempPakPath(); - - // DIFFERENT thread counts on purpose: thread-completion order was the - // nondeterminism source the sorted-batching fix removes — identical - // bytes must hold regardless of parallelism. - int rcA = BakeRunner.Run(new BakeOptions { DatDir = datDir, OutPath = pathA, IdFilter = ids, Threads = 8 }); - int rcB = BakeRunner.Run(new BakeOptions { DatDir = datDir, OutPath = pathB, IdFilter = ids, Threads = 3 }); - - Assert.Equal(0, rcA); - Assert.Equal(0, rcB); - - var bytesA = File.ReadAllBytes(pathA); - var bytesB = File.ReadAllBytes(pathB); - Assert.True(bytesA.Length == bytesB.Length, - $"pak sizes differ between runs: {bytesA.Length} vs {bytesB.Length} bytes"); - Assert.True(bytesA.AsSpan().SequenceEqual(bytesB), - "two bakes of the same id set produced different bytes — the sorted-batching determinism guarantee is broken"); - } -} diff --git a/tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj b/tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj deleted file mode 100644 index dc64fa3c..00000000 --- a/tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - net10.0 - enable - enable - false - latest - true - - - - - - - - - - - - - - - - - - - diff --git a/tests/AcDream.Content.Tests/ContentConformanceDats.cs b/tests/AcDream.Content.Tests/ContentConformanceDats.cs deleted file mode 100644 index c8ce4e21..00000000 --- a/tests/AcDream.Content.Tests/ContentConformanceDats.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.IO; -using Microsoft.Extensions.Logging; - -namespace AcDream.Content.Tests; - -/// -/// Dat-dir resolution for the Content.Tests dat-gated equivalence suite. -/// Mirrors the exact pattern used by -/// tests/AcDream.Core.Tests/Conformance/ConformanceDats.ResolveDatDir and -/// tests/AcDream.Core.Tests/Conformance/DatConcurrencyStressTests -/// ("dats absent (CI) -- skip, matching suite convention"). Content.Tests -/// cannot reference AcDream.Core.Tests (a test project, not a library), so -/// this is a deliberate small duplication of the resolution logic — not a -/// new convention. -/// -public static class ContentConformanceDats { - public static string? ResolveDatDir() { - var fromEnv = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); - if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv)) return fromEnv; - - var def = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - return Directory.Exists(def) ? def : null; - } -} - -/// Minimal ILogger writing to Console — surfaces MeshExtractor failures during the equivalence sweep instead of silently swallowing them (NullLogger would hide the exact failure this suite exists to catch). -internal sealed class TestConsoleLogger : ILogger { - public IDisposable? BeginScope(TState state) where TState : notnull => null; - public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Warning; - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) { - if (!IsEnabled(logLevel)) return; - Console.WriteLine($"[{logLevel}] MeshExtractor: {formatter(state, exception)}"); - if (exception is not null) Console.WriteLine(exception); - } -} diff --git a/tests/AcDream.Content.Tests/Crc32Tests.cs b/tests/AcDream.Content.Tests/Crc32Tests.cs deleted file mode 100644 index 3ab7897c..00000000 --- a/tests/AcDream.Content.Tests/Crc32Tests.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Text; -using AcDream.Content.Pak; - -namespace AcDream.Content.Tests; - -/// -/// Known-answer vectors for the hand-rolled CRC-32 (IEEE 802.3 / zip / PNG -/// variant). Without these the suite is blind to a self-consistent-but-wrong -/// implementation: writer and reader would agree with each other while -/// producing values no external tool could reproduce (review finding 6). -/// -public class Crc32Tests { - [Fact] - public void KnownAnswer_123456789_IsCBF43926() { - // THE standard CRC-32 check value: CRC of the ASCII string - // "123456789" is 0xCBF43926 for the reflected 0xEDB88320 polynomial. - var input = Encoding.ASCII.GetBytes("123456789"); - Assert.Equal(0xCBF43926u, Crc32.Compute(input)); - } - - [Fact] - public void KnownAnswer_EmptyInput_IsZero() { - Assert.Equal(0x00000000u, Crc32.Compute(ReadOnlySpan.Empty)); - } - - [Fact] - public void KnownAnswer_SingleZeroByte() { - // CRC-32 of a single 0x00 byte is 0xD202EF8D (standard vector). - Assert.Equal(0xD202EF8Du, Crc32.Compute(new byte[] { 0x00 })); - } -} diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs b/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs deleted file mode 100644 index 717f048c..00000000 --- a/tests/AcDream.Content.Tests/ObjectMeshDataEquality.cs +++ /dev/null @@ -1,221 +0,0 @@ -using System.Linq; -using Chorizite.Core.Render.Enums; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -namespace AcDream.Content.Tests; - -/// -/// Field-by-field deep-equality comparator for and -/// its whole family (, , -/// , ). Used by both the -/// round-trip tests (Task 3) and the dat-gated equivalence suite (Task 6) so a -/// mismatch always names the exact field that diverged instead of just -/// "objects not equal". -/// -public static class ObjectMeshDataEquality { - public static void AssertEqual(ObjectMeshData? expected, ObjectMeshData? actual, string path = "root") { - if (expected is null && actual is null) return; - Assert.True(expected is not null, $"{path}: expected null but actual was non-null"); - Assert.True(actual is not null, $"{path}: expected non-null but actual was null"); - - Assert.True(expected.ObjectId == actual.ObjectId, $"{path}.ObjectId: expected 0x{expected.ObjectId:X16}, got 0x{actual.ObjectId:X16}"); - Assert.True(expected.IsSetup == actual.IsSetup, $"{path}.IsSetup: expected {expected.IsSetup}, got {actual.IsSetup}"); - AssertVerticesEqual(expected.Vertices, actual.Vertices, $"{path}.Vertices"); - - Assert.True(expected.Batches.Count == actual.Batches.Count, - $"{path}.Batches.Count: expected {expected.Batches.Count}, got {actual.Batches.Count}"); - for (int i = 0; i < expected.Batches.Count; i++) - AssertBatchEqual(expected.Batches[i], actual.Batches[i], $"{path}.Batches[{i}]"); - - Assert.True(expected.UploadAttempts == actual.UploadAttempts, - $"{path}.UploadAttempts: expected {expected.UploadAttempts}, got {actual.UploadAttempts}"); - - AssertEqual(expected.EnvCellGeometry, actual.EnvCellGeometry, $"{path}.EnvCellGeometry"); - - Assert.True(expected.SetupParts.Count == actual.SetupParts.Count, - $"{path}.SetupParts.Count: expected {expected.SetupParts.Count}, got {actual.SetupParts.Count}"); - for (int i = 0; i < expected.SetupParts.Count; i++) { - var (eId, eT) = expected.SetupParts[i]; - var (aId, aT) = actual.SetupParts[i]; - Assert.True(eId == aId, $"{path}.SetupParts[{i}].GfxObjId: expected 0x{eId:X16}, got 0x{aId:X16}"); - AssertMatrixEqual(eT, aT, $"{path}.SetupParts[{i}].Transform"); - } - - Assert.True(expected.ParticleEmitters.Count == actual.ParticleEmitters.Count, - $"{path}.ParticleEmitters.Count: expected {expected.ParticleEmitters.Count}, got {actual.ParticleEmitters.Count}"); - for (int i = 0; i < expected.ParticleEmitters.Count; i++) - AssertStagedEmitterEqual(expected.ParticleEmitters[i], actual.ParticleEmitters[i], $"{path}.ParticleEmitters[{i}]"); - - AssertTextureBatchesEqual(expected.TextureBatches, actual.TextureBatches, $"{path}.TextureBatches"); - - AssertBoundingBoxEqual(expected.BoundingBox, actual.BoundingBox, $"{path}.BoundingBox"); - AssertVector3Equal(expected.SortCenter, actual.SortCenter, $"{path}.SortCenter"); - Assert.True(expected.DIDDegrade == actual.DIDDegrade, $"{path}.DIDDegrade: expected {expected.DIDDegrade}, got {actual.DIDDegrade}"); - - AssertSphereEqual(expected.SelectionSphere, actual.SelectionSphere, $"{path}.SelectionSphere"); - AssertVector3ArrayEqual(expected.EdgeLines, actual.EdgeLines, $"{path}.EdgeLines"); - } - - private static void AssertVerticesEqual(VertexPositionNormalTexture[] expected, VertexPositionNormalTexture[] actual, string path) { - Assert.True(expected.Length == actual.Length, $"{path}.Length: expected {expected.Length}, got {actual.Length}"); - for (int i = 0; i < expected.Length; i++) { - AssertVector3Equal(expected[i].Position, actual[i].Position, $"{path}[{i}].Position"); - AssertVector3Equal(expected[i].Normal, actual[i].Normal, $"{path}[{i}].Normal"); - Assert.True(BitEqual(expected[i].UV, actual[i].UV), $"{path}[{i}].UV: expected {expected[i].UV}, got {actual[i].UV}"); - } - } - - private static void AssertBatchEqual(MeshBatchData expected, MeshBatchData actual, string path) { - Assert.True(expected.Indices.SequenceEqual(actual.Indices), - $"{path}.Indices: expected [{string.Join(",", expected.Indices)}], got [{string.Join(",", actual.Indices)}]"); - Assert.True(expected.TextureFormat == actual.TextureFormat, - $"{path}.TextureFormat: expected {expected.TextureFormat}, got {actual.TextureFormat}"); - AssertTextureKeyEqual(expected.TextureKey, actual.TextureKey, $"{path}.TextureKey"); - Assert.True(expected.TextureIndex == actual.TextureIndex, - $"{path}.TextureIndex: expected {expected.TextureIndex}, got {actual.TextureIndex}"); - Assert.True(expected.TextureData.SequenceEqual(actual.TextureData), - $"{path}.TextureData: length expected {expected.TextureData.Length}, got {actual.TextureData.Length}"); - Assert.True(expected.UploadPixelFormat == actual.UploadPixelFormat, - $"{path}.UploadPixelFormat: expected {expected.UploadPixelFormat}, got {actual.UploadPixelFormat}"); - Assert.True(expected.UploadPixelType == actual.UploadPixelType, - $"{path}.UploadPixelType: expected {expected.UploadPixelType}, got {actual.UploadPixelType}"); - Assert.True(expected.CullMode == actual.CullMode, - $"{path}.CullMode: expected {expected.CullMode}, got {actual.CullMode}"); - } - - private static void AssertTextureBatchDataEqual(TextureBatchData expected, TextureBatchData actual, string path) { - AssertTextureKeyEqual(expected.Key, actual.Key, $"{path}.Key"); - Assert.True(expected.TextureData.SequenceEqual(actual.TextureData), - $"{path}.TextureData: length expected {expected.TextureData.Length}, got {actual.TextureData.Length}"); - Assert.True(expected.UploadPixelFormat == actual.UploadPixelFormat, - $"{path}.UploadPixelFormat: expected {expected.UploadPixelFormat}, got {actual.UploadPixelFormat}"); - Assert.True(expected.UploadPixelType == actual.UploadPixelType, - $"{path}.UploadPixelType: expected {expected.UploadPixelType}, got {actual.UploadPixelType}"); - Assert.True(expected.Indices.SequenceEqual(actual.Indices), - $"{path}.Indices: expected [{string.Join(",", expected.Indices)}], got [{string.Join(",", actual.Indices)}]"); - Assert.True(expected.CullMode == actual.CullMode, $"{path}.CullMode: expected {expected.CullMode}, got {actual.CullMode}"); - Assert.True(expected.IsTransparent == actual.IsTransparent, $"{path}.IsTransparent: expected {expected.IsTransparent}, got {actual.IsTransparent}"); - Assert.True(expected.IsAdditive == actual.IsAdditive, $"{path}.IsAdditive: expected {expected.IsAdditive}, got {actual.IsAdditive}"); - Assert.True(expected.HasWrappingUVs == actual.HasWrappingUVs, $"{path}.HasWrappingUVs: expected {expected.HasWrappingUVs}, got {actual.HasWrappingUVs}"); - } - - private static void AssertTextureBatchesEqual( - System.Collections.Generic.Dictionary<(int Width, int Height, TextureFormat Format), System.Collections.Generic.List> expected, - System.Collections.Generic.Dictionary<(int Width, int Height, TextureFormat Format), System.Collections.Generic.List> actual, - string path) { - Assert.True(expected.Count == actual.Count, $"{path}.Count: expected {expected.Count}, got {actual.Count}"); - foreach (var key in expected.Keys) { - Assert.True(actual.ContainsKey(key), $"{path}: missing key {key}"); - var expList = expected[key]; - var actList = actual[key]; - Assert.True(expList.Count == actList.Count, $"{path}[{key}].Count: expected {expList.Count}, got {actList.Count}"); - for (int i = 0; i < expList.Count; i++) - AssertTextureBatchDataEqual(expList[i], actList[i], $"{path}[{key}][{i}]"); - } - } - - private static void AssertStagedEmitterEqual(StagedEmitter expected, StagedEmitter actual, string path) { - Assert.True(expected.PartIndex == actual.PartIndex, $"{path}.PartIndex: expected {expected.PartIndex}, got {actual.PartIndex}"); - AssertMatrixEqual(expected.Offset, actual.Offset, $"{path}.Offset"); - AssertParticleEmitterEqual(expected.Emitter, actual.Emitter, $"{path}.Emitter"); - } - - private static void AssertParticleEmitterEqual(ParticleEmitter? expected, ParticleEmitter? actual, string path) { - if (expected is null && actual is null) return; - Assert.True(expected is not null, $"{path}: expected null but actual was non-null"); - Assert.True(actual is not null, $"{path}: expected non-null but actual was null"); - - Assert.True(expected.Id == actual.Id, $"{path}.Id: expected 0x{expected.Id:X8}, got 0x{actual.Id:X8}"); - Assert.True(expected.DataCategory == actual.DataCategory, $"{path}.DataCategory: expected {expected.DataCategory}, got {actual.DataCategory}"); - Assert.True(expected.Unknown == actual.Unknown, $"{path}.Unknown: expected {expected.Unknown}, got {actual.Unknown}"); - Assert.True(expected.EmitterType == actual.EmitterType, $"{path}.EmitterType: expected {expected.EmitterType}, got {actual.EmitterType}"); - Assert.True(expected.ParticleType == actual.ParticleType, $"{path}.ParticleType: expected {expected.ParticleType}, got {actual.ParticleType}"); - Assert.True(expected.GfxObjId.DataId == actual.GfxObjId.DataId, $"{path}.GfxObjId: expected 0x{expected.GfxObjId.DataId:X8}, got 0x{actual.GfxObjId.DataId:X8}"); - Assert.True(expected.HwGfxObjId.DataId == actual.HwGfxObjId.DataId, $"{path}.HwGfxObjId: expected 0x{expected.HwGfxObjId.DataId:X8}, got 0x{actual.HwGfxObjId.DataId:X8}"); - Assert.True(BitEqual(expected.Birthrate, actual.Birthrate), $"{path}.Birthrate: expected {expected.Birthrate}, got {actual.Birthrate}"); - Assert.True(expected.MaxParticles == actual.MaxParticles, $"{path}.MaxParticles: expected {expected.MaxParticles}, got {actual.MaxParticles}"); - Assert.True(expected.InitialParticles == actual.InitialParticles, $"{path}.InitialParticles: expected {expected.InitialParticles}, got {actual.InitialParticles}"); - Assert.True(expected.TotalParticles == actual.TotalParticles, $"{path}.TotalParticles: expected {expected.TotalParticles}, got {actual.TotalParticles}"); - Assert.True(BitEqual(expected.TotalSeconds, actual.TotalSeconds), $"{path}.TotalSeconds: expected {expected.TotalSeconds}, got {actual.TotalSeconds}"); - Assert.True(BitEqual(expected.Lifespan, actual.Lifespan), $"{path}.Lifespan: expected {expected.Lifespan}, got {actual.Lifespan}"); - Assert.True(BitEqual(expected.LifespanRand, actual.LifespanRand), $"{path}.LifespanRand: expected {expected.LifespanRand}, got {actual.LifespanRand}"); - AssertVector3Equal(expected.OffsetDir, actual.OffsetDir, $"{path}.OffsetDir"); - Assert.True(BitEqual(expected.MinOffset, actual.MinOffset), $"{path}.MinOffset: expected {expected.MinOffset}, got {actual.MinOffset}"); - Assert.True(BitEqual(expected.MaxOffset, actual.MaxOffset), $"{path}.MaxOffset: expected {expected.MaxOffset}, got {actual.MaxOffset}"); - AssertVector3Equal(expected.A, actual.A, $"{path}.A"); - Assert.True(BitEqual(expected.MinA, actual.MinA), $"{path}.MinA: expected {expected.MinA}, got {actual.MinA}"); - Assert.True(BitEqual(expected.MaxA, actual.MaxA), $"{path}.MaxA: expected {expected.MaxA}, got {actual.MaxA}"); - AssertVector3Equal(expected.B, actual.B, $"{path}.B"); - Assert.True(BitEqual(expected.MinB, actual.MinB), $"{path}.MinB: expected {expected.MinB}, got {actual.MinB}"); - Assert.True(BitEqual(expected.MaxB, actual.MaxB), $"{path}.MaxB: expected {expected.MaxB}, got {actual.MaxB}"); - AssertVector3Equal(expected.C, actual.C, $"{path}.C"); - Assert.True(BitEqual(expected.MinC, actual.MinC), $"{path}.MinC: expected {expected.MinC}, got {actual.MinC}"); - Assert.True(BitEqual(expected.MaxC, actual.MaxC), $"{path}.MaxC: expected {expected.MaxC}, got {actual.MaxC}"); - Assert.True(BitEqual(expected.StartScale, actual.StartScale), $"{path}.StartScale: expected {expected.StartScale}, got {actual.StartScale}"); - Assert.True(BitEqual(expected.FinalScale, actual.FinalScale), $"{path}.FinalScale: expected {expected.FinalScale}, got {actual.FinalScale}"); - Assert.True(BitEqual(expected.ScaleRand, actual.ScaleRand), $"{path}.ScaleRand: expected {expected.ScaleRand}, got {actual.ScaleRand}"); - Assert.True(BitEqual(expected.StartTrans, actual.StartTrans), $"{path}.StartTrans: expected {expected.StartTrans}, got {actual.StartTrans}"); - Assert.True(BitEqual(expected.FinalTrans, actual.FinalTrans), $"{path}.FinalTrans: expected {expected.FinalTrans}, got {actual.FinalTrans}"); - Assert.True(BitEqual(expected.TransRand, actual.TransRand), $"{path}.TransRand: expected {expected.TransRand}, got {actual.TransRand}"); - Assert.True(expected.IsParentLocal == actual.IsParentLocal, $"{path}.IsParentLocal: expected {expected.IsParentLocal}, got {actual.IsParentLocal}"); - } - - private static void AssertTextureKeyEqual(TextureKey expected, TextureKey actual, string path) { - Assert.True(expected.Equals(actual), - $"{path}: expected SurfaceId=0x{expected.SurfaceId:X8} PaletteId=0x{expected.PaletteId:X8} Stippling={expected.Stippling} IsSolid={expected.IsSolid}, " + - $"got SurfaceId=0x{actual.SurfaceId:X8} PaletteId=0x{actual.PaletteId:X8} Stippling={actual.Stippling} IsSolid={actual.IsSolid}"); - } - - private static void AssertBoundingBoxEqual(Chorizite.Core.Lib.BoundingBox expected, Chorizite.Core.Lib.BoundingBox actual, string path) { - AssertVector3Equal(expected.Min, actual.Min, $"{path}.Min"); - AssertVector3Equal(expected.Max, actual.Max, $"{path}.Max"); - } - - private static void AssertSphereEqual(Sphere? expected, Sphere? actual, string path) { - if (expected is null && actual is null) return; - Assert.True(expected is not null, $"{path}: expected null but actual was non-null"); - Assert.True(actual is not null, $"{path}: expected non-null but actual was null"); - AssertVector3Equal(expected.Origin, actual.Origin, $"{path}.Origin"); - Assert.True(BitEqual(expected.Radius, actual.Radius), $"{path}.Radius: expected {expected.Radius}, got {actual.Radius}"); - } - - private static void AssertVector3Equal(System.Numerics.Vector3 expected, System.Numerics.Vector3 actual, string path) { - Assert.True(BitEqual(expected, actual), $"{path}: expected {expected}, got {actual}"); - } - - private static void AssertVector3ArrayEqual(System.Numerics.Vector3[] expected, System.Numerics.Vector3[] actual, string path) { - Assert.True(expected.Length == actual.Length, $"{path}.Length: expected {expected.Length}, got {actual.Length}"); - for (int i = 0; i < expected.Length; i++) - AssertVector3Equal(expected[i], actual[i], $"{path}[{i}]"); - } - - private static void AssertMatrixEqual(System.Numerics.Matrix4x4 expected, System.Numerics.Matrix4x4 actual, string path) { - Assert.True(BitEqual(expected, actual), $"{path}: expected {expected}, got {actual}"); - } - - // ---- bitwise float equality ---------------------------------------------- - // The pak stores raw IEEE-754 bits, so "round-trip preserved the field" - // means BIT equality, not `==` semantics: `==` is false for NaN==NaN - // (a NaN payload surviving the round-trip would FAIL a correct - // serializer) and true for -0.0==+0.0 (a sign-bit flip would silently - // PASS). Review finding 9. - - private static bool BitEqual(float a, float b) => - BitConverter.SingleToInt32Bits(a) == BitConverter.SingleToInt32Bits(b); - - private static bool BitEqual(double a, double b) => - BitConverter.DoubleToInt64Bits(a) == BitConverter.DoubleToInt64Bits(b); - - private static bool BitEqual(System.Numerics.Vector2 a, System.Numerics.Vector2 b) => - BitEqual(a.X, b.X) && BitEqual(a.Y, b.Y); - - private static bool BitEqual(System.Numerics.Vector3 a, System.Numerics.Vector3 b) => - BitEqual(a.X, b.X) && BitEqual(a.Y, b.Y) && BitEqual(a.Z, b.Z); - - private static bool BitEqual(System.Numerics.Matrix4x4 a, System.Numerics.Matrix4x4 b) => - BitEqual(a.M11, b.M11) && BitEqual(a.M12, b.M12) && BitEqual(a.M13, b.M13) && BitEqual(a.M14, b.M14) && - BitEqual(a.M21, b.M21) && BitEqual(a.M22, b.M22) && BitEqual(a.M23, b.M23) && BitEqual(a.M24, b.M24) && - BitEqual(a.M31, b.M31) && BitEqual(a.M32, b.M32) && BitEqual(a.M33, b.M33) && BitEqual(a.M34, b.M34) && - BitEqual(a.M41, b.M41) && BitEqual(a.M42, b.M42) && BitEqual(a.M43, b.M43) && BitEqual(a.M44, b.M44); -} diff --git a/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs b/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs deleted file mode 100644 index 8df8a0c6..00000000 --- a/tests/AcDream.Content.Tests/ObjectMeshDataSerializerTests.cs +++ /dev/null @@ -1,297 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Numerics; -using AcDream.Content.Pak; -using Chorizite.Core.Lib; -using Chorizite.Core.Render.Enums; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; -using CullMode = DatReaderWriter.Enums.CullMode; -using StipplingType = DatReaderWriter.Enums.StipplingType; -using EmitterType = DatReaderWriter.Enums.EmitterType; -using ParticleType = DatReaderWriter.Enums.ParticleType; - -namespace AcDream.Content.Tests; - -public class ObjectMeshDataSerializerTests { - // ---- fixture builders --------------------------------------------------- - - private static ObjectMeshData EmptyObject() => new() { - ObjectId = 0x0100_0001u, - IsSetup = false, - }; - - private static ObjectMeshData VerticesAndIndicesOnly() { - var data = new ObjectMeshData { - ObjectId = 0x0100_0002u, - IsSetup = false, - Vertices = new[] { - new VertexPositionNormalTexture(new Vector3(1, 2, 3), new Vector3(0, 0, 1), new Vector2(0, 0)), - new VertexPositionNormalTexture(new Vector3(4, 5, 6), new Vector3(0, 1, 0), new Vector2(1, 0)), - new VertexPositionNormalTexture(new Vector3(7, 8, 9), new Vector3(1, 0, 0), new Vector2(1, 1)), - }, - BoundingBox = new BoundingBox(new Vector3(1, 2, 3), new Vector3(7, 8, 9)), - SortCenter = new Vector3(4, 5, 6), - DIDDegrade = 0x11223344, - }; - data.Batches.Add(new MeshBatchData { - Indices = new ushort[] { 0, 1, 2 }, - TextureFormat = (64, 64, TextureFormat.RGBA8), - TextureKey = new TextureKey { SurfaceId = 0x08000001, PaletteId = 0x04000001, Stippling = StipplingType.Both, IsSolid = true }, - TextureIndex = 0, - TextureData = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 }, - UploadPixelFormat = AcDream.Content.UploadPixelFormat.Rgba, - UploadPixelType = AcDream.Content.UploadPixelType.UnsignedByte, - CullMode = CullMode.Clockwise, - }); - return data; - } - - private static ObjectMeshData MultipleTextureBatchGroups() { - var data = EmptyObject(); - data.ObjectId = 0x0100_0003u; - - TextureBatchData Batch(uint surfaceId, string tag) => new() { - Key = new TextureKey { SurfaceId = surfaceId, PaletteId = 1, Stippling = StipplingType.Positive, IsSolid = false }, - TextureData = System.Text.Encoding.ASCII.GetBytes(tag), - UploadPixelFormat = AcDream.Content.UploadPixelFormat.Rgba, - UploadPixelType = AcDream.Content.UploadPixelType.UnsignedByte, - Indices = new List { 0, 1, 2, 2, 3, 0 }, - CullMode = CullMode.CounterClockwise, - IsTransparent = true, - IsAdditive = false, - HasWrappingUVs = true, - }; - - data.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List { Batch(1, "a"), Batch(2, "b") }; - data.TextureBatches[(64, 64, TextureFormat.DXT5)] = new List { Batch(3, "c") }; - data.TextureBatches[(16, 16, TextureFormat.A8)] = new List { Batch(4, "d"), Batch(5, "e"), Batch(6, "f") }; - return data; - } - - private static ObjectMeshData SetupWithParts() { - var data = EmptyObject(); - data.ObjectId = 0x0200_0001u; - data.IsSetup = true; - data.SetupParts.Add((0x0100_0010u, Matrix4x4.CreateTranslation(1, 2, 3))); - data.SetupParts.Add((0x0100_0011u, Matrix4x4.CreateFromYawPitchRoll(0.1f, 0.2f, 0.3f))); - return data; - } - - private static ParticleEmitter BuildEmitter(uint id) => new() { - Id = id, - DataCategory = 0x2A, - Unknown = 7, - EmitterType = EmitterType.BirthratePerSec, - ParticleType = ParticleType.Explode, - GfxObjId = new QualifiedDataId { DataId = 0x0100_0099u }, - HwGfxObjId = new QualifiedDataId { DataId = 0x0100_009Au }, - Birthrate = 2.5, - MaxParticles = 40, - InitialParticles = 5, - TotalParticles = 100, - TotalSeconds = 3.0, - Lifespan = 1.5, - LifespanRand = 0.25, - OffsetDir = new Vector3(0, 0, 1), - MinOffset = 0.1f, - MaxOffset = 0.5f, - A = new Vector3(1, 0, 0), - MinA = 0.9f, - MaxA = 1.1f, - B = new Vector3(0, 1, 0), - MinB = 0.8f, - MaxB = 1.2f, - C = new Vector3(0, 0, 1), - MinC = 0.7f, - MaxC = 1.3f, - StartScale = 0.5f, - FinalScale = 1.5f, - ScaleRand = 0.05f, - StartTrans = 1f, - FinalTrans = 0f, - TransRand = 0.1f, - IsParentLocal = true, - }; - - private static ObjectMeshData WithEmitters() { - var data = EmptyObject(); - data.ObjectId = 0x0200_0002u; - data.IsSetup = true; - data.ParticleEmitters.Add(new StagedEmitter { - Emitter = BuildEmitter(0x2A00_0001u), - PartIndex = 3, - Offset = Matrix4x4.CreateTranslation(10, 20, 30), - }); - data.ParticleEmitters.Add(new StagedEmitter { - Emitter = BuildEmitter(0x2A00_0002u), - PartIndex = 0, - Offset = Matrix4x4.Identity, - }); - return data; - } - - private static ObjectMeshData WithNullableFieldsPresent() { - var data = EmptyObject(); - data.ObjectId = 0x0300_0001u; - data.SelectionSphere = new Sphere { Origin = new Vector3(1, 1, 1), Radius = 2.5f }; - data.Batches.Add(new MeshBatchData { - Indices = new ushort[] { 0 }, - UploadPixelFormat = AcDream.Content.UploadPixelFormat.Rgba, - UploadPixelType = AcDream.Content.UploadPixelType.UnsignedByte, - }); - return data; - } - - private static ObjectMeshData WithNullableFieldsAbsent() { - var data = EmptyObject(); - data.ObjectId = 0x0300_0002u; - data.SelectionSphere = null; - data.Batches.Add(new MeshBatchData { - Indices = new ushort[] { 0 }, - UploadPixelFormat = null, - UploadPixelType = null, - }); - return data; - } - - private static ObjectMeshData WithEdgeLines() { - var data = EmptyObject(); - data.ObjectId = 0x0400_0001u; - data.EdgeLines = new[] { - new Vector3(0, 0, 0), new Vector3(1, 0, 0), - new Vector3(1, 0, 0), new Vector3(1, 1, 0), - }; - return data; - } - - private static ObjectMeshData WithNestedEnvCellGeometry() { - var data = EmptyObject(); - data.ObjectId = 0x0D00_0001_0000_0100u | (1UL << 32); - data.IsSetup = true; - data.EnvCellGeometry = VerticesAndIndicesOnly(); - return data; - } - - public static IEnumerable AllFixtures() { - yield return new object[] { EmptyObject() }; - yield return new object[] { VerticesAndIndicesOnly() }; - yield return new object[] { MultipleTextureBatchGroups() }; - yield return new object[] { SetupWithParts() }; - yield return new object[] { WithEmitters() }; - yield return new object[] { WithNullableFieldsPresent() }; - yield return new object[] { WithNullableFieldsAbsent() }; - yield return new object[] { WithEdgeLines() }; - yield return new object[] { WithNestedEnvCellGeometry() }; - } - - // ---- round-trip tests ---------------------------------------------------- - - [Theory] - [MemberData(nameof(AllFixtures))] - public void RoundTrip_PreservesEveryField(ObjectMeshData original) { - using var ms = new MemoryStream(); - ObjectMeshDataSerializer.Write(original, ms); - var bytes = ms.ToArray(); - - var readBack = ObjectMeshDataSerializer.Read(bytes); - - ObjectMeshDataEquality.AssertEqual(original, readBack); - } - - // ---- determinism ----------------------------------------------------- - - [Fact] - public void Serialize_SameInstanceTwice_ByteIdentical() { - var data = MultipleTextureBatchGroups(); - - using var ms1 = new MemoryStream(); - ObjectMeshDataSerializer.Write(data, ms1); - - using var ms2 = new MemoryStream(); - ObjectMeshDataSerializer.Write(data, ms2); - - Assert.Equal(ms1.ToArray(), ms2.ToArray()); - } - - [Fact] - public void Serialize_DictionaryInsertedInDifferentOrders_ByteIdentical() { - TextureBatchData Batch(uint surfaceId) => new() { - Key = new TextureKey { SurfaceId = surfaceId, PaletteId = 1, Stippling = StipplingType.None, IsSolid = false }, - TextureData = new byte[] { (byte)surfaceId }, - Indices = new List { 0, 1, 2 }, - CullMode = CullMode.None, - }; - - var a = EmptyObject(); - a.ObjectId = 0x0500_0001u; - a.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List { Batch(1) }; - a.TextureBatches[(64, 64, TextureFormat.DXT5)] = new List { Batch(2) }; - a.TextureBatches[(16, 16, TextureFormat.A8)] = new List { Batch(3) }; - - var b = EmptyObject(); - b.ObjectId = 0x0500_0001u; - // Insert in a completely different order. - b.TextureBatches[(16, 16, TextureFormat.A8)] = new List { Batch(3) }; - b.TextureBatches[(32, 32, TextureFormat.RGBA8)] = new List { Batch(1) }; - b.TextureBatches[(64, 64, TextureFormat.DXT5)] = new List { Batch(2) }; - - using var msA = new MemoryStream(); - ObjectMeshDataSerializer.Write(a, msA); - using var msB = new MemoryStream(); - ObjectMeshDataSerializer.Write(b, msB); - - Assert.Equal(msA.ToArray(), msB.ToArray()); - } - - [Fact] - public void Write_SortsTextureBatchesByWidthHeightFormatKeyTuple() { - // Insert in scrambled order; the serialized bytes must reflect the - // KEY-sorted order (Width, Height, Format), not insertion order. - var data = EmptyObject(); - data.ObjectId = 0x0600_0001u; - - // Each batch's TextureData is a distinctive multi-byte marker (not a - // single ambiguous byte value that could collide with unrelated - // length-prefix / width / height bytes elsewhere in the stream). - TextureBatchData Batch(byte[] marker) => new() { - Key = default, - TextureData = marker, - Indices = new List(), - CullMode = CullMode.None, - }; - - byte[] markerA = { 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA }; - byte[] markerB = { 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB }; - byte[] markerC = { 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC, 0xCC }; - - data.TextureBatches[(100, 1, TextureFormat.RGBA8)] = new List { Batch(markerC) }; - data.TextureBatches[(1, 1, TextureFormat.RGBA8)] = new List { Batch(markerA) }; - data.TextureBatches[(1, 100, TextureFormat.RGBA8)] = new List { Batch(markerB) }; - - using var ms = new MemoryStream(); - ObjectMeshDataSerializer.Write(data, ms); - var bytes = ms.ToArray(); - - // markerA (width=1,height=1) < markerB (width=1,height=100) < - // markerC (width=100,height=1) in ascending (Width, Height) order. - int iA = IndexOfSequence(bytes, markerA); - int iB = IndexOfSequence(bytes, markerB); - int iC = IndexOfSequence(bytes, markerC); - Assert.True(iA >= 0 && iB >= 0 && iC >= 0, "all three markers must appear in the stream"); - Assert.True(iA < iB, $"markerA (width=1,height=1) must precede markerB (width=1,height=100): iA={iA} iB={iB}"); - Assert.True(iB < iC, $"markerB (width=1,height=100) must precede markerC (width=100,height=1): iB={iB} iC={iC}"); - } - - private static int IndexOfSequence(byte[] haystack, byte[] needle) { - for (int i = 0; i <= haystack.Length - needle.Length; i++) { - bool match = true; - for (int j = 0; j < needle.Length; j++) { - if (haystack[i + j] != needle[j]) { match = false; break; } - } - if (match) return i; - } - return -1; - } -} diff --git a/tests/AcDream.Content.Tests/PakEquivalenceTests.cs b/tests/AcDream.Content.Tests/PakEquivalenceTests.cs deleted file mode 100644 index e42567f3..00000000 --- a/tests/AcDream.Content.Tests/PakEquivalenceTests.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Linq; -using AcDream.Content.Pak; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; - -namespace AcDream.Content.Tests; - -/// -/// MP1b Task 6: dat-gated live-vs-pak equivalence suite. Runs MeshExtractor -/// LIVE and bakes the SAME ids to a temp pak, reads the pak back, and deep- -/// compares field-by-field via the Task 3 comparator. Because the bake tool -/// and the live client drive the identical MeshExtractor code (MP1a), this -/// test is proving the pak ROUND-TRIP (serialize/deserialize) preserves -/// what extraction actually produces on real content — not re-verifying the -/// extraction algorithm itself (that's the existing Conformance suite's job). -/// -/// Skips cleanly when the real dats are absent (CI), matching -/// DatConcurrencyStressTests' convention. -/// -public sealed class PakEquivalenceTests { - // Known-tricky GfxObj ids reused from existing conformance fixtures: - // 0x010002B4 / 0x010008A8 — #119 "[up-null] upload returned null" dump - // targets (Issue119UpNullGfxObjDumpTests). - // 0x010014C3 — the #113-saga Holtburg meeting-hall shell - // (Issue119UpNullGfxObjDumpTests.ShellModel_NoTexturedPolyIsDropped). - private static readonly uint[] KnownTrickyGfxObjIds = { 0x010002B4u, 0x010008A8u, 0x010014C3u }; - - // Setup ids reused from existing physics/conformance fixtures: - // 0x020019FF — the door setup (DoorBugTrajectoryReplayTests, - // DoorSetupGfxObjInspectionTests, DoorCollisionApparatusTests). - // 0x020005D8 / 0x020003F2 — Issue119TowerDumpTests fixtures. - private static readonly uint[] SetupIds = { 0x020019FFu, 0x020005D8u, 0x020003F2u }; - - private const uint HoltburgLandblock = 0xA9B40000u; // ConformanceDats.HoltburgLandblock - - [Fact] - public void LiveExtraction_MatchesPakRoundTrip_OnFixtureIdSet() { - var datDir = ContentConformanceDats.ResolveDatDir(); - if (datDir is null) return; // dats absent (CI) — skip, matching suite convention - - using var dats = new DatCollection(datDir, DatAccessType.Read); - // The unified AcDream.Content.DatCollectionAdapter — same class the - // client and the bake tool use (MP1b review finding 7). - using var datReaderWriter = new DatCollectionAdapter(dats); - var logger = new TestConsoleLogger(); - - var sideStaged = new List(); - var extractor = new MeshExtractor(datReaderWriter, logger, data => sideStaged.Add(data)); - - var gfxObjIds = BuildGfxObjIdSet(dats); - var setupIds = SetupIds.ToList(); - var envCellIds = BuildEnvCellIdSet(dats, HoltburgLandblock, minCount: 5); - - Assert.True(gfxObjIds.Count >= 10, $"fixture GfxObj id set unexpectedly small ({gfxObjIds.Count})"); - Assert.True(setupIds.Count >= 3, $"fixture Setup id set unexpectedly small ({setupIds.Count})"); - Assert.True(envCellIds.Count >= 5, $"fixture EnvCell id set unexpectedly small ({envCellIds.Count})"); - - var work = new List<(PakAssetType Type, uint FileId, ulong ExtractorId, bool IsSetup)>(); - work.AddRange(gfxObjIds.Select(id => (PakAssetType.GfxObjMesh, id, (ulong)id, false))); - work.AddRange(setupIds.Select(id => (PakAssetType.SetupMesh, id, (ulong)id, true))); - // isSetup: false for EnvCell — matches the runtime's own request sites - // (WbMeshAdapter.IncrementRefCount/EnsureLoaded pass isSetup: false for - // every MeshRef id, incl. cell-geometry ids) and BakeRunner's call site. - // (Review finding 10 — the two call sites previously disagreed.) - work.AddRange(envCellIds.Select(id => (PakAssetType.EnvCellMesh, id, id | 0x1_0000_0000UL, false))); - - // ---- LIVE extraction (golden) ---- - var golden = new Dictionary<(PakAssetType, uint), ObjectMeshData>(); - var extractionFailures = new List(); - foreach (var (type, fileId, extractorId, isSetup) in work) { - var data = extractor.PrepareMeshData(extractorId, isSetup); - if (data is null) { - extractionFailures.Add($"{type} 0x{fileId:X8}: live extraction returned null"); - continue; - } - golden[(type, fileId)] = data; - } - - Assert.True(extractionFailures.Count == 0, - $"{extractionFailures.Count} fixture ids failed LIVE extraction (fixture assumption broke): " + - string.Join(" | ", extractionFailures)); - - // ---- bake the SAME ids to a temp pak ---- - var pakPath = Path.Combine(Path.GetTempPath(), $"acdream-equivtest-{System.Guid.NewGuid():N}.pak"); - try { - var header = new PakHeader { - FormatVersion = 1, - PortalIteration = (uint)dats.Portal.Iteration!.CurrentIteration, - CellIteration = (uint)dats.Cell.Iteration!.CurrentIteration, - HighResIteration = (uint)dats.HighRes.Iteration!.CurrentIteration, - LanguageIteration = (uint)dats.Local.Iteration!.CurrentIteration, - BakeToolVersion = 1, - }; - using (var writer = new PakWriter(pakPath, header)) { - foreach (var ((type, fileId), data) in golden) { - writer.AddBlob(PakKey.Compose(type, fileId), data); - } - writer.Finish(); - } - - // ---- read back and deep-compare ---- - using var reader = new PakReader(pakPath); - var mismatches = new List(); - foreach (var ((type, fileId), expected) in golden) { - var key = PakKey.Compose(type, fileId); - if (!reader.TryReadObjectMeshData(key, out var actual)) { - mismatches.Add($"{type} 0x{fileId:X8}: pak read failed (missing or CRC mismatch)"); - continue; - } - try { - ObjectMeshDataEquality.AssertEqual(expected, actual); - } - catch (Xunit.Sdk.XunitException ex) { - mismatches.Add($"{type} 0x{fileId:X8}: {ex.Message}"); - } - } - - Assert.True(mismatches.Count == 0, - $"{mismatches.Count}/{golden.Count} fixture ids mismatched between live extraction and pak round-trip:\n" + - string.Join("\n", mismatches)); - } - finally { - if (File.Exists(pakPath)) File.Delete(pakPath); - } - } - - /// - /// Known-tricky ids (#119/#113 dump fixtures) plus enough additional - /// GfxObjs (deterministically, the first N ids in dat order after the - /// tricky set) to reach at least 10 total. - /// - private static List BuildGfxObjIdSet(DatCollection dats) { - var ids = new List(KnownTrickyGfxObjIds); - var seen = new HashSet(ids); - - foreach (var id in dats.GetAllIdsOfType()) { - if (ids.Count >= 10) break; - if (seen.Add(id)) ids.Add(id); - } - return ids; - } - - /// Walks the Holtburg landblock's LandBlockInfo.NumCells range (mirrors StipplingSurfaceEquivalenceTests' enumeration) to get at least real EnvCell ids. - private static List BuildEnvCellIdSet(DatCollection dats, uint landblockId, int minCount) { - var ids = new List(); - var lbInfo = dats.Get(landblockId | 0xFFFEu); - Assert.True(lbInfo is not null, $"LandBlockInfo for landblock 0x{landblockId:X8} not found — fixture assumption broke"); - Assert.True(lbInfo!.NumCells >= minCount, - $"landblock 0x{landblockId:X8} has only {lbInfo.NumCells} cells — fixture assumption broke (need >= {minCount})"); - - uint firstCellId = landblockId | 0x0100u; - for (uint offset = 0; offset < lbInfo.NumCells && ids.Count < minCount; offset++) { - uint envCellId = firstCellId + offset; - if (dats.Get(envCellId) is not null) ids.Add(envCellId); - } - return ids; - } -} diff --git a/tests/AcDream.Content.Tests/PakFormatTests.cs b/tests/AcDream.Content.Tests/PakFormatTests.cs deleted file mode 100644 index 4eb1181d..00000000 --- a/tests/AcDream.Content.Tests/PakFormatTests.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System.IO; -using AcDream.Content.Pak; - -namespace AcDream.Content.Tests; - -public class PakFormatTests { - [Fact] - public void Header_Size_Is64Bytes() { - Assert.Equal(64, PakHeader.Size); - } - - [Fact] - public void TocEntry_Size_Is24Bytes() { - Assert.Equal(24, PakTocEntry.Size); - } - - [Fact] - public void Header_Magic_IsAcpkLittleEndian() { - // 'A'=0x41 'C'=0x43 'P'=0x50 'K'=0x4B — little-endian dword reads - // back as 0x4B504341 per the normative spec. - Assert.Equal(0x4B504341u, PakHeader.MagicValue); - } - - [Fact] - public void Header_WriteThenRead_RoundTripsEveryField() { - var header = new PakHeader { - FormatVersion = 1, - PortalIteration = 111, - CellIteration = 222, - HighResIteration = 333, - LanguageIteration = 444, - TocOffset = 0x1_0000_0002UL, - TocCount = 777, - BakeToolVersion = 1, - }; - - using var ms = new MemoryStream(); - header.WriteTo(ms); - Assert.Equal(PakHeader.Size, ms.Length); - - ms.Position = 0; - var readBack = PakHeader.ReadFrom(ms); - - Assert.Equal(PakHeader.MagicValue, readBack.Magic); - Assert.Equal(header.FormatVersion, readBack.FormatVersion); - Assert.Equal(header.PortalIteration, readBack.PortalIteration); - Assert.Equal(header.CellIteration, readBack.CellIteration); - Assert.Equal(header.HighResIteration, readBack.HighResIteration); - Assert.Equal(header.LanguageIteration, readBack.LanguageIteration); - Assert.Equal(header.TocOffset, readBack.TocOffset); - Assert.Equal(header.TocCount, readBack.TocCount); - Assert.Equal(header.BakeToolVersion, readBack.BakeToolVersion); - } - - [Fact] - public void Header_ReadFrom_Span_RoundTrips() { - var header = new PakHeader { - FormatVersion = 1, - PortalIteration = 5, - CellIteration = 6, - HighResIteration = 7, - LanguageIteration = 8, - TocOffset = 999, - TocCount = 3, - BakeToolVersion = 1, - }; - Span buf = stackalloc byte[PakHeader.Size]; - header.WriteTo(buf); - var readBack = PakHeader.ReadFrom((ReadOnlySpan)buf); - Assert.Equal(header.CellIteration, readBack.CellIteration); - Assert.Equal(header.TocOffset, readBack.TocOffset); - } - - [Fact] - public void Header_ReservedBytes_AreZero() { - var header = new PakHeader { FormatVersion = 1, BakeToolVersion = 1 }; - Span buf = stackalloc byte[PakHeader.Size]; - header.WriteTo(buf); - // offset 40, length 24 per the normative layout - for (int i = 40; i < 64; i++) { - Assert.Equal(0, buf[i]); - } - } - - [Fact] - public void Header_FieldOffsets_MatchNormativeLayout() { - var header = new PakHeader { - FormatVersion = 0x11111111, - PortalIteration = 0x22222222, - CellIteration = 0x33333333, - HighResIteration = 0x44444444, - LanguageIteration = 0x55555555, - TocOffset = 0x6666666677777777UL, - TocCount = 0x88888888, - BakeToolVersion = 0x99999999, - }; - Span buf = stackalloc byte[PakHeader.Size]; - header.WriteTo(buf); - - Assert.Equal(PakHeader.MagicValue, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[0..4])); - Assert.Equal(0x11111111u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[4..8])); - Assert.Equal(0x22222222u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[8..12])); - Assert.Equal(0x33333333u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[12..16])); - Assert.Equal(0x44444444u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[16..20])); - Assert.Equal(0x55555555u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[20..24])); - Assert.Equal(0x6666666677777777UL, System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(buf[24..32])); - Assert.Equal(0x88888888u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[32..36])); - Assert.Equal(0x99999999u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[36..40])); - } - - [Fact] - public void TocEntry_WriteThenRead_RoundTrips() { - var entry = new PakTocEntry { - Key = PakKey.Compose(PakAssetType.GfxObjMesh, 0x010002B4u), - Offset = 0x4000, - Length = 12345, - Crc32 = 0xDEADBEEF, - }; - Span buf = stackalloc byte[PakTocEntry.Size]; - entry.WriteTo(buf); - var readBack = PakTocEntry.ReadFrom((ReadOnlySpan)buf); - - Assert.Equal(entry.Key, readBack.Key); - Assert.Equal(entry.Offset, readBack.Offset); - Assert.Equal(entry.Length, readBack.Length); - Assert.Equal(entry.Crc32, readBack.Crc32); - } - - [Fact] - public void TocEntry_FieldOffsets_MatchNormativeLayout() { - var entry = new PakTocEntry { - Key = 0x1111111122222222UL, - Offset = 0x3333333344444444UL, - Length = 0x55555555, - Crc32 = 0x66666666, - }; - Span buf = stackalloc byte[PakTocEntry.Size]; - entry.WriteTo(buf); - - Assert.Equal(0x1111111122222222UL, System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(buf[0..8])); - Assert.Equal(0x3333333344444444UL, System.Buffers.Binary.BinaryPrimitives.ReadUInt64LittleEndian(buf[8..16])); - Assert.Equal(0x55555555u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[16..20])); - Assert.Equal(0x66666666u, System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(buf[20..24])); - } -} diff --git a/tests/AcDream.Content.Tests/PakKeyTests.cs b/tests/AcDream.Content.Tests/PakKeyTests.cs deleted file mode 100644 index ba527ac5..00000000 --- a/tests/AcDream.Content.Tests/PakKeyTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -using AcDream.Content.Pak; - -namespace AcDream.Content.Tests; - -public class PakKeyTests { - [Theory] - [InlineData(PakAssetType.GfxObjMesh, 0u)] - [InlineData(PakAssetType.GfxObjMesh, 0x01000001u)] - [InlineData(PakAssetType.SetupMesh, 0x020019FFu)] - [InlineData(PakAssetType.EnvCellMesh, 0xA9B40100u)] - [InlineData(PakAssetType.GfxObjMesh, 0xFFFFFFFFu)] // max fileId - [InlineData(PakAssetType.EnvCellMesh, 0xFFFFFFFFu)] - public void ComposeDecompose_RoundTrips(PakAssetType type, uint fileId) { - ulong key = PakKey.Compose(type, fileId); - var (decodedType, decodedFileId) = PakKey.Decompose(key); - Assert.Equal(type, decodedType); - Assert.Equal(fileId, decodedFileId); - } - - [Fact] - public void Compose_LowFourBytesReserved() { - // Low 24 bits are reserved (zero in v1) — fileId << 24 must not spill - // into them, and the reserved region must actually read back as zero. - ulong key = PakKey.Compose(PakAssetType.GfxObjMesh, 0xFFFFFFFFu); - Assert.Equal(0u, (uint)(key & 0xFFFFFFu)); - } - - [Fact] - public void Compose_TypeOccupiesTopByte() { - ulong key = PakKey.Compose(PakAssetType.SetupMesh, 0u); - Assert.Equal((byte)PakAssetType.SetupMesh, (byte)(key >> 56)); - } - - [Theory] - [InlineData(PakAssetType.GfxObjMesh, 1)] - [InlineData(PakAssetType.SetupMesh, 2)] - [InlineData(PakAssetType.EnvCellMesh, 3)] - public void AssetType_NumericValuesAreStable(PakAssetType type, byte expected) { - // These values are a wire format — pin them so a future refactor can't - // silently renumber the enum and corrupt existing paks. - Assert.Equal(expected, (byte)type); - } - - [Fact] - public void KeyOrdering_MatchesTypeThenFileIdOrdering() { - // Ascending key order must equal ascending (type, fileId) tuple order — - // this is what makes the TOC's binary search over raw u64 keys valid. - var pairs = new (PakAssetType Type, uint FileId)[] { - (PakAssetType.GfxObjMesh, 0u), - (PakAssetType.GfxObjMesh, 1u), - (PakAssetType.GfxObjMesh, 0xFFFFFFFFu), - (PakAssetType.SetupMesh, 0u), - (PakAssetType.SetupMesh, 0x020019FFu), - (PakAssetType.EnvCellMesh, 0u), - (PakAssetType.EnvCellMesh, 0xA9B40100u), - }; - - var keys = pairs.Select(p => PakKey.Compose(p.Type, p.FileId)).ToArray(); - - // keys[] as generated must already be strictly ascending since pairs[] - // is in ascending tuple order. - for (int i = 1; i < keys.Length; i++) { - Assert.True(keys[i] > keys[i - 1], - $"key[{i}]=0x{keys[i]:X16} should be > key[{i - 1}]=0x{keys[i - 1]:X16} " + - $"for tuple order ({pairs[i - 1]}) < ({pairs[i]})"); - } - - // Sorting the keys numerically must reproduce the same order as sorting - // the tuples lexicographically by (Type, FileId). - var numericSorted = keys.OrderBy(k => k).ToArray(); - var tupleSorted = pairs.OrderBy(p => (byte)p.Type).ThenBy(p => p.FileId) - .Select(p => PakKey.Compose(p.Type, p.FileId)).ToArray(); - Assert.Equal(tupleSorted, numericSorted); - } -} diff --git a/tests/AcDream.Content.Tests/PakRoundTripTests.cs b/tests/AcDream.Content.Tests/PakRoundTripTests.cs deleted file mode 100644 index 48ec18fc..00000000 --- a/tests/AcDream.Content.Tests/PakRoundTripTests.cs +++ /dev/null @@ -1,478 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Numerics; -using AcDream.Content.Pak; - -namespace AcDream.Content.Tests; - -public class PakRoundTripTests : IDisposable { - private readonly List _tempFiles = new(); - - private string NewTempPakPath() { - var path = Path.Combine(Path.GetTempPath(), $"acdream-paktest-{Guid.NewGuid():N}.pak"); - _tempFiles.Add(path); - return path; - } - - public void Dispose() { - foreach (var f in _tempFiles) { - try { if (File.Exists(f)) File.Delete(f); } catch { /* best effort cleanup */ } - } - } - - private static ObjectMeshData MakeSyntheticData(uint fileId, int vertexCount) { - var vertices = new VertexPositionNormalTexture[vertexCount]; - for (int i = 0; i < vertexCount; i++) { - vertices[i] = new VertexPositionNormalTexture( - new Vector3(i, i * 2, i * 3), - new Vector3(0, 0, 1), - new Vector2(i * 0.1f, i * 0.2f)); - } - return new ObjectMeshData { - ObjectId = fileId, - IsSetup = false, - Vertices = vertices, - DIDDegrade = fileId + 1, - }; - } - - private static (PakAssetType Type, uint FileId, ObjectMeshData Data)[] MakeBlobSet(int n) { - var result = new (PakAssetType, uint, ObjectMeshData)[n]; - for (int i = 0; i < n; i++) { - var type = (PakAssetType)((i % 3) + 1); - uint fileId = 0x0100_0000u + (uint)i; - result[i] = (type, fileId, MakeSyntheticData(fileId, i + 1)); - } - return result; - } - - private static PakHeader WritePak(string path, (PakAssetType Type, uint FileId, ObjectMeshData Data)[] blobs) { - var header = new PakHeader { - FormatVersion = 1, - PortalIteration = 10, - CellIteration = 20, - HighResIteration = 30, - LanguageIteration = 40, - BakeToolVersion = 1, - }; - using var writer = new PakWriter(path, header); - foreach (var (type, fileId, data) in blobs) { - writer.AddBlob(PakKey.Compose(type, fileId), data); - } - writer.Finish(); - return header; - } - - [Fact] - public void WriteThenOpen_HeaderFieldsMatch() { - var path = NewTempPakPath(); - var blobs = MakeBlobSet(5); - var written = WritePak(path, blobs); - - using var reader = new PakReader(path); - Assert.Equal(written.FormatVersion, reader.Header.FormatVersion); - Assert.Equal(written.PortalIteration, reader.Header.PortalIteration); - Assert.Equal(written.CellIteration, reader.Header.CellIteration); - Assert.Equal(written.HighResIteration, reader.Header.HighResIteration); - Assert.Equal(written.LanguageIteration, reader.Header.LanguageIteration); - Assert.Equal(written.BakeToolVersion, reader.Header.BakeToolVersion); - Assert.Equal((uint)blobs.Length, reader.Header.TocCount); - } - - [Fact] - public void WriteThenOpen_EveryKeyFound() { - var path = NewTempPakPath(); - var blobs = MakeBlobSet(20); - WritePak(path, blobs); - - using var reader = new PakReader(path); - foreach (var (type, fileId, _) in blobs) { - Assert.True(reader.ContainsKey(PakKey.Compose(type, fileId)), - $"key for type={type} fileId=0x{fileId:X8} should be found"); - } - } - - [Fact] - public void WriteThenOpen_BlobsDeserializeToDeepEqualObjects() { - var path = NewTempPakPath(); - var blobs = MakeBlobSet(8); - WritePak(path, blobs); - - using var reader = new PakReader(path); - foreach (var (type, fileId, expected) in blobs) { - var key = PakKey.Compose(type, fileId); - Assert.True(reader.TryReadObjectMeshData(key, out var actual), $"expected key 0x{key:X16} to read successfully"); - ObjectMeshDataEquality.AssertEqual(expected, actual); - } - } - - [Fact] - public void MissingKey_ReturnsFalse() { - var path = NewTempPakPath(); - var blobs = MakeBlobSet(3); - WritePak(path, blobs); - - using var reader = new PakReader(path); - var missingKey = PakKey.Compose(PakAssetType.GfxObjMesh, 0xFFFF_FFFEu); - Assert.False(reader.ContainsKey(missingKey)); - Assert.False(reader.TryReadObjectMeshData(missingKey, out var data)); - Assert.Null(data); - } - - [Fact] - public void CorruptedBlob_CrcMismatch_TreatedAsMissing() { - var path = NewTempPakPath(); - var blobs = MakeBlobSet(4); - WritePak(path, blobs); - - // Flip one byte inside the FIRST blob's region (right after the 64-byte header). - using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) { - fs.Position = PakHeader.Size + 4; // a few bytes into the first blob's payload - int b = fs.ReadByte(); - fs.Position = PakHeader.Size + 4; - fs.WriteByte((byte)(b ^ 0xFF)); - } - - using var reader = new PakReader(path); - var key = PakKey.Compose(blobs[0].Type, blobs[0].FileId); - Assert.False(reader.TryReadObjectMeshData(key, out var data), - "a CRC-mismatched blob must be treated as missing"); - Assert.Null(data); - } - - [Fact] - public void CorruptedBlob_DoesNotAffectOtherBlobs() { - var path = NewTempPakPath(); - var blobs = MakeBlobSet(4); - WritePak(path, blobs); - - using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) { - fs.Position = PakHeader.Size + 4; - int b = fs.ReadByte(); - fs.Position = PakHeader.Size + 4; - fs.WriteByte((byte)(b ^ 0xFF)); - } - - using var reader = new PakReader(path); - // blobs[1..] should still read fine. - for (int i = 1; i < blobs.Length; i++) { - var key = PakKey.Compose(blobs[i].Type, blobs[i].FileId); - Assert.True(reader.TryReadObjectMeshData(key, out var actual), $"blob {i} should be unaffected by corruption in blob 0"); - ObjectMeshDataEquality.AssertEqual(blobs[i].Data, actual); - } - } - - [Fact] - public void Blobs_Are64ByteAligned() { - var path = NewTempPakPath(); - var blobs = MakeBlobSet(6); - WritePak(path, blobs); - - using var reader = new PakReader(path); - foreach (var (type, fileId, _) in blobs) { - var offset = reader.GetBlobOffsetForTest(PakKey.Compose(type, fileId)); - Assert.True(offset % 64 == 0, $"blob offset {offset} for type={type} fileId=0x{fileId:X8} must be 64-byte aligned"); - } - } - - [Fact] - public void TocBinarySearch_MatchesLinearScan() { - var path = NewTempPakPath(); - var blobs = MakeBlobSet(50); - WritePak(path, blobs); - - using var reader = new PakReader(path); - var allKeys = blobs.Select(b => PakKey.Compose(b.Type, b.FileId)).ToArray(); - foreach (var key in allKeys) { - bool binarySearchFound = reader.ContainsKey(key); - bool linearScanFound = reader.DebugLinearScanContainsKey(key); - Assert.Equal(linearScanFound, binarySearchFound); - } - - // Also verify a handful of keys NOT present agree between both paths. - var absentKeys = new[] { - PakKey.Compose(PakAssetType.GfxObjMesh, 0xDEAD_0000u), - PakKey.Compose(PakAssetType.SetupMesh, 0xDEAD_0001u), - PakKey.Compose(PakAssetType.EnvCellMesh, 0xDEAD_0002u), - }; - foreach (var key in absentKeys) { - Assert.Equal(reader.DebugLinearScanContainsKey(key), reader.ContainsKey(key)); - Assert.False(reader.ContainsKey(key)); - } - } - - [Fact] - public void EmptyPak_OpensCleanly_NoKeysFound() { - var path = NewTempPakPath(); - WritePak(path, Array.Empty<(PakAssetType, uint, ObjectMeshData)>()); - - using var reader = new PakReader(path); - Assert.Equal(0u, reader.Header.TocCount); - Assert.False(reader.ContainsKey(PakKey.Compose(PakAssetType.GfxObjMesh, 1))); - } - - // ---- format-version enforcement (review finding 2) --------------------- - - [Fact] - public void Writer_StampsFormatVersion_IgnoringTemplate() { - // The default-0 footgun: a caller building a header template without - // setting FormatVersion must still produce a CURRENT-version pak. - var path = NewTempPakPath(); - using (var writer = new PakWriter(path, new PakHeader { BakeToolVersion = 1 })) { - writer.AddBlob(PakKey.Compose(PakAssetType.GfxObjMesh, 1), MakeSyntheticData(1, 1)); - writer.Finish(); - } - - using var reader = new PakReader(path); - Assert.Equal(PakFormat.CurrentFormatVersion, reader.Header.FormatVersion); - } - - [Theory] - [InlineData(0u)] - [InlineData(2u)] - public void Reader_RejectsWrongFormatVersion(uint wrongVersion) { - var path = NewTempPakPath(); - WritePak(path, MakeBlobSet(2)); - - // Patch the header's formatVersion dword (offset 4) in place. - using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) { - fs.Position = 4; - Span buf = stackalloc byte[4]; - System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(buf, wrongVersion); - fs.Write(buf); - } - - var ex = Assert.Throws(() => new PakReader(path)); - Assert.Contains($"version {wrongVersion}", ex.Message); - Assert.Contains($"version {PakFormat.CurrentFormatVersion}", ex.Message); - } - - // ---- reader robustness (review finding 3) ------------------------------- - - [Fact] - public void CorruptTocEntry_TreatedAsMissing_SiblingsUnaffected_NoThrow() { - var path = NewTempPakPath(); - var blobs = MakeBlobSet(4); - WritePak(path, blobs); - - // Patch the FIRST victim entry's length field (entry offset +16) to a - // value that runs past the file end. - var victimKey = PakKey.Compose(blobs[0].Type, blobs[0].FileId); - long entryPos = FindTocEntryPosition(path, victimKey); - using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) { - fs.Position = entryPos + 16; - Span buf = stackalloc byte[4]; - System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(buf, 0x7FFF_FFF0u); - fs.Write(buf); - } - - using var reader = new PakReader(path); // must NOT throw — corrupt entry = missing - Assert.False(reader.ContainsKey(victimKey)); - Assert.False(reader.TryReadObjectMeshData(victimKey, out var data)); - Assert.Null(data); - - for (int i = 1; i < blobs.Length; i++) { - var key = PakKey.Compose(blobs[i].Type, blobs[i].FileId); - Assert.True(reader.TryReadObjectMeshData(key, out var sibling), $"sibling blob {i} should be unaffected"); - ObjectMeshDataEquality.AssertEqual(blobs[i].Data, sibling); - } - } - - [Fact] - public void TruncatedPak_RefusedAtOpen() { - var path = NewTempPakPath(); - WritePak(path, MakeBlobSet(4)); - - // Chop the file mid-TOC: the header claims 4 entries the file no - // longer holds — a structural fault, refused at open. - using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) { - fs.SetLength(fs.Length - PakTocEntry.Size - 4); - } - - var ex = Assert.Throws(() => new PakReader(path)); - Assert.Contains("truncated", ex.Message); - } - - [Fact] - public void HalfWrittenPak_UnfinalizedHeader_RefusedAtOpen() { - var path = NewTempPakPath(); - // Simulate a bake crash between the placeholder-header write and - // Finish(): current-version header with TocOffset still 0, blob bytes - // behind it. - using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write)) { - new PakHeader { FormatVersion = PakFormat.CurrentFormatVersion }.WriteTo(fs); - var junk = new byte[256]; - new Random(42).NextBytes(junk); - fs.Write(junk); - } - - var ex = Assert.Throws(() => new PakReader(path)); - Assert.Contains("unfinalized", ex.Message); - } - - [Fact] - public void MalformedBlobBehindValidCrc_TreatedAsMissing_SiblingsUnaffected() { - var path = NewTempPakPath(); - var blobs = MakeBlobSet(3); - WritePak(path, blobs); - - // Corrupt the FIRST blob's STRUCTURE (vertices count:i32 at blob - // offset 9 -> -1) and then RE-COMPUTE the CRC over the tampered bytes - // so the CRC tripwire passes — only the deserialization catch can - // save the read now. - var victimKey = PakKey.Compose(blobs[0].Type, blobs[0].FileId); - long entryPos = FindTocEntryPosition(path, victimKey); - using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) { - // Read the victim entry to get offset+length. - fs.Position = entryPos; - var entryBuf = new byte[PakTocEntry.Size]; - fs.ReadExactly(entryBuf); - var entry = PakTocEntry.ReadFrom((ReadOnlySpan)entryBuf); - - // Tamper: vertices count -> -1 (ObjectId u64 + IsSetup byte = offset 9). - fs.Position = (long)entry.Offset + 9; - Span negOne = stackalloc byte[4]; - System.Buffers.Binary.BinaryPrimitives.WriteInt32LittleEndian(negOne, -1); - fs.Write(negOne); - - // Recompute CRC over the tampered blob region. - fs.Position = (long)entry.Offset; - var blobBytes = new byte[entry.Length]; - fs.ReadExactly(blobBytes); - uint newCrc = Crc32.Compute(blobBytes); - - // Patch the TOC entry's crc32 field (entry offset +20). - fs.Position = entryPos + 20; - Span crcBuf = stackalloc byte[4]; - System.Buffers.Binary.BinaryPrimitives.WriteUInt32LittleEndian(crcBuf, newCrc); - fs.Write(crcBuf); - } - - using var reader = new PakReader(path); - Assert.False(reader.TryReadObjectMeshData(victimKey, out var data), - "a structurally-malformed blob behind a matching CRC must be treated as missing"); - Assert.Null(data); - // Second read: still missing, no throw (verdict cached). - Assert.False(reader.TryReadObjectMeshData(victimKey, out _)); - - for (int i = 1; i < blobs.Length; i++) { - var key = PakKey.Compose(blobs[i].Type, blobs[i].FileId); - Assert.True(reader.TryReadObjectMeshData(key, out var sibling), $"sibling blob {i} should be unaffected"); - ObjectMeshDataEquality.AssertEqual(blobs[i].Data, sibling); - } - } - - // ---- writer disposal safety (review finding 5) --------------------------- - - [Fact] - public void WriterDisposedAfterAddBlobException_ReleasesFileHandle() { - var path = NewTempPakPath(); - var data = MakeSyntheticData(1, 1); - var key = PakKey.Compose(PakAssetType.GfxObjMesh, 1); - - var writer = new PakWriter(path, new PakHeader { BakeToolVersion = 1 }); - writer.AddBlob(key, data); - Assert.Throws(() => writer.AddBlob(key, data)); // duplicate key mid-AddBlob - writer.Dispose(); - - // The stream must be closed even after the AddBlob exception: the - // file is deletable (no leaked/locked handle). - File.Delete(path); - Assert.False(File.Exists(path)); - } - - // ---- on-disk TOC sortedness (review finding 8) --------------------------- - - [Fact] - public void OnDiskToc_IsSortedAscendingByKey_RegardlessOfAddOrder() { - var path = NewTempPakPath(); - // Add blobs in DESCENDING key order — the on-disk TOC must still come - // out ascending (the reader's binary-search precondition, asserted - // here against the raw bytes, not through the reader). - var blobs = MakeBlobSet(12).OrderByDescending(b => PakKey.Compose(b.Type, b.FileId)).ToArray(); - WritePak(path, blobs); - - var fileBytes = File.ReadAllBytes(path); - var header = PakHeader.ReadFrom((ReadOnlySpan)fileBytes); - Assert.Equal((uint)blobs.Length, header.TocCount); - - ulong previousKey = 0; - for (uint i = 0; i < header.TocCount; i++) { - int pos = checked((int)((long)header.TocOffset + i * PakTocEntry.Size)); - var entry = PakTocEntry.ReadFrom(fileBytes.AsSpan(pos, PakTocEntry.Size)); - Assert.True(entry.Key > previousKey || i == 0, - $"TOC entry {i} key 0x{entry.Key:X16} is not strictly greater than its predecessor 0x{previousKey:X16}"); - previousKey = entry.Key; - } - } - - // ---- corruption logged once (review finding 8) ---------------------------- - - [Fact] - public void CorruptBlob_RepeatedReads_LogExactlyOnce() { - var path = NewTempPakPath(); - var blobs = MakeBlobSet(2); - WritePak(path, blobs); - - // Flip a byte in the first blob (plain CRC corruption). - using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite)) { - fs.Position = PakHeader.Size + 4; - int b = fs.ReadByte(); - fs.Position = PakHeader.Size + 4; - fs.WriteByte((byte)(b ^ 0xFF)); - } - - var victimKey = PakKey.Compose(blobs[0].Type, blobs[0].FileId); - var originalError = Console.Error; - var capture = new StringWriter(); - try { - Console.SetError(capture); - using var reader = new PakReader(path); - // Hammer the corrupt entry through BOTH public paths, repeatedly. - Assert.False(reader.TryReadObjectMeshData(victimKey, out _)); - Assert.False(reader.TryReadObjectMeshData(victimKey, out _)); - Assert.False(reader.ContainsKey(victimKey)); - Assert.False(reader.ContainsKey(victimKey)); - Assert.False(reader.TryReadObjectMeshData(victimKey, out _)); - } - finally { - Console.SetError(originalError); - } - - string logged = capture.ToString(); - int occurrences = CountOccurrences(logged, $"0x{victimKey:X16}"); - Assert.True(occurrences == 1, - $"expected exactly ONE [pak-corrupt] line for key 0x{victimKey:X16} across 5 reads, got {occurrences}:\n{logged}"); - } - - private static int CountOccurrences(string haystack, string needle) { - int count = 0, index = 0; - while ((index = haystack.IndexOf(needle, index, StringComparison.Ordinal)) >= 0) { - count++; - index += needle.Length; - } - return count; - } - - // ---- helpers ------------------------------------------------------------- - - /// Locates the on-disk file position of the TOC entry for by raw parsing. - private static long FindTocEntryPosition(string path, ulong key) { - using var fs = new FileStream(path, FileMode.Open, FileAccess.Read); - var headerBuf = new byte[PakHeader.Size]; - fs.ReadExactly(headerBuf); - var header = PakHeader.ReadFrom((ReadOnlySpan)headerBuf); - - var entryBuf = new byte[PakTocEntry.Size]; - for (uint i = 0; i < header.TocCount; i++) { - long pos = (long)header.TocOffset + i * PakTocEntry.Size; - fs.Position = pos; - fs.ReadExactly(entryBuf); - var entry = PakTocEntry.ReadFrom((ReadOnlySpan)entryBuf); - if (entry.Key == key) return pos; - } - throw new InvalidOperationException($"TOC entry for key 0x{key:X16} not found"); - } -} diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 0bafb2f2..c414ddbf 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -37,10 +37,10 @@ public sealed class GameEventWiringTests return body; } - private static (GameEventDispatcher, ClientObjectTable, CombatState, Spellbook, ChatLog) MakeAll() + private static (GameEventDispatcher, ItemRepository, CombatState, Spellbook, ChatLog) MakeAll() { var dispatcher = new GameEventDispatcher(); - var items = new ClientObjectTable(); + var items = new ItemRepository(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); @@ -101,10 +101,10 @@ public sealed class GameEventWiringTests } [Fact] - public void WireAll_WieldObject_RoutesToClientObjectTable() + public void WireAll_WieldObject_RoutesToItemRepository() { var (d, items, _, _, _) = MakeAll(); - items.AddOrUpdate(new ClientObject { ObjectId = 0x1000, WeenieClassId = 1 }); + items.AddOrUpdate(new ItemInstance { ObjectId = 0x1000, WeenieClassId = 1 }); byte[] payload = new byte[12]; BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1000); @@ -114,31 +114,12 @@ public sealed class GameEventWiringTests var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload)); d.Dispatch(env!.Value); - var item = items.Get(0x1000); + var item = items.GetItem(0x1000); Assert.NotNull(item); Assert.Equal(EquipMask.MeleeWeapon, item!.CurrentlyEquippedLocation); Assert.Equal(0x2000u, item.ContainerId); } - [Fact] - public void WireAll_WieldObject_ConfirmsOptimisticWield() - { - var (d, items, _, _, _) = MakeAll(); - const uint player = 0x2000u; - items.AddOrUpdate(new ClientObject { ObjectId = 0x1500, WeenieClassId = 1 }); - items.MoveItem(0x1500, 0x9999u, newSlot: 0); // start in a pack - items.WieldItemOptimistic(0x1500, player, EquipMask.MeleeWeapon); // optimistic wield → snapshot pending - - byte[] payload = new byte[12]; - BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1500); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)EquipMask.MeleeWeapon); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), player); - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.WieldObject, payload)); - d.Dispatch(env!.Value); // server confirms the wield - - Assert.False(items.RollbackMove(0x1500)); // pending snapshot was cleared by ConfirmMove - } - [Fact] public void WireAll_PopupString_RoutesToChatLog() { @@ -160,7 +141,7 @@ public sealed class GameEventWiringTests // through WireAll, lands in LocalPlayerState with the right // ranks/start/current values. var dispatcher = new GameEventDispatcher(); - var items = new ClientObjectTable(); + var items = new ItemRepository(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); @@ -219,7 +200,7 @@ public sealed class GameEventWiringTests public void WireAll_PlayerDescription_FeedsSpellbook() { var dispatcher = new GameEventDispatcher(); - var items = new ClientObjectTable(); + var items = new ItemRepository(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); @@ -349,20 +330,20 @@ public sealed class GameEventWiringTests } [Fact] - public void PlayerDescription_RegistersInventoryEntries_InClientObjectTable() + public void PlayerDescription_RegistersInventoryEntries_InItemRepository() { // Issue #13 acceptance test: after a PlayerDescription with non-empty - // Inventory is dispatched through WireAll, ClientObjectTable.ObjectCount > 0. + // Inventory is dispatched through WireAll, ItemRepository.ItemCount > 0. // Wire format: strict path (no GAMEPLAY_OPTIONS bit) so inventory + // equipped follow directly after spellbook_filters. var dispatcher = new GameEventDispatcher(); - var items = new ClientObjectTable(); + var items = new ItemRepository(); var combat = new CombatState(); var spellbook = new Spellbook(); var chat = new ChatLog(); GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat); - Assert.Equal(0, items.ObjectCount); // pre-condition + Assert.Equal(0, items.ItemCount); // pre-condition var sb = new MemoryStream(); using var w = new BinaryWriter(sb); @@ -389,276 +370,9 @@ public sealed class GameEventWiringTests var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); dispatcher.Dispatch(env!.Value); - Assert.Equal(2, items.ObjectCount); - Assert.NotNull(items.Get(0x50000A01u)); - Assert.NotNull(items.Get(0x50000A02u)); - } - - [Fact] - public void PlayerDescription_SeedsMembership_NotWeenieClassIdMisuse() - { - // D.5.4: PlayerDescription is a membership MANIFEST, not the data - // source. The old code set WeenieClassId = inv.ContainerType (a - // 0/1/2 discriminator), which is a misuse. After the fix, the - // registered stub has WeenieClassId == 0 and the equipped item's - // CurrentlyEquippedLocation is set to MeleeWeapon (0x1). - // Uses the SAME wire fixture as PlayerDescription_RegistersInventoryEntries_InClientObjectTable. - var dispatcher = new GameEventDispatcher(); - var items = new ClientObjectTable(); - var combat = new CombatState(); - var spellbook = new Spellbook(); - var chat = new ChatLog(); - GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat); - - var sb = new MemoryStream(); - using var w = new BinaryWriter(sb); - w.Write(0u); // propertyFlags = 0 - w.Write(0x52u); // weenieType - w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT - w.Write(1u); // has_health - w.Write(0u); // attribute_flags = 0 (no attrs) - w.Write(0u); // enchantment_mask = 0 - - w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS → strict inv path) - w.Write(0u); // options1 - w.Write(0u); // legacy hotbar list count = 0 - w.Write(0u); // spellbook_filters - - // Inventory: 1 entry with ContainerType=1 (the OLD code would have - // set WeenieClassId=1; the new code must leave WeenieClassId==0). - w.Write(1u); - w.Write(0x700u); w.Write(1u); // guid=0x700, ContainerType=1 - - // Equipped: 1 entry with EquipLocation = MeleeWeapon (0x1). - // Wire format: guid(4) + loc(4) + priority(4) = 12 bytes per entry. - w.Write(1u); - w.Write(0x701u); w.Write((uint)EquipMask.MeleeWeapon); w.Write(0u); // guid=0x701, slot=MeleeWeapon, prio=0 - - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); - dispatcher.Dispatch(env!.Value); - - // (a) inventory guid is registered - Assert.NotNull(items.Get(0x700u)); - // (b) WeenieClassId must be 0, NOT the ContainerType discriminator (1) — misuse gone - Assert.Equal(0u, items.Get(0x700u)!.WeenieClassId); - // (c) equipped guid has its equip slot set - Assert.NotNull(items.Get(0x701u)); - Assert.Equal(EquipMask.MeleeWeapon, items.Get(0x701u)!.CurrentlyEquippedLocation); - } - - [Fact] - public void WireAll_PlayerDescription_invokesOnShortcuts() - { - // D.5.1 Task 4: WireAll must forward parsed.Shortcuts to the onShortcuts - // callback so the toolbar can read them without keeping a parser reference. - // Mirrors PlayerDescription_RegistersInventoryEntries_InClientObjectTable - // for the harness pattern; adds the Shortcut flag (0x1) + one 12-byte - // entry, followed by the legacy-hotbar count (0) + spellbook_filters (0) - // then empty inventory and equipped. - IReadOnlyList? got = null; - - var dispatcher = new GameEventDispatcher(); - var items = new ClientObjectTable(); - var combat = new CombatState(); - var spellbook = new Spellbook(); - var chat = new ChatLog(); - GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat, - onShortcuts: list => got = list); - - // PlayerDescription body — minimal: no property flags, ATTRIBUTE|ENCHANTMENT - // vectorFlags (so the parser sees has_health=1, attribute_flags=0, - // enchantment_mask=0 and advances past both vector blocks), then the trailer - // with option_flags=Shortcut (0x1). - // - // Trailer layout when option_flags=0x1 (Shortcut only, no SpellLists8): - // u32 option_flags = 0x1 - // u32 options1 = 0 - // u32 count = 1 ← shortcut block (Shortcut flag set) - // u32 idx = 0 - // u32 guid = 0x5001 - // u16 spellId = 0 - // u16 layer = 0 - // u32 legacyHotbar count = 0 ← SpellLists8 NOT set → legacy fallback - // u32 spellbook_filters = 0 - // u32 inventory count = 0 - // u32 equipped count = 0 - var sb = new MemoryStream(); - using var w = new BinaryWriter(sb); - w.Write(0u); // propertyFlags = 0 - w.Write(0x52u); // weenieType - w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT - w.Write(1u); // has_health - w.Write(0u); // attribute_flags = 0 (no attrs) - w.Write(0u); // enchantment_mask = 0 - - // Trailer - w.Write(0x00000001u); // option_flags = Shortcut - w.Write(0u); // options1 - // Shortcut block (option_flags & 0x1 set): - w.Write(1u); // count = 1 - w.Write(0u); // idx = 0 - w.Write(0x5001u); // guid = 0x5001 - w.Write((ushort)0); // spellId = 0 - w.Write((ushort)0); // layer = 0 - // SpellLists8 NOT set → legacy single-list fallback: - w.Write(0u); // legacy hotbar list count = 0 - // No DesiredComps, no CharacterOptions2, no GameplayOptions → strict path: - w.Write(0u); // spellbook_filters = 0 - w.Write(0u); // inventory count = 0 - w.Write(0u); // equipped count = 0 - - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); - dispatcher.Dispatch(env!.Value); - - Assert.NotNull(got); - Assert.Single(got!); - Assert.Equal(0x5001u, got![0].ObjectGuid); - } - - [Fact] - public void WireAll_PlayerDescription_UpsertsPlayerPropertiesIntoClientObject() - { - // PD with a PropertyInt32 table carrying EncumbranceVal(5)=1500. The handler - // must land it in the player ClientObject so the burden bar reads the wire value. - const uint playerGuid = 0x50000001u; - var dispatcher = new GameEventDispatcher(); - var items = new ClientObjectTable(); - GameEventWiring.WireAll(dispatcher, items, new CombatState(), new Spellbook(), - new ChatLog(), playerGuid: () => playerGuid); - - var sb = new MemoryStream(); - using var w = new BinaryWriter(sb); - w.Write(0x00000001u); // propertyFlags = PropertyInt32 - w.Write(0x52u); // weenieType - // int table: u16 count, u16 buckets, then key/val pairs - w.Write((ushort)1); // count - w.Write((ushort)8); // buckets (ignored) - w.Write(5u); // key = EncumbranceVal - w.Write(1500u); // val - // vector + has_health (no vector blocks) - w.Write(0u); // vectorFlags = None - w.Write(0u); // has_health - // strict trailer - w.Write(0u); // option_flags = None - w.Write(0u); // options1 - w.Write(0u); // legacy hotbar count - w.Write(0u); // spellbook_filters - w.Write(0u); // inventory count - w.Write(0u); // equipped count - - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray())); - dispatcher.Dispatch(env!.Value); - - var player = items.Get(playerGuid); - Assert.NotNull(player); - Assert.Equal(1500, player!.Properties.Ints[5]); - } - - [Fact] - public void WireAll_ViewContents_RecordsMembershipInClientObjectTable() - { - var (d, items, _, _, _) = MakeAll(); - - // containerGuid 0xC9 + 2 entries - byte[] payload = new byte[8 + 2 * 8]; - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x500000C9u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 2u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0x50000A01u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(16), 0x50000A02u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(20), 1u); - - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload)); - d.Dispatch(env!.Value); - - Assert.Equal(0x500000C9u, items.Get(0x50000A01u)!.ContainerId); - Assert.Equal(0x500000C9u, items.Get(0x50000A02u)!.ContainerId); - } - - [Fact] - public void WireAll_ViewContents_IsFullReplace_DropsRemovedItems() - { - var (d, items, _, _, _) = MakeAll(); - - // First view of container 0xC9: two items. - DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A01u, 0x50000A02u }); - Assert.Equal(2, items.GetContents(0x500000C9u).Count); - - // Second view: only one item — the other was removed server-side. Additive merge would keep both. - DispatchViewContents(d, 0x500000C9u, new uint[] { 0x50000A02u }); - Assert.Equal(new[] { 0x50000A02u }, items.GetContents(0x500000C9u)); - Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); - } - - private static void DispatchViewContents(GameEventDispatcher d, uint containerGuid, uint[] itemGuids) - { - byte[] payload = new byte[8 + itemGuids.Length * 8]; - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), containerGuid); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), (uint)itemGuids.Length); - for (int i = 0; i < itemGuids.Length; i++) - { - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8 + i * 8), itemGuids[i]); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12 + i * 8), 0u); // containerType - } - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.ViewContents, payload)); - d.Dispatch(env!.Value); - } - - [Fact] - public void WireAll_InventoryServerSaveFailed_RollsBackOptimisticMove() - { - var (d, items, _, _, _) = MakeAll(); - // An item known to be in container 0xC0 at slot 2, then optimistically moved to 0xC1. - items.RecordMembership(0x50000B01u, containerId: 0x500000C0u); - items.MoveItem(0x50000B01u, 0x500000C0u, 2); - items.MoveItemOptimistic(0x50000B01u, 0x500000C1u, 0); - Assert.Equal(0x500000C1u, items.Get(0x50000B01u)!.ContainerId); // moved (optimistic) - - // Server bounces it: InventoryServerSaveFailed (itemGuid, weenieError). - byte[] payload = new byte[8]; - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B01u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x1Au); // some WeenieError - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryServerSaveFailed, payload)); - d.Dispatch(env!.Value); - - Assert.Equal(0x500000C0u, items.Get(0x50000B01u)!.ContainerId); // snapped back - Assert.Equal(2, items.Get(0x50000B01u)!.ContainerSlot); - } - - [Fact] - public void WireAll_InventoryPutObjInContainer_ConfirmsOptimisticMove_soNoRollback() - { - var (d, items, _, _, _) = MakeAll(); - items.RecordMembership(0x50000B02u, containerId: 0x500000C0u); - items.MoveItem(0x50000B02u, 0x500000C0u, 2); - items.MoveItemOptimistic(0x50000B02u, 0x500000C1u, 0); // optimistic move (pending snapshot) - - // Server CONFIRMS via InventoryPutObjInContainer (item, container, placement, containerType). - byte[] payload = new byte[16]; - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(0), 0x50000B02u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x500000C1u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 0u); // placement - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 0u); // containerType - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjInContainer, payload)); - d.Dispatch(env!.Value); - - // ConfirmMove cleared the pending snapshot → a later rollback is a no-op (the move stuck). - Assert.False(items.RollbackMove(0x50000B02u)); - Assert.Equal(0x500000C1u, items.Get(0x50000B02u)!.ContainerId); - } - - [Fact] - public void WireAll_InventoryPutObjectIn3D_UnparentsFromContainer() - { - var (d, items, _, _, _) = MakeAll(); - items.RecordMembership(0x50000A01u, containerId: 0x500000C9u); - - byte[] payload = new byte[4]; - BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x50000A01u); - var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.InventoryPutObjectIn3D, payload)); - d.Dispatch(env!.Value); - - Assert.Equal(0u, items.Get(0x50000A01u)!.ContainerId); + Assert.Equal(2, items.ItemCount); + Assert.NotNull(items.GetItem(0x50000A01u)); + Assert.NotNull(items.GetItem(0x50000A02u)); } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs index 9977a71f..79dcd1d4 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/AppraiseTests.cs @@ -56,18 +56,15 @@ public sealed class AppraiseTests [Fact] public void ParsePutObjInContainer_RoundTrip() { - // 4 fields (Task 7 fix: containerType added) — 16 bytes required. - byte[] payload = new byte[16]; - BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1001u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x2001u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 3u); - BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(12), 1u); // containerType + byte[] payload = new byte[12]; + BinaryPrimitives.WriteUInt32LittleEndian(payload, 0x1001u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(4), 0x2001u); + BinaryPrimitives.WriteUInt32LittleEndian(payload.AsSpan(8), 3u); var parsed = GameEvents.ParsePutObjInContainer(payload); Assert.NotNull(parsed); Assert.Equal(0x1001u, parsed!.Value.ItemGuid); Assert.Equal(0x2001u, parsed.Value.ContainerGuid); Assert.Equal(3u, parsed.Value.Placement); - Assert.Equal(1u, parsed.Value.ContainerType); } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs index 9652f3e1..630cae6f 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/AutonomousPositionTests.cs @@ -122,69 +122,6 @@ public class AutonomousPositionTests Assert.Equal(0, body[52]); } - [Fact] - public void Build_TimestampOrder_MatchesAutonomousPositionPackPack() - { - // AutonomousPositionPack::Pack (0x00516af0, confirmed via Ghidra - // decompile-by-address during this slice 2026-06-30): after - // Position::Pack (32 bytes), four u16 timestamps in order - // instance_timestamp, server_control_timestamp, teleport_timestamp, - // force_position_ts — then a CONTACT-ONLY byte (no longjump bit; - // that bit only exists in MoveToStatePack), then ALIGN_PTR. - var body = AutonomousPosition.Build( - gameActionSequence: 8, - cellId: 0xA9B40001u, - position: Vector3.Zero, - rotation: Quaternion.Identity, - instanceSequence: 0x1111, - serverControlSequence: 0x2222, - teleportSequence: 0x3333, - forcePositionSequence: 0x4444); - - // 12 (envelope) + 32 (Position) = 44. - int tsOffset = 44; - ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset)); - ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2)); - ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4)); - ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6)); - - Assert.Equal((ushort)0x1111, instance); - Assert.Equal((ushort)0x2222, serverControl); - Assert.Equal((ushort)0x3333, teleport); - Assert.Equal((ushort)0x4444, forcePosition); - } - - [Fact] - public void Build_ContactByte_IsContactOnly_NoLongjumpBit() - { - // contact != 0 -> byte = 1 (bool cast, not an OR'd bitmask like MTS). - var bodyOnGround = AutonomousPosition.Build( - gameActionSequence: 9, - cellId: 0xA9B40001u, - position: Vector3.Zero, - rotation: Quaternion.Identity, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0, - lastContact: 1); - - Assert.Equal(1, bodyOnGround[52]); - - var bodyAirborne = AutonomousPosition.Build( - gameActionSequence: 10, - cellId: 0xA9B40001u, - position: Vector3.Zero, - rotation: Quaternion.Identity, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0, - lastContact: 0); - - Assert.Equal(0, bodyAirborne[52]); - } - [Fact] public void Build_ContainsIdentityRotation_AfterPosition() { diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs index d7101022..1e9ce105 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs @@ -156,303 +156,6 @@ public sealed class CreateObjectTests Assert.Equal(2.5f, parsed.Value.UseRadius!.Value, precision: 3); } - // ----------------------------------------------------------------------- - // D.5.1 (2026-06-16): IconId was discarded at cs:516 — surface it so the - // action bar / equipment UI can read icon dat ids from spawn messages. - // ----------------------------------------------------------------------- - - [Fact] - public void TryParse_IconId_Surfaced() - { - // Icon dat id 0x06001234: the wire writer strips the 0x06000000 prefix - // before packing (WritePackedDwordOfKnownType strips it), so we write - // 0x1234 as the packed value and expect 0x06001234 back. - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000009u, - name: "SwordIcon", - itemType: (uint)ItemType.MeleeWeapon, - iconId: 0x1234u); - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal(0x06001234u, parsed!.Value.IconId); - } - - // ----------------------------------------------------------------------- - // D.5.1 (2026-06-17): extended WeenieHeader optional-tail walk — the parser - // now continues past UseRadius through ALL intervening fields to reach - // IconOverlay (weenieFlags bit 0x40000000) and IconUnderlay (weenieFlags2 - // bit 0x01, present when objDescFlags bit 0x04000000 is set). - // - // Two tests: - // 1. WithIconOverlay — sets only the IconOverlay bit + the minimum - // intervening fields (none in this minimal body, so weenieFlags only has - // 0x40000000). Verifies the parse walks to IconOverlay and captures it. - // 2. WithIconOverlayAndUnderlay — sets IconOverlay + the IncludesSecondHeader - // objDescFlag + weenieFlags2 bit 0x01, writes both ids, asserts both are - // captured. - // 3. NoOverlayBits_CommonCase — weenieFlags=0, verifies the extended walk - // produces no overlay (regression guard for the common spawn path). - // ----------------------------------------------------------------------- - - [Fact] - public void TryParse_IconOverlay_CapturedFromExtendedTail() - { - // Only IconOverlay (0x40000000) bit set in weenieFlags. No intervening - // optional fields, so the extended tail immediately reads the overlay id. - // ACE WritePackedDwordOfKnownType strips the 0x06000000 prefix before - // packing; the reader ORs it back in. - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x5000000Au, - name: "EnchantedSword", - itemType: (uint)ItemType.MeleeWeapon, - weenieFlags: 0x40000000u, // IconOverlay - iconOverlayId: 0x1ABCu); // will be read back as 0x06001ABC - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal(0x06001ABCu, parsed!.Value.IconOverlayId); - Assert.Equal(0u, parsed.Value.IconUnderlayId); - } - - [Fact] - public void TryParse_IconOverlayAndUnderlay_BothCaptured() - { - // IncludesSecondHeader in objDescFlags (0x04000000) makes the parser read - // weenieFlags2. weenieFlags2 bit 0x01 (IconUnderlay) triggers the underlay - // read. Both overlay + underlay are captured. - // objectDescriptionFlags: 0x04000000 = IncludesSecondHeader - // weenieFlags: 0x40000000 = IconOverlay - // weenieFlags2: 0x00000001 = IconUnderlay - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x5000000Bu, - name: "MagicRing", - itemType: (uint)ItemType.Jewelry, - objectDescriptionFlags: 0x04000000u, - weenieFlags: 0x40000000u, - weenieFlags2: 0x00000001u, - iconOverlayId: 0x5678u, // → 0x06005678 - iconUnderlayId: 0x9ABCu); // → 0x06009ABC - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal(0x06005678u, parsed!.Value.IconOverlayId); - Assert.Equal(0x06009ABCu, parsed.Value.IconUnderlayId); - } - - [Fact] - public void TryParse_NoOverlayBits_CommonCase_OverlaysStayZero() - { - // Regression guard: most spawned entities (creatures, scenery, players) - // have weenieFlags=0 and no second-header. The extended walk must not - // corrupt existing parsed fields and must leave overlay ids at zero. - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x5000000Cu, - name: "CommonDrudge", - itemType: (uint)ItemType.Creature, - weenieFlags: 0u); - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal("CommonDrudge", parsed!.Value.Name); - Assert.Equal(0u, parsed.Value.IconOverlayId); - Assert.Equal(0u, parsed.Value.IconUnderlayId); - Assert.Null(parsed.Value.Useability); - } - - [Fact] - public void TryParse_IntermediateFieldsBeforeIconOverlay_SkippedCorrectly() - { - // Verifies the cursor arithmetic for fields between UseRadius and - // IconOverlay. This body sets several intermediate bits (Structure u16, - // MaxStructure u16, StackSize u16, Burden u16) plus IconOverlay. - // If any skip is wrong, the parser reads the wrong bytes as the - // overlay id or throws, both of which the assert would catch. - // 0x00000400 = Structure (u16) - // 0x00000800 = MaxStructure (u16) - // 0x00001000 = StackSize (u16) - // 0x00200000 = Burden (u16) - // 0x40000000 = IconOverlay - const uint flags = 0x40000000u | 0x00200000u | 0x00001000u | 0x00000800u | 0x00000400u; - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x5000000Du, - name: "FancySword", - itemType: (uint)ItemType.MeleeWeapon, - weenieFlags: flags, - structure: 50, - maxStructure: 100, - stackSize: 1, - burden: 300, - iconOverlayId: 0x2222u); // → 0x06002222 - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal(0x06002222u, parsed!.Value.IconOverlayId); - } - - [Fact] - public void TryParse_HouseRestrictionsSkipped_ThenIconOverlayCaptured() - { - // Verifies that the variable-length RestrictionDB skip (weenieFlags bit - // 0x04000000) lands the cursor at the correct position so that - // IconOverlay (bit 0x40000000) immediately after it is still captured. - // - // Wire layout per ACE RestrictionDB (16 bytes, zero entries): - // Version(u32) + OpenStatus(u32) + MonarchId(u32) = 12 bytes - // count(u16) + numBuckets(u16) = 4 bytes - // entries: count(0) × 8 = 0 bytes - // total = 16 bytes - // - // Also exercises the IncludesSecondHeader / IconUnderlay path so that - // all three optional-tail branches that follow HouseOwner are covered - // in a single cursor sweep. - // - // weenieFlags: 0x04000000 (HouseRestrictions) | 0x40000000 (IconOverlay) - // objectDescriptionFlags: 0x04000000 (IncludesSecondHeader → weenieFlags2 present) - // weenieFlags2: 0x00000001 (IconUnderlay) - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x5000000Eu, - name: "HousePortal", - itemType: (uint)ItemType.Portal, - objectDescriptionFlags: 0x04000000u, // IncludesSecondHeader - weenieFlags: 0x04000000u | 0x40000000u, // HouseRestrictions + IconOverlay - weenieFlags2: 0x00000001u, // IconUnderlay - iconOverlayId: 0x3333u, // → 0x06003333 - iconUnderlayId: 0x4444u); // → 0x06004444 - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal(0x06003333u, parsed!.Value.IconOverlayId); - Assert.Equal(0x06004444u, parsed.Value.IconUnderlayId); - } - - // ----------------------------------------------------------------------- - // D.5.2 (2026-06-17): UiEffects bitfield (weenieFlags bit 0x80) — captured - // instead of skipped. Drives the icon's effect-overlay recolor. - // ----------------------------------------------------------------------- - - [Fact] - public void TryParse_UiEffects_Captured() - { - // weenieFlags 0x80 = UiEffects; value 0x1 = Magical. - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000010u, name: "MagicWand", itemType: (uint)ItemType.Caster, - weenieFlags: 0x80u, uiEffects: 0x1u); - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal(0x1u, parsed!.Value.UiEffects); - } - - [Fact] - public void TryParse_UiEffectsThenIconOverlay_BothCaptured() - { - // Verifies the cursor still reaches IconOverlay after reading (not skipping) UiEffects. - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000011u, name: "GlowSword", itemType: (uint)ItemType.MeleeWeapon, - weenieFlags: 0x80u | 0x40000000u, uiEffects: 0x4u, iconOverlayId: 0x1ABCu); - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal(0x4u, parsed!.Value.UiEffects); - Assert.Equal(0x06001ABCu, parsed.Value.IconOverlayId); - } - - [Fact] - public void TryParse_NoUiEffectsBit_LeavesUiEffectsZero() - { - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000012u, name: "PlainRock", itemType: (uint)ItemType.Misc, weenieFlags: 0u); - - var parsed = CreateObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.Equal(0u, parsed!.Value.UiEffects); - } - - [Fact] - public void TryParse_WeenieClassId_Surfaced() - { - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000020u, name: "Sword", itemType: (uint)ItemType.MeleeWeapon, - weenieClassId: 0xABCDu); - var parsed = CreateObject.TryParse(body); - Assert.NotNull(parsed); - Assert.Equal(0xABCDu, parsed!.Value.WeenieClassId); - } - - [Fact] - public void TryParse_FullItemFields_Captured() - { - uint flags = - 0x00000008u | 0x00001000u | 0x00002000u | 0x00200000u | - 0x00000002u | 0x00000004u | 0x00004000u | 0x00008000u | - 0x00010000u | 0x00020000u | 0x00040000u | 0x00000400u | - 0x00000800u | 0x01000000u; - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000021u, name: "Pack", itemType: (uint)ItemType.Container, - weenieFlags: flags, - value: 250u, stackSize: 7, maxStackSize: 100u, burden: 42, - itemsCapacity: 24, containersCapacity: 7, - container: 0x50000099u, wielder: 0x5000009Au, - validLocations: 0x02000000u, currentWieldedLocation: 0x02000000u, - priority: 8u, structure: 5, maxStructure: 10, workmanship: 7.5f); - var parsed = CreateObject.TryParse(body); - Assert.NotNull(parsed); - var p = parsed!.Value; - Assert.Equal(250, p.Value); - Assert.Equal(7, p.StackSize); - Assert.Equal(100, p.StackSizeMax); - Assert.Equal(42, p.Burden); - Assert.Equal(24, p.ItemsCapacity); - Assert.Equal(7, p.ContainersCapacity); - Assert.Equal(0x50000099u, p.ContainerId); - Assert.Equal(0x5000009Au, p.WielderId); - Assert.Equal(0x02000000u, p.ValidLocations); - Assert.Equal(0x02000000u, p.CurrentWieldedLocation); - Assert.Equal(8u, p.Priority); - Assert.Equal(5, p.Structure); - Assert.Equal(10, p.MaxStructure); - Assert.Equal(7.5f, p.Workmanship); - } - - [Fact] - public void TryParse_MovementSequence_SurfacedFromTimestampBlock() - { - // L.2g S1 (DEV-6): index 1 of the 9-u16 PhysicsDesc timestamp block - // is ObjectMovement (ACE WorldObject_Networking.cs:412) — it seeds - // MotionSequenceGate's MOVEMENT_TS so post-spawn UpdateMotion events - // are judged against the entity's live sequence, not zero. - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000030u, name: "Runner", itemType: 0x10u, - movementSeq: 0x9000); - var parsed = CreateObject.TryParse(body); - Assert.NotNull(parsed); - Assert.Equal((ushort)0x9000, parsed!.Value.MovementSequence); - } - - [Fact] - public void TryParse_MidTailFieldsSet_StillReachesIconOverlay() - { - uint flags = 0x00001000u | 0x00004000u | 0x40000000u; - byte[] body = BuildMinimalCreateObjectWithWeenieHeader( - guid: 0x50000022u, name: "Ring", itemType: (uint)ItemType.Jewelry, - weenieFlags: flags, stackSize: 1, container: 0x500000F0u, - iconOverlayId: 0x4321u); - var parsed = CreateObject.TryParse(body); - Assert.NotNull(parsed); - Assert.Equal(0x06004321u, parsed!.Value.IconOverlayId); - Assert.Equal(0x500000F0u, parsed.Value.ContainerId); - } - private static byte[] BuildMinimalCreateObjectWithWeenieHeader( uint guid, string name, @@ -460,30 +163,9 @@ public sealed class CreateObjectTests uint physicsState = 0, uint objectDescriptionFlags = 0, uint weenieFlags = 0, - uint weenieFlags2 = 0, - uint iconId = 0, - uint uiEffects = 0, uint? value = null, uint? useability = null, - float? useRadius = null, - uint iconOverlayId = 0, - uint iconUnderlayId = 0, - // intermediate fields for cursor-arithmetic test - ushort? structure = null, - ushort? maxStructure = null, - ushort? stackSize = null, - ushort? burden = null, - uint weenieClassId = 0x1234, - uint? maxStackSize = null, - byte? itemsCapacity = null, - byte? containersCapacity = null, - uint? container = null, - uint? wielder = null, - uint? validLocations = null, - uint? currentWieldedLocation = null, - uint? priority = null, - float? workmanship = null, - ushort movementSeq = 0) + float? useRadius = null) { var bytes = new List(); WriteU32(bytes, CreateObject.Opcode); @@ -495,84 +177,35 @@ public sealed class CreateObjectTests bytes.Add(0); bytes.Add(0); - // PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps - // (PhysicsTimeStamp enum order; index 1 = ObjectMovement). + // PhysicsData: physics flags = 0, then PhysicsState u32, then 9 seq stamps. WriteU32(bytes, 0); WriteU32(bytes, physicsState); for (int i = 0; i < 9; i++) - WriteU16(bytes, i == 1 ? movementSeq : (ushort)0); + WriteU16(bytes, 0); Align4(bytes); // Fixed WeenieHeader prefix per ACE SerializeCreateObject. WriteU32(bytes, weenieFlags); // weenieFlags WriteString16L(bytes, name); - WritePackedDword(bytes, weenieClassId); // WeenieClassId - WritePackedDword(bytes, iconId); // IconId via known-type writer (prefix stripped by ACE writer) + WritePackedDword(bytes, 0x1234); // WeenieClassId + WritePackedDword(bytes, 0); // IconId via known-type writer WriteU32(bytes, itemType); WriteU32(bytes, objectDescriptionFlags); Align4(bytes); - // IncludesSecondHeader → weenieFlags2 written immediately after the align, - // before any other optional tail field (ACE WorldObject_Networking.cs:84-85). - if ((objectDescriptionFlags & 0x04000000u) != 0) - WriteU32(bytes, weenieFlags2); - - // Optional WeenieHeader tail — same order as ACE - // WorldObject_Networking.cs:87-206. Each field is written only when + // Optional WeenieHeader tail (2026-05-15) — same order as ACE + // WorldObject_Networking.cs:87-114. Each field is written only when // its weenieFlags bit is set, matching the parser's walker exactly. - // Fields not parameterized above default to 0. - if ((weenieFlags & 0x00000001u) != 0) { /* PluralName — not parameterized */ } - if ((weenieFlags & 0x00000002u) != 0) bytes.Add(itemsCapacity ?? 0); // ItemsCapacity u8 - if ((weenieFlags & 0x00000004u) != 0) bytes.Add(containersCapacity ?? 0); // ContainersCapacity u8 - if ((weenieFlags & 0x00000100u) != 0) WriteU16(bytes, 0); // AmmoType u16 - if ((weenieFlags & 0x00000008u) != 0) WriteU32(bytes, value ?? 0u); // Value u32 - if ((weenieFlags & 0x00000010u) != 0) WriteU32(bytes, useability ?? 0u); // Usable u32 - if ((weenieFlags & 0x00000020u) != 0) // UseRadius f32 + if ((weenieFlags & 0x00000008u) != 0) // Value u32 + WriteU32(bytes, value ?? 0u); + if ((weenieFlags & 0x00000010u) != 0) // Useability u32 + WriteU32(bytes, useability ?? 0u); + if ((weenieFlags & 0x00000020u) != 0) // UseRadius f32 { Span tmp = stackalloc byte[4]; BinaryPrimitives.WriteSingleLittleEndian(tmp, useRadius ?? 0f); bytes.AddRange(tmp.ToArray()); } - if ((weenieFlags & 0x00080000u) != 0) WriteU32(bytes, 0); // TargetType u32 - if ((weenieFlags & 0x00000080u) != 0) WriteU32(bytes, uiEffects); // UiEffects u32 - if ((weenieFlags & 0x00000200u) != 0) bytes.Add(0); // CombatUse sbyte/1 byte - if ((weenieFlags & 0x00000400u) != 0) WriteU16(bytes, structure ?? 0); // Structure u16 - if ((weenieFlags & 0x00000800u) != 0) WriteU16(bytes, maxStructure ?? 0); // MaxStructure u16 - if ((weenieFlags & 0x00001000u) != 0) WriteU16(bytes, stackSize ?? 0); // StackSize u16 - if ((weenieFlags & 0x00002000u) != 0) WriteU16(bytes, (ushort)(maxStackSize ?? 0)); // MaxStackSize u16 - if ((weenieFlags & 0x00004000u) != 0) WriteU32(bytes, container ?? 0); // Container u32 - if ((weenieFlags & 0x00008000u) != 0) WriteU32(bytes, wielder ?? 0); // Wielder u32 - if ((weenieFlags & 0x00010000u) != 0) WriteU32(bytes, validLocations ?? 0); // ValidLocations u32 - if ((weenieFlags & 0x00020000u) != 0) WriteU32(bytes, currentWieldedLocation ?? 0); // CurrentlyWieldedLocation u32 - if ((weenieFlags & 0x00040000u) != 0) WriteU32(bytes, priority ?? 0); // Priority u32 - if ((weenieFlags & 0x00100000u) != 0) bytes.Add(0); // RadarBlipColor u8 - if ((weenieFlags & 0x00800000u) != 0) bytes.Add(0); // RadarBehavior u8 - if ((weenieFlags & 0x08000000u) != 0) WriteU16(bytes, 0); // PScript u16 - if ((weenieFlags & 0x01000000u) != 0) // Workmanship f32 - { - Span tmp = stackalloc byte[4]; - BinaryPrimitives.WriteSingleLittleEndian(tmp, workmanship ?? 0f); - bytes.AddRange(tmp.ToArray()); - } - if ((weenieFlags & 0x00200000u) != 0) WriteU16(bytes, burden ?? 0); // Burden u16 - if ((weenieFlags & 0x00400000u) != 0) WriteU16(bytes, 0); // Spell u16 - if ((weenieFlags & 0x02000000u) != 0) WriteU32(bytes, 0); // HouseOwner u32 - // HouseRestrictions (0x04000000): not parameterized (zero entries). - // Wire: Version(u32) + OpenStatus(u32) + MonarchId(u32) + count(u16) + numBuckets(u16) + entries. - // Zero entries → 16 bytes total. - if ((weenieFlags & 0x04000000u) != 0) - { - WriteU32(bytes, 0x10000002u); // Version - WriteU32(bytes, 0u); // OpenStatus - WriteU32(bytes, 0u); // MonarchId - WriteU16(bytes, 0); // count - WriteU16(bytes, 768); // numBuckets (retail constant) - } - if ((weenieFlags & 0x20000000u) != 0) WriteU32(bytes, 0); // HookItemTypes u32 - if ((weenieFlags & 0x00000040u) != 0) WriteU32(bytes, 0); // Monarch u32 - if ((weenieFlags & 0x10000000u) != 0) WriteU16(bytes, 0); // HookType u16 - if ((weenieFlags & 0x40000000u) != 0) WritePackedDword(bytes, iconOverlayId); // IconOverlay - if ((weenieFlags2 & 0x00000001u) != 0) WritePackedDword(bytes, iconUnderlayId); // IconUnderlay return bytes.ToArray(); } diff --git a/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs index b1140d94..b464cab1 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/DeleteObjectTests.cs @@ -36,42 +36,4 @@ public sealed class DeleteObjectTests Assert.Equal(0x80000439u, parsed!.Value.Guid); Assert.Equal((ushort)0x1234, parsed.Value.InstanceSequence); } - - /// - /// Regression guard: TryParse (0xF747 = true destroy) must always produce - /// FromPickup = false. PickupEvent (0xF74A) is a different opcode and is - /// the ONLY path that sets FromPickup = true (in WorldSession). - /// - [Fact] - public void TryParse_AlwaysReturnsFromPickupFalse() - { - Span body = stackalloc byte[12]; - BinaryPrimitives.WriteUInt32LittleEndian(body, DeleteObject.Opcode); - BinaryPrimitives.WriteUInt32LittleEndian(body.Slice(4), 0x80000001u); - BinaryPrimitives.WriteUInt16LittleEndian(body.Slice(8), 0x0001); - - var parsed = DeleteObject.TryParse(body); - - Assert.NotNull(parsed); - Assert.False(parsed!.Value.FromPickup, - "DeleteObject 0xF747 is a true destroy — FromPickup must be false."); - } - - /// - /// FromPickup = true can be constructed via the record directly (as - /// WorldSession does for PickupEvent 0xF74A). Default is false. - /// - [Fact] - public void Parsed_DefaultFromPickupIsFalse() - { - var p = new DeleteObject.Parsed(0x80000001u, 1); - Assert.False(p.FromPickup); - } - - [Fact] - public void Parsed_FromPickupTrueCanBeSet() - { - var p = new DeleteObject.Parsed(0x80000001u, 1, FromPickup: true); - Assert.True(p.FromPickup); - } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs b/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs deleted file mode 100644 index 0517a1ff..00000000 --- a/tests/AcDream.Core.Net.Tests/Messages/GameEventsInventoryTests.cs +++ /dev/null @@ -1,75 +0,0 @@ -using System.Buffers.Binary; -using AcDream.Core.Net.Messages; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -public sealed class GameEventsInventoryTests -{ - [Fact] - public void ParseViewContents_twoEntries_returnsContainerAndItems() - { - // containerGuid + count(2) + 2×{guid, containerType} - var b = new byte[4 + 4 + 2 * 8]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); // containerGuid - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 2u); // count - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 0x50000A01u); // guid 1 - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 0u); // type 1 (item) - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(16), 0x50000A02u);// guid 2 - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(20), 1u); // type 2 (container) - - var p = GameEvents.ParseViewContents(b); - Assert.NotNull(p); - Assert.Equal(0x500000C9u, p!.Value.ContainerGuid); - Assert.Equal(2, p.Value.Items.Count); - Assert.Equal(0x50000A01u, p.Value.Items[0].Guid); - Assert.Equal(0u, p.Value.Items[0].ContainerType); - Assert.Equal(0x50000A02u, p.Value.Items[1].Guid); - Assert.Equal(1u, p.Value.Items[1].ContainerType); - } - - [Fact] - public void ParseViewContents_zeroCount_returnsEmptyList() - { - var b = new byte[8]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x500000C9u); - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0u); - var p = GameEvents.ParseViewContents(b); - Assert.NotNull(p); - Assert.Empty(p!.Value.Items); - } - - [Fact] - public void ParseViewContents_truncated_returnsNull() - => Assert.Null(GameEvents.ParseViewContents(new byte[4])); - - [Fact] - public void ParsePutObjInContainer_readsAllFourFields() - { - var b = new byte[16]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x500000C9u); // containerGuid - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(8), 3u); // placement - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(12), 1u); // containerType - - var p = GameEvents.ParsePutObjInContainer(b); - Assert.NotNull(p); - Assert.Equal(0x50000A01u, p!.Value.ItemGuid); - Assert.Equal(0x500000C9u, p.Value.ContainerGuid); - Assert.Equal(3u, p.Value.Placement); - Assert.Equal(1u, p.Value.ContainerType); - } - - [Fact] - public void ParseInventoryServerSaveFailed_readsGuidAndError() - { - var b = new byte[8]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), 0x50000A01u); // itemGuid - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), 0x0Au); // weenieError - - var p = GameEvents.ParseInventoryServerSaveFailed(b); - Assert.NotNull(p); - Assert.Equal(0x50000A01u, p!.Value.ItemGuid); - Assert.Equal(0x0Au, p.Value.WeenieError); - } -} diff --git a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs index 750147d0..5bfec696 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/InventoryActionsTests.cs @@ -74,25 +74,18 @@ public sealed class InventoryActionsTests } [Fact] - public void BuildAddShortcut_ItemShortcut_FieldLayout() + public void BuildAddShortcut_ThreeFields() { - // ShortCutData = Index(u32), ObjectId(u32), SpellId(u16), Layer(u16). Item → spell/layer 0. - byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 0, objectGuid: 0x3E1, spellId: 0, layer: 0); - Assert.Equal(24, body.Length); + byte[] body = InventoryActions.BuildAddShortcut( + seq: 1, slotIndex: 0, objectType: 1, targetId: 0x3E1); Assert.Equal(InventoryActions.AddShortcutOpcode, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8))); - Assert.Equal(0u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); // index - Assert.Equal(0x3E1u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); // objectGuid - Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); // spellId - Assert.Equal((ushort)0, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); // layer - } - - [Fact] - public void BuildAddShortcut_SpellShortcut_PacksSpellAndLayerAsU16s() - { - byte[] body = InventoryActions.BuildAddShortcut(seq: 1, index: 2, objectGuid: 0, spellId: 0x1234, layer: 3); - Assert.Equal(0x1234, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(20))); - Assert.Equal(3, BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(22))); + Assert.Equal(0u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); + Assert.Equal(1u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16))); + Assert.Equal(0x3E1u, + BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20))); } [Fact] @@ -113,38 +106,4 @@ public sealed class InventoryActionsTests Assert.Equal(42u, BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12))); } - - [Fact] - public void BuildDropItem_layout() - { - byte[] b = InventoryActions.BuildDropItem(seq: 7, itemGuid: 0x50000A01u); - Assert.Equal(16, b.Length); - Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); - Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); - Assert.Equal(0x001Bu, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); - Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); - } - - [Fact] - public void BuildGetAndWieldItem_layout() - { - byte[] b = InventoryActions.BuildGetAndWieldItem(seq: 7, itemGuid: 0x50000A01u, equipMask: 0x02u); - Assert.Equal(20, b.Length); - Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); - Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); - Assert.Equal(0x001Au, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); - Assert.Equal(0x50000A01u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); - Assert.Equal(0x02u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(16))); - } - - [Fact] - public void BuildNoLongerViewingContents_layout() - { - byte[] b = InventoryActions.BuildNoLongerViewingContents(seq: 7, containerGuid: 0x500000C9u); - Assert.Equal(16, b.Length); - Assert.Equal(0xF7B1u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(0))); - Assert.Equal(7u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(4))); - Assert.Equal(0x0195u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(8))); - Assert.Equal(0x500000C9u, BinaryPrimitives.ReadUInt32LittleEndian(b.AsSpan(12))); - } } diff --git a/tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs deleted file mode 100644 index b4588fe9..00000000 --- a/tests/AcDream.Core.Net.Tests/Messages/InventoryRemoveObjectTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using System.Buffers.Binary; -using AcDream.Core.Net.Messages; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -public sealed class InventoryRemoveObjectTests -{ - private static byte[] Build(uint guid, uint opcode = 0x0024u) - { - var b = new byte[8]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(4), guid); - return b; - } - - [Fact] - public void TryParse_valid_returnsGuid() - { - var p = InventoryRemoveObject.TryParse(Build(0x50000A07u)); - Assert.NotNull(p); - Assert.Equal(0x50000A07u, p!.Value.Guid); - } - - [Fact] - public void TryParse_wrongOpcode_returnsNull() - => Assert.Null(InventoryRemoveObject.TryParse(Build(1, opcode: 0x0023u))); - - [Fact] - public void TryParse_truncated_returnsNull() - => Assert.Null(InventoryRemoveObject.TryParse(new byte[7])); -} diff --git a/tests/AcDream.Core.Net.Tests/Messages/JumpActionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/JumpActionTests.cs deleted file mode 100644 index 779ab40c..00000000 --- a/tests/AcDream.Core.Net.Tests/Messages/JumpActionTests.cs +++ /dev/null @@ -1,170 +0,0 @@ -using System; -using System.Buffers.Binary; -using System.Numerics; -using AcDream.Core.Net.Messages; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -/// -/// Golden-byte tests for the retail-faithful -/// layout, porting JumpPack::Pack (0x00516d10, decomp lines -/// ~284934-284963). Confirmed verbatim against the Ghidra decompile-by-address -/// bridge (http://127.0.0.1:8081/decompile_function?address=0x00516d10) -/// during this slice (2026-06-30): -/// -/// -/// extent (f32), velocity.x/y/z (f32 x3), Position::Pack (cellId + Frame), -/// instance_timestamp (u16), server_control_timestamp (u16), -/// teleport_timestamp (u16), force_position_ts (u16), ALIGN_PTR. -/// -/// -/// D4: retail does NOT pack an objectGuid or spellId. The pre-slice acdream -/// code wrote two spurious trailing u32 0 fields and omitted Position -/// entirely — both are fixed here. -/// -public class JumpActionTests -{ - [Fact] - public void Build_ProducesValidGameAction() - { - var body = JumpAction.Build( - gameActionSequence: 9, - extent: 0.5f, - velocity: new Vector3(1f, 2f, 3f), - cellId: 0xA9B40001u, - position: new Vector3(96f, 96f, 50f), - rotation: Quaternion.Identity, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0); - - uint opcode = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); - Assert.Equal(0xF7B1u, opcode); - - uint seq = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)); - Assert.Equal(9u, seq); - - uint actionType = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)); - Assert.Equal(0xF61Bu, actionType); - } - - [Fact] - public void Build_ExtentAndVelocity_FollowEnvelope() - { - var body = JumpAction.Build( - gameActionSequence: 1, - extent: 0.75f, - velocity: new Vector3(1.5f, -2.5f, 9.81f), - cellId: 0xA9B40001u, - position: Vector3.Zero, - rotation: Quaternion.Identity, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0); - - // 12-byte envelope, then extent(4), vx(4), vy(4), vz(4). - float extent = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(12)); - float vx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(16)); - float vy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(20)); - float vz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24)); - - Assert.Equal(0.75f, extent); - Assert.Equal(1.5f, vx); - Assert.Equal(-2.5f, vy); - Assert.Equal(9.81f, vz); - } - - [Fact] - public void Build_PositionFollowsVelocity_CellIdThenOriginThenQuaternion() - { - var body = JumpAction.Build( - gameActionSequence: 2, - extent: 0f, - velocity: Vector3.Zero, - cellId: 0xDEADBEEFu, - position: new Vector3(12.5f, 34.0f, 56.75f), - rotation: Quaternion.Identity, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0); - - // 12 (envelope) + 4 (extent) + 12 (velocity) = offset 28 -> Position::Pack. - int positionOffset = 28; - uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(positionOffset)); - float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 4)); - float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 8)); - float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 12)); - float qw = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 16)); - float qx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 20)); - float qy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 24)); - float qz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(positionOffset + 28)); - - Assert.Equal(0xDEADBEEFu, cellId); - Assert.Equal(12.5f, x); - Assert.Equal(34.0f, y); - Assert.Equal(56.75f, z); - Assert.Equal(1.0f, qw); - Assert.Equal(0.0f, qx); - Assert.Equal(0.0f, qy); - Assert.Equal(0.0f, qz); - } - - [Fact] - public void Build_TimestampsFollowPosition_InRetailOrder() - { - var body = JumpAction.Build( - gameActionSequence: 3, - extent: 0f, - velocity: Vector3.Zero, - cellId: 0xA9B40001u, - position: Vector3.Zero, - rotation: Quaternion.Identity, - instanceSequence: 0x1111, - serverControlSequence: 0x2222, - teleportSequence: 0x3333, - forcePositionSequence: 0x4444); - - // Position::Pack is 32 bytes (cellId + Frame). Timestamps start at - // 28 (extent/velocity/envelope) + 32 = 60. - int tsOffset = 60; - ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset)); - ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2)); - ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4)); - ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6)); - - Assert.Equal((ushort)0x1111, instance); - Assert.Equal((ushort)0x2222, serverControl); - Assert.Equal((ushort)0x3333, teleport); - Assert.Equal((ushort)0x4444, forcePosition); - } - - [Fact] - public void Build_NoObjectGuidOrSpellId_JumpPackBodyLengthIs56() - { - // JumpPack::Pack body (everything AFTER the 12-byte GameAction - // envelope): extent(4) + velocity(12) + Position(32) + - // 4x u16 timestamps(8) = 56 bytes, already a multiple of 4 -> no - // align padding. Total wire length = 12 (envelope) + 56 = 68. - // Retail's JumpPack has NO objectGuid/spellId fields (D4) — the - // pre-slice code's two spurious trailing u32 writes are gone. - var body = JumpAction.Build( - gameActionSequence: 4, - extent: 0f, - velocity: Vector3.Zero, - cellId: 0xA9B40001u, - position: Vector3.Zero, - rotation: Quaternion.Identity, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0); - - Assert.Equal(56, body.Length - 12); - Assert.Equal(68, body.Length); - Assert.Equal(0, body.Length % 4); - } -} diff --git a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateGoldenTests.cs b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateGoldenTests.cs deleted file mode 100644 index 83c0bd19..00000000 --- a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateGoldenTests.cs +++ /dev/null @@ -1,254 +0,0 @@ -using System; -using System.Buffers.Binary; -using System.Numerics; -using AcDream.Core.Net.Messages; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -/// -/// Full-message golden-byte tests for the refactored -/// (D1 default-difference RawMotionState packing + D3 trailing byte), porting -/// MoveToStatePack::Pack (0x005168f0, decomp lines ~284694-284722). -/// Confirmed verbatim against the Ghidra decompile-by-address bridge during -/// this slice (2026-06-30): -/// -/// -/// MoveToStatePack::Pack: -/// RawMotionState::Pack(...) -/// Position::Pack(...) -/// instance_timestamp(u16), server_control_timestamp(u16), -/// teleport_timestamp(u16), force_position_ts(u16) -/// trailing byte = ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0) -/// ALIGN_PTR -/// -/// -public class MoveToStateGoldenTests -{ - private static readonly Vector3 Pos = new(96f, 96f, 50f); - private static readonly Quaternion Rot = Quaternion.Identity; - - [Fact] - public void Build_DefaultRawMotionState_FlagsAreZero_EnvelopePlusPositionPlusTimestampsPlusTrailingByte() - { - var body = MoveToState.Build( - gameActionSequence: 1, - rawMotionState: RawMotionState.Default, - cellId: 0xA9B40001u, - position: Pos, - rotation: Rot, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0, - contact: true, - standingLongjump: false); - - // 12 (envelope) + 4 (flags=0, no fields) + 32 (Position) + 8 (timestamps) - // + 1 (trailing byte) = 57, aligned to 60. - Assert.Equal(60, body.Length); - - uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); - Assert.Equal(0u, flags); - - uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)); - Assert.Equal(0xA9B40001u, cellId); - - // trailing byte at 12+4+32+8 = 56: contact=true, longjump=false -> 0x01 - Assert.Equal(0x01, body[56]); - } - - [Fact] - public void Build_WalkForward_OmitsForwardSpeedDefault() - { - // Walk-forward at default speed 1.0 -> forward_speed bit OMITTED (D1 fix). - var state = new RawMotionState - { - CurrentHoldKey = HoldKey.None, // default, omitted - ForwardCommand = 0x45000005u, // WalkForward - ForwardHoldKey = HoldKey.None, // differs from Invalid -> set - ForwardSpeed = 1.0f, // default, omitted - }; - - var body = MoveToState.Build( - gameActionSequence: 2, - rawMotionState: state, - cellId: 0xA9B40001u, - position: Pos, - rotation: Rot, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0, - contact: true, - standingLongjump: false); - - uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); - Assert.Equal(0x0000000Cu, flags); // ForwardCommand | ForwardHoldKey only - - // RawMotionState body = flags(4) + fwd_cmd(4) + fwd_holdkey(4) = 12. - uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12 + 12)); - Assert.Equal(0xA9B40001u, cellId); - } - - [Fact] - public void Build_RunForward_IncludesHoldKeyAndSpeed() - { - var state = new RawMotionState - { - CurrentHoldKey = HoldKey.Run, - ForwardCommand = 0x44000007u, // RunForward - ForwardHoldKey = HoldKey.Run, - ForwardSpeed = 2.94f, - }; - - var body = MoveToState.Build( - gameActionSequence: 3, - rawMotionState: state, - cellId: 0xA9B40001u, - position: Pos, - rotation: Rot, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0, - contact: true, - standingLongjump: false); - - uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); - Assert.Equal(0x0000001Du, flags); // holdkey | fwd_cmd | fwd_holdkey | fwd_speed - - // RawMotionState body = flags(4) + holdkey(4) + fwd_cmd(4) + fwd_holdkey(4) + fwd_speed(4) = 20. - uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12 + 20)); - Assert.Equal(0xA9B40001u, cellId); - } - - [Fact] - public void Build_Sidestep_SetsSidestepBitsOnly() - { - var state = new RawMotionState - { - SidestepCommand = 0x6500000Fu, // SideStepRight - SidestepHoldKey = HoldKey.None, - SidestepSpeed = 1.0f, // default -> omitted - }; - - var body = MoveToState.Build( - gameActionSequence: 4, - rawMotionState: state, - cellId: 0xA9B40001u, - position: Pos, - rotation: Rot, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0, - contact: true, - standingLongjump: false); - - uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); - Assert.Equal(0x00000060u, flags); // SidestepCommand(0x20) | SidestepHoldKey(0x40) - } - - [Fact] - public void Build_Turn_SetsTurnBitsOnly() - { - var state = new RawMotionState - { - TurnCommand = 0x6500000Du, // TurnRight - TurnHoldKey = HoldKey.None, - TurnSpeed = 1.0f, // default -> omitted - }; - - var body = MoveToState.Build( - gameActionSequence: 5, - rawMotionState: state, - cellId: 0xA9B40001u, - position: Pos, - rotation: Rot, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0, - contact: true, - standingLongjump: false); - - uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); - Assert.Equal(0x00000300u, flags); // TurnCommand(0x100) | TurnHoldKey(0x200) - } - - [Theory] - [InlineData(false, false, 0x00)] - [InlineData(true, false, 0x01)] - [InlineData(false, true, 0x02)] - [InlineData(true, true, 0x03)] - public void Build_TrailingByte_AllFourContactLongjumpCombinations(bool contact, bool standingLongjump, byte expected) - { - // MoveToStatePack::Pack trailing byte: - // ((longjump_mode != 0) ? 0x02 : 0) | (contact != 0 ? 0x01 : 0) - var body = MoveToState.Build( - gameActionSequence: 6, - rawMotionState: RawMotionState.Default, - cellId: 0xA9B40001u, - position: Pos, - rotation: Rot, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0, - contact: contact, - standingLongjump: standingLongjump); - - // flags(4) + Position(32) + timestamps(8) = 44; trailing byte at 12+44=56. - Assert.Equal(expected, body[56]); - } - - [Fact] - public void Build_TimestampOrder_MatchesMoveToStatePackPack() - { - var body = MoveToState.Build( - gameActionSequence: 7, - rawMotionState: RawMotionState.Default, - cellId: 0xA9B40001u, - position: Pos, - rotation: Rot, - instanceSequence: 0x1111, - serverControlSequence: 0x2222, - teleportSequence: 0x3333, - forcePositionSequence: 0x4444, - contact: true, - standingLongjump: false); - - // 12 (envelope) + 4 (flags) + 32 (Position) = 48. - int tsOffset = 48; - ushort instance = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset)); - ushort serverControl = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 2)); - ushort teleport = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 4)); - ushort forcePosition = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(tsOffset + 6)); - - Assert.Equal((ushort)0x1111, instance); - Assert.Equal((ushort)0x2222, serverControl); - Assert.Equal((ushort)0x3333, teleport); - Assert.Equal((ushort)0x4444, forcePosition); - } - - [Fact] - public void Build_IsAlignedTo4Bytes() - { - var body = MoveToState.Build( - gameActionSequence: 8, - rawMotionState: RawMotionState.Default, - cellId: 0xA9B40001u, - position: Pos, - rotation: Rot, - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0, - contact: true, - standingLongjump: false); - - Assert.Equal(0, body.Length % 4); - } -} diff --git a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs index d5729940..53f95d60 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/MoveToStateTests.cs @@ -2,36 +2,26 @@ using System; using System.Buffers.Binary; using System.Numerics; using AcDream.Core.Net.Messages; -using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Net.Tests.Messages; -/// -/// Envelope + structural tests for . Golden -/// byte-layout tests for the RawMotionState default-difference packing (D1) -/// and the trailing contact/longjump byte (D3) live in -/// and . -/// -/// D1/D3 refactor (2026-06-30): Build now takes a -/// snapshot (matching retail's -/// CPhysicsObj::InqRawMotionState()) instead of flat nullable -/// per-axis params, plus explicit contact/standingLongjump -/// booleans for the trailing byte — see MoveToStatePack::Pack -/// (0x005168f0). -/// public class MoveToStateTests { - private static readonly Vector3 Pos = new(96f, 96f, 50f); - [Fact] public void Build_IdleState_ProducesValidGameAction() { var body = MoveToState.Build( gameActionSequence: 1, - rawMotionState: RawMotionState.Default, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, cellId: 0xA9B40001u, - position: Pos, + position: new Vector3(96f, 96f, 50f), rotation: Quaternion.Identity, instanceSequence: 0, serverControlSequence: 0, @@ -52,33 +42,31 @@ public class MoveToStateTests } [Fact] - public void Build_WalkForward_DefaultSpeedOmitted_OnlyCommandAndHoldKeyFlagsSet() + public void Build_WalkForward_IncludesForwardCommandInFlags() { - // D1 fix: forward_speed == 1.0 (the retail default) is OMITTED from - // the flags, unlike the pre-slice presence-based packer which always - // set the ForwardSpeed bit whenever a caller supplied a value. - var state = new RawMotionState - { - ForwardCommand = 0x45000005u, // WalkForward - ForwardHoldKey = HoldKey.None, // differs from Invalid -> set - ForwardSpeed = 1.0f, // default -> omitted - }; - var body = MoveToState.Build( gameActionSequence: 2, - rawMotionState: state, + forwardCommand: 0x45000005u, // WalkForward + forwardSpeed: 1.0f, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, cellId: 0xA9B40001u, - position: Pos, + position: new Vector3(96f, 96f, 50f), rotation: Quaternion.Identity, instanceSequence: 0, serverControlSequence: 0, teleportSequence: 0, forcePositionSequence: 0); + // After the 12-byte GameAction header comes RawMotionState. + // First u32 is the packed flags word. ForwardCommand flag = 0x4. uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(12)); Assert.True((flags & 0x4u) != 0, "ForwardCommand flag (0x4) should be set"); - Assert.True((flags & 0x8u) != 0, "ForwardHoldKey flag (0x8) should be set"); - Assert.False((flags & 0x10u) != 0, "ForwardSpeed flag (0x10) must be OMITTED at the retail default 1.0"); + // ForwardSpeed flag = 0x10 + Assert.True((flags & 0x10u) != 0, "ForwardSpeed flag (0x10) should be set"); } [Fact] @@ -86,7 +74,13 @@ public class MoveToStateTests { var body = MoveToState.Build( gameActionSequence: 3, - rawMotionState: RawMotionState.Default, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, cellId: 0xA9B40001u, position: Vector3.Zero, rotation: Quaternion.Identity, @@ -100,14 +94,19 @@ public class MoveToStateTests } [Fact] - public void Build_IdleState_WorldPositionFollowsZeroFlagMotionState() + public void Build_IdleState_WorldPositionFollowsMotionState() { - // With the default raw motion state, flags = 0 and no conditional - // fields are written. So WorldPosition starts at offset 12 - // (envelope) + 4 (flags) = 16. + // With no motion state, flags = 0 and no conditional fields are written. + // So WorldPosition starts at offset 12 (envelope) + 4 (flags) = 16. var body = MoveToState.Build( gameActionSequence: 4, - rawMotionState: RawMotionState.Default, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, cellId: 0xDEADBEEFu, position: Vector3.Zero, rotation: Quaternion.Identity, @@ -125,7 +124,13 @@ public class MoveToStateTests { var body = MoveToState.Build( gameActionSequence: 5, - rawMotionState: RawMotionState.Default, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, cellId: 0xA9B40001u, position: Vector3.Zero, rotation: Quaternion.Identity, @@ -138,11 +143,17 @@ public class MoveToStateTests } [Fact] - public void Build_UsesExplicitAirborneContact() + public void Build_UsesExplicitAirborneContactByte() { var body = MoveToState.Build( gameActionSequence: 7, - rawMotionState: RawMotionState.Default, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: null, cellId: 0xA9B40001u, position: Vector3.Zero, rotation: Quaternion.Identity, @@ -150,20 +161,23 @@ public class MoveToStateTests serverControlSequence: 0, teleportSequence: 0, forcePositionSequence: 0, - contact: false); + contactLongJump: 0); - // flags(4) + Position(32) + timestamps(8) = 44; trailing byte at 12+44=56. Assert.Equal(0, body[56]); } [Fact] public void Build_WithHoldKey_IncludesHoldKeyFlag() { - var state = new RawMotionState { CurrentHoldKey = HoldKey.Run }; - var body = MoveToState.Build( gameActionSequence: 6, - rawMotionState: state, + forwardCommand: null, + forwardSpeed: null, + sidestepCommand: null, + sidestepSpeed: null, + turnCommand: null, + turnSpeed: null, + holdKey: 2u, // Run cellId: 0xA9B40001u, position: Vector3.Zero, rotation: Quaternion.Identity, diff --git a/tests/AcDream.Core.Net.Tests/Messages/PositionPackTests.cs b/tests/AcDream.Core.Net.Tests/Messages/PositionPackTests.cs deleted file mode 100644 index 95d8d983..00000000 --- a/tests/AcDream.Core.Net.Tests/Messages/PositionPackTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Buffers.Binary; -using System.Numerics; -using AcDream.Core.Net.Messages; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -/// -/// Pins the shared WorldPosition/Position block byte order used by -/// , , and -/// : Position::Pack (0x005a9640) wraps -/// cellId(u32) then Frame::Pack (0x00535130) = -/// origin.x/y/z(f32) then qw/qx/qy/qz(f32). Confirmed verbatim -/// against the Ghidra decompile-by-address bridge during this slice -/// (2026-06-30): -/// -/// -/// Position::Pack: objcell_id(u32), Frame::Pack(...) -/// Frame::Pack: m_fOrigin.x/y/z(f32 x3), qw(f32), qx(f32), qy(f32), qz(f32) -/// -/// -/// 32 bytes total (4 + 12 + 16). This was already correct pre-slice — these -/// tests lock the byte order with a golden-value assertion rather than -/// changing behavior. -/// -public class PositionPackTests -{ - [Fact] - public void MoveToState_PositionBlock_OrderIsCellIdThenOriginThenQuaternion() - { - var body = MoveToState.Build( - gameActionSequence: 1, - rawMotionState: RawMotionState.Default, - cellId: 0xA9B40001u, - position: new Vector3(1.5f, 2.5f, 3.5f), - rotation: new Quaternion(0.1f, 0.2f, 0.3f, 0.9f), // X,Y,Z,W ctor order - instanceSequence: 0, - serverControlSequence: 0, - teleportSequence: 0, - forcePositionSequence: 0); - - // No motion state -> flags(4) only before the Position block at offset 16. - int off = 12 + 4; - uint cellId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(off)); - float x = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 4)); - float y = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 8)); - float z = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 12)); - float qw = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 16)); - float qx = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 20)); - float qy = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 24)); - float qz = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(off + 28)); - - Assert.Equal(0xA9B40001u, cellId); - Assert.Equal(1.5f, x); - Assert.Equal(2.5f, y); - Assert.Equal(3.5f, z); - Assert.Equal(0.9f, qw); - Assert.Equal(0.1f, qx); - Assert.Equal(0.2f, qy); - Assert.Equal(0.3f, qz); - } -} diff --git a/tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs b/tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs deleted file mode 100644 index f696c010..00000000 --- a/tests/AcDream.Core.Net.Tests/Messages/PrivateUpdatePropertyIntTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Buffers.Binary; -using AcDream.Core.Net.Messages; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -public sealed class PrivateUpdatePropertyIntTests -{ - // 0x02CD body: opcode(4) + seq(1) + property(4) + value(4) = 13 bytes. NO guid. - private static byte[] Build(uint property, int value, byte seq = 1, uint opcode = 0x02CDu) - { - var b = new byte[13]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); - b[4] = seq; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), property); - BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), value); - return b; - } - - [Fact] - public void TryParse_encumbranceVal_returnsPropValue() - { - var p = PrivateUpdatePropertyInt.TryParse(Build(property: 5u, value: 1500)); - Assert.NotNull(p); - Assert.Equal(5u, p!.Value.Property); - Assert.Equal(1500, p.Value.Value); - } - - [Fact] - public void TryParse_wrongOpcode_returnsNull() - => Assert.Null(PrivateUpdatePropertyInt.TryParse(Build(5, 1, opcode: 0x02CEu))); - - [Fact] - public void TryParse_truncated_returnsNull() - => Assert.Null(PrivateUpdatePropertyInt.TryParse(new byte[12])); -} diff --git a/tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs b/tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs deleted file mode 100644 index bda5555a..00000000 --- a/tests/AcDream.Core.Net.Tests/Messages/PublicUpdatePropertyIntTests.cs +++ /dev/null @@ -1,36 +0,0 @@ -using System.Buffers.Binary; -using AcDream.Core.Net.Messages; - -namespace AcDream.Core.Net.Tests.Messages; - -public sealed class PublicUpdatePropertyIntTests -{ - private static byte[] Build(uint guid, uint property, int value, byte seq = 1, uint opcode = 0x02CEu) - { - var b = new byte[17]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); - b[4] = seq; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid); - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(9), property); - BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value); - return b; - } - - [Fact] - public void TryParse_uiEffectsUpdate_returnsGuidPropValue() - { - var p = PublicUpdatePropertyInt.TryParse(Build(0x50000001u, property: 18u, value: 0x9)); - Assert.NotNull(p); - Assert.Equal(0x50000001u, p!.Value.Guid); - Assert.Equal(18u, p.Value.Property); - Assert.Equal(0x9, p.Value.Value); - } - - [Fact] - public void TryParse_wrongOpcode_returnsNull() - => Assert.Null(PublicUpdatePropertyInt.TryParse(Build(1, 18, 1, opcode: 0x02CDu))); - - [Fact] - public void TryParse_truncated_returnsNull() - => Assert.Null(PublicUpdatePropertyInt.TryParse(new byte[16])); -} diff --git a/tests/AcDream.Core.Net.Tests/Messages/RawMotionStatePackTests.cs b/tests/AcDream.Core.Net.Tests/Messages/RawMotionStatePackTests.cs deleted file mode 100644 index 29025841..00000000 --- a/tests/AcDream.Core.Net.Tests/Messages/RawMotionStatePackTests.cs +++ /dev/null @@ -1,212 +0,0 @@ -using System.Buffers.Binary; -using AcDream.Core.Net.Messages; -using AcDream.Core.Net.Packets; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -/// -/// Golden-byte tests for , porting -/// retail's RawMotionState::Pack (0x0051ed10, decomp lines -/// ~293761-294013; bitfield layout acclient.h RawMotionState::PackBitfield, -/// line 46474). Confirmed verbatim against the Ghidra decompile-by-address -/// bridge (http://127.0.0.1:8081/decompile_function?address=0x0051ed10) -/// during this slice (2026-06-30). -/// -/// -/// Retail compares every field against its DEFAULT and only sets the -/// matching bit (and emits the field) when the live value DIFFERS. This is -/// the D1 fix — the old presence-based packer over-sent defaulted fields. -/// -/// -public class RawMotionStatePackTests -{ - private static byte[] Pack(RawMotionState state) - { - var w = new PacketWriter(64); - RawMotionStatePacker.Pack(w, state); - return w.ToArray(); - } - - [Fact] - public void Pack_DefaultState_EmitsOnlyZeroFlags() - { - // Every field equals its retail default -> flags dword is 0, - // no conditional fields, no actions. 4 bytes total. - var body = Pack(RawMotionState.Default); - - Assert.Equal(new byte[] { 0x00, 0x00, 0x00, 0x00 }, body); - } - - [Fact] - public void Pack_ShiftWalk_OmitsForwardSpeedAndCurrentHoldKey() - { - // Shift-walk: current_holdkey stays None (default omitted), - // forward_command = WalkForward (0x45000005), forward_holdkey = - // None (1) -- DIFFERS from default Invalid(0) so IS sent -- - // forward_speed stays 1.0 (default, omitted). Rest default. - // - // Flags expected: ForwardCommand(0x004) | ForwardHoldKey(0x008) = 0x00C. - // Body: flags(u32) + forward_command(u32) + forward_holdkey(u32). - var state = new RawMotionState - { - CurrentHoldKey = HoldKey.None, // default -> omitted - ForwardCommand = 0x45000005u, // WalkForward -> differs -> set - ForwardHoldKey = HoldKey.None, // differs from Invalid -> set - ForwardSpeed = 1.0f, // default -> omitted (the D1 fix) - }; - - var body = Pack(state); - - Assert.Equal(12, body.Length); - - uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); - Assert.Equal(0x0000000Cu, flags); - - uint fwdCommand = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)); - Assert.Equal(0x45000005u, fwdCommand); - - uint fwdHoldKey = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)); - Assert.Equal(1u, fwdHoldKey); // HoldKey.None - - byte[] expected = - { - 0x0C, 0x00, 0x00, 0x00, - 0x05, 0x00, 0x00, 0x45, - 0x01, 0x00, 0x00, 0x00, - }; - Assert.Equal(expected, body); - } - - [Fact] - public void Pack_RunForward_SetsHoldKeyForwardCommandHoldKeyAndSpeed() - { - // Run-forward: current_holdkey = Run(2) (differs from None -> set), - // forward_command = RunForward-ish 0x44000007 (differs -> set), - // forward_holdkey = Run(2) (differs from Invalid -> set), - // forward_speed = 3.0 (differs from 1.0 -> set). - // - // Flags expected: CurrentHoldKey(0x001) | ForwardCommand(0x004) | - // ForwardHoldKey(0x008) | ForwardSpeed(0x010) = 0x01D. - var state = new RawMotionState - { - CurrentHoldKey = HoldKey.Run, - ForwardCommand = 0x44000007u, - ForwardHoldKey = HoldKey.Run, - ForwardSpeed = 3.0f, - }; - - var body = Pack(state); - - Assert.Equal(20, body.Length); - - uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); - Assert.Equal(0x0000001Du, flags); - - byte[] expected = - { - 0x1D, 0x00, 0x00, 0x00, // flags - 0x02, 0x00, 0x00, 0x00, // current_holdkey = Run(2) - 0x07, 0x00, 0x00, 0x44, // forward_command = 0x44000007 - 0x02, 0x00, 0x00, 0x00, // forward_holdkey = Run(2) - 0x00, 0x00, 0x40, 0x40, // forward_speed = 3.0f (0x40400000 LE) - }; - Assert.Equal(expected, body); - } - - [Fact] - public void Pack_NonDefaultCurrentStyle_SetsStyleBitAndEmitsValue() - { - // current_style differs from 0x8000003D -> bit 0x002 set, value emitted - // immediately after the flags dword (bit order: holdkey, style, ...). - var state = new RawMotionState - { - CurrentStyle = 0x80000042u, - }; - - var body = Pack(state); - - uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); - Assert.Equal(0x002u, flags); - - uint style = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)); - Assert.Equal(0x80000042u, style); - - Assert.Equal(8, body.Length); - } - - [Fact] - public void Pack_PopulatedActionsList_SetsNumActionsBitsAndEmitsPairs() - { - // num_actions occupies bits 11-15 (mask 0xF800) of the flags dword. - // Two actions -> num_actions = 2 -> bits = 2 << 11 = 0x1000. - // Each action emits u16 command then u16 (stamp & 0x7FFF) | - // (autonomous ? 0x8000 : 0) (decomp ~293998-294010). - var state = new RawMotionState - { - Actions = new[] - { - new RawMotionAction(Command: 0x0150, Stamp: 0x0001, Autonomous: false), - new RawMotionAction(Command: 0x0163, Stamp: 0x7FFF, Autonomous: true), - }, - }; - - var body = Pack(state); - - uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); - Assert.Equal(0x1000u, flags); // num_actions=2 << 11, no continuous-axis bits - - // Body: flags(4) + action0(4) + action1(4) = 12 bytes. - Assert.Equal(12, body.Length); - - ushort cmd0 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(4)); - ushort stamp0 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(6)); - Assert.Equal((ushort)0x0150, cmd0); - Assert.Equal((ushort)0x0001, stamp0); // autonomous=false -> 0x8000 bit clear - - ushort cmd1 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(8)); - ushort stamp1 = BinaryPrimitives.ReadUInt16LittleEndian(body.AsSpan(10)); - Assert.Equal((ushort)0x0163, cmd1); - Assert.Equal((ushort)0xFFFF, stamp1); // (0x7FFF & 0x7FFF) | 0x8000 = 0xFFFF - } - - [Fact] - public void Pack_SidestepAndTurnNonDefault_SetExpectedBitsInOrder() - { - // sidestep_command(0x020), sidestep_holdkey(0x040), sidestep_speed(0x080), - // turn_command(0x100), turn_holdkey(0x200), turn_speed(0x400) all non-default. - var state = new RawMotionState - { - SidestepCommand = 0x44000009u, - SidestepHoldKey = HoldKey.Run, - SidestepSpeed = 1.248f, - TurnCommand = 0x4400000Du, - TurnHoldKey = HoldKey.Run, - TurnSpeed = 1.5f, - }; - - var body = Pack(state); - - uint flags = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0)); - Assert.Equal(0x000007E0u, flags); // 0x20|0x40|0x80|0x100|0x200|0x400 - - // Body order after flags: sidestep_command, sidestep_holdkey, - // sidestep_speed, turn_command, turn_holdkey, turn_speed. - uint ssCmd = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4)); - uint ssHold = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8)); - float ssSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(12)); - uint turnCmd = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(16)); - uint turnHold = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(20)); - float turnSpeed = BinaryPrimitives.ReadSingleLittleEndian(body.AsSpan(24)); - - Assert.Equal(0x44000009u, ssCmd); - Assert.Equal(2u, ssHold); - Assert.Equal(1.248f, ssSpeed); - Assert.Equal(0x4400000Du, turnCmd); - Assert.Equal(2u, turnHold); - Assert.Equal(1.5f, turnSpeed); - - Assert.Equal(28, body.Length); - } -} diff --git a/tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs b/tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs deleted file mode 100644 index 7f09c4f1..00000000 --- a/tests/AcDream.Core.Net.Tests/Messages/SetStackSizeTests.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System.Buffers.Binary; -using AcDream.Core.Net.Messages; -using Xunit; - -namespace AcDream.Core.Net.Tests.Messages; - -public sealed class SetStackSizeTests -{ - private static byte[] Build(uint guid, int stackSize, int value, byte seq = 1, uint opcode = 0x0197u) - { - var b = new byte[17]; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(0), opcode); - b[4] = seq; - BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(5), guid); - BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(9), stackSize); - BinaryPrimitives.WriteInt32LittleEndian(b.AsSpan(13), value); - return b; - } - - [Fact] - public void TryParse_validStack_returnsGuidStackValue() - { - var p = SetStackSize.TryParse(Build(0x50000A01u, stackSize: 25, value: 125)); - Assert.NotNull(p); - Assert.Equal(0x50000A01u, p!.Value.Guid); - Assert.Equal(25, p.Value.StackSize); - Assert.Equal(125, p.Value.Value); - } - - [Fact] - public void TryParse_wrongOpcode_returnsNull() - => Assert.Null(SetStackSize.TryParse(Build(1, 1, 1, opcode: 0x0196u))); - - [Fact] - public void TryParse_truncated_returnsNull() - => Assert.Null(SetStackSize.TryParse(new byte[16])); -} diff --git a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs index 70feca2c..09f9eb96 100644 --- a/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs +++ b/tests/AcDream.Core.Net.Tests/Messages/UpdateMotionTests.cs @@ -1,7 +1,6 @@ using System; using System.Buffers.Binary; using AcDream.Core.Net.Messages; -using AcDream.Core.Physics.Motion; using Xunit; namespace AcDream.Core.Net.Tests.Messages; @@ -304,37 +303,6 @@ public class UpdateMotionTests Assert.Equal(1.25f, result.Value.MotionState.ForwardSpeed); } - [Fact] - public void ParsesSequenceNumbersAndAutonomyFlag() - { - // L.2g S1 (DEV-6): the three staleness stamps + autonomy flag must - // survive parsing — retail gates every 0xF74C on them - // (INSTANCE_TS at dispatch, MOVEMENT_TS + SERVER_CONTROLLED_MOVE_TS - // in CPhysics::SetObjectMovement 0x00509690, which also stores - // last_move_was_autonomous). - var body = new byte[4 + 4 + 2 + 6 + 4 + 4]; - int p = 0; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x50001234u); p += 4; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0102); p += 2; // instanceSeq - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0304); p += 2; // movementSeq - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0506); p += 2; // serverControlSeq - body[p++] = 1; // isAutonomous - p += 1; // Align(4) pad - body[p++] = 0; // movementType = Invalid - body[p++] = 0; // motionFlags - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; // outer stance - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no IMS flags - - var result = UpdateMotion.TryParse(body); - - Assert.NotNull(result); - Assert.Equal((ushort)0x0102, result!.Value.InstanceSequence); - Assert.Equal((ushort)0x0304, result.Value.MovementSequence); - Assert.Equal((ushort)0x0506, result.Value.ServerControlSequence); - Assert.True(result.Value.IsAutonomous); - } - [Fact] public void ParsesMoveToObjectTargetGuidAndOrigin() { @@ -383,336 +351,4 @@ public class UpdateMotionTests Assert.Equal(7f, path.OriginZ); Assert.Equal(1.25f, result.Value.MotionState.MoveToRunRate); } - - // ───────────────────────────────────────────────────────────────── - // R4-V3 (closes M7): mt 8 (TurnToObject) / mt 9 (TurnToHeading). - // - // Golden bytes assembled from ACE's own writers (V0-pins.md P6): - // MovementDataExtensions.Write (references/ACE/Source/ACE.Server/ - // Network/Motion/MovementData.cs:184-229) writes the common header - // (movementType u8, motionFlags u8, currentStyle u16) then dispatches - // on MovementType to: - // TurnToObjectExtensions.Write (TurnToObject.cs:25-30): - // writer.WriteGuid(Target); // u32 - // writer.Write(DesiredHeading); // f32 — the STANDALONE - // // "wire_heading" field - // writer.Write(TurnToParameters); // 3-dword UnPackNet form - // TurnToParametersExtensions.Write (TurnToParameters.cs:23-28): - // writer.Write((uint)MovementParams); // u32 bitfield - // writer.Write(Speed); // f32 - // writer.Write(DesiredHeading); // f32 — TurnToParameters' - // // OWN desired_heading - // TurnToHeadingExtensions.Write (TurnToHeading.cs:18-21): - // writer.Write(TurnToParameters); // 3-dword UnPackNet form only - // - // P6's fixture caveat: ACE always populates field2 (TurnToObject. - // DesiredHeading) and field5 (TurnToParameters.DesiredHeading) from the - // SAME motion.DesiredHeading source, so a byte-faithful ACE capture - // would have field2 == field5. To prove the parser distinguishes the - // two fields by OFFSET (not by coincidentally-equal value), these - // fixtures hand-vary the two headings. - // ───────────────────────────────────────────────────────────────── - - [Fact] - public void ParsesTurnToObject_GuidWireHeadingAndParams() - { - // Header (20 bytes) + guid (4) + wireHeading (4) + TurnToParameters (12) = 40. - var body = new byte[20 + 4 + 4 + 12]; - int p = 0; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80005678u); p += 4; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; - p += 6; // MovementData header padding - - body[p++] = 8; // TurnToObject - body[p++] = 0; // motionFlags - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; - - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80009999u); p += 4; // target guid - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 42.0f); p += 4; // standalone wire_heading (field2) - - const uint flags = 0x1u | 0x2u | 0x200u; // can_walk | can_run | move_towards - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4; // TurnToParameters.bitfield - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.5f); p += 4; // TurnToParameters.speed - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 199.0f); p += 4; // TurnToParameters.desired_heading (field5) — DELIBERATELY != field2 - - var result = UpdateMotion.TryParse(body); - - Assert.NotNull(result); - Assert.Equal((byte)8, result!.Value.MotionState.MovementType); - Assert.True(result.Value.MotionState.IsServerControlledTurnTo); - Assert.False(result.Value.MotionState.IsServerControlledMoveTo); - Assert.NotNull(result.Value.MotionState.TurnToPath); - - var path = result.Value.MotionState.TurnToPath!.Value; - Assert.Equal(0x80009999u, path.TargetGuid); - Assert.Equal(42.0f, path.WireHeading); // field2 — distinguished by OFFSET - Assert.Equal(flags, path.Bitfield); - Assert.Equal(1.5f, path.Speed); - Assert.Equal(199.0f, path.DesiredHeading); // field5 — distinct from field2 - - // The consumer feeds this straight into FromWireTurnTo (App-layer, - // out of scope here) — verify the fixture is round-trippable. - var mp = MovementParameters.FromWireTurnTo(path.Bitfield, path.Speed, path.DesiredHeading); - Assert.True(mp.CanRun); - Assert.Equal(1.5f, mp.Speed); - Assert.Equal(199.0f, mp.DesiredHeading); - } - - [Fact] - public void ParsesTurnToObject_UnresolvableFallback_BothHeadingsSurvivedDistinctly() - { - // Retail's degrade-to-TurnToHeading fallback (decomp §2f case 8) only - // fires when GetObjectA(object_id) == 0 — a runtime/consumer-side - // resolution the wire parser has no visibility into. The parser's - // job is just to expose BOTH heading fields so the (future) V4/V5 - // consumer can implement: "if unresolvable, params.DesiredHeading = - // wireHeading, then degrade to TurnToHeading". Confirm both survive - // even when they'd trigger the fallback (i.e. even when they differ). - var body = new byte[20 + 4 + 4 + 12]; - int p = 0; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x8000AAAAu); p += 4; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; - p += 6; - - body[p++] = 8; - body[p++] = 0; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; - - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xDEADBEEFu); p += 4; // unresolvable guid - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 270.0f); p += 4; // wire_heading — the fallback source - - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x1u); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4; // params.desired_heading (would be overwritten by wire_heading on fallback) - - var result = UpdateMotion.TryParse(body); - - Assert.NotNull(result); - var path = result!.Value.MotionState.TurnToPath!.Value; - Assert.Equal(0xDEADBEEFu, path.TargetGuid); - Assert.Equal(270.0f, path.WireHeading); - Assert.Equal(0.0f, path.DesiredHeading); - Assert.NotEqual(path.WireHeading, path.DesiredHeading); - } - - [Fact] - public void ParsesTurnToHeading_ThreeDwordFormOnly_NoGuidOrWireHeading() - { - // Header (20 bytes) + TurnToParameters (12) = 32. No guid, no - // standalone heading field — TurnToHeadingExtensions.Write emits - // ONLY the 3-dword UnPackNet form (TurnToHeading.cs:18-21). - var body = new byte[20 + 12]; - int p = 0; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x8000BBBBu); p += 4; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; - p += 6; - - body[p++] = 9; // TurnToHeading - body[p++] = 0; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; - - const uint flags = 0x2u | 0x800u; // can_run | set_hold_key - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 2.0f); p += 4; // speed - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 315.0f); p += 4; // desired_heading - - var result = UpdateMotion.TryParse(body); - - Assert.NotNull(result); - Assert.Equal((byte)9, result!.Value.MotionState.MovementType); - Assert.True(result.Value.MotionState.IsServerControlledTurnTo); - Assert.NotNull(result.Value.MotionState.TurnToPath); - - var path = result.Value.MotionState.TurnToPath!.Value; - Assert.Null(path.TargetGuid); - Assert.Null(path.WireHeading); - Assert.Equal(flags, path.Bitfield); - Assert.Equal(2.0f, path.Speed); - Assert.Equal(315.0f, path.DesiredHeading); - } - - [Theory] - [InlineData(0u)] // no flags - [InlineData(0x1u | 0x2u)] // can_walk | can_run - [InlineData(0x10u)] // can_charge (fast-path bit) - [InlineData(0x3FFFFu)] // every A4 bit through 0x20000 - public void ParsesTurnToHeading_FlagPermutations_BitfieldRoundTrips(uint bitfield) - { - var body = new byte[20 + 12]; - int p = 0; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80001111u); p += 4; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; - p += 6; - - body[p++] = 9; - body[p++] = 0; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; - - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), bitfield); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.0f); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.0f); p += 4; - - var result = UpdateMotion.TryParse(body); - - Assert.NotNull(result); - Assert.Equal(bitfield, result!.Value.MotionState.TurnToPath!.Value.Bitfield); - } - - // ───────────────────────────────────────────────────────────────── - // R4-V3 deliverable B: mt 6/7 widened exposure. MoveToPathData already - // carries every UnPackNet field (V0/V1 shipped that); this proves the - // fixture round-trips end-to-end through MovementParameters.FromWire — - // i.e. that ALL seven UnPackNet fields (not just the three ad-hoc bool - // properties MoveToCanRun/MoveTowards/CanCharge) reach a consumer. - // ───────────────────────────────────────────────────────────────── - - [Fact] - public void MoveToPositionPath_FeedsFromWire_AllSevenFieldsSurvive() - { - var body = new byte[20 + 16 + 28 + 4]; - int p = 0; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80002222u); p += 4; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; - p += 6; - - body[p++] = 7; // MoveToPosition - body[p++] = 0; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; - - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xA8B4000Eu); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 11f); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 22f); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 33f); p += 4; - - const uint flags = 0x1u | 0x2u | 0x4u | 0x8u | 0x10u | 0x200u | 0x400u; // incl. can_charge + use_spheres - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), flags); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.6f); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 0.1f); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 50.0f); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 1.25f); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 15.0f); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 123.0f); p += 4; - BinaryPrimitives.WriteSingleLittleEndian(body.AsSpan(p), 2.75f); p += 4; // runRate - - var result = UpdateMotion.TryParse(body); - Assert.NotNull(result); - var path = result!.Value.MotionState.MoveToPath!.Value; - - var mp = MovementParameters.FromWire( - flags, - path.DistanceToObject, - path.MinDistance, - path.FailDistance, - result.Value.MotionState.MoveToSpeed!.Value, - path.WalkRunThreshold, - path.DesiredHeading); - - Assert.True(mp.CanWalk); - Assert.True(mp.CanRun); - Assert.True(mp.CanSidestep); - Assert.True(mp.CanWalkBackwards); - Assert.True(mp.CanCharge); - Assert.True(mp.MoveTowards); - Assert.True(mp.UseSpheres); - Assert.Equal(0.6f, mp.DistanceToObject); - Assert.Equal(0.1f, mp.MinDistance); - Assert.Equal(50.0f, mp.FailDistance); - Assert.Equal(1.25f, mp.Speed); - Assert.Equal(15.0f, mp.WalkRunThreshhold); - Assert.Equal(123.0f, mp.DesiredHeading); - Assert.Equal(2.75f, result.Value.MotionState.MoveToRunRate); - } - - // ───────────────────────────────────────────────────────────────── - // R4-V3 deliverable C: the 0xF74C motionFlags sticky-guid trailer - // (mt=0/Invalid only — ACE MovementInvalid.Write gates the trailing - // guid on MotionFlags.StickToObject 0x1; decomp §2f case 0 - // @0052455d). Cursor-honesty test: bytes AFTER the trailer must still - // parse correctly (i.e. the trailer's 4 bytes were actually consumed, - // not left dangling / double-read). - // ───────────────────────────────────────────────────────────────── - - [Fact] - public void ParsesStickyGuidTrailer_WhenMotionFlagsBitSet() - { - // motionFlags byte1&0x1 (StickToObject) set; InterpretedMotionState - // flags = 0 (no fields), so the sticky guid dword immediately - // follows the packed flags dword. - var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 4]; - int p = 0; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80003333u); p += 4; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; - p += 6; - - body[p++] = 0; // movementType = Invalid - body[p++] = 0x1; // motionFlags = StickToObject - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no IMS flags - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80004444u); p += 4; // sticky object guid - - var result = UpdateMotion.TryParse(body); - - Assert.NotNull(result); - Assert.Equal(0x80004444u, result!.Value.MotionState.StickyObjectGuid); - } - - [Fact] - public void SkipsStickyGuidTrailer_WhenMotionFlagsBitClear() - { - var body = new byte[4 + 4 + 2 + 6 + 4 + 4]; - int p = 0; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80005555u); p += 4; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; - p += 6; - - body[p++] = 0; // movementType = Invalid - body[p++] = 0; // motionFlags = none - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x003D); p += 2; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0u); p += 4; // no IMS flags - - var result = UpdateMotion.TryParse(body); - - Assert.NotNull(result); - Assert.Null(result!.Value.MotionState.StickyObjectGuid); - } - - [Fact] - public void ParsesStickyGuidTrailer_CursorHonesty_BytesAfterTrailerStillParseCorrectly() - { - // Sticky trailer with the ForwardCommand flag ALSO set, so there are - // bytes both BEFORE (forwardCommand u16) and the sticky dword AFTER - // the flags dword — the trailer must be read at the right offset - // (after ForwardCommand + its own 2-byte read), not glued onto the - // packed-flags dword itself. Cross-checks against ACE's actual field - // order: MovementInvalid.Write emits `State` (the whole - // InterpretedMotionState, incl. Commands list) THEN the sticky guid - // — decomp confirms the same order (UnPack first, sticky guid read - // after, r4-moveto-decomp.md:274-275). - var body = new byte[4 + 4 + 2 + 6 + 4 + 4 + 2 + 4]; - int p = 0; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0xF74Cu); p += 4; - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80006666u); p += 4; - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; - p += 6; - - body[p++] = 0; // movementType = Invalid - body[p++] = 0x1; // motionFlags = StickToObject - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0); p += 2; // outer stance - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x2u); p += 4; // IMS flags = ForwardCommand only - BinaryPrimitives.WriteUInt16LittleEndian(body.AsSpan(p), 0x0007); p += 2; // ForwardCommand = RunForward - BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(p), 0x80007777u); p += 4; // sticky object guid — AFTER ForwardCommand - - var result = UpdateMotion.TryParse(body); - - Assert.NotNull(result); - Assert.Equal((ushort)0x0007, result!.Value.MotionState.ForwardCommand); - Assert.Equal(0x80007777u, result.Value.MotionState.StickyObjectGuid); - } } diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs deleted file mode 100644 index 855f77c9..00000000 --- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs +++ /dev/null @@ -1,176 +0,0 @@ -using AcDream.Core.Items; -using AcDream.Core.Net; -using AcDream.Core.Net.Messages; - -namespace AcDream.Core.Net.Tests; - -/// -/// D.5.4 Task 7 — ObjectTableWiring. -/// -/// WorldSession.EntitySpawned/EntityDeleted have no in-process test seam (the -/// ctor opens a UDP socket), so the subscription wiring is tested via the guard -/// logic extracted as a local handler — this is the same lambda body that -/// ObjectTableWiring.Wire wires to session.EntityDeleted. The retail two-table -/// semantics are: PickupEvent (0xF74A) removes from the 3-D object_table but -/// KEEPS the weenie in ClientObjectTable; DeleteObject (0xF747) evicts the -/// weenie from both. FromPickup = true guards the eviction. -/// -public sealed class ObjectTableWiringTests -{ - [Fact] - public void ToWeenieData_CopiesFieldsFromSpawn() - { - // Every EntitySpawn item field is set to a DISTINCT recognisable value so - // a positional transposition in ObjectTableWiring.ToWeenieData would trip - // at least one Assert. All 22 WeenieData fields are verified below. - var spawn = new WorldSession.EntitySpawn( - Guid: 0x00000600u, - Position: null, - SetupTableId: null, - AnimPartChanges: System.Array.Empty(), - TextureChanges: System.Array.Empty(), - SubPalettes: System.Array.Empty(), - BasePaletteId: null, - ObjScale: null, - Name: "Iron Sword", - ItemType: (uint)ItemType.MeleeWeapon, - MotionState: null, - MotionTableId: null) - with - { - WeenieClassId = 0x00001001u, - IconId = 0x06001111u, - IconOverlayId = 0x06002222u, - IconUnderlayId = 0x06003333u, - UiEffects = 0x00000004u, - Value = 7, - StackSize = 1, - StackSizeMax = 1, - Burden = 300, - ContainerId = 0x000000C9u, - WielderId = 0x000000DAu, - ValidLocations = 0x00000012u, // MeleeWeapon wield mask - CurrentWieldedLocation = 0x00000002u, // right-hand - Priority = 0x00000005u, - ItemsCapacity = 0, - ContainersCapacity = 0, - Structure = 80, - MaxStructure = 100, - Workmanship = 4.5f, - }; - - var d = ObjectTableWiring.ToWeenieData(spawn); - - // --- identity --- - Assert.Equal(0x00000600u, d.Guid); - Assert.Equal("Iron Sword", d.Name); - Assert.Equal(ItemType.MeleeWeapon, d.Type); - - // --- weenie / icon --- - Assert.Equal(0x00001001u, d.WeenieClassId); - Assert.Equal(0x06001111u, d.IconId); - Assert.Equal(0x06002222u, d.IconOverlayId); - Assert.Equal(0x06003333u, d.IconUnderlayId); - Assert.Equal(0x00000004u, d.Effects); - - // --- quantity / economy --- - Assert.Equal(7, d.Value); - Assert.Equal(1, d.StackSize); - Assert.Equal(1, d.StackSizeMax); - Assert.Equal(300, d.Burden); - - // --- container / wielder --- - Assert.Equal(0x000000C9u, d.ContainerId); - Assert.Equal(0x000000DAu, d.WielderId); - - // --- equip masks --- - Assert.Equal(0x00000012u, d.ValidLocations); - Assert.Equal(0x00000002u, d.CurrentWieldedLocation); - Assert.Equal(0x00000005u, d.Priority); - - // --- capacity --- - Assert.Equal(0, d.ItemsCapacity); - Assert.Equal(0, d.ContainersCapacity); - - // --- durability --- - Assert.Equal(80, d.Structure); - Assert.Equal(100, d.MaxStructure); - Assert.Equal(4.5f, d.Workmanship); - } - - // ------------------------------------------------------------------------- - // The handler that ObjectTableWiring.Wire subscribes to session.EntityDeleted. - // Testing it directly avoids needing a live WorldSession (UDP socket). - // ------------------------------------------------------------------------- - private static Action MakeEntityDeletedHandler(ClientObjectTable table) - => d => { if (!d.FromPickup) table.Remove(d.Guid); }; - - private static WeenieData MinimalWeenie(uint guid) => new( - Guid: guid, - Name: "Test Item", - Type: null, - WeenieClassId: 1u, - IconId: 0x06000001u, - IconOverlayId: 0u, - IconUnderlayId: 0u, - Effects: 0u, - Value: null, - StackSize: null, - StackSizeMax: null, - Burden: null, - ContainerId: null, - WielderId: null, - ValidLocations: null, - CurrentWieldedLocation: null, - Priority: null, - ItemsCapacity: null, - ContainersCapacity: null, - Structure: null, - MaxStructure: null, - Workmanship: null); - - /// - /// PickupEvent path: FromPickup = true → weenie is RETAINED in ClientObjectTable. - /// The 3-D render entity is removed (EntityDeleted still fires), but the weenie - /// data persists so the follow-up InventoryPutObjInContainer can find it. - /// - [Fact] - public void EntityDeleted_FromPickupTrue_RetainsWeenieInTable() - { - var table = new ClientObjectTable(); - var handler = MakeEntityDeletedHandler(table); - - const uint guid = 0x80000100u; - table.Ingest(MinimalWeenie(guid)); - Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table - - // Fire EntityDeleted as WorldSession does for PickupEvent (0xF74A). - // The handler itself represents the EntityDeleted subscription — firing it IS the event. - handler(new DeleteObject.Parsed(guid, 0, FromPickup: true)); - - // Post-condition: weenie still in table (it moved into a container, was NOT destroyed). - // EntityDeleted still fires (the handler ran), so the 3-D render layer would remove the WorldEntity. - Assert.NotNull(table.Get(guid)); - } - - /// - /// DeleteObject path: FromPickup = false → weenie IS evicted from ClientObjectTable. - /// Regression guard — true destroys (0xF747) must still clean up the table. - /// - [Fact] - public void EntityDeleted_FromPickupFalse_EvictsWeenieFromTable() - { - var table = new ClientObjectTable(); - var handler = MakeEntityDeletedHandler(table); - - const uint guid = 0x80000200u; - table.Ingest(MinimalWeenie(guid)); - Assert.NotNull(table.Get(guid)); // pre-condition: item is in the table - - // Fire EntityDeleted as WorldSession does for DeleteObject (0xF747). - handler(new DeleteObject.Parsed(guid, 0, FromPickup: false)); - - // Post-condition: weenie evicted (truly destroyed). - Assert.Null(table.Get(guid)); - } -} diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs index 8ebf8ddb..0bdd0bec 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionCombatTests.cs @@ -74,18 +74,4 @@ public sealed class WorldSessionCombatTests Assert.NotNull(captured); Assert.Equal(AttackTargetRequest.BuildCancel(1), captured); } - - [Fact] - public void SendQueryHealth_UsesRetailQueryHealthBuilder() - { - // Retail anchor: CM_Combat::Event_QueryHealth / gmToolbarUI::HandleSelectionChanged:198635 - using var session = NewSession(); - byte[]? captured = null; - session.GameActionCapture = body => captured = body; - - session.SendQueryHealth(0x50000007u); - - Assert.NotNull(captured); - Assert.Equal(SocialActions.BuildQueryHealth(1, 0x50000007u), captured); - } } diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionInboundBudgetTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionInboundBudgetTests.cs deleted file mode 100644 index aee35646..00000000 --- a/tests/AcDream.Core.Net.Tests/WorldSessionInboundBudgetTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using AcDream.Core.Net; -using Xunit; - -namespace AcDream.Core.Net.Tests; - -/// -/// #2 flood-timeslice: the per-frame inbound drain is bounded to a wall-clock budget once -/// in-world so a CreateObject flood can't monopolize the update loop. These cover the pure -/// gate decision (); the FIFO tail-drain is -/// standard Channel behavior and is verified live. -/// -public sealed class WorldSessionInboundBudgetTests -{ - [Fact] - public void InWorld_overBudget_stops() - => Assert.True(WorldSession.InboundBudgetExceeded( - WorldSession.State.InWorld, startTicks: 100, nowTicks: 250, budgetTicks: 100)); - - [Fact] - public void InWorld_atBudgetBoundary_stops() - => Assert.True(WorldSession.InboundBudgetExceeded( - WorldSession.State.InWorld, startTicks: 0, nowTicks: 100, budgetTicks: 100)); - - [Fact] - public void InWorld_underBudget_continues() - => Assert.False(WorldSession.InboundBudgetExceeded( - WorldSession.State.InWorld, startTicks: 0, nowTicks: 99, budgetTicks: 100)); - - [Fact] - public void NotInWorld_neverBounds_soTheHandshakeDrainsFully() - => Assert.False(WorldSession.InboundBudgetExceeded( - WorldSession.State.Disconnected, startTicks: 0, nowTicks: 1_000_000, budgetTicks: 100)); -} diff --git a/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs index 3a178756..7a4a9a1f 100644 --- a/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs +++ b/tests/AcDream.Core.Tests/Combat/CombatAnimationPlannerTests.cs @@ -9,12 +9,12 @@ public sealed class CombatAnimationPlannerTests [Theory] [InlineData(0x10000058u, CombatAnimationKind.MeleeSwing)] // ThrustMed [InlineData(0x1000005Bu, CombatAnimationKind.MeleeSwing)] // SlashHigh - [InlineData(0x1000017Du, CombatAnimationKind.MeleeSwing)] // OffhandTripleSlashMed (DRW) - [InlineData(0x1000018Eu, CombatAnimationKind.CreatureAttack)] // AttackLow6 (DRW) — was mislabelled PunchFastLow under 2013 numbering + [InlineData(0x1000017Du, CombatAnimationKind.MeleeSwing)] // OffhandDoubleThrustMed + [InlineData(0x1000018Eu, CombatAnimationKind.MeleeSwing)] // PunchFastLow [InlineData(0x10000061u, CombatAnimationKind.MissileAttack)] // Shoot - [InlineData(0x40000016u, CombatAnimationKind.MissileAttack)] // Reload (DRW SubState) — was the dead 2013 value 0x100000D4 + [InlineData(0x100000D4u, CombatAnimationKind.MissileAttack)] // Reload [InlineData(0x10000062u, CombatAnimationKind.CreatureAttack)] // AttackHigh1 - [InlineData(0x1000018Bu, CombatAnimationKind.CreatureAttack)] // AttackLow5 (DRW) + [InlineData(0x1000018Bu, CombatAnimationKind.CreatureAttack)] // AttackLow6 [InlineData(0x400000D3u, CombatAnimationKind.SpellCast)] // CastSpell [InlineData(0x400000E0u, CombatAnimationKind.SpellCast)] // UseMagicStaff [InlineData(0x10000051u, CombatAnimationKind.HitReaction)] // Twitch1 @@ -30,16 +30,11 @@ public sealed class CombatAnimationPlannerTests Assert.Equal(expected, CombatAnimationPlanner.ClassifyMotionCommand(command)); } - // These pin the RESOLVER (wire u16 -> full 32-bit) against the DRW enum, - // independent of the planner. wire 0x0170 is IssueSlashCommand = 0x09000170 - // (class 0x09, UI command) — there is no Action-class (0x10) entry at that - // wire; the real OffhandSlashHigh is 0x10000173 (the 2013 decomp numbers it - // 0x10000170, +3 low). See docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md. [Theory] - [InlineData(0x0170, 0x09000170u)] // IssueSlashCommand (UI class), NOT OffhandSlashHigh - [InlineData(0x017D, 0x1000017Du)] // OffhandTripleSlashMed - [InlineData(0x018B, 0x1000018Bu)] // AttackLow5 - [InlineData(0x018E, 0x1000018Eu)] // AttackLow6 + [InlineData(0x0170, 0x10000170u)] // OffhandSlashHigh + [InlineData(0x017D, 0x1000017Du)] // OffhandDoubleThrustMed + [InlineData(0x018B, 0x1000018Bu)] // AttackLow6 + [InlineData(0x018E, 0x1000018Eu)] // PunchFastLow public void MotionCommandResolver_UsesNamedRetailLateCombatCommands( ushort wireCommand, uint expectedFullCommand) @@ -47,50 +42,6 @@ public sealed class CombatAnimationPlannerTests Assert.Equal(expectedFullCommand, MotionCommandResolver.ReconstructFullCommand(wireCommand)); } - // #159 parity: the full wire -> resolve -> classify pipeline for the - // late-combat block, which ACE/DRW numbers +3 above the 2013 decomp. - // CombatAnimationMotionCommands is now derived directly from - // DatReaderWriter.Enums.MotionCommand, so these ACE wire values must both - // reconstruct to their DRW full value AND classify into the right kind. - // (Expected full/kind pairs cross-checked against the DRW MotionCommand - // enum, Chorizite.DatReaderWriter 2.1.7.) - [Theory] - [InlineData((ushort)0x0173, 0x10000173u, CombatAnimationKind.MeleeSwing)] // OffhandSlashHigh - [InlineData((ushort)0x0175, 0x10000175u, CombatAnimationKind.MeleeSwing)] // OffhandSlashLow - [InlineData((ushort)0x0176, 0x10000176u, CombatAnimationKind.MeleeSwing)] // OffhandThrustHigh - [InlineData((ushort)0x017E, 0x1000017Eu, CombatAnimationKind.MeleeSwing)] // OffhandTripleSlashHigh - [InlineData((ushort)0x0182, 0x10000182u, CombatAnimationKind.MeleeSwing)] // OffhandTripleThrustLow - [InlineData((ushort)0x0185, 0x10000185u, CombatAnimationKind.MeleeSwing)] // OffhandKick - [InlineData((ushort)0x0186, 0x10000186u, CombatAnimationKind.CreatureAttack)] // AttackHigh4 - [InlineData((ushort)0x0188, 0x10000188u, CombatAnimationKind.CreatureAttack)] // AttackLow4 - [InlineData((ushort)0x018C, 0x1000018Cu, CombatAnimationKind.CreatureAttack)] // AttackHigh6 - [InlineData((ushort)0x018E, 0x1000018Eu, CombatAnimationKind.CreatureAttack)] // AttackLow6 - [InlineData((ushort)0x018F, 0x1000018Fu, CombatAnimationKind.MeleeSwing)] // PunchFastHigh - [InlineData((ushort)0x0191, 0x10000191u, CombatAnimationKind.MeleeSwing)] // PunchFastLow - [InlineData((ushort)0x0197, 0x10000197u, CombatAnimationKind.MeleeSwing)] // OffhandPunchFastLow - [InlineData((ushort)0x019A, 0x1000019Au, CombatAnimationKind.MeleeSwing)] // OffhandPunchSlowLow - public void PlanFromWireCommand_LateCombatBlock_UsesAceDrwNumbering( - ushort wireCommand, - uint expectedFullCommand, - CombatAnimationKind expectedKind) - { - var plan = CombatAnimationPlanner.PlanFromWireCommand(wireCommand); - Assert.Equal(expectedFullCommand, plan.MotionCommand); - Assert.Equal(expectedKind, plan.Kind); - } - - // #159: Reload is the DRW SubState 0x40000016 (wire 0x0016), not the dead - // 2013 value 0x100000D4 the planner used to hold (absent from DRW entirely). - [Fact] - public void PlanFromWireCommand_Reload_UsesDrwSubStateValue() - { - var plan = CombatAnimationPlanner.PlanFromWireCommand(0x0016); - - Assert.Equal(0x40000016u, plan.MotionCommand); - Assert.Equal(CombatAnimationKind.MissileAttack, plan.Kind); - Assert.Equal(AnimationCommandRouteKind.SubState, plan.RouteKind); - } - [Fact] public void PlanFromWireCommand_Swing_IsActionOverlay() { diff --git a/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs b/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs deleted file mode 100644 index 1d3a7a41..00000000 --- a/tests/AcDream.Core.Tests/Conformance/HoltburgTorchFalloffProbeTests.cs +++ /dev/null @@ -1,116 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using AcDream.Core.Lighting; -using DatReaderWriter; -using DatReaderWriter.Options; -using DatLandBlockInfo = DatReaderWriter.DBObjs.LandBlockInfo; -using DatSetup = DatReaderWriter.DBObjs.Setup; -using Xunit; -using Xunit.Abstractions; - -namespace AcDream.Core.Tests.Conformance; - -/// -/// A7 Fix D round 2 (2026-06-19) — resolve the OPEN torch-REACH question without -/// guessing or a live launch: dump the RAW dat LightInfo.Falloff for every -/// static light in the Holtburg landblocks, via the EXACT production load path -/// (). The dat is the SAME file retail reads, so -/// these falloffs ARE what retail reads (modulo any load-time transform, settled -/// separately in the decomp). Output-only — no assertions; read the log. -/// -public sealed class HoltburgTorchFalloffProbeTests -{ - private readonly ITestOutputHelper _out; - public HoltburgTorchFalloffProbeTests(ITestOutputHelper output) => _out = output; - - [Fact] - public void Dump_Holtburg_StaticLight_Falloffs() - { - var datDir = ConformanceDats.ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - // The meeting hall sits in the Holtburg town landblocks. Sweep a small - // neighbourhood so we catch every entrance torch the streaming window - // would load around the player at the hall. - uint[] landblocks = - { - 0xA9B3u, 0xA9B4u, 0xA9B2u, 0xA9B5u, 0xAAB3u, 0xAAB4u, 0xA8B3u, 0xA8B4u, - }; - - // Tally every distinct raw Falloff seen (the headline number). - var falloffTally = new SortedDictionary(); - int totalLights = 0; - - foreach (uint lb in landblocks) - { - uint infoId = (lb << 16) | 0xFFFEu; - var info = dats.Get(infoId); - if (info is null) { _out.WriteLine($"=== LB 0x{lb:X4}: LandBlockInfo NULL ==="); continue; } - - int buildings = info.Buildings?.Count ?? 0; - int objects = info.Objects?.Count ?? 0; - _out.WriteLine($"=== LB 0x{lb:X4}: Buildings={buildings} Objects={objects} ==="); - - // Record building-shell origins so we can rank torches by proximity. - var shells = new List<(uint model, Vector3 pos)>(); - if (info.Buildings is not null) - { - foreach (var b in info.Buildings) - { - var o = b.Frame.Origin; - shells.Add((b.ModelId, new Vector3(o.X, o.Y, o.Z))); - _out.WriteLine($" BUILDING shell model=0x{b.ModelId:X8} pos=({o.X:F1},{o.Y:F1},{o.Z:F1}) portals={b.Portals?.Count ?? 0}"); - } - } - - if (info.Objects is null) continue; - foreach (var stab in info.Objects) - { - // Only Setup-sourced stabs (0x02xxxxxx) carry a Lights dictionary — - // identical gate to GameWindow.cs:6399. - if ((stab.Id & 0xFF000000u) != 0x02000000u) continue; - var setup = dats.Get(stab.Id); - if (setup?.Lights is null || setup.Lights.Count == 0) continue; - - var loaded = LightInfoLoader.Load( - setup, - ownerId: 0, - entityPosition: new Vector3(stab.Frame.Origin.X, stab.Frame.Origin.Y, stab.Frame.Origin.Z), - entityRotation: new Quaternion( - stab.Frame.Orientation.X, stab.Frame.Orientation.Y, - stab.Frame.Orientation.Z, stab.Frame.Orientation.W)); - - foreach (var (kvp, ls) in setup.Lights.Zip(loaded, (k, l) => (k, l))) - { - float rawFalloff = kvp.Value.Falloff; - totalLights++; - falloffTally.TryGetValue(rawFalloff, out int c); - falloffTally[rawFalloff] = c + 1; - - // Nearest building shell, for "is this an entrance torch on the hall?". - float nearest = float.MaxValue; - uint nearestModel = 0; - foreach (var (model, spos) in shells) - { - float dd = Vector3.Distance(ls.WorldPosition, spos); - if (dd < nearest) { nearest = dd; nearestModel = model; } - } - - _out.WriteLine( - $" LIGHT setup=0x{stab.Id:X8} kind={ls.Kind} " + - $"pos=({ls.WorldPosition.X:F1},{ls.WorldPosition.Y:F1},{ls.WorldPosition.Z:F1}) " + - $"color=({ls.ColorLinear.X:F3},{ls.ColorLinear.Y:F3},{ls.ColorLinear.Z:F3}) " + - $"intensity={ls.Intensity:F1} rawFalloff={rawFalloff:F3} Range={ls.Range:F3} " + - $"cone={ls.ConeAngle:F3} nearestShell=0x{nearestModel:X8}@{(nearest == float.MaxValue ? -1f : nearest):F1}m"); - } - } - } - - _out.WriteLine($"=== FALLOFF HISTOGRAM (raw dat values across {totalLights} static lights) ==="); - foreach (var kv in falloffTally) - _out.WriteLine($" rawFalloff={kv.Key:F3} -> Range(x1.3)={kv.Key * 1.3f:F3}m count={kv.Value}"); - } -} diff --git a/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs index 5141770e..ffe398ad 100644 --- a/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs +++ b/tests/AcDream.Core.Tests/Conformance/Issue112MembershipTests.cs @@ -34,12 +34,6 @@ public sealed class Issue112MembershipTests try { ConformanceDats.LoadEnvCell(dats, cache, lbPrefix | low); } catch { } } - // #145 D: register terrain for the block so TryGetTerrainOrigin succeeds for - // outdoor seeds (in production the streaming-center landblock is always resident - // before outdoor physics resolves run). Tests use block-local coordinates, so - // origin is (0,0,0). Without this, Component D's early-return for unregistered - // terrain would prevent the building-portal walk that promotes outdoor→indoor. - cache.CellGraph.RegisterTerrain(lbPrefix, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero); return cache; } diff --git a/tests/AcDream.Core.Tests/Conformance/Issue119UpNullGfxObjDumpTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue119UpNullGfxObjDumpTests.cs index 0b123199..be5e4413 100644 --- a/tests/AcDream.Core.Tests/Conformance/Issue119UpNullGfxObjDumpTests.cs +++ b/tests/AcDream.Core.Tests/Conformance/Issue119UpNullGfxObjDumpTests.cs @@ -16,11 +16,9 @@ namespace AcDream.Core.Tests.Conformance; /// invisible)` at startup (t5-gate-launch.log:33-34); the old tower shows /// missing stair parts (visible in retail — user axiom). UploadGfxObjMeshData /// returns null only when the PREPARE phase produced ZERO vertices -/// (ObjectMeshManager.UploadGfxObjMeshData's empty-vertices guard), so the -/// upload is innocent — some extraction +/// (ObjectMeshManager.cs:1780), so the upload is innocent — some extraction /// gate dropped every polygon. This dump prints the raw dat facts per polygon -/// and replicates MeshExtractor.PrepareGfxObjMeshData's gates (moved from -/// ObjectMeshManager in MP1a) +/// and replicates PrepareGfxObjMeshData's gates (ObjectMeshManager.cs:1040-1058) /// so the zeroing gate reads directly off the output. /// public sealed class Issue119UpNullGfxObjDumpTests diff --git a/tests/AcDream.Core.Tests/Conformance/StipplingSurfaceEquivalenceTests.cs b/tests/AcDream.Core.Tests/Conformance/StipplingSurfaceEquivalenceTests.cs index db553c80..721a0ca1 100644 --- a/tests/AcDream.Core.Tests/Conformance/StipplingSurfaceEquivalenceTests.cs +++ b/tests/AcDream.Core.Tests/Conformance/StipplingSurfaceEquivalenceTests.cs @@ -16,8 +16,8 @@ namespace AcDream.Core.Tests.Conformance; /// (0x2) and BASE1_CLIPMAP (0x4) are skipped (D3DPolyRender inner draw, /// Ghidra 0x0059d4a0; default on @0x00820e30). acdream suppresses them at /// BUILD time via Stippling.NoPos in all four extraction paths -/// (MeshExtractor.PrepareGfxObjMeshData + PrepareCellStructMeshData -/// [moved from ObjectMeshManager in MP1a], CellMesh.Build:44, GfxObjMesh.Build:71). +/// (ObjectMeshManager.PrepareGfxObjMeshData:1046 + PrepareCellStructMeshData +/// :1394, CellMesh.Build:44, GfxObjMesh.Build:71). /// /// These criteria are equivalent ONLY if NoPos ⇔ untextured-surface holds on /// the content. This sweep pins both directions across the populated diff --git a/tests/AcDream.Core.Tests/Fixtures/l2g-observer-trace.log b/tests/AcDream.Core.Tests/Fixtures/l2g-observer-trace.log deleted file mode 100644 index 2a69c1fa..00000000 --- a/tests/AcDream.Core.Tests/Fixtures/l2g-observer-trace.log +++ /dev/null @@ -1,2953 +0,0 @@ -Opened log file 'C:\Users\erikn\source\repos\acdream\.claude\worktrees\vigorous-joliot-f0c3ad\l2g-observer-trace.log' -0:018> .sympath C:\Users\erikn\source\repos\acdream\refs -Symbol search path is: C:\Users\erikn\source\repos\acdream\refs -Expanded Symbol search path is: c:\users\erikn\source\repos\acdream\refs - -************* Path validation summary ************** -Response Time (ms) Location -OK C:\Users\erikn\source\repos\acdream\refs -0:018> .symopt+ 0x40 -Symbol options are 0xB0367: - 0x00000001 - SYMOPT_CASE_INSENSITIVE - 0x00000002 - SYMOPT_UNDNAME - 0x00000004 - SYMOPT_DEFERRED_LOADS - 0x00000020 - SYMOPT_OMAP_FIND_NEAREST - 0x00000040 - SYMOPT_LOAD_ANYTHING - 0x00000100 - SYMOPT_NO_UNQUALIFIED_LOADS - 0x00000200 - SYMOPT_FAIL_CRITICAL_ERRORS - 0x00010000 - SYMOPT_AUTO_PUBLICS - 0x00020000 - SYMOPT_NO_IMAGE_SEARCH - 0x00080000 - SYMOPT_NO_PROMPTS -0:018> .reload /f acclient.exe -0:018> -0:018> x acclient!CPhysics::SetObjectMovement -00509790 acclient!CPhysics::SetObjectMovement (class CPhysicsObj *, void *, unsigned int) -00509690 acclient!CPhysics::SetObjectMovement (class CPhysicsObj *, void *, unsigned int, unsigned short, unsigned short, int) -0:018> x acclient!MovementManager::unpack_movement -00524440 acclient!MovementManager::unpack_movement (void **, unsigned int) -0:018> x acclient!CMotionInterp::move_to_interpreted_state -005289c0 acclient!CMotionInterp::move_to_interpreted_state (class InterpretedMotionState *) -0:018> x acclient!CMotionInterp::DoInterpretedMotion -00528360 acclient!CMotionInterp::DoInterpretedMotion (unsigned long, class MovementParameters *) -0:018> -00528360 acclient!CMotionInterp::DoInterpretedMotion (unsigned long, class MovementParameters *) -0:018> r $t0 = 0 -0:018> bp acclient!CPhysics::SetObjectMovement "r $t0 = @$t0 + 1; .printf \"\\n[SOM %d] obj=%p movSeq=%x scSeq=%x auto=%x id: \", @$t0, poi(@esp+4), poi(@esp+10)&0xffff, poi(@esp+14)&0xffff, poi(@esp+18); dt acclient!CPhysicsObj poi(@esp+4) id; .if (@$t0 >= 250) { .detach } .else { gc }" -Matched: 00509790 acclient!CPhysics::SetObjectMovement (class CPhysicsObj *, void *, unsigned int) -Matched: 00509690 acclient!CPhysics::SetObjectMovement (class CPhysicsObj *, void *, unsigned int, unsigned short, unsigned short, int) -Ambiguous symbol error at 'acclient!CPhysics::SetObjectMovement "r $t0 = @$t0 + 1; .printf \"\\n[SOM %d] obj=%p movSeq=%x scSeq=%x auto=%x id: \", @$t0, poi(@esp+4), poi(@esp+10)&0xffff, poi(@esp+14)&0xffff, poi(@esp+18); dt acclient!CPhysicsObj poi(@esp+4) id; .if (@$t0 >= 250) { .detach } .else { gc }"' -0:018> bp acclient!MovementManager::unpack_movement ".printf \"[UNPACK] mm=%p\\n\", @ecx; gc" -0:018> bp acclient!CMotionInterp::move_to_interpreted_state ".printf \"[MTIS] minterp=%p ims:\\n\", @ecx; dt acclient!InterpretedMotionState poi(@esp+4); gc" -0:018> bp acclient!CMotionInterp::apply_interpreted_movement ".printf \"[AIM] minterp=%p\\n\", @ecx; gc" -0:018> bp acclient!CMotionInterp::apply_raw_movement ".printf \"[ARM] minterp=%p\\n\", @ecx; gc" -0:018> bp acclient!CMotionInterp::DoInterpretedMotion ".printf \"[DIM] minterp=%p motion=%08x\\n\", @ecx, poi(@esp+4); gc" -0:018> g -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000054 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000054 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000052 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[DIM] minterp=14fc2e00 motion=41000003 -[DIM] minterp=14fc2e00 motion=6500000d -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000052 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[DIM] minterp=14fc2e00 motion=41000003 -[DIM] minterp=14fc2e00 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000052 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000053 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000052 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000053 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=40000015 -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=40000015 -[UNPACK] mm=196e1718 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=40000015 -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=14fc2e00 motion=6500000d -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000053 -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[DIM] minterp=14fc2e00 motion=41000003 -[DIM] minterp=14fc2e00 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=41000003 -[DIM] minterp=14fc2e00 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000053 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000053 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000052 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000054 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000054 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000052 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : -1.854302526 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0x6500000f - +0x014 sidestep_speed : -3 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000f -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0x6500000f - +0x014 sidestep_speed : 3 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000f -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000053 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000053 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000054 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000054 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000052 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000053 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000052 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000053 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000054 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000054 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000053 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000053 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000052 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000054 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000054 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000052 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[DIM] minterp=29ec56b8 motion=6500000d -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=6500000d -[DIM] minterp=14fc2e00 motion=41000003 -[DIM] minterp=14fc2e00 motion=41000003 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[DIM] minterp=29ec56b8 motion=6500000d -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : -2.924999952 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=45000005 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 4.5 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=44000007 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=45000005 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 4.5 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=44000007 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[DIM] minterp=29ec56b8 motion=6500000d -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[DIM] minterp=29ec56b8 motion=6500000d -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 4.5 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=44000007 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=45000005 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 4.5 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=44000007 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 4.5 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=44000007 -[DIM] minterp=29ec56b8 motion=6500000d -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[DIM] minterp=29ec56b8 motion=6500000d -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 4.5 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=44000007 -[DIM] minterp=29ec56b8 motion=6500000d -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000052 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[DIM] minterp=29ec56b8 motion=6500000d -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[DIM] minterp=29ec56b8 motion=6500000d -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 4.5 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=44000007 -[DIM] minterp=29ec56b8 motion=6500000d -[UNPACK] mm=2a225778 -[DIM] minterp=29ec56b8 motion=80000000 -[MTIS] minterp=29ec56b8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=41000003 -[DIM] minterp=14fc2e00 motion=41000003 -[DIM] minterp=14fc2e00 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000053 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : -1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x45000005 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=45000005 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000052 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0x6500000d - +0x01c turn_speed : 1.5 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=6500000d -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x44000007 - +0x00c forward_speed : 2.85277319 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=44000007 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000053 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=10000053 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=2a167f90 -[MTIS] minterp=29ebfd48 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=41000003 -[UNPACK] mm=196e1718 -[DIM] minterp=18e8b0f8 motion=80000000 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[UNPACK] mm=196e1718 -[MTIS] minterp=18e8b0f8 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=41000003 -[DIM] minterp=18e8b0f8 motion=1000011e -[DIM] minterp=14fc2e00 motion=41000003 -[DIM] minterp=14fc2e00 motion=41000003 -[DIM] minterp=14fc2e00 motion=41000003 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x10000052 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=10000052 -[UNPACK] mm=2a222e50 -[MTIS] minterp=29ec3b28 ims: - +0x000 __VFN_table : 0x007c7a60 - +0x004 current_style : 0x8000003d - +0x008 forward_command : 0x41000003 - +0x00c forward_speed : 1 - +0x010 sidestep_command : 0 - +0x014 sidestep_speed : 1 - +0x018 turn_command : 0 - +0x01c turn_speed : 1 - +0x020 actions : LList -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=41000003 -[AIM] minterp=18e8b0f8 -[DIM] minterp=18e8b0f8 motion=8000003d -[DIM] minterp=18e8b0f8 motion=40000015 -[DIM] minterp=14fc2e00 motion=41000003 -[AIM] minterp=298cf070 -[DIM] minterp=298cf070 motion=8000003d -[DIM] minterp=298cf070 motion=40000015 -[AIM] minterp=14fc2e00 -[DIM] minterp=14fc2e00 motion=8000003d -[DIM] minterp=14fc2e00 motion=40000015 -[AIM] minterp=1705c458 -[DIM] minterp=1705c458 motion=8000003d -[DIM] minterp=1705c458 motion=40000015 -[AIM] minterp=29ebc6b8 -[DIM] minterp=29ebc6b8 motion=8000003d -[DIM] minterp=29ebc6b8 motion=40000015 -[AIM] minterp=29ec56b8 -[DIM] minterp=29ec56b8 motion=8000003d -[DIM] minterp=29ec56b8 motion=40000015 -[AIM] minterp=298cdcc0 -[DIM] minterp=298cdcc0 motion=8000003d -[DIM] minterp=298cdcc0 motion=40000015 -[AIM] minterp=298d6330 -[DIM] minterp=298d6330 motion=8000003d -[DIM] minterp=298d6330 motion=40000015 -[AIM] minterp=16e5c030 -[DIM] minterp=16e5c030 motion=8000003d -[DIM] minterp=16e5c030 motion=40000015 -[AIM] minterp=298d6720 -[DIM] minterp=298d6720 motion=8000003d -[DIM] minterp=298d6720 motion=40000015 -[AIM] minterp=17056788 -[DIM] minterp=17056788 motion=8000003d -[DIM] minterp=17056788 motion=40000015 -[AIM] minterp=298d5eb0 -[DIM] minterp=298d5eb0 motion=8000003d -[DIM] minterp=298d5eb0 motion=40000015 -[AIM] minterp=1788f350 -[DIM] minterp=1788f350 motion=8000003d -[DIM] minterp=1788f350 motion=40000015 -[AIM] minterp=298d99c0 -[DIM] minterp=298d99c0 motion=8000003d -[DIM] minterp=298d99c0 motion=40000015 -[AIM] minterp=1788f980 -[DIM] minterp=1788f980 motion=8000003d -[DIM] minterp=1788f980 motion=40000015 -[AIM] minterp=178917e0 -[DIM] minterp=178917e0 motion=8000003d -[DIM] minterp=178917e0 motion=40000015 -[AIM] minterp=1788ac70 -[DIM] minterp=1788ac70 motion=8000003d -[DIM] minterp=1788ac70 motion=40000015 -[AIM] minterp=1788b330 -[DIM] minterp=1788b330 motion=8000003d -[DIM] minterp=1788b330 motion=40000015 -[AIM] minterp=11209dc0 -[DIM] minterp=11209dc0 motion=8000003d -[DIM] minterp=11209dc0 motion=40000015 -[AIM] minterp=16e5bc40 -[DIM] minterp=16e5bc40 motion=8000003d -[DIM] minterp=16e5bc40 motion=40000015 -[AIM] minterp=298d0390 -[DIM] minterp=298d0390 motion=8000003d -[DIM] minterp=298d0390 motion=40000015 -[AIM] minterp=1788c770 -[DIM] minterp=1788c770 motion=8000003d -[DIM] minterp=1788c770 motion=40000015 -[AIM] minterp=16e550d0 -[DIM] minterp=16e550d0 motion=8000003d -[DIM] minterp=16e550d0 motion=40000015 -[AIM] minterp=19ad15c8 -[DIM] minterp=19ad15c8 motion=8000003d -[DIM] minterp=19ad15c8 motion=40000015 -[AIM] minterp=19ad3398 -[DIM] minterp=19ad3398 motion=8000003d -[DIM] minterp=19ad3398 motion=40000015 -[AIM] minterp=19ac4d88 -[DIM] minterp=19ac4d88 motion=8000003d -[DIM] minterp=19ac4d88 motion=40000015 -[AIM] minterp=17057868 -[DIM] minterp=17057868 motion=8000003d -[DIM] minterp=17057868 motion=40000015 -[AIM] minterp=29ec3b28 -[DIM] minterp=29ec3b28 motion=8000003d -[DIM] minterp=29ec3b28 motion=40000015 -[AIM] minterp=29ebdd38 -[DIM] minterp=29ebdd38 motion=8000003d -[DIM] minterp=29ebdd38 motion=40000015 -[AIM] minterp=19ac81d8 -[DIM] minterp=19ac81d8 motion=8000003d -[DIM] minterp=19ac81d8 motion=40000015 -[AIM] minterp=1788cfe0 -[DIM] minterp=1788cfe0 motion=8000003d -[DIM] minterp=1788cfe0 motion=40000015 -[AIM] minterp=29ebfd48 -[DIM] minterp=29ebfd48 motion=8000003d -[DIM] minterp=29ebfd48 motion=40000015 -[AIM] minterp=172f0070 -[DIM] minterp=172f0070 motion=8000003d -[DIM] minterp=172f0070 motion=40000015 -[AIM] minterp=172f1ed0 -[DIM] minterp=172f1ed0 motion=8000003d -[DIM] minterp=172f1ed0 motion=40000015 -[AIM] minterp=298c9dc0 -[DIM] minterp=298c9dc0 motion=8000003d -[DIM] minterp=298c9dc0 motion=40000015 -[AIM] minterp=298cc520 -[DIM] minterp=298cc520 motion=8000003d -[DIM] minterp=298cc520 motion=40000015 -[AIM] minterp=17056668 -[DIM] minterp=17056668 motion=8000003d -[DIM] minterp=17056668 motion=40000015 -[AIM] minterp=17059488 -[DIM] minterp=17059488 motion=8000003d -[DIM] minterp=17059488 motion=40000015 -[AIM] minterp=16e57710 -[DIM] minterp=16e57710 motion=8000003d -[DIM] minterp=16e57710 motion=40000015 -[AIM] minterp=16e59210 -[DIM] minterp=16e59210 motion=8000003d -[DIM] minterp=16e59210 motion=40000015 -[AIM] minterp=298d4170 -[DIM] minterp=298d4170 motion=8000003d -[DIM] minterp=298d4170 motion=40000015 -[AIM] minterp=1705cc38 -[DIM] minterp=1705cc38 motion=8000003d -[DIM] minterp=1705cc38 motion=40000015 -[AIM] minterp=298d4050 -[DIM] minterp=298d4050 motion=8000003d -[DIM] minterp=298d4050 motion=40000015 -[AIM] minterp=298d2c10 -[DIM] minterp=298d2c10 motion=8000003d -[DIM] minterp=298d2c10 motion=40000015 -[AIM] minterp=298d2dc0 -[DIM] minterp=298d2dc0 motion=8000003d -[DIM] minterp=298d2dc0 motion=40000015 -eax=00000000 ebx=77e57240 ecx=00000000 edx=00000000 esi=00000000 edi=00000000 -eip=77d99d8c esp=001afdcc ebp=001afea4 iopl=0 nv up ei pl nz na pe nc -cs=0023 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00200206 -ntdll!NtTerminateProcess+0xc: -77d99d8c c20800 ret 8 diff --git a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs b/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs deleted file mode 100644 index 1a32b33d..00000000 --- a/tests/AcDream.Core.Tests/Input/PlayerMoveToCutoverTests.cs +++ /dev/null @@ -1,386 +0,0 @@ -using System; -using System.Numerics; -using AcDream.App.Input; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; -using Position = AcDream.Core.Physics.Position; - -namespace AcDream.Core.Tests.Input; - -/// -/// R4-V5 — local-player MoveTo cutover: a bound -/// to the controller exactly the way GameWindow.EnterPlayerModeNow -/// binds it (Yaw-authoritative heading seams via the P5 bridge, Contact -/// transient bit, SimTimeSeconds clock) drives the player's body through -/// with NO user input — the -/// manager's _DoMotion dispatches land in InterpretedState and the -/// controller's per-frame sections turn them into Yaw rotation + body -/// velocity. Also pins the TS-36 retirement: any input edge (movement key, -/// jump) cancels the moveto through the retail -/// InterruptCurrentMovement → CancelMoveTo chain, and a cancel never fires -/// the MoveToComplete completion seam (AD-27's deferred-Use contract). -/// -public class PlayerMoveToCutoverTests -{ - private const uint NC = 0x8000003Du; - private const uint Ready = 0x41000003u; - private const uint Walk = 0x45000005u; - private const uint Run = 0x44000007u; - private const uint TurnRight = 0x6500000Du; - - private sealed class Loader : IAnimationLoader - { - private readonly System.Collections.Generic.Dictionary _anims = new(); - public void Register(uint id, Animation anim) => _anims[id] = anim; - public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null; - } - - private static Animation MakeAnim(int frames) - { - var anim = new Animation(); - for (int f = 0; f < frames; f++) - { - var pf = new AnimationFrame(1); - pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }); - anim.PartFrames.Add(pf); - } - return anim; - } - - private static MotionData MakeMd(uint animId) - { - var md = new MotionData(); - QualifiedDataId qid = animId; - md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f }); - return md; - } - - private static AnimationSequencer MakeSequencer() - { - var setup = new Setup(); - setup.Parts.Add(0x01000000u); - setup.DefaultScale.Add(Vector3.One); - - var loader = new Loader(); - loader.Register(0x300u, MakeAnim(4)); - loader.Register(0x301u, MakeAnim(6)); - loader.Register(0x302u, MakeAnim(6)); - loader.Register(0x303u, MakeAnim(6)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC }; - mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready; - mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u); - mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(0x301u); - mt.Cycles[(int)((NC << 16) | (Run & 0xFFFFFFu))] = MakeMd(0x302u); - // The MoveToManager's aux-turn steering / TurnToHeading nodes - // dispatch TurnRight (adjust_motion normalizes TurnLeft into it) — - // without a table cycle the real sink's false return becomes a - // _DoMotion error and the manager cancels the moveto (retail - // dispatch-failure semantics), so the fixture needs one. - mt.Cycles[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] = MakeMd(0x303u); - return new AnimationSequencer(setup, mt, loader); - } - - private static PhysicsEngine MakeFlatEngine() - { - var engine = new PhysicsEngine(); - var heights = new byte[81]; - Array.Fill(heights, (byte)50); - var heightTable = new float[256]; - for (int i = 0; i < 256; i++) heightTable[i] = i * 1f; - var terrain = new TerrainSurface(heights, heightTable); - engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), - Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); - return engine; - } - - private sealed class Rig - { - public required PlayerMovementController Controller; - public required MoveToManager MoveTo; - public int MoveToCompleteCount; - public WeenieError LastCompleteError; - } - - /// The EnterPlayerModeNow bind set, verbatim shape: real sink, - /// Yaw-authoritative heading seams (P5 bridge), Contact bit, false - /// isInterpolating, SimTimeSeconds clock, TS-36 interrupt binding. - private static Rig MakeRig() => MakeRig(out _); - - private static Rig MakeRig(out AnimationSequencer seqOut) - { - var controller = new PlayerMovementController(MakeFlatEngine()); - var seq = MakeSequencer(); - seqOut = seq; - // Production (EnterPlayerModeNow) order: the sink binds BEFORE the - // initial SetPosition — SetPosition → StopCompletely needs the sink - // for its type-5 motion-table dispatch, the completable partner of - // the A9 pending_motions node (a null sink orphans it and the - // MoveToManager wait-for-anims gate never opens — the live stall). - controller.Motion.DefaultSink = new MotionTableDispatchSink(seq); - // #174: production wiring — HandleEnterWorld (strip + drain), not - // the bare sequence strip (which orphaned pending manager nodes). - controller.Motion.RemoveLinkAnimations = () => seq.Manager.HandleEnterWorld(); - controller.Motion.InitializeMotionTables = () => seq.Manager.InitializeState(); - controller.Motion.CheckForCompletedMotions = seq.Manager.CheckForCompletedMotions; - controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); - controller.Yaw = 0f; // heading 90 = facing +X - - var rig = new Rig { Controller = controller, MoveTo = null! }; - var moveTo = new MoveToManager( - controller.Motion, - stopCompletely: () => controller.Motion.StopCompletely(), - getPosition: () => new Position( - controller.CellId, controller.Position, controller.BodyOrientation), - getHeading: () => MoveToMath.HeadingFromYaw(controller.Yaw), - setHeading: (h, _) => controller.Yaw = MoveToMath.YawFromHeading(h), - getOwnRadius: () => 0f, - getOwnHeight: () => 0f, - contact: () => controller.BodyInContact, - isInterpolating: () => false, - getVelocity: () => controller.BodyVelocity, - getSelfId: () => 0x5000000Au, - setTarget: (_, _, _, _) => { }, - clearTarget: () => { }, - getTargetQuantum: () => 0.0, - setTargetQuantum: _ => { }, - curTime: () => controller.SimTimeSeconds); - moveTo.MoveToComplete = err => - { - rig.MoveToCompleteCount++; - rig.LastCompleteError = err; - }; - controller.MoveTo = moveTo; - controller.Motion.InterruptCurrentMovement = - () => moveTo.CancelMoveTo(WeenieError.ActionCancelled); - rig.MoveTo = moveTo; - return rig; - } - - /// Stands in for the player sequencer's MotionDone feed (bound - /// in production via the R3-W2 MotionTableManager seam) — without it, - /// pending_motions never drains in this fixture and the manager's - /// wait-for-anims gates wedge. - private static void Drain(PlayerMovementController c) - { - while (c.Motion.MotionsPending()) - c.Motion.MotionDone(0, true); - } - - [Fact] - public void ServerMoveToPosition_WalksToArrival_WithNoUserInput() - { - var rig = MakeRig(); - var c = rig.Controller; - var dest = new Vector3(104f, 96f, 50f); // 8 m dead ahead (heading 90) - - // Mirrors the GameWindow wire path's P1 unpack store (00509730): - // a server moveto arrives with IsAutonomous=false, routing the - // per-tick pump's A3 dual dispatch to the interpreted branch. - rig.Controller.SetLastMoveWasAutonomous(false); - rig.MoveTo.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = new Position(c.CellId, dest, Quaternion.Identity), - Params = new MovementParameters { UseSpheres = false, DistanceToObject = 0.6f }, - }); - Drain(c); - Assert.True(rig.MoveTo.IsMovingTo()); - - for (int f = 0; f < 1200 && rig.MoveTo.IsMovingTo(); f++) - { - var result = c.Update(1f / 60f, new MovementInput()); - // The #75 invariant, now by construction: manager-driven motion - // never registers as a user motion-state change, so no outbound - // MoveToState is built mid-moveto. - Assert.False(result.MotionStateChanged, - $"manager-driven frame {f} must not flag MotionStateChanged"); - Drain(c); - } - - Assert.False(rig.MoveTo.IsMovingTo(), "moveto must arrive within the frame budget"); - float distLeft = Vector2.Distance( - new Vector2(c.Position.X, c.Position.Y), new Vector2(dest.X, dest.Y)); - Assert.True(distLeft <= 1.0f, - $"body must stop at the arrival radius; {distLeft:F2} m left"); - Assert.Equal(1, rig.MoveToCompleteCount); - Assert.Equal(WeenieError.None, rig.LastCompleteError); - Assert.Equal(Ready, c.Motion.InterpretedState.ForwardCommand); - } - - [Fact] - public void ServerTurnToHeading_RotatesYaw_AndSnapsExact() - { - var rig = MakeRig(); - var c = rig.Controller; - - // Mirrors the GameWindow wire path's P1 unpack store (00509730): - // a server moveto arrives with IsAutonomous=false, routing the - // per-tick pump's A3 dual dispatch to the interpreted branch. - rig.Controller.SetLastMoveWasAutonomous(false); - rig.MoveTo.PerformMovement(new MovementStruct - { - Type = MovementType.TurnToHeading, - Params = new MovementParameters { DesiredHeading = 180f }, // South = -Y - }); - Drain(c); - Assert.True(rig.MoveTo.IsMovingTo()); - - for (int f = 0; f < 600 && rig.MoveTo.IsMovingTo(); f++) - { - c.Update(1f / 60f, new MovementInput()); - Drain(c); - } - - Assert.False(rig.MoveTo.IsMovingTo(), "turn-to must complete within the frame budget"); - // HandleTurnToHeading's arrival snap (the ONE heading snap in the - // family, 0052a146) writes through the Yaw seam: heading 180 → yaw - // -π/2 per the P5 bridge. - Assert.Equal(MoveToMath.YawFromHeading(180f), c.Yaw, 3); - Assert.Equal(1, rig.MoveToCompleteCount); - } - - [Fact] - public void MovementKeyEdge_CancelsMoveTo_WithoutFiringComplete() - { - var rig = MakeRig(); - var c = rig.Controller; - - // Mirrors the GameWindow wire path's P1 unpack store (00509730): - // a server moveto arrives with IsAutonomous=false, routing the - // per-tick pump's A3 dual dispatch to the interpreted branch. - rig.Controller.SetLastMoveWasAutonomous(false); - rig.MoveTo.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = new Position(c.CellId, new Vector3(150f, 96f, 50f), Quaternion.Identity), - Params = new MovementParameters { UseSpheres = false }, - }); - Drain(c); - Assert.True(rig.MoveTo.IsMovingTo()); - c.Update(1f / 60f, new MovementInput()); - Drain(c); - Assert.True(rig.MoveTo.IsMovingTo()); - - // W press edge → DoMotion(ctor-default params, CancelMoveTo bit set) - // → InterruptCurrentMovement → CancelMoveTo(ActionCancelled). - var result = c.Update(1f / 60f, new MovementInput { Forward = true }); - - Assert.False(rig.MoveTo.IsMovingTo(), "user input must cancel the moveto (TS-36)"); - Assert.Equal(0, rig.MoveToCompleteCount); - // The cancel-frame's state change IS user intent — it must go on - // the wire (the former !IsServerAutoWalking guard is gone). - Assert.True(result.MotionStateChanged); - } - - [Fact] - public void LoginQueue_DrainsToEmpty_UnderProductionFeed() - { - // The second live stall (2026-07-03, [autowalk-gate] probe): ONE - // immortal Ready node in pending_motions — login SetPosition's - // StopCompletely fired before the sink bind, orphaning its A9 node - // (no completable partner). Head-pop-any relabels but never empties - // a queue with an orphan, and MotionsPending==true wedges every - // subsequent moveto. This pins the invariant: after login (rig - // construction, production bind order) the queue must reach EMPTY - // under the production completion feed. - var rig = MakeRig(out var seq); - var c = rig.Controller; - seq.MotionDoneTarget = (m, ok) => c.Motion.MotionDone(m, ok); - - for (int f = 0; f < 300 && c.Motion.MotionsPending(); f++) - { - c.Update(1f / 60f, new MovementInput()); - seq.Advance(1f / 60f); - } - - Assert.False(c.Motion.MotionsPending(), - "login pending_motions must drain to empty under the production feed"); - } - - [Fact] - public void ServerMoveToPosition_ProductionCompletionFeed_WalksToArrival() - { - // The 2026-07-03 live wedge repro: same scenario as - // ServerMoveToPosition_WalksToArrival_WithNoUserInput, but the - // pending_motions queue drains the way PRODUCTION drains it — the - // sequencer's Advance() fires MotionDoneTarget → Motion.MotionDone - // (the TickAnimations R3-W2 bind) — instead of this suite's - // force-drain. Live symptom: the moveto armed (movingTo=True) but - // the body never moved; BeginTurnToHeading's wait-for-anims gate - // (MotionsPending) never opened because the player's queue never - // emptied (launch.log: pending=True on 92/94 player MOTIONDONE - // pops; remotes 0/40). - var rig = MakeRig(out var seq); - var c = rig.Controller; - seq.MotionDoneTarget = (m, ok) => c.Motion.MotionDone(m, ok); - var dest = new Vector3(104f, 96f, 50f); // 8 m dead ahead (heading 90) - - // A few idle frames first — production always has render ticks - // between login (SetPosition → StopCompletely queues the A9 node - // with NO dispatch) and the first Use. - for (int f = 0; f < 30; f++) - { - c.Update(1f / 60f, new MovementInput()); - seq.Advance(1f / 60f); - } - - // Mirrors the GameWindow wire path's P1 unpack store (00509730): - // a server moveto arrives with IsAutonomous=false, routing the - // per-tick pump's A3 dual dispatch to the interpreted branch. - rig.Controller.SetLastMoveWasAutonomous(false); - rig.MoveTo.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = new Position(c.CellId, dest, Quaternion.Identity), - Params = new MovementParameters { UseSpheres = false, DistanceToObject = 0.6f }, - }); - Assert.True(rig.MoveTo.IsMovingTo()); - - for (int f = 0; f < 1200 && rig.MoveTo.IsMovingTo(); f++) - { - c.Update(1f / 60f, new MovementInput()); - seq.Advance(1f / 60f); - } - - string queueDump = string.Join(", ", - System.Linq.Enumerable.Select(c.Motion.PendingMotions, n => $"0x{n.Motion:X8}")); - Assert.False(rig.MoveTo.IsMovingTo(), - $"moveto wedged: pending_motions never drained under the production " - + $"completion feed (queue: [{queueDump}])"); - Assert.Equal(1, rig.MoveToCompleteCount); - } - - [Fact] - public void JumpRelease_CancelsMoveTo() - { - var rig = MakeRig(); - var c = rig.Controller; - - // Mirrors the GameWindow wire path's P1 unpack store (00509730): - // a server moveto arrives with IsAutonomous=false, routing the - // per-tick pump's A3 dual dispatch to the interpreted branch. - rig.Controller.SetLastMoveWasAutonomous(false); - rig.MoveTo.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = new Position(c.CellId, new Vector3(150f, 96f, 50f), Quaternion.Identity), - Params = new MovementParameters { UseSpheres = false }, - }); - Drain(c); - Assert.True(rig.MoveTo.IsMovingTo()); - - // Charge one frame, release the next — jump() fires on the release - // edge and its interrupt site cancels the moveto (the exact - // silent-double-motion failure the TS-36 register row predicted). - c.Update(1f / 60f, new MovementInput { Jump = true }); - c.Update(1f / 60f, new MovementInput()); - - Assert.False(rig.MoveTo.IsMovingTo(), "jump must cancel the moveto (TS-36)"); - Assert.Equal(0, rig.MoveToCompleteCount); - } -} diff --git a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs b/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs deleted file mode 100644 index aa80104e..00000000 --- a/tests/AcDream.Core.Tests/Input/W6EdgeDrivenMovementTests.cs +++ /dev/null @@ -1,202 +0,0 @@ -using System; -using System.Numerics; -using AcDream.App.Input; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; - -namespace AcDream.Core.Tests.Input; - -/// -/// R3-W6 regression suite for the edge-driven local player — specifically -/// the "press W and stop instantly" bug (2026-07-03): the funnel's apply -/// pass let its own style dispatch's ApplyMotion(style) state-write -/// reset forward to Ready (raw 0051ea6c). The original W6 fix entry-cached -/// the axis fields; #161 replaced that with the TRUE retail mechanism — -/// the apply pass's MovementParameters carry -/// ModifyInterpretedState = false (retail's smeared bitfield store -/// at raw 305778, mask 0x37ff; ACE MotionInterp.cs:447), so NO dispatch in -/// the pass can write InterpretedState at all and live field reads -/// are retail semantics. These tests pin the user-visible invariant either -/// way: pressing W keeps you moving. -/// -/// These tests bind the REAL over a -/// real sequencer (a fake sink's return values can mask state-write gating -/// — the lesson that created this file). -/// -public class W6EdgeDrivenMovementTests -{ - private const uint NC = 0x8000003Du; - private const uint Ready = 0x41000003u; - private const uint Walk = 0x45000005u; - private const uint Run = 0x44000007u; - - private sealed class Loader : IAnimationLoader - { - private readonly System.Collections.Generic.Dictionary _anims = new(); - public void Register(uint id, Animation anim) => _anims[id] = anim; - public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null; - } - - private static Animation MakeAnim(int frames) - { - var anim = new Animation(); - for (int f = 0; f < frames; f++) - { - var pf = new AnimationFrame(1); - pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }); - anim.PartFrames.Add(pf); - } - return anim; - } - - private static MotionData MakeMd(uint animId) - { - var md = new MotionData(); - QualifiedDataId qid = animId; - md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f }); - return md; - } - - private static AnimationSequencer MakeSequencer() - { - var setup = new Setup(); - setup.Parts.Add(0x01000000u); - setup.DefaultScale.Add(Vector3.One); - - var loader = new Loader(); - loader.Register(0x300u, MakeAnim(4)); - loader.Register(0x301u, MakeAnim(6)); - loader.Register(0x302u, MakeAnim(6)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC }; - mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready; - mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(0x300u); - mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(0x301u); - mt.Cycles[(int)((NC << 16) | (Run & 0xFFFFFFu))] = MakeMd(0x302u); - return new AnimationSequencer(setup, mt, loader); - } - - private static PhysicsEngine MakeFlatEngine() - { - var engine = new PhysicsEngine(); - var heights = new byte[81]; - Array.Fill(heights, (byte)50); - var heightTable = new float[256]; - for (int i = 0; i < 256; i++) heightTable[i] = i * 1f; - var terrain = new TerrainSurface(heights, heightTable); - engine.AddLandblock(0xA9B4FFFFu, terrain, Array.Empty(), - Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); - return engine; - } - - private static PlayerMovementController MakeControllerWithRealSink(out AnimationSequencer seq) - { - var controller = new PlayerMovementController(MakeFlatEngine()); - seq = MakeSequencer(); - // The full W6 GameWindow bind set (EnterPlayerModeNow equivalent) — - // sink binds BEFORE SetPosition, matching the R4-V5 stall-fix order - // (SetPosition → StopCompletely needs the sink for its type-5 - // dispatch, else its A9 pending_motions node is orphaned). - controller.Motion.DefaultSink = new MotionTableDispatchSink(seq); - var s = seq; - // #174: production wiring — HandleEnterWorld (strip + drain), not - // the bare sequence strip (which orphaned pending manager nodes). - controller.Motion.RemoveLinkAnimations = () => s.Manager.HandleEnterWorld(); - controller.Motion.InitializeMotionTables = () => s.Manager.InitializeState(); - controller.Motion.CheckForCompletedMotions = s.Manager.CheckForCompletedMotions; - controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001); - controller.Yaw = 0f; - return controller; - } - - /// - /// The full apply pass the W6b live bug rode in on. Pre-R4-V5 the - /// trigger was ApplyServerRunRate (the ACE autonomous-echo tap); - /// V5's P1 gate drops that echo before it reaches the player, and the - /// tap is deleted — but the regression these tests pin lives in - /// ApplyInterpretedMovement's pass-params state protection - /// (ModifyInterpretedState=false since #161), which any - /// apply_current_movement pass (HitGround re-apply, future R6 per-tick - /// order) still exercises. Same two statements the deleted tap ran. - /// - private static void RunApplyPass(PlayerMovementController controller, float forwardSpeed) - { - controller.Motion.InterpretedState.ForwardSpeed = forwardSpeed; - controller.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); - } - - [Fact] - public void ApplyPass_WithRealSink_ForwardSelfHeals() - { - // The distilled bug: a full apply pass (style dispatch included) - // against the REAL sink must leave the interpreted forward exactly - // where it started — the style apply's Ready reset is re-applied - // over by the entry-cached fwd dispatch (retail self-heal). - var controller = MakeControllerWithRealSink(out _); - var input = new MovementInput { Forward = true, Run = true }; - controller.Update(1f / 60f, input); // W press edge -> RunForward - - Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand); - - // The live killer: a full apply pass mid-hold (was the ~10Hz - // UM-echo tap pre-V5; see RunApplyPass). - RunApplyPass(controller, 4.5f); - - Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand); - Assert.True(controller.Motion.get_state_velocity().Length() > 1f, - "state velocity must survive the apply pass"); - } - - [Fact] - public void HoldW_WithRealSink_AndEchoes_BodyKeepsMoving() - { - var controller = MakeControllerWithRealSink(out _); - var input = new MovementInput { Forward = true, Run = true }; - - float startX = 96f; - Vector3 pos = new(startX, 96f, 50f); - for (int f = 0; f < 120; f++) - { - if (f % 6 == 3) - RunApplyPass(controller, 4.5f); // ex-ACE-echo cadence - pos = controller.Update(1f / 60f, input).Position; - } - - // 2 seconds of held-W running (post-fix ~9.5 m/s) must cover - // meters, not centimeters. Pre-fix this stalled at ~one tick of - // travel (the echo pass reset forward to Ready). - Assert.True(pos.X - startX > 5f, - $"expected sustained forward motion, got {pos.X - startX:F2} m"); - } - - [Fact] - public void ShiftToggle_MidHold_WalkRunTransitionSurvivesEcho() - { - var controller = MakeControllerWithRealSink(out _); - - // Hold W at run for 30 frames. - for (int f = 0; f < 30; f++) - controller.Update(1f / 60f, new MovementInput { Forward = true, Run = true }); - Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand); - - // Shift pressed (walk) — the set_hold_run edge demotes to walk. - for (int f = 0; f < 30; f++) - { - if (f == 10) RunApplyPass(controller, 1.0f); // apply pass mid-walk - controller.Update(1f / 60f, new MovementInput { Forward = true, Run = false }); - } - Assert.Equal(Walk, controller.Motion.InterpretedState.ForwardCommand); - - // Shift released — promote back to run; survives another echo. - for (int f = 0; f < 30; f++) - { - if (f == 10) RunApplyPass(controller, 4.5f); - controller.Update(1f / 60f, new MovementInput { Forward = true, Run = true }); - } - Assert.Equal(Run, controller.Motion.InterpretedState.ForwardCommand); - } -} diff --git a/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs b/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs deleted file mode 100644 index 7d9bcec7..00000000 --- a/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -public class BurdenMathTests -{ - [Theory] - [InlineData(100, 0, 15000)] // base: str*150 - [InlineData(100, 3, 24000)] // +clamp(3*30,0,150)=90 -> +90*100 - [InlineData(100, 10, 30000)] // 10*30=300 clamped to 150 -> +150*100 - [InlineData(0, 5, 0)] // str<=0 -> 0 - public void EncumbranceCapacity_matches_retail(int str, int aug, int expected) - => Assert.Equal(expected, BurdenMath.EncumbranceCapacity(str, aug)); - - [Theory] - [InlineData(15000, 7500, 0.5f)] - [InlineData(15000, 0, 0f)] - [InlineData(0, 100, 0f)] // cap<=0 guard - public void LoadRatio_is_burden_over_capacity(int cap, int burden, float expected) - => Assert.Equal(expected, BurdenMath.LoadRatio(cap, burden), 4); - - [Theory] - [InlineData(0f, 0f)] - [InlineData(0.5f, 0.16667f)] - [InlineData(1.0f, 0.33333f)] - [InlineData(3.0f, 1.0f)] // full at 300% - [InlineData(4.0f, 1.0f)] // clamped - public void LoadToFill_is_third_clamped(float load, float expected) - => Assert.Equal(expected, BurdenMath.LoadToFill(load), 4); - - [Theory] - [InlineData(0f, 0)] - [InlineData(0.5f, 50)] - [InlineData(1.0f, 100)] - [InlineData(3.0f, 300)] // 300% = full - [InlineData(4.0f, 300)] // SATURATES at 300% (computed from the clamped fill, decomp 176544-176576) - public void LoadToPercent_saturates_at_300(float load, int expected) - => Assert.Equal(expected, BurdenMath.LoadToPercent(load)); -} diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs deleted file mode 100644 index 13e348c4..00000000 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableBurdenTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -public class ClientObjectTableBurdenTests -{ - private const uint Player = 0x50000001u; - - [Fact] - public void SumCarriedBurden_sums_pack_sidebag_and_wielded_but_not_unrelated() - { - var t = new ClientObjectTable(); - // loose pack item - t.AddOrUpdate(new ClientObject { ObjectId = 0xA, ContainerId = Player, Burden = 100 }); - // side bag in pack - t.AddOrUpdate(new ClientObject { ObjectId = 0xB, ContainerId = Player, Burden = 50, - Type = ItemType.Container }); - // item inside the side bag (2-deep) - t.AddOrUpdate(new ClientObject { ObjectId = 0xC, ContainerId = 0xB, Burden = 30 }); - // wielded (ContainerId 0, WielderId = player) - t.AddOrUpdate(new ClientObject { ObjectId = 0xD, WielderId = Player, Burden = 200 }); - // unrelated object in the world - t.AddOrUpdate(new ClientObject { ObjectId = 0xE, ContainerId = 0, Burden = 999 }); - - Assert.Equal(380, t.SumCarriedBurden(Player)); // 100+50+30+200 - } - - [Fact] - public void SumCarriedBurden_unknown_owner_is_zero() - => Assert.Equal(0, new ClientObjectTable().SumCarriedBurden(Player)); -} diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs deleted file mode 100644 index 4c8f524e..00000000 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs +++ /dev/null @@ -1,519 +0,0 @@ -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -public sealed class ClientObjectTableTests -{ - private static ClientObject MakeItem(uint id, string name = "Widget") => - new ClientObject - { - ObjectId = id, - WeenieClassId = 1, - Name = name, - Type = ItemType.Misc, - StackSize = 1, - Burden = 10, - Value = 5, - }; - - [Fact] - public void AddOrUpdate_FiresAddedEvent() - { - var repo = new ClientObjectTable(); - ClientObject? added = null; - repo.ObjectAdded += i => added = i; - - var item = MakeItem(100); - repo.AddOrUpdate(item); - - Assert.Same(item, added); - Assert.Equal(1, repo.ObjectCount); - Assert.Same(item, repo.Get(100)); - } - - [Fact] - public void AddOrUpdate_ExistingItem_FiresPropertiesUpdated() - { - var repo = new ClientObjectTable(); - var item = MakeItem(100); - repo.AddOrUpdate(item); - - int propUpdateCount = 0; - repo.ObjectUpdated += _ => propUpdateCount++; - - repo.AddOrUpdate(item); // second call is an update - Assert.Equal(1, propUpdateCount); - } - - [Fact] - public void MoveItem_UpdatesContainerAndFiresEvent() - { - var repo = new ClientObjectTable(); - var item = MakeItem(100); - repo.AddOrUpdate(item); - - uint seenOld = 999, seenNew = 999; - repo.ObjectMoved += (it, oldC, newC) => { seenOld = oldC; seenNew = newC; }; - - repo.MoveItem(100, 42, newSlot: 3); - - Assert.Equal(0u, seenOld); // was not in any container initially - Assert.Equal(42u, seenNew); - Assert.Equal(42u, item.ContainerId); - Assert.Equal(3, item.ContainerSlot); - } - - [Fact] - public void MoveItem_Nonexistent_ReturnsFalse() - { - var repo = new ClientObjectTable(); - Assert.False(repo.MoveItem(999, 42)); - } - - [Fact] - public void Remove_FiresEventAndRemoves() - { - var repo = new ClientObjectTable(); - var item = MakeItem(100); - repo.AddOrUpdate(item); - - ClientObject? removed = null; - repo.ObjectRemoved += i => removed = i; - - Assert.True(repo.Remove(100)); - Assert.Same(item, removed); - Assert.Null(repo.Get(100)); - } - - [Fact] - public void UpdateProperties_MergesIncomingBundle() - { - var repo = new ClientObjectTable(); - var item = MakeItem(100); - item.Properties.Ints[1] = 10; - repo.AddOrUpdate(item); - - var patch = new PropertyBundle(); - patch.Ints[2] = 20; // new - patch.Ints[1] = 15; // overrides - patch.Strings[100] = "desc"; - repo.UpdateProperties(100, patch); - - Assert.Equal(15, item.Properties.Ints[1]); - Assert.Equal(20, item.Properties.Ints[2]); - Assert.Equal("desc", item.Properties.Strings[100]); - } - - [Fact] - public void Clear_RemovesAllItems() - { - var repo = new ClientObjectTable(); - repo.AddOrUpdate(MakeItem(1)); - repo.AddOrUpdate(MakeItem(2)); - repo.AddOrUpdate(MakeItem(3)); - - repo.Clear(); - Assert.Equal(0, repo.ObjectCount); - } - - [Fact] - public void UpdateIntProperty_uiEffects_setsEffectsAndFires() - { - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x500000ABu }); - ClientObject? fired = null; - repo.ObjectUpdated += i => fired = i; - bool ok = repo.UpdateIntProperty(0x500000ABu, ClientObjectTable.UiEffectsPropertyId, value: 0x9); - Assert.True(ok); - Assert.Equal(0x9u, repo.Get(0x500000ABu)!.Effects); - Assert.Equal(0x9, repo.Get(0x500000ABu)!.Properties.Ints[ClientObjectTable.UiEffectsPropertyId]); - Assert.NotNull(fired); - } - - [Fact] - public void UpdateIntProperty_unknownItem_returnsFalse() - { - var repo = new ClientObjectTable(); - Assert.False(repo.UpdateIntProperty(0xDEADBEEFu, 18u, 1)); - } - - [Fact] - public void UpdateIntProperty_uiEffectsClearedToZero_clearsEffects() - { - // The core "item with mana vs out of mana" promise: a draining item whose - // UiEffects clears to 0 must return to its base (un-tinted) icon. Guards - // against a future `if (value != 0)` regression on the unconditional assign. - var repo = new ClientObjectTable(); - repo.AddOrUpdate(new ClientObject { ObjectId = 0x500000ACu, Effects = 0x1u }); - repo.UpdateIntProperty(0x500000ACu, ClientObjectTable.UiEffectsPropertyId, value: 0x1); - Assert.Equal(0x1u, repo.Get(0x500000ACu)!.Effects); - repo.UpdateIntProperty(0x500000ACu, ClientObjectTable.UiEffectsPropertyId, value: 0); - Assert.Equal(0u, repo.Get(0x500000ACu)!.Effects); - } - - [Fact] - public void ClientObject_NewFields_DefaultAndSettable() - { - var o = new ClientObject - { - ObjectId = 1, WielderId = 0x42u, ItemsCapacity = 24, ContainersCapacity = 7, - Priority = 8u, Structure = 5, MaxStructure = 10, Workmanship = 7.5f, - }; - o.WeenieClassId = 0xABCDu; // now settable - Assert.Equal(0x42u, o.WielderId); - Assert.Equal(24, o.ItemsCapacity); - Assert.Equal(7, o.ContainersCapacity); - Assert.Equal(8u, o.Priority); - Assert.Equal(5, o.Structure); - Assert.Equal(10, o.MaxStructure); - Assert.Equal(7.5f, o.Workmanship); - Assert.Equal(0xABCDu, o.WeenieClassId); - } - - [Fact] - public void WeenieData_Construct() - { - var d = new WeenieData(Guid: 1, Name: "x", Type: ItemType.Misc, WeenieClassId: 2, - IconId: 0x06001234u, IconOverlayId: 0, IconUnderlayId: 0, Effects: 0, - Value: 5, StackSize: 1, StackSizeMax: 1, Burden: 10, - ContainerId: 0x99u, WielderId: null, ValidLocations: null, - CurrentWieldedLocation: null, Priority: null, - ItemsCapacity: null, ContainersCapacity: null, - Structure: null, MaxStructure: null, Workmanship: null); - Assert.Equal(0x99u, d.ContainerId); - } - - private static WeenieData FullWeenie(uint guid, uint icon = 0x06001234u, - string name = "Sword", ItemType type = ItemType.MeleeWeapon, uint effects = 0, - int? value = 100, int? stack = 1, uint? container = null, uint wcid = 0xABCDu) => - new WeenieData(guid, name, type, wcid, icon, 0, 0, effects, - value, stack, StackSizeMax: 1, Burden: 10, ContainerId: container, - WielderId: null, ValidLocations: null, CurrentWieldedLocation: null, - Priority: null, ItemsCapacity: null, ContainersCapacity: null, - Structure: null, MaxStructure: null, Workmanship: null); - - [Fact] - public void Ingest_NewItemWithNoPriorStub_Creates_AndFiresAdded() // the Coldeve bug - { - var table = new ClientObjectTable(); - ClientObject? added = null; - table.ObjectAdded += o => added = o; - var obj = table.Ingest(FullWeenie(0x500000B0u)); - Assert.NotNull(added); - Assert.Equal(0x06001234u, table.Get(0x500000B0u)!.IconId); - Assert.Equal(0xABCDu, obj.WeenieClassId); - } - - [Fact] - public void Ingest_Existing_PatchesInPlace_PreservesPropertyBundle() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x500000B1u)); - table.Get(0x500000B1u)!.Properties.Ints[999u] = 7; // simulate appraise - ClientObject? updated = null; - table.ObjectUpdated += o => updated = o; - table.Ingest(FullWeenie(0x500000B1u, name: "Renamed")); - Assert.NotNull(updated); - Assert.Equal("Renamed", table.Get(0x500000B1u)!.Name); - Assert.Equal(7, table.Get(0x500000B1u)!.Properties.Ints[999u]); // NOT clobbered - } - - [Fact] - public void Ingest_AbsentNullableField_DoesNotClobber() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x500000B2u, value: 100)); - var noValue = FullWeenie(0x500000B2u) with { Value = null }; - table.Ingest(noValue); - Assert.Equal(100, table.Get(0x500000B2u)!.Value); - } - - [Fact] - public void Ingest_Effects_AssignedUnconditionally_ClearsToZero() // D.5.2 contract - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x500000B3u, effects: 0x1u)); - Assert.Equal(0x1u, table.Get(0x500000B3u)!.Effects); - table.Ingest(FullWeenie(0x500000B3u, effects: 0u)); - Assert.Equal(0u, table.Get(0x500000B3u)!.Effects); - } - - [Fact] - public void RecordMembership_CreatesEntry_AndSetsEquip() - { - var table = new ClientObjectTable(); - table.RecordMembership(0x500000B4u, equip: EquipMask.MeleeWeapon); - var o = table.Get(0x500000B4u); - Assert.NotNull(o); - Assert.Equal(EquipMask.MeleeWeapon, o!.CurrentlyEquippedLocation); - Assert.Equal(0u, o.IconId); // data not set — CreateObject fills it - } - - [Fact] - public void Ingest_AfterMembership_FillsData_NoDuplicate() // out-of-order: PD then CreateObject - { - var table = new ClientObjectTable(); - table.RecordMembership(0x500000B5u); - table.Ingest(FullWeenie(0x500000B5u)); - Assert.Equal(1, table.ObjectCount); - Assert.Equal(0x06001234u, table.Get(0x500000B5u)!.IconId); - } - - [Fact] - public void Membership_AfterIngest_NoDuplicate_PreservesData() // out-of-order: CreateObject then PD - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x500000B6u)); // CreateObject first (ground/vendor item) - table.RecordMembership(0x500000B6u, equip: EquipMask.MeleeWeapon); // then PD manifest - Assert.Equal(1, table.ObjectCount); - Assert.Equal(0x06001234u, table.Get(0x500000B6u)!.IconId); // data NOT clobbered by membership - Assert.Equal(EquipMask.MeleeWeapon, table.Get(0x500000B6u)!.CurrentlyEquippedLocation); - } - - [Fact] - public void ContainerIndex_IngestThenContents_OrderedBySlot() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x510u, container: 0xC0u)); - table.Ingest(FullWeenie(0x511u, container: 0xC0u)); - table.MoveItem(0x510u, 0xC0u, newSlot: 1); - table.MoveItem(0x511u, 0xC0u, newSlot: 0); - Assert.Equal(new[] { 0x511u, 0x510u }, table.GetContents(0xC0u)); - } - - [Fact] - public void ContainerIndex_Move_ReparentsBetweenContainers() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x520u, container: 0xC1u)); - table.MoveItem(0x520u, 0xC2u, newSlot: 0); - Assert.Empty(table.GetContents(0xC1u)); - Assert.Equal(new[] { 0x520u }, table.GetContents(0xC2u)); - } - - [Fact] - public void ContainerIndex_Remove_DropsFromContents() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x530u, container: 0xC3u)); - table.Remove(0x530u); - Assert.Empty(table.GetContents(0xC3u)); - } - - [Fact] - public void GetContents_UnknownContainer_Empty() - { - var table = new ClientObjectTable(); - Assert.Empty(table.GetContents(0xDEADu)); - } - - [Fact] - public void Ingest_CreatureTyped_ResolvesNameAndTypeViaGet() // spec §8: selection/describe creature resolution after _liveEntityInfoByGuid retirement - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x560u, name: "Drudge", type: ItemType.Creature)); - var o = table.Get(0x560u); - Assert.NotNull(o); - Assert.Equal("Drudge", o!.Name); // LiveName(guid) reads this - Assert.True((o.Type & ItemType.Creature) != 0); // LiveItemType(guid) & Creature drives creature targeting - } - - [Fact] - public void ReplaceContents_RecordsAllInListOrder() - { - var table = new ClientObjectTable(); - table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); - Assert.Equal(new[] { 0xA01u, 0xA02u, 0xA03u }, table.GetContents(0xC9u)); - Assert.Equal(0xC9u, table.Get(0xA01u)!.ContainerId); - } - - [Fact] - public void ReplaceContents_SecondSmallerList_DetachesDropped() // the full-REPLACE semantics (vs the old additive merge) - { - var table = new ClientObjectTable(); - table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u, 0xA03u }); - table.ReplaceContents(0xC9u, new uint[] { 0xA02u }); // A01 + A03 removed server-side - Assert.Equal(new[] { 0xA02u }, table.GetContents(0xC9u)); - Assert.Equal(0u, table.Get(0xA01u)!.ContainerId); // detached, not lingering - Assert.Equal(0u, table.Get(0xA03u)!.ContainerId); - Assert.Equal(0xC9u, table.Get(0xA02u)!.ContainerId); - } - - [Fact] - public void ReplaceContents_ReordersToNewListOrder() - { - var table = new ClientObjectTable(); - table.ReplaceContents(0xC9u, new uint[] { 0xA01u, 0xA02u }); - table.ReplaceContents(0xC9u, new uint[] { 0xA02u, 0xA01u }); - Assert.Equal(new[] { 0xA02u, 0xA01u }, table.GetContents(0xC9u)); - } - - [Fact] - public void ReplaceContents_ZeroContainer_NoOp() - { - var table = new ClientObjectTable(); - table.ReplaceContents(0u, new uint[] { 0xA01u }); - Assert.Empty(table.GetContents(0u)); - Assert.Null(table.Get(0xA01u)); - } - - [Fact] - public void MoveItemOptimistic_movesAndRemembersPreMove() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x700u, container: 0xC0u)); table.MoveItem(0x700u, 0xC0u, newSlot: 3); - table.MoveItemOptimistic(0x700u, 0xC1u, 0); - Assert.Equal(0xC1u, table.Get(0x700u)!.ContainerId); // moved now (instant) - Assert.True(table.RollbackMove(0x700u)); // pre-move was remembered - Assert.Equal(0xC0u, table.Get(0x700u)!.ContainerId); // snapped back to the original container - Assert.Equal(3, table.Get(0x700u)!.ContainerSlot); // and slot - } - - [Fact] - public void ConfirmMove_clearsPending_soRollbackIsNoOp() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x701u, container: 0xC0u)); - table.MoveItemOptimistic(0x701u, 0xC1u, 0); - table.ConfirmMove(0x701u); - Assert.False(table.RollbackMove(0x701u)); // nothing pending → no rollback - Assert.Equal(0xC1u, table.Get(0x701u)!.ContainerId); // stays at the confirmed container - } - - [Fact] - public void MoveItemOptimistic_twice_keepsTheOriginalPreMove() - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x702u, container: 0xC0u)); - table.MoveItemOptimistic(0x702u, 0xC1u, 0); - table.MoveItemOptimistic(0x702u, 0xC2u, 0); // a second move before any confirm - table.RollbackMove(0x702u); - Assert.Equal(0xC0u, table.Get(0x702u)!.ContainerId); // rolls back to the FIRST pre-move - } - - [Fact] - public void RollbackMove_unknownItem_false() - => Assert.False(new ClientObjectTable().RollbackMove(0xDEADu)); - - [Fact] - public void MoveItemOptimistic_twice_oneConfirm_stillRollsBack() // I1: an early confirm must not strand the item - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x910u, container: 0xC0u)); table.MoveItem(0x910u, 0xC0u, 5); - table.MoveItemOptimistic(0x910u, 0xC1u, 0); // move 1 (outstanding 1, snapshot C0/5) - table.MoveItemOptimistic(0x910u, 0xC2u, 0); // move 2 (outstanding 2) - table.ConfirmMove(0x910u); // server confirms move 1 → outstanding 1, STILL pending - Assert.True(table.RollbackMove(0x910u)); // move 2 rejected → can still roll back (not stranded at C2) - Assert.Equal(0xC0u, table.Get(0x910u)!.ContainerId); - Assert.Equal(5, table.Get(0x910u)!.ContainerSlot); - } - - [Fact] - public void Clear_dropsPendingMoves_soRecycledGuidIsntMisRolledBack() // I2: pending must not survive a table flush - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x911u, container: 0xC0u)); - table.MoveItemOptimistic(0x911u, 0xC1u, 0); // pending snapshot for 0x911 - table.Clear(); // teleport/logoff flushes the table - - table.Ingest(FullWeenie(0x911u, container: 0xC5u)); // a recycled guid reappears fresh - Assert.False(table.RollbackMove(0x911u)); // no stale pending → no mis-rollback - Assert.Equal(0xC5u, table.Get(0x911u)!.ContainerId); - } - - [Fact] - public void MoveItemOptimistic_insertBefore_shiftsOthers_keepsGaplessOrder() // the "items change position" bug - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x920u, container: 0xC0u)); table.MoveItem(0x920u, 0xC0u, 0); - table.Ingest(FullWeenie(0x921u, container: 0xC0u)); table.MoveItem(0x921u, 0xC0u, 1); - table.Ingest(FullWeenie(0x922u, container: 0xC0u)); table.MoveItem(0x922u, 0xC0u, 2); - - // Move 0x922 (index 2) to index 0 → order [0x922, 0x920, 0x921], slots renumbered 0,1,2 (no ties, - // no reshuffle). A raw slot-set would leave 0x922 + 0x920 both at slot 0 → unstable sort. - table.MoveItemOptimistic(0x922u, 0xC0u, 0); - Assert.Equal(new[] { 0x922u, 0x920u, 0x921u }, table.GetContents(0xC0u)); - Assert.Equal(0, table.Get(0x922u)!.ContainerSlot); - Assert.Equal(1, table.Get(0x920u)!.ContainerSlot); - Assert.Equal(2, table.Get(0x921u)!.ContainerSlot); - } - - [Fact] - public void ContainerIndex_SlotChange_ResortsInPlace() // guards the Reindex same-container early-out - { - var table = new ClientObjectTable(); - table.Ingest(FullWeenie(0x540u, container: 0xC4u)); - table.Ingest(FullWeenie(0x541u, container: 0xC4u)); - table.MoveItem(0x540u, 0xC4u, newSlot: 0); - table.MoveItem(0x541u, 0xC4u, newSlot: 1); - Assert.Equal(new[] { 0x540u, 0x541u }, table.GetContents(0xC4u)); - // move 0x540 to a later slot WITHIN THE SAME container — order must flip - table.MoveItem(0x540u, 0xC4u, newSlot: 5); - Assert.Equal(new[] { 0x541u, 0x540u }, table.GetContents(0xC4u)); - Assert.Equal(2, table.GetContents(0xC4u).Count); // no duplicate from the same-container move - } - - [Fact] - public void WieldItemOptimistic_equipsInstantly_setsContainerAndEquip() - { - var table = new ClientObjectTable(); - const uint player = 0x50000001u, pack = 0x40000005u; - table.AddOrUpdate(new ClientObject { ObjectId = 0x940u }); - table.MoveItem(0x940u, pack, newSlot: 2); // a pack item (WielderId stays 0) - table.WieldItemOptimistic(0x940u, player, EquipMask.HeadWear); - var o = table.Get(0x940u)!; - Assert.Equal(EquipMask.HeadWear, o.CurrentlyEquippedLocation); // equipped now (instant) - Assert.Equal(player, o.ContainerId); // contained-by-wielder (matches the WieldObject 0x0023 confirm) - Assert.Equal(0u, o.WielderId); // optimistic wield does NOT write WielderId (ContainerId-based model) - } - - [Fact] - public void WieldItemOptimistic_rollback_unequips_andReturnsToPack() - { - var table = new ClientObjectTable(); - const uint player = 0x50000001u, pack = 0x40000005u; - table.AddOrUpdate(new ClientObject { ObjectId = 0x941u }); - table.MoveItem(0x941u, pack, newSlot: 2); - table.WieldItemOptimistic(0x941u, player, EquipMask.HeadWear); - Assert.True(table.RollbackMove(0x941u)); // server rejected the wield - var o = table.Get(0x941u)!; - Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // un-equipped - Assert.Equal(pack, o.ContainerId); // back in the pack - Assert.Equal(2, o.ContainerSlot); // at its original slot - } - - [Fact] - public void RollbackMove_restoresPreMoveEquipLocation() // unwield-reject must snap back to EQUIPPED - { - var table = new ClientObjectTable(); - const uint player = 0x50000001u; - table.AddOrUpdate(new ClientObject { ObjectId = 0x942u }); - table.MoveItem(0x942u, player, newSlot: -1, newEquipLocation: EquipMask.Shield); // equipped - table.MoveItemOptimistic(0x942u, player, 0); // optimistic UNWIELD into the pack (clears equip) - Assert.Equal(EquipMask.None, table.Get(0x942u)!.CurrentlyEquippedLocation); - Assert.True(table.RollbackMove(0x942u)); // server rejected the unwield - Assert.Equal(EquipMask.Shield, table.Get(0x942u)!.CurrentlyEquippedLocation); // restored to equipped - } - - [Fact] - public void WieldItemOptimistic_unknownItem_false() - => Assert.False(new ClientObjectTable().WieldItemOptimistic(0xDEADu, 0x1u, EquipMask.HeadWear)); - - [Fact] - public void WieldThenMove_oneConfirm_rollsBackToPreWield() // outstanding-count across wield+move (shared RecordPending) - { - var table = new ClientObjectTable(); - const uint player = 0x50000001u, pack = 0x40000005u, pack2 = 0x40000006u; - table.AddOrUpdate(new ClientObject { ObjectId = 0x950u }); - table.MoveItem(0x950u, pack, newSlot: 2); // pre-wield: pack slot 2 - table.WieldItemOptimistic(0x950u, player, EquipMask.HeadWear); // outstanding 1, snapshot (pack, 2, None) - table.MoveItemOptimistic(0x950u, pack2, 0); // outstanding 2 (same snapshot) - table.ConfirmMove(0x950u); // confirm the first → outstanding 1, still pending - Assert.True(table.RollbackMove(0x950u)); // the second rejected → roll back - var o = table.Get(0x950u)!; - Assert.Equal(pack, o.ContainerId); // to the ORIGINAL pre-wield container - Assert.Equal(2, o.ContainerSlot); // and slot - Assert.Equal(EquipMask.None, o.CurrentlyEquippedLocation); // pre-wield was un-equipped - } -} diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs deleted file mode 100644 index e2bab4ea..00000000 --- a/tests/AcDream.Core.Tests/Items/ClientObjectTableUpdateTests.cs +++ /dev/null @@ -1,61 +0,0 @@ -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -public sealed class ClientObjectTableUpdateTests -{ - [Fact] - public void UpsertProperties_unknownObject_createsThenMerges() - { - var t = new ClientObjectTable(); - bool added = false; - t.ObjectAdded += _ => added = true; - - var bundle = new PropertyBundle(); - bundle.Ints[5] = 1234; // EncumbranceVal - - t.UpsertProperties(0x50000001u, bundle); - - var o = t.Get(0x50000001u); - Assert.NotNull(o); - Assert.Equal(1234, o!.Properties.Ints[5]); - Assert.True(added); - } - - [Fact] - public void UpsertProperties_existingObject_mergesAndFiresUpdated() - { - var t = new ClientObjectTable(); - t.AddOrUpdate(new ClientObject { ObjectId = 0x50000001u }); - bool updated = false; - t.ObjectUpdated += _ => updated = true; - - var bundle = new PropertyBundle(); - bundle.Ints[5] = 99; - t.UpsertProperties(0x50000001u, bundle); - - Assert.Equal(99, t.Get(0x50000001u)!.Properties.Ints[5]); - Assert.True(updated); - } - - [Fact] - public void UpdateStackSize_knownObject_setsFieldsAndFiresUpdated() - { - var t = new ClientObjectTable(); - t.AddOrUpdate(new ClientObject { ObjectId = 0x600u, StackSize = 1, Value = 5 }); - bool updated = false; - t.ObjectUpdated += _ => updated = true; - - bool ok = t.UpdateStackSize(0x600u, stackSize: 25, value: 125); - - Assert.True(ok); - Assert.Equal(25, t.Get(0x600u)!.StackSize); - Assert.Equal(125, t.Get(0x600u)!.Value); - Assert.True(updated); - } - - [Fact] - public void UpdateStackSize_unknownObject_returnsFalse() - => Assert.False(new ClientObjectTable().UpdateStackSize(0xDEADu, 1, 1)); -} diff --git a/tests/AcDream.Core.Tests/Items/EquipMaskTests.cs b/tests/AcDream.Core.Tests/Items/EquipMaskTests.cs deleted file mode 100644 index d3576603..00000000 --- a/tests/AcDream.Core.Tests/Items/EquipMaskTests.cs +++ /dev/null @@ -1,54 +0,0 @@ -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -/// -/// Pins every EquipMask member to the verbatim retail INVENTORY_LOC value -/// (docs/research/named-retail/acclient.h:3193). The wire delivers these exact bits -/// (ACE EquipMask == retail INVENTORY_LOC); any drift desyncs the paperdoll element-id→mask -/// map and the GetAndWieldItem wire. This test is the anti-regression lock. -/// -public sealed class EquipMaskTests -{ - [Theory] - [InlineData(0x00000000u, EquipMask.None)] - [InlineData(0x00000001u, EquipMask.HeadWear)] - [InlineData(0x00000002u, EquipMask.ChestWear)] - [InlineData(0x00000004u, EquipMask.AbdomenWear)] - [InlineData(0x00000008u, EquipMask.UpperArmWear)] - [InlineData(0x00000010u, EquipMask.LowerArmWear)] - [InlineData(0x00000020u, EquipMask.HandWear)] - [InlineData(0x00000040u, EquipMask.UpperLegWear)] - [InlineData(0x00000080u, EquipMask.LowerLegWear)] - [InlineData(0x00000100u, EquipMask.FootWear)] - [InlineData(0x00000200u, EquipMask.ChestArmor)] - [InlineData(0x00000400u, EquipMask.AbdomenArmor)] - [InlineData(0x00000800u, EquipMask.UpperArmArmor)] - [InlineData(0x00001000u, EquipMask.LowerArmArmor)] - [InlineData(0x00002000u, EquipMask.UpperLegArmor)] - [InlineData(0x00004000u, EquipMask.LowerLegArmor)] - [InlineData(0x00008000u, EquipMask.NeckWear)] - [InlineData(0x00010000u, EquipMask.WristWearLeft)] - [InlineData(0x00020000u, EquipMask.WristWearRight)] - [InlineData(0x00040000u, EquipMask.FingerWearLeft)] - [InlineData(0x00080000u, EquipMask.FingerWearRight)] - [InlineData(0x00100000u, EquipMask.MeleeWeapon)] - [InlineData(0x00200000u, EquipMask.Shield)] - [InlineData(0x00400000u, EquipMask.MissileWeapon)] - [InlineData(0x00800000u, EquipMask.MissileAmmo)] - [InlineData(0x01000000u, EquipMask.Held)] - [InlineData(0x02000000u, EquipMask.TwoHanded)] - [InlineData(0x04000000u, EquipMask.TrinketOne)] - [InlineData(0x08000000u, EquipMask.Cloak)] - [InlineData(0x10000000u, EquipMask.SigilOne)] - [InlineData(0x20000000u, EquipMask.SigilTwo)] - [InlineData(0x40000000u, EquipMask.SigilThree)] - public void Member_has_canonical_retail_value(uint expected, EquipMask member) - => Assert.Equal(expected, (uint)member); - - [Fact] - public void Weapon_ready_slot_composite_is_0x3500000() // acclient.h:3235 WEAPON_READY_SLOT_LOC - => Assert.Equal(0x3500000u, - (uint)(EquipMask.MeleeWeapon | EquipMask.MissileWeapon | EquipMask.Held | EquipMask.TwoHanded)); -} diff --git a/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs b/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs new file mode 100644 index 00000000..79fe2ef7 --- /dev/null +++ b/tests/AcDream.Core.Tests/Items/ItemRepositoryTests.cs @@ -0,0 +1,119 @@ +using AcDream.Core.Items; +using Xunit; + +namespace AcDream.Core.Tests.Items; + +public sealed class ItemRepositoryTests +{ + private static ItemInstance MakeItem(uint id, string name = "Widget") => + new ItemInstance + { + ObjectId = id, + WeenieClassId = 1, + Name = name, + Type = ItemType.Misc, + StackSize = 1, + Burden = 10, + Value = 5, + }; + + [Fact] + public void AddOrUpdate_FiresAddedEvent() + { + var repo = new ItemRepository(); + ItemInstance? added = null; + repo.ItemAdded += i => added = i; + + var item = MakeItem(100); + repo.AddOrUpdate(item); + + Assert.Same(item, added); + Assert.Equal(1, repo.ItemCount); + Assert.Same(item, repo.GetItem(100)); + } + + [Fact] + public void AddOrUpdate_ExistingItem_FiresPropertiesUpdated() + { + var repo = new ItemRepository(); + var item = MakeItem(100); + repo.AddOrUpdate(item); + + int propUpdateCount = 0; + repo.ItemPropertiesUpdated += _ => propUpdateCount++; + + repo.AddOrUpdate(item); // second call is an update + Assert.Equal(1, propUpdateCount); + } + + [Fact] + public void MoveItem_UpdatesContainerAndFiresEvent() + { + var repo = new ItemRepository(); + var item = MakeItem(100); + repo.AddOrUpdate(item); + + uint seenOld = 999, seenNew = 999; + repo.ItemMoved += (it, oldC, newC) => { seenOld = oldC; seenNew = newC; }; + + repo.MoveItem(100, 42, newSlot: 3); + + Assert.Equal(0u, seenOld); // was not in any container initially + Assert.Equal(42u, seenNew); + Assert.Equal(42u, item.ContainerId); + Assert.Equal(3, item.ContainerSlot); + } + + [Fact] + public void MoveItem_Nonexistent_ReturnsFalse() + { + var repo = new ItemRepository(); + Assert.False(repo.MoveItem(999, 42)); + } + + [Fact] + public void Remove_FiresEventAndRemoves() + { + var repo = new ItemRepository(); + var item = MakeItem(100); + repo.AddOrUpdate(item); + + ItemInstance? removed = null; + repo.ItemRemoved += i => removed = i; + + Assert.True(repo.Remove(100)); + Assert.Same(item, removed); + Assert.Null(repo.GetItem(100)); + } + + [Fact] + public void UpdateProperties_MergesIncomingBundle() + { + var repo = new ItemRepository(); + var item = MakeItem(100); + item.Properties.Ints[1] = 10; + repo.AddOrUpdate(item); + + var patch = new PropertyBundle(); + patch.Ints[2] = 20; // new + patch.Ints[1] = 15; // overrides + patch.Strings[100] = "desc"; + repo.UpdateProperties(100, patch); + + Assert.Equal(15, item.Properties.Ints[1]); + Assert.Equal(20, item.Properties.Ints[2]); + Assert.Equal("desc", item.Properties.Strings[100]); + } + + [Fact] + public void Clear_RemovesAllItems() + { + var repo = new ItemRepository(); + repo.AddOrUpdate(MakeItem(1)); + repo.AddOrUpdate(MakeItem(2)); + repo.AddOrUpdate(MakeItem(3)); + + repo.Clear(); + Assert.Equal(0, repo.ItemCount); + } +} diff --git a/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs b/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs deleted file mode 100644 index a58dabd9..00000000 --- a/tests/AcDream.Core.Tests/Items/ShortcutStoreTests.cs +++ /dev/null @@ -1,39 +0,0 @@ -using AcDream.Core.Items; -using Xunit; - -namespace AcDream.Core.Tests.Items; - -public class ShortcutStoreTests -{ - [Fact] - public void Load_mapsSlotToObjId_skipsEmptyAndOutOfRange() - { - var store = new ShortcutStore(); - store.Load(new (int, uint)[] - { - (0, 0x5001u), - (3, 0x5002u), - (5, 0u), // empty/spell → skipped - (99, 0x5003u), // out of range → skipped - }); - Assert.Equal(0x5001u, store.Get(0)); - Assert.Equal(0x5002u, store.Get(3)); - Assert.Equal(0u, store.Get(5)); - Assert.True(store.IsEmpty(1)); - } - - [Fact] - public void SetRemoveGet_roundtrip_andBoundsSafe() - { - var store = new ShortcutStore(); - store.Set(4, 0xABCDu); - Assert.Equal(0xABCDu, store.Get(4)); - Assert.False(store.IsEmpty(4)); - store.Remove(4); - Assert.True(store.IsEmpty(4)); - Assert.Equal(0u, store.Get(-1)); - Assert.Equal(0u, store.Get(18)); - store.Set(18, 1u); // no-op, no throw - store.Remove(-1); // no-op, no throw - } -} diff --git a/tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs b/tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs deleted file mode 100644 index 174c4c41..00000000 --- a/tests/AcDream.Core.Tests/Lighting/GlobalLightPackerTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System.Numerics; -using AcDream.Core.Lighting; -using Xunit; - -namespace AcDream.Core.Tests.Lighting; - -public class GlobalLightPackerTests -{ - [Fact] - public void Pack_WritesSixteenFloatsPerLight_InTheExpectedLayout() - { - var light = new LightSource - { - Kind = LightKind.Point, - WorldPosition = new Vector3(10f, 20f, 30f), - WorldForward = new Vector3(0f, 0f, 1f), - ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), - Intensity = 100f, - Range = 5.2f, - ConeAngle = 0f, - }; - float[] buffer = System.Array.Empty(); - - int count = GlobalLightPacker.Pack(new[] { light }, ref buffer); - - Assert.Equal(1, count); - Assert.True(buffer.Length >= 16); - Assert.Equal(10f, buffer[0]); Assert.Equal(20f, buffer[1]); Assert.Equal(30f, buffer[2]); - Assert.Equal((float)(int)LightKind.Point, buffer[3]); - Assert.Equal(0f, buffer[4]); Assert.Equal(0f, buffer[5]); Assert.Equal(1f, buffer[6]); - Assert.Equal(5.2f, buffer[7]); - Assert.Equal(1.0f, buffer[8]); Assert.Equal(0.588f, buffer[9]); Assert.Equal(0.314f, buffer[10]); - Assert.Equal(100f, buffer[11]); - Assert.Equal(0f, buffer[12]); - } - - [Fact] - public void Pack_NullOrEmpty_ReturnsZero_AndBufferHasAtLeastOneSlot() - { - float[] buffer = System.Array.Empty(); - int count = GlobalLightPacker.Pack(null, ref buffer); - Assert.Equal(0, count); - Assert.True(buffer.Length >= GlobalLightPacker.FloatsPerLight); - } -} diff --git a/tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs b/tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs deleted file mode 100644 index ab3c0803..00000000 --- a/tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Lighting; -using Xunit; - -namespace AcDream.Core.Tests.Lighting; - -/// -/// Golden conformance for the retail bake (calc_point_light + the [0,1] clamp), -/// driven by the live-cdb-captured Holtburg wall torches. Pins the contract that -/// mesh_modern.vert's pointContribution + the new pointAcc clamp (A7 Fix D, D-1) -/// must mirror line-for-line. See docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md. -/// -public class LightBakeConformanceTests -{ - private static LightSource OrangeTorch(Vector3 pos) => new() - { - Kind = LightKind.Point, - WorldPosition = pos, - ColorLinear = new Vector3(1.0f, 0.588f, 0.314f), // captured orange - Intensity = 100f, - Range = 4f * 1.3f, // falloff 4 × static_light_factor - IsLit = true, - }; - - [Theory] - [InlineData(1f)] - [InlineData(2f)] - [InlineData(3f)] - [InlineData(4f)] - [InlineData(5f)] - public void SingleOrangeTorch_IsWarmAndBounded_NeverWhite(float dist) - { - var vtx = Vector3.Zero; - var normal = Vector3.UnitX; - var torch = OrangeTorch(new Vector3(dist, 0f, 0f)); - - var c = LightBake.ComputeVertexColor(vtx, normal, new[] { torch }); - - Assert.InRange(c.X, 0f, 1f); - Assert.InRange(c.Y, 0f, 1f); - Assert.InRange(c.Z, 0f, 1f); - if (c.X > 0f) - { - Assert.True(c.X >= c.Y, $"R({c.X}) >= G({c.Y}) at d={dist}"); - Assert.True(c.Y >= c.Z, $"G({c.Y}) >= B({c.Z}) at d={dist}"); - } - } - - [Fact] - public void BeyondRange_ContributesNothing() - { - var torch = OrangeTorch(new Vector3(100f, 0f, 0f)); - var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, new[] { torch }); - Assert.Equal(Vector3.Zero, c); - } - - [Fact] - public void ManyOverlappingIntenseTorches_StillClampToOne() - { - var lights = new List(); - for (int i = 0; i < 8; i++) - lights.Add(new LightSource - { - Kind = LightKind.Point, - WorldPosition = new Vector3(1.5f, 0.1f * i, 0f), - ColorLinear = new Vector3(0.98f, 0.95f, 0.9f), - Intensity = 100f, - Range = 5.2f, - IsLit = true, - }); - - var c = LightBake.ComputeVertexColor(Vector3.Zero, Vector3.UnitX, lights); - Assert.InRange(c.X, 0f, 1f); - Assert.InRange(c.Y, 0f, 1f); - Assert.InRange(c.Z, 0f, 1f); - } -} diff --git a/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs b/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs deleted file mode 100644 index be082b37..00000000 --- a/tests/AcDream.Core.Tests/Lighting/LightBakeTests.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using System.Numerics; -using AcDream.Core.Lighting; -using Xunit; - -namespace AcDream.Core.Tests.Lighting; - -/// -/// Conformance tests for the per-vertex static-light burn-in -/// (), ported from retail calc_point_light -/// (0x0059c8b0). Golden values are hand-derived from the decompiled equation: -/// wrap = (1/1.5)·(N·D + 0.5·dist); norm = distsq>1 ? distsq·dist : dist; -/// scale = (1 − dist/Range)·intensity·(wrap/norm); contrib = min(scale·color, color). -/// -public sealed class LightBakeTests -{ - private static LightSource Torch(Vector3 pos, float intensity = 100f, float range = 10f) - => new LightSource - { - Kind = LightKind.Point, - WorldPosition = pos, - ColorLinear = Vector3.One, - Intensity = intensity, - Range = range, - IsLit = true, - }; - - [Fact] - public void NearTorch_FacingIt_SaturatesToColor() - { - // Vertex at origin facing up (+Z); torch 2 m above. - // dist=2, distsq=4, wrap=(1/1.5)(2+1)=2, norm=4·2=8, - // scale=(1-0.2)·100·(2/8)=20 → min(20·1,1)=1 per channel. - var c = LightBake.PointContribution( - Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 2))); - Assert.Equal(1f, c.X, 4); - Assert.Equal(1f, c.Y, 4); - Assert.Equal(1f, c.Z, 4); - } - - [Fact] - public void FarTorch_FallsOffSmoothly() - { - // Torch 8 m above (still within Range 10). scale=(1-0.8)·100·(8/512)=0.3125. - var c = LightBake.PointContribution( - Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 8))); - Assert.Equal(0.3125f, c.X, 4); - Assert.Equal(0.3125f, c.Y, 4); - Assert.Equal(0.3125f, c.Z, 4); - } - - [Fact] - public void OutOfRange_ContributesNothing() - { - // Torch 11 m above, Range 10 → dist >= falloff_eff, skipped. - var c = LightBake.PointContribution( - Vector3.Zero, new Vector3(0, 0, 1), Torch(new Vector3(0, 0, 11))); - Assert.Equal(Vector3.Zero, c); - } - - [Fact] - public void FacingAway_BeyondWrap_ContributesNothing() - { - // Normal points away (−Z) from a torch above: N·D=−2, wrap=(1/1.5)(−2+1)<0. - var c = LightBake.PointContribution( - Vector3.Zero, new Vector3(0, 0, -1), Torch(new Vector3(0, 0, 2))); - Assert.Equal(Vector3.Zero, c); - } - - [Fact] - public void HalfLambertWrap_LightsSurfaceAngledPast90Degrees() - { - // Normal at ~100° from the light direction still gets light (Lambert would not). - // Light straight above (+Z 2 m); normal tilted to (sin100°, 0, cos100°). - double t = 100.0 * Math.PI / 180.0; - var n = new Vector3((float)Math.Sin(t), 0, (float)Math.Cos(t)); // cos100° < 0 - var c = LightBake.PointContribution(Vector3.Zero, n, Torch(new Vector3(0, 0, 2))); - Assert.True(c.X > 0f, "half-Lambert wrap should light a surface angled past 90°"); - } - - [Fact] - public void ComputeVertexColor_SumsLightsAndClampsToOne() - { - // Two saturating torches → sum clamps to 1, never overflows. - var lights = new[] - { - Torch(new Vector3(0, 0, 2)), - Torch(new Vector3(0, 0, 2)), - }; - var c = LightBake.ComputeVertexColor(Vector3.Zero, new Vector3(0, 0, 1), lights); - Assert.Equal(1f, c.X, 4); - Assert.Equal(1f, c.Y, 4); - Assert.Equal(1f, c.Z, 4); - } - - [Fact] - public void ComputeVertexColor_SkipsDirectionalAndUnlit() - { - var lights = new[] - { - new LightSource { Kind = LightKind.Directional, WorldPosition = new Vector3(0,0,2), - ColorLinear = Vector3.One, Intensity = 100f, Range = 10f, IsLit = true }, - new LightSource { Kind = LightKind.Point, WorldPosition = new Vector3(0,0,2), - ColorLinear = Vector3.One, Intensity = 100f, Range = 10f, IsLit = false }, - }; - var c = LightBake.ComputeVertexColor(Vector3.Zero, new Vector3(0, 0, 1), lights); - Assert.Equal(Vector3.Zero, c); - } -} diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index 139e0d7a..9df68a2b 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -6,7 +6,7 @@ namespace AcDream.Core.Tests.Lighting; public sealed class LightManagerTests { - private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true, uint cellId = 0) + private static LightSource MakePoint(Vector3 pos, float range, uint ownerId = 0, bool lit = true) => new LightSource { Kind = LightKind.Point, @@ -14,7 +14,6 @@ public sealed class LightManagerTests Range = range, IsLit = lit, OwnerId = ownerId, - CellId = cellId, }; [Fact] @@ -61,29 +60,21 @@ public sealed class LightManagerTests } [Fact] - public void Tick_SelectsByDistance_RegardlessOfViewerRange() + public void Tick_DropsLightsOutsideRangeWithSlack() { - // Retail D3D-style: candidacy is distance-only (the nearest 8). A torch - // lights its OWN surfaces — the shader applies the hard `d < range` cutoff - // PER FRAGMENT (mesh_modern.frag) — so a torch the VIEWER is standing - // outside the range of is still selected; it lights the wall it sits on. - // Replaces the old viewer-range candidacy filter that suppressed it, which - // left dungeon rooms (2227 registered torches) at activeLights≈1 / flat 0.2 - // ambient — the "dungeon lighting off" report (#133 A7). var mgr = new LightManager(); - mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // viewer outside the torch's range + mgr.Register(MakePoint(new Vector3(20, 0, 0), range: 5f)); // far outside its own range mgr.Tick(viewerWorldPos: Vector3.Zero); - Assert.Equal(1, mgr.ActiveCount); // selected by distance; the shader culls per-surface + Assert.Equal(0, mgr.ActiveCount); } [Fact] - public void Tick_IncludesNearbyLight() + public void Tick_IncludesLightsNearRangeEdge_WithSlack() { var mgr = new LightManager(); - // A nearby point light is selected (distance-only candidacy; the shader - // applies the per-fragment range cutoff). + // Light at distance 5.0, range 5.0: distSq=25, rangeSq*1.1^2 = 25*1.21 = 30.25 → included. mgr.Register(MakePoint(new Vector3(5, 0, 0), range: 5f)); mgr.Tick(viewerWorldPos: Vector3.Zero); @@ -145,230 +136,4 @@ public sealed class LightManagerTests mgr.Tick(new Vector3(3, 0, 0)); // same x, same y, z diff 4 Assert.Equal(16f, light.DistSq, 2); } - - // ── Fix B: per-object selection (minimize_object_lighting) ──────────────── - - [Fact] - public void BuildPointLightSnapshot_ExcludesDirectionalAndUnlit() - { - var mgr = new LightManager(); - mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // in - mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, lit: false)); // unlit → out - mgr.Register(new LightSource { Kind = LightKind.Directional }); // sun → out - - mgr.BuildPointLightSnapshot(Vector3.Zero); - - Assert.Single(mgr.PointSnapshot); - Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3); - } - - [Fact] - public void BuildPointLightSnapshot_IndexStable_InBudget() - { - var mgr = new LightManager(); - // Registration order preserved when under MaxGlobalLights (no sort). - mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far - mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near - - mgr.BuildPointLightSnapshot(Vector3.Zero); - - Assert.Equal(2, mgr.PointSnapshot.Count); - Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered - Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3); - } - - // ── Visible-cell scoping (retail: add_*_lights over visible_cell_table) ──── - // A7 #176/#177: the per-frame pool is built from ONLY the lights of currently- - // visible cells (plus cell-less globals), not a flat world-space set. - - [Fact] - public void BuildPointLightSnapshot_VisibleScope_ExcludesLightsOfNonVisibleCells() - { - var mgr = new LightManager(); - mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); // visible cell - mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // under-room, NOT visible - - var visible = new System.Collections.Generic.HashSet { 0xAAAA0101u }; - mgr.BuildPointLightSnapshot(Vector3.Zero, visible); - - // Only the visible cell's light survives — the under-room light can't wash - // through the floor (retail: its cell isn't in visible_cell_table). - Assert.Single(mgr.PointSnapshot); - Assert.Equal(0xAAAA0101u, mgr.PointSnapshot[0].CellId); - } - - [Fact] - public void BuildPointLightSnapshot_VisibleScope_AlwaysIncludesCellLessGlobals() - { - var mgr = new LightManager(); - mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0u)); // viewer/global — CellId 0 - mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); // non-visible cell - - var visible = new System.Collections.Generic.HashSet { 0xAAAA0101u }; // does NOT contain 0102 - mgr.BuildPointLightSnapshot(Vector3.Zero, visible); - - // The cell-less light (viewer fill) is always a candidate; the non-visible - // cell's light is dropped. - Assert.Single(mgr.PointSnapshot); - Assert.Equal(0u, mgr.PointSnapshot[0].CellId); - } - - [Fact] - public void BuildPointLightSnapshot_NullScope_KeepsFullPool() - { - var mgr = new LightManager(); - mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f, cellId: 0xAAAA0101u)); - mgr.Register(MakePoint(new Vector3(2, 0, 0), 5f, cellId: 0xAAAA0102u)); - - // Null visible set = outdoor root / no flood → legacy full-pool behaviour. - mgr.BuildPointLightSnapshot(Vector3.Zero, visibleCells: null); - - Assert.Equal(2, mgr.PointSnapshot.Count); - } - - [Fact] - public void SelectForObject_EmptySnapshot_ReturnsZero() - { - Span idx = stackalloc int[8]; - int n = LightManager.SelectForObject(System.Array.Empty(), Vector3.Zero, 1f, idx); - Assert.Equal(0, n); - } - - [Fact] - public void SelectForObject_InRange_Selected() - { - var snapshot = new[] { MakePoint(new Vector3(3, 0, 0), range: 5f) }; // dist 3 < range 5 - Span idx = stackalloc int[8]; - int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); - Assert.Equal(1, n); - Assert.Equal(0, idx[0]); - } - - [Fact] - public void SelectForObject_OutOfRange_Excluded() - { - // dist 10, range 5, radius 0 → 10 >= 5 → excluded. - var snapshot = new[] { MakePoint(new Vector3(10, 0, 0), range: 5f) }; - Span idx = stackalloc int[8]; - int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); - Assert.Equal(0, n); - } - - [Fact] - public void SelectForObject_ObjectRadiusExtendsReach() - { - // dist 7, range 5: out of reach at radius 0, but a radius-3 object sphere - // overlaps (7 < 5+3). The whole object catches the light — retail uses the - // object's bounding sphere, not its centre point. - var snapshot = new[] { MakePoint(new Vector3(7, 0, 0), range: 5f) }; - Span idx = stackalloc int[8]; - - Assert.Equal(0, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx)); - Assert.Equal(1, LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 3f, idx)); - } - - [Fact] - public void SelectForObject_MoreThan8_KeepsNearest8() - { - // 10 candidate lights all in range; expect the 8 nearest the object centre, - // ascending by distance, with the two farthest dropped. - var snapshot = new LightSource[10]; - for (int i = 0; i < 10; i++) - snapshot[i] = MakePoint(new Vector3(i + 1, 0, 0), range: 100f); // dist i+1, all in range - - Span idx = stackalloc int[8]; - int n = LightManager.SelectForObject(snapshot, Vector3.Zero, radius: 0f, idx); - - Assert.Equal(8, n); - // Nearest-first: index 0 (dist 1) … index 7 (dist 8). The two farthest - // (indices 8,9 / dist 9,10) are evicted. - for (int k = 0; k < 8; k++) - Assert.Equal(k, idx[k]); - } - - [Fact] - public void SelectForObject_CameraIndependent_DependsOnlyOnObjectCentre() - { - // Same snapshot, same object centre → identical selection regardless of - // where any "camera" is (the method takes no camera). This is the property - // that kills the "lights up as I approach" popping. - var snapshot = new[] - { - MakePoint(new Vector3(2, 0, 0), range: 10f), - MakePoint(new Vector3(20, 0, 0), range: 10f), // out of reach of centre 0 - }; - Span a = stackalloc int[8]; - Span b = stackalloc int[8]; - int na = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, a); - int nb = LightManager.SelectForObject(snapshot, Vector3.Zero, 1f, b); - - Assert.Equal(1, na); - Assert.Equal(na, nb); - Assert.Equal(a[0], b[0]); - } - - /// - /// #176/#177 (2026-07-06) — the end-state pin, via the SHIPPED fix (visible-cell - /// scoping, not "uncap"). Before: BuildPointLightSnapshot kept only the - /// MaxGlobalLights nearest THE CAMERA over the WHOLE registered set, so in - /// the Facility Hub (366 fixtures) an in-range torch of a VISIBLE cell could rank - /// past the cap and be evicted → the cell's 8-set (and its Gouraud vertex lighting) - /// flipped as the camera moved (#176 seam flash / #177 stair-room pop-in). The fix - /// is retail's per-frame collection: the pool is built from ONLY the lights of the - /// currently-VISIBLE cells (CObjCell::add_*_to_global_lights over - /// CEnvCell::visible_cell_table), so the visible pool is a handful of cells, - /// the cap never bites, and a visible cell's in-range light is never camera-evicted. - /// The same scoping keeps a NON-visible cell's light out of the pool entirely - /// (through-floor prevention). See docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md. - /// - [Fact] - public void PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant() - { - var mgr = new LightManager(); - - // 400 fixtures clustered near the origin, all in the UNDER-ROOM cell (not - // visible from the target room). These would have filled every low - // camera-distance rank under the old camera-nearest cap. - const uint underRoom = 0xAAAA0102u; - for (int i = 0; i < 400; i++) - mgr.Register(MakePoint(new Vector3(i * 0.05f, 0, 0), range: 5f, ownerId: (uint)(i + 1), cellId: underRoom)); - - // The target torch: far from the origin-side camera, in the VISIBLE room - // cell, squarely in range of the target object around (200, 0, 0). - const uint targetRoom = 0xAAAA0101u; - var torch = MakePoint(new Vector3(198f, 0, 0), range: 15f, ownerId: 0xF00DF00Du, cellId: targetRoom); - mgr.Register(torch); - - // The portal flood says only the target room is visible. - var visible = new System.Collections.Generic.HashSet { targetRoom }; - Span sel = stackalloc int[LightManager.MaxLightsPerObject]; - - // Camera parked at the origin end — the torch must still light the visible cell. - mgr.BuildPointLightSnapshot(cameraWorldPos: Vector3.Zero, visible); - int n1 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel); - bool torchSelectedFar = SelectedContains(mgr.PointSnapshot, sel, n1, torch); - // The 400 under-room lights are NOT in the pool (their cell isn't visible). - int underRoomInPool = 0; - foreach (var l in mgr.PointSnapshot) if (l.CellId == underRoom) underRoomInPool++; - - // Camera next to the cell — the reference behaviour. - mgr.BuildPointLightSnapshot(cameraWorldPos: new Vector3(200f, 0, 0), visible); - int n2 = LightManager.SelectForObject(mgr.PointSnapshot, new Vector3(200f, 0, 0), 6f, sel); - bool torchSelectedNear = SelectedContains(mgr.PointSnapshot, sel, n2, torch); - - Assert.True(torchSelectedNear, "sanity: the torch reaches the cell when the camera is beside it"); - Assert.True(torchSelectedFar, - "an in-range light of a VISIBLE cell was evicted by the snapshot cap — " + - "per-cell lighting would pop with camera movement (the #176/#177 mechanism)"); - Assert.Equal(0, underRoomInPool); // through-floor prevention: non-visible cell's lights excluded - - static bool SelectedContains( - System.Collections.Generic.IReadOnlyList snapshot, - Span indices, int count, LightSource target) - { - for (int i = 0; i < count; i++) - if (ReferenceEquals(snapshot[indices[i]], target)) return true; - return false; - } - } } diff --git a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs index 676155bf..c3884a66 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs @@ -93,7 +93,7 @@ public sealed class LightInfoLoaderTests var light = result[0]; Assert.Equal(LightKind.Point, light.Kind); Assert.Equal(77u, light.OwnerId); - Assert.Equal(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24) + Assert.Equal(8f, light.Range); Assert.Equal(0.8f, light.Intensity); Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition); Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f); diff --git a/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs b/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs index 887bd340..54dc9c28 100644 --- a/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs +++ b/tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs @@ -179,89 +179,4 @@ public class GfxObjDegradeResolverTests Assert.Equal(baseId, resolvedId); Assert.Null(resolvedGfx); } - - // ── #136: editor-only placement marker detection ────────────────────────── - - /// - /// The #136 dungeon "cone": its degrade table's slot 0 is visible ONLY at distance 0 - /// (MaxDist=0) and the table degrades to GfxObj id 0 (= nothing) at real distance. - /// Retail's distance degrade never draws it in the live client; we must skip it. - /// - [Fact] - public void IsRuntimeHiddenMarker_EditorMarkerDegradingToNothing_True() - { - const uint markerGfx = 0x010028CAu; - const uint degradeId = 0x11000118u; - var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; - var info = new GfxObjDegradeInfo - { - Degrades = - { - new GfxObjInfo { Id = markerGfx, MaxDist = 0f }, - new GfxObjInfo { Id = 0u, MaxDist = float.MaxValue }, - }, - }; - var gfxObjs = new Dictionary { [markerGfx] = gfx }; - var infos = new Dictionary { [degradeId] = info }; - - Assert.True(GfxObjDegradeResolver.IsRuntimeHiddenMarker( - id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), markerGfx)); - } - - /// A real LOD object — slot 0 visible out to a real distance (MaxDist>0) — - /// is NOT a marker, even though it degrades further. - [Fact] - public void IsRuntimeHiddenMarker_NormalLodObject_False() - { - const uint baseId = 0x01000055u; - const uint degradeId = 0x110006D0u; - var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; - var info = new GfxObjDegradeInfo - { - Degrades = - { - new GfxObjInfo { Id = 0x01001795u, MaxDist = 25f }, - new GfxObjInfo { Id = 0u, MaxDist = float.MaxValue }, - }, - }; - var gfxObjs = new Dictionary { [baseId] = gfx }; - var infos = new Dictionary { [degradeId] = info }; - - Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( - id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId)); - } - - /// No degrade table at all → not a marker. - [Fact] - public void IsRuntimeHiddenMarker_NoDegradeTable_False() - { - const uint baseId = 0x01001212u; - var gfx = new GfxObj { Flags = 0, DIDDegrade = 0 }; - var gfxObjs = new Dictionary { [baseId] = gfx }; - Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( - id => gfxObjs.GetValueOrDefault(id), _ => null, baseId)); - } - - /// slot 0 is editor-only (MaxDist=0) but degrades to a REAL mesh (no id-0 - /// entry) — a genuine close-only LOD, not an invisible marker. Do NOT skip. - [Fact] - public void IsRuntimeHiddenMarker_EditorSlotButDegradesToRealMesh_False() - { - const uint baseId = 0x01002000u; - const uint degradeId = 0x11002000u; - var gfx = new GfxObj { Flags = GfxObjFlags.HasDIDDegrade, DIDDegrade = degradeId }; - var info = new GfxObjDegradeInfo - { - Degrades = - { - new GfxObjInfo { Id = baseId, MaxDist = 0f }, - new GfxObjInfo { Id = 0x01002001u, MaxDist = float.MaxValue }, - }, - }; - var gfxObjs = new Dictionary { [baseId] = gfx }; - var infos = new Dictionary { [degradeId] = info }; - - Assert.False(GfxObjDegradeResolver.IsRuntimeHiddenMarker( - id => gfxObjs.GetValueOrDefault(id), id => infos.GetValueOrDefault(id), baseId)); - } } diff --git a/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs b/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs index 94440fb3..2bcb4e8d 100644 --- a/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs +++ b/tests/AcDream.Core.Tests/Physics/A6P7DispatchRulesTests.cs @@ -75,48 +75,4 @@ public class A6P7DispatchRulesTests { Assert.Equal(expected, Transition.BspOnlyDispatch(entityState)); } - - // ----------------------------------------------------------------------- - // W1 (2026-06-24) — named PvP/missile terms are false in M1.5 - // ----------------------------------------------------------------------- - - /// - /// W1: must return false in M1.5. - /// When PK ships (M2+) this stub will accept mover + target state; - /// the test pins the current named-false value as a guard. - /// Retail oracle: CPhysicsObj::FindObjCollisions pc:276808–276841. - /// - [Fact] - public void W1_PvpExempt_ReturnsFalseInM15Scope() - { - // PvP hasn't shipped (M1.5); the stub always returns false. - Assert.False(Transition.PvpExempt()); - } - - /// - /// W1: must return false in M1.5. - /// When missiles ship (F.3) this stub will accept mover OBJECTINFO + - /// target state; the test pins the current named-false value as a guard. - /// Retail oracle: OBJECTINFO::missile_ignore pc:274385; - /// dispatch use pc:276858–276861. - /// - [Fact] - public void W1_MissileIgnore_ReturnsFalseInM15Scope() - { - // Missiles haven't shipped (M1.5); the stub always returns false. - Assert.False(Transition.MissileIgnore()); - } - - /// - /// Guard: with both W1 terms false, a BSP-flagged entity still - /// dispatches BSP-only — A6.P7 door behavior is unchanged after W1. - /// This protects the A6.P7 cottage-door / dungeon-wall fix. - /// - [Fact] - public void W1_BspOnlyDispatch_DoorStateStillDispatchesBspOnly() - { - // Cottage door state from A6.P7 investigation: 0x10008 = STATIC | REPORT | HAS_BSP - const uint doorState = 0x00010008u; - Assert.True(Transition.BspOnlyDispatch(doorState)); - } } diff --git a/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs index 767265f1..83ca7d07 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationCommandRouterTests.cs @@ -1,6 +1,5 @@ using AcDream.Core.Physics; using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; using Xunit; namespace AcDream.Core.Tests.Physics; @@ -25,13 +24,7 @@ public sealed class AnimationCommandRouterTests [Fact] public void RouteWireCommand_SubState_UsesSetCycle() { - // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) - // — GetObjectSequence also refuses substate==0, so the MotionTable - // needs both a StyleDefaults entry AND cycles for the style's default - // substate (Ready, installed by initialize_state) and for the routed - // Dead substate, or the whole dispatch chain silently no-ops and - // CurrentStyle/CurrentMotion stay at their zero defaults. - var seq = MakeRoutableSequencer(); + var seq = MakeEmptySequencer(); var route = AnimationCommandRouter.RouteWireCommand(seq, NonCombat, 0x0011); @@ -66,66 +59,8 @@ public sealed class AnimationCommandRouterTests return new AnimationSequencer(new Setup(), new MotionTable(), new NullAnimationLoader()); } - /// - /// R2-Q4: a MotionTable that can actually complete a SubState dispatch — - /// StyleDefaults[NonCombat]=Ready (required by initialize_state's - /// SetDefaultState) plus cycles for both Ready (the installed baseline) - /// and Dead (the substate this test routes to). One shared part/anim is - /// enough; RouteWireCommand only inspects CurrentStyle/CurrentMotion. - /// - private static AnimationSequencer MakeRoutableSequencer() - { - const uint Ready = 0x41000003u; - const uint Dead = 0x40000011u; - const uint AnimId = 0x03000001u; - - var setup = new Setup(); - setup.Parts.Add(0x01000000u); - setup.DefaultScale.Add(System.Numerics.Vector3.One); - - var mt = new MotionTable - { - DefaultStyle = (DatReaderWriter.Enums.MotionCommand)NonCombat, - }; - mt.StyleDefaults[(DatReaderWriter.Enums.MotionCommand)NonCombat] = - (DatReaderWriter.Enums.MotionCommand)Ready; - - int ReadyKey = (int)((NonCombat << 16) | (Ready & 0xFFFFFFu)); - int DeadKey = (int)((NonCombat << 16) | (Dead & 0xFFFFFFu)); - mt.Cycles[ReadyKey] = MakeMotionData(AnimId); - mt.Cycles[DeadKey] = MakeMotionData(AnimId); - - var loader = new NullAnimationLoader(); - loader.Register(AnimId, MakeAnim()); - - return new AnimationSequencer(setup, mt, loader); - } - - private static MotionData MakeMotionData(uint animId) - { - var md = new MotionData(); - QualifiedDataId qid = animId; - md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f }); - return md; - } - - private static Animation MakeAnim() - { - var anim = new Animation(); - var pf = new AnimationFrame(1); - pf.Frames.Add(new Frame - { - Origin = System.Numerics.Vector3.Zero, - Orientation = System.Numerics.Quaternion.Identity, - }); - anim.PartFrames.Add(pf); - return anim; - } - private sealed class NullAnimationLoader : IAnimationLoader { - private readonly System.Collections.Generic.Dictionary _anims = new(); - public void Register(uint id, Animation anim) => _anims[id] = anim; - public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null; + public Animation? LoadAnimation(uint id) => null; } } diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs deleted file mode 100644 index 1af00c11..00000000 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerCutoverTraceTests.cs +++ /dev/null @@ -1,388 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using System.Text; -using AcDream.Core.Physics; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; -using Xunit; - -using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; - -namespace AcDream.Core.Tests.Physics; - -// ───────────────────────────────────────────────────────────────────────────── -// R2-Q4 adapter-cutover trace conformance (r2-port-plan.md §3 Q4). -// -// These scenarios were CAPTURED against the PRE-cutover adapter (the legacy -// SetCycle/PlayAction with the fast-path, Fix B, stop-anim fallback, and G17 -// gate) at commit aa65990a, then replayed against the PerformMovement-hosted -// adapter. Golden strings assert the post-Q4 behavior; every place the cutover -// INTENTIONALLY changed the outcome carries an `EXPECTED-DIFF(Q4)` comment -// with the pre-cutover value and the retail rationale. Everything without an -// annotation is byte-identical across the cutover — that is the parity bar. -// -// Snapshot format (Describe): comma-joined node list in queue order, each node -// `@` with suffix `*` = first_cyclic, `^` = curr_anim; -// then ` | frame= vel=(x,y,z F2) om=(x,y,z F2) style= motion= -// mod=`. -// ───────────────────────────────────────────────────────────────────────────── - -internal sealed class TraceLoader : IAnimationLoader -{ - private readonly Dictionary _anims = new(); - private readonly Dictionary _ids = new(); - - public void Register(uint id, Animation anim) - { - _anims[id] = anim; - _ids[anim] = id; - } - - public Animation? LoadAnimation(uint id) => - _anims.TryGetValue(id, out var a) ? a : null; - - public uint IdOf(Animation anim) => _ids.TryGetValue(anim, out var id) ? id : 0; -} - -public sealed class AnimationSequencerCutoverTraceTests -{ - // Styles (FULL command words, as GameWindow passes them). - private const uint NC = 0x8000003Du; // NonCombat - private const uint Style2 = 0x8000004Cu; // synthetic second style - - // Substates / cycles. - private const uint Ready = 0x41000003u; - private const uint Walk = 0x45000005u; - private const uint WalkBack = 0x45000006u; - private const uint Run = 0x44000007u; - private const uint Falling = 0x40000015u; - - // Action / modifier class ids. - private const uint EmoteAction = 0x10000062u; - private const uint TurnMod = 0x6500000Du; - - // Anim resource ids. - private const uint ReadyAnim = 0x100u; // 4 frames - private const uint WalkAnim = 0x101u; // 6 frames - private const uint RunAnim = 0x102u; // 6 frames - private const uint ReadyToWalkLink = 0x103u; // 2 frames - private const uint ReadyToRunLink = 0x104u; // 2 frames - private const uint WalkToReadyLink = 0x105u; // 3 frames - private const uint RunToReadyLink = 0x108u; // 3 frames - private const uint TurnModAnim = 0x109u; // 2 frames - private const uint ReadyToStyle2Link = 0x10Au; // 2 frames - private const uint ReadyToEmoteLink = 0x10Bu; // 5 frames - private const uint FallAnim = 0x10Cu; // 4 frames - private const uint Style2ReadyAnim = 0x107u; // 4 frames - - private static Animation MakeAnim(int numFrames, int numParts = 1) - { - var anim = new Animation(); - for (int f = 0; f < numFrames; f++) - { - var pf = new AnimationFrame((uint)numParts); - for (int p = 0; p < numParts; p++) - pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }); - anim.PartFrames.Add(pf); - } - return anim; - } - - private static MotionData MakeMd(uint animId, float framerate = 30f, - Vector3? velocity = null, Vector3? omega = null) - { - var md = new MotionData(); - QualifiedDataId qid = animId; - md.Anims.Add(new AnimData - { - AnimId = qid, - LowFrame = 0, - HighFrame = -1, - Framerate = framerate, - }); - if (velocity is { } v) - { - md.Velocity = v; - md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasVelocity; - } - if (omega is { } o) - { - md.Omega = o; - md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasOmega; - } - return md; - } - - private static void AddLink(MotionTable mt, uint style, uint from, uint to, MotionData md) - { - int outer = (int)((style << 16) | (from & 0xFFFFFFu)); - if (!mt.Links.TryGetValue(outer, out var cmd)) - { - cmd = new MotionCommandData(); - mt.Links[outer] = cmd; - } - cmd.MotionData[(int)to] = md; - } - - private static (Setup, MotionTable, TraceLoader) BuildFixture() - { - var setup = new Setup(); - setup.Parts.Add(0x01000000u); - setup.DefaultScale.Add(Vector3.One); - - var loader = new TraceLoader(); - loader.Register(ReadyAnim, MakeAnim(4)); - loader.Register(WalkAnim, MakeAnim(6)); - loader.Register(RunAnim, MakeAnim(6)); - loader.Register(ReadyToWalkLink, MakeAnim(2)); - loader.Register(ReadyToRunLink, MakeAnim(2)); - loader.Register(WalkToReadyLink, MakeAnim(3)); - loader.Register(RunToReadyLink, MakeAnim(3)); - loader.Register(TurnModAnim, MakeAnim(2)); - loader.Register(ReadyToStyle2Link, MakeAnim(2)); - loader.Register(ReadyToEmoteLink, MakeAnim(5)); - loader.Register(FallAnim, MakeAnim(4)); - loader.Register(Style2ReadyAnim, MakeAnim(4)); - - var mt = new MotionTable - { - DefaultStyle = (DRWMotionCommand)NC, - }; - mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready; - mt.StyleDefaults[(DRWMotionCommand)Style2] = (DRWMotionCommand)Ready; - - int CycleKey(uint style, uint substate) => (int)((style << 16) | (substate & 0xFFFFFFu)); - mt.Cycles[CycleKey(NC, Ready)] = MakeMd(ReadyAnim); - mt.Cycles[CycleKey(NC, Walk)] = MakeMd(WalkAnim, velocity: new Vector3(0f, 3.12f, 0f)); - mt.Cycles[CycleKey(NC, Run)] = MakeMd(RunAnim, velocity: new Vector3(0f, 4.0f, 0f)); - mt.Cycles[CycleKey(NC, Falling)] = MakeMd(FallAnim); - mt.Cycles[CycleKey(Style2, Ready)] = MakeMd(Style2ReadyAnim); - - AddLink(mt, NC, Ready, Walk, MakeMd(ReadyToWalkLink)); - AddLink(mt, NC, Ready, Run, MakeMd(ReadyToRunLink)); - AddLink(mt, NC, Walk, Ready, MakeMd(WalkToReadyLink)); - AddLink(mt, NC, Run, Ready, MakeMd(RunToReadyLink)); - AddLink(mt, NC, Ready, EmoteAction, MakeMd(ReadyToEmoteLink)); - AddLink(mt, NC, Ready, Style2, MakeMd(ReadyToStyle2Link)); - - // Modifier: styled key (styleMasked<<16 | low24). - int modKey = (int)((NC << 16) | (TurnMod & 0xFFFFFFu)); - mt.Modifiers[modKey] = MakeMd(TurnModAnim, omega: new Vector3(0f, 0f, 1.5f)); - - return (setup, mt, loader); - } - - private static string Describe(AnimationSequencer seq, TraceLoader loader) - { - var core = seq.Core; - var sb = new StringBuilder(); - bool first = true; - for (var n = core.AnimList.First; n is not null; n = n.Next) - { - if (!first) sb.Append(','); - first = false; - uint id = n.Value.Anim is null ? 0u : loader.IdOf(n.Value.Anim); - sb.Append($"{id:X}@{n.Value.Framerate:F1}"); - if (ReferenceEquals(n, core.FirstCyclicNode)) sb.Append('*'); - if (ReferenceEquals(n, core.CurrAnimNode)) sb.Append('^'); - } - var v = core.Velocity; - var o = core.Omega; - sb.Append($" | frame={core.FrameNumber:F1}"); - sb.Append($" vel=({v.X:F2},{v.Y:F2},{v.Z:F2})"); - sb.Append($" om=({o.X:F2},{o.Y:F2},{o.Z:F2})"); - sb.Append($" style={seq.CurrentStyle:X8} motion={seq.CurrentMotion:X8} mod={seq.CurrentSpeedMod:F2}"); - return sb.ToString(); - } - - private static AnimationSequencer NewSeq(out TraceLoader loader) - { - var (setup, mt, l) = BuildFixture(); - loader = l; - return new AnimationSequencer(setup, mt, l); - } - - // ── Scenarios ─────────────────────────────────────────────────────────── - - [Fact] - public void S1_SpawnIdle() - { - var seq = NewSeq(out var loader); - seq.SetCycle(NC, Ready); - Assert.Equal( - "100@30.0*^ | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=41000003 mod=1.00", - Describe(seq, loader)); - } - - [Fact] - public void S2_IdleToWalk_LinkThenCycle() - { - var seq = NewSeq(out var loader); - seq.SetCycle(NC, Ready); - seq.SetCycle(NC, Walk, 1.0f); - Assert.Equal( - "103@30.0^,101@30.0* | frame=0.0 vel=(0.00,3.12,0.00) om=(0.00,0.00,0.00) style=8000003D motion=45000005 mod=1.00", - Describe(seq, loader)); - } - - [Fact] - public void S2b_LinkDrain_FiresOneAnimDoneSentinel() - { - var seq = NewSeq(out var loader); - seq.SetCycle(NC, Ready); - seq.SetCycle(NC, Walk, 1.0f); - - // Drain the 2-frame link at fr=30: 2 frames / 30fps < 0.1s. Advance - // enough to cross into the cycle. - int animDone = 0; - for (int i = 0; i < 10; i++) - { - seq.Advance(0.02f); - foreach (var h in seq.ConsumePendingHooks()) - if (h is DatReaderWriter.Types.AnimationDoneHook) - animDone++; - } - Assert.Equal(1, animDone); - } - - [Fact] - public void S3_WalkToRun_CyclicToCyclic() - { - var seq = NewSeq(out var loader); - seq.SetCycle(NC, Ready); - seq.SetCycle(NC, Walk, 1.0f); - seq.SetCycle(NC, Run, 2.0f); - // EXPECTED-DIFF(Q4): pre-cutover Fix B stripped every link leaving - // "102@60.0*^". The verbatim GetObjectSequence (Branch 2, no direct - // Walk->Run link in this fixture) routes the DOUBLE-HOP: Walk->Ready - // settle (105 at the OLD substate mod) + Ready->Run windup (104 at - // the new speed) + the Run cycle; the old Ready->Walk link (103) - // keeps draining first (pending-queue discipline). Rapid same-motion - // re-issues now collapse via remove_redundant_links (the retail - // Fix B), not an adapter locomotion special case. - Assert.Equal( - "103@30.0^,105@30.0,104@60.0,102@60.0* | frame=0.0 vel=(0.00,8.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=44000007 mod=2.00", - Describe(seq, loader)); - } - - [Fact] - public void S4_RunReSpeed_FastPath() - { - var seq = NewSeq(out var loader); - seq.SetCycle(NC, Ready); - seq.SetCycle(NC, Walk, 1.0f); - seq.SetCycle(NC, Run, 2.0f); - seq.SetCycle(NC, Run, 2.5f); - // EXPECTED-DIFF(Q4): inherits S3's double-hop list; the re-speed - // itself is the verbatim Branch-2 fast path (change_cycle_speed - // scales ONLY first_cyclic..tail: 102 60->75; links untouched; - // velocity via subtract_motion(2.0)+combine_motion(2.5)). - Assert.Equal( - "103@30.0^,105@30.0,104@60.0,102@75.0* | frame=0.0 vel=(0.00,10.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=44000007 mod=2.50", - Describe(seq, loader)); - } - - [Fact] - public void S5_WalkBackward_RemapNegativeSpeed() - { - var seq = NewSeq(out var loader); - seq.SetCycle(NC, Ready); - seq.SetCycle(NC, WalkBack, 1.0f); - // Node list BYTE-IDENTICAL to pre-cutover: the reversed-key get_link - // resolves the Walk->Ready link (0x105) played in reverse (fr=-19.5 = - // 30 x -0.65 BackwardsFactor); cursor at the reverse starting frame - // (HighFrame+1 = 3). - // EXPECTED-DIFF(Q4), mirrors only: CurrentMotion now reads the - // POST-adjust_motion substate (45000005, was 45000006) and - // CurrentSpeedMod the signed mod (-0.65, was 1.00) - MotionState owns - // the state and retail's interpreted state is post-adjustment. The - // om=(-0.00,...) is IEEE negative zero from add_motion's - // zero-omega x negative-speed multiply (numerically equal to 0). - Assert.Equal( - "105@-19.5^,101@-19.5* | frame=3.0 vel=(0.00,-2.03,0.00) om=(-0.00,-0.00,-0.00) style=8000003D motion=45000005 mod=-0.65", - Describe(seq, loader)); - } - - [Fact] - public void S6_WalkBackToReady_StopSettleFallback() - { - var seq = NewSeq(out var loader); - seq.SetCycle(NC, Ready); - seq.SetCycle(NC, WalkBack, 1.0f); - seq.SetCycle(NC, Ready, 1.0f); - // EXPECTED-DIFF(Q4): pre-cutover the stop-anim low-byte fallback - // re-keyed the settle as 105@30 (forward). The verbatim get_link - // (SubstateMod=-0.65 routes the REVERSED-key branch) resolves the - // Ready->Walk windup (103) played in REVERSE (fr=-30) - retail's - // actual backward-walk settle. The old reversed link (105@-19.5, - // mid-drain) still drains first, unchanged from pre-cutover. - Assert.Equal( - "105@-19.5^,103@-30.0,100@30.0* | frame=3.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=41000003 mod=1.00", - Describe(seq, loader)); - } - - [Fact] - public void S7_EmoteAction_MidReady() - { - var seq = NewSeq(out var loader); - seq.SetCycle(NC, Ready); - seq.PlayAction(EmoteAction, 1.0f); - Assert.Equal( - "10B@30.0^,100@30.0* | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=41000003 mod=1.00", - Describe(seq, loader)); - } - - [Fact] - public void S8_LeaveGroundLinkStrip_FallingEngagesInstantly() - { - // EXPECTED-DIFF(R3-W4): the K-fix18 skipTransitionLink flag is - // DELETED. The instant-Falling engage is retail's own mechanism: - // MotionInterpreter.LeaveGround (0x00528b00) fires the - // RemoveLinkAnimations seam — bound to this sequencer's - // RemoveAllLinkAnimations (CPhysicsObj::RemoveLinkAnimations - // 0x0050fe20 → CSequence::remove_all_link_animations) — after the - // Falling dispatch. Same final state the flag produced. - var seq = NewSeq(out var loader); - seq.SetCycle(NC, Ready); - seq.SetCycle(NC, Walk, 1.0f); - seq.SetCycle(NC, Falling, 1.0f); - seq.RemoveAllLinkAnimations(); // = LeaveGround's seam firing - Assert.Equal( - "10C@30.0*^ | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000003D motion=40000015 mod=1.00", - Describe(seq, loader)); - } - - [Fact] - public void S9_TurnModifier() - { - var seq = NewSeq(out var loader); - seq.SetCycle(NC, Ready); - seq.SetCycle(NC, Walk, 1.0f); - seq.PlayAction(TurnMod, 1.0f); - // EXPECTED-DIFF(Q4): pre-cutover PlayAction INSERTED the modifier's - // anim (109) before the cyclic tail - an acdream invention. Retail - // Branch 4 is PHYSICS-ONLY combine_motion (the AP-73 mechanism): the - // walk cycle's frames are untouched, the modifier contributes omega - // (0,0,1.5) and is tracked on the MotionState modifier stack. - Assert.Equal( - "103@30.0^,101@30.0* | frame=0.0 vel=(0.00,3.12,0.00) om=(0.00,0.00,1.50) style=8000003D motion=45000005 mod=1.00", - Describe(seq, loader)); - } - - [Fact] - public void S10_StyleChange() - { - var seq = NewSeq(out var loader); - seq.SetCycle(NC, Ready); - seq.SetCycle(Style2, Ready, 1.0f); - // EXPECTED-DIFF(Q4): pre-cutover switched cycles bare ("107@30.0*^"). - // GetObjectSequence Branch 1 (style-change) plays the cross-style - // entry link (10A = Ready->Style2) before the target style's default - // cycle - retail's stance-transition animation. - Assert.Equal( - "10A@30.0^,107@30.0* | frame=0.0 vel=(0.00,0.00,0.00) om=(0.00,0.00,0.00) style=8000004C motion=41000003 mod=1.00", - Describe(seq, loader)); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs index ed63ef8e..e911d21d 100644 --- a/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/AnimationSequencerTests.cs @@ -224,16 +224,32 @@ public sealed class AnimationSequencerTests } [Fact] - public void SetCycle_MissingCycle_LeavesSequenceAndStateUntouched() + public void HasCycle_PresentInTable_ReturnsTrue() { - // R2-Q5: HasCycle + the caller-side fallback chains are DELETED. - // The retail mechanism replacing the L.1c "torso on the ground" - // guard: GetObjectSequence (0x00522860) checks the cycle BEFORE any - // list surgery — a missing cycle leaves the sequence AND MotionState - // untouched, so whatever was playing (here the initialize_state - // default) keeps playing. - const uint Style = 0x8000003Cu; // HandCombat (full command) - const uint ReadyMotion = 0x41000003u; + // Phase L.1c followup (2026-04-28): regression guard for + // "torso on the ground" — caller (GameWindow MoveTo path) needs + // to query the table before SetCycle to avoid the + // ClearCyclicTail wipe on a missing cycle. + const uint Style = 0x003Cu; // HandCombat + const uint Motion = 0x0003u; // Ready + const uint AnimId = 0x03000001u; + + var setup = Fixtures.MakeSetup(2); + var mt = Fixtures.MakeMtable(Style, Motion, AnimId); + var loader = new FakeLoader(); + loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity)); + var seq = new AnimationSequencer(setup, mt, loader); + + // Caller passes the SAME shape SetCycle expects: full style with + // class byte (0x80000000) and full motion (0x40000000 / 0x10000000). + Assert.True(seq.HasCycle(0x8000003Cu, 0x41000003u)); + } + + [Fact] + public void HasCycle_MissingFromTable_ReturnsFalse() + { + const uint Style = 0x003Cu; + const uint ReadyMotion = 0x0003u; const uint AnimId = 0x03000001u; var setup = Fixtures.MakeSetup(2); @@ -242,17 +258,9 @@ public sealed class AnimationSequencerTests loader.Register(AnimId, Fixtures.MakeTwoFrameAnim(2, Vector3.Zero, Quaternion.Identity, Vector3.Zero, Quaternion.Identity)); var seq = new AnimationSequencer(setup, mt, loader); - seq.InitializeState(); - Assert.Equal(ReadyMotion, seq.CurrentMotion); - int nodesBefore = seq.QueueCount; - - // RunForward (0x44000007) is NOT in the table — the dispatch fails - // and nothing changes (no cyclic-tail wipe, no state overwrite). - seq.SetCycle(Style, 0x44000007u); - - Assert.Equal(ReadyMotion, seq.CurrentMotion); - Assert.Equal(nodesBefore, seq.QueueCount); - Assert.True(seq.HasCurrentNode); + // RunForward (0x44000007) is NOT in the table — caller should + // see false and fall back to a known motion (WalkForward / Ready). + Assert.False(seq.HasCycle(0x8000003Cu, 0x44000007u)); } [Fact] @@ -325,21 +333,13 @@ public sealed class AnimationSequencerTests [Fact] public void SetCycle_WithTransitionLink_PrependLinkFrames() { - // R2-Q4: GetObjectSequence gates Branch 2 (cyclic substates) on the - // 0x40000000 class bit and Branch 1 (style change) on the top bit — - // bare low-word ids like the pre-cutover 0x0003/0x0005 never satisfy - // those gates and the dispatch silently fails. Tag Style/IdleMotion/ - // WalkMotion with their class bits (masking to the low 24 bits for - // the cycle/link key hash is unaffected — CMotionTable keys on - // `id & 0xFFFFFF`). - const uint Style = 0x8000003Du; - const uint IdleMotion = 0x40000003u; - const uint WalkMotion = 0x40000005u; - const uint IdleAnim = 0x03000012u; + // Two animations: link (2 frames at Y=1) and cycle (4 frames at X=1). + const uint Style = 0x003Du; + const uint IdleMotion = 0x0003u; + const uint WalkMotion = 0x0005u; const uint CycleAnim = 0x03000010u; const uint LinkAnim = 0x03000011u; - var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity); var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(1, 0, 0), Quaternion.Identity); var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 1, 0), Quaternion.Identity); @@ -352,26 +352,15 @@ public sealed class AnimationSequencerTests fromMotion: IdleMotion, toMotion: WalkMotion, linkAnimId: LinkAnim); - // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) — - // route it at IdleMotion (the state this test "was already playing" - // before priming) and give IdleMotion its own cycle so the real - // SetCycle(Style, IdleMotion) priming call below actually dispatches. - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; - int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); - mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 30f); var loader = new FakeLoader(); - loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); - // Prime the sequencer as if it was already playing IdleMotion — a - // real SetCycle call now that CurrentStyle/CurrentMotion are - // read-only mirrors of MotionState (R2-Q4; reflection SetValue no - // longer works, "Property set method not found"). - seq.SetCycle(Style, IdleMotion); + // Prime the sequencer as if it was already playing IdleMotion. + SetCurrentMotion(seq, Style, IdleMotion); seq.SetCycle(Style, WalkMotion); @@ -393,13 +382,9 @@ public sealed class AnimationSequencerTests // link's starting pose at the link→cycle boundary. Symptoms: door // swing-open flap (frame 0 = closed); player run-stop twitch // (frame 0 = mid-stride). - // R2-Q4: class-bit-tagged ids (GetObjectSequence gates Branch 1/2 on - // the 0x80000000/0x40000000 bits) — masking to the low 24 bits for - // the cycle/link key hash is unaffected. - const uint Style = 0x8000003Du; - const uint IdleMotion = 0x40000003u; - const uint WalkMotion = 0x40000005u; - const uint IdleAnim = 0x03000082u; + const uint Style = 0x003Du; + const uint IdleMotion = 0x0003u; + const uint WalkMotion = 0x0005u; const uint CycleAnim = 0x03000080u; const uint LinkAnim = 0x03000081u; @@ -417,7 +402,6 @@ public sealed class AnimationSequencerTests // Cycle anim: single frame at Y=0 (the "open" / "idle" rest pose). var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity); - var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = Fixtures.MakeMtable( @@ -428,23 +412,13 @@ public sealed class AnimationSequencerTests toMotion: WalkMotion, linkAnimId: LinkAnim, framerate: 30f); - // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) - // — route it at IdleMotion (the state we prime through below) with - // its own cycle so the priming SetCycle call actually dispatches. - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; - int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); - mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 30f); var loader = new FakeLoader(); - loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); - // Prime the sequencer as if it was already playing IdleMotion — a - // real SetCycle call (reflection SetValue no longer works against - // the now-read-only CurrentStyle/CurrentMotion mirrors). - seq.SetCycle(Style, IdleMotion); + SetCurrentMotion(seq, Style, IdleMotion); seq.SetCycle(Style, WalkMotion); // Advance to _framePosition ≈ 2.5 — past the last integer frame (2) @@ -466,71 +440,53 @@ public sealed class AnimationSequencerTests [Fact] public void SetCycle_StopFromWalkBackward_FallsBackToWalkForwardStopLink() { - // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests. - // S6_WalkBackToReady_StopSettleFallback): the pre-cutover adapter had - // an invented "stop-anim low-byte fallback" that re-keyed a - // WalkForward->Ready link when a WalkBackward->Ready lookup missed. - // Retail has no such fallback — CMotionTable.GetLink's verbatim - // reversed-key branch (Q0-pins A1) does the real job: adjust_motion - // remaps WalkBackward to WalkForward with a NEGATIVE SubstateMod, so - // stopping from it drives GetLink's "either speed negative -> swapped - // keys" path, which resolves the link stored FROM the style default - // (Ready) TO WalkForward and plays it IN REVERSE (the Ready->Walk - // windup run backward as a settle). The fixture below stores that - // link under Links[(style,Ready)][WalkForward] — the opposite - // direction from the old WalkForward->Ready fallback entry — because - // that's the key GetLink's reversed branch actually probes. - const uint Style = 0x8000003Du; - const uint WalkForwardCmd = 0x40000005u; - const uint WalkBackCmd = 0x40000006u; - const uint ReadyCmd = 0x40000003u; + // Stop-anim asymmetry: the Humanoid motion table only authors a + // "stop walking" link under WalkForward (low byte 0x05). Stopping + // from WalkBackward (0x06) without a fallback returns null linkData + // and the cycle snaps to Ready with no settle blend. Fix: when the + // primary GetLink lookup fails, retry with WalkBackward's low byte + // remapped to WalkForward. + const uint Style = 0x003Du; + const uint WalkForwardCmd = 0x0005u; + const uint WalkBackCmd = 0x0006u; + const uint ReadyCmd = 0x0003u; const uint CycleAnim = 0x03000090u; // Ready cycle (Y=0) - const uint LinkAnim = 0x03000091u; // Ready->Walk windup (Y=7), played reversed as the settle + const uint LinkAnim = 0x03000091u; // stop-link (Y=7) var cycleAnim = Fixtures.MakeAnim(1, 1, new Vector3(0, 0, 0), Quaternion.Identity); var linkAnim = Fixtures.MakeAnim(4, 1, new Vector3(0, 7, 0), Quaternion.Identity); var setup = Fixtures.MakeSetup(1); - // Table: Ready cycle + Ready->WalkForward windup link (probed - // REVERSED by GetLink's swapped-key branch when settling out of a - // negative-SubstateMod substate). No forward WalkBackward cycle is - // needed — adjust_motion remaps WalkBackward to WalkForward with a - // negated + BackwardsFactor-scaled speed before dispatch ever sees it. + // Table: Ready cycle + WalkForward→Ready link. NO WalkBackward→Ready link. var mt = Fixtures.MakeMtable( style: Style, motion: ReadyCmd, cycleAnimId: CycleAnim, - fromMotion: ReadyCmd, - toMotion: WalkForwardCmd, + fromMotion: WalkForwardCmd, + toMotion: ReadyCmd, linkAnimId: LinkAnim, framerate: 30f); - // WalkForward also needs a cycle — adjust_motion's WalkBackward remap - // dispatches WalkForward's cycle (with the negated/scaled speed) as - // part of entering "backward" motion below. - int walkKey = (int)((Style << 16) | (WalkForwardCmd & 0xFFFFFFu)); - mt.Cycles[walkKey] = Fixtures.MakeMotionData(CycleAnim, framerate: 30f); var loader = new FakeLoader(); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); - // Enter WalkBackward for real — SetCycle's adjust_motion head remaps - // this to WalkForward with SubstateMod = -0.65 (BackwardsFactor), - // which is what makes the SUBSEQUENT stop-to-Ready call route - // GetLink's reversed-key branch. - seq.SetCycle(Style, WalkBackCmd, 1.0f); + // Simulate "we were walking backward" — substate = WalkBackward, + // substateSpeed = +1 (the original speedMod stored by SetCycle). + SetCurrentMotion(seq, Style, WalkBackCmd); seq.SetCycle(Style, ReadyCmd); - // Advance a tiny dt — should land on the reversed windup link - // (Y=7), not the Ready cycle (Y=0). + // Advance a tiny dt — should land on link frame 0 (Y=7), not the + // cycle (Y=0). Without the fallback, linkData is null, only the + // Ready cycle is enqueued, and we read Y=0 immediately. var transforms = seq.Advance(0.001f); Assert.Single(transforms); Assert.True(transforms[0].Origin.Y > 5f, - $"Stop-from-backward should resolve GetLink's reversed-key branch " - + $"(expect Y≈7 from the reversed windup link); got Y={transforms[0].Origin.Y} " - + "(Y=0 means the link didn't resolve and we snapped to the Ready cycle)."); + $"Stop-from-backward should fall back to WalkForward→Ready link " + + $"(expect Y≈7 from link); got Y={transforms[0].Origin.Y} " + + "(Y=0 means linkData was null and we snapped to Ready cycle)."); } [Fact] @@ -625,14 +581,10 @@ public sealed class AnimationSequencerTests // with negated speed, so the animation plays in reverse. // We verify this by checking CurrentMotion is still TurnLeft (the // original command), but the sequencer internally uses TurnRight's anim. - // R2-Q4: Style needs the 0x80000000 top bit and TurnRight/TurnLeft the - // 0x40000000 cycle-class bit — GetObjectSequence's entry/branch gates - // (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity) test the - // FULL command word, not just the low 16 bits adjust_motion remaps. - const uint Style = 0x8000003Du; // NonCombat - const uint TurnRight = 0x4045000Du; // bit pattern for TurnRight in NonCombat - const uint TurnLeft = 0x4045000Eu; // bit pattern for TurnLeft + const uint Style = 0x003Du; // NonCombat + const uint TurnRight = 0x0045000Du; // bit pattern for TurnRight in NonCombat + const uint TurnLeft = 0x0045000Eu; // bit pattern for TurnLeft const uint AnimId = 0x03000050u; // 4-frame animation; each frame has a distinct Z-origin so we can tell @@ -648,11 +600,6 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) - // — route the default straight at TurnRight (the only cycle this - // fixture defines) so initialize_state's baseline install succeeds - // and state.Style/Substate are non-zero before the explicit dispatch. - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)TurnRight; // Register TurnRight cycle (adjusted motion, not TurnLeft). int cycleKey = (int)((Style << 16) | (TurnRight & 0xFFFFFFu)); @@ -664,47 +611,29 @@ public sealed class AnimationSequencerTests var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, TurnLeft, speedMod: 1f); - // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests. - // S5_WalkBackward_RemapNegativeSpeed's "mirrors only" diff): - // CurrentMotion is now a GET-ONLY mirror of MotionState.Substate, - // and MotionState owns the POST-adjust_motion substate — retail's - // interpreted state IS the adjusted one (TurnRight played reversed), - // not the raw TurnLeft the caller passed in. Pre-cutover the adapter - // kept its own separate CurrentMotion field and never overwrote it - // with the adjusted id; that field no longer exists. - Assert.Equal(TurnRight, seq.CurrentMotion); - Assert.Equal(-1f, seq.CurrentSpeedMod, 3); + // CurrentMotion should record the original TurnLeft command. + Assert.Equal(TurnLeft, seq.CurrentMotion); - // R1-P5 (2026-07-02): pre-cutover this pinned the ACE-fabricated - // epsilon boundary ((EndFrame+1)-eps ~= 3.99999). Retail's - // AnimSequenceNode.GetStartingFrame (0x00525c80) returns a BARE INT - // for reverse playback: HighFrame + 1 (gap map G1 — "NO epsilon"). - // LowFrame=0, HighFrame=3 (no swap at append time; the swap only - // happens inside MultiplyFramerate for an in-place resign, which - // this path doesn't take), so the retail-exact start position is - // exactly 4.0, not "near but under" 4.0. The cursor starts at the - // boundary and counts DOWN toward LowFrame(=0) on the next Advance. + // Without swap: StartFrame=0, EndFrame=3 (original range preserved). + // GetStartFramePosition for negative speed = (EndFrame+1)-eps = (3+1)-eps ≈ 3.99999. + // The cursor starts near the HIGH end and counts DOWN toward StartFrame(=0). double pos = GetFramePosition(seq); - Assert.True(pos == 4.0, - $"Expected framePosition == 4 (bare-int reverse start = HighFrame+1, " - + $"retail AnimSequenceNode.GetStartingFrame 0x00525c80 has NO epsilon — G1); got {pos}"); + Assert.True(pos > 3.9 && pos < 4.0, + $"Expected framePosition near 3.99999 (reverse start near EndFrame+1) but got {pos}"); } [Fact] public void Advance_NegativeSpeed_FramePositionDecreases() { // Verify that a cycle loaded with negative framerate counts downward. - // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class - // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). - const uint Style = 0x8000003Du; - const uint Motion = 0x40000003u; + const uint Style = 0x003Du; + const uint Motion = 0x0003u; const uint AnimId = 0x03000060u; var anim = Fixtures.MakeAnim(8, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; // Register cycle with NEGATIVE framerate to simulate reverse playback. int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); @@ -790,11 +719,9 @@ public sealed class AnimationSequencerTests { // Queue: [linkNode (2 frames, 10fps, non-looping)] → [cycleNode (4 frames, looping)] // Advance enough to exhaust the link node, then verify we're in the cycle. - // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames). - const uint Style = 0x8000003Du; - const uint IdleMotion = 0x40000003u; - const uint WalkMotion = 0x40000005u; - const uint IdleAnim = 0x03000082u; + const uint Style = 0x003Du; + const uint IdleMotion = 0x0003u; + const uint WalkMotion = 0x0005u; const uint CycleAnim = 0x03000080u; const uint LinkAnim = 0x03000081u; @@ -802,7 +729,6 @@ public sealed class AnimationSequencerTests var linkAnim = Fixtures.MakeAnim(2, 1, new Vector3(0, 5, 0), Quaternion.Identity); // Cycle anim: 4 frames, X=9 (distinct marker). var cycleAnim = Fixtures.MakeAnim(4, 1, new Vector3(9, 0, 0), Quaternion.Identity); - var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = Fixtures.MakeMtable( @@ -813,22 +739,13 @@ public sealed class AnimationSequencerTests toMotion: WalkMotion, linkAnimId: LinkAnim, framerate: 10f); - // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) - // — route it at IdleMotion with its own cycle so the priming - // SetCycle call below actually dispatches. - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; - int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); - mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f); var loader = new FakeLoader(); - loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); - // Prime as if already playing IdleMotion — real SetCycle call - // (reflection SetValue no longer works, see WithTransitionLink test). - seq.SetCycle(Style, IdleMotion); + SetCurrentMotion(seq, Style, IdleMotion); seq.SetCycle(Style, WalkMotion); // Link node is 2 frames at 10fps → 0.2s to exhaust. @@ -999,11 +916,8 @@ public sealed class AnimationSequencerTests public void Advance_ForwardHookDoesNotFire_OnReversePlayback() { // A hook tagged Direction.Forward should NOT fire when playback is reversed. - // R2-Q4: class-bit-tagged ids + retail-mandatory StyleDefaults — the bare - // ids made this test pass VACUOUSLY (dispatch silently failed, no anim - // played, so "hook did not fire" held for the wrong reason). - const uint Style = 0x8000003Du; - const uint Motion = 0x40000003u; + const uint Style = 0x003Du; + const uint Motion = 0x0003u; const uint AnimId = 0x03000103u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); @@ -1016,7 +930,6 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData(); QualifiedDataId qid = AnimId; @@ -1031,10 +944,7 @@ public sealed class AnimationSequencerTests seq.ConsumePendingHooks(); // Reverse playback: cursor starts near frame 4 and counts down. - // 0.25s at -10fps = -2.5 frames → crosses the 3→2 boundary, so the - // hooked frame IS reached (same advance as the Backward sibling test - // — the direction filter, not distance, is what's under test). - seq.Advance(0.25f); + seq.Advance(0.15f); var hooks = seq.ConsumePendingHooks(); // Forward-only hook on frame 2 should NOT fire on reverse playback. @@ -1044,10 +954,8 @@ public sealed class AnimationSequencerTests [Fact] public void Advance_BackwardHook_FiresOnReversePlayback() { - // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class - // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). - const uint Style = 0x8000003Du; - const uint Motion = 0x40000003u; + const uint Style = 0x003Du; + const uint Motion = 0x0003u; const uint AnimId = 0x03000104u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); @@ -1060,7 +968,6 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData(); QualifiedDataId qid = AnimId; @@ -1083,17 +990,13 @@ public sealed class AnimationSequencerTests Assert.Contains(hooks, h => h is SoundHook sh && (uint)sh.Id == 0x0A000005u); } - // ── PosFrames root motion (R1-P6: the wired Frame path, gap map G7) ─────── + // ── PosFrames root motion (Phase E.1) ──────────────────────────────────── [Fact] - public void Advance_WithRootMotionFrame_AccumulatesPosFrameDeltas() + public void Advance_WithPosFrames_AccumulatesRootMotion() { - // R1-P6 (2026-07-02): root motion flows through retail's actual - // contract — CSequence::update(quantum, Frame*) (0x00525b80): - // every crossed integer frame combines the node's pos_frame into - // the caller-supplied Frame (update_internal 0x005255d0). The old - // adapter-side accumulator (ConsumeRootMotionDelta) is DELETED — - // this is the seam R6's per-tick order consumes. + // Animation with PosFrames flag and per-frame origin deltas should + // surface a non-zero root motion delta after Advance. const uint Style = 0x003Du; const uint Motion = 0x0003u; const uint AnimId = 0x03000110u; @@ -1117,27 +1020,27 @@ public sealed class AnimationSequencerTests var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, Motion); + seq.ConsumeRootMotionDelta(); // clear - // Advance 0.25s @10fps → 2.5 frames → 2 crossings (0→1, 1→2), each - // combining +1 X of pos_frame origin into the supplied Frame. - var rootFrame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; - seq.Advance(0.25f, rootFrame); + // Advance 0.25s → 2.5 frames → 2 crossings (0→1, 1→2) → 2 posFrame deltas applied. + seq.Advance(0.25f); + var (pos, _) = seq.ConsumeRootMotionDelta(); - Assert.True(rootFrame.Origin.X >= 1.8f && rootFrame.Origin.X <= 2.2f, - $"Expected ~2.0 root motion X after 2 crossings via the wired Frame, got {rootFrame.Origin.X}"); + // Each crossing adds +X origin → total X should be 2. + Assert.True(pos.X >= 1.8f && pos.X <= 2.2f, + $"Expected ~2.0 root motion X after 2 crossings, got {pos.X}"); + + // A subsequent consume with no advance should return zero (drained). + var (pos2, _) = seq.ConsumeRootMotionDelta(); + Assert.Equal(Vector3.Zero, pos2); } [Fact] public void CurrentVelocity_ExposedFromMotionData_WhenHasVelocity() { // MotionData with HasVelocity flag should surface via CurrentVelocity. - // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) — - // without it, initialize_state's SetDefaultState fails, state.Style - // stays 0, and GetObjectSequence's entry guard rejects every - // dispatch. Route the default straight at Motion (the cycle this - // test cares about). - const uint Style = 0x8000003Du; - const uint Motion = 0x40000003u; + const uint Style = 0x003Du; + const uint Motion = 0x0003u; const uint AnimId = 0x03000120u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); @@ -1145,7 +1048,6 @@ public sealed class AnimationSequencerTests var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData { @@ -1169,12 +1071,8 @@ public sealed class AnimationSequencerTests [Fact] public void CurrentVelocity_ScaledBySpeedMod() { - // R2-Q4: retail-mandatory StyleDefaults, and Motion needs its - // 0x40000000 cycle-class bit — the same-motion re-speed fast path - // (Branch 2, target==substate) still requires the class-bit gate to - // be reached in the first place. - const uint Style = 0x8000003Du; - const uint Motion = 0x40000003u; + const uint Style = 0x003Du; + const uint Motion = 0x0003u; const uint AnimId = 0x03000121u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); @@ -1182,7 +1080,6 @@ public sealed class AnimationSequencerTests var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData { @@ -1209,37 +1106,27 @@ public sealed class AnimationSequencerTests // When a non-cyclic link node exhausts and we advance_to_next_animation, // an AnimationDoneHook should be queued so consumers can react (e.g. UI // wake-on-idle-complete). - // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames). - const uint Style = 0x8000003Du; - const uint IdleMotion = 0x40000003u; - const uint WalkMotion = 0x40000005u; - const uint IdleAnim = 0x03000132u; + const uint Style = 0x003Du; + const uint IdleMotion = 0x0003u; + const uint WalkMotion = 0x0005u; const uint CycleAnim = 0x03000130u; const uint LinkAnim = 0x03000131u; var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity); var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); - var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = Fixtures.MakeMtable( style: Style, motion: WalkMotion, cycleAnimId: CycleAnim, fromMotion: IdleMotion, toMotion: WalkMotion, linkAnimId: LinkAnim, framerate: 10f); - // R2-Q4: retail-mandatory StyleDefaults — route it at IdleMotion with - // its own cycle so the priming SetCycle call below actually dispatches. - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; - int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); - mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f); var loader = new FakeLoader(); - loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); - // Prime as if already playing IdleMotion — real SetCycle call. - seq.SetCycle(Style, IdleMotion); + SetCurrentMotion(seq, Style, IdleMotion); seq.SetCycle(Style, WalkMotion); seq.ConsumePendingHooks(); @@ -1257,11 +1144,8 @@ public sealed class AnimationSequencerTests { // A 10-frame cycle at 10 fps = 1.0s per loop. If we halve the playback // rate (factor 0.5), advancing 1.0s should produce half a loop (5 frames). - // R2-Q4: Motion needs the 0x40000000 cycle-class bit — GetObjectSequence - // Branch 2 (and its same-motion fast re-speed path) never triggers on - // a bare low-word id (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). - const uint Style = 0x8000003Du; - const uint Motion = 0x40000007u; // RunForward + const uint Style = 0x003Du; + const uint Motion = 0x0007u; // RunForward const uint AnimId = 0x03000401u; // Unique per-frame Z so we can tell where the cursor lands. @@ -1287,11 +1171,6 @@ public sealed class AnimationSequencerTests Framerate = 10f, }); mt.Cycles[cycleKey] = md; - // R2-Q4: the dispatch stack needs the retail-mandatory StyleDefaults - // entry (SetDefaultState 0x005230a0 requires StyleDefaults[DefaultStyle]; - // GetObjectSequence refuses a zero style/substate). Real dat tables - // always carry it. - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; var loader = new FakeLoader(); loader.Register(AnimId, anim); @@ -1299,11 +1178,8 @@ public sealed class AnimationSequencerTests var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, Motion, speedMod: 1f); - // R2-Q4: halve the playback rate via the retail same-motion re-speed - // (GetObjectSequence Branch-2 fast path: change_cycle_speed + - // subtract_motion(old) + combine_motion(new), decomp §5) — the old - // MultiplyCyclicFramerate adapter composite is deleted. - seq.SetCycle(Style, Motion, speedMod: 0.5f); + // Halve the playback rate. + seq.MultiplyCyclicFramerate(0.5f); // 10 frames at 5 fps = 2.0s per loop. Advance 1.0s → cursor ~= frame 5. seq.Advance(1.0f); @@ -1311,8 +1187,7 @@ public sealed class AnimationSequencerTests Assert.Single(frames); Assert.InRange(frames[0].Origin.Z, 4f, 6f); - // Velocity also scales: originally (0,4,0), now (0,2,0) - // (subtract_motion(1.0) + combine_motion(0.5) = ×0.5 net). + // Velocity also scales: originally (0,4,0), now (0,2,0). Assert.Equal(2f, seq.CurrentVelocity.Y, 1); } @@ -1321,11 +1196,8 @@ public sealed class AnimationSequencerTests { // Changing speed mid-cycle must NOT reset the frame cursor — the // animation keeps playing from where it was, just faster/slower. - // R2-Q4: class-bit-tagged ids — the bare ids made this test pass - // VACUOUSLY (dispatch silently failed; "cursor unchanged" held - // because nothing moved at all). - const uint Style = 0x8000003Du; - const uint Motion = 0x40000007u; + const uint Style = 0x003Du; + const uint Motion = 0x0007u; const uint AnimId = 0x03000402u; var anim = new Animation(); @@ -1341,9 +1213,6 @@ public sealed class AnimationSequencerTests mt.DefaultStyle = (DRWMotionCommand)Style; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(AnimId, framerate: 10f); - // R2-Q4: retail-mandatory StyleDefaults entry (see - // MultiplyCyclicFramerate_HalvesPlaybackRate). - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; var loader = new FakeLoader(); loader.Register(AnimId, anim); @@ -1353,9 +1222,7 @@ public sealed class AnimationSequencerTests seq.Advance(0.3f); // cursor ~ frame 3 double before = GetFramePosition(seq); - // R2-Q4: mid-cycle re-speed via the retail Branch-2 fast path — must - // not touch the cursor (change_cycle_speed scales framerates only). - seq.SetCycle(Style, Motion, speedMod: 2.0f); + seq.MultiplyCyclicFramerate(2.0f); double after = GetFramePosition(seq); Assert.Equal(before, after, 5); @@ -1368,10 +1235,8 @@ public sealed class AnimationSequencerTests // NOT reset the cursor — it should call MultiplyCyclicFramerate to // keep the run loop smooth (retail behavior for a mid-run RunRate // broadcast). Mirror of ACE MotionTable.cs:132-139 fast-path. - // R2-Q4: Motion needs the 0x40000000 cycle-class bit — see - // CurrentVelocity_ExposedFromMotionData_WhenHasVelocity. - const uint Style = 0x8000003Du; - const uint Motion = 0x40000007u; + const uint Style = 0x003Du; + const uint Motion = 0x0007u; const uint AnimId = 0x03000403u; var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity); @@ -1402,17 +1267,14 @@ public sealed class AnimationSequencerTests // surface as (0,4,0) at speedMod=1.0, (0,6,0) at 1.5×, (0,2,0) at // 0.5×. The dead-reckoning integrator in TickAnimations reads // CurrentVelocity each tick, so this has to be accurate. - // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class - // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). - const uint Style = 0x8000003Du; - const uint Motion = 0x40000007u; + const uint Style = 0x003Du; + const uint Motion = 0x0007u; const uint AnimId = 0x03000405u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 4, 0) }; @@ -1448,10 +1310,8 @@ public sealed class AnimationSequencerTests { // Guard: the new speed-path must not break the classic // "identical call = no state change" behavior. - // R2-Q4: Motion needs the 0x40000000 cycle-class bit — see - // CurrentVelocity_ExposedFromMotionData_WhenHasVelocity. - const uint Style = 0x8000003Du; - const uint Motion = 0x40000007u; + const uint Style = 0x003Du; + const uint Motion = 0x0007u; const uint AnimId = 0x03000404u; var anim = Fixtures.MakeAnim(10, 1, Vector3.Zero, Quaternion.Identity); @@ -1478,17 +1338,14 @@ public sealed class AnimationSequencerTests // A turn cycle with MotionData.Omega = (0, 0, 1) rad/sec (yaw) // should surface as CurrentOmega = (0, 0, 1) after SetCycle. // Scales with speedMod exactly like Velocity. - // R2-Q4: retail-mandatory StyleDefaults + the 0x40000000 cycle-class - // bit on Motion (see CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). - const uint Style = 0x8000003Du; - const uint Motion = 0x4000000Du; // TurnRight + const uint Style = 0x003Du; + const uint Motion = 0x000Du; // TurnRight const uint AnimId = 0x03000701u; var anim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)Motion; int cycleKey = (int)((Style << 16) | (Motion & 0xFFFFFFu)); var md = new MotionData { Flags = MotionDataFlags.HasOmega, Omega = new Vector3(0, 0, 1.0f) }; @@ -1517,27 +1374,19 @@ public sealed class AnimationSequencerTests // reads the cycle's run-speed and moves the entity smoothly. // Crucial: otherwise remote entities would stutter at every stance // transition while the link plays. - // R2-Q4: class-bit-tagged ids (see SetCycle_WithTransitionLink_PrependLinkFrames). - const uint Style = 0x8000003Du; - const uint IdleMotion = 0x40000003u; - const uint WalkMotion = 0x40000005u; - const uint IdleAnim = 0x03000603u; + const uint Style = 0x003Du; + const uint IdleMotion = 0x0003u; + const uint WalkMotion = 0x0005u; const uint CycleAnim = 0x03000601u; const uint LinkAnim = 0x03000602u; var cycleAnim = Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity); var linkAnim = Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity); - var idleAnim = Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity); var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - // R2-Q4: retail-mandatory StyleDefaults (SetDefaultState 0x005230a0) - // — route it at IdleMotion (the state we prime through below). - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; - - int idleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); - mt.Cycles[idleKey] = Fixtures.MakeMotionData(IdleAnim, framerate: 10f); + mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)WalkMotion; int cycleKey = (int)((Style << 16) | (WalkMotion & 0xFFFFFFu)); var cycleMd = new MotionData { Flags = MotionDataFlags.HasVelocity, Velocity = new Vector3(0, 3.12f, 0) }; @@ -1555,13 +1404,11 @@ public sealed class AnimationSequencerTests mt.Links[linkOuter] = linkCmdData; var loader = new FakeLoader(); - loader.Register(IdleAnim, idleAnim); loader.Register(CycleAnim, cycleAnim); loader.Register(LinkAnim, linkAnim); var seq = new AnimationSequencer(setup, mt, loader); - // Prime as if already playing IdleMotion — real SetCycle call. - seq.SetCycle(Style, IdleMotion); + SetCurrentMotion(seq, Style, IdleMotion); seq.SetCycle(Style, WalkMotion); // We just enqueued [link(0)][cycle(3.12 forward)]. Current node is @@ -1585,11 +1432,7 @@ public sealed class AnimationSequencerTests // An Action-class command (mask 0x10) resolves via the Links dict // keyed by (style, currentSubstate) → motion. Example: a ThrustMed // attack while in SwordCombat stance. - // R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry - // (SetDefaultState 0x005230a0 is retail-mandatory — GetObjectSequence - // refuses style==0/substate==0, see - // CurrentVelocity_ExposedFromMotionData_WhenHasVelocity). - const uint Style = 0x8000003Eu; // SwordCombat + const uint Style = 0x003Eu; // SwordCombat const uint IdleMotion = 0x41000003u; // Ready const uint ActionMotion = 0x10000058u; // ThrustMed (Action class) const uint IdleAnimId = 0x03000501u; @@ -1602,7 +1445,6 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); @@ -1636,9 +1478,7 @@ public sealed class AnimationSequencerTests // values followed by Ready. Retail keeps currState.Substate at Ready // while the action link drains, so the Ready echo must not abort the // in-flight swing. - // R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry - // (see PlayAction_Action_ResolvesFromLinksDict). - const uint Style = 0x8000003Du; + const uint Style = 0x003Du; const uint IdleMotion = 0x41000003u; const uint AttackMotion = 0x10000052u; const uint IdleAnimId = 0x03000503u; @@ -1646,7 +1486,6 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)Style }; - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); @@ -1674,19 +1513,11 @@ public sealed class AnimationSequencerTests [Fact] public void PlayAction_Modifier_ResolvesFromModifiersDict() { - // R2-Q4 EXPECTED-DIFF (mirrors AnimationSequencerCutoverTraceTests. - // S9_TurnModifier): a Modifier-class command (mask 0x20) — like Jump - // (0x2500003B) or a turn-while-moving overlay — resolves from the - // Modifiers dict, first with style-specific key then with unstyled - // fallback (CMotionTable.GetObjectSequence Branch 4). Pre-cutover the - // adapter INSERTED the modifier's anim before the cyclic tail — an - // acdream invention. Retail Branch 4 is PHYSICS-ONLY combine_motion: - // the base cycle's anim list is untouched (no new nodes), and the - // modifier contributes velocity/omega on top of the cycle's own, - // tracked on the MotionState modifier stack (AP-73 mechanism). - // R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry - // (see PlayAction_Action_ResolvesFromLinksDict). - const uint Style = 0x8000003Du; + // A Modifier-class command (mask 0x20) — like Jump (0x2500003B) — + // resolves from the Modifiers dict, first with style-specific key + // then with unstyled fallback. Empirically: the modifier's anim + // plays on top of the current cycle. + const uint Style = 0x003Du; const uint IdleMotion = 0x41000003u; const uint JumpMotion = 0x2500003Bu; // Modifier class const uint IdleAnimId = 0x03000510u; @@ -1698,18 +1529,12 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); - // Modifier: (Style, Jump) — carries an omega contribution (a jump - // kick's angular nudge) rather than an anim payload, since Branch 4 - // never touches the anim list. + // Modifier: (Style, Jump) int modKey = (int)((Style << 16) | (JumpMotion & 0xFFFFFFu)); - var jumpMd = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f); - jumpMd.Flags = MotionDataFlags.HasOmega; - jumpMd.Omega = new Vector3(0f, 0f, 2.5f); - mt.Modifiers[modKey] = jumpMd; + mt.Modifiers[modKey] = Fixtures.MakeMotionData(JumpAnimId, framerate: 10f); var loader = new FakeLoader(); loader.Register(IdleAnimId, idleAnim); @@ -1717,15 +1542,12 @@ public sealed class AnimationSequencerTests var seq = new AnimationSequencer(setup, mt, loader); seq.SetCycle(Style, IdleMotion); - int queueBefore = seq.QueueCount; seq.PlayAction(JumpMotion); - // No anim nodes inserted — the queue is unchanged from before the - // modifier fired. - Assert.Equal(queueBefore, seq.QueueCount); - // The modifier's omega is combined onto the sequence's physics. - Assert.Equal(2.5f, seq.CurrentOmega.Z, 3); + var fr = seq.Advance(0.01f); + Assert.Single(fr); + Assert.Equal(77f, fr[0].Origin.Z, 1); } [Fact] @@ -1735,9 +1557,7 @@ public sealed class AnimationSequencerTests // Action(0x10) | ChatEmote(0x02) | Mappable(0x01). Because the // Action bit is set, they route through the Links-dict lookup just // like attacks. Verifies the class-bit math. - // R2-Q4: Style needs the 0x80000000 top bit + a StyleDefaults entry - // (see PlayAction_Action_ResolvesFromLinksDict). - const uint Style = 0x8000003Du; + const uint Style = 0x003Du; const uint IdleMotion = 0x41000003u; const uint WaveMotion = 0x13000087u; const uint IdleAnimId = 0x03000520u; @@ -1749,7 +1569,6 @@ public sealed class AnimationSequencerTests var setup = Fixtures.MakeSetup(1); var mt = new MotionTable(); mt.DefaultStyle = (DRWMotionCommand)Style; - mt.StyleDefaults[(DRWMotionCommand)Style] = (DRWMotionCommand)IdleMotion; int cycleKey = (int)((Style << 16) | (IdleMotion & 0xFFFFFFu)); mt.Cycles[cycleKey] = Fixtures.MakeMotionData(IdleAnimId, framerate: 10f); @@ -1805,27 +1624,14 @@ public sealed class AnimationSequencerTests // ── Helpers ────────────────────────────────────────────────────────────── - /// - /// Expose the core CSequence's FrameNumber via reflection (test-only). - /// R1-P5 rehost (2026-07-02): _framePosition lived directly on - /// AnimationSequencer pre-cutover; it now lives on the private _core - /// (CSequence) field as the public FrameNumber. Two-hop reflection: - /// grab _core, then its FrameNumber field. - /// + /// Expose _framePosition (double) via reflection (test-only). private static double GetFramePosition(AnimationSequencer seq) { - var coreField = typeof(AnimationSequencer) - .GetField("_core", + var field = typeof(AnimationSequencer) + .GetField("_framePosition", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - var core = coreField?.GetValue(seq); - if (core is null) return -1.0; - - var frameNumberField = core.GetType() - .GetField("FrameNumber", - System.Reflection.BindingFlags.Public | - System.Reflection.BindingFlags.Instance); - return frameNumberField is null ? -1.0 : (double)frameNumberField.GetValue(core)!; + return field is null ? -1.0 : (double)field.GetValue(seq)!; } /// diff --git a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs index be9b279a..c2474e4e 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs @@ -699,13 +699,8 @@ public class BSPQueryTests // Regression guard for the FULL-HIT case in the same Path 5 branch. // Sphere overlaps wall AND moves INTO it: moveDot < 0, cull does NOT // reject, pos_hits_sphere returns 1, Path 5 takes the `if (hit0)` - // branch. With engine=null we fall through to the real slide - // (CSphere::slide_sphere via Transition.SlideSphereInternal). No - // contact plane is seeded on this bare Transition, so the slide takes - // the wall-only branch (project out the into-wall displacement, - // return Slid) — and per retail it must NOT write the sliding normal - // (#137 mechanism 2; validate_transition 0x0050ac21 is the only - // in-transition writer). + // branch. With engine=null we fall through to the slide fallback + // (SetCollisionNormal + SetSlidingNormal + return Slid). var (root, resolved) = BuildSingleWallBsp(); var transition = new Transition(); @@ -736,9 +731,6 @@ public class BSPQueryTests Assert.Equal(TransitionState.Slid, state); Assert.True(transition.CollisionInfo.CollisionNormalValid, "Full hit should set the collision normal (slide fallback)."); - Assert.False(transition.CollisionInfo.SlidingNormalValid, - "find_collisions must not write the sliding normal — retail's " + - "only in-transition writer is validate_transition (#137)."); Assert.False(transition.SpherePath.NegPolyHit, "Full hit should NOT also fire NegPolyHit — that's the near-miss " + "path only. Retail at acclient_2013_pseudo_c.txt:0053a647 returns " + diff --git a/tests/AcDream.Core.Tests/Physics/CellMarchLandblockPreservationTests.cs b/tests/AcDream.Core.Tests/Physics/CellMarchLandblockPreservationTests.cs deleted file mode 100644 index e4fad6ed..00000000 --- a/tests/AcDream.Core.Tests/Physics/CellMarchLandblockPreservationTests.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -// Component D — cell-march landblock preservation (#145 residual, 2026-06-22 spec §3 D). -// -// Scenario: a player arrives via teleport at outdoor cell 0xA9B40019 (lbX=0xA9, lbY=0xB4). -// The destination landblock has NOT yet been applied to the physics engine (priority-apply -// is in flight). The body's CarriedBlockOrigin is null (either the body has no seeded outdoor -// cell yet, or the incoming cell differs from the body's last-known cell). -// -// Without the fix: BuildCellSetAndPickContaining's TryGetTerrainOrigin returns false with -// blockOrigin=(0,0,0). AddAllOutsideCells treats worldSphereCenter as block-local and marches -// the cell one block in whatever direction the player is moving — lbX→lbX−1 or lbY→lbY−1 -// per tick, cascading until lbX or lbY underflows to 0x00. The outbound wire then encodes -// the marched cell and ACE rejects every move (MOVEMENT SPEED / failed transition). -// -// With the fix: when TryGetTerrainOrigin returns false (block not resident), return the -// seed currentCellId verbatim — "no block-local frame → preserve" (same contract as -// PhysicsEngine.Resolve's NO-LANDBLOCK verbatim branch). The player cell stays correct -// until the landblock applies. -// -// The entry point exercised here is CellTransit.FindCellSet — the same function that -// RunCheckOtherCellsAndAdvance (TransitionTypes.cs) calls on every per-frame physics tick. -public class CellMarchLandblockPreservationTests -{ - private static int LbX(uint cellId) => (int)((cellId >> 24) & 0xFFu); - private static int LbY(uint cellId) => (int)((cellId >> 16) & 0xFFu); - - // ── West-edge crossing: no anchor, unstreamed terrain → must NOT march lbX ────────────── - [Fact] - public void WestEdge_UnstreamedLandblock_NoAnchor_PreservesSeedCell() - { - // Empty cache — landblock 0xA9B4 has not been applied (TryGetTerrainOrigin → false). - var cache = new PhysicsDataCache(); - - // Sphere just west of 0xA9B4's origin (world X = −0.088 → block-local X = −0.088 - // relative to (0,0) origin → floor(−0.088/24) = −1 → lbX marches without fix). - var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(-0.088f, 14.8f, 12f), Radius = 0.48f } }; - const uint currentCell = 0xA9B40019u; - - // No carried anchor (the teleport-arrival first tick: body still carries an old - // cell or is null-seeded, so ResolveWithTransition passes null). - uint result = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null); - - // The cell must be preserved unchanged — lbX must NOT drop below 0xA9. - Assert.Equal(0xA9, LbX(result)); - // The full cell id must equal the seed (no march at all). - Assert.Equal(currentCell, result); - } - - // ── South-edge crossing: no anchor, unstreamed terrain → must NOT march lbY ───────────── - [Fact] - public void SouthEdge_UnstreamedLandblock_NoAnchor_PreservesSeedCell() - { - var cache = new PhysicsDataCache(); - var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(14.8f, -0.088f, 12f), Radius = 0.48f } }; - const uint currentCell = 0xA9B40001u; // south-west landcell of 0xA9B4 - - uint result = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null); - - // Without fix: lbY drops from 0xB4 to 0xB3 on the first tick, then cascades. - Assert.Equal(0xB4, LbY(result)); - Assert.Equal(currentCell, result); - } - - // ── Carried anchor STILL works: if the anchor is supplied, the correct neighbour ──────── - // landblock cell is returned even when terrain is unstreamed (the original #145 fix). - [Fact] - public void WithCarriedAnchor_UnstreamedNeighbour_ReturnsCorrectCell() - { - var cache = new PhysicsDataCache(); - // Player crossed the south edge into 0xA9B3 (origin (0, −192)). - var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(14.8f, -0.088f, 12f), Radius = 0.48f } }; - const uint currentCell = 0xA9B30001u; - var anchor = new Vector3(0f, -192f, 0f); // body.Position − CellPosition.Origin - - uint result = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, anchor); - - // The anchor gives the correct block-local frame → stays in 0xA9B3 (no march back to B4). - Assert.Equal(0xB3, LbY(result)); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs index cdbf5afa..136b28c4 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs @@ -110,9 +110,6 @@ public class CellTransitFindCellSetTests public void FindCellSet_OutdoorSeed_IncludesNeighbourLandcells() { var cache = new PhysicsDataCache(); - // #145 D: register A9B4 at origin (0,0) — in production the streaming-center - // landblock is always registered before any outdoor physics resolve runs. - cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero); // A6.P4 (2026-05-24): sphere coords are LANDBLOCK-LOCAL (X/Y in // [0, 192]). Place the sphere center near the east boundary of // landcell grid(0,0) (i.e., near local X=24) so AddAllOutsideCells @@ -175,13 +172,10 @@ public class CellTransitFindCellSetTests public void OutdoorSeed_CrossesLandblockBoundary_South() { // The #106 acceptance golden: walking south out of A9B4, the outdoor - // cell must advance to the southern neighbour block's cell. Origin of - // A9B4 is Zero (streaming center). #145 D: register A9B4 at (0,0) so - // TryGetTerrainOrigin succeeds (in production the streaming-center - // landblock is always resident before any outdoor physics resolve). - // world y = -0.2 is 0.2 m into A9B3's row 7 under x=150 → cell 0xA9B30038. + // cell must advance to the southern neighbour block's cell. Anchor + // frame (no registered terrain → origin Zero): world y = -0.2 is + // 0.2 m into A9B3's row 7 under x=150 → cell 0xA9B30038. var cache = new PhysicsDataCache(); - cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero); uint containing = CellTransit.FindCellSet( cache, new Vector3(150f, -0.2f, 0f), sphereRadius: 0.5f, @@ -200,9 +194,7 @@ public class CellTransitFindCellSetTests // neighbour (sphere overlaps it) but the centre column is still the // current cell — membership must NOT flip early (single clean flip // at the line, matching the capture's 96/96 within-block behaviour). - // #145 D: register A9B4 at (0,0) — streaming-center block is always resident. var cache = new PhysicsDataCache(); - cache.CellGraph.RegisterTerrain(0xA9B40000u, new TerrainSurface(new byte[81], new float[256]), Vector3.Zero); uint containing = CellTransit.FindCellSet( cache, new Vector3(150f, 0.2f, 0f), sphereRadius: 0.5f, diff --git a/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs b/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs index 4d20f46d..3950bd90 100644 --- a/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CollisionExemptionTests.cs @@ -42,16 +42,16 @@ public class CollisionExemptionTests } [Fact] - public void EtherealOnly_NotInstantSkipped() + public void EtherealOnly_Skipped() { - // Task 3 (2026-06-24): AD-7 shim retired. ETHEREAL alone (0x4) no - // longer instant-skips — retail requires BOTH ETHEREAL_PS (0x4) AND - // IGNORE_COLLISIONS_PS (0x10) for the Gate-1 early-out. - // ETHEREAL-alone takes the obstruction_ethereal path: ShouldSkip - // returns false → sp.ObstructionEthereal = true → BSP Path 1 fires - // (sphere_intersects_solid) → open door is passable because its BSP - // has no solid leaf at the opening. Retail pc:276782 / 0x0050f067. - Assert.False(CollisionExemption.ShouldSkip( + // L.2g slice 1b (2026-05-13): ETHEREAL alone exempts collision. + // Retail (acclient_2013_pseudo_c.txt:276782) required both bits, + // but ACE's Door.Open() broadcasts ETHEREAL alone — observed + // live: state=0x0001000C (HasPhysicsBSP | Ethereal | ReportCollisions). + // Pragmatic shortcut: widen the early-out to ETHEREAL alone so + // doors become passable when ACE flips the bit. Retail-server + // broadcasts (state=0x14+) still hit the same branch correctly. + Assert.True(CollisionExemption.ShouldSkip( targetState: ETHEREAL_PS, targetFlags: EntityCollisionFlags.None, moverState: ObjectInfoState.IsPlayer)); diff --git a/tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs b/tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs deleted file mode 100644 index 57f405a4..00000000 --- a/tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs +++ /dev/null @@ -1,287 +0,0 @@ -using System; -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; -using Xunit.Abstractions; -using Plane = System.Numerics.Plane; - -namespace AcDream.Core.Tests.Physics; - -/// -/// Conformance tests for the retail CCylSphere collision family port -/// (2026-07-05) — dispatcher 0x0053b440 + step_sphere_down -/// 0x0053a9b0 + step_sphere_up 0x0053b310 + -/// land_on_cylinder 0x0053b3d0. Pseudocode: -/// docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md. -/// -/// -/// The driving repro: the Holtburg town-network portal platform (stab -/// 0xC0A9B465, Setup 0x020019E3) registers a WIDE LOW cylinder -/// (r=2.597 m, h=0.256 m). Retail steps a grounded player UP ONTO its flat -/// top; the pre-port approximation could only radial-slide, so the player -/// orbited the rim forever (launch-137-repro.log, 2026-07-05). These tests -/// pin the three retail behaviors the family provides: grounded -/// step-up-onto-top, too-tall side slide, and the airborne top landing. -/// Synthetic cylinders only — no dat dependency. -/// -/// -public class CylSphereFamilyTests -{ - private readonly ITestOutputHelper _out; - public CylSphereFamilyTests(ITestOutputHelper output) => _out = output; - - private const uint TestLandblockId = 0xA9B40000u; - private const uint TestCellId = TestLandblockId | 0x0001u; // landcell (0,0) - - private const float SphereRadius = 0.48f; // retail player capsule radius - private const float SphereHeight = 1.20f; - private const float StepUpHeight = 0.60f; - private const float StepDownHeight = 0.04f; - - // The live platform's registered shape ([cyl-test] launch-137-repro.log). - private const float PlatformRadius = 2.597f; - private const float PlatformHeight = 0.256f; - - /// - /// The portal-platform repro: a grounded player walking into the wide low - /// cylinder must STEP UP onto its flat top (retail - /// grounded branch → step_sphere_up → CTransition::step_up, whose - /// step-down probe lands via step_sphere_down's top-disc contact plane) — - /// not slide around the rim. - /// - [Fact] - public void Grounded_WalkIntoWideLowCylinder_StepsUpOntoTop() - { - var engine = BuildEngine(out _); - RegisterCylinder(engine, entityId: 0xCAFEu, - worldPos: new Vector3(12f, 14f, 0f), - radius: PlatformRadius, height: PlatformHeight); - - var body = MakeGroundedBody(new Vector3(12f, 10.4f, 0f)); - Vector3 pos = body.Position; - uint cellId = TestCellId; - bool grounded = true; - var perTick = new Vector3(0f, 0.10f, 0f); - - for (int tick = 0; tick < 40; tick++) - { - var result = engine.ResolveWithTransition( - pos, pos + perTick, cellId, - SphereRadius, SphereHeight, StepUpHeight, StepDownHeight, - grounded, - body: body, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, - movingEntityId: 0); - - body.Position = result.Position; - pos = result.Position; - cellId = result.CellId; - grounded = result.IsOnGround; - } - - _out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}"); - - // Rim contact is at Y ≈ 14 − 2.597 − 0.48 = 10.92. Pre-port the player - // pinned there (Z stayed 0, Y never passed the rim). Post-port the - // player must be standing ON the platform top. - Assert.True(pos.Y > 11.5f, - $"Player must advance past the rim contact (pre-port it pinned at Y≈10.9); got Y={pos.Y:F3}"); - Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f, - $"Player must stand ON the platform top (Z≈{PlatformHeight:F3}); got Z={pos.Z:F3}"); - Assert.True(grounded, "Player must remain grounded after stepping onto the platform"); - } - - /// - /// A tall thin cylinder (the Holtburg torch shape, r=0.2 h=2.2 — #149) - /// exceeds step_up_height: the grounded dead-center approach must NOT - /// step up and must NOT pass through — retail slides (dead-center the - /// crease projection degenerates to a hard stop). - /// - [Fact] - public void Grounded_WalkIntoTallCylinder_BlocksBeforeAxis() - { - var engine = BuildEngine(out _); - RegisterCylinder(engine, entityId: 0xF00Du, - worldPos: new Vector3(12f, 14f, 0f), - radius: 0.2f, height: 2.2f); - - var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f)); - Vector3 pos = body.Position; - uint cellId = TestCellId; - bool grounded = true; - var perTick = new Vector3(0f, 0.10f, 0f); - - for (int tick = 0; tick < 30; tick++) - { - var result = engine.ResolveWithTransition( - pos, pos + perTick, cellId, - SphereRadius, SphereHeight, StepUpHeight, StepDownHeight, - grounded, - body: body, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, - movingEntityId: 0); - - body.Position = result.Position; - pos = result.Position; - cellId = result.CellId; - grounded = result.IsOnGround; - } - - _out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded}"); - - // Surface contact at Y = 14 − 0.2 − 0.48 = 13.32. - Assert.True(pos.Y < 13.4f, - $"Tall cylinder must block the dead-center approach; got Y={pos.Y:F3}"); - Assert.True(pos.Z < 0.5f, - $"Player must NOT end up on top of a 2.2 m cylinder; got Z={pos.Z:F3}"); - } - - /// - /// Airborne landing: a falling sphere over the platform center must land - /// ON the flat top (land_on_cylinder → Collide re-test → branch-5 - /// exact-TOI rest + top-disc contact plane), not fall through to the - /// terrain inside the footprint. - /// - [Fact] - public void Airborne_FallOntoWideCylinder_LandsOnTop() - { - var engine = BuildEngine(out _); - RegisterCylinder(engine, entityId: 0xCAFEu, - worldPos: new Vector3(12f, 14f, 0f), - radius: PlatformRadius, height: PlatformHeight); - - Vector3 pos = new(12f, 14f, 1.0f); // 1 m above the base, over the center - uint cellId = TestCellId; - bool grounded = false; - var perTick = new Vector3(0f, 0f, -0.25f); - - int landedTick = -1; - for (int tick = 0; tick < 20; tick++) - { - var result = engine.ResolveWithTransition( - pos, pos + perTick, cellId, - SphereRadius, SphereHeight, StepUpHeight, StepDownHeight, - grounded, - body: null, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, - movingEntityId: 0); - - pos = result.Position; - cellId = result.CellId; - grounded = result.IsOnGround; - - if (grounded) { landedTick = tick; break; } - } - - _out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) grounded={grounded} landedTick={landedTick}"); - - Assert.True(grounded, "Falling sphere must land (ground) on the platform top"); - Assert.True(MathF.Abs(pos.Z - PlatformHeight) < 0.05f, - $"Landing must rest on the top disc (Z≈{PlatformHeight:F3}), not the terrain " + - $"(Z=0) inside the footprint; got Z={pos.Z:F3}"); - } - - /// - /// Ethereal cylinders stay fully passable through the caller's Layer-2 - /// override (pc:276961-276989) — branch 1 detects, the override clears. - /// Guards the #150 door behavior against the branch-1 change from the - /// old early-OK consume. - /// - [Fact] - public void Grounded_EtherealCylinder_IsFullyPassable() - { - var engine = BuildEngine(out _); - RegisterCylinder(engine, entityId: 0xE7E7u, - worldPos: new Vector3(12f, 14f, 0f), - radius: 0.2f, height: 2.2f, - state: 0x4u); // ETHEREAL_PS, non-static - - var body = MakeGroundedBody(new Vector3(12f, 12.6f, 0f)); - Vector3 pos = body.Position; - uint cellId = TestCellId; - bool grounded = true; - var perTick = new Vector3(0f, 0.10f, 0f); - - for (int tick = 0; tick < 30; tick++) - { - var result = engine.ResolveWithTransition( - pos, pos + perTick, cellId, - SphereRadius, SphereHeight, StepUpHeight, StepDownHeight, - grounded, - body: body, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, - movingEntityId: 0); - - body.Position = result.Position; - pos = result.Position; - cellId = result.CellId; - grounded = result.IsOnGround; - } - - _out.WriteLine($"final pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3})"); - - Assert.True(pos.Y > 14.5f, - $"Ethereal cylinder must not block (walked from 12.6 to past the axis); got Y={pos.Y:F3}"); - } - - // ─────────────────────────────────────────────────────────────── - // Harness - // ─────────────────────────────────────────────────────────────── - - private static PhysicsEngine BuildEngine(out PhysicsDataCache cache) - { - cache = new PhysicsDataCache(); - var engine = new PhysicsEngine { DataCache = cache }; - - // Flat terrain at Z=0 across the whole landblock. - var heights = new byte[81]; - var heightTable = new float[256]; // all zero → terrain Z = 0 - engine.AddLandblock( - landblockId: TestLandblockId, - terrain: new TerrainSurface(heights, heightTable), - cells: Array.Empty(), - portals: Array.Empty(), - worldOffsetX: 0f, - worldOffsetY: 0f); - - return engine; - } - - private static void RegisterCylinder(PhysicsEngine engine, uint entityId, - Vector3 worldPos, float radius, float height, uint state = 0u) - { - engine.ShadowObjects.Register( - entityId, gfxObjId: 0u, - worldPos, Quaternion.Identity, radius, - worldOffsetX: 0f, worldOffsetY: 0f, landblockId: TestLandblockId, - collisionType: ShadowCollisionType.Cylinder, - cylHeight: height, - state: state); - } - - private static PhysicsBody MakeGroundedBody(Vector3 position) - { - var floorPlane = new Plane(Vector3.UnitZ, 0f); - var floorVerts = new[] - { - new Vector3(-100f, -100f, 0f), - new Vector3( 100f, -100f, 0f), - new Vector3( 100f, 100f, 0f), - new Vector3(-100f, 100f, 0f), - }; - - return new PhysicsBody - { - Position = position, - Orientation = Quaternion.Identity, - ContactPlaneValid = true, - ContactPlane = floorPlane, - ContactPlaneCellId = TestCellId, - WalkablePolygonValid = true, - WalkablePlane = floorPlane, - WalkableVertices = floorVerts, - WalkableUp = Vector3.UnitZ, - TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, - }; - } -} diff --git a/tests/AcDream.Core.Tests/Physics/InWorldLinkGuardTests.cs b/tests/AcDream.Core.Tests/Physics/InWorldLinkGuardTests.cs deleted file mode 100644 index c6733aed..00000000 --- a/tests/AcDream.Core.Tests/Physics/InWorldLinkGuardTests.cs +++ /dev/null @@ -1,78 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// R4-V5 door-swing fix (2026-07-03, register TS-40): CMotionInterp's -/// dispatch tails strip link animations for DETACHED objects only (retail -/// if (physics_obj->cell == 0) RemoveLinkAnimations, raw @305627). -/// The old proxy (CellPosition.ObjCellId == 0) was seeded only by -/// the local player's SnapToCell, so every REMOTE body read "detached" and -/// every dispatched transition link (door open/close swings, remote -/// walk↔run links) was stripped the same tick it was appended — the pose -/// snapped straight to the new cycle. These pin the corrected -/// guard polarity. -/// -public class InWorldLinkGuardTests -{ - [Fact] - public void InWorldBody_DispatchKeepsTransitionLinks() - { - var body = new PhysicsBody - { - InWorld = true, - TransientState = TransientStateFlags.Contact - | TransientStateFlags.OnWalkable - | TransientStateFlags.Active, - }; - var interp = new MotionInterpreter(body); - int strips = 0; - interp.RemoveLinkAnimations = () => strips++; - - interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters()); - - Assert.Equal(0, strips); - } - - [Fact] - public void DetachedBody_DispatchStripsLinks_RetailGuard() - { - var body = new PhysicsBody - { - // InWorld left false — retail's pre-enter_world detached state. - TransientState = TransientStateFlags.Contact - | TransientStateFlags.OnWalkable - | TransientStateFlags.Active, - }; - var interp = new MotionInterpreter(body); - int strips = 0; - interp.RemoveLinkAnimations = () => strips++; - - interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters()); - - Assert.Equal(1, strips); - } - - [Fact] - public void RemoteShapedBody_StopCompletely_KeepsLinksToo() - { - // The other two guard sites (StopCompletely / StopInterpretedMotion) - // share the same InWorld polarity. - var body = new PhysicsBody - { - InWorld = true, - TransientState = TransientStateFlags.Contact - | TransientStateFlags.OnWalkable - | TransientStateFlags.Active, - }; - var interp = new MotionInterpreter(body); - int strips = 0; - interp.RemoveLinkAnimations = () => strips++; - - interp.StopCompletely(); - - Assert.Equal(0, strips); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs b/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs deleted file mode 100644 index e429f100..00000000 --- a/tests/AcDream.Core.Tests/Physics/Issue133DungeonTeleportPrefixTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Physics; -using DatReaderWriter.Enums; -using DatReaderWriter.Types; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// #133 (Bug A) — the validated-claim placement branch of -/// must return the VALIDATED claim's own -/// full cell id, NOT lbPrefix | (cellId & 0xFFFF). -/// -/// -/// lbPrefix is found by scanning resident landblocks for one whose -/// [0,192) local bounds contain the candidate XY. A dungeon EnvCell's -/// local Y can be NEGATIVE relative to its own landblock (the live capture: -/// server teleport to dungeon cell 0x00070143 at local (70,-60,0.01)). -/// The dungeon landblock fails the localY >= 0 bounds test, so the loop -/// instead matches a still-resident NEIGHBOURING block (a Holtburg landblock -/// whose world bounds happen to contain the same XY) and sets -/// lbPrefix = 0xA9B30000. The old code then returned -/// 0xA9B30000 | 0x0143 = 0xA9B30143, re-stamping the validated dungeon -/// claim with the wrong landblock — the client mis-resolved the player into -/// Holtburg and spammed ACE with rejected moves -/// (movement pre-validation failed from 00070143 to A9B30143). -/// -/// -/// -/// The validated claim's prefix is authoritative; a position falling in a -/// neighbouring resident landblock must not re-stamp it. This test reproduces -/// the exact geometry of the capture (dungeon claim in landblock 0x0007, -/// candidate XY also inside resident Holtburg 0xA9B3) and asserts the -/// returned cell keeps its 0x0007 prefix. -/// -/// -public class Issue133DungeonTeleportPrefixTests -{ - private const uint DungeonLandblock = 0x00070000u; - private const uint DungeonCellId = 0x00070143u; // indoor (low 0x0143 ≥ 0x0100) - private const uint HoltburgLandblock = 0xA9B30000u; // a neighbouring resident block - - // The capture: dungeon cell 0x00070143 at dungeon-local (70, -60, 0.01). - // We place the Holtburg block at world origin so its [0,192) bounds contain - // the candidate XY, and the dungeon block at world Y-offset 130 so the SAME - // world XY lands at dungeon-local Y = 70 - 130 = -60 (the captured negative). - private static readonly Vector3 SpawnPos = new(70f, 70f, 0.01f); - - [Fact] - public void ValidatedDungeonClaim_KeepsItsLandblockPrefix_NotTheNeighbour() - { - var engine = BuildEngine(); - - // Zero delta = the snap shape (teleport arrival). cellId is the dungeon - // claim; the candidate XY also falls inside the resident Holtburg block. - var result = engine.Resolve(SpawnPos, DungeonCellId, delta: Vector3.Zero, stepUpHeight: 0.5f); - - Assert.True(result.IsOnGround); - // The validated claim's prefix is authoritative — high word stays 0x0007, - // NOT re-stamped to the neighbouring Holtburg 0xA9B3. - Assert.Equal(DungeonCellId, result.CellId); - Assert.Equal(DungeonLandblock, result.CellId & 0xFFFF0000u); - } - - // ── fixture ────────────────────────────────────────────────────────────── - - private static PhysicsEngine BuildEngine() - { - var cache = new PhysicsDataCache(); - var engine = new PhysicsEngine { DataCache = cache }; - - // The dungeon cell: a Leaf CellBSP contains any point, so AdjustPosition - // validates the claim (returns it with found=true). Its Resolved set has - // one walkable floor polygon at z=0 under the spawn XY so the #111 - // validated-claim branch grounds onto it. - cache.RegisterCellStructForTest(DungeonCellId, MakeDungeonCell()); - - // Resident Holtburg block at world origin: its [0,192) bounds CONTAIN the - // candidate XY (70,70). This is the block the lbPrefix loop wrongly matched. - engine.AddLandblock( - landblockId: HoltburgLandblock, - terrain: FlatTerrain(), - cells: Array.Empty(), - portals: Array.Empty(), - worldOffsetX: 0f, - worldOffsetY: 0f); - - // The dungeon's own landblock, offset so the candidate XY produces a - // NEGATIVE dungeon-local Y (70 - 130 = -60) → it FAILS the [0,192) bounds - // test, which is exactly why the old code fell through to the Holtburg - // prefix. Registered so the scenario is faithful (a resident dungeon block - // whose local bounds don't cover the EnvCell's negative-Y position). - engine.AddLandblock( - landblockId: DungeonLandblock, - terrain: FlatTerrain(), - cells: Array.Empty(), - portals: Array.Empty(), - worldOffsetX: 0f, - worldOffsetY: 130f); - - return engine; - } - - /// Flat 81-vertex stub terrain (all zero heights). - private static TerrainSurface FlatTerrain() => new(new byte[81], new float[256]); - - private static CellPhysics MakeDungeonCell() - { - // One floor polygon: a 200×200 square at z=0 centred so it covers the - // spawn XY. Normal (0,0,1) → normal.Z = 1 ≥ FloorZ (0.6642) → walkable. - // Identity transform: cell-local == world, so the plane d = 0 (z + d = 0). - var floor = new ResolvedPolygon - { - Vertices = new[] - { - new Vector3(-100f, -100f, 0f), - new Vector3( 200f, -100f, 0f), - new Vector3( 200f, 200f, 0f), - new Vector3(-100f, 200f, 0f), - }, - Plane = new Plane(new Vector3(0f, 0f, 1f), 0f), - NumPoints = 4, - SidesType = CullMode.None, - }; - - return new CellPhysics - { - BSP = new PhysicsBSPTree { Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf } }, - WorldTransform = Matrix4x4.Identity, - InverseWorldTransform = Matrix4x4.Identity, - Resolved = new Dictionary { [0] = floor }, - // Leaf root → point_in_cell true for any point → AdjustPosition - // validates the claim (found=true, cell unchanged). - CellBSP = new CellBSPTree { Root = new CellBSPNode { Type = BSPNodeType.Leaf } }, - Portals = Array.Empty(), - PortalPolygons = new Dictionary(), - VisibleCellIds = new HashSet(), - }; - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs deleted file mode 100644 index f901d4ca..00000000 --- a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamInspectionTests.cs +++ /dev/null @@ -1,518 +0,0 @@ -using System; -using System.IO; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; -using Xunit; -using Xunit.Abstractions; -using Env = System.Environment; - -namespace AcDream.Core.Tests.Physics; - -/// -/// #137 corridor-seam inspection (2026-07-05, Facility Hub). Live probe -/// evidence (launch-175-verify2.log:42858): crossing corridor cells -/// 0x8A02016E → 0x8A02017A at world x≈85.25 records a wall hit with normal -/// (−1,0,0) — pointing straight back against the movement — after which the -/// stale sliding normal wedges all forward motion (ok=False hit=no, offset -/// projected to zero). Question this dump answers: does cell 0x8A02017A's -/// PHYSICS polygon set contain a portal-spanning polygon at its entry plane -/// (normal ≈ ±X at the portal's local X) — i.e., are portal-sealing polys in -/// our collision set where retail filters them? -/// -public class Issue137CorridorSeamInspectionTests -{ - private readonly ITestOutputHelper _out; - public Issue137CorridorSeamInspectionTests(ITestOutputHelper output) => _out = output; - - [Theory] - [InlineData(0x8A02016Eu)] - [InlineData(0x8A02017Au)] - [InlineData(0x8A02011Eu)] // the under-floor room the corridor's floor-portals lead to - [InlineData(0x8A020179u)] // the ramp corridor cell with the window (the #137 window-climb repro) - [InlineData(0x8A02017Eu)] // the cell beyond the window the player climbed into - public void CorridorCell_PhysicsPolysAndPortals_DatInspection(uint envCellId) - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - if (!Directory.Exists(datDir)) - { - _out.WriteLine($"SKIP: dat directory not found at {datDir}"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - var envCell = dats.Get(envCellId); - Assert.NotNull(envCell); - _out.WriteLine($"=== EnvCell 0x{envCellId:X8} ==="); - _out.WriteLine($" pos=({envCell!.Position.Origin.X:F2},{envCell.Position.Origin.Y:F2},{envCell.Position.Origin.Z:F2}) " + - $"rot=({envCell.Position.Orientation.X:F3},{envCell.Position.Orientation.Y:F3},{envCell.Position.Orientation.Z:F3},{envCell.Position.Orientation.W:F3})"); - _out.WriteLine($" EnvironmentId=0x{envCell.EnvironmentId:X4} CellStructure={envCell.CellStructure}"); - _out.WriteLine($" CellPortals={envCell.CellPortals.Count}"); - foreach (var p in envCell.CellPortals) - _out.WriteLine($" portal poly={p.PolygonId} other=0x{p.OtherCellId:X4} flags={p.Flags}"); - - var environment = dats.Get(0x0D000000u | envCell.EnvironmentId); - Assert.NotNull(environment); - Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); - - _out.WriteLine($" PhysicsPolygons={cs!.PhysicsPolygons.Count} (portal-relevant normals below)"); - foreach (var (id, poly) in cs.PhysicsPolygons) - { - // Compute the face normal from the vertex fan (same math as - // PhysicsDataCache.ResolvePolygons). - var verts = poly.VertexIds; - if (verts.Count < 3) continue; - if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue; - if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue; - if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue; - var n = System.Numerics.Vector3.Normalize(System.Numerics.Vector3.Cross( - v1.Origin - v0.Origin, v2.Origin - v0.Origin)); - - // Only print near-horizontal-normal polys (walls) — the seam wall - // candidates; floors/ceilings are noise here. - if (MathF.Abs(n.Z) > 0.3f) continue; - _out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) v0=({v0.Origin.X:F2},{v0.Origin.Y:F2},{v0.Origin.Z:F2}) verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}"); - } - - // The portal polygons live in the VISUAL polygon set — print their - // ids so overlap with the physics set (same id space?) is visible. - _out.WriteLine($" VisualPolygons={cs.Polygons.Count}"); - foreach (var p in envCell.CellPortals) - { - if (cs.Polygons.TryGetValue((ushort)p.PolygonId, out var vp)) - { - _out.WriteLine($" portal-poly {p.PolygonId} IS in the visual set (verts={vp.VertexIds.Count})"); - bool inPhysics = cs.PhysicsPolygons.ContainsKey((ushort)p.PolygonId); - _out.WriteLine($" portal-poly {p.PolygonId} in PHYSICS set: {inPhysics}"); - } - } - } - - /// - /// Mechanism-1 follow-up (2026-07-06): being in the CellStruct's - /// PhysicsPolygons TABLE does not mean the physics BSP ever tests a - /// polygon — retail's BSPLEAF::sphere_intersects_poly (0x0053d580) - /// iterates the LEAF's in_polys index list (leaf construction - /// 0x0053d4a0: in_polys[i] = &pack_poly[index]), and our - /// BSPQuery walks the dat's PhysicsBSP leaves the same way. This dump - /// answers: do the physics-BSP LEAVES of the corridor cells reference the - /// portal polygons? If yes, retail's own BSP query would test them too - /// (→ the passable mechanism must be transit/approach-side — the cdb - /// question). If no, our collision is testing polys retail never reaches - /// (→ a desk-fixable acdream divergence). - /// - [Theory] - [InlineData(0x8A02016Eu)] - [InlineData(0x8A02017Au)] - public void CorridorCell_PhysicsBspLeafMembership_OfPortalPolys(uint envCellId) - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - if (!Directory.Exists(datDir)) - { - _out.WriteLine($"SKIP: dat directory not found at {datDir}"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - var envCell = dats.Get(envCellId); - Assert.NotNull(envCell); - var environment = dats.Get(0x0D000000u | envCell!.EnvironmentId); - Assert.NotNull(environment); - Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); - - var portalPolyIds = new System.Collections.Generic.HashSet(); - foreach (var p in envCell.CellPortals) - portalPolyIds.Add((ushort)p.PolygonId); - - _out.WriteLine($"=== EnvCell 0x{envCellId:X8} — physics BSP leaf membership ==="); - _out.WriteLine($" Env=0x{envCell.EnvironmentId:X4} struct={envCell.CellStructure} " + - $"portalPolyIds=[{string.Join(",", portalPolyIds)}] " + - $"physicsTable=[{string.Join(",", cs!.PhysicsPolygons.Keys)}]"); - - var root = cs.PhysicsBSP?.Root; - Assert.NotNull(root); - - int leafCount = 0; - var leafPolyIds = new System.Collections.Generic.HashSet(); - var portalPolyLeafHits = new System.Collections.Generic.List(); - var stack = new System.Collections.Generic.Stack<(DatReaderWriter.Types.PhysicsBSPNode Node, string Path)>(); - stack.Push((root!, "R")); - while (stack.Count > 0) - { - var (n, path) = stack.Pop(); - if (n.Polygons is { Count: > 0 }) - { - leafCount++; - foreach (var pid in n.Polygons) - { - leafPolyIds.Add(pid); - if (portalPolyIds.Contains(pid)) - portalPolyLeafHits.Add($"poly {pid} in leaf@{path} (type={n.Type}, polys=[{string.Join(",", n.Polygons)}])"); - } - } - if (n.PosNode is not null) stack.Push((n.PosNode, path + "+")); - if (n.NegNode is not null) stack.Push((n.NegNode, path + "-")); - } - - _out.WriteLine($" BSP leaves-with-polys={leafCount} distinctLeafPolyIds=[{string.Join(",", leafPolyIds)}]"); - var tableNotInLeaves = new System.Collections.Generic.List(); - foreach (var pid in cs.PhysicsPolygons.Keys) - if (!leafPolyIds.Contains(pid)) - tableNotInLeaves.Add(pid); - _out.WriteLine($" physics-table polys NOT referenced by any BSP leaf: [{string.Join(",", tableNotInLeaves)}]"); - - if (portalPolyLeafHits.Count == 0) - { - _out.WriteLine(" >>> NO portal polygon is referenced by any physics-BSP leaf — " + - "retail's sphere_intersects_poly never tests them from this cell's BSP."); - } - else - { - foreach (var hit in portalPolyLeafHits) - _out.WriteLine($" >>> PORTAL POLY IN PHYSICS LEAF: {hit}"); - } - } - - /// - /// #137 window climb: the dat truth for the player's collision spheres. - /// Our InitPath places the head sphere at (sphereHeight − radius) = 0.72 - /// (capsule top 1.2 m); retail collides with the Setup's SPHERE LIST - /// verbatim (CPhysicsObj::transition → init_sphere(GetNumSphere, - /// GetSphere, scale)). Print human Setup 0x02000001's spheres. - /// - [Fact] - public void HumanSetup_CollisionSpheres_DatTruth() - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - if (!Directory.Exists(datDir)) - { - _out.WriteLine($"SKIP: dat directory not found at {datDir}"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var setup = dats.Get(0x02000001u); - Assert.NotNull(setup); - - _out.WriteLine($"Setup 0x02000001: Height={setup!.Height:F3} Radius={setup.Radius:F3} " + - $"StepUp={setup.StepUpHeight:F3} StepDown={setup.StepDownHeight:F3}"); - _out.WriteLine($"Spheres ({setup.Spheres.Count}):"); - foreach (var s in setup.Spheres) - _out.WriteLine($" origin=({s.Origin.X:F3},{s.Origin.Y:F3},{s.Origin.Z:F3}) r={s.Radius:F3}"); - _out.WriteLine($"CylSpheres ({setup.CylSpheres.Count}):"); - foreach (var c in setup.CylSpheres) - _out.WriteLine($" origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3}) r={c.Radius:F3} h={c.Height:F3}"); - } - - /// - /// #137 window-climb geometry (2026-07-06): full world-space vertex dump - /// of the shaft cell 0x8A02017E (all physics polys) and 0x8A020179's - /// south-wall family — the opening's lintel/ceiling spans decide where - /// retail blocks the head. - /// - [Fact] - public void WindowShaft_FullPolyDump() - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - if (!Directory.Exists(datDir)) - { - _out.WriteLine($"SKIP: dat directory not found at {datDir}"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - foreach (var cellId in new[] { 0x8A02017Eu, 0x8A020179u }) - { - var envCell = dats.Get(cellId); - Assert.NotNull(envCell); - var environment = dats.Get(0x0D000000u | envCell!.EnvironmentId); - Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); - - var rot = new System.Numerics.Quaternion( - envCell.Position.Orientation.X, envCell.Position.Orientation.Y, - envCell.Position.Orientation.Z, envCell.Position.Orientation.W); - var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot) - * System.Numerics.Matrix4x4.CreateTranslation( - envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); - - _out.WriteLine($"=== 0x{cellId:X8} full physics polys (world verts) ==="); - foreach (var (id, poly) in cs!.PhysicsPolygons) - { - var verts = poly.VertexIds; - if (verts.Count < 3) continue; - var w = new System.Collections.Generic.List(); - foreach (var vid in verts) - if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) - w.Add(System.Numerics.Vector3.Transform(v.Origin, world)); - var n = System.Numerics.Vector3.Normalize( - System.Numerics.Vector3.Cross(w[1] - w[0], w[2] - w[0])); - - // 017E: everything. 0179: south-wall family + ceilings only. - if (cellId == 0x8A020179u && MathF.Abs(n.Y) < 0.3f && n.Z > -0.3f) continue; - - var vs = string.Join(" ", w.ConvertAll(p => $"({p.X:F2},{p.Y:F2},{p.Z:F2})")); - _out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) verts={vs}"); - } - } - } - - /// - /// Mechanism-1 re-characterization (2026-07-06): the live hit normal - /// (−1.00, 0.03, −0.03) at world (85.253, −39.776, −5.992) matches NO - /// physics polygon of either corridor cell — 0x8A02016E (identity - /// rotation) and 0x8A02017A (180° Z) both have only ±Y-normal wall polys, - /// and the PortalSide portals to 0x011E (polys 1/3/5) are ±Y planes - /// 1.4 m north of the player's track, perpendicular to the +X run — the - /// pos_hits_sphere directional cull rejects them for this movement. This - /// sweep hunts the ACTUAL culprit: every physics poly of the seam cell + - /// all portal-adjacent neighbors, world-transformed, scored against the - /// hit point + normal. - /// - [Fact] - public void CorridorSeam_FindPolygonMatchingLiveHit() - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - if (!Directory.Exists(datDir)) - { - _out.WriteLine($"SKIP: dat directory not found at {datDir}"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - // Live evidence (launch-175-verify2.log:42858). - var hitPoint = new System.Numerics.Vector3(85.253f, -39.776f, -5.992f); - var hitNormal = new System.Numerics.Vector3(-1.00f, 0.03f, -0.03f); - hitNormal = System.Numerics.Vector3.Normalize(hitNormal); - const float sphereRadius = 0.48f; - - // Seam cells + every portal-adjacent neighbor of both. - var cellIds = new System.Collections.Generic.HashSet - { - 0x8A02016Eu, 0x8A02017Au, - }; - foreach (var seed in new[] { 0x8A02016Eu, 0x8A02017Au }) - { - var seedCell = dats.Get(seed); - if (seedCell is null) continue; - foreach (var p in seedCell.CellPortals) - cellIds.Add(0x8A020000u | p.OtherCellId); - } - - foreach (var cellId in cellIds) - { - var envCell = dats.Get(cellId); - if (envCell is null) { _out.WriteLine($"cell 0x{cellId:X8}: NOT FOUND"); continue; } - var environment = dats.Get(0x0D000000u | envCell.EnvironmentId); - if (environment is null || !environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) - continue; - - var rot = new System.Numerics.Quaternion( - envCell.Position.Orientation.X, envCell.Position.Orientation.Y, - envCell.Position.Orientation.Z, envCell.Position.Orientation.W); - var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot) - * System.Numerics.Matrix4x4.CreateTranslation( - envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); - - var portalPolyIds = new System.Collections.Generic.HashSet(); - foreach (var p in envCell.CellPortals) portalPolyIds.Add((ushort)p.PolygonId); - - foreach (var (id, poly) in cs!.PhysicsPolygons) - { - var verts = poly.VertexIds; - if (verts.Count < 3) continue; - if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue; - if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue; - if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue; - - var w0 = System.Numerics.Vector3.Transform(v0.Origin, world); - var w1 = System.Numerics.Vector3.Transform(v1.Origin, world); - var w2 = System.Numerics.Vector3.Transform(v2.Origin, world); - var n = System.Numerics.Vector3.Normalize( - System.Numerics.Vector3.Cross(w1 - w0, w2 - w0)); - - float align = System.Numerics.Vector3.Dot(n, hitNormal); - // |align|: the vertex-fan winding convention can flip the - // computed normal vs the physics plane's true facing — accept - // both signs (2026-07-06 sweep flaw fix). - if (MathF.Abs(align) < 0.95f) continue; // within ~18° of the recorded normal - - // Plane distance from the hit point. - float d = -System.Numerics.Vector3.Dot(n, w0); - float dist = System.Numerics.Vector3.Dot(n, hitPoint) + d; - if (MathF.Abs(dist) > sphereRadius + 0.1f) continue; - - // Rough proximity: hit point near the polygon's vertex span. - float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X)) - 1f; - float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X)) + 1f; - float minY = MathF.Min(w0.Y, MathF.Min(w1.Y, w2.Y)) - 1f; - float maxY = MathF.Max(w0.Y, MathF.Max(w1.Y, w2.Y)) + 1f; - if (hitPoint.X < minX || hitPoint.X > maxX || - hitPoint.Y < minY || hitPoint.Y > maxY) continue; - - _out.WriteLine( - $">>> CANDIDATE cell=0x{cellId:X8} poly={id} " + - $"worldN=({n.X:F3},{n.Y:F3},{n.Z:F3}) align={align:F3} planeDist={dist:F3} " + - $"isPortalPoly={portalPolyIds.Contains(id)} " + - $"w0=({w0.X:F2},{w0.Y:F2},{w0.Z:F2}) w1=({w1.X:F2},{w1.Y:F2},{w1.Z:F2}) w2=({w2.X:F2},{w2.Y:F2},{w2.Z:F2}) " + - $"verts={verts.Count} sides={poly.SidesType} stip={poly.Stippling}"); - } - } - _out.WriteLine("(sweep complete)"); - } - - /// - /// Entry-poly hunt: the synthetic reversed-movement collision normal is - /// produced by slide_sphere's opposing-normals branch, which needs an - /// INPUT collision normal anti-parallel to the grounded contact plane — - /// i.e., a DOWNWARD-facing polygon (lintel / arch underside). Those were - /// filtered out of the wall dump (|n.Z| > 0.3). Sweep both corridor - /// cells for downward polys near the seam column and print where their - /// planes sit relative to the player's head sphere. - /// - [Fact] - public void CorridorSeam_DownwardPolysNearSeam() - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - if (!Directory.Exists(datDir)) - { - _out.WriteLine($"SKIP: dat directory not found at {datDir}"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - foreach (var cellId in new[] { 0x8A02016Eu, 0x8A02017Au }) - { - var envCell = dats.Get(cellId); - Assert.NotNull(envCell); - var environment = dats.Get(0x0D000000u | envCell!.EnvironmentId); - Assert.NotNull(environment); - Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); - - var rot = new System.Numerics.Quaternion( - envCell.Position.Orientation.X, envCell.Position.Orientation.Y, - envCell.Position.Orientation.Z, envCell.Position.Orientation.W); - var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot) - * System.Numerics.Matrix4x4.CreateTranslation( - envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); - - _out.WriteLine($"=== 0x{cellId:X8} downward physics polys (n.Z < -0.3) ==="); - foreach (var (id, poly) in cs!.PhysicsPolygons) - { - var verts = poly.VertexIds; - if (verts.Count < 3) continue; - if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[0], out var v0)) continue; - if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[1], out var v1)) continue; - if (!cs.VertexArray.Vertices.TryGetValue((ushort)verts[2], out var v2)) continue; - - var w0 = System.Numerics.Vector3.Transform(v0.Origin, world); - var w1 = System.Numerics.Vector3.Transform(v1.Origin, world); - var w2 = System.Numerics.Vector3.Transform(v2.Origin, world); - var n = System.Numerics.Vector3.Normalize( - System.Numerics.Vector3.Cross(w1 - w0, w2 - w0)); - if (n.Z > -0.3f) continue; - - // Only near the seam column the player crossed. - float minX = MathF.Min(w0.X, MathF.Min(w1.X, w2.X)); - float maxX = MathF.Max(w0.X, MathF.Max(w1.X, w2.X)); - if (maxX < 83.5f || minX > 87.0f) continue; - - var allW = new System.Collections.Generic.List(); - foreach (var vid in verts) - if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var vv)) - allW.Add(System.Numerics.Vector3.Transform(vv.Origin, world)); - float minZ = float.MaxValue, maxZ = float.MinValue, minY = float.MaxValue, maxY = float.MinValue; - foreach (var w in allW) - { - minZ = MathF.Min(minZ, w.Z); maxZ = MathF.Max(maxZ, w.Z); - minY = MathF.Min(minY, w.Y); maxY = MathF.Max(maxY, w.Y); - } - - _out.WriteLine( - $" poly {id}: worldN=({n.X:F2},{n.Y:F2},{n.Z:F2}) x=[{minX:F2},{maxX:F2}] " + - $"y=[{minY:F2},{maxY:F2}] z=[{minZ:F2},{maxZ:F2}] verts={verts.Count} " + - $"sides={poly.SidesType} stip={poly.Stippling}"); - } - } - _out.WriteLine("(downward sweep complete)"); - } - - /// - /// 2026-07-06 gate-session follow-up: seam crossings SUCCEED at - /// y≈−40.8..−41.2 and BLOCK at y≈−39.5..−39.8 (cell-transit log, - /// launch-137-corridor-gate.log). A y-dependent boundary with no physics - /// polygon culprit points at the PORTAL POLYGONS — if the doorway - /// openings don't span the full corridor width, the transit/membership - /// machinery only hands the sphere to the neighbor inside the portal - /// poly's span. Dump every portal polygon's world-space vertex extent. - /// - [Theory] - [InlineData(0x8A02016Eu)] - [InlineData(0x8A02017Au)] - public void CorridorCell_PortalPolygonWorldSpans(uint envCellId) - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - if (!Directory.Exists(datDir)) - { - _out.WriteLine($"SKIP: dat directory not found at {datDir}"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - var envCell = dats.Get(envCellId); - Assert.NotNull(envCell); - var environment = dats.Get(0x0D000000u | envCell!.EnvironmentId); - Assert.NotNull(environment); - Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cs)); - - var rot = new System.Numerics.Quaternion( - envCell.Position.Orientation.X, envCell.Position.Orientation.Y, - envCell.Position.Orientation.Z, envCell.Position.Orientation.W); - var world = System.Numerics.Matrix4x4.CreateFromQuaternion(rot) - * System.Numerics.Matrix4x4.CreateTranslation( - envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); - - _out.WriteLine($"=== 0x{envCellId:X8} portal polygons (world spans) ==="); - foreach (var p in envCell.CellPortals) - { - if (!cs!.Polygons.TryGetValue((ushort)p.PolygonId, out var poly)) - { - _out.WriteLine($" portal poly {p.PolygonId} -> 0x{p.OtherCellId:X4} {p.Flags}: NOT in visual set"); - continue; - } - - var min = new System.Numerics.Vector3(float.MaxValue); - var max = new System.Numerics.Vector3(float.MinValue); - foreach (var vid in poly.VertexIds) - { - if (!cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) continue; - var w = System.Numerics.Vector3.Transform(v.Origin, world); - min = System.Numerics.Vector3.Min(min, w); - max = System.Numerics.Vector3.Max(max, w); - } - _out.WriteLine( - $" portal poly {p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] " + - $"x=[{min.X:F2},{max.X:F2}] y=[{min.Y:F2},{max.Y:F2}] z=[{min.Z:F2},{max.Z:F2}] " + - $"verts={poly.VertexIds.Count}"); - } - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs deleted file mode 100644 index 3724db77..00000000 --- a/tests/AcDream.Core.Tests/Physics/Issue137CorridorSeamReplayTests.cs +++ /dev/null @@ -1,500 +0,0 @@ -using System; -using System.IO; -using System.Numerics; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; -using AcDream.Core.Physics; -using Xunit; -using Xunit.Abstractions; -using Env = System.Environment; -using Plane = System.Numerics.Plane; - -namespace AcDream.Core.Tests.Physics; - -/// -/// #137 corridor-seam replay (2026-07-06) — dat-backed reproduction of the -/// Facility Hub phantom hit (launch-175-verify2.log:42858): running +X down -/// the corridor, crossing 0x8A02016E → 0x8A02017A at x≈85.25, the live -/// client recorded `ok=True hit=yes n=(−1.00,0.03,−0.03)` with full advance, -/// persisted the sliding normal, and every later forward resolve absorbed to -/// zero (`ok=False hit=no`). -/// -/// -/// Dat facts pinned by : -/// neither corridor cell (nor any portal-adjacent neighbor) has a physics -/// polygon whose plane matches that normal near the hit point — the recorded -/// normal is SYNTHETIC (the negated movement direction), which is exactly -/// what slide_sphere's opposing-normals branch records. Retail -/// (CSphere::slide_sphere 0x00537440 @0x0053762c) returns -/// COLLIDED_TS from that branch; our port returned OK — letting the step -/// complete with full advance and the synthetic normal persisted. -/// -/// -/// -/// This replay drives the real engine over the real dat cells with the -/// live-log positions and player dimensions, and pins: the seam crossing -/// must complete WITHOUT persisting a sliding normal, and continued forward -/// running must keep advancing (no absorbing wedge). -/// -/// -public class Issue137CorridorSeamReplayTests -{ - private readonly ITestOutputHelper _out; - public Issue137CorridorSeamReplayTests(ITestOutputHelper output) => _out = output; - - private const uint SeamCellWest = 0x8A02016Eu; - private const uint SeamCellEast = 0x8A02017Au; - - private static string? FindDatDir() - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - return Directory.Exists(datDir) ? datDir : null; - } - - /// - /// Hydrate the two seam cells + every portal-adjacent neighbor into a - /// PhysicsEngine, exactly as the streaming path does (CacheCellStruct - /// with the dat world transform). - /// - private static PhysicsEngine BuildCorridorEngine(DatCollection dats) - { - var engine = new PhysicsEngine(); - engine.DataCache = new PhysicsDataCache(); - - var toLoad = new System.Collections.Generic.HashSet { SeamCellWest, SeamCellEast }; - foreach (var seed in new[] { SeamCellWest, SeamCellEast }) - { - var seedCell = dats.Get(seed); - Assert.NotNull(seedCell); - foreach (var p in seedCell!.CellPortals) - toLoad.Add(0x8A020000u | p.OtherCellId); - } - - // Expand three portal rings — the live collision cell array reaches - // cells three hops out (0x8A020166, the under-ramp room whose ceiling - // is the ramp slab's underside, is ring-3 in the 2026-07-06 - // seam-shake trace; with only two rings the offline flood can never - // add it and the shake does not reproduce). - for (int ring = 0; ring < 3; ring++) - { - foreach (var known in new System.Collections.Generic.List(toLoad)) - { - var cell = dats.Get(known); - if (cell is null) continue; - foreach (var p in cell.CellPortals) - toLoad.Add(0x8A020000u | p.OtherCellId); - } - } - - foreach (var cellId in toLoad) - { - var envCell = dats.Get(cellId); - if (envCell is null) continue; - var environment = dats.Get(0x0D000000u | envCell.EnvironmentId); - if (environment is null) continue; - if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue; - - var rot = new Quaternion( - envCell.Position.Orientation.X, envCell.Position.Orientation.Y, - envCell.Position.Orientation.Z, envCell.Position.Orientation.W); - var world = Matrix4x4.CreateFromQuaternion(rot) - * Matrix4x4.CreateTranslation( - envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); - - engine.DataCache.CacheCellStruct(cellId, envCell, cs!, world); - } - - return engine; - } - - private static PhysicsBody GroundedBody() - { - var body = new PhysicsBody(); - body.ContactPlaneValid = true; - // Corridor floor at world z = −6 → n·p + d = 0 with n = +Z, d = 6. - body.ContactPlane = new Plane(Vector3.UnitZ, 6f); - body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; - // The live session carried a walkable polygon (walkable=True on every - // [resolve] line) — seed the corridor floor slab so the transition's - // SetWalkable path runs like live. - body.WalkablePolygonValid = true; - body.WalkablePlane = new Plane(Vector3.UnitZ, 6f); - body.WalkableUp = Vector3.UnitZ; - body.WalkableVertices = new[] - { - new Vector3(75f, -41.67f, -6f), - new Vector3(85f, -41.67f, -6f), - new Vector3(85f, -38.33f, -6f), - new Vector3(75f, -38.33f, -6f), - }; - return body; - } - - private ResolveResult Resolve(PhysicsEngine engine, PhysicsBody body, - Vector3 from, Vector3 to, uint cellId) - => engine.ResolveWithTransition( - currentPos: from, - targetPos: to, - cellId: cellId, - sphereRadius: 0.48f, // human player, PlayerMovementController:885 - sphereHeight: 1.2f, // human player, PlayerMovementController:886 - stepUpHeight: 0.4f, // PlayerMovementController defaults - stepDownHeight: 0.4f, - isOnGround: true, - body: body, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide); - - /// - /// 2026-07-06 seam-shake repro, snapshot-exact (probe session - /// launch-137-seam-probes.log + resolve-137-seam-capture.jsonl tick 4101, - /// repeated ×46): running WEST across the x=75 boundary - /// (0x8A02016E → 0x8A020165, the ramp cell) from (75.287, −40.035, −6) - /// toward (74.685, −39.988, −6), the resolve blocks with the SYNTHETIC - /// reversed-movement normal (0.997, −0.078, −0.002) and out==in — every - /// frame — the "shaking at the seam" report. - /// - /// - /// Probe-traced chain: the foot sphere (tangent to the floor) crosses - /// onto 0165's ramp floor; the ramp slab is double-faced and the - /// UNDERSIDE face (poly 0, n=(−0.03,0,−1)) grazes the sphere within the - /// hit threshold → recorded as a foot near-miss → neg-poly step-up - /// dispatch with a downward normal → the nested step-up's walkable probe - /// rejects the exactly-tangent ramp floor ([walkable-nearest] - /// gap=−0.0000 overlapsSphere=False) → StepUpSlide → - /// slide_sphere(downward normal vs up-facing contact plane) → the - /// opposing-normals branch → Collided → revert. Repeat. - /// - /// - [Fact] - public void SeamShake_WestBoundary_SnapshotExact_Advances() - { - var datDir = FindDatDir(); - if (datDir is null) - { - _out.WriteLine("SKIP: dat directory not found"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var engine = BuildCorridorEngine(dats); - - // Body seeded EXACTLY from the capture's bodyBefore (tick 4101). - var body = new PhysicsBody(); - body.ContactPlaneValid = true; - body.ContactPlane = new Plane(Vector3.UnitZ, 6f); - body.ContactPlaneCellId = SeamCellWest; - body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; - body.WalkablePolygonValid = true; - body.WalkablePlane = new Plane(Vector3.UnitZ, 6f); - body.WalkableUp = Vector3.UnitZ; - body.WalkableVertices = new[] - { - new Vector3(75f, -38.33333f, -6f), - new Vector3(75f, -41.66667f, -6f), - new Vector3(78.33333f, -41.66667f, -6f), - new Vector3(78.33333f, -38.33333f, -6f), - }; - - var from = new Vector3(75.28674f, -40.03537f, -6f); - var to = new Vector3(74.6854f, -39.988018f, -6f); - - // Emit the same step-level probes the live session logged so the - // offline trace can be line-diffed against launch-137-seam-probes.log - // — the first divergent line names the state the replay is missing. - var probeBuffer = new System.IO.StringWriter(); - var prevOut = Console.Out; - ResolveResult r1; - try - { - Console.SetOut(probeBuffer); - PhysicsDiagnostics.ProbeStepWalkEnabled = true; - PhysicsDiagnostics.ProbePushBackEnabled = true; - PhysicsDiagnostics.ProbeIndoorBspEnabled = true; - - r1 = engine.ResolveWithTransition( - currentPos: from, - targetPos: to, - cellId: SeamCellWest, - sphereRadius: 0.48f, - sphereHeight: 1.2f, - stepUpHeight: 0.6f, // live Setup values from the capture - stepDownHeight: 1.5f, - isOnGround: true, - body: body, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide); - } - finally - { - PhysicsDiagnostics.ProbeStepWalkEnabled = false; - PhysicsDiagnostics.ProbePushBackEnabled = false; - PhysicsDiagnostics.ProbeIndoorBspEnabled = false; - Console.SetOut(prevOut); - } - _out.WriteLine(probeBuffer.ToString()); - - _out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " + - $"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " + - $"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " + - $"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)}"); - - Assert.True(r1.Position.X < from.X - 0.3f, - $"The westward boundary crossing onto the ramp must advance " + - $"({from.X:F3} → {r1.Position.X:F3}, target {to.X:F3}); zero " + - $"advance with the reversed-movement normal = the seam shake."); - } - - /// - /// #137 window-climb repro (2026-07-06 gate 2, launch-137-gate2.log): - /// running from the ramp top in 0x8A020179 into the corridor-end opening - /// (the portal to the 0x8A02017E shaft, wall plane world y=−41.67), the - /// player stepped INTO the niche — `in=(89.531,−41.506,−5.112) → - /// out=(90.209,−41.774,−5.209) cell=0x8A02017E` — ending with the head - /// (and camera) through the opening's roof. The opening is ~1.3 m tall - /// (z −5.2..−3.9); a 1.68 m character cannot fit — retail blocks entry - /// (the raised probe's HEAD sphere hits the lintel/ceiling). User axiom: - /// "should not be able to run into it". - /// - [Fact] - public void WindowOpening_HeadCannotFit_EntryBlocked() - { - var datDir = FindDatDir(); - if (datDir is null) - { - _out.WriteLine("SKIP: dat directory not found"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var engine = BuildCorridorEngine(dats); - - var body = new PhysicsBody(); - body.ContactPlaneValid = true; - body.ContactPlane = new Plane(Vector3.UnitZ, 5.112f); // ramp-top level - body.ContactPlaneCellId = 0x8A020179u; - body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; - - // Walk the live approach (ramp-top toward the corridor-end opening) - // so the engine self-accumulates its contact-plane/walkable state, - // then push into the opening for several held-key frames (the live - // climb happened under a held key, not a single resolve). - var pos = new Vector3(88.60f, -41.10f, -5.05f); - uint cell = 0x8A020179u; - ResolveResult r = default; - bool probeFrames = Env.GetEnvironmentVariable("ACDREAM_TEST_WINDOW_PROBE") == "1"; - for (int i = 0; i < 22; i++) - { - var dir = Vector3.Normalize(new Vector3(90.209f, -41.809f, 0f) - new Vector3(pos.X, pos.Y, 0f)); - var step = new Vector3(dir.X, dir.Y, 0f) * 0.13f; - - var probeBuffer = new System.IO.StringWriter(); - var prevOut = Console.Out; - try - { - if (probeFrames && i >= 9) - { - Console.SetOut(probeBuffer); - PhysicsDiagnostics.ProbeStepWalkEnabled = true; - PhysicsDiagnostics.ProbeIndoorBspEnabled = true; - } - r = engine.ResolveWithTransition( - currentPos: pos, - targetPos: pos + step, - cellId: cell, - sphereRadius: 0.48f, - // #137: the corrected capsule top (dat Setup 0x02000001, - // head sphere center 1.350 → top 1.830; Height 1.835). - // The live climb happened under the old 1.2f (head top - // 1.2 m — no head collision at the lintel). - sphereHeight: 1.835f, - stepUpHeight: 0.6f, - stepDownHeight: 1.5f, - isOnGround: true, - body: body, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide); - } - finally - { - if (probeFrames && i >= 9) - { - PhysicsDiagnostics.ProbeStepWalkEnabled = false; - PhysicsDiagnostics.ProbeIndoorBspEnabled = false; - Console.SetOut(prevOut); - } - } - if (probeFrames && i >= 9 && i <= 10) - _out.WriteLine(probeBuffer.ToString()); - _out.WriteLine($"r{i}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " + - $"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " + - $"n=({r.CollisionNormal.X:F2},{r.CollisionNormal.Y:F2},{r.CollisionNormal.Z:F2})"); - pos = r.Position; - cell = r.CellId; - - Assert.NotEqual(0x8A02017Eu, r.CellId); - Assert.True(r.Position.Y > -41.6f, - $"A 1.68 m character must not enter the 1.3 m-tall opening " + - $"(wall plane y=−41.67); frame {i} got Y={r.Position.Y:F3} " + - $"cell=0x{r.CellId:X8} (live bug: ended at −41.774 inside " + - $"0x8A02017E, head through the roof)."); - } - } - - /// - /// The window-climb's placement half, pinned at the exact site: at the - /// step-up's raised position on the alcove sill (foot −5.019), the HEAD - /// sphere (center −3.339, span −3.82..−2.86) pokes ~6 cm past the south - /// wall plane into the SOLID rock above the alcove ceiling (0x8A020179's - /// lintel band, polys 14/15 at y=−41.67 z∈[−3.90,−3.00]). Retail's - /// step-down placement insert (CTransition::step_down 0x0050b3b3 → - /// placement transitional_insert → BSPTREE::sphere_intersects_solid - /// 0x0053d5f0) REJECTS — that's what makes the 0.7 m sill unclimbable. - /// Our placement passed (the live + offline climb), so our Path-1 solid - /// test misses the head-vs-solid overlap. - /// - [Fact] - public void WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides() - { - var datDir = FindDatDir(); - if (datDir is null) - { - _out.WriteLine("SKIP: dat directory not found"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var engine = BuildCorridorEngine(dats); - - var cell = engine.DataCache!.GetCellStruct(0x8A020179u); - Assert.NotNull(cell); - Assert.NotNull(cell!.BSP?.Root); - - // The raised (post-sill-climb) pose from the offline repro's r9. - var footWorld = new Vector3(89.683f, -41.247f, -4.539f); // foot sphere CENTER - var headWorld = new Vector3(89.683f, -41.247f, -3.339f); // head sphere CENTER - - var footLocal = Vector3.Transform(footWorld, cell.InverseWorldTransform); - var headLocal = Vector3.Transform(headWorld, cell.InverseWorldTransform); - - var t = new Transition(); - t.SpherePath.InitPath( - new Vector3(89.683f, -41.247f, -5.019f), - new Vector3(89.683f, -41.247f, -5.019f), - 0x8A020179u, 0.48f, 1.2f); - t.SpherePath.InsertType = InsertType.Placement; - - Matrix4x4.Decompose(cell.WorldTransform, out _, out var cellRot, out var cellOrigin); - - var result = BSPQuery.FindCollisions( - cell.BSP!.Root, - cell.Resolved, - t, - new DatReaderWriter.Types.Sphere { Origin = footLocal, Radius = 0.48f }, - new DatReaderWriter.Types.Sphere { Origin = headLocal, Radius = 0.48f }, - footLocal, - Vector3.UnitZ, - 1.0f, - cellRot, - engine, - worldOrigin: cellOrigin); - - _out.WriteLine($"placement result={result} footLocal=({footLocal.X:F3},{footLocal.Y:F3},{footLocal.Z:F3}) " + - $"headLocal=({headLocal.X:F3},{headLocal.Y:F3},{headLocal.Z:F3})"); - - Assert.Equal(TransitionState.Collided, result); - } - - /// - /// 2026-07-06 gate session repro (launch-137-corridor-gate.log): standing - /// at (84.851, −39.764, −6.000) — the foot sphere already straddling the - /// x=85 cell boundary by 0.33 m — the first move attempt toward - /// (85.453, −39.782) blocked with the synthetic reversed-movement normal - /// (−1.00, 0.03, −0.02), out==in, cp lost (cp=none), and repeated every - /// frame (the "shaking at the seam" report). The deeper straddle start is - /// what the original replay frame (84.638 → 85.253) didn't cover. - /// - [Fact] - public void SeamCrossing_FromDeepStraddleStart_Advances() - { - var datDir = FindDatDir(); - if (datDir is null) - { - _out.WriteLine("SKIP: dat directory not found"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var engine = BuildCorridorEngine(dats); - var body = GroundedBody(); - - var from = new Vector3(84.851f, -39.764f, -6.000f); - var to = new Vector3(85.453f, -39.782f, -6.000f); - - var r1 = Resolve(engine, body, from, to, SeamCellWest); - _out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " + - $"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " + - $"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " + - $"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " + - $"bodyCpValid={body.ContactPlaneValid}"); - - Assert.True(r1.Position.X > from.X + 0.2f, - $"The straddling-start seam crossing must advance " + - $"({from.X:F3} → {r1.Position.X:F3}); zero advance with a " + - $"reversed-movement normal = the 2026-07-06 seam shake."); - } - - [Fact] - public void SeamCrossing_DoesNotPersistSyntheticSlidingNormal_AndRunContinues() - { - var datDir = FindDatDir(); - if (datDir is null) - { - _out.WriteLine("SKIP: dat directory not found"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var engine = BuildCorridorEngine(dats); - var body = GroundedBody(); - - // ── The live hit frame verbatim (launch-175-verify2.log:42858) ── - var from = new Vector3(84.638f, -39.758f, -6.000f); - var to = new Vector3(85.253f, -39.776f, -6.000f); - - var r1 = Resolve(engine, body, from, to, SeamCellWest); - _out.WriteLine($"r1: ok={r1.Ok} out=({r1.Position.X:F3},{r1.Position.Y:F3},{r1.Position.Z:F3}) " + - $"cell=0x{r1.CellId:X8} hit={r1.CollisionNormalValid} " + - $"n=({r1.CollisionNormal.X:F2},{r1.CollisionNormal.Y:F2},{r1.CollisionNormal.Z:F2}) " + - $"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)} " + - $"slidingN=({body.SlidingNormal.X:F2},{body.SlidingNormal.Y:F2},{body.SlidingNormal.Z:F2})"); - - // The corridor is straight and open: the crossing must not leave the - // body carrying a sliding normal (there is no wall to slide on — - // Issue137CorridorSeamInspectionTests proved no polygon matches the - // live-recorded normal; retail's slide_sphere opposing branch returns - // COLLIDED and its validate handling never lets a synthetic - // reversed-movement normal survive a clean corridor run). - Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding), - "Crossing the open corridor seam must not persist a sliding " + - "normal — the live wedge's entry state (#137 mechanism 2)."); - - // ── Keep running +X (the live session's held-W frames) ────────── - var pos = r1.Position; - var cell = r1.CellId; - for (int i = 0; i < 6; i++) - { - var step = new Vector3(0.13f, -0.004f, 0f); // ~run speed per tick, same heading - var r = Resolve(engine, body, pos, pos + step, cell); - _out.WriteLine($"r{i + 2}: ok={r.Ok} out=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " + - $"cell=0x{r.CellId:X8} hit={r.CollisionNormalValid} " + - $"bodySliding={body.TransientState.HasFlag(TransientStateFlags.Sliding)}"); - Assert.True(r.Position.X > pos.X + 0.05f, - $"Forward run must keep advancing through the open corridor " + - $"(frame {i + 2}: {pos.X:F3} → {r.Position.X:F3}) — zero advance " + - $"= the #137 absorbing wedge."); - pos = r.Position; - cell = r.CellId; - } - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs deleted file mode 100644 index 98406131..00000000 --- a/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs +++ /dev/null @@ -1,343 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using DatReaderWriter.Enums; -using DatReaderWriter.Types; -using AcDream.Core.Physics; -using Xunit; -using Plane = System.Numerics.Plane; - -namespace AcDream.Core.Tests.Physics; - -/// -/// #137 mechanism 2 — the sliding-normal absorbing wedge (2026-07-06). -/// -/// -/// Retail's in-transition collision_info.sliding_normal has exactly ONE -/// writer besides the per-frame seed: CTransition::validate_transition -/// (0x0050ac21-ac30, "if collision_normal_valid → set_sliding_normal"). The -/// BSP collision layer NEVER writes it — BSPTREE::find_collisions' -/// Contact branch dispatches full hits to step_sphere_up (foot, -/// 0x0053a719) / BSPTREE::slide_sphere (head, 0x0053a697), and -/// CSphere::slide_sphere (0x00537440) slides IN-FRAME via -/// add_offset_to_check_pos without touching sliding_normal -/// (grep-verified: zero sliding_normal references between 0x005155 and -/// 0x00841f in acclient_2013_pseudo_c.txt). ACE mirrors this: the only -/// SetSlidingNormal call sites are CollisionInfo.cs:58 (the setter) and -/// Transition.cs:1027 (validate). The body-side persistence -/// (CPhysicsObj::SetPositionInternal 0x005154c2, SLIDING_TS bit-4 sync -/// at 0x005154e1) runs only on transition SUCCESS. -/// -/// -/// -/// acdream's BSPQuery Contact branch carried stub fallbacks -/// (SetCollisionNormal + SetSlidingNormal + return Slid) instead of the real -/// slide. The leaked sliding normal survived to the transition end, the -/// unconditional body writeback persisted it, and the next frame's seed -/// projected an exactly-anti-parallel push to zero — aborting at step 0 -/// BEFORE any collision test could refresh the state. Live shape: the -/// Facility Hub corridor phantom (launch-175-verify2.log:42858 — one wall -/// hit at the 0x8A02016E→0x8A02017A seam, then endless ok=False hit=no -/// zero-advance resolves; strafe escapes). -/// -/// -public class Issue137SlidingNormalLifecycleTests -{ - // ========================================================================= - // Site-level pins — BSPQuery.FindCollisions Contact branch must not write - // the transition's sliding normal (retail: only validate_transition does). - // ========================================================================= - - /// - /// Contact foot-sphere FULL HIT with the step-up recursion unavailable - /// (engine=null / step-up already in progress) must dispatch the real - /// sphere slide — never the SetSlidingNormal stub. - /// - /// - /// Retail: a blocked step-up funnels to SPHEREPATH::step_up_slide → - /// CSphere::slide_sphere (ACE SpherePath.cs:316 → Sphere.cs:558) — - /// in-frame slide, no sliding_normal write. Face-on into a vertical wall - /// while grounded: the crease projection (cross(wallN, floorN)) has no - /// component along the movement, the slide offset is degenerate - /// (< F_EPSILON), and slide_sphere returns COLLIDED_TS (0x00537735). - /// - /// - [Fact] - public void ContactFootFullHit_StepUpUnavailable_RealSlide_NoSlidingNormalWrite() - { - var (root, resolved) = BSPStepUpFixtures.TallWall(); - - // Grounded mover pushing face-on (+X) into the 5 m wall at x=0.5 - // (normal −X). Sphere center reach 0.35+0.2=0.55 penetrates the wall. - var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius); - var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius); - var t = BSPStepUpFixtures.MakeGroundedTransition(from, to); - - var localSphere = new DatReaderWriter.Types.Sphere - { - Origin = to, - Radius = BSPStepUpFixtures.SphereRadius, - }; - - var result = BSPQuery.FindCollisions( - root, resolved, t, localSphere, null, - from, Vector3.UnitZ, 1.0f); - - Assert.False(t.CollisionInfo.SlidingNormalValid, - "find_collisions must not write collision_info.sliding_normal — " + - "retail's only in-transition writer is validate_transition " + - "(0x0050ac21). A sliding normal leaked here survives to the body " + - "writeback and absorbs the next frame's forward offset (#137)."); - Assert.True(t.CollisionInfo.CollisionNormalValid, - "The real slide records the collision normal (CSphere::slide_sphere " + - "→ set_collision_normal)."); - Assert.Equal(TransitionState.Collided, result); - } - - /// - /// Contact HEAD-sphere FULL HIT must dispatch BSPTREE::slide_sphere - /// (retail 0x0053a697; ACE BSPTree.cs:202 → 310-316: the real - /// Sphere.SlideSphere on GlobalSphere[0]) — never the stub. - /// The corridor phantom's portal-side polys span head height; this is the - /// path that recorded the (−1,0,0) normal the wedge absorbed on. - /// - [Fact] - public void ContactHeadFullHit_RealSlide_NoSlidingNormalWrite() - { - // Raised wall: z ∈ [0.6, 5] at x=0.5, normal −X. The foot sphere - // (center z=0.2, r=0.2 → z-span [0, 0.4]) passes under it; the head - // sphere (center z=0.8 → z-span [0.6, 1.0]) fully hits it. - var resolved = new Dictionary(); - - var floorVerts = new[] - { - new Vector3(-2f, -1f, 0f), new Vector3(2f, -1f, 0f), - new Vector3(2f, 1f, 0f), new Vector3(-2f, 1f, 0f), - }; - resolved[1] = new ResolvedPolygon - { - Vertices = floorVerts, - Plane = new Plane(Vector3.UnitZ, 0f), - NumPoints = 4, - SidesType = CullMode.None, - }; - - var wallNormal = new Vector3(-1f, 0f, 0f); - var wallVerts = new[] - { - new Vector3(0.5f, -1f, 0.6f), - new Vector3(0.5f, -1f, 5f), - new Vector3(0.5f, 1f, 5f), - new Vector3(0.5f, 1f, 0.6f), - }; - resolved[2] = new ResolvedPolygon - { - Vertices = wallVerts, - Plane = new Plane(wallNormal, 0.5f), // n·p + d = 0 at x=0.5 - NumPoints = 4, - SidesType = CullMode.None, - }; - - var leaf = new PhysicsBSPNode - { - Type = BSPNodeType.Leaf, - BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = new Vector3(0f, 0f, 2.5f), Radius = 10f }, - }; - leaf.Polygons.Add(1); - leaf.Polygons.Add(2); - - var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius); - var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius); - var t = BSPStepUpFixtures.MakeGroundedTransition(from, to); - - var footSphere = new DatReaderWriter.Types.Sphere - { - Origin = to, - Radius = BSPStepUpFixtures.SphereRadius, - }; - var headSphere = new DatReaderWriter.Types.Sphere - { - Origin = new Vector3(to.X, to.Y, 0.8f), - Radius = BSPStepUpFixtures.SphereRadius, - }; - - var result = BSPQuery.FindCollisions( - leaf, resolved, t, footSphere, headSphere, - from, Vector3.UnitZ, 1.0f); - - Assert.False(t.CollisionInfo.SlidingNormalValid, - "Head full hit must go through the real BSPTREE::slide_sphere — " + - "no sliding_normal write at the BSP layer (retail 0x0053a697)."); - Assert.True(t.CollisionInfo.CollisionNormalValid); - Assert.Equal(TransitionState.Collided, result); - } - - /// - /// CSphere::slide_sphere's opposing-normals branch (collision - /// normal anti-parallel to the contact plane — e.g. a ceiling-facing - /// normal while grounded) records the REVERSED displacement as the - /// collision normal and returns COLLIDED_TS — retail 0x00537440 - /// @0x005375d7-0x0053762c: *normal = −gDelta; normalize; - /// set_collision_normal; return 2. Our port returned OK (its comment - /// even claimed "retail returns OK here"), letting the step complete - /// as-is with a synthetic reversed-movement collision normal — the exact - /// signature of the live corridor hit (`hit=yes n=(−1.00,0.03,−0.03)` = - /// the negated run direction, matching NO dat polygon). - /// - [Fact] - public void SlideSphere_OpposingNormals_ReturnsCollided_WithReversedDisplacementNormal() - { - var t = new Transition(); - t.SpherePath.InitPath( - new Vector3(0f, 0f, 0.2f), new Vector3(0.3f, 0f, 0.2f), - 0xA9B40001u, BSPStepUpFixtures.SphereRadius); - t.CollisionInfo.SetContactPlane(new Plane(Vector3.UnitZ, 0f), 0xA9B40001u, false); - - // Make gDelta exactly (0.4, 0, 0): currPos = check sphere − (0.4,0,0). - var currPos = t.SpherePath.GlobalSphere[0].Origin - new Vector3(0.4f, 0f, 0f); - - // Downward collision normal vs the +Z contact plane → cross ≈ 0 - // (parallel), dot = −1 < 0 (opposing) → the reverse branch. - var result = t.SlideSphereInternal(new Vector3(0f, 0f, -1f), currPos); - - Assert.Equal(TransitionState.Collided, result); - Assert.True(t.CollisionInfo.CollisionNormalValid); - Assert.True(t.CollisionInfo.CollisionNormal.X < -0.99f, - $"Collision normal must be the normalized reversed displacement " + - $"(−1,0,0); got ({t.CollisionInfo.CollisionNormal.X:F3}," + - $"{t.CollisionInfo.CollisionNormal.Y:F3},{t.CollisionInfo.CollisionNormal.Z:F3})."); - } - - // ========================================================================= - // Engine-level lifecycle pin — the retail persist/absorb/clear cycle at a - // REAL wall. Guards the fix against regressing wall behavior, and - // documents where retail CLEARS the body's sliding state (the successful - // transition's writeback, when no step re-records a collision). - // ========================================================================= - - private const uint CellId = 0xA9B40157u; - - private static PhysicsEngine BuildWallEngine() - { - var (wallRoot, wallResolved) = BSPStepUpFixtures.TallWall(); - - var cell = new CellPhysics - { - BSP = new PhysicsBSPTree { Root = wallRoot }, - WorldTransform = Matrix4x4.Identity, - InverseWorldTransform = Matrix4x4.Identity, - Resolved = wallResolved, - CellBSP = new CellBSPTree - { - Root = new CellBSPNode { Type = BSPNodeType.Leaf }, - }, - }; - - var engine = new PhysicsEngine(); - engine.DataCache = new PhysicsDataCache(); - - // Flat terrain strip so the outdoor fall-through has something to - // sample if it ever fires (same shape as FindEnvCollisionsMultiCellTests). - var heights = new byte[81]; - Array.Fill(heights, (byte)0); - engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, BuildHeightTable()), - Array.Empty(), Array.Empty(), - worldOffsetX: 0f, worldOffsetY: 0f); - - engine.DataCache.RegisterCellStructForTest(CellId, cell); - return engine; - } - - private static float[] BuildHeightTable() - { - var ht = new float[256]; - for (int i = 0; i < 256; i++) ht[i] = i * 1.0f; - return ht; - } - - private static PhysicsBody GroundedBody() - { - var body = new PhysicsBody(); - body.ContactPlaneValid = true; - body.ContactPlane = new Plane(Vector3.UnitZ, 0f); - body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; - return body; - } - - private ResolveResult ResolveForward(PhysicsEngine engine, PhysicsBody body, - Vector3 from, Vector3 to) - => engine.ResolveWithTransition( - currentPos: from, - targetPos: to, - cellId: CellId, - sphereRadius: BSPStepUpFixtures.SphereRadius, - sphereHeight: 0f, // single sphere — keeps the scenario deterministic - stepUpHeight: 0.04f, // cannot scale the 5 m wall - stepDownHeight: 0.04f, - isOnGround: true, - body: body); - - /// - /// The full retail lifecycle at a real wall: - /// (1) a blocked face-on push persists the validate-recorded sliding - /// normal via the SUCCESS writeback (SetPositionInternal bit-4 sync, - /// 0x005154e1); - /// (2) the next exactly-anti-parallel push is absorbed by the seed - /// (get_object_info 0x00511d44 → adjust_offset projects to zero → - /// find_transitional_position's step-0 small-offset abort) — the - /// retail cache semantics: "still pressed against this wall"; - /// (3) an oblique push escapes along the wall tangent, the step runs - /// without re-recording a collision, and the successful writeback - /// CLEARS the body's sliding state (sliding_normal_valid==0 → bit - /// 4 cleared). - /// - [Fact] - public void WallLifecycle_PersistOnBlock_AbsorbExactAntiParallel_ClearOnEscape() - { - var engine = BuildWallEngine(); - var body = GroundedBody(); - - // ── 1. Face-on +X into the wall at x=0.5 (normal −X) ───────────── - var r1 = ResolveForward(engine, body, - from: new Vector3(0.10f, 0f, 0f), - to: new Vector3(0.35f, 0f, 0f)); - - Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding), - "A blocked push must persist the validate-recorded sliding normal " + - "(retail SetPositionInternal 0x005154c2 on transition success)."); - Assert.True(body.SlidingNormal.X < -0.9f, - $"Persisted normal should face the mover (−X); got {body.SlidingNormal}."); - Assert.True(r1.Position.X + BSPStepUpFixtures.SphereRadius - <= 0.5f + PhysicsGlobals.EPSILON * 20f, - $"The 5 m wall must block the sphere; reach={r1.Position.X + BSPStepUpFixtures.SphereRadius:F4}."); - - // ── 2. Exactly-anti-parallel push again: absorbed frame ────────── - var r2 = ResolveForward(engine, body, - from: r1.Position, - to: r1.Position + new Vector3(0.15f, 0f, 0f)); - - Assert.False(r2.Ok, - "The seeded sliding normal projects the exactly-anti-parallel " + - "offset to zero → step-0 abort (retail find_transitional_position " + - "0050bfb7/0050c0ef). Faithful absorbed frame at a REAL wall."); - Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding), - "A failed transition leaves the body's sliding state untouched " + - "(retail: SetPositionInternal never runs on failure)."); - - // ── 3. Oblique push escapes and CLEARS the persisted state ─────── - var r3 = ResolveForward(engine, body, - from: r2.Position, - to: r2.Position + new Vector3(0.10f, 0.15f, 0f)); - - Assert.True(r3.Ok, "Oblique push must escape along the wall tangent."); - Assert.True(r3.Position.Y > r2.Position.Y + 0.05f, - $"Expected tangential advance along +Y; got Y={r3.Position.Y:F4} " + - $"(from {r2.Position.Y:F4})."); - Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding), - "A successful transition whose steps re-record no collision must " + - "CLEAR the body's sliding state (retail SetPositionInternal " + - "0x005154e1: bit 4 synced from the transition's final " + - "sliding_normal_valid, which each step clears before its insert)."); - Assert.Equal(Vector3.Zero, body.SlidingNormal); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Issue147ArwicBuildingsDumpTests.cs b/tests/AcDream.Core.Tests/Physics/Issue147ArwicBuildingsDumpTests.cs deleted file mode 100644 index 610b87d2..00000000 --- a/tests/AcDream.Core.Tests/Physics/Issue147ArwicBuildingsDumpTests.cs +++ /dev/null @@ -1,64 +0,0 @@ -using System.IO; -using System.Text; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; -using Xunit; -using Xunit.Abstractions; -using Env = System.Environment; - -namespace AcDream.Core.Tests.Physics; - -/// -/// #147 dat fixture: dump Arwic's (0xC6A9FFFE) LandBlockInfo Buildings + Objects -/// to decide what the city/perimeter WALLS are — buildings WITHOUT portals -/// (skipped by the building-collision cache loop's -/// if (building.Portals.Count == 0) continue;) vs landblock-static stabs -/// (a separate shadow-object collision path). Dat-data-dependent; SKIPs cleanly -/// without ACDREAM_DAT_DIR / the real dats. -/// -public sealed class Issue147ArwicBuildingsDumpTests -{ - private readonly ITestOutputHelper _out; - public Issue147ArwicBuildingsDumpTests(ITestOutputHelper o) => _out = o; - - [Fact] - public void DumpArwicBuildingsAndObjects() - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - if (!Directory.Exists(datDir)) { _out.WriteLine($"SKIP: no dats at {datDir}"); return; } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var info = dats.Get(0xC6A9FFFEu); // Arwic landblock info - Assert.NotNull(info); - - var sb = new StringBuilder(); - sb.AppendLine($"Arwic 0xC6A9FFFE: Buildings={info.Buildings.Count} Objects={info.Objects.Count}"); - - int portalless = 0; - sb.AppendLine("--- Buildings (ModelId, portals, origin) ---"); - foreach (var b in info.Buildings) - { - if (b.Portals.Count == 0) portalless++; - sb.AppendLine(System.FormattableString.Invariant( - $" model=0x{b.ModelId:X8} portals={b.Portals.Count} origin=({b.Frame.Origin.X:F1},{b.Frame.Origin.Y:F1},{b.Frame.Origin.Z:F1})")); - } - sb.AppendLine($"--- portal-less buildings (skipped by the collision cache): {portalless} ---"); - - sb.AppendLine("--- Objects / stabs (first 50: Id, origin) ---"); - int n = 0; - foreach (var o in info.Objects) - { - if (n++ >= 50) break; - sb.AppendLine(System.FormattableString.Invariant( - $" id=0x{o.Id:X8} origin=({o.Frame.Origin.X:F1},{o.Frame.Origin.Y:F1},{o.Frame.Origin.Z:F1})")); - } - - var outPath = Path.Combine(Path.GetTempPath(), "arwic-buildings-dump.txt"); - File.WriteAllText(outPath, sb.ToString()); - _out.WriteLine(sb.ToString()); - _out.WriteLine($"(also written to {outPath})"); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs b/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs deleted file mode 100644 index 6cd9bf4f..00000000 --- a/tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs +++ /dev/null @@ -1,265 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Numerics; -using AcDream.Core.Physics; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; -using DatReaderWriter.Types; -using Xunit; -using Xunit.Abstractions; -using Env = System.Environment; -using Placement = DatReaderWriter.Enums.Placement; - -namespace AcDream.Core.Tests.Physics; - -/// -/// #175 (2026-07-05) — read-only dat inspection for the Facility Hub door -/// (Setup 0x02000C9D, guid 0x78A020C7 in the live session). User report: -/// the door's COLLISION sits displaced to the far side of the VISUAL panel -/// (embed from one side deep enough to camera-clip; a phantom wall on the -/// other side that can push the player out of use radius). -/// -/// Hypothesis under test: collision registers from the Setup's -/// PlacementFrames (ShadowShapeBuilder.FromSetup — Resting|Default|first) -/// while the rendered panel poses from the motion table's default/closed -/// state through the sequencer; retail tests the part's LIVE pose -/// (CPhysicsPart), so a door whose placement frame differs from its -/// motion-table closed pose shows exactly this offset. This test dumps both -/// poses so the divergence (or its absence) is a dat fact, not a theory. -/// -/// SKIP when the dat directory is absent (CI); local runs have it. -/// -public class Issue175HubDoorPoseInspectionTests -{ - private readonly ITestOutputHelper _out; - public Issue175HubDoorPoseInspectionTests(ITestOutputHelper output) => _out = output; - - private const uint HubDoorSetupId = 0x02000C9Du; - - [Fact] - public void HubDoorSetup_PlacementVsMotionPose_DatInspection() - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - if (!Directory.Exists(datDir)) - { - _out.WriteLine($"SKIP: dat directory not found at {datDir}"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - var setup = dats.Get(HubDoorSetupId); - Assert.NotNull(setup); - - _out.WriteLine($"=== Setup 0x{HubDoorSetupId:X8} ==="); - _out.WriteLine($" Flags = {setup!.Flags} (0x{(uint)setup.Flags:X8})"); - _out.WriteLine($" Parts = {setup.Parts.Count}"); - for (int i = 0; i < setup.Parts.Count; i++) - _out.WriteLine($" [{i}] gfxObj=0x{setup.Parts[i]:X8}"); - _out.WriteLine($" DefaultAnimation = 0x{setup.DefaultAnimation:X8}"); - _out.WriteLine($" DefaultScript = 0x{setup.DefaultScript:X8}"); - _out.WriteLine($" DefaultMotionTable = 0x{setup.DefaultMotionTable:X8}"); - _out.WriteLine($" CylSpheres={setup.CylSpheres.Count} Spheres={setup.Spheres.Count} Radius={setup.Radius:F3}"); - foreach (var c in setup.CylSpheres) - _out.WriteLine($" cyl r={c.Radius:F3} h={c.Height:F3} origin=({c.Origin.X:F3},{c.Origin.Y:F3},{c.Origin.Z:F3})"); - - _out.WriteLine($" PlacementFrames = {setup.PlacementFrames.Count}"); - foreach (var kv in setup.PlacementFrames) - { - _out.WriteLine($" [{kv.Key}] frames={kv.Value.Frames.Count}"); - for (int i = 0; i < kv.Value.Frames.Count; i++) - { - var f = kv.Value.Frames[i]; - _out.WriteLine( - $" part[{i}] pos=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " + - $"rot=({f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3},{f.Orientation.W:F3})"); - } - } - - // Part 0's physics BSP bounds — where the slab actually is in - // PART-LOCAL space (composed with the poses above for world). - foreach (uint gfxId in setup.Parts.Distinct()) - { - var gfx = dats.Get(gfxId); - _out.WriteLine($"=== GfxObj 0x{gfxId:X8} ==="); - if (gfx is null) { _out.WriteLine(" NULL"); continue; } - var root = gfx.PhysicsBSP?.Root; - _out.WriteLine($" PhysicsBSP.Root = {(root is null ? "NULL" : "non-null")}"); - if (root?.BoundingSphere is { } bs) - _out.WriteLine($" BSP bounds = ({bs.Origin.X:F3},{bs.Origin.Y:F3},{bs.Origin.Z:F3}) r={bs.Radius:F3}"); - if (gfx.PhysicsPolygons is { } pp && gfx.VertexArray?.Vertices is { } verts) - { - float minX = float.MaxValue, maxX = float.MinValue; - float minY = float.MaxValue, maxY = float.MinValue; - float minZ = float.MaxValue, maxZ = float.MinValue; - foreach (var poly in pp.Values) - foreach (var vid in poly.VertexIds) - { - if (!verts.TryGetValue((ushort)vid, out var sv)) continue; - minX = Math.Min(minX, sv.Origin.X); maxX = Math.Max(maxX, sv.Origin.X); - minY = Math.Min(minY, sv.Origin.Y); maxY = Math.Max(maxY, sv.Origin.Y); - minZ = Math.Min(minZ, sv.Origin.Z); maxZ = Math.Max(maxZ, sv.Origin.Z); - } - _out.WriteLine($" Physics AABB (part-local) = X[{minX:F3},{maxX:F3}] Y[{minY:F3},{maxY:F3}] Z[{minZ:F3},{maxZ:F3}]"); - } - } - - // The motion-table default (closed) pose, if the setup names one: - // frame 0 of the default style's default cycle — what the sequencer - // renders for an idle closed door. - if (setup.DefaultMotionTable != 0) - { - var mt = dats.Get(setup.DefaultMotionTable); - _out.WriteLine($"=== MotionTable 0x{setup.DefaultMotionTable:X8} ==="); - if (mt is null) { _out.WriteLine(" NULL"); return; } - _out.WriteLine($" DefaultStyle = 0x{(uint)mt.DefaultStyle:X8}"); - if (mt.Cycles.TryGetValue((int)mt.DefaultStyle, out var defCycle) - && defCycle.Anims.Count > 0) - { - var animRef = defCycle.Anims[0]; - _out.WriteLine($" default cycle anim[0] id=0x{animRef.AnimId:X8} lo={animRef.LowFrame} hi={animRef.HighFrame}"); - var anim = dats.Get(animRef.AnimId); - if (anim is not null && anim.PartFrames.Count > 0) - { - var f0 = anim.PartFrames[Math.Clamp((int)animRef.LowFrame, 0, anim.PartFrames.Count - 1)]; - for (int i = 0; i < f0.Frames.Count; i++) - { - var f = f0.Frames[i]; - _out.WriteLine( - $" anim frame0 part[{i}] pos=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) " + - $"rot=({f.Orientation.X:F3},{f.Orientation.Y:F3},{f.Orientation.Z:F3},{f.Orientation.W:F3})"); - } - } - else - { - _out.WriteLine(" anim NULL or no PartFrames"); - } - } - else - { - _out.WriteLine(" no default-style cycle"); - } - } - else - { - _out.WriteLine("=== no DefaultMotionTable on the setup ==="); - } - } - - /// - /// #175 derivation conformance — REAL-DAT pin for - /// . - /// The first cut of the derivation looked up Cycles[DefaultStyle] - /// with the bare style word; the dictionary is keyed by the COMBINED - /// (style << 16) | substate word (CMotionTable.cs:683), so it - /// always missed and the #175 fix silently no-oped. This pin loads the - /// human motion table (0x09000001 — guaranteed present, default state - /// NonCombat/Ready) and asserts the derivation actually resolves a pose. - /// - [Fact] - public void MotionTablePose_DefaultState_ResolvesOnRealTable() - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - if (!Directory.Exists(datDir)) - { - _out.WriteLine($"SKIP: dat directory not found at {datDir}"); - return; - } - - using var dats = new DatCollection(datDir, DatAccessType.Read); - var mt = dats.Get(0x09000001u); - Assert.NotNull(mt); - - var pose = AcDream.Core.Physics.Motion.MotionTablePose.DefaultStatePartFrames( - mt!, id => dats.Get(id)); - - Assert.NotNull(pose); - _out.WriteLine($"human MT default pose parts={pose!.Count} " + - $"part0=({pose[0].Origin.X:F3},{pose[0].Origin.Y:F3},{pose[0].Origin.Z:F3})"); - Assert.True(pose.Count >= 1); - } - - // ── #175 fix pins: ShadowShapeBuilder partPoseOverride ────────────── - - private static Setup MakeTwoPartSetup() - { - var setup = new Setup(); - setup.Parts.Add(0x01000001u); - setup.Parts.Add(0x01000002u); - var placement = new AnimationFrame(2); - placement.Frames.Clear(); - placement.Frames.Add(new Frame { Origin = new Vector3(0.88f, -0.44f, 1.37f), - Orientation = new Quaternion(0f, 0f, -0.966f, 0.259f) }); - placement.Frames.Add(new Frame { Origin = new Vector3(-0.88f, -0.44f, 1.37f), - Orientation = new Quaternion(0f, 0f, -0.259f, 0.966f) }); - setup.PlacementFrames[Placement.Default] = placement; - return setup; - } - - /// - /// With a motion-table pose override, the BSP part shapes must use it — - /// the closed pose, not the ajar placement pose (the #175 offset). - /// - [Fact] - public void FromSetup_PartPoseOverride_ReplacesPlacementFrames() - { - var setup = MakeTwoPartSetup(); - var closed = new[] - { - new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity }, - new Frame { Origin = new Vector3(-0.85f, 0f, 1.37f), Orientation = Quaternion.Identity }, - }; - - var shapes = ShadowShapeBuilder.FromSetup( - setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: closed); - - Assert.Equal(2, shapes.Count); - Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition); - Assert.Equal(Quaternion.Identity, shapes[0].LocalRotation); - Assert.Equal(new Vector3(-0.85f, 0f, 1.37f), shapes[1].LocalPosition); - } - - /// - /// Null override (no motion table) keeps the pre-#175 placement-frame - /// behavior — landblock statics and table-less entities unchanged. - /// - [Fact] - public void FromSetup_NoOverride_KeepsPlacementFrames() - { - var setup = MakeTwoPartSetup(); - - var shapes = ShadowShapeBuilder.FromSetup( - setup, entScale: 1f, hasPhysicsBsp: _ => true); - - Assert.Equal(2, shapes.Count); - Assert.Equal(new Vector3(0.88f, -0.44f, 1.37f), shapes[0].LocalPosition); - Assert.Equal(new Quaternion(0f, 0f, -0.966f, 0.259f), shapes[0].LocalRotation); - } - - /// - /// A short override (fewer frames than parts) falls back to placement - /// frames — a mismatched motion table must not misplace collision. - /// - [Fact] - public void FromSetup_ShortOverride_FallsBackPerPart() - { - var setup = MakeTwoPartSetup(); - var shortOverride = new[] - { - new Frame { Origin = new Vector3(0.85f, 0f, 1.37f), Orientation = Quaternion.Identity }, - }; - - var shapes = ShadowShapeBuilder.FromSetup( - setup, entScale: 1f, hasPhysicsBsp: _ => true, partPoseOverride: shortOverride); - - Assert.Equal(2, shapes.Count); - Assert.Equal(new Vector3(0.85f, 0f, 1.37f), shapes[0].LocalPosition); // override - Assert.Equal(new Vector3(-0.88f, -0.44f, 1.37f), shapes[1].LocalPosition); // placement fallback - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs b/tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs deleted file mode 100644 index 33bf9952..00000000 --- a/tests/AcDream.Core.Tests/Physics/Issue176177SeamTransitLagTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System; -using System.IO; -using System.Numerics; -using AcDream.Core.Physics; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; -using Xunit; -using Xunit.Abstractions; -using Env = System.Environment; - -namespace AcDream.Core.Tests.Physics; - -/// -/// #176/#177 membership half: production [cell-transit] lines -/// (launch-137-gate2.log) fire 0.1–0.6 m PAST the portal plane in the travel -/// direction (016E→017A at x=85.33–85.47 vs the plane at x=85.00), while the -/// dat CellBSP volumes partition EXACTLY at the plane -/// (Issue176177DungeonSeamInspectionTests.SeamCells_CellBspContainment) — -/// retail's center-only point_in_cell flips at the plane. The render root -/// (viewer cell) resolves through the same machinery; while it lags, the -/// portal flood correctly refuses the boundary portal the eye has already -/// crossed and the whole forward chain drops (the purple seam flash / -/// stair pop). This replay measures OUR resolver's flip point across the -/// x=85 seam in a controlled run. -/// -public class Issue176177SeamTransitLagTests -{ - private const uint SeamCellWest = 0x8A02016Eu; // x 75..85 - private const uint SeamCellEast = 0x8A02017Au; // x 85..88.33 - - private readonly ITestOutputHelper _out; - public Issue176177SeamTransitLagTests(ITestOutputHelper output) => _out = output; - - private static string? ResolveDatDir() - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - return Directory.Exists(datDir) ? datDir : null; - } - - private static PhysicsEngine BuildEngine(DatCollection dats) - { - var engine = new PhysicsEngine(); - engine.DataCache = new PhysicsDataCache(); - - var toLoad = new System.Collections.Generic.HashSet { SeamCellWest, SeamCellEast }; - for (int ring = 0; ring < 3; ring++) - { - foreach (var known in new System.Collections.Generic.List(toLoad)) - { - var cell = dats.Get(known); - if (cell is null) continue; - foreach (var p in cell.CellPortals) - toLoad.Add(0x8A020000u | p.OtherCellId); - } - } - - foreach (var cellId in toLoad) - { - var envCell = dats.Get(cellId); - if (envCell is null) continue; - var environment = dats.Get(0x0D000000u | envCell.EnvironmentId); - if (environment is null) continue; - if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) continue; - - var rot = new Quaternion( - envCell.Position.Orientation.X, envCell.Position.Orientation.Y, - envCell.Position.Orientation.Z, envCell.Position.Orientation.W); - var world = Matrix4x4.CreateFromQuaternion(rot) - * Matrix4x4.CreateTranslation( - envCell.Position.Origin.X, envCell.Position.Origin.Y, envCell.Position.Origin.Z); - - engine.DataCache.CacheCellStruct(cellId, envCell, cs!, world); - } - return engine; - } - - private static PhysicsBody GroundedBody() - { - var body = new PhysicsBody(); - body.ContactPlaneValid = true; - body.ContactPlane = new Plane(Vector3.UnitZ, 6f); - body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; - body.WalkablePolygonValid = true; - body.WalkablePlane = new Plane(Vector3.UnitZ, 6f); - body.WalkableUp = Vector3.UnitZ; - body.WalkableVertices = new[] - { - new Vector3(75f, -41.67f, -6f), - new Vector3(85f, -41.67f, -6f), - new Vector3(85f, -38.33f, -6f), - new Vector3(75f, -38.33f, -6f), - }; - return body; - } - - /// - /// Run +X across the x=85 seam at run-speed tick steps (13.5 cm/tick ≈ - /// 4 m/s at 30 Hz) and record where ResolveWithTransition's CellId flips. - /// Retail (center-only point_in_cell, exact-partition CellBSP) flips on - /// the first tick whose END position is past x=85.00 — any flip later - /// than one step past the plane is OUR lag. - /// - [Theory] - [InlineData(+1)] // west → east across x=85 - [InlineData(-1)] // east → west back across - public void RunAcrossSeam_CellFlipPosition(int direction) - { - var datDir = ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - var engine = BuildEngine(dats); - var body = GroundedBody(); - - const float step = 0.135f; - float startX = direction > 0 ? 83.8f : 86.2f; - uint cell = direction > 0 ? SeamCellWest : SeamCellEast; - var pos = new Vector3(startX, -40f, -6f); - - float? flipX = null; - for (int tick = 0; tick < 26; tick++) - { - var target = pos + new Vector3(direction * step, 0f, 0f); - var r = engine.ResolveWithTransition( - currentPos: pos, - targetPos: target, - cellId: cell, - sphereRadius: 0.48f, - sphereHeight: 1.835f, - stepUpHeight: 0.4f, - stepDownHeight: 0.4f, - isOnGround: true, - body: body, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide); - - bool flipped = r.CellId != cell; - _out.WriteLine($"tick={tick,2} pos=({r.Position.X:F3},{r.Position.Y:F3},{r.Position.Z:F3}) " + - $"cell=0x{r.CellId:X8} ok={r.Ok}{(flipped ? " <<< FLIP" : "")}"); - if (flipped && flipX is null) - flipX = r.Position.X; - - cell = r.CellId; - pos = r.Position; - if (direction > 0 && pos.X > 86.4f) break; - if (direction < 0 && pos.X < 83.6f) break; - } - - Assert.NotNull(flipX); - float lag = direction > 0 ? flipX!.Value - 85.00f : 85.00f - flipX!.Value; - _out.WriteLine($"flip at x={flipX:F3} → lag past the plane = {lag:F3} m " + - $"(one-tick quantization bound = {step:F3} m)"); - // Diagnostic, not a pin: the finding is the printed lag. A lag beyond - // one tick step is the divergence under investigation. - } -} diff --git a/tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs b/tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs deleted file mode 100644 index a9a75b35..00000000 --- a/tests/AcDream.Core.Tests/Physics/LandDefsBlockOffsetTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -public class LandDefsBlockOffsetTests -{ - // get_block_offset(src, dst) returns the world-meter offset from src's - // landblock origin to dst's landblock origin. block_byte<<3 = lcoord, *24 m/cell - // → nets to (Δlandblock)·192 m. Same landblock → Zero. Decomp @0x0043e630. - - [Fact] - public void SameLandblock_ReturnsZero() - { - // both 0xC95B - Assert.Equal(Vector3.Zero, LandDefs.GetBlockOffset(0xC95B0001u, 0xC95B0008u)); - } - - [Fact] - public void SouthNeighbour_IsMinus192Y() - { - // 0xC95B (lbY=0x5B) → 0xC95A (lbY=0x5A): one landblock south = (0,-192,0). - // This is the exact #145 case: correct origin for 0xC95A relative to 0xC95B. - Assert.Equal(new Vector3(0f, -192f, 0f), LandDefs.GetBlockOffset(0xC95B0001u, 0xC95A0001u)); - } - - [Fact] - public void EastNeighbour_IsPlus192X() - { - // 0xC95B (lbX=0xC9) → 0xCA5B (lbX=0xCA): one landblock east = (+192,0,0). - Assert.Equal(new Vector3(192f, 0f, 0f), LandDefs.GetBlockOffset(0xC95B0001u, 0xCA5B0001u)); - } - - [Fact] - public void DiagonalNeighbour_CombinesBothAxes() - { - // 0xC95B → 0xCA5C: lbX +1 (+192), lbY +1 (+192). - Assert.Equal(new Vector3(192f, 192f, 0f), LandDefs.GetBlockOffset(0xC95B0001u, 0xCA5C0001u)); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/AnimSequenceNodeTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/AnimSequenceNodeTests.cs deleted file mode 100644 index 2452d153..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/AnimSequenceNodeTests.cs +++ /dev/null @@ -1,209 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R1-P1 — verbatim AnimSequenceNode (Phase R plan, gap-map items -/// G1/G2/G16/G18). Oracle: r1-csequence-decomp.md §25-28 (ctors 0x00525d30 / -/// 0x00525f90, set_animation_id 0x00525d60, get_starting_frame 0x00525c80, -/// get_ending_frame 0x00525cb0, multiply_framerate 0x00525be0, get_pos_frame -/// 0x005247b0 / 0x00525c10). -/// -/// KEY RETAIL SEMANTICS UNDER TEST: -/// - boundary pair is DIRECTION-AWARE and returns BARE INTS with NO epsilon -/// (ACE's epsilon subtraction is an ACE fabrication — P0-pins.md); -/// - multiply_framerate SWAPS low/high on a negative factor; -/// - set_animation_id clamps in a fixed order (high<0 → num−1; -/// low≥num → num−1; high≥num → num−1; low>high → high=low). -/// -public class AnimSequenceNodeTests -{ - private sealed class FakeLoader : IAnimationLoader - { - private readonly Animation? _anim; - public FakeLoader(Animation? anim) => _anim = anim; - public Animation? LoadAnimation(uint id) => _anim; - } - - private static Animation MakeAnim(int numFrames, bool posFrames = false) - { - var anim = new Animation(); - for (int f = 0; f < numFrames; f++) - { - var pf = new AnimationFrame(1u); - pf.Frames.Add(new Frame { Origin = new Vector3(f, 0, 0), Orientation = Quaternion.Identity }); - anim.PartFrames.Add(pf); - if (posFrames) - anim.PosFrames.Add(new Frame { Origin = new Vector3(0, f, 0), Orientation = Quaternion.Identity }); - } - return anim; - } - - [Fact] - public void DefaultCtor_RetailDefaults() - { - // 0x00525d30: framerate=30f, low=-1, high=-1, anim=null. - var n = new AnimSequenceNode(); - Assert.Equal(30f, n.Framerate); - Assert.Equal(-1, n.LowFrame); - Assert.Equal(-1, n.HighFrame); - Assert.False(n.HasAnim); - } - - [Fact] - public void SetAnimationId_Zero_LeavesAnimNullAndFramesUnclamped() - { - // 0x00525d60: arg2==0 → anim=null; the clamp block is gated on - // anim != null, so low/high stay at their raw values. - var n = new AnimSequenceNode(); - n.SetAnimationId(0, new FakeLoader(MakeAnim(10))); - Assert.False(n.HasAnim); - Assert.Equal(-1, n.LowFrame); - Assert.Equal(-1, n.HighFrame); - } - - [Theory] - // low, high (pre-clamp), numFrames, expectedLow, expectedHigh - [InlineData(0, -1, 10, 0, 9)] // high<0 → num-1 ("play to end") - [InlineData(12, -1, 10, 9, 9)] // high<0 first, then low>=num → num-1 - [InlineData(3, 15, 10, 3, 9)] // high>=num → num-1 - [InlineData(15, 15, 10, 9, 9)] // both clamp to num-1 - [InlineData(5, 2, 10, 5, 5)] // low>high after clamps → high=low - [InlineData(2, 7, 10, 2, 7)] // in-range untouched - public void SetAnimationId_ClampOrder(int low, int high, int numFrames, int expLow, int expHigh) - { - var n = new AnimSequenceNode { LowFrame = low, HighFrame = high }; - n.SetAnimationId(0x0300ABCDu, new FakeLoader(MakeAnim(numFrames))); - Assert.True(n.HasAnim); - Assert.Equal(expLow, n.LowFrame); - Assert.Equal(expHigh, n.HighFrame); - } - - [Theory] - // framerate, low, high, expectedStart, expectedEnd — BARE INTS, no epsilon - [InlineData(30f, 2, 7, 2, 8)] // forward: start=low, end=high+1 - [InlineData(-30f, 2, 7, 8, 2)] // reverse: start=high+1, end=low - [InlineData(0f, 2, 7, 2, 8)] // zero framerate is NOT < 0 → forward - [InlineData(30f, 0, 0, 0, 1)] // single-frame forward - [InlineData(-30f, 0, 0, 1, 0)] // single-frame reverse - public void BoundaryPair_DirectionAware_BareInts( - float framerate, int low, int high, int expStart, int expEnd) - { - var n = new AnimSequenceNode { Framerate = framerate, LowFrame = low, HighFrame = high }; - Assert.Equal(expStart, n.GetStartingFrame()); - Assert.Equal(expEnd, n.GetEndingFrame()); - } - - [Fact] - public void MultiplyFramerate_Positive_NoSwap() - { - var n = new AnimSequenceNode { Framerate = 30f, LowFrame = 2, HighFrame = 7 }; - n.MultiplyFramerate(2f); - Assert.Equal(60f, n.Framerate); - Assert.Equal(2, n.LowFrame); - Assert.Equal(7, n.HighFrame); - } - - [Fact] - public void MultiplyFramerate_Negative_SwapsLowHigh() - { - // 0x00525be0: factor < 0 → swap(low_frame, high_frame), then - // framerate *= factor. Coupled with the direction-aware boundary - // pair (framerate now negative reads the swapped fields). - var n = new AnimSequenceNode { Framerate = 30f, LowFrame = 2, HighFrame = 7 }; - n.MultiplyFramerate(-1f); - Assert.Equal(-30f, n.Framerate); - Assert.Equal(7, n.LowFrame); - Assert.Equal(2, n.HighFrame); - // Reverse playback boundaries against the SWAPPED fields: - // start = high_frame(2)+1 = 3?? — no: framerate<0 → start = high+1 - // where high_frame is now 2 → 3; end = low_frame = 7. - Assert.Equal(3, n.GetStartingFrame()); - Assert.Equal(7, n.GetEndingFrame()); - } - - [Fact] - public void MultiplyFramerate_DoubleNegative_RoundTrips() - { - var n = new AnimSequenceNode { Framerate = 30f, LowFrame = 2, HighFrame = 7 }; - n.MultiplyFramerate(-1f); - n.MultiplyFramerate(-1f); - Assert.Equal(30f, n.Framerate); - Assert.Equal(2, n.LowFrame); - Assert.Equal(7, n.HighFrame); - } - - [Fact] - public void GetPosFrame_NullAnim_ReturnsNull() - { - var n = new AnimSequenceNode(); - Assert.Null(n.GetPosFrame(0)); - } - - [Fact] - public void GetPosFrame_OutOfRange_ReturnsNull() - { - // 0x00525c10: retail returns NULL out of range (ACE returns identity - // — an ACE-ism, gap G18: port the null). - var n = new AnimSequenceNode(); - n.SetAnimationId(1, new FakeLoader(MakeAnim(3, posFrames: true))); - Assert.Null(n.GetPosFrame(-1)); - Assert.Null(n.GetPosFrame(3)); - Assert.NotNull(n.GetPosFrame(2)); - } - - [Fact] - public void GetPosFrame_DoubleOverload_Floors() - { - // 0x005247b0: floor(double) → int overload. - var n = new AnimSequenceNode(); - n.SetAnimationId(1, new FakeLoader(MakeAnim(3, posFrames: true))); - var f = n.GetPosFrame(1.99); - Assert.NotNull(f); - Assert.Equal(1f, f!.Origin.Y); // frame index 1, not 2 - } - - [Fact] - public void GetPosFrame_AnimWithoutPosFrames_ReturnsNull() - { - var n = new AnimSequenceNode(); - n.SetAnimationId(1, new FakeLoader(MakeAnim(3, posFrames: false))); - Assert.Null(n.GetPosFrame(0)); - } - - [Fact] - public void GetPartFrame_BoundsAndValue() - { - var n = new AnimSequenceNode(); - n.SetAnimationId(1, new FakeLoader(MakeAnim(3))); - Assert.Null(n.GetPartFrame(-1)); - Assert.Null(n.GetPartFrame(3)); - var pf = n.GetPartFrame(2); - Assert.NotNull(pf); - Assert.Equal(2f, pf!.Frames[0].Origin.X); - } - - [Fact] - public void CtorFromAnimData_CopiesThenClamps() - { - // 0x00525f90: copies framerate/low/high from AnimData then runs - // set_animation_id (which clamps against the resolved anim). - QualifiedDataId qid = 0x03001234u; - var ad = new AnimData - { - AnimId = qid, - LowFrame = 0, - HighFrame = -1, - Framerate = 15f, - }; - var n = new AnimSequenceNode(ad, new FakeLoader(MakeAnim(5))); - Assert.Equal(15f, n.Framerate); - Assert.Equal(0, n.LowFrame); - Assert.Equal(4, n.HighFrame); // -1 sentinel clamped to num-1 - Assert.True(n.HasAnim); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/CMotionTableTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/CMotionTableTests.cs deleted file mode 100644 index e123e3a1..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/CMotionTableTests.cs +++ /dev/null @@ -1,1045 +0,0 @@ -using System.Linq; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Enums; -using DatReaderWriter.Types; - -using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; - -namespace AcDream.Core.Tests.Physics.Motion; - -// ───────────────────────────────────────────────────────────────────────────── -// CMotionTableTests — R2-Q2 conformance harness for the verbatim -// CMotionTable::GetObjectSequence dispatcher (r2-port-plan.md §3 Q2). -// -// Every test is labeled with the gap id(s) it pins per the plan's REQUIRED -// case list. Fixture convention matches AnimationSequencerTests.cs's -// Fixtures class (file-local, replicated here per the task's instruction to -// keep this file self-contained). -// -// Command-word constants below are real DatReaderWriter.Enums.MotionCommand -// values (verified via reflection against Chorizite.DatReaderWriter 2.1.7, -// 2026-07-02) — NOT synthetic bit patterns — so the class-bit dispatch -// (cycle/modifier/action/style) exercises the same bit layout retail ships: -// Ready = 0x41000003 (cycle) -// WalkForward = 0x45000005 (cycle) -// RunForward = 0x44000007 (cycle) -// TurnRight = 0x6500000D (cycle AND modifier — both bits set; -// the AP-73 run-while-turning mechanism test -// depends on this real dual-class shape) -// Jump = 0x2500003B (modifier only) -// ThrustMed = 0x10000058 (action only) -// HandCombat = 0x8000003C (style; top bit set) -// NonCombat = 0x8000003D (style) -// ───────────────────────────────────────────────────────────────────────────── - -file static class Fixtures -{ - public static Animation MakeAnim(int numFrames, int numParts, Vector3 origin, Quaternion orientation) - { - var anim = new Animation(); - for (int f = 0; f < numFrames; f++) - { - var pf = new AnimationFrame((uint)numParts); - for (int p = 0; p < numParts; p++) - pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation }); - anim.PartFrames.Add(pf); - } - return anim; - } - - public static AnimData MakeAnimData(uint animId, float framerate) - => new() - { - AnimId = (QualifiedDataId)animId, - LowFrame = 0, - HighFrame = -1, - Framerate = framerate, - }; - - public static MotionData MakeMotionData(uint animId, float framerate, byte bitfield = 0, - Vector3? velocity = null, Vector3? omega = null) - { - var md = new MotionData { Bitfield = bitfield }; - md.Anims.Add(MakeAnimData(animId, framerate)); - if (velocity is { } v) - { - md.Velocity = v; - md.Flags |= MotionDataFlags.HasVelocity; - } - if (omega is { } w) - { - md.Omega = w; - md.Flags |= MotionDataFlags.HasOmega; - } - return md; - } - - /// MotionData with N AnimData entries (for outTicks sum tests, A3). - public static MotionData MakeMultiAnimMotionData(uint firstAnimId, int count, float framerate) - { - var md = new MotionData(); - for (int i = 0; i < count; i++) - md.Anims.Add(MakeAnimData(firstAnimId + (uint)i, framerate)); - return md; - } - - public static void AddLink(MotionTable mt, uint style, uint fromSubstate, uint toSubstate, MotionData data) - { - int outerKey = (int)((style << 16) | (fromSubstate & 0xFFFFFFu)); - if (!mt.Links.TryGetValue(outerKey, out var cmd)) - { - cmd = new MotionCommandData(); - mt.Links[outerKey] = cmd; - } - cmd.MotionData[(int)toSubstate] = data; - } - - public static void AddCycle(MotionTable mt, uint style, uint substate, MotionData data) - { - int key = (int)((style << 16) | (substate & 0xFFFFFFu)); - mt.Cycles[key] = data; - } - - public static void AddModifier(MotionTable mt, uint style, uint modifier, MotionData data, bool styleSpecific = true) - { - int key = styleSpecific ? (int)((style << 16) | (modifier & 0xFFFFFFu)) : (int)(modifier & 0xFFFFFFu); - mt.Modifiers[key] = data; - } -} - -public sealed class CMotionTableTests -{ - // Real retail command words (DatReaderWriter.Enums.MotionCommand). - private const uint Ready = 0x41000003u; - private const uint WalkForward = 0x45000005u; - private const uint RunForward = 0x44000007u; - private const uint TurnRight = 0x6500000Du; // cycle AND modifier class - private const uint Jump = 0x2500003Bu; // modifier only - private const uint ThrustMed = 0x10000058u; // action only - - private const uint HandCombatStyle = 0x8000003Cu; // style, top bit set - private const uint NonCombatStyle = 0x8000003Du; // style, top bit set - - // ── Ready→Walk link+cycle chain shape (H1, base dispatcher) ──────── - - [Fact] - public void GetObjectSequence_ReadyToWalk_BuildsLinkThenCycleChain() - { - var loader = new FakeLoader(); - uint readyAnim = 0x03000001u, walkAnim = 0x03000002u, linkAnim = 0x03000003u; - loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - - var readyCycle = Fixtures.MakeMotionData(readyAnim, 30f); - Fixtures.AddCycle(mt, NonCombatStyle, Ready, readyCycle); - - var walkCycle = Fixtures.MakeMotionData(walkAnim, 30f); - Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, walkCycle); - - var link = Fixtures.MakeMotionData(linkAnim, 30f); - Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, link); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - var seq = new CSequence(loader); - - bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 1f, out uint outTicks, stopCall: false); - - Assert.True(ok); - Assert.Equal(WalkForward, state.Substate); - Assert.Equal(NonCombatStyle, state.Style); - Assert.Equal(1f, state.SubstateMod); - // list shape: link then cycle (2 nodes total). - Assert.Equal(2, seq.Count); - // A3: outTicks = link.num_anims + cycle.num_anims - 1 = 1 + 1 - 1 = 1 - Assert.Equal(1u, outTicks); - } - - [Fact] - public void GetObjectSequence_ReadyToWalk_FirstCyclicIsTheCycleNotTheLink() - { - var loader = new FakeLoader(); - uint linkAnim = 0x03000010u, cycleAnim = 0x03000011u; - loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(0x03000012u, 30f)); - Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(cycleAnim, 30f)); - Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, Fixtures.MakeMotionData(linkAnim, 30f)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - var seq = new CSequence(loader); - - Assert.True(cmt.GetObjectSequence(WalkForward, state, seq, 1f, out _, stopCall: false)); - - Assert.NotNull(seq.FirstCyclic); - Assert.NotNull(seq.CurrAnim); - // The cyclic tail is the LAST appended node (append_animation slides - // first_cyclic on every call, R1 G10) — that's the cycle, not the link. - Assert.Equal(2, seq.Count); - } - - // ── walk↔run same-substate re-speed fast path (H8) ───────────────── - - [Fact] - public void GetObjectSequence_SameSubstateSameSignFasterSpeed_TakesReSpeedFastPath_NoListChange() - { - var loader = new FakeLoader(); - uint walkAnim = 0x03000020u; - loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - var walkCycle = Fixtures.MakeMotionData(walkAnim, 30f, velocity: new Vector3(0, 3.12f, 0)); - Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, walkCycle); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f }; - var seq = new CSequence(loader); - - // Seed the sequence as if WalkForward is already playing at speed 1.0. - Assert.True(cmt.DoObjectMotion(WalkForward, state, seq, 1f, out _)); - int countBefore = seq.Count; - var firstCyclicBefore = seq.FirstCyclic; - var velBefore = seq.Velocity; - - // Re-issue WalkForward at speed 2.0 (same substate, same sign). - bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 2f, out uint outTicks, stopCall: false); - - Assert.True(ok); - Assert.Equal(2f, state.SubstateMod); - // NO list change — fast path never calls remove_cyclic_anims/add_motion chain. - Assert.Equal(countBefore, seq.Count); - Assert.Same(firstCyclicBefore, seq.FirstCyclic); - // velocity = subtract-old(1x) + combine-new(2x) => net velocity scales to 2x the base. - Assert.Equal(velBefore * 2f, seq.Velocity); - // outTicks stays 0 on the fast path (retail: *arg6=0 at entry, never reassigned - // before the early `return 1`). - Assert.Equal(0u, outTicks); - } - - [Fact] - public void GetObjectSequence_SameSubstateSameSignFastPath_RescalesFramerates() - { - var loader = new FakeLoader(); - uint walkAnim = 0x03000021u; - loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(walkAnim, 10f)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f }; - var seq = new CSequence(loader); - - Assert.True(cmt.DoObjectMotion(WalkForward, state, seq, 1f, out _)); - double frBefore = seq.CurrAnim!.Framerate; - - Assert.True(cmt.GetObjectSequence(WalkForward, state, seq, 2.5f, out _, stopCall: false)); - - double frAfter = seq.CurrAnim!.Framerate; - Assert.Equal(frBefore * 2.5, frAfter, 3); - } - - // ── sign-flip Walk(+)→Walk(-) routes the style-default double-hop (A2) ── - - [Fact] - public void GetObjectSequence_SignFlipSameSubstate_RoutesStyleDefaultDoubleHop() - { - // Walk(+1.0) -> Walk(-1.0): same substate id, OPPOSITE sign. This must - // NOT take the re-speed fast path (same_sign gate fails) and must NOT - // take the direct-link path (no link registered substate->substate); - // it routes via the style-default double-hop: - // hop1: get_link(style, substate, oldSpeed, styleDefaultSubstate, 1f) - // hop2: get_link(style, styleDefaultSubstate, 1f, substate, newSpeed) - var loader = new FakeLoader(); - uint walkAnim = 0x03000030u, hop1Anim = 0x03000031u, hop2Anim = 0x03000032u; - loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(hop1Anim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(hop2Anim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(walkAnim, 30f)); - // hop1: WalkForward -> Ready (style default), hop2: Ready -> WalkForward - Fixtures.AddLink(mt, NonCombatStyle, WalkForward, Ready, Fixtures.MakeMotionData(hop1Anim, 30f)); - Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, Fixtures.MakeMotionData(hop2Anim, 30f)); - - var cmt = new CMotionTable(mt); - // Seed state DIRECTLY (bypassing GetObjectSequence) so state.Substate - // starts at WalkForward WITHOUT the seeding call itself tripping the - // WalkForward->WalkForward double-hop routing this fixture's own - // links would otherwise trigger (both hop links target/originate at - // WalkForward, which would also satisfy a same-substate rebuild). - var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f }; - var seq = new CSequence(loader); - CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(walkAnim, 30f), 1f); - Assert.Equal(1, seq.Count); - - bool ok = cmt.GetObjectSequence(WalkForward, state, seq, -1f, out uint outTicks, stopCall: false); - - Assert.True(ok); - Assert.Equal(-1f, state.SubstateMod); - Assert.Equal(WalkForward, state.Substate); - // Rebuild happened (not the fast path): list = hop1(1) + hop2(1) + cycle(1) = 3 nodes. - Assert.Equal(3, seq.Count); - } - - // ── stance change → Branch 1 chain (H14) ──────────────────────────── - - [Fact] - public void GetObjectSequence_StyleChange_BuildsExitLinkPlusStyleLinkPlusNewCycle() - { - var loader = new FakeLoader(); - uint exitAnim = 0x03000040u, styleLinkAnim = 0x03000041u, newCycleAnim = 0x03000042u; - loader.Register(exitAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(styleLinkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(newCycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - mt.StyleDefaults[(DRWMotionCommand)HandCombatStyle] = (DRWMotionCommand)Ready; - - // Current: NonCombat, playing WalkForward (NOT the style default). - Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(0x03000043u, 30f)); - // exit-link: WalkForward -> NonCombat's default (Ready) - Fixtures.AddLink(mt, NonCombatStyle, WalkForward, Ready, Fixtures.MakeMotionData(exitAnim, 30f)); - // style-to-style link: NonCombat's default (Ready) -> HandCombat's default (Ready) - Fixtures.AddLink(mt, NonCombatStyle, Ready, HandCombatStyle, Fixtures.MakeMotionData(styleLinkAnim, 30f)); - // HandCombat's default cycle (Ready) - Fixtures.AddCycle(mt, HandCombatStyle, Ready, Fixtures.MakeMotionData(newCycleAnim, 30f)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = WalkForward, SubstateMod = 1f }; - var seq = new CSequence(loader); - - Assert.True(cmt.DoObjectMotion(WalkForward, state, seq, 1f, out _)); - - bool ok = cmt.GetObjectSequence(HandCombatStyle, state, seq, 1f, out uint outTicks, stopCall: false); - - Assert.True(ok); - Assert.Equal(HandCombatStyle, state.Style); - Assert.Equal(Ready, state.Substate); // committed to the TARGET style's default substate - // exit-link(1) + style-link(1) + new cycle(1) = 3 nodes - Assert.Equal(3, seq.Count); - } - - [Fact] - public void GetObjectSequence_StyleChange_AlreadyInTargetStyle_IsNoOp() - { - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - var seq = new CSequence(new NullLoader()); - - bool ok = cmt.GetObjectSequence(NonCombatStyle, state, seq, 1f, out uint outTicks, stopCall: false); - - Assert.True(ok); - Assert.Equal(0u, outTicks); - Assert.Equal(0, seq.Count); // untouched - } - - // ── emote-while-running Branch 3 out-and-back (A4-#1) ─────────────── - - [Fact] - public void GetObjectSequence_ActionDirectLink_QueuesActionAndReAddsBaseCycle() - { - var loader = new FakeLoader(); - uint cycleAnim = 0x03000050u, actionAnim = 0x03000051u; - loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(actionAnim, Fixtures.MakeAnim(3, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(cycleAnim, 30f)); - Fixtures.AddLink(mt, NonCombatStyle, RunForward, ThrustMed, Fixtures.MakeMotionData(actionAnim, 30f)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; - var seq = new CSequence(loader); - - Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); - - bool ok = cmt.GetObjectSequence(ThrustMed, state, seq, 1f, out uint outTicks, stopCall: false); - - Assert.True(ok); - // Direct-link action path: action(1) + re-added base cycle(1) = 2 nodes. - Assert.Equal(2, seq.Count); - // Action queued onto the FIFO. - Assert.Single(state.Actions); - Assert.Equal(ThrustMed, state.Actions.First().Motion); - // Base substate is NOT changed by an action (state.Substate stays RunForward). - Assert.Equal(RunForward, state.Substate); - // A3: outTicks = action's num_anims (1) only, direct-link path. - Assert.Equal(1u, outTicks); - } - - [Fact] - public void GetObjectSequence_ActionNoDirectLink_FourLayerOutAndBack_BaseCycleAtOldSubstateMod() - { - var loader = new FakeLoader(); - uint cycleAnim = 0x03000060u, outHopAnim = 0x03000061u, actionAnim = 0x03000062u, returnHopAnim = 0x03000063u; - loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(outHopAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(actionAnim, Fixtures.MakeAnim(3, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(returnHopAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(cycleAnim, 30f)); - // NO direct RunForward -> ThrustMed link. Route via style default (Ready). - Fixtures.AddLink(mt, NonCombatStyle, RunForward, Ready, Fixtures.MakeMotionData(outHopAnim, 30f)); - Fixtures.AddLink(mt, NonCombatStyle, Ready, ThrustMed, Fixtures.MakeMotionData(actionAnim, 30f)); - Fixtures.AddLink(mt, NonCombatStyle, Ready, RunForward, Fixtures.MakeMotionData(returnHopAnim, 30f)); - - var cmt = new CMotionTable(mt); - // Running at a NON-default speed so we can assert the base cycle is - // re-added at the OLD substate_mod (A4-#1), not the action's speed. - // Seed state DIRECTLY (bypassing GetObjectSequence) — this fixture's - // RunForward<->Ready links would otherwise make a DoObjectMotion(RunForward) - // seeding call itself take the double-hop rebuild path (RunForward - // requested while already at RunForward, no direct self-link, empty - // sequence so no fast-path) and pre-populate 3 nodes before the real - // action call under test even runs. - var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1.5f }; - var seq = new CSequence(loader); - CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cycleAnim, 30f), 1.5f); - Assert.Equal(1, seq.Count); - - bool ok = cmt.GetObjectSequence(ThrustMed, state, seq, 1f, out uint outTicks, stopCall: false); - - Assert.True(ok); - // outHop(1) + action(1) + returnHop(1) + base cycle(1) = 4 nodes. - Assert.Equal(4, seq.Count); - Assert.Equal(RunForward, state.Substate); // action doesn't change base substate - Assert.Equal(1.5f, state.SubstateMod); // untouched — base cycle re-added at OLD speed - - // Base cycle (the last-appended / cyclic tail node) keeps the OLD - // substate_mod's framerate scale (1.5x of the dat's 30f == 45f). - Assert.NotNull(seq.FirstCyclic); - Assert.Equal(45.0, seq.FirstCyclic!.Framerate, 3); - - // A4-#1: outTicks = outHop.num_anims + action.num_anims + returnHop.num_anims - // (NEVER the base cycle, NEVER double-counted — ACE's bug, not retail's). - Assert.Equal(3u, outTicks); - } - - // ── turn-in-place (Branch 2 cycle) ────────────────────────────────── - - [Fact] - public void GetObjectSequence_TurnInPlace_ResolvesAsCycleFromReady() - { - var loader = new FakeLoader(); - uint readyAnim = 0x03000070u, turnAnim = 0x03000071u, linkAnim = 0x03000072u; - loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(turnAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); - // TurnRight is NOT gated (bitfield=0) in this fixture, isolating the - // "resolves as a normal Branch-2 cycle" behavior from is_allowed gating - // (that's the SEPARATE run-while-turning test below). - Fixtures.AddCycle(mt, NonCombatStyle, TurnRight, Fixtures.MakeMotionData(turnAnim, 30f, bitfield: 0)); - Fixtures.AddLink(mt, NonCombatStyle, Ready, TurnRight, Fixtures.MakeMotionData(linkAnim, 30f)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - var seq = new CSequence(loader); - - bool ok = cmt.GetObjectSequence(TurnRight, state, seq, 1f, out _, stopCall: false); - - Assert.True(ok); - Assert.Equal(TurnRight, state.Substate); - Assert.Equal(2, seq.Count); // link + cycle - } - - // ── run-while-turning: is_allowed rejects gated turn cycle → Branch 4 ── - // (AP-73 mechanism test, gap H4/H13) - - [Fact] - public void GetObjectSequence_RunWhileTurning_GatedTurnCycleRejected_FallsToModifierPhysicsOnlyCombine() - { - var loader = new FakeLoader(); - uint runAnim = 0x03000080u, turnAnim = 0x03000081u; - loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(turnAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - - var runVelocity = new Vector3(0, 4.0f, 0); - Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f, velocity: runVelocity)); - - // TurnRight AS A CYCLE is bitfield&2 gated (substate-checked) — - // is_allowed will reject it because current substate (RunForward) != - // TurnRight and != the style default. - Fixtures.AddCycle(mt, NonCombatStyle, TurnRight, Fixtures.MakeMotionData(turnAnim, 30f, bitfield: 2)); - // TurnRight AS A MODIFIER: physics-only turn omega, resolved from the - // Modifiers dict once Branch 2 falls through. - var turnOmega = new Vector3(0, 0, -(MathF.PI / 2f)); - Fixtures.AddModifier(mt, NonCombatStyle, TurnRight, Fixtures.MakeMotionData(0, 0f, omega: turnOmega)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; - var seq = new CSequence(loader); - - Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); - int countBefore = seq.Count; - var cyclicBefore = seq.FirstCyclic; - var currBefore = seq.CurrAnim; - - bool ok = cmt.GetObjectSequence(TurnRight, state, seq, 1f, out _, stopCall: false); - - Assert.True(ok); - // Run anims UNTOUCHED — Branch 4 is physics-only, no add/remove of anim nodes. - Assert.Equal(countBefore, seq.Count); - Assert.Equal(cyclicBefore, seq.FirstCyclic); - Assert.Equal(currBefore, seq.CurrAnim); - // Base substate stays RunForward (a modifier doesn't replace the cycle). - Assert.Equal(RunForward, state.Substate); - // TurnRight now tracked as an active modifier. - Assert.Contains(state.Modifiers, m => m.Motion == TurnRight); - // Physics combined: velocity untouched (turn modifier carries no - // velocity), omega now includes the turn contribution. - Assert.Equal(runVelocity, seq.Velocity); - Assert.Equal(turnOmega, seq.Omega); - } - - // ── modifier stop = subtract + unlink ─────────────────────────────── - - [Fact] - public void StopSequenceMotion_Modifier_SubtractsPhysicsAndUnlinksFromModifierChain() - { - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - var jumpOmega = new Vector3(0, 0, 5f); - Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - var seq = new CSequence(new NullLoader()); - - // Activate the modifier directly (bypassing GetObjectSequence's gate logic). - state.AddModifierNoCheck(Jump, 1f); - seq.CombinePhysics(Vector3.Zero, jumpOmega); - Assert.Equal(jumpOmega, seq.Omega); - - bool ok = cmt.StopSequenceMotion(Jump, 1f, state, seq, out uint outTicks); - - Assert.True(ok); - Assert.Equal(Vector3.Zero, seq.Omega); // subtracted back out - Assert.DoesNotContain(state.Modifiers, m => m.Motion == Jump); // unlinked - Assert.Equal(0u, outTicks); - } - - [Fact] - public void StopSequenceMotion_UnknownModifier_ReturnsFalse_NoOp() - { - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - var seq = new CSequence(new NullLoader()); - - bool ok = cmt.StopSequenceMotion(Jump, 1f, state, seq, out uint outTicks); - - Assert.False(ok); - Assert.Equal(0u, outTicks); - } - - // ── StopObjectCompletely drains modifiers then re-drives to style default (A4-#4) ── - - [Fact] - public void StopObjectCompletely_DrainsAllModifiers_ThenRedrivesToStyleDefault() - { - var loader = new FakeLoader(); - uint runAnim = 0x03000090u, readyAnim = 0x03000091u, exitLinkAnim = 0x03000092u; - loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(exitLinkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f)); - Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); - Fixtures.AddLink(mt, NonCombatStyle, RunForward, Ready, Fixtures.MakeMotionData(exitLinkAnim, 30f)); - - var jumpOmega = new Vector3(1, 0, 0); - Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; - var seq = new CSequence(loader); - - Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); - state.AddModifierNoCheck(Jump, 1f); - seq.CombinePhysics(Vector3.Zero, jumpOmega); - - bool ok = cmt.StopObjectCompletely(state, seq, out _); - - Assert.True(ok); - Assert.Empty(state.Modifiers); // drained - Assert.Equal(Ready, state.Substate); // re-driven to style default - // The turn/jump physics contribution is gone (subtracted during drain). - Assert.Equal(Vector3.Zero, seq.Omega); - } - - // ── missing cycle → return false, sequence UNTOUCHED (H5) ─────────── - - [Fact] - public void GetObjectSequence_MissingCycle_ReturnsFalse_SequenceUntouched() - { - var loader = new FakeLoader(); - uint readyAnim = 0x030000A0u; - loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); - // Deliberately NO RunForward cycle anywhere (not under NonCombatStyle, - // not under DefaultStyle) — the default_style retry also misses. - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - var seq = new CSequence(loader); - - Assert.True(cmt.DoObjectMotion(Ready, state, seq, 1f, out _)); - int countBefore = seq.Count; - var velBefore = seq.Velocity; - var omegaBefore = seq.Omega; - uint substateBefore = state.Substate; - - bool ok = cmt.GetObjectSequence(RunForward, state, seq, 1f, out uint outTicks, stopCall: false); - - Assert.False(ok); - Assert.Equal(0u, outTicks); - Assert.Equal(countBefore, seq.Count); // list untouched - Assert.Equal(velBefore, seq.Velocity); - Assert.Equal(omegaBefore, seq.Omega); - Assert.Equal(substateBefore, state.Substate); // state untouched - } - - // ── entry guards ───────────────────────────────────────────────────── - - [Fact] - public void GetObjectSequence_ZeroStyle_ReturnsFalse() - { - var mt = new MotionTable(); - var cmt = new CMotionTable(mt); - var state = new MotionState(); // Style=0, Substate=0 (default ctor) - var seq = new CSequence(new NullLoader()); - - bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 1f, out uint outTicks, stopCall: false); - - Assert.False(ok); - Assert.Equal(0u, outTicks); - } - - [Fact] - public void GetObjectSequence_ZeroSubstate_ReturnsFalse() - { - var mt = new MotionTable(); - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = 0 }; - var seq = new CSequence(new NullLoader()); - - bool ok = cmt.GetObjectSequence(WalkForward, state, seq, 1f, out uint outTicks, stopCall: false); - - Assert.False(ok); - Assert.Equal(0u, outTicks); - } - - // ── modifier-class no-op fast path ────────────────────────────────── - - [Fact] - public void GetObjectSequence_ModifierClassTargetEqualsStyleDefault_NotStopCall_IsNoOp() - { - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Jump; // contrived: style default == Jump (modifier-class id) - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Jump, SubstateMod = 1f }; - var seq = new CSequence(new NullLoader()); - - bool ok = cmt.GetObjectSequence(Jump, state, seq, 1f, out uint outTicks, stopCall: false); - - Assert.True(ok); - Assert.Equal(0u, outTicks); - Assert.Equal(0, seq.Count); // no-op, nothing built - } - - // ── re_modify replays the modifier stack after a substate change ──── - - [Fact] - public void ReModify_ReplaysActiveModifiers_ThroughGetObjectSequence() - { - var loader = new FakeLoader(); - uint runAnim = 0x030000B0u; - loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f)); - var jumpOmega = new Vector3(0, 0, 9f); - Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; - var seq = new CSequence(loader); - - Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); - - // Active modifier BEFORE the transition that calls re_modify. - state.AddModifierNoCheck(Jump, 1f); - seq.ClearPhysics(); // simulate a fresh rebuild wiping physics - - cmt.ReModify(seq, state); - - // re_modify replayed Jump through GetObjectSequence -> combine_motion - // re-applies its omega contribution. - Assert.Equal(jumpOmega, seq.Omega); - // Modifier chain popped-and-readded during replay; net membership unchanged. - Assert.Contains(state.Modifiers, m => m.Motion == Jump); - } - - [Fact] - public void ReModify_EmptyModifierChain_IsNoOp() - { - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - var seq = new CSequence(new NullLoader()); - - cmt.ReModify(seq, state); // must not throw; no modifiers to replay - - Assert.Empty(state.Modifiers); - Assert.Equal(0, seq.Count); - } - - // ── free-function unit coverage ───────────────────────────────────── - - [Theory] - [InlineData(1f, 1f, true)] - [InlineData(-1f, -1f, true)] - [InlineData(1f, -1f, false)] - [InlineData(-1f, 1f, false)] - [InlineData(0f, 1f, true)] - [InlineData(0f, -1f, false)] - public void SameSign_MatchesRetailSemantics(float a, float b, bool expected) - { - Assert.Equal(expected, CMotionTable.SameSign(a, b)); - } - - [Fact] - public void ChangeCycleSpeed_RescalesFramerate_ByRatio() - { - var loader = new FakeLoader(); - uint animId = 0x030000C0u; - loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - var seq = new CSequence(loader); - seq.AppendAnimation(Fixtures.MakeAnimData(animId, 10f)); - - var md = Fixtures.MakeMotionData(animId, 10f); - CMotionTable.ChangeCycleSpeed(seq, md, oldSpeed: 1f, newSpeed: 2f); - - Assert.Equal(20.0, seq.CurrAnim!.Framerate, 3); - } - - [Fact] - public void ChangeCycleSpeed_OldSpeedNearZero_NewSpeedNearZero_ZeroesFramerate() - { - // A4-#2: retail's own gap, ported verbatim — when BOTH old and new - // speed are ~0, the framerate is explicitly zeroed. - var loader = new FakeLoader(); - uint animId = 0x030000C1u; - loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - var seq = new CSequence(loader); - seq.AppendAnimation(Fixtures.MakeAnimData(animId, 10f)); - - var md = Fixtures.MakeMotionData(animId, 10f); - CMotionTable.ChangeCycleSpeed(seq, md, oldSpeed: 0.0001f, newSpeed: 0.0001f); - - Assert.Equal(0.0, seq.CurrAnim!.Framerate, 5); - } - - [Fact] - public void ChangeCycleSpeed_OldSpeedNearZero_NewSpeedNonZero_SilentNoOp_A4Gap() - { - // A4-#2 gap, PORTED VERBATIM (retail bug, not ours to fix): when - // old speed ~0 but new speed is NOT ~0, retail's fabsl(arg4) branch - // structure suppresses the rescale entirely — framerate is left - // exactly as-is (no zeroing, no rescale). Leave this test failing - // rather than fudge the assertion if the decomp's fall-through is - // ever re-read differently. - var loader = new FakeLoader(); - uint animId = 0x030000C2u; - loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - var seq = new CSequence(loader); - seq.AppendAnimation(Fixtures.MakeAnimData(animId, 10f)); - - var md = Fixtures.MakeMotionData(animId, 10f); - CMotionTable.ChangeCycleSpeed(seq, md, oldSpeed: 0.0001f, newSpeed: 5f); - - Assert.Equal(10.0, seq.CurrAnim!.Framerate, 5); - } - - [Fact] - public void AddMotion_NullMotionData_IsNoOp() - { - var seq = new CSequence(new NullLoader()); - CMotionTable.AddMotion(seq, null, 1f); - Assert.Equal(0, seq.Count); - Assert.Equal(Vector3.Zero, seq.Velocity); - } - - [Fact] - public void AddMotion_SetsVelocityOmega_Unconditionally_EvenWhenZero() - { - // G17 core: add_motion is UNCONDITIONAL (retail 0x005224b0) — no - // HasVelocity/HasOmega gate. A MotionData with a non-zero existing - // sequence velocity gets overwritten with the dat-silent zero. - var loader = new FakeLoader(); - uint animId = 0x030000D0u; - loader.Register(animId, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - var seq = new CSequence(loader); - seq.SetVelocity(new Vector3(9, 9, 9)); - - var md = Fixtures.MakeMotionData(animId, 10f); // no HasVelocity flag -> Velocity field is default(Vector3.Zero) - CMotionTable.AddMotion(seq, md, 1f); - - Assert.Equal(Vector3.Zero, seq.Velocity); // overwritten with zero, not left at (9,9,9) - } - - [Fact] - public void CombineMotion_AddsToExistingPhysics_AnimsUntouched() - { - var seq = new CSequence(new NullLoader()); - seq.SetVelocity(new Vector3(1, 0, 0)); - var md = Fixtures.MakeMotionData(0, 0f, velocity: new Vector3(2, 0, 0)); - - CMotionTable.CombineMotion(seq, md, 1f); - - Assert.Equal(new Vector3(3, 0, 0), seq.Velocity); - Assert.Equal(0, seq.Count); // combine_motion never touches anims - } - - [Fact] - public void SubtractMotion_RemovesFromExistingPhysics_AnimsUntouched() - { - var seq = new CSequence(new NullLoader()); - seq.SetVelocity(new Vector3(5, 0, 0)); - var md = Fixtures.MakeMotionData(0, 0f, velocity: new Vector3(2, 0, 0)); - - CMotionTable.SubtractMotion(seq, md, 1f); - - Assert.Equal(new Vector3(3, 0, 0), seq.Velocity); - Assert.Equal(0, seq.Count); - } - - // ── GetLink / IsAllowed direct unit coverage ──────────────────────── - - [Fact] - public void GetLink_ForwardDirection_LooksUpBySubstateThenMotion() - { - var mt = new MotionTable(); - var link = Fixtures.MakeMotionData(0x030000E0u, 30f); - Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, link); - var cmt = new CMotionTable(mt); - - var result = cmt.GetLink(NonCombatStyle, Ready, 1f, WalkForward, 1f); - - Assert.Same(link, result); - } - - [Fact] - public void GetLink_ReversedDirection_EitherSpeedNegative_SwapsKeys() - { - // A1 pin: EITHER speed negative -> swapped-key branch (link stored - // FROM motion TO substate). - var mt = new MotionTable(); - var reversedLink = Fixtures.MakeMotionData(0x030000E1u, 30f); - Fixtures.AddLink(mt, NonCombatStyle, WalkForward, Ready, reversedLink); - var cmt = new CMotionTable(mt); - - // fromSubstate=Ready(+1), toSubstate=WalkForward but NEGATIVE speed. - var result = cmt.GetLink(NonCombatStyle, Ready, 1f, WalkForward, -1f); - - Assert.Same(reversedLink, result); - } - - [Fact] - public void IsAllowed_UngatedMotionData_AlwaysAllowed() - { - var mt = new MotionTable(); - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready }; - var md = Fixtures.MakeMotionData(0x030000F0u, 30f, bitfield: 0); - - Assert.True(cmt.IsAllowed(WalkForward, md, state)); - } - - [Fact] - public void IsAllowed_GatedMotionData_CandidateEqualsCurrentSubstate_Allowed() - { - var mt = new MotionTable(); - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = TurnRight }; - var md = Fixtures.MakeMotionData(0x030000F1u, 30f, bitfield: 2); - - Assert.True(cmt.IsAllowed(TurnRight, md, state)); - } - - [Fact] - public void IsAllowed_GatedMotionData_CurrentSubstateIsStyleDefault_Allowed() - { - var mt = new MotionTable(); - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready }; - var md = Fixtures.MakeMotionData(0x030000F2u, 30f, bitfield: 2); - - Assert.True(cmt.IsAllowed(TurnRight, md, state)); - } - - [Fact] - public void IsAllowed_GatedMotionData_CurrentSubstateMismatch_Rejected() - { - var mt = new MotionTable(); - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = RunForward }; // neither TurnRight nor Ready - var md = Fixtures.MakeMotionData(0x030000F3u, 30f, bitfield: 2); - - Assert.False(cmt.IsAllowed(TurnRight, md, state)); - } - - [Fact] - public void IsAllowed_NullMotionData_Rejected() - { - var mt = new MotionTable(); - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready }; - - Assert.False(cmt.IsAllowed(TurnRight, null, state)); - } - - // ── SetDefaultState (hard reset) ───────────────────────────────────── - - [Fact] - public void SetDefaultState_ResetsToTableDefaultStyleAndSubstate_ClearAnimationsHardReset() - { - var loader = new FakeLoader(); - uint readyAnim = 0x03000100u, junkAnim = 0x03000101u; - loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(junkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = HandCombatStyle, Substate = RunForward, SubstateMod = 3f }; - state.AddModifierNoCheck(Jump, 1f); - state.AddAction(ThrustMed, 1f); - - var seq = new CSequence(loader); - seq.AppendAnimation(Fixtures.MakeAnimData(junkAnim, 5f)); // stale junk node - - bool ok = cmt.SetDefaultState(state, seq, out uint outTicks); - - Assert.True(ok); - Assert.Equal(NonCombatStyle, state.Style); - Assert.Equal(Ready, state.Substate); - Assert.Equal(1f, state.SubstateMod); - Assert.Empty(state.Modifiers); // clear_modifiers - Assert.Empty(state.Actions); // clear_actions - // clear_animations hard reset: the stale junk node is gone; only the - // fresh Ready cycle remains. - Assert.Equal(1, seq.Count); - // outTicks = cyclic.num_anims - 1 = 1 - 1 = 0 (decomp §8: *arg4 = node->motionData->num_anims - 1). - Assert.Equal(0u, outTicks); - } - - [Fact] - public void SetDefaultState_TableHasNoDefaultStyleEntry_ReturnsFalse() - { - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - // No StyleDefaults entry registered for NonCombatStyle. - var cmt = new CMotionTable(mt); - var state = new MotionState(); - var seq = new CSequence(new NullLoader()); - - bool ok = cmt.SetDefaultState(state, seq, out uint outTicks); - - Assert.False(ok); - Assert.Equal(0u, outTicks); - } - - // ── DoObjectMotion / StopObjectMotion thin-wrapper coverage ────────── - - [Fact] - public void DoObjectMotion_ForcesStopFlagFalse_DelegatesToGetObjectSequence() - { - var loader = new FakeLoader(); - uint animId = 0x03000110u; - loader.Register(animId, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(animId, 30f)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - var seq = new CSequence(loader); - - bool ok = cmt.DoObjectMotion(Ready, state, seq, 1f, out _); - - Assert.True(ok); - Assert.Equal(1, seq.Count); - } - - [Fact] - public void StopObjectMotion_DelegatesToStopSequenceMotion() - { - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - var jumpOmega = new Vector3(0, 0, 3f); - Fixtures.AddModifier(mt, NonCombatStyle, Jump, Fixtures.MakeMotionData(0, 0f, omega: jumpOmega)); - - var cmt = new CMotionTable(mt); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - state.AddModifierNoCheck(Jump, 1f); - var seq = new CSequence(new NullLoader()); - seq.CombinePhysics(Vector3.Zero, jumpOmega); - - bool ok = cmt.StopObjectMotion(Jump, 1f, state, seq, out _); - - Assert.True(ok); - Assert.Equal(Vector3.Zero, seq.Omega); - } -} - -/// In-memory that never resolves. -file sealed class NullLoader : IAnimationLoader -{ - public Animation? LoadAnimation(uint id) => null; -} - -/// In-memory test double. -file sealed class FakeLoader : IAnimationLoader -{ - private readonly System.Collections.Generic.Dictionary _anims = new(); - public void Register(uint id, Animation anim) => _anims[id] = anim; - public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null; -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/CSequencePhysicsTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/CSequencePhysicsTests.cs deleted file mode 100644 index a1d4c972..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/CSequencePhysicsTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -using System; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using DatReaderWriter.Types; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R1-P3 — CSequence::apply_physics (0x00524ab0) + -/// Frame::rotate/grotate (0x004525b0/0x005357a0) verbatim -/// (gap G7's math half). Oracle: r1-csequence-decomp.md §19 + raw decomp -/// reads of rotate/grotate this session. -/// -/// KEY SEMANTICS: -/// - apply_physics takes MAGNITUDE from arg3 and SIGN from arg4 -/// (copysign): origin += velocity·signed; rotate(omega·signed); -/// - rotate() maps the LOCAL rotation vector through the frame's -/// orientation to GLOBAL, then grotate premultiplies the axis-angle -/// quaternion (rotation applied in world space); -/// - grotate skips rotations with |v|² < F_EPSILON² (0.000199999995²). -/// -public class CSequencePhysicsTests -{ - private sealed class NullLoader : IAnimationLoader - { - public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null; - } - - private static void AssertVec(Vector3 expected, Vector3 actual, float tol = 1e-5f) - { - Assert.True((expected - actual).Length() < tol, - $"expected {expected}, got {actual}"); - } - - [Fact] - public void ApplyPhysics_PositiveSign_AddsVelocityTimesQuantum() - { - var seq = new CSequence(new NullLoader()); - seq.SetVelocity(new Vector3(2, 0, 0)); - var frame = new Frame { Origin = new Vector3(1, 1, 1), Orientation = Quaternion.Identity }; - - seq.ApplyPhysics(frame, quantum: 0.5, signSource: 1.0); - - AssertVec(new Vector3(2, 1, 1), frame.Origin); - } - - [Fact] - public void ApplyPhysics_CopySign_MagnitudeFromQuantum_SignFromSource() - { - // signed_quantum = copysign(fabs(quantum), sign_source): a NEGATIVE - // quantum with a POSITIVE sign source still moves forward; a - // negative sign source reverses. - var seq = new CSequence(new NullLoader()); - seq.SetVelocity(new Vector3(2, 0, 0)); - - var f1 = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; - seq.ApplyPhysics(f1, quantum: -0.5, signSource: 1.0); - AssertVec(new Vector3(1, 0, 0), f1.Origin); - - var f2 = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; - seq.ApplyPhysics(f2, quantum: 0.5, signSource: -1.0); - AssertVec(new Vector3(-1, 0, 0), f2.Origin); - } - - [Fact] - public void ApplyPhysics_OmegaRotatesFrame_GlobalZ() - { - // omega = (0,0,π) for 0.5s → 90° about global Z: local +X maps to +Y. - var seq = new CSequence(new NullLoader()); - seq.SetOmega(new Vector3(0, 0, MathF.PI)); - var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; - - seq.ApplyPhysics(frame, quantum: 0.5, signSource: 1.0); - - AssertVec(new Vector3(0, 1, 0), Vector3.Transform(Vector3.UnitX, frame.Orientation)); - } - - [Fact] - public void GRotate_ComposesInGlobalSpace() - { - // Frame already rotated 90° about Z; grotate 90° about GLOBAL X. - // local +X: q maps it to +Y; global X-rot then maps +Y to +Z. - var frame = new Frame - { - Origin = Vector3.Zero, - Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f), - }; - - FrameOps.GRotate(frame, new Vector3(MathF.PI / 2f, 0, 0)); - - AssertVec(new Vector3(0, 0, 1), Vector3.Transform(Vector3.UnitX, frame.Orientation)); - } - - [Fact] - public void Rotate_LocalVector_MappedThroughOrientation() - { - // Frame rotated 90° about Z: a LOCAL X-axis rotation is a GLOBAL - // Y-axis rotation. rotate(local πX/2) on this frame must equal - // grotate(global πY/2). - var q0 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f); - var viaLocal = new Frame { Origin = Vector3.Zero, Orientation = q0 }; - FrameOps.Rotate(viaLocal, new Vector3(MathF.PI / 2f, 0, 0)); - - var viaGlobal = new Frame { Origin = Vector3.Zero, Orientation = q0 }; - FrameOps.GRotate(viaGlobal, new Vector3(0, MathF.PI / 2f, 0)); - - AssertVec( - Vector3.Transform(Vector3.UnitX, viaGlobal.Orientation), - Vector3.Transform(Vector3.UnitX, viaLocal.Orientation)); - AssertVec( - Vector3.Transform(Vector3.UnitZ, viaGlobal.Orientation), - Vector3.Transform(Vector3.UnitZ, viaLocal.Orientation)); - } - - [Fact] - public void GRotate_TinyRotation_Skipped() - { - // |v|² < F_EPSILON² → no-op (0x005357a0 early return). - var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; - FrameOps.GRotate(frame, new Vector3(1e-5f, 0, 0)); - Assert.Equal(Quaternion.Identity, frame.Orientation); - } - - [Fact] - public void ApplyPhysics_ZeroPhysics_NoChange() - { - var seq = new CSequence(new NullLoader()); - var frame = new Frame - { - Origin = new Vector3(3, 4, 5), - Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitY, 0.3f), - }; - var beforeO = frame.Origin; - var beforeQ = frame.Orientation; - - seq.ApplyPhysics(frame, 1.0, 1.0); - - Assert.Equal(beforeO, frame.Origin); - Assert.Equal(beforeQ, frame.Orientation); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/CSequenceTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/CSequenceTests.cs deleted file mode 100644 index 62f66e60..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/CSequenceTests.cs +++ /dev/null @@ -1,323 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R1-P2 — verbatim CSequence container + list surgery (gap-map -/// G10/G11/G12/G14/G15/G20). Oracle: r1-csequence-decomp.md §1-§17, §20, -/// §24 (ctor 0x005249f0, clear 0x005255b0, clear_animations 0x00524dc0, -/// clear_physics 0x00524d50, remove_cyclic_anims 0x00524e40, -/// remove_link_animations 0x00524be0, remove_all_link_animations -/// 0x00524ca0, apricot 0x00524b40, append_animation 0x00525510, -/// set/combine/subtract physics 0x00524880-0x00524900, -/// multiply_cyclic_animation_fr 0x00524940, placement family -/// 0x00524970-0x005249d0). -/// -/// KEY RETAIL SEMANTICS UNDER TEST: -/// - append_animation slides first_cyclic to the JUST-APPENDED node on -/// EVERY call (G10) and seeds curr_anim=head + frame_number=starting -/// only when curr_anim was null; -/// - remove_cyclic_anims snaps a removed curr_anim BACK to the previous -/// node at its get_ending_frame() (or 0.0 when none); -/// - remove_link_animations snaps a removed curr_anim FORWARD to -/// first_cyclic at its get_starting_frame(); -/// - apricot trims consumed head nodes bounded by curr_anim AND -/// first_cyclic; -/// - clear (0x005255b0 raw body) resets placement_frame/id too — the -/// "2-instruction clear" note in the gap map was wrong, the raw body -/// is authority; -/// - physics accumulators live on the SEQUENCE (replace/combine/subtract); -/// - multiply_cyclic_animation_fr touches node framerates ONLY (G13). -/// -public class CSequenceTests -{ - private sealed class MapLoader : IAnimationLoader - { - private readonly Dictionary _map = new(); - public void Add(uint id, Animation anim) => _map[id] = anim; - public Animation? LoadAnimation(uint id) => _map.TryGetValue(id, out var a) ? a : null; - } - - private static Animation MakeAnim(int numFrames) - { - var anim = new Animation(); - for (int f = 0; f < numFrames; f++) - { - var pf = new AnimationFrame(1u); - pf.Frames.Add(new Frame { Origin = new Vector3(f, 0, 0), Orientation = Quaternion.Identity }); - anim.PartFrames.Add(pf); - } - return anim; - } - - private static AnimData Ad(uint animId, int low = 0, int high = -1, float framerate = 30f) - { - QualifiedDataId qid = animId; - return new AnimData { AnimId = qid, LowFrame = low, HighFrame = high, Framerate = framerate }; - } - - private static (CSequence seq, MapLoader loader) NewSeq(params (uint id, int frames)[] anims) - { - var loader = new MapLoader(); - foreach (var (id, frames) in anims) - loader.Add(id, MakeAnim(frames)); - return (new CSequence(loader), loader); - } - - // ── append_animation (G10) ────────────────────────────────────────── - - [Fact] - public void Append_First_SeedsCurrAnimAndFrameNumber() - { - var (seq, _) = NewSeq((1u, 10)); - seq.AppendAnimation(Ad(1u, low: 3)); - - Assert.Equal(1, seq.Count); - Assert.NotNull(seq.CurrAnim); - Assert.Same(seq.CurrAnim, seq.FirstCyclic); - Assert.Equal(3.0, seq.FrameNumber); // head.get_starting_frame() - } - - [Fact] - public void Append_SlidesFirstCyclicToNewTail_EveryCall() - { - var (seq, _) = NewSeq((1u, 10), (2u, 5), (3u, 4)); - seq.AppendAnimation(Ad(1u)); - seq.AppendAnimation(Ad(2u)); - seq.AppendAnimation(Ad(3u)); - - Assert.Equal(3, seq.Count); - Assert.Equal(4 - 1, seq.FirstCyclic!.HighFrame); // the LAST appended (anim 3, 4 frames) - Assert.Equal(10 - 1, seq.CurrAnim!.HighFrame); // curr stays at head (anim 1) - Assert.Equal(0.0, seq.FrameNumber); - } - - [Fact] - public void Append_UnresolvableAnim_Discarded() - { - var (seq, _) = NewSeq((1u, 10)); - seq.AppendAnimation(Ad(999u)); // not in loader - Assert.Equal(0, seq.Count); - Assert.Null(seq.CurrAnim); - Assert.False(seq.HasAnims()); - } - - // ── remove_cyclic_anims (G11) ─────────────────────────────────────── - - [Fact] - public void RemoveCyclicAnims_CurrOutsideTail_KeepsCurr_FirstCyclicToNewTail() - { - // A (link) + B (cycle): first_cyclic == B, curr == A (head). - var (seq, _) = NewSeq((1u, 10), (2u, 5)); - seq.AppendAnimation(Ad(1u)); - seq.AppendAnimation(Ad(2u)); - - seq.RemoveCyclicAnims(); - - Assert.Equal(1, seq.Count); // B deleted - Assert.Equal(9, seq.CurrAnim!.HighFrame); // still A - Assert.Same(seq.CurrAnim, seq.FirstCyclic); // first_cyclic = new tail (A) - } - - [Fact] - public void RemoveCyclicAnims_CurrInsideTail_SnapsBackToPrevAtEndingFrame() - { - var (seq, _) = NewSeq((1u, 10), (2u, 5)); - seq.AppendAnimation(Ad(1u)); - seq.AppendAnimation(Ad(2u)); - seq.SetCurrAnimForTest(1); // curr = B (index 1, inside cyclic tail) - - seq.RemoveCyclicAnims(); - - Assert.Equal(1, seq.Count); - Assert.Equal(9, seq.CurrAnim!.HighFrame); // snapped back to A - Assert.Equal(10.0, seq.FrameNumber); // A.get_ending_frame() = high+1 = 10 - } - - [Fact] - public void RemoveCyclicAnims_SingleNode_EmptiesAndZeroes() - { - var (seq, _) = NewSeq((1u, 10)); - seq.AppendAnimation(Ad(1u)); - - seq.RemoveCyclicAnims(); - - Assert.Equal(0, seq.Count); - Assert.Null(seq.CurrAnim); - Assert.Null(seq.FirstCyclic); - Assert.Equal(0.0, seq.FrameNumber); - } - - // ── remove_link_animations / remove_all_link_animations (G11) ────── - - [Fact] - public void RemoveLinkAnimations_RemovesPredecessorsOfFirstCyclic() - { - var (seq, _) = NewSeq((1u, 10), (2u, 5), (3u, 4)); - seq.AppendAnimation(Ad(1u)); // A - seq.AppendAnimation(Ad(2u)); // B - seq.AppendAnimation(Ad(3u)); // C = first_cyclic - - seq.RemoveLinkAnimations(1); // removes B (immediate predecessor) - - Assert.Equal(2, seq.Count); - Assert.Equal(9, seq.CurrAnim!.HighFrame); // A untouched (curr was A) - Assert.Equal(3, seq.FirstCyclic!.HighFrame); // C untouched - } - - [Fact] - public void RemoveLinkAnimations_CurrRemoved_SnapsForwardToFirstCyclicStart() - { - var (seq, _) = NewSeq((1u, 10), (2u, 5)); - seq.AppendAnimation(Ad(1u)); // A (curr) - seq.AppendAnimation(Ad(2u)); // B = first_cyclic - - seq.RemoveLinkAnimations(1); // removes A == curr - - Assert.Equal(1, seq.Count); - Assert.Same(seq.FirstCyclic, seq.CurrAnim); - Assert.Equal(0.0, seq.FrameNumber); // B.get_starting_frame() = low = 0 - } - - [Fact] - public void RemoveAllLinkAnimations_RemovesEverythingBeforeFirstCyclic() - { - var (seq, _) = NewSeq((1u, 10), (2u, 5), (3u, 4)); - seq.AppendAnimation(Ad(1u)); - seq.AppendAnimation(Ad(2u)); - seq.AppendAnimation(Ad(3u)); // first_cyclic - - seq.RemoveAllLinkAnimations(); - - Assert.Equal(1, seq.Count); - Assert.Same(seq.FirstCyclic, seq.CurrAnim); - Assert.Equal(3, seq.CurrAnim!.HighFrame); - } - - // ── apricot (G11/G19) ─────────────────────────────────────────────── - - [Fact] - public void Apricot_HeadIsCurr_NoOp() - { - var (seq, _) = NewSeq((1u, 10), (2u, 5)); - seq.AppendAnimation(Ad(1u)); - seq.AppendAnimation(Ad(2u)); - - seq.Apricot(); - - Assert.Equal(2, seq.Count); - } - - [Fact] - public void Apricot_TrimsConsumedHeads_StopsAtCurr() - { - var (seq, _) = NewSeq((1u, 10), (2u, 5), (3u, 4)); - seq.AppendAnimation(Ad(1u)); // A (consumed) - seq.AppendAnimation(Ad(2u)); // B (curr) - seq.AppendAnimation(Ad(3u)); // C = first_cyclic - seq.SetCurrAnimForTest(1); // curr = B - - seq.Apricot(); - - Assert.Equal(2, seq.Count); // A trimmed - Assert.Equal(4, seq.CurrAnim!.HighFrame); // B still curr - } - - [Fact] - public void Apricot_BoundedByFirstCyclic_EvenIfCurrBeyond() - { - var (seq, _) = NewSeq((1u, 10), (2u, 5), (3u, 4)); - seq.AppendAnimation(Ad(1u)); // A - seq.AppendAnimation(Ad(2u)); // B - seq.AppendAnimation(Ad(3u)); // C = first_cyclic = curr target - seq.SetCurrAnimForTest(2); // curr = C - - seq.Apricot(); - - Assert.Equal(1, seq.Count); // A and B trimmed; stops at first_cyclic - Assert.Equal(3, seq.CurrAnim!.HighFrame); - } - - // ── physics accumulators (G12) ────────────────────────────────────── - - [Fact] - public void Physics_SetCombineSubtract() - { - var (seq, _) = NewSeq(); - seq.SetVelocity(new Vector3(1, 2, 3)); - seq.SetOmega(new Vector3(0, 0, 1)); - seq.CombinePhysics(new Vector3(1, 0, 0), new Vector3(0, 0, 0.5f)); - Assert.Equal(new Vector3(2, 2, 3), seq.Velocity); - Assert.Equal(new Vector3(0, 0, 1.5f), seq.Omega); - seq.SubtractPhysics(new Vector3(2, 2, 3), new Vector3(0, 0, 1.5f)); - Assert.Equal(Vector3.Zero, seq.Velocity); - Assert.Equal(Vector3.Zero, seq.Omega); - } - - // ── multiply_cyclic_animation_fr (G13) ────────────────────────────── - - [Fact] - public void MultiplyCyclicFramerate_TouchesOnlyCyclicTailFramerates() - { - var (seq, _) = NewSeq((1u, 10), (2u, 5)); - seq.AppendAnimation(Ad(1u, framerate: 30f)); // A (link, pre-cyclic after next append) - seq.AppendAnimation(Ad(2u, framerate: 30f)); // B = first_cyclic - - seq.MultiplyCyclicAnimationFramerate(2f); - - Assert.Equal(30f, seq.CurrAnim!.Framerate); // A untouched - Assert.Equal(60f, seq.FirstCyclic!.Framerate); // B scaled - // Velocity/Omega untouched (retail scales those via - // subtract/combine_motion in R2, never here). - Assert.Equal(Vector3.Zero, seq.Velocity); - } - - // ── clear family (G20 corrected) ──────────────────────────────────── - - [Fact] - public void Clear_WipesAnimsPhysicsAndPlacement() - { - var (seq, _) = NewSeq((1u, 10)); - seq.AppendAnimation(Ad(1u)); - seq.SetVelocity(new Vector3(1, 1, 1)); - var pf = new AnimationFrame(1u); - seq.SetPlacementFrame(pf, 0x65u); - - seq.Clear(); - - Assert.Equal(0, seq.Count); - Assert.Null(seq.CurrAnim); - Assert.Equal(0.0, seq.FrameNumber); - Assert.Equal(Vector3.Zero, seq.Velocity); - Assert.Null(seq.PlacementFrame); // raw 0x005255b0 resets placement too - Assert.Equal(0u, seq.PlacementFrameId); - } - - // ── placement + accessors (G14) ───────────────────────────────────── - - [Fact] - public void GetCurrAnimframe_PlacementFallback_WhenNoCurrAnim() - { - var (seq, _) = NewSeq(); - var pf = new AnimationFrame(1u); - seq.SetPlacementFrame(pf, 0x65u); - Assert.Same(pf, seq.GetCurrAnimframe()); - } - - [Fact] - public void GetCurrAnimframe_FlooredFrameLookup() - { - var (seq, _) = NewSeq((1u, 10)); - seq.AppendAnimation(Ad(1u)); - seq.FrameNumber = 2.9; - var frame = seq.GetCurrAnimframe(); - Assert.NotNull(frame); - Assert.Equal(2f, frame!.Frames[0].Origin.X); // frame index 2 - - Assert.Equal(2, seq.GetCurrFrameNumber()); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs deleted file mode 100644 index 4b486bb1..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/CSequenceUpdateTests.cs +++ /dev/null @@ -1,269 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Enums; -using DatReaderWriter.Types; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R1-P4 — the frame-advance core: update_internal (0x005255d0), -/// update (0x00525b80), advance_to_next_animation -/// (0x005252b0), execute_hooks (0x00524830) — gaps -/// G3/G4/G5/G6/G8/G9/G19. -/// -/// Skeleton per the ACE-verified structure (P0-pins.md): overshoot → -/// clamp to the RAW high/low frame + leftover carry + animDone → -/// per-crossed-frame pose/physics/hook loop → AnimDone gate -/// (list HEAD != first_cyclic) → advance (pose-out both directions, -/// forward wraps to first_cyclic, reverse wraps to the LIST TAIL) → -/// carry the leftover (P0 pin) → loop. NO safety cap, NO boundary -/// epsilon. -/// -public class CSequenceUpdateTests -{ - private sealed class MapLoader : IAnimationLoader - { - private readonly Dictionary _map = new(); - public void Add(uint id, Animation anim) => _map[id] = anim; - public Animation? LoadAnimation(uint id) => _map.TryGetValue(id, out var a) ? a : null; - } - - private sealed class RecordingHookQueue : IAnimHookQueue - { - public readonly List Events = new(); - public void AddAnimHook(AnimationHook hook) - => Events.Add($"hook:{((TaggedHook)hook).Tag}:{hook.Direction}"); - public void AddAnimDoneHook() => Events.Add("ANIMDONE"); - } - - /// Hook subclass carrying a test tag (frame index). - private sealed class TaggedHook : AnimationHook - { - public string Tag = ""; - public override AnimationHookType HookType => (AnimationHookType)0; - } - - /// numFrames frames; one Forward-direction tagged hook per frame. - private static Animation MakeAnim(int numFrames, bool posFrames = false, string hookPrefix = "f") - { - var anim = new Animation(); - for (int f = 0; f < numFrames; f++) - { - var pf = new AnimationFrame(1u); - pf.Frames.Add(new Frame { Origin = new Vector3(f, 0, 0), Orientation = Quaternion.Identity }); - pf.Hooks.Add(new TaggedHook { Tag = $"{hookPrefix}{f}", Direction = AnimationHookDir.Both }); - anim.PartFrames.Add(pf); - if (posFrames) - anim.PosFrames.Add(new Frame { Origin = new Vector3(1, 0, 0), Orientation = Quaternion.Identity }); - } - return anim; - } - - private static AnimData Ad(uint animId, float framerate = 30f, int low = 0, int high = -1) - { - QualifiedDataId qid = animId; - return new AnimData { AnimId = qid, LowFrame = low, HighFrame = high, Framerate = framerate }; - } - - private static (CSequence seq, RecordingHookQueue hooks) Cyclic(int frames, float framerate = 30f) - { - var loader = new MapLoader(); - loader.Add(1u, MakeAnim(frames)); - var seq = new CSequence(loader); - var hooks = new RecordingHookQueue(); - seq.HookObj = hooks; - seq.AppendAnimation(Ad(1u, framerate)); - return (seq, hooks); - } - - // ── forward advance basics (G3) ───────────────────────────────────── - - [Fact] - public void Forward_SingleTick_AdvancesOneFrame_FiresExitedFrameHook() - { - var (seq, hooks) = Cyclic(5); - - seq.Update(1.0 / 30.0, null); - - Assert.Equal(1.0, seq.FrameNumber, 9); - Assert.Equal(new[] { "hook:f0:Both" }, hooks.Events); - } - - [Fact] - public void Forward_ExactBoundaryLanding_NoAdvance() - { - // Boundary test is STRICT (>): landing exactly at high_frame+0.0 - // integer positions must NOT advance (the G3 bug class: acdream's - // old epsilon-shifted boundary reclassified these). - var (seq, hooks) = Cyclic(5); - seq.FrameNumber = 3.5; - - seq.Update(0.5 / 30.0, null); // lands exactly at 4.0 == high_frame - - Assert.Equal(4.0, seq.FrameNumber, 9); - Assert.Equal(new[] { "hook:f3:Both" }, hooks.Events); // crossed 3→4 - Assert.DoesNotContain("ANIMDONE", hooks.Events); - } - - [Fact] - public void Forward_CyclicWrap_NoAnimDone_RestartsAtStartingFrame() - { - // Single cyclic node: overshoot wraps to itself via first_cyclic; - // head == first_cyclic → NO AnimDoneHook (G5's structural gate). - var (seq, hooks) = Cyclic(3); - seq.FrameNumber = 2.0; - - seq.Update(1.0 / 30.0, null); // 2→3 > high(2) → clamp, advance, wrap - - Assert.Equal(0.0, seq.FrameNumber, 9); - Assert.DoesNotContain("ANIMDONE", hooks.Events); - Assert.Same(seq.FirstCyclic, seq.CurrAnim); - } - - // ── multi-node fast-forward + the P0-pinned carry (G3/G5) ─────────── - - [Fact] - public void Forward_LinkToCycle_FiresAnimDone_AndCarriesLeftover() - { - // link (2 frames) + cycle (4 frames): a 0.1s tick (3 frames at - // 30fps) exhausts the link (2 frames) and carries 1 frame of time - // into the cycle. Retail order: link's crossed-frame hooks → - // AnimDoneHook (head != first_cyclic) → cycle's crossed hooks. - var loader = new MapLoader(); - loader.Add(1u, MakeAnim(2, hookPrefix: "link")); - loader.Add(2u, MakeAnim(4, hookPrefix: "cyc")); - var seq = new CSequence(loader); - var hooks = new RecordingHookQueue(); - seq.HookObj = hooks; - seq.AppendAnimation(Ad(1u)); // link — first_cyclic slides… - seq.AppendAnimation(Ad(2u)); // …to the cycle - - seq.Update(0.1, null); // 3 frames of time - - // link: FrameNumber 0→3, clamp to high(1), leftover = 1 frame; - // crossing fires link f0 only (floor(1)>0), then AnimDone, then - // advance → cycle at starting(0); carry 1/30 → cycle 0→1 fires cyc f0. - Assert.Equal(new[] { "hook:link0:Both", "ANIMDONE", "hook:cyc0:Both" }, hooks.Events); - Assert.Equal(1.0, seq.FrameNumber, 6); // the carry (P0 pin) — zeroing would leave 0.0 - Assert.Same(seq.FirstCyclic, seq.CurrAnim); // now inside the cycle - } - - // ── reverse playback (G3/G9) ──────────────────────────────────────── - - [Fact] - public void Reverse_DescendingHooks_BackwardDirection() - { - // Natively-reverse node (framerate −30): seeded at starting = - // high+1 = 5. Each tick fires the hook of the frame being LEFT - // (the pre-tick floor index), descending; the very first tick's - // index (5 = high+1) is OOB → null part frame → no hook, exactly - // the retail structure. - var (seq, hooks) = Cyclic(5, framerate: -30f); - Assert.Equal(5.0, seq.FrameNumber, 9); // get_starting_frame = high+1 - - seq.Update(1.0 / 30.0, null); // 5→4 (leaves OOB index 5 — nothing) - seq.Update(1.0 / 30.0, null); // 4→3 (leaves frame 4) - seq.Update(1.0 / 30.0, null); // 3→2 (leaves frame 3) - - Assert.Equal(2.0, seq.FrameNumber, 9); - Assert.Equal(new[] { "hook:f4:Both", "hook:f3:Both" }, hooks.Events); - } - - [Fact] - public void Reverse_AdvanceWrapsToListTail() - { - // Two nodes A(reverse),B(forward): exhausting A backward wraps - // curr_anim to the LIST TAIL (B) — retail's asymmetric wrap (G9). - var loader = new MapLoader(); - loader.Add(1u, MakeAnim(3, hookPrefix: "a")); - loader.Add(2u, MakeAnim(4, hookPrefix: "b")); - var seq = new CSequence(loader); - var hooks = new RecordingHookQueue(); - seq.HookObj = hooks; - seq.AppendAnimation(Ad(1u, framerate: -30f)); // A — curr seeds here - seq.AppendAnimation(Ad(2u)); // B = first_cyclic = tail - seq.FrameNumber = 0.5; // mid-window of A - - seq.Update(0.02, null); // frametime = −0.6 → crosses low(0) - - // After the reverse advance, curr must have left A via the TAIL - // wrap (B), whatever the carry then does within B. - Assert.NotNull(seq.CurrAnim); - Assert.Equal(3, seq.CurrAnim!.HighFrame); // B (4 frames → high 3) - } - - // ── stationary / degenerate (G8 + else-branch) ────────────────────── - - [Fact] - public void ZeroFramerate_PhysicsOnlyAndReturn() - { - var (seq, hooks) = Cyclic(3, framerate: 0f); - seq.SetVelocity(new Vector3(2, 0, 0)); - var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; - - seq.Update(0.5, frame); - - // frametime == 0 → apply_physics(frame, elapsed, elapsed) then return. - Assert.Equal(1.0f, frame.Origin.X, 5); - Assert.Empty(hooks.Events); - Assert.Equal(0.0, seq.FrameNumber, 9); - } - - [Fact] - public void EmptyList_Update_AppliesAccumulatedPhysics() - { - // update (0x00525b80): empty anim_list + frame != null → - // apply_physics(frame, elapsed, elapsed) — free-fall/knockback - // with no animation (G8). - var seq = new CSequence(new MapLoader()); - seq.SetVelocity(new Vector3(0, 3, 0)); - var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; - - seq.Update(2.0, frame); - - Assert.Equal(6.0f, frame.Origin.Y, 5); - } - - // ── root motion into the caller Frame (G7 wiring half) ────────────── - - [Fact] - public void Forward_PosFrames_CombinedIntoCallerFrame() - { - // Each crossed frame combines the node's pos_frame into the caller - // Frame (retail root motion): 2 crossed frames → +2 X. - var loader = new MapLoader(); - loader.Add(1u, MakeAnim(5, posFrames: true)); - var seq = new CSequence(loader); - seq.AppendAnimation(Ad(1u)); - var frame = new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }; - - seq.Update(2.0 / 30.0, frame); // crosses frames 0 and 1 - - Assert.Equal(2.0f, frame.Origin.X, 5); - Assert.Equal(2.0, seq.FrameNumber, 9); - } - - // ── update entry contract (G19) ───────────────────────────────────── - - [Fact] - public void Update_RunsApricot_TrimmingConsumedLinkNodes() - { - // After the link→cycle advance, apricot (called by update) frees - // the consumed link node. - var loader = new MapLoader(); - loader.Add(1u, MakeAnim(2, hookPrefix: "link")); - loader.Add(2u, MakeAnim(4, hookPrefix: "cyc")); - var seq = new CSequence(loader); - seq.AppendAnimation(Ad(1u)); - seq.AppendAnimation(Ad(2u)); - Assert.Equal(2, seq.Count); - - seq.Update(0.1, null); // exhausts the link, advances into the cycle - - Assert.Equal(1, seq.Count); // link trimmed by apricot - Assert.Same(seq.FirstCyclic, seq.CurrAnim); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/ConstraintManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/ConstraintManagerTests.cs deleted file mode 100644 index 2e844c60..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/ConstraintManagerTests.cs +++ /dev/null @@ -1,133 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R5 conformance — (retail 0x00556090-…). -/// The leash-band taper + the 90 % IsFullyConstrained jump gate (port-plan §2b/§2c). -/// -public sealed class ConstraintManagerTests -{ - private static (R5Host host, ConstraintManager cm) Setup() - { - var world = new Dictionary(); - var host = new R5Host(10u, world); - return (host, new ConstraintManager(host)); - } - - private static Position Anchor(float x) => new(1u, new Vector3(x, 0f, 0f), Quaternion.Identity); - - [Fact] - public void ConstrainTo_InitializesOffsetToCurrentDistanceFromAnchor() - { - var (host, cm) = Setup(); - host.SetOrigin(Vector3.Zero); - - cm.ConstrainTo(Anchor(5f), startDistance: 2f, maxDistance: 10f); - - Assert.True(cm.IsConstrained); - Assert.Equal(5f, cm.ConstraintPosOffset, 3); // distance(anchor(5,0,0), self(0,0,0)) - } - - [Fact] - public void IsFullyConstrained_TrueOnlyBeyond90PercentOfMax() - { - var (host, cm) = Setup(); - host.SetOrigin(Vector3.Zero); - - cm.ConstrainTo(Anchor(8f), 2f, 10f); // offset 8, 90% of 10 = 9 → 8 < 9 - Assert.False(cm.IsFullyConstrained()); - - cm.ConstrainTo(Anchor(9.5f), 2f, 10f); // offset 9.5 > 9 - Assert.True(cm.IsFullyConstrained()); - } - - [Fact] - public void IsFullyConstrained_FalseWhenNotConstrainedYet() - { - var (_, cm) = Setup(); - Assert.False(cm.IsFullyConstrained()); // max 0 → 0 < 0 is false - } - - [Fact] - public void AdjustOffset_InBand_AppliesLinearTaper() - { - var (host, cm) = Setup(); - host.SetOrigin(Vector3.Zero); - host.InContact = true; - cm.ConstrainTo(Anchor(5f), startDistance: 2f, maxDistance: 10f); // offset 5 in (2,10) - - var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) }; - cm.AdjustOffset(frame, 0.1); - - // taper = (10-5)/(10-2) = 5/8 = 0.625. - Assert.Equal(0.625f, frame.Origin.X, 3); - Assert.Equal(0.625f, cm.ConstraintPosOffset, 3); // recomputed = |offset| - } - - [Fact] - public void AdjustOffset_PastMax_HardClampsToZero() - { - var (host, cm) = Setup(); - host.SetOrigin(Vector3.Zero); - host.InContact = true; - cm.ConstrainTo(Anchor(20f), 2f, 10f); // offset 20 >= max 10 - - var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) }; - cm.AdjustOffset(frame, 0.1); - - Assert.Equal(Vector3.Zero, frame.Origin); - } - - [Fact] - public void AdjustOffset_BelowStart_PassesThrough() - { - var (host, cm) = Setup(); - host.SetOrigin(Vector3.Zero); - host.InContact = true; - cm.ConstrainTo(Anchor(1f), startDistance: 2f, maxDistance: 10f); // offset 1 < start 2 - - var frame = new MotionDeltaFrame { Origin = new Vector3(1f, 0f, 0f) }; - cm.AdjustOffset(frame, 0.1); - - Assert.Equal(1f, frame.Origin.X, 3); // unscaled - Assert.Equal(1f, cm.ConstraintPosOffset, 3); - } - - [Fact] - public void AdjustOffset_Airborne_SkipsClampButStillTracksLength() - { - var (host, cm) = Setup(); - host.SetOrigin(Vector3.Zero); - host.InContact = false; // airborne - cm.ConstrainTo(Anchor(20f), 2f, 10f); // would clamp if grounded - - var frame = new MotionDeltaFrame { Origin = new Vector3(3f, 4f, 0f) }; - cm.AdjustOffset(frame, 0.1); - - Assert.Equal(new Vector3(3f, 4f, 0f), frame.Origin); // untouched while airborne - Assert.Equal(5f, cm.ConstraintPosOffset, 3); // |(3,4,0)| = 5, still updated - } - - [Fact] - public void AdjustOffset_NotConstrained_IsNoOp() - { - var (host, cm) = Setup(); - var frame = new MotionDeltaFrame { Origin = new Vector3(7f, 0f, 0f) }; - cm.AdjustOffset(frame, 0.1); - Assert.Equal(new Vector3(7f, 0f, 0f), frame.Origin); - } - - [Fact] - public void UnConstrain_ClearsFlag() - { - var (host, cm) = Setup(); - cm.ConstrainTo(Anchor(5f), 2f, 10f); - cm.UnConstrain(); - Assert.False(cm.IsConstrained); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/InterpretedMotionStateActionFifoTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/InterpretedMotionStateActionFifoTests.cs deleted file mode 100644 index 68420929..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/InterpretedMotionStateActionFifoTests.cs +++ /dev/null @@ -1,217 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R3-W1 — action FIFO discipline + ApplyMotion/RemoveMotion -/// field effects on (closes J2). Oracle: -/// docs/research/named-retail/acclient_2013_pseudo_c.txt — verbatim bodies -/// quoted in MotionInterpreter.cs doc comments: -/// InterpretedMotionState::AddAction (0x0051e9e0), RemoveAction -/// (0x0051ead0), GetNumActions (0x0051eb00), ApplyMotion -/// (0x0051ea40), RemoveMotion (0x0051e790). -/// -public sealed class InterpretedMotionStateActionFifoTests -{ - [Fact] - public void Default_HasEmptyActionsAndZeroCount() - { - var ims = InterpretedMotionState.Default(); - Assert.Empty(ims.Actions); - Assert.Equal(0u, ims.GetNumActions()); - } - - // ── AddAction / RemoveAction / GetNumActions FIFO discipline ────────── - - [Fact] - public void AddAction_AppendsInOrder_GetNumActionsCounts() - { - var ims = InterpretedMotionState.Default(); - ims.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false); - ims.AddAction(0x10000050u, 1.5f, 2, autonomous: true); - - Assert.Equal(2u, ims.GetNumActions()); - Assert.Equal((ushort)0x004Bu, ims.Actions[0].Command); - Assert.Equal((ushort)0x0050u, ims.Actions[1].Command); - } - - [Fact] - public void RemoveAction_PopsHeadFirst_FifoOrder() - { - var ims = InterpretedMotionState.Default(); - ims.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false); - ims.AddAction(0x10000050u, 1.5f, 2, autonomous: true); - - uint first = ims.RemoveAction(); - uint second = ims.RemoveAction(); - - Assert.Equal(0x004Bu, first); - Assert.Equal(0x0050u, second); - Assert.Equal(0u, ims.GetNumActions()); - } - - [Fact] - public void RemoveAction_Empty_ReturnsZero() - { - var ims = InterpretedMotionState.Default(); - Assert.Equal(0u, ims.RemoveAction()); - } - - [Fact] - public void GetNumActions_DefaultStruct_NoNullRef() - { - // Defensive: a bare default(InterpretedMotionState) (bypassing - // .Default()) must not NRE on GetNumActions/Actions/RemoveAction — - // the lazy-list field starts null. - InterpretedMotionState bare = default; - Assert.Equal(0u, bare.GetNumActions()); - Assert.Empty(bare.Actions); - Assert.Equal(0u, bare.RemoveAction()); - } - - // ── DoMotion's depth-cap gate consumes this directly (documentation) ── - - [Fact] - public void GetNumActions_SixQueued_MeetsDoMotionDepthCapThreshold() - { - // DoMotion (0x00528d20 @306159) rejects with 0x45 when an - // action-class motion arrives AND GetNumActions() >= 6. W1 only - // proves the counter is correct; the gate itself lands in W5. - var ims = InterpretedMotionState.Default(); - for (uint i = 0; i < 6; i++) - ims.AddAction(0x10000000u + i, 1.0f, i, false); - - Assert.Equal(6u, ims.GetNumActions()); - Assert.True(ims.GetNumActions() >= 6); - } - - // ── ApplyMotion field effects (0x0051ea40) ───────────────────────────── - - [Fact] - public void ApplyMotion_TurnRight_SetsTurnCommandAndSpeed() - { - var ims = InterpretedMotionState.Default(); - var p = new MovementParameters { Speed = 1.5f }; - - ims.ApplyMotion(0x6500000du, p); - - Assert.Equal(0x6500000du, ims.TurnCommand); - Assert.Equal(1.5f, ims.TurnSpeed); - } - - [Fact] - public void ApplyMotion_SideStepRight_SetsSideStepCommandAndSpeed() - { - var ims = InterpretedMotionState.Default(); - var p = new MovementParameters { Speed = 1.248f }; - - ims.ApplyMotion(0x6500000fu, p); - - Assert.Equal(0x6500000fu, ims.SideStepCommand); - Assert.Equal(1.248f, ims.SideStepSpeed); - } - - [Fact] - public void ApplyMotion_ForwardClassMotion_SetsForwardCommandAndSpeed() - { - // Unlike RawMotionState::ApplyMotion, InterpretedMotionState's - // forward-class branch has NO RunForward exclusion — every - // forward-class id (bit 0x40000000) writes forward_command. - var ims = InterpretedMotionState.Default(); - var p = new MovementParameters { Speed = 2.94f }; - - ims.ApplyMotion(0x44000007u, p); // RunForward - - Assert.Equal(0x44000007u, ims.ForwardCommand); - Assert.Equal(2.94f, ims.ForwardSpeed); - } - - [Fact] - public void ApplyMotion_StyleClassMotion_ResetsForwardToReady_SetsCurrentStyle() - { - var ims = InterpretedMotionState.Default(); - ims.ForwardCommand = 0x45000005u; - var p = new MovementParameters(); - - ims.ApplyMotion(0x80000042u, p); - - Assert.Equal(0x41000003u, ims.ForwardCommand); - Assert.Equal(0x80000042u, ims.CurrentStyle); - } - - [Fact] - public void ApplyMotion_ActionClassMotion_AddsAction() - { - var ims = InterpretedMotionState.Default(); - var p = new MovementParameters { Speed = 1.0f, ActionStamp = 7u, Autonomous = true }; - - ims.ApplyMotion(0x1000004Bu, p); - - Assert.Equal(1u, ims.GetNumActions()); - var a = ims.Actions[0]; - Assert.Equal((ushort)0x004Bu, a.Command); - Assert.Equal(1.0f, a.Speed); - Assert.Equal((ushort)7u, a.Stamp); - Assert.True(a.Autonomous); - } - - // ── RemoveMotion field effects (0x0051e790) ──────────────────────────── - - [Fact] - public void RemoveMotion_TurnRightExact_ClearsTurnCommand() - { - var ims = InterpretedMotionState.Default(); - ims.TurnCommand = 0x6500000du; - ims.RemoveMotion(0x6500000du); - Assert.Equal(0u, ims.TurnCommand); - } - - [Fact] - public void RemoveMotion_TurnLeftExact_DoesNotMatchTurnBranch_FallsThroughToStyleCheck() - { - // Asymmetric vs RawMotionState::RemoveMotion: InterpretedMotionState - // only exact-matches 0x6500000d (TurnRight) for the turn branch, NOT - // 0x6500000e (TurnLeft) — verbatim per the raw decomp. - var ims = InterpretedMotionState.Default(); - ims.TurnCommand = 0x6500000eu; - ims.RemoveMotion(0x6500000eu); - // Falls through: bit 0x40000000 clear, arg2 (0x6500000e) is not - // negative and != current_style (0x8000003D default) -> no-op. - Assert.Equal(0x6500000eu, ims.TurnCommand); // untouched - } - - [Fact] - public void RemoveMotion_SideStepRightExact_ClearsSideStepCommand() - { - var ims = InterpretedMotionState.Default(); - ims.SideStepCommand = 0x6500000fu; - ims.RemoveMotion(0x6500000fu); - Assert.Equal(0u, ims.SideStepCommand); - } - - [Fact] - public void RemoveMotion_ForwardClassMotion_MatchingCommand_ResetsToReady() - { - var ims = InterpretedMotionState.Default(); - ims.ForwardCommand = 0x45000005u; - ims.ForwardSpeed = 3.0f; - - ims.RemoveMotion(0x45000005u); - - Assert.Equal(0x41000003u, ims.ForwardCommand); - Assert.Equal(1f, ims.ForwardSpeed); - } - - [Fact] - public void RemoveMotion_StyleClassMotion_MatchingCurrentStyle_ResetsToNonCombat() - { - var ims = InterpretedMotionState.Default(); - ims.CurrentStyle = 0x80000042u; - - ims.RemoveMotion(0x80000042u); - - Assert.Equal(0x8000003du, ims.CurrentStyle); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/Issue174LinkStripDrainTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/Issue174LinkStripDrainTests.cs deleted file mode 100644 index dadba9a3..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/Issue174LinkStripDrainTests.cs +++ /dev/null @@ -1,97 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; -using Xunit.Abstractions; - -namespace AcDream.Core.Tests.Physics.Motion; - -// ───────────────────────────────────────────────────────────────────────────── -// #174 pin (2026-07-05): the RemoveLinkAnimations seam must be retail -// CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a TAILCALL to -// CPartArray::HandleEnterWorld 0x00517d70 → MotionTableManager:: -// HandleEnterWorld 0x0051bdd0: CSequence::remove_all_link_animations PLUS a -// full pending_animations drain (`while (head) AnimationDone(0)`), each pop -// relaying MotionDone → CMotionInterp pops its pending_motions node in -// lockstep. -// -// The pre-fix binding was the bare sequence strip: every LeaveGround (jump) -// removed the link animations that queued MotionTableManager nodes were -// counting down on, orphaning them (NumAnims > 0, animations gone). Both -// queues then dammed permanently — MotionsPending() never drained at rest — -// and BeginTurnToHeading/BeginMoveForward (retail 0x00529b90 motions_pending -// gate) starved every armed moveto: ACE's walk-to-door mt-6 armed but the -// body never walked; the close-range Use turn never completed so the -// deferred action was silently eaten. Live evidence: launch-174-autowalk.log -// (last player pending=False at the first MovementJump press; old jump -// motions still completing at rest minutes later). -// ───────────────────────────────────────────────────────────────────────────── -public class Issue174LinkStripDrainTests -{ - private readonly ITestOutputHelper _out; - public Issue174LinkStripDrainTests(ITestOutputHelper output) => _out = output; - - /// - /// The jam repro: queue motions (link + cycle nodes land in BOTH the - /// interp's pending_motions and the manager's pending_animations), then - /// fire the LeaveGround-side seam. With the retail HandleEnterWorld - /// binding both queues drain to empty; the pre-fix bare-strip binding - /// left both non-empty forever. - /// - [Fact] - public void RemoveLinkAnimationsSeam_DrainsBothQueues() - { - var h = new RemoteChaseHarness(_out); - - // Drive a motion burst — walk, run, stop — the shape a player's - // pre-jump input produces. Each successful dispatch pairs an interp - // node with a manager node. - var p = new MovementParameters(); - h.Interp.DoMotion(MotionCommand.WalkForward, p); - h.Interp.set_hold_run(true, interrupt: false); - h.Interp.StopMotion(MotionCommand.WalkForward, p); - - Assert.True(h.Interp.MotionsPending(), - "precondition: the burst must leave pending interp nodes"); - Assert.NotEmpty(h.Seq.Manager.PendingAnimations); - - // The LeaveGround seam (retail CMotionInterp::LeaveGround 0x00528b00 - // fires CPhysicsObj::RemoveLinkAnimations). - h.Interp.RemoveLinkAnimations!.Invoke(); - - Assert.False(h.Interp.MotionsPending(), - "HandleEnterWorld's drain must pop every pending interp node " + - "(retail: each AnimationDone(0) relays MotionDone)"); - Assert.Empty(h.Seq.Manager.PendingAnimations); - } - - /// - /// The post-jump livability pin: after the seam fires mid-activity, a - /// NEW moveto-style dispatch must be able to queue and complete — the - /// #174 symptom was that BeginTurnToHeading's motions_pending gate never - /// re-opened after a jump, permanently starving armed movetos. - /// - [Fact] - public void AfterSeamDrain_NewMotionsQueueAndComplete() - { - var h = new RemoteChaseHarness(_out); - var p = new MovementParameters(); - - // Pre-jump activity, then the jump's LeaveGround strip+drain. - h.Interp.DoMotion(MotionCommand.WalkForward, p); - h.Interp.RemoveLinkAnimations!.Invoke(); - Assert.False(h.Interp.MotionsPending()); - - // A fresh dispatch (the armed moveto's turn) queues... - h.Interp.DoMotion(MotionCommand.TurnRight, p); - Assert.True(h.Interp.MotionsPending()); - - // ...and the normal completion path (the manager's queue feeding - // MotionDone) drains it — the gate re-opens. - while (h.Seq.Manager.PendingAnimations.GetEnumerator() is var e && e.MoveNext()) - h.Seq.Manager.AnimationDone(success: true); - - h.Seq.Manager.CheckForCompletedMotions(); - Assert.False(h.Interp.MotionsPending(), - "the normal AnimationDone → MotionDone chain must drain the new node"); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionNodeTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionNodeTests.cs deleted file mode 100644 index 78fa2bcf..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MotionNodeTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R3-W1 — CMotionInterp::MotionNode shape pin. Oracle: -/// acclient.h:53293 (struct #5857). W2 consumes this as the -/// pending_motions element type; W1 only ports the shape. -/// -public sealed class MotionNodeTests -{ - [Fact] - public void Ctor_StoresAllThreeFields() - { - var node = new MotionNode(ContextId: 7u, Motion: 0x41000003u, JumpErrorCode: 0x48u); - - Assert.Equal(7u, node.ContextId); - Assert.Equal(0x41000003u, node.Motion); - Assert.Equal(0x48u, node.JumpErrorCode); - } - - [Fact] - public void Equality_IsValueBased() - { - var a = new MotionNode(1u, 2u, 3u); - var b = new MotionNode(1u, 2u, 3u); - var c = new MotionNode(1u, 2u, 4u); - - Assert.Equal(a, b); - Assert.NotEqual(a, c); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionStateTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionStateTests.cs deleted file mode 100644 index 41c51f98..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MotionStateTests.cs +++ /dev/null @@ -1,145 +0,0 @@ -using System.Linq; -using AcDream.Core.Physics.Motion; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R2-Q1 — verbatim MotionState (gap H2). Oracle: -/// r2-motiontable-decomp.md §13 (ctor 0x00525fd0, add_modifier_no_check -/// 0x00525ff0, add_modifier 0x00526340, remove_modifier 0x00526040, -/// clear_modifiers 0x00526070, add_action 0x005260a0, clear_actions -/// 0x005260f0, remove_action_head 0x00526120) + §14 (one node struct, -/// two independent chains: modifier PUSH-FRONT STACK vs action TAIL-APPEND -/// FIFO) + Q0-pins A4-#5 (deep-copy ctor clones both chains — re_modify's -/// snapshot is a termination bound, never shared state). -/// -public class MotionStateTests -{ - [Fact] - public void Defaults_MatchRetailCtor() - { - var ms = new MotionState(); - Assert.Equal(0u, ms.Style); - Assert.Equal(0u, ms.Substate); - Assert.Equal(1f, ms.SubstateMod); - Assert.Empty(ms.Modifiers); - Assert.Empty(ms.Actions); - } - - [Fact] - public void Modifiers_ArePushFrontStack() - { - var ms = new MotionState(); - ms.AddModifierNoCheck(0x0Du, 1.0f); - ms.AddModifierNoCheck(0x0Fu, 1.5f); - - // Newest first — retail pushes onto modifier_head. - Assert.Equal(new uint[] { 0x0Fu, 0x0Du }, ms.Modifiers.Select(m => m.Motion).ToArray()); - } - - [Fact] - public void Actions_AreTailAppendFifo() - { - var ms = new MotionState(); - ms.AddAction(0x62u, 1.0f); - ms.AddAction(0x63u, 1.25f); - - Assert.Equal(new uint[] { 0x62u, 0x63u }, ms.Actions.Select(a => a.Motion).ToArray()); - } - - [Fact] - public void AddModifier_RejectsDuplicate() - { - var ms = new MotionState(); - Assert.True(ms.AddModifier(0x0Du, 1.0f)); - Assert.False(ms.AddModifier(0x0Du, 2.0f)); // already present → caller must stop-then-re-add - Assert.Single(ms.Modifiers); - Assert.Equal(1.0f, ms.Modifiers.First().SpeedMod); // original untouched - } - - [Fact] - public void AddModifier_RefusesCurrentSubstate() - { - var ms = new MotionState { Substate = 0x45000005u }; - Assert.False(ms.AddModifier(0x45000005u, 1.0f)); - Assert.Empty(ms.Modifiers); - } - - [Fact] - public void AddModifierNoCheck_SkipsBothGuards() - { - var ms = new MotionState { Substate = 0x45000005u }; - ms.AddModifierNoCheck(0x45000005u, 1.0f); - ms.AddModifierNoCheck(0x45000005u, 2.0f); // duplicate allowed too - Assert.Equal(2, ms.Modifiers.Count()); - } - - [Fact] - public void RemoveModifier_ByNodeIdentity() - { - var ms = new MotionState(); - ms.AddModifierNoCheck(0x0Du, 1.0f); - ms.AddModifierNoCheck(0x0Fu, 1.5f); - var target = ms.Modifiers.First(m => m.Motion == 0x0Du); - - ms.RemoveModifier(target); - - Assert.Single(ms.Modifiers); - Assert.Equal(0x0Fu, ms.Modifiers.First().Motion); - } - - [Fact] - public void RemoveActionHead_PopsFifo_ReturnsMotion_ZeroWhenEmpty() - { - var ms = new MotionState(); - ms.AddAction(0x62u, 1f); - ms.AddAction(0x63u, 1f); - - Assert.Equal(0x62u, ms.RemoveActionHead()); - Assert.Equal(0x63u, ms.RemoveActionHead()); - Assert.Equal(0u, ms.RemoveActionHead()); // empty → 0 (retail returns 0) - Assert.Empty(ms.Actions); - - // Tail cleared with the last pop — a fresh append works normally. - ms.AddAction(0x64u, 1f); - Assert.Equal(new uint[] { 0x64u }, ms.Actions.Select(a => a.Motion).ToArray()); - } - - [Fact] - public void ClearModifiers_And_ClearActions_AreIndependentChains() - { - var ms = new MotionState(); - ms.AddModifierNoCheck(0x0Du, 1f); - ms.AddAction(0x62u, 1f); - - ms.ClearModifiers(); - Assert.Empty(ms.Modifiers); - Assert.Single(ms.Actions); - - ms.ClearActions(); - Assert.Empty(ms.Actions); - } - - [Fact] - public void DeepCopy_ClonesChains_NoSharedState() - { - // A4-#5: re_modify's snapshot is a DEEP copy used as a termination - // bound — mutating the original must not touch the snapshot. - var ms = new MotionState { Style = 0x8000003Du, Substate = 0x44000007u, SubstateMod = 2.85f }; - ms.AddModifierNoCheck(0x0Du, 1.5f); - ms.AddAction(0x62u, 1f); - - var snap = new MotionState(ms); - - ms.ClearModifiers(); - ms.RemoveActionHead(); - ms.Substate = 0x41000003u; - - Assert.Equal(0x8000003Du, snap.Style); - Assert.Equal(0x44000007u, snap.Substate); - Assert.Equal(2.85f, snap.SubstateMod); - Assert.Single(snap.Modifiers); - Assert.Equal(0x0Du, snap.Modifiers.First().Motion); - Assert.Single(snap.Actions); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs deleted file mode 100644 index 192927a6..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MotionTableDispatchSinkTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System.Linq; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; - -using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R2-Q5 — : the funnel's dispatches go -/// straight into (no axis -/// collection, no priority pick, no fallback chain — GetObjectSequence -/// 0x00522860 + is_allowed decide) with the TurnApplied/TurnStopped -/// ObservedOmega seam. -/// -public class MotionTableDispatchSinkTests -{ - private const uint NC = 0x8000003Du; - private const uint Ready = 0x41000003u; - private const uint Walk = 0x45000005u; - private const uint TurnRight = 0x6500000Du; - - private const uint ReadyAnim = 0x200u; - private const uint WalkAnim = 0x201u; - - private sealed class Loader : IAnimationLoader - { - private readonly System.Collections.Generic.Dictionary _anims = new(); - public void Register(uint id, Animation anim) => _anims[id] = anim; - public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null; - } - - private static Animation MakeAnim(int frames) - { - var anim = new Animation(); - for (int f = 0; f < frames; f++) - { - var pf = new AnimationFrame(1); - pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }); - anim.PartFrames.Add(pf); - } - return anim; - } - - private static MotionData MakeMd(uint animId, Vector3? omega = null) - { - var md = new MotionData(); - QualifiedDataId qid = animId; - md.Anims.Add(new AnimData { AnimId = qid, LowFrame = 0, HighFrame = -1, Framerate = 30f }); - if (omega is { } o) - { - md.Omega = o; - md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasOmega; - } - return md; - } - - private static AnimationSequencer MakeSequencer(bool withTurnModifier = true) - { - var setup = new Setup(); - setup.Parts.Add(0x01000000u); - setup.DefaultScale.Add(Vector3.One); - - var loader = new Loader(); - loader.Register(ReadyAnim, MakeAnim(4)); - loader.Register(WalkAnim, MakeAnim(6)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NC }; - mt.StyleDefaults[(DRWMotionCommand)NC] = (DRWMotionCommand)Ready; - mt.Cycles[(int)((NC << 16) | (Ready & 0xFFFFFFu))] = MakeMd(ReadyAnim); - mt.Cycles[(int)((NC << 16) | (Walk & 0xFFFFFFu))] = MakeMd(WalkAnim); - if (withTurnModifier) - { - mt.Modifiers[(int)((NC << 16) | (TurnRight & 0xFFFFFFu))] = - MakeMd(ReadyAnim, omega: new Vector3(0f, 0f, 1.2f)); - } - - return new AnimationSequencer(setup, mt, loader); - } - - [Fact] - public void ApplyMotion_CycleClass_InstallsSubstate_LazyInitialized() - { - var seq = MakeSequencer(); - var sink = new MotionTableDispatchSink(seq); - - // Fresh sequencer: the first dispatch lazily runs initialize_state - // (retail lazy-create, r3-motioninterp-decomp §6g) then installs - // the requested substate. - sink.ApplyMotion(Walk, 2.0f); - - Assert.Equal(Walk, seq.CurrentMotion); - Assert.Equal(2.0f, seq.CurrentSpeedMod); - // Locomotion velocity synthesis ran in the passthrough (AP-75). - Assert.Equal(MotionInterpreter.WalkAnimSpeed * 2.0f, seq.CurrentVelocity.Y, 3); - } - - [Fact] - public void ApplyMotion_Turn_Branch4Modifier_FiresTurnApplied_NoCycleChange() - { - var seq = MakeSequencer(); - uint? turnMotion = null; - float turnSpeed = 0f; - var sink = new MotionTableDispatchSink(seq) - { - TurnApplied = (m, s) => { turnMotion = m; turnSpeed = s; }, - }; - - sink.ApplyMotion(Walk, 1.0f); - sink.ApplyMotion(TurnRight, 1.5f); - - // The AP-73 mechanism for real: substate cycle untouched, the turn - // is a MotionState modifier with its dat omega combined. - Assert.Equal(Walk, seq.CurrentMotion); - Assert.Equal((TurnRight, 1.5f), (turnMotion!.Value, turnSpeed)); - Assert.Equal(1.2f * 1.5f, seq.CurrentOmega.Z, 3); - } - - [Fact] - public void StopMotion_Turn_FiresTurnStopped_UnwindsModifierPhysics() - { - var seq = MakeSequencer(); - bool stopped = false; - var sink = new MotionTableDispatchSink(seq) { TurnStopped = () => stopped = true }; - - sink.ApplyMotion(Walk, 1.0f); - sink.ApplyMotion(TurnRight, 1.5f); - sink.StopMotion(TurnRight); - - Assert.True(stopped); - // StopSequenceMotion Case B (0x00522fc0): subtract_motion of the dat - // omega + modifier unlinked. - Assert.Equal(0f, seq.CurrentOmega.Z, 3); - } - - [Fact] - public void StopMotion_CurrentSubstate_ReDrivesToStyleDefault() - { - var seq = MakeSequencer(); - var sink = new MotionTableDispatchSink(seq); - - sink.ApplyMotion(Walk, 1.0f); - sink.StopMotion(Walk); - - // StopSequenceMotion Case A: stopping the active cycle re-drives - // GetObjectSequence toward the style default (Ready). - Assert.Equal(Ready, seq.CurrentMotion); - } - - [Fact] - public void ApplyMotion_MissingEverywhere_NoOp_DefaultKeepsPlaying() - { - var seq = MakeSequencer(withTurnModifier: false); - var sink = new MotionTableDispatchSink(seq); - - sink.ApplyMotion(Walk, 1.0f); - // 0x44000007 RunForward: no cycle, no modifier -> dispatch fails, - // sequence + state untouched (no fallback chain — H4/H5). - sink.ApplyMotion(0x44000007u, 2.0f); - - Assert.Equal(Walk, seq.CurrentMotion); - Assert.Equal(1.0f, seq.CurrentSpeedMod); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MotionTableManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MotionTableManagerTests.cs deleted file mode 100644 index ab4f44d5..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MotionTableManagerTests.cs +++ /dev/null @@ -1,1018 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; -using Xunit; - -using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; - -namespace AcDream.Core.Tests.Physics.Motion; - -// ───────────────────────────────────────────────────────────────────────────── -// MotionTableManagerTests — R2-Q3 conformance harness for the verbatim -// MotionTableManager pending-animation queue + tick-countdown completion -// machinery (r2-port-plan.md §3 Q3; oracle -// docs/research/2026-07-02-r2-motiontable/r2-motiontable-decomp.md §11). -// -// Fixture convention matches CMotionTableTests.cs's file-local Fixtures -// class. Most tests exercise MotionTableManager directly via its -// PendingAnimations/AnimationCounter inspection surface + a RecordingSink, -// bypassing GetObjectSequence entirely so the queue mechanics are pinned in -// isolation from the selection dispatcher (already covered by -// CMotionTableTests.cs). -// ───────────────────────────────────────────────────────────────────────────── - -file static class Fixtures -{ - public static Animation MakeAnim(int numFrames, int numParts, Vector3 origin, Quaternion orientation) - { - var anim = new Animation(); - for (int f = 0; f < numFrames; f++) - { - var pf = new AnimationFrame((uint)numParts); - for (int p = 0; p < numParts; p++) - pf.Frames.Add(new Frame { Origin = origin, Orientation = orientation }); - anim.PartFrames.Add(pf); - } - return anim; - } - - public static AnimData MakeAnimData(uint animId, float framerate) - => new() - { - AnimId = (QualifiedDataId)animId, - LowFrame = 0, - HighFrame = -1, - Framerate = framerate, - }; - - public static MotionData MakeMotionData(uint animId, float framerate, byte bitfield = 0) - { - var md = new MotionData { Bitfield = bitfield }; - md.Anims.Add(MakeAnimData(animId, framerate)); - return md; - } - - public static void AddLink(MotionTable mt, uint style, uint fromSubstate, uint toSubstate, MotionData data) - { - int outerKey = (int)((style << 16) | (fromSubstate & 0xFFFFFFu)); - if (!mt.Links.TryGetValue(outerKey, out var cmd)) - { - cmd = new MotionCommandData(); - mt.Links[outerKey] = cmd; - } - cmd.MotionData[(int)toSubstate] = data; - } - - public static void AddCycle(MotionTable mt, uint style, uint substate, MotionData data) - { - int key = (int)((style << 16) | (substate & 0xFFFFFFu)); - mt.Cycles[key] = data; - } -} - -/// Records every call in order. -internal sealed class RecordingSink : IMotionDoneSink -{ - public readonly List<(uint Motion, bool Success)> Calls = new(); - public void MotionDone(uint motion, bool success) => Calls.Add((motion, success)); -} - -/// In-memory that never resolves. -file sealed class NullLoader : IAnimationLoader -{ - public Animation? LoadAnimation(uint id) => null; -} - -/// In-memory test double. -file sealed class FakeLoader : IAnimationLoader -{ - private readonly Dictionary _anims = new(); - public void Register(uint id, Animation anim) => _anims[id] = anim; - public Animation? LoadAnimation(uint id) => _anims.TryGetValue(id, out var a) ? a : null; -} - -public sealed class MotionTableManagerTests -{ - // Real retail command words (DatReaderWriter.Enums.MotionCommand), - // matching CMotionTableTests.cs's fixture conventions. - private const uint Ready = 0x41000003u; // cycle, == ReadySentinel - private const uint WalkForward = 0x45000005u; // cycle - private const uint RunForward = 0x44000007u; // cycle - private const uint ThrustMed = 0x10000058u; // action only - private const uint NonCombatStyle = 0x8000003Du; // style, top bit set - private const uint HandCombatStyle = 0x8000003Cu; // style, top bit set - - private static (MotionTableManager mgr, RecordingSink sink, MotionState state, CSequence seq) MakeManager(CMotionTable? table = null) - { - var sink = new RecordingSink(); - var state = new MotionState(); - var seq = new CSequence(new NullLoader()); - var mgr = new MotionTableManager(table, state, seq, sink); - return (mgr, sink, state, seq); - } - - // ── add_to_queue / basic queueing ─────────────────────────────────── - - [Fact] - public void AddToQueue_AppendsNode_InOrder() - { - var (mgr, _, _, _) = MakeManager(); - - mgr.AddToQueue(WalkForward, 3); - mgr.AddToQueue(RunForward, 5); - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(2, list.Count); - Assert.Equal(WalkForward, list[0].Motion); - Assert.Equal(3u, list[0].NumAnims); - Assert.Equal(RunForward, list[1].Motion); - Assert.Equal(5u, list[1].NumAnims); - } - - // ── countdown-chain: ONE AnimationDone completing MULTIPLE entries ── - - [Fact] - public void AnimationDone_CounterRollover_CompletesMultipleQueuedEntries_InOneCall() - { - // Two zero-duration-relative-to-each-other entries stacked so a - // single tick (counter=1) pops BOTH: first has NumAnims=1 (<=1 on - // the first increment), and after popping it counter -= 1 => back to - // 0... but decomp's while-loop re-tests the NEW head against the - // SAME counter value within the same AnimationDone call (no re- - // increment between pops) — so a second entry with NumAnims<=0 - // (i.e. 0) also pops in the same call. - var (mgr, sink, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 1); // pops when counter reaches 1 - mgr.AddToQueue(RunForward, 0); // zero-duration -> pops same call once counter>=0 - - mgr.AnimationDone(true); - - Assert.Equal(2, sink.Calls.Count); - Assert.Equal((WalkForward, true), sink.Calls[0]); - Assert.Equal((RunForward, true), sink.Calls[1]); - Assert.Empty(mgr.PendingAnimations); - // Drained-list counter reset. - Assert.Equal(0, mgr.AnimationCounter); - } - - [Fact] - public void AnimationDone_MultiTickCountdown_PopsOnlyWhenCounterReachesDuration() - { - var (mgr, sink, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 3); // needs counter >= 3 to pop - - mgr.AnimationDone(true); // counter=1 - Assert.Empty(sink.Calls); - Assert.Single(mgr.PendingAnimations); - Assert.Equal(1, mgr.AnimationCounter); - - mgr.AnimationDone(true); // counter=2 - Assert.Empty(sink.Calls); - Assert.Equal(2, mgr.AnimationCounter); - - mgr.AnimationDone(true); // counter=3 -> pops, counter -= 3 -> 0, list drains -> reset stays 0 - Assert.Single(sink.Calls); - Assert.Equal((WalkForward, true), sink.Calls[0]); - Assert.Empty(mgr.PendingAnimations); - Assert.Equal(0, mgr.AnimationCounter); - } - - [Fact] - public void AnimationDone_LeftoverCounter_ResetOnlyWhenListFullyDrains() - { - // Two entries: first pops on tick 1 (duration 1), second needs - // duration 5. After the first pop, counter = 1 - 1 = 0, but the - // list is NOT empty (second entry remains) -> counter must NOT be - // force-reset to something else; it stays at the algebraic value (0). - var (mgr, sink, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 1); - mgr.AddToQueue(RunForward, 5); - - mgr.AnimationDone(true); // counter=1 -> pops Walk (1<=1), counter -= 1 = 0; Run(5) remains, 0<=5? no. - - Assert.Single(sink.Calls); - Assert.Equal((WalkForward, true), sink.Calls[0]); - Assert.Single(mgr.PendingAnimations); - Assert.Equal(RunForward, mgr.PendingAnimations.First().Motion); - Assert.Equal(0, mgr.AnimationCounter); - } - - [Fact] - public void AnimationDone_EmptyQueue_IsNoOp() - { - var (mgr, sink, _, _) = MakeManager(); - - mgr.AnimationDone(true); - - Assert.Empty(sink.Calls); - Assert.Equal(0, mgr.AnimationCounter); - } - - // ── zero-tick sweep (CheckForCompletedMotions) vs counter sweep (AnimationDone) ── - - [Fact] - public void CheckForCompletedMotions_PopsOnlyZeroTickHeads_NeverIncrementsCounter() - { - var (mgr, sink, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 0); // zero-tick: pops immediately - mgr.AddToQueue(RunForward, 2); // non-zero: stays - - mgr.CheckForCompletedMotions(); - - Assert.Single(sink.Calls); - Assert.Equal((WalkForward, true), sink.Calls[0]); // success hardcoded true - Assert.Single(mgr.PendingAnimations); - Assert.Equal(RunForward, mgr.PendingAnimations.First().Motion); - Assert.Equal(0, mgr.AnimationCounter); // untouched - } - - [Fact] - public void CheckForCompletedMotions_NonZeroHead_DoesNotPop() - { - var (mgr, sink, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 4); - - mgr.CheckForCompletedMotions(); - - Assert.Empty(sink.Calls); - Assert.Single(mgr.PendingAnimations); - } - - [Fact] - public void UseTime_IsAliasFor_CheckForCompletedMotions() - { - var (mgr, sink, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 0); - - mgr.UseTime(); - - Assert.Single(sink.Calls); - Assert.Equal((WalkForward, true), sink.Calls[0]); - } - - [Fact] - public void AnimationDone_Distinguished_FromCheckForCompletedMotions_ByCounterUse() - { - // A node with NumAnims=1 does NOT pop via CheckForCompletedMotions - // (which only pops EXACTLY zero), but DOES pop via one AnimationDone - // tick (counter reaches 1). - var (mgrA, sinkA, _, _) = MakeManager(); - mgrA.AddToQueue(WalkForward, 1); - mgrA.CheckForCompletedMotions(); - Assert.Empty(sinkA.Calls); - Assert.Single(mgrA.PendingAnimations); - - var (mgrB, sinkB, _, _) = MakeManager(); - mgrB.AddToQueue(WalkForward, 1); - mgrB.AnimationDone(true); - Assert.Single(sinkB.Calls); - Assert.Empty(mgrB.PendingAnimations); - } - - // ── action-class completion pops MotionState's action FIFO ────────── - - [Fact] - public void AnimationDone_ActionClassMotion_PopsActionHead() - { - var (mgr, sink, state, _) = MakeManager(); - state.AddAction(ThrustMed, 1f); - Assert.Single(state.Actions); - - mgr.AddToQueue(ThrustMed, 0); // zero-duration -> pops on first tick - - mgr.AnimationDone(true); - - Assert.Empty(state.Actions); // popped - Assert.Single(sink.Calls); - Assert.Equal((ThrustMed, true), sink.Calls[0]); - } - - [Fact] - public void CheckForCompletedMotions_ActionClassMotion_PopsActionHead() - { - var (mgr, sink, state, _) = MakeManager(); - state.AddAction(ThrustMed, 1f); - - mgr.AddToQueue(ThrustMed, 0); - mgr.CheckForCompletedMotions(); - - Assert.Empty(state.Actions); - Assert.Single(sink.Calls); - } - - [Fact] - public void AnimationDone_NonActionClassMotion_DoesNotTouchActionFifo() - { - var (mgr, _, state, _) = MakeManager(); - state.AddAction(ThrustMed, 1f); - - mgr.AddToQueue(WalkForward, 0); // cycle-class, not action-class - mgr.AnimationDone(true); - - Assert.Single(state.Actions); // untouched - } - - // ── enter/exit-world drains fire MotionDone(success=false) ────────── - - [Fact] - public void HandleExitWorld_DrainsEntireQueue_FiringMotionDoneFalse_ForEveryEntry() - { - var (mgr, sink, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 0); - mgr.AddToQueue(RunForward, 0); - mgr.AddToQueue(ThrustMed, 0); - - mgr.HandleExitWorld(); - - Assert.Equal(3, sink.Calls.Count); - Assert.All(sink.Calls, c => Assert.False(c.Success)); - Assert.Empty(mgr.PendingAnimations); - } - - [Fact] - public void HandleExitWorld_NonZeroDurationEntries_StillFullyDrained() - { - // Drain loops AnimationDone(false) until head_==0, incrementing the - // counter each call — even non-zero-duration entries eventually pop. - var (mgr, sink, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 3); - - mgr.HandleExitWorld(); - - Assert.Single(sink.Calls); - Assert.Equal((WalkForward, false), sink.Calls[0]); - Assert.Empty(mgr.PendingAnimations); - } - - [Fact] - public void HandleEnterWorld_DrainsQueue_AndStripsLinkAnimations() - { - var loader = new FakeLoader(); - uint linkAnim = 0x03000200u, cycleAnim = 0x03000201u; - loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - - var sink = new RecordingSink(); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - var seq = new CSequence(loader); - // Build a link + cyclic tail directly (bypassing GetObjectSequence). - CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(linkAnim, 30f), 1f); - CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cycleAnim, 30f), 1f); - Assert.Equal(2, seq.Count); - - var mgr = new MotionTableManager(null, state, seq, sink); - mgr.AddToQueue(WalkForward, 0); - - mgr.HandleEnterWorld(); - - // Queue drained, MotionDone(false) fired. - Assert.Single(sink.Calls); - Assert.False(sink.Calls[0].Success); - Assert.Empty(mgr.PendingAnimations); - // Link animation stripped — only the cyclic tail remains. - Assert.Equal(1, seq.Count); - } - - [Fact] - public void HandleEnterWorld_EmptyQueue_StillStripsLinkAnimations_NoOpOnDrain() - { - var loader = new FakeLoader(); - uint linkAnim = 0x03000210u, cycleAnim = 0x03000211u; - loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(cycleAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - - var sink = new RecordingSink(); - var state = new MotionState(); - var seq = new CSequence(loader); - CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(linkAnim, 30f), 1f); - CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cycleAnim, 30f), 1f); - - var mgr = new MotionTableManager(null, state, seq, sink); - - mgr.HandleEnterWorld(); - - Assert.Empty(sink.Calls); - Assert.Equal(1, seq.Count); // link stripped, cyclic tail remains - } - - // ── truncate blocked/allowed matrices for BOTH class masks ────────── - - [Fact] - public void RemoveRedundantLinks_CycleClassTail_EarlierSameMotion_NoBlocker_Truncates() - { - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 5); // earlier same-motion, non-zero - mgr.AddToQueue(WalkForward, 3); // new tail, same motion -> matches the - // earlier node immediately (no - // intervening node to block) -> - // truncate walks tail_..matched - // EXCLUSIVE, so the TAIL node itself - // (this new entry) is in the walked - // range and gets zeroed; the earlier - // matched node (the stop boundary) - // is untouched. - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(2, list.Count); - Assert.Equal(5u, list[0].NumAnims); // matched node (stop boundary) untouched - Assert.Equal(0u, list[1].NumAnims); // the redundant re-issue itself, zeroed - } - - [Fact] - public void RemoveRedundantLinks_CycleClassTail_WithInterveningZeroTickNode_Truncates() - { - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 5); // earlier match - mgr.AddToQueue(ThrustMed, 0); // intervening, ZERO ticks -> does not block (only non-zero blocks) - mgr.AddToQueue(WalkForward, 3); // new tail, same motion - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(3, list.Count); - Assert.Equal(5u, list[0].NumAnims); // matched node (stop boundary) untouched - Assert.Equal(0u, list[1].NumAnims); // already zero (unaffected either way) - Assert.Equal(0u, list[2].NumAnims); // the new tail — ALSO in the truncation range - // (truncate walks tail..matched EXCLUSIVE, so - // the tail itself IS zeroed too) - } - - [Fact] - public void RemoveRedundantLinks_CycleClassTail_InterveningDifferentCycle_NotBlocked_Truncates() - { - // Bit decomposition (verified): 0xb0000000 == STYLE(0x80000000) | - // MODIFIER(0x20000000) | ACTION(0x10000000) — it deliberately - // EXCLUDES the cycle-class bit (0x40000000) itself. So a DIFFERENT - // intervening CYCLE (RunForward) does NOT block a WalkForward - // cycle-tail scan; two cycles naturally supersede each other via the - // base-cycle rebuild mechanism. - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 5); // earlier match candidate - mgr.AddToQueue(RunForward, 4); // intervening, non-zero, CYCLE-class but not in 0xb0000000 -> does not block - mgr.AddToQueue(WalkForward, 3); // new tail, same motion as the earlier node - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(3, list.Count); - Assert.Equal(5u, list[0].NumAnims); // matched (stop boundary), untouched - Assert.Equal(0u, list[1].NumAnims); // truncated — RunForward zeroed - Assert.Equal(0u, list[2].NumAnims); // truncated — the new tail itself zeroed - } - - [Fact] - public void RemoveRedundantLinks_CycleClassTail_BlockedByInterveningNonZeroModifierClassNode() - { - // 0xb0000000 DOES include the modifier-class bit (0x20000000) — a - // pure modifier-class node with non-zero ticks blocks a cycle-tail - // collapse (can't safely erase an active modifier's queue entry). - var (mgr, _, _, _) = MakeManager(); - const uint pureModifier = 0x20000099u; // modifier-class only, no cycle/action/style bits - mgr.AddToQueue(WalkForward, 5); - mgr.AddToQueue(pureModifier, 2); // non-zero, matches 0xb0000000 -> BLOCKS - mgr.AddToQueue(WalkForward, 3); - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(3, list.Count); - // Blocked -> nothing truncated, all NumAnims preserved as queued. - Assert.Equal(5u, list[0].NumAnims); - Assert.Equal(2u, list[1].NumAnims); - Assert.Equal(3u, list[2].NumAnims); - } - - [Fact] - public void RemoveRedundantLinks_CycleClassTail_BlockedByInterveningNonZeroActionClassNode() - { - // 0xb0000000 also includes the action-class bit (0x10000000). - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 5); - mgr.AddToQueue(ThrustMed, 2); // non-zero, action-class -> matches 0xb0000000 -> BLOCKS - mgr.AddToQueue(WalkForward, 3); - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(3, list.Count); - Assert.Equal(5u, list[0].NumAnims); - Assert.Equal(2u, list[1].NumAnims); // untouched — blocked - Assert.Equal(3u, list[2].NumAnims); // untouched — blocked - } - - [Fact] - public void RemoveRedundantLinks_CycleClassTail_BlockedByInterveningNonZeroStyleClassNode() - { - // 0xb0000000 also includes the style-class top bit (0x80000000). - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 5); - mgr.AddToQueue(HandCombatStyle, 2); // non-zero, style-class -> matches 0xb0000000 -> BLOCKS - mgr.AddToQueue(WalkForward, 3); - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(3, list.Count); - Assert.Equal(5u, list[0].NumAnims); - Assert.Equal(2u, list[1].NumAnims); - Assert.Equal(3u, list[2].NumAnims); - } - - [Fact] - public void RemoveRedundantLinks_StyleClassTail_EarlierSameMotion_Truncates() - { - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(HandCombatStyle, 5); // style-class (top bit set), earlier match - mgr.AddToQueue(ThrustMed, 0); // zero-tick intervening, no block - mgr.AddToQueue(HandCombatStyle, 3); // new tail, same style motion - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(3, list.Count); - Assert.Equal(5u, list[0].NumAnims); - Assert.Equal(0u, list[1].NumAnims); - Assert.Equal(0u, list[2].NumAnims); - } - - [Fact] - public void RemoveRedundantLinks_StyleClassTail_BlockedByInterveningNonZeroModifierClassNode() - { - // Style-class tail scan blocks on the WIDER 0x70000000 mask - // (cycle|action|modifier) -- a pure modifier-class node DOES block - // here, unlike the cycle-class tail scan above. - var (mgr, _, _, _) = MakeManager(); - const uint pureModifier = 0x20000099u; - mgr.AddToQueue(HandCombatStyle, 5); - mgr.AddToQueue(pureModifier, 2); // non-zero, matches 0x70000000 -> BLOCKS - mgr.AddToQueue(HandCombatStyle, 3); - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(3, list.Count); - Assert.Equal(5u, list[0].NumAnims); - Assert.Equal(2u, list[1].NumAnims); // untouched — blocked - Assert.Equal(3u, list[2].NumAnims); // untouched — blocked - } - - [Fact] - public void RemoveRedundantLinks_StyleClassTail_BlockedByInterveningNonZeroCycleClassNode() - { - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(HandCombatStyle, 5); - mgr.AddToQueue(WalkForward, 4); // non-zero, cycle-class -> matches 0x70000000 too -> BLOCKS - mgr.AddToQueue(HandCombatStyle, 3); - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(3, list.Count); - Assert.Equal(5u, list[0].NumAnims); - Assert.Equal(4u, list[1].NumAnims); - Assert.Equal(3u, list[2].NumAnims); - } - - [Fact] - public void RemoveRedundantLinks_NoEarlierMatch_ScanExhausts_NoTruncation() - { - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(RunForward, 4); - mgr.AddToQueue(WalkForward, 3); // no earlier WalkForward anywhere - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(2, list.Count); - Assert.Equal(4u, list[0].NumAnims); - Assert.Equal(3u, list[1].NumAnims); - } - - [Fact] - public void RemoveRedundantLinks_TrailingZeroTickNodesSkipped_BeforeScanning() - { - // Tail itself is zero-tick -> skip backward past it to find the - // "effective tail" for the class dispatch. - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 5); - mgr.AddToQueue(WalkForward, 0); // trailing zero-tick — skipped as "effective tail" search - - var list = mgr.PendingAnimations.ToList(); - // Effective tail (after skip) = list[0] (WalkForward, 5) itself — - // scanning backward from it finds nothing earlier -> no truncation. - Assert.Equal(2, list.Count); - Assert.Equal(5u, list[0].NumAnims); - Assert.Equal(0u, list[1].NumAnims); - } - - [Fact] - public void RemoveRedundantLinks_AllZeroTickNodes_ScanBottomsOut_ReturnsQuietly() - { - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 0); - mgr.AddToQueue(RunForward, 0); - - // Must not throw; nothing to truncate. - mgr.RemoveRedundantLinks(); - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(2, list.Count); - Assert.Equal(0u, list[0].NumAnims); - Assert.Equal(0u, list[1].NumAnims); - } - - [Fact] - public void RemoveRedundantLinks_EmptyQueue_IsNoOp() - { - var (mgr, _, _, _) = MakeManager(); - mgr.RemoveRedundantLinks(); // must not throw - Assert.Empty(mgr.PendingAnimations); - } - - [Fact] - public void RemoveRedundantLinks_ModifierOrActionClassTail_NoScanPerformed() - { - // A pure modifier-class or action-class tail motion takes NEITHER - // the cycle-tail nor the style-tail branch in retail -> no scan, no - // truncation, regardless of what precedes it. - var (mgr, _, _, _) = MakeManager(); - const uint pureModifier = 0x20000099u; - mgr.AddToQueue(pureModifier, 5); - mgr.AddToQueue(pureModifier, 3); // same motion id repeated, but tail is modifier-class -> no truncation path - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(2, list.Count); - Assert.Equal(5u, list[0].NumAnims); - Assert.Equal(3u, list[1].NumAnims); - } - - // ── truncate_animation_list: zeroes in place, nodes stay queued ───── - - [Fact] - public void Truncate_ViaRedundantCollapse_NodesStayQueued_OnlyNumAnimsZeroed() - { - // RunForward is a DIFFERENT cycle-class motion — not in the - // 0xb0000000 cycle-tail block mask (which excludes the cycle bit) — - // so it does not block WalkForward's re-match scan and gets - // truncated (zeroed in place, still queued) along with the tail. - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 5); - mgr.AddToQueue(RunForward, 2); // will get zeroed but not removed - mgr.AddToQueue(WalkForward, 3); - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(3, list.Count); // node count UNCHANGED — truncate never unlinks - Assert.Equal(RunForward, list[1].Motion); // motion id preserved, only NumAnims zeroed - Assert.Equal(0u, list[1].NumAnims); - } - - [Fact] - public void Truncate_StripsLinkAnimationsFromSequence_ByTotalRemovedTicks() - { - var loader = new FakeLoader(); - uint link1 = 0x03000300u, link2 = 0x03000301u, cyc = 0x03000302u; - loader.Register(link1, Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(link2, Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(cyc, Fixtures.MakeAnim(1, 1, Vector3.Zero, Quaternion.Identity)); - - var sink = new RecordingSink(); - var state = new MotionState(); - var seq = new CSequence(loader); - // Build a 3-node sequence: link1, link2 (both before first_cyclic-ish), - // cyc (the cyclic tail — append_animation slides first_cyclic to it). - CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(link1, 30f), 1f); - CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(link2, 30f), 1f); - CMotionTable.AddMotion(seq, Fixtures.MakeMotionData(cyc, 30f), 1f); - Assert.Equal(3, seq.Count); - - var mgr = new MotionTableManager(null, state, seq, sink); - mgr.AddToQueue(WalkForward, 2); // earlier match, non-zero - mgr.AddToQueue(WalkForward, 2); // new tail, same motion -> truncates the earlier - // one's successor range (which is just this - // node itself, ticks=2) -> RemoveLinkAnimations(2) - - // RemoveLinkAnimations(2) removes up to 2 predecessors of first_cyclic - // (link2, then link1) from the live sequence. - Assert.Equal(1, seq.Count); // only the cyclic tail (cyc) remains - } - - // ── rapid same-motion re-issue -> collapse (Fix B replacement proof) ── - - [Fact] - public void RapidSameMotionReissue_ThroughAddToQueue_CollapsesRedundantMiddleNodes() - { - // Enqueue walk, run, walk, run quickly. Bit decomposition of the - // cycle-tail block mask (0xb0000000 = STYLE|MODIFIER|ACTION, - // deliberately EXCLUDING the cycle bit 0x40000000): a DIFFERENT - // real cycle in between does NOT block a cycle-tail scan, so: - // 1. add W(5) -> [W5] - // 2. add R(4): tail=R, scan W5 -> no match (diff motion), no - // block (W not in 0xb0000000) -> scan exhausts -> [W5,R4] - // 3. add W(3): tail=W, scan R4 -> no match, no block -> scan W5 - // -> MATCH (same motion, non-zero) -> truncate(W5..tail - // exclusive) zeroes R4 and this new W3 -> [W5, R0, W0] - // 4. add R(2): tail=R, scan W0(NumAnims=0, fails the non-zero - // match requirement) -> no block (0 ticks) -> scan R0 - // (NumAnims=0, fails non-zero match requirement too) -> no - // block -> scan W5 -> no match (diff motion), no block -> - // scan exhausts -> nothing new truncated -> [W5, R0, W0, R2] - // This is the Fix-B-replacement proof: the FIRST redundant - // walk->run->walk sequence collapses down to just the original - // WalkForward(5) plus the freshly-added RunForward(2) — matching - // "rapid re-issue of the same locomotion collapses" without - // inventing a bespoke locomotion-low-byte special case (H6). - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 5); - mgr.AddToQueue(RunForward, 4); - mgr.AddToQueue(WalkForward, 3); - mgr.AddToQueue(RunForward, 2); - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(4, list.Count); - Assert.Equal(5u, list[0].NumAnims); // original WalkForward — the surviving anchor - Assert.Equal(0u, list[1].NumAnims); // RunForward — truncated by step 3's collapse - Assert.Equal(0u, list[2].NumAnims); // WalkForward (re-issue) — truncated by step 3's collapse - Assert.Equal(2u, list[3].NumAnims); // newest RunForward — survives (its own scan found no match) - } - - [Fact] - public void RapidSameMotionReissue_ZeroTickIntervening_DoesCollapse() - { - // When the intervening entries are ZERO-tick (already-instant / a - // fast-path re-speed that queued 0 outTicks), the scan is NOT - // blocked (only non-zero nodes block) and the collapse proceeds — - // this IS the realistic Fix-B-replacement shape: rapid re-speeds of - // the SAME cycle produce zero-outTicks entries between the real - // queue events. - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 5); - mgr.AddToQueue(WalkForward, 0); // e.g. a fast-path re-speed, zero outTicks - mgr.AddToQueue(WalkForward, 3); // new tail, same motion - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(3, list.Count); - Assert.Equal(5u, list[0].NumAnims); // matched node preserved - Assert.Equal(0u, list[1].NumAnims); // already zero - Assert.Equal(0u, list[2].NumAnims); // truncated - } - - // ── the 2026-05-03 golden: cyclic->cyclic walk->run, truncate NOT firing ── - - [Fact] - public void Golden_CyclicToCyclicWalkThenRun_BothNodesQueued_TruncateNotFiring() - { - // Quoted golden (Fix B comment block, r2-port-plan.md H6): cyclic-> - // cyclic transitions produce add_to_queue(0x45000005) [WalkForward] - // followed by add_to_queue(0x44000007) [RunForward], with truncate - // NOT firing (different motion ids -> remove_redundant_links' match - // test never succeeds). - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 3); - mgr.AddToQueue(RunForward, 4); - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(2, list.Count); - Assert.Equal(WalkForward, list[0].Motion); - Assert.Equal(3u, list[0].NumAnims); // untouched — no truncation - Assert.Equal(RunForward, list[1].Motion); - Assert.Equal(4u, list[1].NumAnims); // untouched - } - - // ── initialize_state ────────────────────────────────────────────── - - [Fact] - public void InitializeState_QueuesReadySentinel_WithSetDefaultStateOutTicks() - { - var loader = new FakeLoader(); - uint readyAnim = 0x03000400u; - loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); - - var cmt = new CMotionTable(mt); - var sink = new RecordingSink(); - var state = new MotionState(); - var seq = new CSequence(loader); - var mgr = new MotionTableManager(cmt, state, seq, sink); - - mgr.InitializeState(); - - Assert.Equal(NonCombatStyle, state.Style); - Assert.Equal(Ready, state.Substate); - Assert.Single(mgr.PendingAnimations); - var node = mgr.PendingAnimations.First(); - Assert.Equal(MotionTableManager.ReadySentinel, node.Motion); - // SetDefaultState outTicks = cyclic.num_anims - 1 = 1 - 1 = 0. - Assert.Equal(0u, node.NumAnims); - } - - [Fact] - public void InitializeState_NoTableLoaded_QueuesSentinelWithZeroTicks_NoThrow() - { - var sink = new RecordingSink(); - var state = new MotionState(); - var seq = new CSequence(new NullLoader()); - var mgr = new MotionTableManager(null, state, seq, sink); - - mgr.InitializeState(); - - Assert.Single(mgr.PendingAnimations); - Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion); - Assert.Equal(0u, mgr.PendingAnimations.First().NumAnims); - } - - // ── PerformMovement error-code matrix ──────────────────────────────── - - [Fact] - public void PerformMovement_NoTableLoaded_ReturnsError7() - { - var (mgr, _, _, _) = MakeManager(table: null); - - uint result = mgr.PerformMovement(MotionTableMovement.Interpreted(WalkForward, 1f)); - - Assert.Equal(MotionTableManagerError.NoTable, result); - Assert.Equal(7u, result); - } - - [Fact] - public void PerformMovement_InterpretedCommand_Success_QueuesMotionWithOutTicks_ReturnsZero() - { - var loader = new FakeLoader(); - uint readyAnim = 0x03000500u, walkAnim = 0x03000501u, linkAnim = 0x03000502u; - loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(walkAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(linkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); - Fixtures.AddCycle(mt, NonCombatStyle, WalkForward, Fixtures.MakeMotionData(walkAnim, 30f)); - Fixtures.AddLink(mt, NonCombatStyle, Ready, WalkForward, Fixtures.MakeMotionData(linkAnim, 30f)); - - var cmt = new CMotionTable(mt); - var sink = new RecordingSink(); - var state = new MotionState { Style = NonCombatStyle, Substate = Ready, SubstateMod = 1f }; - var seq = new CSequence(loader); - var mgr = new MotionTableManager(cmt, state, seq, sink); - - uint result = mgr.PerformMovement(MotionTableMovement.Interpreted(WalkForward, 1f)); - - Assert.Equal(MotionTableManagerError.Success, result); - Assert.Equal(WalkForward, state.Substate); - Assert.Single(mgr.PendingAnimations); - Assert.Equal(WalkForward, mgr.PendingAnimations.First().Motion); - // outTicks = link(1) + cycle(1) - 1 = 1 - Assert.Equal(1u, mgr.PendingAnimations.First().NumAnims); - } - - [Fact] - public void PerformMovement_InterpretedCommand_Failure_ReturnsError0x43_NoQueue() - { - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - // No cycles registered at all -> DoObjectMotion fails. - var cmt = new CMotionTable(mt); - var (mgr, _, state, _) = MakeManager(cmt); - state.Style = NonCombatStyle; - state.Substate = Ready; - state.SubstateMod = 1f; - - uint result = mgr.PerformMovement(MotionTableMovement.Interpreted(WalkForward, 1f)); - - Assert.Equal(MotionTableManagerError.MotionFailed, result); - Assert.Equal(0x43u, result); - Assert.Empty(mgr.PendingAnimations); - } - - [Fact] - public void PerformMovement_StopInterpretedCommand_Success_QueuesReadySentinel() - { - var loader = new FakeLoader(); - uint runAnim = 0x03000510u, readyAnim = 0x03000511u, exitLinkAnim = 0x03000512u; - loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(exitLinkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f)); - Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); - Fixtures.AddLink(mt, NonCombatStyle, RunForward, Ready, Fixtures.MakeMotionData(exitLinkAnim, 30f)); - - var cmt = new CMotionTable(mt); - var sink = new RecordingSink(); - var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; - var seq = new CSequence(loader); - var mgr = new MotionTableManager(cmt, state, seq, sink); - Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); - - uint result = mgr.PerformMovement(MotionTableMovement.StopInterpreted(RunForward, 1f)); - - Assert.Equal(MotionTableManagerError.Success, result); - Assert.Equal(Ready, state.Substate); // re-driven to style default - Assert.Single(mgr.PendingAnimations); - Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion); - } - - [Fact] - public void PerformMovement_StopInterpretedCommand_Failure_ReturnsError0x43_NoQueue() - { - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - var cmt = new CMotionTable(mt); - var (mgr, _, state, _) = MakeManager(cmt); - // No modifier and no matching current substate -> StopObjectMotion fails. - state.Style = NonCombatStyle; - state.Substate = Ready; - state.SubstateMod = 1f; - const uint unrelatedModifier = 0x20000099u; - - uint result = mgr.PerformMovement(MotionTableMovement.StopInterpreted(unrelatedModifier, 1f)); - - Assert.Equal(MotionTableManagerError.MotionFailed, result); - Assert.Empty(mgr.PendingAnimations); - } - - [Fact] - public void PerformMovement_StopCompletely_UnconditionallyQueuesReadySentinel_EvenOnNoOpStop() - { - // Even when StopObjectCompletely finds nothing to stop (returns - // false), PerformMovement STILL queues the Ready sentinel - // unconditionally (decomp §11 note) and returns success. - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - // No StyleDefaults entry -> the final StopSequenceMotion(substate=0,...) - // call inside StopObjectCompletely can't resolve a style default either, - // but that's fine: Case A requires substate==0x40000000-class AND == - // state.Substate; with Substate=0 (default MotionState ctor) neither - // stop mechanism matches anything -> both return false -> overall false. - var cmt = new CMotionTable(mt); - var (mgr, _, state, _) = MakeManager(cmt); - // state.Substate stays 0 (default) -> StopObjectCompletely no-ops. - - uint result = mgr.PerformMovement(MotionTableMovement.StopCompletely()); - - Assert.Equal(MotionTableManagerError.Success, result); - Assert.Single(mgr.PendingAnimations); - Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion); - } - - [Fact] - public void PerformMovement_StopCompletely_DrainsModifiers_QueuesReadySentinel() - { - var loader = new FakeLoader(); - uint runAnim = 0x03000520u, readyAnim = 0x03000521u, exitLinkAnim = 0x03000522u; - loader.Register(runAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(readyAnim, Fixtures.MakeAnim(4, 1, Vector3.Zero, Quaternion.Identity)); - loader.Register(exitLinkAnim, Fixtures.MakeAnim(2, 1, Vector3.Zero, Quaternion.Identity)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - mt.StyleDefaults[(DRWMotionCommand)NonCombatStyle] = (DRWMotionCommand)Ready; - Fixtures.AddCycle(mt, NonCombatStyle, RunForward, Fixtures.MakeMotionData(runAnim, 30f)); - Fixtures.AddCycle(mt, NonCombatStyle, Ready, Fixtures.MakeMotionData(readyAnim, 30f)); - Fixtures.AddLink(mt, NonCombatStyle, RunForward, Ready, Fixtures.MakeMotionData(exitLinkAnim, 30f)); - - var cmt = new CMotionTable(mt); - var sink = new RecordingSink(); - var state = new MotionState { Style = NonCombatStyle, Substate = RunForward, SubstateMod = 1f }; - var seq = new CSequence(loader); - var mgr = new MotionTableManager(cmt, state, seq, sink); - Assert.True(cmt.DoObjectMotion(RunForward, state, seq, 1f, out _)); - - uint result = mgr.PerformMovement(MotionTableMovement.StopCompletely()); - - Assert.Equal(MotionTableManagerError.Success, result); - Assert.Equal(Ready, state.Substate); - Assert.Single(mgr.PendingAnimations); - Assert.Equal(MotionTableManager.ReadySentinel, mgr.PendingAnimations.First().Motion); - } - - [Fact] - public void PerformMovement_UnhandledType_ReturnsNotHandled_NoQueue() - { - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - var cmt = new CMotionTable(mt); - var (mgr, _, _, _) = MakeManager(cmt); - - uint result = mgr.PerformMovement(new MotionTableMovement(MovementType.RawCommand, WalkForward, 1f)); - - Assert.Equal(MotionTableManagerError.NotHandled, result); - Assert.Empty(mgr.PendingAnimations); - } - - [Fact] - public void PerformMovement_StopRawCommand_ReturnsNotHandled() - { - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombatStyle }; - var cmt = new CMotionTable(mt); - var (mgr, _, _, _) = MakeManager(cmt); - - uint result = mgr.PerformMovement(new MotionTableMovement(MovementType.StopRawCommand, WalkForward, 1f)); - - Assert.Equal(MotionTableManagerError.NotHandled, result); - } - - // ── AddToQueue calls RemoveRedundantLinks immediately (opportunistic) ── - - [Fact] - public void AddToQueue_CallsRemoveRedundantLinks_Immediately_NotDeferred() - { - var (mgr, _, _, _) = MakeManager(); - mgr.AddToQueue(WalkForward, 5); - mgr.AddToQueue(ThrustMed, 0); - - // Adding a third same-motion WalkForward should IMMEDIATELY trigger - // the collapse as part of THIS AddToQueue call (not deferred to a - // later explicit RemoveRedundantLinks() call). - mgr.AddToQueue(WalkForward, 3); - - var list = mgr.PendingAnimations.ToList(); - Assert.Equal(0u, list[1].NumAnims); // already truncated by the time AddToQueue returns - Assert.Equal(0u, list[2].NumAnims); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerArrivalAndProgressTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerArrivalAndProgressTests.cs deleted file mode 100644 index 7d9fcc0a..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerArrivalAndProgressTests.cs +++ /dev/null @@ -1,295 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V2 — HandleMoveToPosition Phase 2 (00529d80, raw -/// 307283-307331) and CheckProgressMade (005290f0, raw -/// 306385-306431), per r4-moveto-decomp.md §6b/§5b: arrival predicate -/// (chase dist <= DistanceToObject vs flee -/// dist >= MinDistance), fail-distance ( -/// 0x3D), and the 1-second / 0.25-units-per-second progress window (BOTH -/// incremental AND overall rates). -/// -public sealed class MoveToManagerArrivalAndProgressTests -{ - // ── CheckProgressMade — the progress-clock table (§5b) ───────────────── - - [Fact] - public void CheckProgressMade_WithinOneSecondWindow_AlwaysTrue_TooSoonToJudge() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false }); - - h.Advance(0.5); // < 1.0s since PreviousDistanceTime - - Assert.True(h.Manager.CheckProgressMade(currentDistance: 19.9f)); - } - - [Fact] - public void CheckProgressMade_ExactlyOneSecond_StillTooSoon_Inclusive() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false }); - - h.Advance(1.0); // elapsed <= 1.0 -> still true (§5b: "elapsed <= 1.0 -> return 1") - - Assert.True(h.Manager.CheckProgressMade(currentDistance: 19.9f)); - } - - [Fact] - public void CheckProgressMade_AfterWindow_SufficientIncrementalAndOverallRate_True() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false }); - - // PreviousDistance/OriginalDistance seeded to 20 at t=0. - h.Advance(2.0); // 2s elapsed - - // Closed 1 unit/s over 2s = 2 units closed -> rate 1.0 >= 0.25 both ways. - bool progress = h.Manager.CheckProgressMade(currentDistance: 18f); - - Assert.True(progress); - Assert.Equal(0u, h.Manager.FailProgressCount); - Assert.Equal(18f, h.Manager.PreviousDistance, 2); // incremental checkpoint advanced - } - - [Fact] - public void CheckProgressMade_InsufficientIncrementalRate_False_CheckpointDoesNotAdvance() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false }); - - h.Advance(2.0); - - // Closed only 0.1 unit over 2s -> rate 0.05 < 0.25 -> no progress; - // checkpoint (PreviousDistance) must NOT advance. - bool progress = h.Manager.CheckProgressMade(currentDistance: 19.9f); - - Assert.False(progress); - Assert.Equal(20f, h.Manager.PreviousDistance, 2); // unchanged - } - - [Fact] - public void CheckProgressMade_GoodIncrementalButBadOverallRate_False() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { UseSpheres = false }); - - // OriginalDistance/OriginalDistanceTime were seeded to (20, t=0) by - // BeginMoveForward and NEVER move again except on arrival/retarget — - // only PreviousDistance (the incremental checkpoint) advances on a - // passing tick. Simulate a long slow crawl: many small incremental - // passes that each individually clear 0.25/s over their own 1s+ - // window, but the OVERALL rate since t=0 stays under 0.25/s because - // the total elapsed time dominates. - h.Advance(2.0); - Assert.True(h.Manager.CheckProgressMade(19f)); // incremental 0.5/s since t=0 — overall also 0.5/s here, still passes. - - // Now advance a huge amount of time with only a tiny further close — - // incremental since the LAST checkpoint (t=2, dist=19) is healthy - // relative to its own short window, but overall since t=0 (dist 20) - // over the huge elapsed time is far under 0.25/s. - h.Advance(200.0); - bool progress = h.Manager.CheckProgressMade(18.9f); // incremental: 0.1/200s -> fails incremental too in this construction; assert false either way (both gates must independently pass). - - Assert.False(progress); - } - - [Fact] - public void CheckProgressMade_MovingAway_UsesOpeningDistance() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - // pure-away move: MoveTowards=false, MoveAway=true, MinDistance large - // so get_command picks WalkForward+movingAway. - var p = new MovementParameters { MoveTowards = false, MoveAway = true, MinDistance = 50f, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); - Assert.True(h.Manager.MovingAway); - - h.Advance(2.0); - // Distance to the (now-behind) target GREW from ~20 to 25 -> opening - // at 2.5/s -> progress (moving_away: progress = curr - previous). - bool progress = h.Manager.CheckProgressMade(25f); - - Assert.True(progress); - } - - // ── Arrival predicate (§6b Phase 2) — chase vs flee ──────────────────── - - [Fact] - public void HandleMoveToPosition_Chase_ArrivesWhenDistLessOrEqualDto() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - var p = new MovementParameters { DistanceToObject = 5f, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); - h.DrainPendingMotions(); - - // Walk the mover to within DistanceToObject and let enough time pass - // for CheckProgressMade to evaluate true. - h.WorldPosition = new Position(1u, new Vector3(16f, 0f, 0f), Quaternion.Identity); // dist=4 <= dto(5) - h.Advance(2.0); - - h.Manager.HandleMoveToPosition(); - - // Arrival -> node popped, CurrentCommand cleared, BeginNextNode ran - // (queue now empty, non-sticky) -> CleanUp + StopCompletely -> - // MovementTypeState back to Invalid. - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void HandleMoveToPosition_Chase_NotArrivedYet_StaysActive() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - var p = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); - h.DrainPendingMotions(); - - h.WorldPosition = new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity); // dist=15, still far - h.Advance(2.0); - - h.Manager.HandleMoveToPosition(); - - Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); - } - - [Fact] - public void HandleMoveToPosition_Flee_ArrivesWhenDistGreaterOrEqualMinDistance() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - var p = new MovementParameters { MoveTowards = false, MoveAway = true, MinDistance = 10f, UseSpheres = false }; - // Start close (dist=5 < MinDistance=10) so get_command picks - // WalkForward+movingAway (pure-away band, §5c). - h.Manager.MoveToPosition(new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity), p); - Assert.True(h.Manager.MovingAway); - h.DrainPendingMotions(); - - // Mover has fled to distance 12 (>= MinDistance 10) -> arrived. - h.WorldPosition = new Position(1u, new Vector3(-7f, 0f, 0f), Quaternion.Identity); // dist to (5,0,0) = 12 - h.Advance(2.0); - - h.Manager.HandleMoveToPosition(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - // ── Fail-distance (§6b, WeenieError.YouChargedTooFar) ────────────────── - - [Fact] - public void HandleMoveToPosition_ProgressMadeButOverFailDistance_CancelsAsYouChargedTooFar() - { - // §6b Phase 2 ordering: the fail_distance check lives INSIDE the - // "CheckProgressMade == true, but not yet arrived" branch — a - // no-progress tick never reaches it at all (that tick only - // increments FailProgressCount). So the fail-distance trigger - // requires: progress WAS made (rate >= 0.25 both ways) toward the - // target, arrival not yet reached, AND total displacement from - // StartingPosition exceeds FailDistance — e.g. the mover overshot - // past the target along a path that looped far from the start. - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = 5f, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); - h.DrainPendingMotions(); - - // Mover advanced to (8,0,0): 12 units closed toward the target over - // 2s (rate 6/s, passes both incremental+overall) but has traveled - // 8 units from StartingPosition(0,0,0) — over FailDistance(5) — - // while still 12 units short of arrival (dto=0.6). - h.WorldPosition = new Position(1u, new Vector3(8f, 0f, 0f), Quaternion.Identity); - h.Advance(2.0); - - h.Manager.HandleMoveToPosition(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void HandleMoveToPosition_NoProgressButWithinFailDistance_StaysActive_NoCancel() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = 100f, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); - h.DrainPendingMotions(); - - h.WorldPosition = new Position(1u, new Vector3(0f, 1f, 0f), Quaternion.Identity); // 1 unit from start, well under FailDistance - h.Advance(2.0); - - h.Manager.HandleMoveToPosition(); - - Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); - } - - // ── FailProgressCount write-only bookkeeping (§8, do-not-invent) ─────── - - [Fact] - public void FailProgressCount_IncrementsOnStall_ButNoGiveUpThresholdExists() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = float.MaxValue, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); - h.DrainPendingMotions(); - - // Stall many times (no progress, not interpolating, not animating) — - // FailProgressCount should climb with NO cap and NO resulting - // cancellation, since the counter is write-only in retail (§8). - for (int i = 0; i < 20; i++) - { - h.Advance(2.0); - h.Manager.HandleMoveToPosition(); - } - - Assert.True(h.Manager.FailProgressCount >= 20); - Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); // still active, no give-up - } - - [Fact] - public void FailProgressCount_NotIncremented_WhenInterpolating() - { - var h = new MoveToManagerHarness { IsInterpolatingValue = true }; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - var p = new MovementParameters { DistanceToObject = 0.6f, FailDistance = float.MaxValue, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); - h.DrainPendingMotions(); - - h.Advance(2.0); - h.Manager.HandleMoveToPosition(); - - Assert.Equal(0u, h.Manager.FailProgressCount); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerAuxTurnTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerAuxTurnTests.cs deleted file mode 100644 index 2fadd0f8..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerAuxTurnTests.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V2 — HandleMoveToPosition Phase 1 aux-turn steering -/// (00529d80, raw 307187-307280) per r4-moveto-decomp.md §6b: the -/// 20°/340° deadband, direction pick (diff ≥ 180 → TurnLeft else TurnRight), -/// no-redundant-reissue, and the "stop aux while animating" branch. -/// -public sealed class MoveToManagerAuxTurnTests -{ - /// Drives a manager into an active MoveToPosition (heading - /// already settled so BeginMoveForward runs on the first BeginNextNode), - /// then drains the interp's pending_motions queue via a synthetic - /// MotionDone callback — standing in for "the WalkForward/RunForward - /// animation-table dispatch cycle has started/completed" the way a real - /// AnimationSequencer would signal it. Without this, MotionsPending() - /// stays true forever (BeginMoveForward's own _DoMotion dispatch - /// enqueues a node that nothing else in this bare harness ever pops), - /// and HandleMoveToPosition's Phase 1 would perpetually take the - /// "animating, stop aux" branch — never exercising the deadband/turn - /// logic this test file targets. - private static MoveToManagerHarness ArmMoving(float initialHeading, Vector3 targetXY) - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = initialHeading; - - var p = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, targetXY, Quaternion.Identity), p); - - h.DrainPendingMotions(); - - return h; - } - - [Fact] - public void WithinDeadband_NoAuxTurnIssued() - { - // Target due east (heading 90); mover already facing 85 -> diff 5, - // inside [0,20] deadband. - var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f)); - h.Heading = 85f; // simulate drift after the initial turn-to-face completed - - h.Manager.HandleMoveToPosition(); - - Assert.Equal(0u, h.Manager.AuxCommand); - } - - [Fact] - public void JustOutsideDeadband_Positive_IssuesTurnRight() - { - var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f)); - h.Heading = 40f; // diff = 90-40 = 50 -> outside [0,20]∪[340,360) - - h.Manager.HandleMoveToPosition(); - - Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand); - } - - [Fact] - public void DiffAtOrAbove180_IssuesTurnLeft() - { - var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f)); - h.Heading = 300f; // diff = 90-300 = -210 -> +360 = 150... need >=180 for TurnLeft; pick 260. - h.Heading = 260f; // diff = 90-260=-170 -> +360=190 (>=180) -> TurnLeft - - h.Manager.HandleMoveToPosition(); - - Assert.Equal(MotionCommand.TurnLeft, h.Manager.AuxCommand); - } - - [Fact] - public void DeadbandUpperBoundary_340_NoTurn() - { - var h = ArmMoving(initialHeading: 0f, targetXY: new Vector3(0f, 20f, 0f)); // target heading 0 - h.Heading = 20f; // diff = 0-20=-20 -> +360=340 -> boundary INCLUSIVE (diff >= 340) - - h.Manager.HandleMoveToPosition(); - - Assert.Equal(0u, h.Manager.AuxCommand); - } - - [Fact] - public void DeadbandLowerBoundary_20_NoTurn() - { - var h = ArmMoving(initialHeading: 0f, targetXY: new Vector3(0f, 20f, 0f)); // target heading 0 - h.Heading = -20f % 360f; // normalize below - h.Heading = 340f; // diff = 0-340 = -340 -> +360=20 -> boundary INCLUSIVE (diff <= 20) - - h.Manager.HandleMoveToPosition(); - - Assert.Equal(0u, h.Manager.AuxCommand); - } - - [Fact] - public void NoRedundantReissue_SameDirectionTwice_DoesNotRedispatch() - { - var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f)); - h.Heading = 40f; // outside deadband -> TurnRight - - h.Manager.HandleMoveToPosition(); - uint firstAux = h.Manager.AuxCommand; - Assert.Equal(MotionCommand.TurnRight, firstAux); - - // Drain the interp's pending_motions queue (the TurnRight dispatch - // just enqueued a node) so Phase 1's "not animating" branch runs - // again on the second tick — otherwise MotionsPending() would stay - // true and Phase 1 would take the "animating, stop aux" branch - // instead of exercising the redundant-reissue guard this test - // targets. - h.DrainPendingMotions(); - - int stopCallsBefore = h.StopCompletelyCalls; // unrelated counter, just for isolation - - // Second tick, still outside deadband, same direction -> _DoMotion - // should NOT be re-issued (turn != AuxCommand test fails since - // AuxCommand already equals TurnRight) — assert AuxCommand is - // unchanged (still TurnRight) as the observable proxy. - h.Manager.HandleMoveToPosition(); - - Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand); - Assert.Equal(stopCallsBefore, h.StopCompletelyCalls); - } - - [Fact] - public void AnimatingMotionsPending_StopsAuxTurn_DoesNotStartNew() - { - var h = ArmMoving(initialHeading: 90f, targetXY: new Vector3(20f, 0f, 0f)); - h.Heading = 40f; - h.Manager.HandleMoveToPosition(); - Assert.Equal(MotionCommand.TurnRight, h.Manager.AuxCommand); - - // Simulate an animation-table motion still pending by queueing a - // node onto the REAL MotionInterpreter's pending_motions. - h.Interp.AddToQueue(0, MotionCommand.WalkForward, 0); - Assert.True(h.Interp.MotionsPending()); - - h.Manager.HandleMoveToPosition(); - - Assert.Equal(0u, h.Manager.AuxCommand); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerBeginMoveForwardTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerBeginMoveForwardTests.cs deleted file mode 100644 index 9535c870..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerBeginMoveForwardTests.cs +++ /dev/null @@ -1,157 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V2 — MoveToManager::BeginMoveForward (00529a00, raw -/// 306957-307042) per r4-moveto-decomp.md §4c: dispatched motion id / hold -/// key, the write-back to the STORED params, and the progress-clock seed. -/// Also exercises the run→walk demote inside WalkRunThreshhold (the -/// R3 visual-pass expected-diff this closes). -/// -public sealed class MoveToManagerBeginMoveForwardTests -{ - [Fact] - public void FarFromTarget_CanRunCanWalk_DispatchesRunForward() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; // already facing target — no aux turn needed - - var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f }; - // Distance far beyond threshold+dto -> Run. - h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p); - - // MoveToPosition's node plan queues [TurnToHeading(face)] first since - // heading(0->target)=90 != current heading is not tested here (we - // set Heading=90 already so diff=0, GetCommand still picks motion - // because distance is huge, so a turn node is queued anyway — but - // since diff==0 the queued turn will complete immediately in - // BeginNextNode's synchronous dispatch, landing directly on - // BeginMoveForward). - // ForwardCommand (post-adjust_motion, dispatched to the interp) is - // RunForward; CurrentCommand (the manager's OWN field) stores the - // PRE-adjust command GetCommand chose — get_command's own body only - // ever returns WalkForward/WalkBackward/0 (§5c) — the Run promotion - // happens downstream, inside adjust_motion (_DoMotion §7a), and is - // never written back into CurrentCommand. - Assert.Equal(MotionCommand.RunForward, h.ForwardCommand); - Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply); - Assert.Equal(MotionCommand.WalkForward, h.Manager.CurrentCommand); - } - - [Fact] - public void WithinWalkRunThreshold_DemotesToWalk() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f }; - // dist - dto = 10 - 0.6 = 9.4 <= 15 -> walk. - h.Manager.MoveToPosition(new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), p); - - Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand); - Assert.Equal(HoldKey.None, h.Manager.Params.HoldKeyToApply); - } - - [Fact] - public void CanCharge_FastPathWins_RunsEvenWhenClose() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, CanCharge = true }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(2f, 0f, 0f), Quaternion.Identity), p); - - Assert.Equal(MotionCommand.RunForward, h.ForwardCommand); - Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply); - } - - [Fact] - public void HoldKeyWriteBack_ToStoredParams_NotJustLocalCopy() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, HoldKeyToApply = HoldKey.Invalid }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p); - - Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply); - } - - [Fact] - public void ProgressClockSeeded_PreviousAndOriginalEqualCurrentDistance() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - h.CurTime = 5.0; - - // UseSpheres defaults true on a fresh MovementParameters, and - // MoveToPosition's own params (copied into Params BEFORE - // BeginMoveForward runs) drive GetCurrentDistance's use_spheres - // branch: cylinder distance = center distance - ownRadius(0.5) - - // targetRadius(0, position moves always zero SoughtObjectRadius). - var p = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); - - Assert.Equal(20f, h.Manager.PreviousDistance, 2); - Assert.Equal(20f, h.Manager.OriginalDistance, 2); - Assert.Equal(5.0, h.Manager.PreviousDistanceTime, 3); - Assert.Equal(5.0, h.Manager.OriginalDistanceTime, 3); - } - - [Fact] - public void ProgressClockSeeded_UseSpheresDefault_UsesCylinderDistance() - { - var h = new MoveToManagerHarness { OwnRadius = 0.5f }; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - h.CurTime = 5.0; - - var p = new MovementParameters { DistanceToObject = 0.6f }; // UseSpheres=true (default) - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), p); - - // center distance 20 - ownRadius 0.5 - targetRadius 0 (position - // moves zero SoughtObjectRadius, §3c) = 19.5. - Assert.Equal(19.5f, h.Manager.PreviousDistance, 2); - Assert.Equal(19.5f, h.Manager.OriginalDistance, 2); - } - - [Fact] - public void CancelMoveToBit_ClearedOnLocalParams_DoesNotSelfCancel() - { - // If the 0x8000 CancelMoveTo bit were NOT cleared on the local - // params passed into _DoMotion, InterruptCurrentMovement-style - // cancellation logic downstream could tear down THIS moveto before - // it starts. We assert the observable effect: the manager is still - // MovingTo after BeginMoveForward dispatches. - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - - Assert.True(h.Manager.IsMovingTo()); - } - - [Fact] - public void NoPhysicsObj_CancelsWithNoPhysicsObjectCode() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - Assert.True(h.Manager.IsMovingTo()); - - h.Manager.HasPhysicsObj = false; - h.Manager.BeginMoveForward(); - - Assert.False(h.Manager.IsMovingTo()); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs deleted file mode 100644 index 572f1865..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerCompletionSeamTests.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V5 — the MoveToComplete CLIENT-ADDITION seam contract (see the -/// seam's doc on ): fires with -/// on NATURAL COMPLETION (the -/// empty-queue exits, both sticky -/// and non-sticky, plus CleanUpAndCallWeenie's instant-success path) -/// and NEVER on . The App layer's -/// AD-27 re-anchor (deferred Use/PickUp re-send on arrival) depends on -/// exactly this split: arrival fires the deferred action once; a user-input -/// cancel must not. -/// -public sealed class MoveToManagerCompletionSeamTests -{ - /// Arms a position move 5 m dead ahead (heading 90 = +X, - /// facing it) and drives the scripted body to arrival via UseTime. - private static MoveToManagerHarness DriveToArrival() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - h.Manager.MoveToPosition( - new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity), - new MovementParameters { UseSpheres = false, DistanceToObject = 0.6f }); - h.DrainPendingMotions(); - - for (int i = 0; i < 200 && h.Manager.IsMovingTo(); i++) - { - h.Tick(); - var cur = h.WorldPosition.Frame.Origin; - h.WorldPosition = new Position( - 1u, cur + new Vector3(0.2f, 0f, 0f), Quaternion.Identity); - h.Manager.UseTime(); - h.DrainPendingMotions(); - } - - Assert.False(h.Manager.IsMovingTo(), - "scripted drive must reach arrival within the tick budget"); - return h; - } - - [Fact] - public void NaturalArrival_FiresMoveToComplete_OnceWithNone() - { - var h = DriveToArrival(); - - Assert.Single(h.MoveToCompleteCalls); - Assert.Equal(WeenieError.None, h.MoveToCompleteCalls[0]); - } - - [Fact] - public void AfterArrival_ExtraUseTimeTicks_DoNotRefire() - { - var h = DriveToArrival(); - - for (int i = 0; i < 10; i++) - { - h.Tick(); - h.Manager.UseTime(); - } - - Assert.Single(h.MoveToCompleteCalls); - } - - [Fact] - public void CancelMoveTo_DoesNotFireMoveToComplete() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - h.Manager.MoveToPosition( - new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), - new MovementParameters { UseSpheres = false }); - Assert.True(h.Manager.IsMovingTo()); - - h.Manager.CancelMoveTo(WeenieError.ActionCancelled); - - Assert.False(h.Manager.IsMovingTo()); - Assert.Empty(h.MoveToCompleteCalls); - } - - [Fact] - public void StickyCompletion_FiresMoveToComplete_AfterStickToHandoff() - { - // In-range sticky object move: MoveToObject arms the tracker; the - // first Ok callback finds the target already inside - // DistanceToObject, so no motion nodes are needed and BeginNextNode - // lands straight on the empty-queue STICKY exit — StickTo gets the - // pre-CleanUp tlid/radius/height, then the completion seam fires. - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - h.Manager.MoveToObject( - objectId: 0x1000u, topLevelId: 0x1000u, radius: 0.5f, height: 1f, - new MovementParameters { Sticky = true, DistanceToObject = 5f }); - h.DrainPendingMotions(); - - var target = new Position(1u, new Vector3(1f, 0f, 0f), Quaternion.Identity); - h.Manager.HandleUpdateTarget( - new TargetInfo(0x1000u, TargetStatus.Ok, target, target)); - h.DrainPendingMotions(); - - // Some builds need the turn/settle nodes ticked through first. - for (int i = 0; i < 50 && h.Manager.IsMovingTo(); i++) - { - h.Tick(); - h.Manager.UseTime(); - h.DrainPendingMotions(); - } - - Assert.False(h.Manager.IsMovingTo()); - Assert.Single(h.StickToCalls); - Assert.Equal((0x1000u, 0.5f, 1f), h.StickToCalls[0]); - Assert.Single(h.MoveToCompleteCalls); - Assert.Equal(WeenieError.None, h.MoveToCompleteCalls[0]); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerEndToEndTableDriveTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerEndToEndTableDriveTests.cs deleted file mode 100644 index 59647fb7..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerEndToEndTableDriveTests.cs +++ /dev/null @@ -1,154 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V2 — scripted end-to-end table drive (r4-port-plan.md §3 V2 test -/// list): positions fed per tick -> expected node pops + dispatched motion -/// ids/hold keys, including the run-to-walk demote as the mover closes to -/// within WalkRunThreshhold of the target — the exact R3 visual-pass -/// expected-diff this closes ("auto-walk-at-run walk-pace legs (R4)"). -/// -public sealed class MoveToManagerEndToEndTableDriveTests -{ - [Fact] - public void ChaseSequence_TurnFirst_ThenRun_ThenDemoteToWalk_ThenArrive() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; // facing NORTH; target is due EAST -> a turn-to-face node is required first. - - var p = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), p); - - // Step 1: node plan = [TurnToHeading(90), MoveToPosition]. Heading - // diff (0->90) is large -> BeginTurnToHeading armed a real turn - // (not the "already there" early-out). - Assert.Equal(2, System.Linq.Enumerable.Count(h.Manager.PendingActions)); - Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); - h.DrainPendingMotions(); - - // Step 2: the turn completes (heading snaps to 90 via - // HandleTurnToHeading's arrival branch) -> BeginNextNode pops to the - // MoveToPosition node -> BeginMoveForward dispatches. Far from the - // target (100 units, minus threshold 15 well exceeded) -> RunForward. - h.Heading = 91f; // "passed" 90 - h.Manager.HandleTurnToHeading(); - h.DrainPendingMotions(); - - Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); - Assert.Single(h.Manager.PendingActions); - Assert.Equal(MotionCommand.RunForward, h.ForwardCommand); - Assert.Equal(HoldKey.Run, h.Manager.Params.HoldKeyToApply); - - // Step 3: close the distance to just inside the walk/run threshold. - // Phase 2 of HandleMoveToPosition doesn't re-run get_command; only - // BeginMoveForward does (dispatched once per node, on arm) — so the - // walk-demote re-evaluation requires a fresh moveto issue. Route it - // through PerformMovement (NOT a direct MoveToPosition call) — this - // is the retail-faithful re-issue shape (§3a: cancel current + - // unstick FIRST, THEN dispatch) and avoids stacking a stale node - // onto the still-populated queue the way a bare second - // MoveToPosition call would (MoveToPosition itself does not drain; - // only PerformMovement's CancelMoveTo call does). - h.WorldPosition = new Position(1u, new Vector3(90f, 0f, 0f), Quaternion.Identity); // 10 units from target - h.Heading = 90f; // already facing it - var pClose = new MovementParameters { DistanceToObject = 0.6f, WalkRunThreshhold = 15f, UseSpheres = false }; - h.Manager.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = new Position(1u, new Vector3(100f, 0f, 0f), Quaternion.Identity), - Params = pClose, - }); - // PerformMovement's CancelMoveTo (§3a) stops the in-flight motion - // FIRST, which itself enqueues a pending_motions node -- so - // BeginTurnToHeading's wait-for-anims gate (§4d) defers the "already - // facing it" early-out to the NEXT drain, not synchronously inside - // this call. Drain twice: once for the cancel's own stop dispatch, - // once more for whatever BeginTurnToHeading/BeginMoveForward issues - // once armed. - h.DrainPendingMotions(); - h.Manager.BeginNextNode(); // re-check the head node now that anims have drained - h.DrainPendingMotions(); - - Assert.Single(h.Manager.PendingActions); // the stale queue was drained by PerformMovement's CancelMoveTo; the "already facing it" turn completed instantly once anims cleared. - - // dist=10, dto=0.6 -> dist-dto=9.4 <= 15 -> WALK. - Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand); - Assert.Equal(HoldKey.None, h.Manager.Params.HoldKeyToApply); - - // Step 4: arrive. - h.WorldPosition = new Position(1u, new Vector3(99.7f, 0f, 0f), Quaternion.Identity); - h.Advance(2.0); - h.Manager.HandleMoveToPosition(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - Assert.Empty(h.Manager.PendingActions); - } - - [Fact] - public void FleeSequence_WalksBackward_InsideMinBand_ArrivesWhenFarEnough() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, new Vector3(3f, 0f, 0f), Quaternion.Identity); - h.Heading = 270f; // facing the threat (target) which is behind at origin -- WalkBackward needs no turn. - - // towards_and_away band: dist(3) inside [MinDistance(5)... wait need - // dist < min for the inside-band WalkBackward pick]. Use MinDistance - // 5 with mover at distance 3 from the target (origin) -> inside - // band -> WalkBackward, no turn node queued (§5d asymmetry). - var p = new MovementParameters { MoveTowards = true, MoveAway = true, MinDistance = 5f, DistanceToObject = 8f, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, Vector3.Zero, Quaternion.Identity), p); - - Assert.True(h.Manager.MovingAway); - Assert.Equal(MotionCommand.WalkBackward, h.Manager.CurrentCommand); // pre-adjust id (get_command's own return) - // adjust_motion normalizes WalkBackward -> WalkForward with a - // negative BackwardsFactor-scaled speed, dispatched as ForwardCommand. - Assert.Equal(MotionCommand.WalkForward, h.ForwardCommand); - Assert.True(h.ForwardSpeed < 0f); - h.DrainPendingMotions(); - - // Flee to distance 6 (>= MinDistance 5) -> arrived. - h.WorldPosition = new Position(1u, new Vector3(6f, 0f, 0f), Quaternion.Identity); - h.Advance(2.0); - h.Manager.HandleMoveToPosition(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void TurnToObjectSequence_DeferredStart_ThenRetargetIgnored_ThenArrivesOnHeadingPass() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; - - const uint targetId = 0x5000CCCCu; - h.Manager.TurnToObject(targetId, targetId, new MovementParameters()); - Assert.Empty(h.Manager.PendingActions); // deferred (§3d) - - var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90 - h.Manager.HandleUpdateTarget(new TargetInfo(targetId, TargetStatus.Ok, target, target)); - - Assert.True(h.Manager.Initialized); - Assert.Single(h.Manager.PendingActions); - Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); - h.DrainPendingMotions(); - - // Retarget while running: TurnToObject gets no handling (heading frozen). - var target2 = new Position(1u, new Vector3(0f, 10f, 0f), Quaternion.Identity); // heading 0 - h.Manager.HandleUpdateTarget(new TargetInfo(targetId, TargetStatus.Ok, target2, target2)); - var soughtBefore = h.Manager.SoughtPosition; - Assert.Equal(soughtBefore, h.Manager.SoughtPosition); // sanity: unchanged by its own read - - // Complete the turn toward the ORIGINAL (frozen) heading (90), not target2's. - h.Heading = 91f; - h.Manager.HandleTurnToHeading(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - Assert.Equal(90f, h.Heading, 2); // snapped to the frozen heading, unaffected by the retarget. - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerHandleUpdateTargetTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerHandleUpdateTargetTests.cs deleted file mode 100644 index 255c77fd..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerHandleUpdateTargetTests.cs +++ /dev/null @@ -1,158 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V2 — HandleUpdateTarget (0052a7d0, raw 307802-307867, -/// decomp §6d): the P4 TargetTracker feed's deferred-start lifecycle -/// (Initialized=false = the first callback vs true = an in-flight retarget), -/// context/target-id filtering, self-target instant success, NoObject vs -/// ObjectGone status handling, and the retarget progress-clock reset. -/// -public sealed class MoveToManagerHandleUpdateTargetTests -{ - private const uint TargetId = 0x50004444u; - - private static MoveToManagerHarness ArmMoveToObject(float ownRadius = 0.5f, float ownHeight = 2f) - { - var h = new MoveToManagerHarness { OwnRadius = ownRadius, OwnHeight = ownHeight }; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; - h.Manager.MoveToObject(TargetId, TargetId, radius: 1f, height: 2f, new MovementParameters()); - return h; - } - - [Fact] - public void IgnoresUpdate_ForADifferentTarget() - { - var h = ArmMoveToObject(); - var wrongTargetPos = new Position(1u, new Vector3(5f, 5f, 0f), Quaternion.Identity); - - h.Manager.HandleUpdateTarget(new TargetInfo(0x59999999u, TargetStatus.Ok, wrongTargetPos, wrongTargetPos)); - - Assert.False(h.Manager.Initialized); - Assert.Empty(h.Manager.PendingActions); - } - - [Fact] - public void FirstCallback_NonOkStatus_CancelsAsNoObject() - { - var h = ArmMoveToObject(); - var pos = new Position(1u, Vector3.Zero, Quaternion.Identity); - - h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.ExitWorld, pos, pos)); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void FirstCallback_OkStatus_BuildsNodePlan_SetsInitialized() - { - var h = ArmMoveToObject(); - var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); - - h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target)); - - Assert.True(h.Manager.Initialized); - Assert.NotEmpty(h.Manager.PendingActions); - } - - [Fact] - public void FirstCallback_OrdinaryTarget_DoesNotFireMoveToComplete() - { - // MoveToObject's OWN self-target branch (§3b) already short-circuits - // via CleanUp+StopCompletely BEFORE any HandleUpdateTarget ever - // fires for a same-id target — so HandleUpdateTarget's self-target - // instant-success path (§6d: "top_level_object_id == - // physics_obj->id") is reachable only in the deferred-start window, - // and is covered by construction in - // MoveToManagerNodePlanTests.MoveToObject_SelfTarget_* - // (MoveToObject never even reaches SetTarget for a self-id, so the - // callback path is dead in practice — retail's redundant guard). - // This test isolates the ORDINARY path: MoveToComplete's only - // trigger is CleanUpAndCallWeenie, never a plain node-plan build. - var h = ArmMoveToObject(); - var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); - - h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target)); - - Assert.Empty(h.MoveToCompleteCalls); - } - - [Fact] - public void Retarget_NonOkStatus_CancelsAsObjectGone() - { - var h = ArmMoveToObject(); - var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); - h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target)); - Assert.True(h.Manager.Initialized); - - h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.ExitWorld, target, target)); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void Retarget_UpdatesPositions_ResetsProgressClock_DoesNotRequeueNodes() - { - var h = ArmMoveToObject(); - var target1 = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); - h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target1, target1)); - int nodeCountAfterFirst = System.Linq.Enumerable.Count(h.Manager.PendingActions); - Assert.True(nodeCountAfterFirst > 0); - - h.Advance(3.0); // simulate time passing, progress clock advanced by BeginMoveForward - - var target2 = new Position(1u, new Vector3(20f, 5f, 0f), Quaternion.Identity); - var interp2 = new Position(1u, new Vector3(19f, 5f, 0f), Quaternion.Identity); - h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target2, interp2)); - - Assert.Equal(target2, h.Manager.CurrentTargetPosition); - Assert.Equal(interp2, h.Manager.SoughtPosition); - Assert.Equal(float.MaxValue, h.Manager.PreviousDistance); - Assert.Equal(float.MaxValue, h.Manager.OriginalDistance); - - // Node count unchanged by the retarget itself (no requeue) — the - // running MoveToPosition node keeps steering toward the moved - // CurrentTargetPosition on its own next tick. - Assert.Equal(nodeCountAfterFirst, System.Linq.Enumerable.Count(h.Manager.PendingActions)); - } - - [Fact] - public void Retarget_TurnToObject_GetsNoRetargetHandling_HeadingFrozen() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; - h.Manager.TurnToObject(TargetId, TargetId, new MovementParameters()); - - var target1 = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90 - h.Manager.TurnToObject_Internal(target1); // first callback (direct, mirrors deferred-start call shape) - Assert.True(h.Manager.Initialized); - var soughtAfterFirst = h.Manager.SoughtPosition; - - // A retarget-shaped HandleUpdateTarget call for a TurnToObject - // manager: since Initialized is already true, this takes the - // "retarget" branch, which only updates state for MoveToObject — - // TurnToObject gets NO handling at all (decomp §6d note). - var target2 = new Position(1u, new Vector3(0f, 10f, 0f), Quaternion.Identity); // heading 0 - h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target2, target2)); - - Assert.Equal(soughtAfterFirst, h.Manager.SoughtPosition); // untouched - } - - [Fact] - public void NoPhysicsObj_CancelsWithNoPhysicsObjectCode() - { - var h = ArmMoveToObject(); - h.Manager.HasPhysicsObj = false; - - var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); - h.Manager.HandleUpdateTarget(new TargetInfo(TargetId, TargetStatus.Ok, target, target)); - - Assert.False(h.Manager.IsMovingTo()); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerLifecycleTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerLifecycleTests.cs deleted file mode 100644 index fa4527e6..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerLifecycleTests.cs +++ /dev/null @@ -1,100 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V2 — MoveToManager construction / InitializeLocalVariables -/// (00529250, raw 306490-306534) / Destroy (005294b0) / -/// is_moving_to (00529220). Per r4-moveto-decomp.md §1: the ctor -/// zeroes the FLAGS WORD + context_id only (NOT ACE's A2/A3 full-struct-reset -/// transposition — scalar param fields keep stale values since every entry -/// point copies all ten fields anyway), resets both progress-clock pairs to -/// FLT_MAX/now, and resets Sought/CurrentTarget positions but NOT -/// StartingPosition. -/// -public sealed class MoveToManagerLifecycleTests -{ - [Fact] - public void FreshManager_MovementTypeIsInvalid() - { - var h = new MoveToManagerHarness(); - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - Assert.False(h.Manager.IsMovingTo()); - } - - [Fact] - public void FreshManager_ProgressClocksAreFltMax() - { - var h = new MoveToManagerHarness(); - Assert.Equal(float.MaxValue, h.Manager.PreviousDistance); - Assert.Equal(float.MaxValue, h.Manager.OriginalDistance); - } - - [Fact] - public void FreshManager_BitfieldFlagsAllClear_ScalarsUntouchedByCtorReset() - { - // InitializeLocalVariables clears ONLY the bitfield + context_id. - // The scalar fields (DistanceToObject etc.) are NOT part of that - // clear — but since Params starts as `new MovementParameters()` - // (retail ctor defaults), the scalars already hold their defaults - // here; the "stale values survive InitializeLocalVariables" claim is - // exercised by MoveToManagerCancelAndCleanupTests (a scalar surviving - // a CleanUp/InitializeLocalVariables round-trip after being changed - // by an entry point). - var h = new MoveToManagerHarness(); - Assert.False(h.Manager.Params.CanWalk); - Assert.False(h.Manager.Params.CanRun); - Assert.False(h.Manager.Params.MoveTowards); - Assert.False(h.Manager.Params.CancelMoveTo); - Assert.Equal(0u, h.Manager.Params.ContextId); - } - - [Fact] - public void FreshManager_SoughtPositionAndCurrentTargetAreIdentityFrameAtCellZero() - { - // NOT default(Position) — default(Quaternion) is the ZERO - // quaternion, not identity. Retail resets to a genuine identity - // frame (decomp §1c) at cell id 0. - var h = new MoveToManagerHarness(); - var expected = new Position(0u, System.Numerics.Vector3.Zero, System.Numerics.Quaternion.Identity); - Assert.Equal(expected, h.Manager.SoughtPosition); - Assert.Equal(expected, h.Manager.CurrentTargetPosition); - Assert.Equal(0f, MoveToMath.GetHeading(h.Manager.SoughtPosition.Frame.Orientation)); - } - - [Fact] - public void FreshManager_PendingActionsEmpty() - { - var h = new MoveToManagerHarness(); - Assert.Empty(h.Manager.PendingActions); - } - - [Fact] - public void Destroy_DrainsPendingActions_ThenReInitializes() - { - var h = new MoveToManagerHarness(); - h.Manager.AddMoveToPositionNode(); - h.Manager.AddTurnToHeadingNode(90f); - Assert.Equal(2, System.Linq.Enumerable.Count(h.Manager.PendingActions)); - - h.Manager.Destroy(); - - Assert.Empty(h.Manager.PendingActions); - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void IsMovingTo_TrueAfterMoveToPosition_FalseAfterCancel() - { - var h = new MoveToManagerHarness(); - h.Manager.MoveToPosition(new Position(1u, new System.Numerics.Vector3(10f, 0f, 0f), System.Numerics.Quaternion.Identity), new MovementParameters()); - - Assert.True(h.Manager.IsMovingTo()); - - h.Manager.CancelMoveTo(WeenieError.ActionCancelled); - - Assert.False(h.Manager.IsMovingTo()); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerNodePlanTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerNodePlanTests.cs deleted file mode 100644 index 53f1af49..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerNodePlanTests.cs +++ /dev/null @@ -1,306 +0,0 @@ -using System.Linq; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V2 — node-plan goldens for each movement type. Per r4-moveto-decomp.md -/// §3c (MoveToPosition), §6f (MoveToObject_Internal), §3e (TurnToHeading), -/// §6g (TurnToObject_Internal): the SHAPE of pending_actions right -/// after the entry point (deferred moves) or the internal builder (object -/// moves, first target callback) runs. -/// -public sealed class MoveToManagerNodePlanTests -{ - // ── MoveToPosition (§3c): [TurnToHeading(face)] → [MoveToPosition] ──── - - [Fact] - public void MoveToPosition_NeedsMotion_QueuesTurnThenMove() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; - - // Target due east (+X) -> heading 90. Far enough that get_command - // says motion is needed (default DistanceToObject=0.6). - h.Manager.MoveToPosition(new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - - var nodes = h.Manager.PendingActions.ToList(); - Assert.Equal(2, nodes.Count); - Assert.Equal(MovementType.TurnToHeading, nodes[0].Type); - Assert.Equal(90f, nodes[0].Heading, 2); - Assert.Equal(MovementType.MoveToPosition, nodes[1].Type); - } - - [Fact] - public void MoveToPosition_AlreadyInRange_NoMotionNodesQueued() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - - // Target within default DistanceToObject (0.6) -> get_command idles. - h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - - Assert.Empty(h.Manager.PendingActions); - } - - [Fact] - public void MoveToPosition_UseFinalHeading_AppendsAbsoluteFinalTurnNode() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - - var p = new MovementParameters { UseFinalHeading = true, DesiredHeading = 270f }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), p); - - var nodes = h.Manager.PendingActions.ToList(); - // Turn-to-face(90) -> MoveToPosition -> Turn-to-final(270, ABSOLUTE). - Assert.Equal(3, nodes.Count); - Assert.Equal(MovementType.TurnToHeading, nodes[2].Type); - Assert.Equal(270f, nodes[2].Heading, 2); - } - - [Fact] - public void MoveToPosition_UseFinalHeadingOnly_NoMotionNeeded_QueuesOnlyFinalTurn() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - - var p = new MovementParameters { UseFinalHeading = true, DesiredHeading = 45f }; - // Already in range -> no move/turn-to-face nodes, only the final turn. - h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), p); - - var nodes = h.Manager.PendingActions.ToList(); - Assert.Single(nodes); - Assert.Equal(MovementType.TurnToHeading, nodes[0].Type); - Assert.Equal(45f, nodes[0].Heading, 2); - } - - [Fact] - public void MoveToPosition_ClearsStickyBit_EvenIfArgumentRequestedIt() - { - var h = new MoveToManagerHarness(); - var p = new MovementParameters { Sticky = true }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), p); - - Assert.False(h.Manager.Params.Sticky); - } - - [Fact] - public void MoveToPosition_MovementTypeAndStartingPositionSet() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(2u, new Vector3(1f, 2f, 3f), Quaternion.Identity); - - // Distance 10 (> default DistanceToObject 0.6) so the move plan - // actually queues motion and MovementTypeState stays MoveToPosition - // instead of completing instantly via the empty-queue BeginNextNode - // path (see MoveToPosition_AlreadyInRange_NoMotionNodesQueued for - // that degenerate case). - h.Manager.MoveToPosition(new Position(2u, new Vector3(1f, 12f, 3f), Quaternion.Identity), new MovementParameters()); - - Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); - Assert.Equal(h.WorldPosition, h.Manager.StartingPosition); - } - - // ── TurnToHeading (§3e): ONE node, immediate BeginNextNode ───────────── - - [Fact] - public void TurnToHeading_QueuesExactlyOneNode_WithDesiredHeading() - { - var h = new MoveToManagerHarness(); - h.Heading = 0f; - - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 123f }); - - var nodes = h.Manager.PendingActions.ToList(); - // BeginNextNode ran immediately and BeginTurnToHeading may have - // already popped the node if heading matched (it won't here — 123 - // != 0), so the node should still be present as "in flight" (its - // dispatch doesn't remove it — only arrival does). We assert via - // CurrentCommand instead of raw queue count, since BeginNextNode - // does run synchronously. - Assert.Equal(MovementType.TurnToHeading, h.Manager.MovementTypeState); - Assert.NotEqual(0u, h.Manager.CurrentCommand); - Assert.Single(nodes); - Assert.Equal(123f, nodes[0].Heading, 2); - } - - [Fact] - public void TurnToHeading_ClearsStickyBit() - { - var h = new MoveToManagerHarness(); - h.Manager.TurnToHeading(new MovementParameters { Sticky = true, DesiredHeading = 45f }); - Assert.False(h.Manager.Params.Sticky); - } - - [Fact] - public void TurnToHeading_AlreadyFacingTarget_BeginNextNodeCompletesImmediately() - { - var h = new MoveToManagerHarness(); - h.Heading = 90f; - - // DesiredHeading == current heading -> BeginTurnToHeading's - // "already there" branch pops + BeginNextNode -> empty queue, - // non-sticky -> CleanUp + StopCompletely -> back to Invalid. - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - Assert.Empty(h.Manager.PendingActions); - Assert.True(h.StopCompletelyCalls >= 1); - } - - // ── MoveToObject deferred start (§3b + §6f via HandleUpdateTarget) ───── - - [Fact] - public void MoveToObject_NoNodesQueuedUntilTargetCallback() - { - var h = new MoveToManagerHarness(); - h.Manager.MoveToObject(objectId: 0x50002222u, topLevelId: 0x50002222u, radius: 1f, height: 2f, new MovementParameters()); - - Assert.Empty(h.Manager.PendingActions); - Assert.Equal(MovementType.MoveToObject, h.Manager.MovementTypeState); - Assert.False(h.Manager.Initialized); - Assert.Single(h.SetTargetCalls); - Assert.Equal((0u, 0x50002222u, 0.5f, 0.0), h.SetTargetCalls[0]); - } - - [Fact] - public void MoveToObject_PreservesStickyBit_UnlikePositionMoves() - { - var h = new MoveToManagerHarness(); - var p = new MovementParameters { Sticky = true }; - h.Manager.MoveToObject(0x50002222u, 0x50002222u, 1f, 2f, p); - - Assert.True(h.Manager.Params.Sticky); - } - - [Fact] - public void MoveToObject_FirstTargetCallback_BuildsNodePlanViaInternal() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; - - h.Manager.MoveToObject(0x50002222u, 0x50002222u, radius: 0.5f, height: 2f, new MovementParameters()); - - var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); - h.Manager.HandleUpdateTarget(new TargetInfo(0x50002222u, TargetStatus.Ok, target, target)); - - Assert.True(h.Manager.Initialized); - var nodes = h.Manager.PendingActions.ToList(); - Assert.Equal(2, nodes.Count); - Assert.Equal(MovementType.TurnToHeading, nodes[0].Type); - Assert.Equal(90f, nodes[0].Heading, 2); - Assert.Equal(MovementType.MoveToPosition, nodes[1].Type); - } - - [Fact] - public void MoveToObject_UseFinalHeading_RelativeToInterpolatedHeading() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; - - var p = new MovementParameters { UseFinalHeading = true, DesiredHeading = 10f }; - h.Manager.MoveToObject(0x50002222u, 0x50002222u, 0.5f, 2f, p); - - var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90 - h.Manager.HandleUpdateTarget(new TargetInfo(0x50002222u, TargetStatus.Ok, target, target)); - - var nodes = h.Manager.PendingActions.ToList(); - Assert.Equal(3, nodes.Count); - // RELATIVE: iHeading(90) + desired(10) = 100 -- unlike MoveToPosition's absolute form. - Assert.Equal(100f, nodes[2].Heading, 2); - } - - [Fact] - public void MoveToObject_SelfTarget_CleansUpImmediately_NoSetTarget() - { - var h = new MoveToManagerHarness(); - h.Manager.MoveToObject(h.SelfId, h.SelfId, 1f, 2f, new MovementParameters()); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - Assert.Empty(h.SetTargetCalls); - } - - // ── TurnToObject deferred start (§3d + §6g) ──────────────────────────── - - [Fact] - public void TurnToObject_NoNodesQueuedUntilTargetCallback() - { - var h = new MoveToManagerHarness(); - h.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters()); - - Assert.Empty(h.Manager.PendingActions); - Assert.False(h.Manager.Initialized); - Assert.Single(h.SetTargetCalls); - } - - [Fact] - public void TurnToObject_FirstCallback_QueuesExactlyOneTurnNode_FacingObject() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; // already facing north — target due EAST forces an actual turn to queue. - - // DesiredHeading is clobbered (§3d quirk) — it should NOT appear in - // the final node heading; the final heading is purely "face the - // object" (soughtHeading is 0 for a fresh manager). - h.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters { DesiredHeading = 200f }); - - var target = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); // heading 90 (east) - h.Manager.TurnToObject_Internal(target); - - var nodes = h.Manager.PendingActions.ToList(); - Assert.Single(nodes); - Assert.Equal(MovementType.TurnToHeading, nodes[0].Type); - Assert.Equal(90f, nodes[0].Heading, 2); // face-the-object (east), NOT 200 - Assert.True(h.Manager.Initialized); - } - - [Fact] - public void TurnToObject_FirstCallback_AlreadyFacingObject_CompletesImmediately() - { - // When soughtHeading(0) + targetHeading already equals the current - // heading, BeginTurnToHeading's "already there" branch consumes the - // node on the SAME call — the queue is empty by the time - // TurnToObject_Internal returns, but the manager still passed - // through Initialized=true and the CleanUp/StopCompletely tail. - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; - - h.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters()); - var target = new Position(1u, new Vector3(0f, 10f, 0f), Quaternion.Identity); // heading 0 (north) — already facing it. - h.Manager.TurnToObject_Internal(target); - - Assert.Empty(h.Manager.PendingActions); - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void TurnToObject_SelfTarget_CleansUpImmediately() - { - var h = new MoveToManagerHarness(); - h.Manager.TurnToObject(h.SelfId, h.SelfId, new MovementParameters()); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - Assert.Empty(h.SetTargetCalls); - } - - [Fact] - public void TurnToObject_StopCompletelyOnlyWhenBitSet() - { - var h1 = new MoveToManagerHarness(); - h1.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters { StopCompletelyFlag = false }); - Assert.Equal(0, h1.StopCompletelyCalls); - - var h2 = new MoveToManagerHarness(); - h2.Manager.TurnToObject(0x50003333u, 0x50003333u, new MovementParameters { StopCompletelyFlag = true }); - Assert.Equal(1, h2.StopCompletelyCalls); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerStickyAndCancelTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerStickyAndCancelTests.cs deleted file mode 100644 index 2f89d999..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerStickyAndCancelTests.cs +++ /dev/null @@ -1,295 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V2 — BeginNextNode's sticky handoff (00529cb0, raw -/// 307123-307171, decomp §4b) and CancelMoveTo -/// (00529930, raw 306886-306940, decomp §7c) including the -/// reentrancy invariant (r4-port-plan.md §4: CancelMoveTo → -/// CleanUpAndCallWeenie → StopCompletely → InterruptCurrentMovement → -/// CancelMoveTo no-ops on Invalid). -/// -public sealed class MoveToManagerStickyAndCancelTests -{ - // ── Sticky handoff (§4b) — read BEFORE CleanUp, then StickTo ─────────── - - [Fact] - public void StickyArrival_ReadsRadiusHeightTlidBeforeCleanUp_ThenCallsStickTo() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; - - var p = new MovementParameters { Sticky = true }; - h.Manager.MoveToObject(0x50005555u, 0x50005555u, radius: 1.5f, height: 2.5f, p); - Assert.True(h.Manager.Params.Sticky); // preserved by MoveToObject (§3b) - - var target = new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity); // inside default dto -> arrives instantly - h.Manager.HandleUpdateTarget(new TargetInfo(0x50005555u, TargetStatus.Ok, target, target)); - - Assert.Single(h.StickToCalls); - Assert.Equal((0x50005555u, 1.5f, 2.5f), h.StickToCalls[0]); - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // CleanUp ran - } - - [Fact] - public void NonStickyArrival_NoStickToCall_JustCleanUpAndStop() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; - - var p = new MovementParameters { Sticky = false }; - h.Manager.MoveToObject(0x50005555u, 0x50005555u, radius: 1.5f, height: 2.5f, p); - - var target = new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity); - h.Manager.HandleUpdateTarget(new TargetInfo(0x50005555u, TargetStatus.Ok, target, target)); - - Assert.Empty(h.StickToCalls); - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void MoveToPosition_NeverSticks_EvenIfRequested() - { - // §3c: MoveToPosition force-clears the sticky bit — so even an - // arrival that WOULD have stuck (had it been an object move) just - // completes plainly. - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 0f; - - var p = new MovementParameters { Sticky = true, DistanceToObject = 0.6f, UseSpheres = false }; - h.Manager.MoveToPosition(new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity), p); // already in range -> instant complete - - Assert.Empty(h.StickToCalls); - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void StickyHandoff_UsesSoughtRadiusHeight_NotOwnRadiusHeight() - { - var h = new MoveToManagerHarness { OwnRadius = 99f, OwnHeight = 99f }; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - - var p = new MovementParameters { Sticky = true }; - h.Manager.MoveToObject(0x50006666u, 0x50006666u, radius: 3f, height: 4f, p); - - var target = new Position(1u, new Vector3(0.1f, 0f, 0f), Quaternion.Identity); - h.Manager.HandleUpdateTarget(new TargetInfo(0x50006666u, TargetStatus.Ok, target, target)); - - Assert.Equal((0x50006666u, 3f, 4f), h.StickToCalls[0]); // the TARGET's radius/height, not the mover's own. - } - - // ── CancelMoveTo (§7c) — drain + CleanUp + Stop; reentrancy ──────────── - - [Fact] - public void CancelMoveTo_OnInvalidState_IsANoOp() - { - var h = new MoveToManagerHarness(); - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - - h.Manager.CancelMoveTo(WeenieError.ActionCancelled); - - Assert.Equal(0, h.StopCompletelyCalls); // no-op: never even called StopCompletely - Assert.Equal(0, h.ClearTargetCalls); - } - - [Fact] - public void CancelMoveTo_DrainsPendingActions() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - Assert.NotEmpty(h.Manager.PendingActions); - - h.Manager.CancelMoveTo(WeenieError.ActionCancelled); - - Assert.Empty(h.Manager.PendingActions); - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void CancelMoveTo_ErrorArgument_NeverReadInBody_SameBehaviorForAnyCode() - { - // §7c: the WeenieError arg is NEVER read — every code produces - // identical observable behavior (drain + CleanUp + Stop). - var h1 = new MoveToManagerHarness(); - h1.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h1.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - h1.Manager.CancelMoveTo(WeenieError.YouChargedTooFar); - - var h2 = new MoveToManagerHarness(); - h2.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h2.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - h2.Manager.CancelMoveTo(WeenieError.NoObject); - - Assert.Equal(h1.Manager.MovementTypeState, h2.Manager.MovementTypeState); - Assert.Equal(h1.StopCompletelyCalls, h2.StopCompletelyCalls); - } - - [Fact] - public void CancelMoveTo_ClearsTarget_ForObjectMoves() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Manager.MoveToObject(0x50007777u, 0x50007777u, 1f, 2f, new MovementParameters()); - Assert.Single(h.SetTargetCalls); - - h.Manager.CancelMoveTo(WeenieError.ActionCancelled); - - Assert.Equal(1, h.ClearTargetCalls); - } - - [Fact] - public void CancelMoveTo_DoesNotClearTarget_ForPositionMoves() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - - h.Manager.CancelMoveTo(WeenieError.ActionCancelled); - - Assert.Equal(0, h.ClearTargetCalls); // TopLevelObjectId is 0 for a position move -> the clear_target gate never fires. - } - - [Fact] - public void Reentrancy_StopCompletelyCallback_ReenteringCancelMoveTo_NoOps() - { - // r4-port-plan.md §4 reentrancy invariant: the StopCompletely tail - // of CancelMoveTo/CleanUpAndCallWeenie can re-enter - // InterruptCurrentMovement -> CancelMoveTo (this is exactly what - // happens in production: MotionInterpreter.StopCompletely() - // invokes InterruptCurrentMovement, which V5 binds to - // entity.MoveTo.CancelMoveTo). This must no-op because CleanUp - // already reset movement_type to Invalid BEFORE StopCompletely - // runs. Wire the reentrant callback DIRECTLY (bypassing the shared - // harness, which doesn't expose this seam post-construction) to - // prove the invariant against the real CancelMoveTo/CleanUp - // ordering. - var interp = new MotionInterpreter(); - var body = new PhysicsBody { TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active }; - interp.PhysicsObj = body; - - Position worldPosition = new(1u, Vector3.Zero, Quaternion.Identity); - int stopCompletelyCalls = 0; - int reentrantCancelCalls = 0; - MoveToManager? mgr = null; - - mgr = new MoveToManager( - interp, - stopCompletely: () => - { - stopCompletely_body(); - }, - getPosition: () => worldPosition, - getHeading: () => 0f, - setHeading: (h, send) => { }, - getOwnRadius: () => 0.5f, - getOwnHeight: () => 2f, - contact: () => true, - isInterpolating: () => false, - getVelocity: () => Vector3.Zero, - getSelfId: () => 0x50000001u, - setTarget: (a, b, c, d) => { }, - clearTarget: () => { }, - getTargetQuantum: () => 0.0, - setTargetQuantum: q => { }); - - void stopCompletely_body() - { - stopCompletelyCalls++; - // Re-enter: exactly the retail chain - // interp.StopCompletely() -> InterruptCurrentMovement?.Invoke() - // -> entity.MoveTo.CancelMoveTo(ActionCancelled) (V5 binding). - reentrantCancelCalls++; - mgr!.CancelMoveTo(WeenieError.ActionCancelled); - } - - mgr.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - int stopCallsAfterArm = stopCompletelyCalls; // MoveToPosition's own unconditional stop (§3c) — 1 call, no reentrancy (MovementTypeState was already Invalid before this call started, so its OWN reentrant CancelMoveTo-from-StopCompletely no-ops too). - - mgr.CancelMoveTo(WeenieError.ActionCancelled); - - // CancelMoveTo's own StopCompletely fires exactly once more (its - // reentrant CancelMoveTo call finds MovementTypeState already - // Invalid — CleanUp ran first — so it no-ops and does NOT trigger a - // THIRD StopCompletely call). - Assert.Equal(stopCallsAfterArm + 1, stopCompletelyCalls); - Assert.Equal(2, reentrantCancelCalls); // one from MoveToPosition's own stop, one from CancelMoveTo's stop - Assert.Equal(MovementType.Invalid, mgr.MovementTypeState); - } - - [Fact] - public void CleanUpAndCallWeenie_FiresMoveToComplete_WithGivenError() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - - h.Manager.CleanUpAndCallWeenie(WeenieError.None); - - Assert.Single(h.MoveToCompleteCalls); - Assert.Equal(WeenieError.None, h.MoveToCompleteCalls[0]); - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void CleanUpAndCallWeenie_Ordering_MovementTypeAlreadyInvalid_WhenStopCompletelyFires() - { - // §7e: CleanUpAndCallWeenie = CleanUp() THEN StopCompletely() — - // reentrancy-safe ordering (the same ordering CancelMoveTo uses). - // Directly observe MovementTypeState AT THE MOMENT StopCompletely - // is invoked by wiring a probe into the seam. - var interp = new MotionInterpreter(); - var body = new PhysicsBody { TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active }; - interp.PhysicsObj = body; - Position worldPosition = new(1u, Vector3.Zero, Quaternion.Identity); - MovementType? stateDuringStopCompletely = null; - MoveToManager? mgr = null; - - mgr = new MoveToManager( - interp, - stopCompletely: () => stateDuringStopCompletely = mgr!.MovementTypeState, - getPosition: () => worldPosition, - getHeading: () => 0f, - setHeading: (h, send) => { }, - getOwnRadius: () => 0.5f, - getOwnHeight: () => 2f, - contact: () => true, - isInterpolating: () => false, - getVelocity: () => Vector3.Zero, - getSelfId: () => 0x50000001u, - setTarget: (a, b, c, d) => { }, - clearTarget: () => { }, - getTargetQuantum: () => 0.0, - setTargetQuantum: q => { }); - - mgr.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - - mgr.CleanUpAndCallWeenie(WeenieError.None); - - Assert.Equal(MovementType.Invalid, stateDuringStopCompletely); - } - - [Fact] - public void CleanUp_StopsCurrentAndAuxCommands_ClearsTargetForObjectMoves_ThenReinitializes() - { - var h = new MoveToManagerHarness(); - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Manager.MoveToObject(0x50008888u, 0x50008888u, 1f, 2f, new MovementParameters()); - var target = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity); - h.Manager.HandleUpdateTarget(new TargetInfo(0x50008888u, TargetStatus.Ok, target, target)); - Assert.NotEqual(0u, h.Manager.CurrentCommand); - - h.Manager.CleanUp(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - Assert.Equal(1, h.ClearTargetCalls); - Assert.Equal(0u, h.Manager.CurrentCommand); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTestHarness.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTestHarness.cs deleted file mode 100644 index 9a3a96c1..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTestHarness.cs +++ /dev/null @@ -1,138 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V2 — shared scripted fake interp-sink/provider harness for -/// conformance tests (r4-port-plan.md §3 V2: -/// "Use a scripted fake interp-sink/provider harness — NO real sequencer -/// needed; the manager drives the interp seams; assert the call sequences -/// + state"). Wraps a REAL bound to a -/// minimal always-grounded (so -/// _DoMotion/_StopMotion's adjust_motion + -/// DoInterpretedMotion/StopInterpretedMotion chain runs for -/// real, dispatch treated as always-succeeding since no -/// is wired — matching -/// DoInterpretedMotion's documented null-sink posture), and exposes -/// every ctor seam as a mutable, inspectable -/// field so tests can script position/heading/contact/target-tracker -/// behavior and assert on call sequences. -/// -internal sealed class MoveToManagerHarness -{ - public readonly MotionInterpreter Interp = new(); - public readonly PhysicsBody Body = new(); - - /// Scripted world position + cell (defaults to cell 1, origin). - public Position WorldPosition = new(1u, Vector3.Zero, Quaternion.Identity); - - /// Scripted compass heading, degrees (P5 convention). - public float Heading; - - /// Records every SetHeading(heading, send) call. - public readonly List<(float Heading, bool Send)> SetHeadingCalls = new(); - - public float OwnRadius = 0.5f; - public float OwnHeight = 2.0f; - - public bool ContactValue = true; - public bool IsInterpolatingValue; - public Vector3 Velocity = Vector3.Zero; - - public uint SelfId = 0x50000001u; - - /// Records every StopCompletely() call (count only — - /// retail's CPhysicsObj::StopCompletely takes no args at this - /// seam level). - public int StopCompletelyCalls; - - public readonly List<(uint ContextId, uint ObjectId, float Radius, double Quantum)> SetTargetCalls = new(); - public int ClearTargetCalls; - public double TargetQuantum; - public readonly List SetTargetQuantumCalls = new(); - - public int UnstickCalls; - public readonly List<(uint Tlid, float Radius, float Height)> StickToCalls = new(); - public readonly List MoveToCompleteCalls = new(); - - /// Scripted clock — advances by only - /// when a test calls ; reading CurTime alone - /// (e.g. multiple reads within one manager call) does NOT advance it, - /// matching retail's Timer::cur_time being a stable snapshot for - /// the duration of one dispatch. - public double CurTime; - public const double TickSeconds = 1.0 / 30.0; - - public readonly MoveToManager Manager; - - public MoveToManagerHarness() - { - Interp.PhysicsObj = Body; - Body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active; - - Manager = new MoveToManager( - Interp, - stopCompletely: () => StopCompletelyCalls++, - getPosition: () => WorldPosition, - getHeading: () => Heading, - setHeading: (h, send) => { SetHeadingCalls.Add((h, send)); Heading = h; }, - getOwnRadius: () => OwnRadius, - getOwnHeight: () => OwnHeight, - contact: () => ContactValue, - isInterpolating: () => IsInterpolatingValue, - getVelocity: () => Velocity, - getSelfId: () => SelfId, - setTarget: (ctx, obj, radius, quantum) => SetTargetCalls.Add((ctx, obj, radius, quantum)), - clearTarget: () => ClearTargetCalls++, - getTargetQuantum: () => TargetQuantum, - setTargetQuantum: q => { TargetQuantum = q; SetTargetQuantumCalls.Add(q); }, - curTime: () => CurTime); - - Manager.StickTo = (tlid, radius, height) => StickToCalls.Add((tlid, radius, height)); - Manager.MoveToComplete = err => MoveToCompleteCalls.Add(err); - Manager.Unstick = () => UnstickCalls++; - } - - /// Advance the scripted clock by one physics tick (1/30 s). - public void Tick() => CurTime += TickSeconds; - - /// Advance the scripted clock by an arbitrary amount. - public void Advance(double seconds) => CurTime += seconds; - - /// - /// Drains the REAL 's - /// pending_motions queue via synthetic MotionDone - /// callbacks — standing in for "the dispatched motion's animation-table - /// cycle finished", which a live AnimationSequencer/ - /// MotionTableManager would signal in production. Every - /// _DoMotion/_StopMotion call that succeeds enqueues a - /// node (retail AddToQueue, decomp's DoInterpretedMotion - /// body); without draining, - /// stays true forever in this bare harness, which would wedge - /// 's wait-for-anims gate - /// and Phase 1's - /// "animating, stop aux" branch permanently. Call after any manager - /// method that dispatches a motion, before asserting on the NEXT tick's - /// behavior. - /// - public void DrainPendingMotions() - { - while (Interp.MotionsPending()) - Interp.MotionDone(0, true); - } - - /// Current interpreted forward command — the observable proxy - /// for "what motion did MoveToManager just dispatch via _DoMotion", - /// since - /// writes through to - /// when ModifyInterpretedState is set (default true). - public uint ForwardCommand => Interp.InterpretedState.ForwardCommand; - - public uint TurnCommand => Interp.InterpretedState.TurnCommand; - - public float ForwardSpeed => Interp.InterpretedState.ForwardSpeed; -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTurnToHeadingTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTurnToHeadingTests.cs deleted file mode 100644 index bb536d16..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerTurnToHeadingTests.cs +++ /dev/null @@ -1,240 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V2 — BeginTurnToHeading (00529b90, raw 307046-307120, -/// decomp §4d) and HandleTurnToHeading (0052a0c0, raw -/// 307442-307517, decomp §6c) — the direction-pick table (TurnRight ≤180 vs -/// TurnLeft >180), the "already there" early-outs, the -/// MotionsPending wait gate, the arrival snap -/// ( + the ONE set_heading in -/// the whole family), and the PreviousHeading DIFF-seed quirk. -/// -public sealed class MoveToManagerTurnToHeadingTests -{ - // ── BeginTurnToHeading direction pick (§4d) ──────────────────────────── - - [Theory] - [InlineData(0f, 90f, MotionCommand.TurnRight)] // diff=90 <=180 -> TurnRight - [InlineData(0f, 170f, MotionCommand.TurnRight)] // diff=170 <=180 -> TurnRight - [InlineData(0f, 190f, MotionCommand.TurnLeft)] // diff=190 >180 -> TurnLeft - [InlineData(0f, 270f, MotionCommand.TurnLeft)] // diff=270 >180 -> TurnLeft - public void DirectionPick_Table(float currentHeading, float targetHeading, uint expectedTurn) - { - var h = new MoveToManagerHarness(); - h.Heading = currentHeading; - - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = targetHeading }); - - Assert.Equal(expectedTurn, h.Manager.CurrentCommand); - } - - [Fact] - public void DirectionPick_ExactlyAt180_TurnRight_NotStrictlyGreater() - { - // diff > 180 is the TurnLeft gate (strict); exactly 180 stays TurnRight. - var h = new MoveToManagerHarness(); - h.Heading = 0f; - - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 180f }); - - Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); - } - - [Fact] - public void AlreadyThere_DiffLessThanOrEqualEpsilon_PopsImmediately_NoDispatch() - { - var h = new MoveToManagerHarness(); - h.Heading = 90f; - - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); - - Assert.Equal(0u, h.Manager.CurrentCommand); - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - Assert.Empty(h.Manager.PendingActions); - } - - [Fact] - public void AlreadyThere_WrappedNearFullCircle_PopsImmediately() - { - // diff > 180 branch's OWN "already there" check: diff + eps >= 360. - var h = new MoveToManagerHarness(); - h.Heading = 0.0001f; - - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 0f }); - - // diff computed via HeadingDiff(0, 0.0001, TurnRight) ~ -0.0001 -> wraps to ~359.9999 - // which is > 180 -> TurnLeft branch -> diff+eps >= 360 check. - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void WaitsForPendingAnimations_BeforeArmingTurn() - { - var h = new MoveToManagerHarness(); - h.Heading = 0f; - - // Simulate an in-flight animation-table motion BEFORE the turn is armed. - h.Interp.AddToQueue(0, MotionCommand.WalkForward, 0); - Assert.True(h.Interp.MotionsPending()); - - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); - - // BeginNextNode -> BeginTurnToHeading saw MotionsPending() true and - // returned WITHOUT dispatching — CurrentCommand stays 0, the node - // stays queued. - Assert.Equal(0u, h.Manager.CurrentCommand); - Assert.Single(h.Manager.PendingActions); - Assert.Equal(MovementType.TurnToHeading, h.Manager.MovementTypeState); - } - - [Fact] - public void EmptyQueue_CancelsWithNoPhysicsObjectCode() - { - var h = new MoveToManagerHarness(); - // Calling BeginTurnToHeading directly with no queued node -> CancelMoveTo(NoPhysicsObject, per A10). - h.Manager.BeginTurnToHeading(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void PreviousHeadingSeededWithDiff_NotAHeading() - { - var h = new MoveToManagerHarness(); - h.Heading = 0f; - - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); - - // The quirk: PreviousHeading stores the REMAINING DIFF (90), not the - // target heading value coincidentally equal to it here — verify via - // a case where they'd differ. - Assert.Equal(90f, h.Manager.PreviousHeading, 2); - } - - [Fact] - public void PreviousHeadingSeed_DiffersFromTargetHeading_ProvingItsADiffNotAHeading() - { - var h = new MoveToManagerHarness(); - h.Heading = 30f; - - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); - - // diff = HeadingDiff(90, 30, TurnRight) = 60 -- NOT 90 (the target - // heading) and NOT 30 (current heading) -- proves PreviousHeading - // stores the DIFF. - Assert.Equal(60f, h.Manager.PreviousHeading, 2); - } - - // ── HandleTurnToHeading (§6c): arrival snap + progress test ──────────── - - [Fact] - public void HandleTurnToHeading_NotCurrentlyTurning_ReArmsViaBeginTurnToHeading() - { - var h = new MoveToManagerHarness(); - h.Heading = 0f; - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); - h.DrainPendingMotions(); // clear the dispatch so a bare HandleTurnToHeading call doesn't hit the "still turning" path unexpectedly - - // Force CurrentCommand to something that isn't a turn (simulating an - // external interrupt that cleared it without popping the node) — - // exercised via CancelMoveTo would drop everything, so instead just - // confirm the normal flow already armed a turn command. - Assert.True(h.Manager.CurrentCommand is MotionCommand.TurnRight or MotionCommand.TurnLeft); - } - - [Fact] - public void HandleTurnToHeading_Arrival_SnapsHeadingAndSendsTrue() - { - var h = new MoveToManagerHarness(); - h.Heading = 0f; - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); - h.DrainPendingMotions(); - Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); - - // Advance heading to just past the target (heading_greater says we - // passed it) -- simulates the turn animation having rotated us there. - h.Heading = 91f; - - h.Manager.HandleTurnToHeading(); - - // The ONE heading snap in the whole family: SetHeading(90, send:true). - Assert.Contains((90f, true), h.SetHeadingCalls); - Assert.Equal(90f, h.Heading, 2); // snapped to the EXACT node heading, not 91. - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // queue drained -> complete. - } - - [Fact] - public void HandleTurnToHeading_StillTurning_RotationalProgress_ResetsFailCounter() - { - // The first post-BeginTurnToHeading tick compares the LIVE heading - // (still 0, unmoved) against PreviousHeading's quirk-seeded DIFF - // value (170, not a heading) — HeadingDiff(0,170,TurnRight)=190, - // outside (eps,180), so tick 1 reads as NO progress (a numeric - // artifact of the seed, not a real stall) and FailProgressCount - // increments once. From tick 2 onward PreviousHeading holds a REAL - // heading and steady rotation reads as genuine progress. - var h = new MoveToManagerHarness(); - h.Heading = 0f; - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 170f }); - h.DrainPendingMotions(); - Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); - Assert.Equal(170f, h.Manager.PreviousHeading, 2); // diff-seeded (quirk) - - h.Manager.HandleTurnToHeading(); // tick 1 (heading unmoved) -- the seed artifact tick - Assert.Equal(1u, h.Manager.FailProgressCount); - Assert.Equal(0f, h.Manager.PreviousHeading, 2); - - h.Heading = 90f; // tick 2: rotated 90 deg toward the 170 target, hasn't passed it. - h.Manager.HandleTurnToHeading(); - - Assert.Equal(0u, h.Manager.FailProgressCount); // reset by genuine progress - Assert.Equal(90f, h.Manager.PreviousHeading, 2); // updated to the live heading - } - - [Fact] - public void HandleTurnToHeading_NoRotationalProgress_IncrementsFailCounter_WhenNotAnimating() - { - var h = new MoveToManagerHarness(); - h.Heading = 0f; - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 170f }); - h.DrainPendingMotions(); - - // Heading did not move at all -> HeadingDiff(0, 170, TurnRight): - // seeded PreviousHeading was 170; live heading still 0 -> diff = - // HeadingDiff(0, 170, TurnRight) = -170 -> +360 = 190; the progress - // window is (eps,180) exclusive on the high end -- 190 fails it -> - // no progress -> counter increments (not interpolating, not animating). - h.Manager.HandleTurnToHeading(); - - Assert.Equal(1u, h.Manager.FailProgressCount); - } - - [Fact] - public void HandleTurnToHeading_TurnLeftDirection_UsesMirroredHeadingDiff() - { - var h = new MoveToManagerHarness(); - h.Heading = 0f; - // diff = HeadingDiff(190,0,TurnRight) = 190 > 180 -> TurnLeft chosen. - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 190f }); - h.DrainPendingMotions(); - Assert.Equal(MotionCommand.TurnLeft, h.Manager.CurrentCommand); - - // Rotate counter-clockwise (heading decreasing toward the target - // from the TurnLeft direction) -- heading_greater(-, node.Heading=190, TurnLeft) - // needs the mirror-aware diff test to register progress correctly. - h.Heading = 350f; // moved 10 deg counter-clockwise from 0 (i.e. toward 190 the "left" way) - - h.Manager.HandleTurnToHeading(); - - // Just verifying no crash / a sane FailProgressCount either way — - // the mirror's behavioral effect is dead in retail (§8, P3 - // adjudication: the mirror only affects fail_progress_count - // reset-vs-increment, which is write-only) so this is a smoke test - // for the TurnLeft code path executing without throwing. - Assert.True(h.Manager.FailProgressCount is 0 or 1); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerUseTimeGateTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerUseTimeGateTests.cs deleted file mode 100644 index 0fb21e48..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToManagerUseTimeGateTests.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V2 — UseTime (0052a780, raw 307776-307798, decomp §6a): -/// the three-gate tick matrix (grounded / node-exists / object-move -/// initialized), including the uninitialized type-6 stall case from the -/// port plan's V2 test list. -/// -public sealed class MoveToManagerUseTimeGateTests -{ - [Fact] - public void NoNodeQueued_UseTimeIsANoOp() - { - var h = new MoveToManagerHarness(); - // Fresh manager: no active move, no nodes. - h.Manager.UseTime(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - [Fact] - public void NotGrounded_ContactFalse_UseTimeDoesNothing_EvenWithNodesQueued() - { - var h = new MoveToManagerHarness { ContactValue = false }; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; // face the target so BeginMoveForward runs (no turn-to-face node needed) - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters()); - h.DrainPendingMotions(); - uint commandBefore = h.Manager.CurrentCommand; - - // Move the mover without letting UseTime process it (Contact=false blocks the gate). - h.WorldPosition = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity); - h.Advance(5.0); - h.Manager.UseTime(); - - // State machine did not advance -- still the same command, same type. - Assert.Equal(commandBefore, h.Manager.CurrentCommand); - Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); - } - - [Fact] - public void Grounded_MoveToPositionNode_DispatchesToHandleMoveToPosition() - { - var h = new MoveToManagerHarness { ContactValue = true }; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; // face the target so BeginMoveForward runs (no turn-to-face node needed) - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false }); - h.DrainPendingMotions(); - - // Arrived: move the mover close to the TARGET (20,0,0), well within - // DistanceToObject, and advance time so CheckProgressMade evaluates - // true and the arrival branch pops. - h.WorldPosition = new Position(1u, new Vector3(19.7f, 0f, 0f), Quaternion.Identity); - h.Advance(2.0); - - h.Manager.UseTime(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // HandleMoveToPosition ran and completed the move. - } - - [Fact] - public void Grounded_TurnToHeadingNode_DispatchesToHandleTurnToHeading() - { - var h = new MoveToManagerHarness { ContactValue = true }; - h.Heading = 0f; - h.Manager.TurnToHeading(new MovementParameters { DesiredHeading = 90f }); - h.DrainPendingMotions(); - Assert.Equal(MotionCommand.TurnRight, h.Manager.CurrentCommand); - - h.Heading = 91f; // "passed" the target - h.Manager.UseTime(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // HandleTurnToHeading ran and completed the turn. - } - - [Fact] - public void ObjectMove_UninitializedType6_StallsUntilFirstTargetCallback() - { - // The port plan's named "uninitialized type-6 stall" case: a - // MoveToObject manager with TopLevelObjectId != 0 and - // MovementTypeState != Invalid, but Initialized still false (no - // HandleUpdateTarget callback has arrived yet) -- and CRITICALLY, - // no node is queued yet either (MoveToObject defers node-building - // to the first callback, §3b), so UseTime's node-exists gate (gate - // 2) already blocks it. This test proves the stall holds even if a - // node WERE somehow present (defense in depth for gate 3). - var h = new MoveToManagerHarness { ContactValue = true }; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Manager.MoveToObject(0x5000AAAAu, 0x5000AAAAu, 1f, 2f, new MovementParameters()); - - Assert.False(h.Manager.Initialized); - Assert.Empty(h.Manager.PendingActions); // gate 2 alone already stalls it - - h.Manager.UseTime(); - - // No crash, no state change -- the manager is still waiting. - Assert.Equal(MovementType.MoveToObject, h.Manager.MovementTypeState); - Assert.False(h.Manager.Initialized); - } - - [Fact] - public void ObjectMove_Initialized_PassesGate3_ProcessesNormally() - { - var h = new MoveToManagerHarness { ContactValue = true }; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; // face the target so the internal node plan skips the turn-to-face step - h.Manager.MoveToObject(0x5000BBBBu, 0x5000BBBBu, radius: 0.5f, height: 2f, new MovementParameters { UseSpheres = false }); - - var target = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity); - h.Manager.HandleUpdateTarget(new TargetInfo(0x5000BBBBu, TargetStatus.Ok, target, target)); - Assert.True(h.Manager.Initialized); - h.DrainPendingMotions(); - - h.WorldPosition = new Position(1u, new Vector3(19.5f, 0f, 0f), Quaternion.Identity); // within DistanceToObject default 0.6 - h.Advance(2.0); - - h.Manager.UseTime(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // completed via UseTime -> HandleMoveToPosition. - } - - [Fact] - public void NonObjectMove_TopLevelIdZero_Gate3AlwaysPasses_RegardlessOfInitialized() - { - // Gate 3: (top_level_object_id == 0 || movement_type == Invalid) || - // initialized. Position/heading moves never set TopLevelObjectId, - // so the FIRST disjunct alone always satisfies gate 3 -- Initialized - // staying false (as it does for MoveToPosition/TurnToHeading, per - // §3c/§3e's notes) never blocks them. - var h = new MoveToManagerHarness { ContactValue = true }; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - h.Manager.MoveToPosition(new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false }); - h.DrainPendingMotions(); - - Assert.Equal(0u, h.Manager.TopLevelObjectId); - Assert.False(h.Manager.Initialized); - - h.WorldPosition = new Position(1u, new Vector3(19.7f, 0f, 0f), Quaternion.Identity); - h.Advance(2.0); - h.Manager.UseTime(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); // gate 3 passed via the first disjunct. - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs deleted file mode 100644 index eb21d692..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathCylinderDistanceTests.cs +++ /dev/null @@ -1,91 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V1 — Position::cylinder_distance, the pure-math shape per -/// r4-moveto-decomp.md §5a (MoveToManager::GetCurrentDistance, -/// 005291b0): edge-to-edge distance between two vertical cylinders -/// (own radius/height, target radius/height, both positions). Object moves -/// (use_spheres set on the wire) use this; position moves use plain -/// Euclidean Position::distance (§5a: "position moves use center -/// distance" — is the object-move -/// variant only; center distance is Vector3.Distance, already -/// available, not re-ported here). -/// -/// -/// The retail signature's exact combination math for radius/height beyond -/// "edge-to-edge, own+target cylinders" is not spelled out in the raw (BN -/// garbles the x87 plumbing) — ported per the PDB argument ORDER -/// (own radius/height, own position, target radius/height, target -/// position) with the standard cylinder-distance shape: horizontal -/// (planar) distance minus the sum of the two radii (clamped at 0), since -/// that is the only shape consistent with "edge-to-edge" and with -/// distance_to_object's ctor default of 0.6 (melee range from -/// surface to surface, not center to center). -/// -/// -public sealed class MoveToMathCylinderDistanceTests -{ - [Fact] - public void TwoCylinders_HorizontallySeparated_SubtractsBothRadii() - { - // centers 10 units apart on X, radii 1 and 2 → edge distance = 10-1-2=7 - float d = MoveToMath.CylinderDistance( - ownRadius: 1f, ownHeight: 2f, ownPos: Vector3.Zero, - targetRadius: 2f, targetHeight: 2f, targetPos: new Vector3(10f, 0f, 0f)); - - Assert.Equal(7f, d, 3); - } - - [Fact] - public void TwoCylinders_Overlapping_ClampsAtZero_NoNegativeDistance() - { - // centers 1 unit apart, radii 5 and 5 → would be -9, clamps to 0 - float d = MoveToMath.CylinderDistance( - ownRadius: 5f, ownHeight: 2f, ownPos: Vector3.Zero, - targetRadius: 5f, targetHeight: 2f, targetPos: new Vector3(1f, 0f, 0f)); - - Assert.Equal(0f, d, 3); - } - - [Fact] - public void TwoCylinders_ZeroRadii_ReducesToCenterDistance() - { - float d = MoveToMath.CylinderDistance( - ownRadius: 0f, ownHeight: 2f, ownPos: Vector3.Zero, - targetRadius: 0f, targetHeight: 2f, targetPos: new Vector3(3f, 4f, 0f)); - - Assert.Equal(5f, d, 3); // 3-4-5 triangle - } - - [Fact] - public void TwoCylinders_IgnoresVerticalSeparation_PlanarOnly() - { - // Same X/Y, large Z separation — cylinder_distance in retail's own - // callers (GetCurrentDistance) is used for horizontal arrival gates; - // the Z axis is height, not part of the radial edge-to-edge gap. - float d1 = MoveToMath.CylinderDistance( - ownRadius: 1f, ownHeight: 2f, ownPos: new Vector3(0, 0, 0), - targetRadius: 1f, targetHeight: 2f, targetPos: new Vector3(5f, 0f, 0f)); - float d2 = MoveToMath.CylinderDistance( - ownRadius: 1f, ownHeight: 2f, ownPos: new Vector3(0, 0, 50f), - targetRadius: 1f, targetHeight: 2f, targetPos: new Vector3(5f, 0f, -50f)); - - Assert.Equal(d1, d2, 3); - Assert.Equal(3f, d1, 3); // 5 - 1 - 1 - } - - [Fact] - public void SamePosition_ZeroDistance_ClampsNotNegative() - { - float d = MoveToMath.CylinderDistance( - ownRadius: 0.5f, ownHeight: 2f, ownPos: Vector3.Zero, - targetRadius: 0.5f, targetHeight: 2f, targetPos: Vector3.Zero); - - Assert.Equal(0f, d, 3); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingDiffTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingDiffTests.cs deleted file mode 100644 index 1159642b..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingDiffTests.cs +++ /dev/null @@ -1,164 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V1 — heading_diff (0x00528fb0), PINNED by direct -/// disassembly of the PDB-matched retail binary (see -/// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P3 — this is -/// the strongest evidence tier in the whole R4 pin set, one level above a -/// Ghidra decompile). Verbatim body: -/// -/// d = h1 - h2; -/// if (fabs(h1 - h2) < F_EPSILON) d = 0; -/// if (d < -F_EPSILON) d += 360; -/// if (F_EPSILON < d && turnCmd != TurnRight) d = 360 - d; // the mirror -/// return d; -/// -/// F_EPSILON = 0.000199999995f. The mirror gates on the turn command being -/// NOT TurnRight (0x6500000d) — TurnLeft (and any other command) measures -/// the COMPLEMENTARY angle. This contradicts r4-moveto-decomp.md §5g's -/// "arg3 UNUSED" claim, which the Ghidra pin overrides (adjudicated in -/// V0-pins.md). -/// -public sealed class MoveToMathHeadingDiffTests -{ - private const float Eps = 0.000199999995f; - private const uint TurnRight = MotionCommand.TurnRight; - private const uint TurnLeft = MotionCommand.TurnLeft; - - // ── basic subtraction, TurnRight (no mirror) ─────────────────────────── - - [Fact] - public void TurnRight_SimplePositiveDiff_NoWrap() - { - float d = MoveToMath.HeadingDiff(90f, 30f, TurnRight); - Assert.Equal(60f, d, 3); - } - - [Fact] - public void TurnRight_NegativeDiff_WrapsBy360() - { - // h1 - h2 = 30 - 90 = -60 → wraps to 300 - float d = MoveToMath.HeadingDiff(30f, 90f, TurnRight); - Assert.Equal(300f, d, 3); - } - - [Fact] - public void TurnRight_ZeroDiff_IsZero() - { - float d = MoveToMath.HeadingDiff(45f, 45f, TurnRight); - Assert.Equal(0f, d, 3); - } - - // ── epsilon boundary (both sides) ────────────────────────────────────── - - [Fact] - public void EpsilonBoundary_ExactlyAtEpsilon_NotSnappedToZero() - { - // fabs(d) < EPSILON is a STRICT less-than — exactly at epsilon does - // NOT snap to zero. - float d = MoveToMath.HeadingDiff(Eps, 0f, TurnRight); - Assert.NotEqual(0f, d); - Assert.Equal(Eps, d, 6); - } - - [Fact] - public void EpsilonBoundary_JustBelowEpsilon_SnapsToZero() - { - float d = MoveToMath.HeadingDiff(Eps * 0.5f, 0f, TurnRight); - Assert.Equal(0f, d); - } - - [Fact] - public void EpsilonBoundary_NegativeJustBelowNegEpsilon_WrapsBy360() - { - // d = -Eps * 1.5 < -Eps → wraps - float raw = -Eps * 1.5f; - float d = MoveToMath.HeadingDiff(raw, 0f, TurnRight); - Assert.Equal(raw + 360f, d, 3); - } - - [Fact] - public void EpsilonBoundary_NegativeExactlyAtNegEpsilon_DoesNotWrap() - { - // d < -EPSILON is STRICT — exactly -EPSILON does not wrap. - float d = MoveToMath.HeadingDiff(-Eps, 0f, TurnRight); - Assert.Equal(-Eps, d, 6); - } - - // ── the mirror (TurnLeft / not-TurnRight) ────────────────────────────── - - [Fact] - public void TurnLeft_PositiveDiffAboveEpsilon_MirroredTo360MinusD() - { - float d = MoveToMath.HeadingDiff(90f, 30f, TurnLeft); - // raw = 60; mirror: 360 - 60 = 300 - Assert.Equal(300f, d, 3); - } - - [Fact] - public void TurnLeft_NegativeDiff_WrapsThenMirrors() - { - // h1-h2 = 30-90 = -60 → wraps to 300 → mirror gate (300 > EPS, not - // TurnRight) → 360 - 300 = 60 - float d = MoveToMath.HeadingDiff(30f, 90f, TurnLeft); - Assert.Equal(60f, d, 3); - } - - [Fact] - public void TurnLeft_ZeroDiff_MirrorGateDoesNotFire_StaysZero() - { - // d == 0 does not satisfy `d > EPSILON`, so the mirror never fires - // regardless of turn command. - float d = MoveToMath.HeadingDiff(45f, 45f, TurnLeft); - Assert.Equal(0f, d); - } - - [Fact] - public void TurnLeft_AtEpsilonBoundary_MirrorGateIsStrictGreaterThan() - { - // d > EPSILON is STRICT: exactly at EPSILON does NOT mirror. - float d = MoveToMath.HeadingDiff(Eps, 0f, TurnLeft); - Assert.Equal(Eps, d, 6); - } - - [Fact] - public void TurnLeft_JustAboveEpsilon_Mirrors() - { - float raw = Eps * 2f; - float d = MoveToMath.HeadingDiff(raw, 0f, TurnLeft); - Assert.Equal(360f - raw, d, 3); - } - - [Fact] - public void AnyNonTurnRightCommand_AlsoMirrors() - { - // The gate is "!= TurnRight", not "== TurnLeft" — any other command - // (e.g. 0, WalkForward) also triggers the mirror. - float d = MoveToMath.HeadingDiff(90f, 30f, 0u); - Assert.Equal(300f, d, 3); - - float d2 = MoveToMath.HeadingDiff(90f, 30f, MotionCommand.WalkForward); - Assert.Equal(300f, d2, 3); - } - - // ── 360-wrap combined with the mirror ────────────────────────────────── - - [Fact] - public void TurnRight_FullCircleInputs_NormalizeCorrectly() - { - float d = MoveToMath.HeadingDiff(350f, 10f, TurnRight); - Assert.Equal(340f, d, 3); - } - - [Fact] - public void TurnLeft_FullCircleInputs_MirroredAfterNormalize() - { - // raw = 350-10 = 340 (no wrap needed, positive); mirror: 360-340=20 - float d = MoveToMath.HeadingDiff(350f, 10f, TurnLeft); - Assert.Equal(20f, d, 3); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingGreaterTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingGreaterTests.cs deleted file mode 100644 index 7ab6fdff..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathHeadingGreaterTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V1 — heading_greater (00528f60, raw 306281-306323), per -/// r4-moveto-decomp.md §5f: -/// -/// if (fabs(a - b) > 180) greater = (b > a); // wrapped case: compare flipped -/// else greater = (a > b); -/// if (turnCmd == TurnRight) return greater; -/// return !greater; // TurnLeft: inverted -/// -/// "Has the turn passed the target heading" — direction-aware, wrap-aware. -/// -public sealed class MoveToMathHeadingGreaterTests -{ - private const uint TurnRight = MotionCommand.TurnRight; - private const uint TurnLeft = MotionCommand.TurnLeft; - - [Fact] - public void TurnRight_UnwrappedCase_SimpleGreater() - { - Assert.True(MoveToMath.HeadingGreater(90f, 30f, TurnRight)); - Assert.False(MoveToMath.HeadingGreater(30f, 90f, TurnRight)); - } - - [Fact] - public void TurnRight_WrappedCase_ComparisonFlips() - { - // |350 - 10| = 340 > 180 → wrapped: greater = (b > a) = (10 > 350) = false - Assert.False(MoveToMath.HeadingGreater(350f, 10f, TurnRight)); - // |10 - 350| = 340 > 180 → wrapped: greater = (b > a) = (350 > 10) = true - Assert.True(MoveToMath.HeadingGreater(10f, 350f, TurnRight)); - } - - [Fact] - public void TurnRight_ExactlyAt180Delta_UnwrappedBranch() - { - // fabs(a-b) > 180 is STRICT — exactly 180 uses the unwrapped branch. - // a=200,b=20: fabs=180, not >180 → greater = (a>b) = true - Assert.True(MoveToMath.HeadingGreater(200f, 20f, TurnRight)); - } - - [Fact] - public void TurnLeft_InvertsTheUnwrappedResult() - { - Assert.False(MoveToMath.HeadingGreater(90f, 30f, TurnLeft)); - Assert.True(MoveToMath.HeadingGreater(30f, 90f, TurnLeft)); - } - - [Fact] - public void TurnLeft_InvertsTheWrappedResult() - { - Assert.True(MoveToMath.HeadingGreater(350f, 10f, TurnLeft)); - Assert.False(MoveToMath.HeadingGreater(10f, 350f, TurnLeft)); - } - - [Fact] - public void EqualHeadings_NotGreater_TurnRight() - { - Assert.False(MoveToMath.HeadingGreater(45f, 45f, TurnRight)); - } - - [Fact] - public void EqualHeadings_InvertedToTrue_TurnLeft() - { - // greater=false for equal headings; TurnLeft inverts → true. - Assert.True(MoveToMath.HeadingGreater(45f, 45f, TurnLeft)); - } - - [Fact] - public void AnyNonTurnRightCommand_AlsoInverts() - { - // The retail gate is `== TurnRight` (not `!= TurnRight` as in - // heading_diff) — every OTHER command, not just TurnLeft, inverts. - Assert.False(MoveToMath.HeadingGreater(90f, 30f, 0u)); - Assert.False(MoveToMath.HeadingGreater(90f, 30f, MotionCommand.WalkForward)); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs deleted file mode 100644 index cd9fe4f7..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MoveToMathPositionHeadingTests.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V1 — Position::heading / Frame::get_heading / -/// Frame::set_heading, per V0-pins.md §P5 (PINNED — compass degrees, -/// 0 = North (+Y), 90 = East (+X), CLOCKWISE, [0,360); identity quaternion -/// faces heading 0): -/// -/// heading(from, to) = (450 - atan2Deg(dy, dx)) % 360 -/// -/// Golden cardinals: N(0,+1)→0, E(+1,0)→90, S(0,-1)→180, W(-1,0)→270. -/// -/// -/// The packer-reuse trap (V0-pins §P5 correction): acdream's -/// outbound packer (GameWindow.YawToAcQuaternion) is wire-correct at -/// the QUATERNION level but its internal scalar intermediate -/// (headingDeg = 180 - yawDeg) is holtburger's shifted convention, -/// NOT retail's wire convention. must -/// use the CORRECT scalar bridge from acdream yaw (yaw=0 faces +X, per -/// PlayerMovementController.cs:1022-1025): heading = (90 - -/// yawDeg) mod 360 — NOT 180 - yawDeg. -/// -/// -public sealed class MoveToMathPositionHeadingTests -{ - private const float Tol = 0.01f; - - // ── PositionHeading: the four cardinal offsets ───────────────────────── - - [Fact] - public void North_PlusY_IsZero() - { - float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(0f, 1f, 0f)); - Assert.Equal(0f, h, 2); - } - - [Fact] - public void East_PlusX_Is90() - { - float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(1f, 0f, 0f)); - Assert.Equal(90f, h, 2); - } - - [Fact] - public void South_MinusY_Is180() - { - float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(0f, -1f, 0f)); - Assert.Equal(180f, h, 2); - } - - [Fact] - public void West_MinusX_Is270() - { - float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(-1f, 0f, 0f)); - Assert.Equal(270f, h, 2); - } - - [Fact] - public void Heading_IsAlways_InZeroToThreeSixtyRange() - { - // NE diagonal - float h = MoveToMath.PositionHeading(Vector3.Zero, new Vector3(1f, 1f, 0f)); - Assert.InRange(h, 0f, 360f); - Assert.Equal(45f, h, 2); - } - - [Fact] - public void Heading_IgnoresZ_HorizontalOnly() - { - float h1 = MoveToMath.PositionHeading(new Vector3(0, 0, 5f), new Vector3(1f, 0f, -10f)); - float h2 = MoveToMath.PositionHeading(new Vector3(0, 0, -3f), new Vector3(1f, 0f, 100f)); - Assert.Equal(h1, h2, 2); - Assert.Equal(90f, h1, 2); - } - - // ── GetHeading: extracts heading from a body orientation quaternion ──── - - [Fact] - public void GetHeading_IdentityQuaternion_FacesHeadingZero() - { - // Identity quaternion → acdream yaw = 0 → +X-facing in our - // convention, which decodes to AC heading 90 per the corrected - // scalar bridge... BUT the identity quaternion in acdream's body - // frame corresponds to yaw = -PI/2 relative to +Y-forward (see - // PlayerMovementController.cs:1025: Orientation = AxisAngle(Yaw - - // PI/2)). GetHeading must invert that exact convention: identity - // orientation (no rotation applied) means Yaw=PI/2 was baked in, - // which is heading 0 — matching P5's "identity quaternion faces - // heading 0" pin. - float h = MoveToMath.GetHeading(Quaternion.Identity); - Assert.Equal(0f, h, 1); - } - - [Fact] - public void GetHeading_SetHeading_RoundTrips_Cardinals() - { - foreach (float heading in new[] { 0f, 90f, 180f, 270f, 45f, 359f }) - { - var q = MoveToMath.SetHeading(Quaternion.Identity, heading); - float back = MoveToMath.GetHeading(q); - float diff = MathF.Abs(back - heading); - if (diff > 180f) diff = 360f - diff; - Assert.True(diff < 0.5f, $"heading {heading} round-tripped to {back}"); - } - } - - [Fact] - public void SetHeading_North_ProducesForwardVectorFacingPlusY() - { - var q = MoveToMath.SetHeading(Quaternion.Identity, 0f); - var forward = Vector3.Transform(new Vector3(0f, 1f, 0f), q); - Assert.True(forward.Y > 0.9f, $"expected +Y forward, got {forward}"); - } - - [Fact] - public void SetHeading_East_ProducesForwardVectorFacingPlusX() - { - var q = MoveToMath.SetHeading(Quaternion.Identity, 90f); - var forward = Vector3.Transform(new Vector3(0f, 1f, 0f), q); - Assert.True(forward.X > 0.9f, $"expected +X forward, got {forward}"); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs deleted file mode 100644 index da8e627d..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MovementManagerTests.cs +++ /dev/null @@ -1,341 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R5-V5 — facade conformance (retail struct -/// acclient.h /* 3463 */; methods 0x00524000-0x00524790, decomp extract -/// docs/research/2026-07-03-r5-managers/r5-movementmanager-decomp.md). -/// The facade is pure relay/ownership — these tests pin the retail call -/// shapes: which child each method touches, the lazy MakeMoveToManager -/// create, the PerformMovement type dispatch, and null-tolerance before the -/// moveto manager exists. Behavior of the children themselves is covered by -/// the MotionInterpreter / MoveToManager suites (UNMODIFIED by R5-V5). -/// -public sealed class MovementManagerTests -{ - /// Non-creature weenie: silences the MotionInterpreter's - /// HitGround/LeaveGround creature gates (retail IsCreature vtable - /// +0x2c) so a test can isolate the facade's MOVETO relay leg. - private sealed class NonCreatureWeenie : IWeenieObject - { - public bool InqJumpVelocity(float extent, out float vz) { vz = 0f; return false; } - public bool InqRunRate(out float rate) { rate = 1f; return false; } - public bool CanJump(float extent) => false; - bool IWeenieObject.IsCreature() => false; - } - - /// Facade over the shared MoveToManagerHarness: the harness's - /// REAL MotionInterpreter is the minterp child; the harness's REAL - /// seam-scripted MoveToManager arrives via - /// (the acdream stand-in for retail's physics_obj/weenie_obj - /// backpointers that MakeMoveToManager constructs from). - private static (MovementManager Mm, MoveToManagerHarness H, int[] FactoryCalls) MakeFacade() - { - var h = new MoveToManagerHarness(); - var factoryCalls = new int[1]; - var mm = new MovementManager(h.Interp) - { - MoveToFactory = () => { factoryCalls[0]++; return h.Manager; }, - }; - return (mm, h, factoryCalls); - } - - // ── MakeMoveToManager — 0x00524000 ────────────────────────────────────── - - [Fact] - public void MakeMoveToManager_CreatesViaFactory_ExactlyOnce() - { - var (mm, h, calls) = MakeFacade(); - Assert.Null(mm.MoveTo); - - mm.MakeMoveToManager(); - Assert.Same(h.Manager, mm.MoveTo); - Assert.Equal(1, calls[0]); - - // Retail: no-op if already present. - mm.MakeMoveToManager(); - Assert.Same(h.Manager, mm.MoveTo); - Assert.Equal(1, calls[0]); - } - - [Fact] - public void MakeMoveToManager_WithoutFactory_IsANoOp() - { - var mm = new MovementManager(new MotionInterpreter()); - mm.MakeMoveToManager(); - Assert.Null(mm.MoveTo); - } - - // ── PerformMovement — 0x005240d0 (the type-1..9 two-way dispatch) ─────── - - [Fact] - public void PerformMovement_InterpTypes_RouteToMinterp_NotMoveTo() - { - var (mm, h, calls) = MakeFacade(); - - var result = mm.PerformMovement(new MovementStruct - { - Type = MovementType.InterpretedCommand, - Motion = MotionCommand.WalkForward, - Speed = 1f, - ModifyInterpretedState = true, - }); - - Assert.Equal(WeenieError.None, result); - Assert.Equal(MotionCommand.WalkForward, h.Interp.InterpretedState.ForwardCommand); - // Types 1-5 never touch the moveto side (jump table 0x0052415c). - Assert.Equal(0, calls[0]); - Assert.Null(mm.MoveTo); - } - - [Fact] - public void PerformMovement_MoveToTypes_LazyCreate_AndRouteToMoveTo() - { - var (mm, h, calls) = MakeFacade(); - - var result = mm.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), - Params = new MovementParameters(), - }); - - // Retail cases 5-8 (types 6-9): MakeMoveToManager first, delegate, - // and the MoveToManager path's return is NOT propagated (@0052414f - // `return 0`) — the facade reports None regardless. - Assert.Equal(WeenieError.None, result); - Assert.Equal(1, calls[0]); - Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); - } - - [Fact] - public void PerformMovement_InvalidAndOutOfRangeTypes_Fail0x47() - { - var (mm, _, calls) = MakeFacade(); - - // Retail head: (type - 1) > 8 → 0x47. Type 0 underflows unsigned → - // always > 8; anything above 9 fails the same check. - Assert.Equal(WeenieError.GeneralMovementFailure, - mm.PerformMovement(new MovementStruct { Type = MovementType.Invalid })); - Assert.Equal(WeenieError.GeneralMovementFailure, - mm.PerformMovement(new MovementStruct { Type = (MovementType)10 })); - Assert.Equal(0, calls[0]); - } - - [Fact] - public void PerformMovement_MoveToType_WithoutFactory_Fails0x47() - { - // acdream-only guard for the unreachable-in-production ordering - // (a type-6..9 event before the bind sites set MoveToFactory): - // retail would MoveToManager::Create here; without a factory the - // facade reports the same 0x47 the range check uses. - var mm = new MovementManager(new MotionInterpreter()); - Assert.Equal(WeenieError.GeneralMovementFailure, - mm.PerformMovement(new MovementStruct { Type = MovementType.TurnToHeading })); - } - - // ── UseTime — 0x005242f0 (moveto only; never lazy-creates) ────────────── - - [Fact] - public void UseTime_BeforeMoveToExists_IsANoOp_AndDoesNotCreate() - { - var (mm, _, calls) = MakeFacade(); - mm.UseTime(); - Assert.Equal(0, calls[0]); - Assert.Null(mm.MoveTo); - } - - [Fact] - public void UseTime_RelaysToMoveTo() - { - // The MoveToManagerUseTimeGateTests arrival shape, driven through - // the facade: grounded, facing the target, arrived — one UseTime - // completes the move. - var (mm, h, _) = MakeFacade(); - h.ContactValue = true; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - - mm.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), - Params = new MovementParameters { DistanceToObject = 0.6f, UseSpheres = false }, - }); - h.DrainPendingMotions(); - - h.WorldPosition = new Position(1u, new Vector3(19.7f, 0f, 0f), Quaternion.Identity); - h.Advance(2.0); - mm.UseTime(); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - } - - // ── HitGround — 0x00524300 (minterp FIRST, then moveto) ──────────────── - - [Fact] - public void HitGround_RelaysToMinterp_AndToleratesNullMoveTo() - { - var (mm, h, _) = MakeFacade(); - h.Body.State |= PhysicsStateFlags.Gravity; // CMotionInterp::HitGround gates on state & 0x400 - bool minterpHit = false; - h.Interp.RemoveLinkAnimations = () => minterpHit = true; - - mm.HitGround(); // MoveTo still null — retail's if-present guard - Assert.True(minterpHit); - } - - [Fact] - public void HitGround_RelaysMinterpFirst_ThenMoveTo() - { - var (mm, h, _) = MakeFacade(); - h.Body.State |= PhysicsStateFlags.Gravity; - h.ContactValue = true; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - mm.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), - Params = new MovementParameters(), - }); - h.DrainPendingMotions(); - - // Order pin: the FIRST RemoveLinkAnimations firing belongs to the - // minterp leg (CMotionInterp::HitGround invokes it before any - // dispatch), at which point the moveto leg's BeginNextNode - // re-dispatch has NOT happened yet — the pending queue is still - // drained. Later firings (the moveto dispatch's own TS-40 - // detached-strip at the DoInterpretedMotion tail runs AFTER its - // enqueue) must not overwrite the recording — ??= keeps the first. - bool? queueEmptyAtMinterpLeg = null; - h.Interp.RemoveLinkAnimations = - () => queueEmptyAtMinterpLeg ??= !h.Interp.MotionsPending(); - - mm.HitGround(); - - Assert.True(queueEmptyAtMinterpLeg); // minterp leg ran first - Assert.True(h.Interp.MotionsPending()); // a re-dispatch landed after it - } - - [Fact] - public void HitGround_ReachesMoveTo_WhenMinterpLegIsGated() - { - // Isolate the MOVETO leg: a non-creature weenie makes - // CMotionInterp::HitGround a retail no-op (IsCreature gate), so any - // re-dispatched pending motion can only come from - // MoveToManager::HitGround → BeginNextNode. - var (mm, h, _) = MakeFacade(); - h.Interp.WeenieObj = new NonCreatureWeenie(); - h.ContactValue = true; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - mm.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), - Params = new MovementParameters(), - }); - h.DrainPendingMotions(); - - mm.HitGround(); - - Assert.True(h.Interp.MotionsPending()); - } - - // ── HandleExitWorld — 0x00524350 (minterp ONLY) ───────────────────────── - - [Fact] - public void HandleExitWorld_DrainsMinterp_AndDoesNotTouchMoveTo() - { - var (mm, h, _) = MakeFacade(); - h.ContactValue = true; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - h.Heading = 90f; - mm.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), - Params = new MovementParameters(), - }); - Assert.True(h.Interp.MotionsPending()); // the arm's dispatch is queued - - mm.HandleExitWorld(); - - Assert.False(h.Interp.MotionsPending()); - // Retail HandleExitWorld does NOT touch moveto_manager — the armed - // move survives (its teardown is CancelMoveTo / exit-world at the - // CPhysicsObj layer, not here). - Assert.Equal(MovementType.MoveToPosition, h.Manager.MovementTypeState); - } - - // ── CancelMoveTo — 0x005241b0 / IsMovingTo — 0x00524260 ──────────────── - - [Fact] - public void CancelMoveTo_NullTolerant_AndRelaysToMoveTo() - { - var (mm, h, _) = MakeFacade(); - mm.CancelMoveTo(WeenieError.ActionCancelled); // no moveto yet — no throw - - h.ContactValue = true; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - mm.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = new Position(1u, new Vector3(20f, 0f, 0f), Quaternion.Identity), - Params = new MovementParameters(), - }); - Assert.True(mm.IsMovingTo()); - - mm.CancelMoveTo(WeenieError.ActionCancelled); - - Assert.Equal(MovementType.Invalid, h.Manager.MovementTypeState); - Assert.False(mm.IsMovingTo()); - Assert.True(h.StopCompletelyCalls > 0); - } - - [Fact] - public void IsMovingTo_FalseBeforeMoveToExists() - { - var (mm, _, _) = MakeFacade(); - Assert.False(mm.IsMovingTo()); - } - - // ── HandleUpdateTarget — 0x00524790 (→ moveto) ────────────────────────── - - [Fact] - public void HandleUpdateTarget_NullTolerant_AndFeedsMoveToDeferredStart() - { - var (mm, h, _) = MakeFacade(); - var info = new TargetInfo - { - ObjectId = 0x5000AAAAu, - Status = TargetStatus.Ok, - TargetPosition = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), - InterpolatedPosition = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), - }; - mm.HandleUpdateTarget(info); // no moveto yet — no throw - - // The V2 "uninitialized type-6 stall": MoveToObject defers its node - // build to the FIRST HandleUpdateTarget delivery. - h.ContactValue = true; - h.WorldPosition = new Position(1u, Vector3.Zero, Quaternion.Identity); - mm.PerformMovement(new MovementStruct - { - Type = MovementType.MoveToObject, - ObjectId = 0x5000AAAAu, - TopLevelId = 0x5000AAAAu, - Pos = new Position(1u, new Vector3(10f, 0f, 0f), Quaternion.Identity), - Params = new MovementParameters(), - }); - Assert.False(h.Manager.Initialized); - - mm.HandleUpdateTarget(info); - - Assert.True(h.Manager.Initialized); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersFromWireTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersFromWireTests.cs deleted file mode 100644 index 6bc46f0d..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersFromWireTests.cs +++ /dev/null @@ -1,186 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V1 — MovementParameters::UnPackNet (0x0052ac50, raw -/// 308118-308205) factory semantics, per -/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §2g: the 7-dword -/// MoveTo wire form is bitfield, distance_to_object, min_distance, -/// fail_distance, speed, walk_run_threshhold, desired_heading — the SAME -/// field order UpdateMotion.TryParseMoveToPayload already reads off -/// the wire (UpdateMotion.cs:328-341). The A4 bitfield masks -/// (W0-pins.md §A4) decode into the named bool properties; every bit not -/// present on the wire bitfield resolves to false (UnPackNet fully -/// overwrites the bitfield — no ctor-default bits survive). -/// -public sealed class MovementParametersFromWireTests -{ - [Fact] - public void FromWire_AllBitsSet_EveryFlagTrue() - { - var p = MovementParameters.FromWire( - bitfield: 0x3FFFFu, // every A4 bit through 0x20000 - distanceToObject: 1f, - minDistance: 2f, - failDistance: 3f, - speed: 4f, - walkRunThreshhold: 5f, - desiredHeading: 6f); - - Assert.True(p.CanWalk); - Assert.True(p.CanRun); - Assert.True(p.CanSidestep); - Assert.True(p.CanWalkBackwards); - Assert.True(p.CanCharge); - Assert.True(p.FailWalk); - Assert.True(p.UseFinalHeading); - Assert.True(p.Sticky); - Assert.True(p.MoveAway); - Assert.True(p.MoveTowards); - Assert.True(p.UseSpheres); - Assert.True(p.SetHoldKey); - Assert.True(p.Autonomous); - Assert.True(p.ModifyRawState); - Assert.True(p.ModifyInterpretedState); - Assert.True(p.CancelMoveTo); - Assert.True(p.StopCompletelyFlag); - Assert.True(p.DisableJumpDuringLink); - } - - [Fact] - public void FromWire_ZeroBitfield_EveryFlagFalse_NoCtorDefaultsSurvive() - { - var p = MovementParameters.FromWire( - bitfield: 0u, - distanceToObject: 1f, minDistance: 2f, failDistance: 3f, - speed: 4f, walkRunThreshhold: 5f, desiredHeading: 6f); - - Assert.False(p.CanWalk); - Assert.False(p.CanRun); - Assert.False(p.CanSidestep); - Assert.False(p.CanWalkBackwards); - Assert.False(p.CanCharge); - Assert.False(p.MoveTowards); - Assert.False(p.UseSpheres); - Assert.False(p.SetHoldKey); - Assert.False(p.ModifyRawState); - Assert.False(p.ModifyInterpretedState); - Assert.False(p.CancelMoveTo); - Assert.False(p.StopCompletelyFlag); - } - - [Fact] - public void FromWire_CanChargeBit_DecodesIndependently() - { - // The wire bitfield carries can_charge 0x10 — the walk-vs-run answer - // (feedback_autowalk_cancharge_bit). Verify it round-trips on its own. - var p = MovementParameters.FromWire( - bitfield: 0x10u, - distanceToObject: 0f, minDistance: 0f, failDistance: 0f, - speed: 0f, walkRunThreshhold: 0f, desiredHeading: 0f); - - Assert.True(p.CanCharge); - Assert.False(p.CanWalk); - Assert.False(p.CanRun); - } - - [Theory] - [InlineData(0x1u)] - [InlineData(0x2u)] - [InlineData(0x4u)] - [InlineData(0x8u)] - [InlineData(0x10u)] - [InlineData(0x20u)] - [InlineData(0x40u)] - [InlineData(0x80u)] - [InlineData(0x100u)] - [InlineData(0x200u)] - [InlineData(0x400u)] - [InlineData(0x800u)] - [InlineData(0x1000u)] - [InlineData(0x2000u)] - [InlineData(0x4000u)] - [InlineData(0x8000u)] - [InlineData(0x10000u)] - [InlineData(0x20000u)] - public void FromWire_SingleBitMaskRoundTrips(uint mask) - { - var p = MovementParameters.FromWire( - bitfield: mask, - distanceToObject: 0f, minDistance: 0f, failDistance: 0f, - speed: 0f, walkRunThreshhold: 0f, desiredHeading: 0f); - - Assert.Equal(mask, ToBitfield(p)); - } - - [Fact] - public void FromWire_ScalarFields_CopiedInWireOrder() - { - var p = MovementParameters.FromWire( - bitfield: 0u, - distanceToObject: 1.5f, - minDistance: 2.5f, - failDistance: 3.5f, - speed: 4.5f, - walkRunThreshhold: 5.5f, - desiredHeading: 6.5f); - - Assert.Equal(1.5f, p.DistanceToObject); - Assert.Equal(2.5f, p.MinDistance); - Assert.Equal(3.5f, p.FailDistance); - Assert.Equal(4.5f, p.Speed); - Assert.Equal(5.5f, p.WalkRunThreshhold); - Assert.Equal(6.5f, p.DesiredHeading); - } - - [Fact] - public void FromWireTurnTo_ThreeDwordForm_LeavesDistanceFieldsAtDefault() - { - // TurnToObject/TurnToHeading wire form (0xc bytes, 3 dwords): - // bitfield, speed, desired_heading only. distance_to_object / - // min_distance / fail_distance / walk_run_threshhold are NOT on - // this wire form — the factory overload must not touch them - // (they keep the MovementParameters ctor defaults). - var p = MovementParameters.FromWireTurnTo( - bitfield: 0x2u, // can_run - speed: 2f, - desiredHeading: 90f); - - Assert.True(p.CanRun); - Assert.Equal(2f, p.Speed); - Assert.Equal(90f, p.DesiredHeading); - - // ctor defaults, untouched by the 3-dword form: - Assert.Equal(0.6f, p.DistanceToObject); - Assert.Equal(0f, p.MinDistance); - Assert.Equal(float.MaxValue, p.FailDistance); - Assert.Equal(15f, p.WalkRunThreshhold); - } - - private static uint ToBitfield(MovementParameters p) - { - uint bitfield = 0; - if (p.CanWalk) bitfield |= 0x1; - if (p.CanRun) bitfield |= 0x2; - if (p.CanSidestep) bitfield |= 0x4; - if (p.CanWalkBackwards) bitfield |= 0x8; - if (p.CanCharge) bitfield |= 0x10; - if (p.FailWalk) bitfield |= 0x20; - if (p.UseFinalHeading) bitfield |= 0x40; - if (p.Sticky) bitfield |= 0x80; - if (p.MoveAway) bitfield |= 0x100; - if (p.MoveTowards) bitfield |= 0x200; - if (p.UseSpheres) bitfield |= 0x400; - if (p.SetHoldKey) bitfield |= 0x800; - if (p.Autonomous) bitfield |= 0x1000; - if (p.ModifyRawState) bitfield |= 0x2000; - if (p.ModifyInterpretedState) bitfield |= 0x4000; - if (p.CancelMoveTo) bitfield |= 0x8000; - if (p.StopCompletelyFlag) bitfield |= 0x10000; - if (p.DisableJumpDuringLink) bitfield |= 0x20000; - return bitfield; - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetCommandTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetCommandTests.cs deleted file mode 100644 index 885baa2f..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetCommandTests.cs +++ /dev/null @@ -1,340 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V1 — MovementParameters::get_command (0x0052aa00, raw -/// 307946-308012), verbatim per -/// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §5c. Covers the -/// command/moving_away pick (plain-towards / plain-away / towards_and_away -/// delegate) crossed with the walk-vs-run HoldKey cascade, INCLUDING the -/// CanCharge 0x10 fast-path ACE dropped (feedback_autowalk_cancharge_bit) -/// and the walk_run_threshhold ≤-vs-< edge (retail: dist - dto ≤ -/// threshold → walk; the raw's `test ah,0x41` is the inclusive ≤ reading, -/// §5c @308003). -/// -public sealed class MovementParametersGetCommandTests -{ - // ── plain TOWARDS (move_towards set, move_away clear) ───────────────── - - [Fact] - public void PlainTowards_DistGreaterThanDto_WalkForward_NotMovingAway() - { - var p = new MovementParameters { DistanceToObject = 0.6f }; - // move_towards=true (default), move_away=false (default) - - p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out HoldKey holdKey, out bool movingAway); - - Assert.Equal(MotionCommand.WalkForward, motion); - Assert.False(movingAway); - } - - [Fact] - public void PlainTowards_DistNotGreaterThanDto_Idle() - { - var p = new MovementParameters { DistanceToObject = 0.6f }; - - p.GetCommand(dist: 0.6f, headingDiff: 0f, out uint motion, out _, out bool movingAway); - - Assert.Equal(0u, motion); - Assert.False(movingAway); - } - - [Fact] - public void PlainTowards_DistLessThanDto_Idle() - { - var p = new MovementParameters { DistanceToObject = 0.6f }; - - p.GetCommand(dist: 0.1f, headingDiff: 0f, out uint motion, out _, out _); - - Assert.Equal(0u, motion); - } - - // ── pure AWAY (move_away set, move_towards clear) ───────────────────── - - [Fact] - public void PureAway_DistLessThanMinDistance_WalkForward_MovingAway() - { - var p = new MovementParameters - { - MoveTowards = false, - MoveAway = true, - MinDistance = 5f, - }; - - p.GetCommand(dist: 2f, headingDiff: 0f, out uint motion, out _, out bool movingAway); - - Assert.Equal(MotionCommand.WalkForward, motion); - Assert.True(movingAway); - } - - [Fact] - public void PureAway_DistNotLessThanMinDistance_Idle() - { - var p = new MovementParameters - { - MoveTowards = false, - MoveAway = true, - MinDistance = 5f, - }; - - p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out _, out bool movingAway); - - Assert.Equal(0u, motion); - Assert.False(movingAway); - } - - // ── towards_and_away delegate (both move_towards AND move_away set) ─── - - [Fact] - public void TowardsAndAway_DistGreaterThanDto_DelegatesToWalkForwardTowards() - { - var p = new MovementParameters - { - MoveTowards = true, - MoveAway = true, - DistanceToObject = 0.6f, - MinDistance = 0.2f, - }; - - p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out _, out bool movingAway); - - Assert.Equal(MotionCommand.WalkForward, motion); - Assert.False(movingAway); - } - - [Fact] - public void TowardsAndAway_InsideMinBand_WalkBackwards_MovingAway() - { - var p = new MovementParameters - { - MoveTowards = true, - MoveAway = true, - DistanceToObject = 0.6f, - MinDistance = 0.2f, - }; - - // dist - min_distance < epsilon → inside the min band - p.GetCommand(dist: 0.2f, headingDiff: 0f, out uint motion, out _, out bool movingAway); - - Assert.Equal(MotionCommand.WalkBackward, motion); - Assert.True(movingAway); - } - - [Fact] - public void TowardsAndAway_InsideDeadband_Idle() - { - var p = new MovementParameters - { - MoveTowards = true, - MoveAway = true, - DistanceToObject = 0.6f, - MinDistance = 0.2f, - }; - - // strictly inside [min, dto] — neither band fires - p.GetCommand(dist: 0.4f, headingDiff: 0f, out uint motion, out _, out _); - - Assert.Equal(0u, motion); - } - - // ── neither towards nor away (both clear) — falls to plain-towards path ── - - [Fact] - public void NeitherTowardsNorAway_FallsToPlainTowardsBranch() - { - var p = new MovementParameters - { - MoveTowards = false, - MoveAway = false, - DistanceToObject = 0.6f, - }; - - p.GetCommand(dist: 5f, headingDiff: 0f, out uint motion, out _, out bool movingAway); - - Assert.Equal(MotionCommand.WalkForward, motion); - Assert.False(movingAway); - } - - // ── walk-vs-run HoldKey cascade ──────────────────────────────────────── - - [Fact] - public void HoldKey_CanChargeSet_AlwaysRun_FastPath() - { - // THE fast-path ACE dropped: can_charge (0x10) short-circuits - // straight to HoldKey_Run regardless of distance/threshold. - var p = new MovementParameters - { - CanCharge = true, - CanRun = false, // even with can_run CLEAR - CanWalk = true, - WalkRunThreshhold = 15f, - DistanceToObject = 0.6f, - }; - - p.GetCommand(dist: 0.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _); - - Assert.Equal(HoldKey.Run, holdKey); - } - - [Fact] - public void HoldKey_CanRunClear_AlwaysWalk_RegardlessOfDistance() - { - var p = new MovementParameters - { - CanCharge = false, - CanRun = false, - CanWalk = true, - WalkRunThreshhold = 15f, - DistanceToObject = 0.6f, - }; - - p.GetCommand(dist: 1000f, headingDiff: 0f, out _, out HoldKey holdKey, out _); - - Assert.Equal(HoldKey.None, holdKey); - } - - [Fact] - public void HoldKey_CanRunSet_CanWalkClear_AlwaysRun_WalkIncapable() - { - // can_walk clear → the "close enough to walk" branch is skipped - // entirely; walk-incapable movers always run when can_run is set. - var p = new MovementParameters - { - CanCharge = false, - CanRun = true, - CanWalk = false, - WalkRunThreshhold = 15f, - DistanceToObject = 0.6f, - }; - - p.GetCommand(dist: 0.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _); - - Assert.Equal(HoldKey.Run, holdKey); - } - - [Fact] - public void HoldKey_CanRunAndCanWalk_WithinThreshold_Walk() - { - var p = new MovementParameters - { - CanCharge = false, - CanRun = true, - CanWalk = true, - WalkRunThreshhold = 15f, - DistanceToObject = 0.6f, - }; - - // dist - dto = 10 <= 15 → walk - p.GetCommand(dist: 10.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _); - - Assert.Equal(HoldKey.None, holdKey); - } - - [Fact] - public void HoldKey_CanRunAndCanWalk_BeyondThreshold_Run() - { - var p = new MovementParameters - { - CanCharge = false, - CanRun = true, - CanWalk = true, - WalkRunThreshhold = 15f, - DistanceToObject = 0.6f, - }; - - // dist - dto = 15.1 > 15 → run - p.GetCommand(dist: 15.7f, headingDiff: 0f, out _, out HoldKey holdKey, out _); - - Assert.Equal(HoldKey.Run, holdKey); - } - - [Fact] - public void HoldKey_ThresholdEdge_ExactlyAtThreshold_IsInclusive_Walk() - { - // retail: (dist - distance_to_object) <= walk_run_threshhold → WALK. - // The raw's `test ah,0x41` after `fcom` renders as an inclusive - // "not greater than" (≤) — the boundary itself walks, not runs. - var p = new MovementParameters - { - CanCharge = false, - CanRun = true, - CanWalk = true, - WalkRunThreshhold = 15f, - DistanceToObject = 0.6f, - }; - - // dist - dto = exactly 15.0 - p.GetCommand(dist: 15.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _); - - Assert.Equal(HoldKey.None, holdKey); - } - - [Fact] - public void HoldKey_ThresholdEdge_JustOverThreshold_Run() - { - var p = new MovementParameters - { - CanCharge = false, - CanRun = true, - CanWalk = true, - WalkRunThreshhold = 15f, - DistanceToObject = 0.6f, - }; - - // dist - dto = 15.0 + epsilon - p.GetCommand(dist: 15.600001f, headingDiff: 0f, out _, out HoldKey holdKey, out _); - - Assert.Equal(HoldKey.Run, holdKey); - } - - [Fact] - public void HoldKey_CanChargeSet_OverridesWalkIncapableAndThreshold() - { - // CanCharge fast-path wins even when every other flag would say walk. - var p = new MovementParameters - { - CanCharge = true, - CanRun = true, - CanWalk = true, - WalkRunThreshhold = 1000f, // would otherwise force walk - DistanceToObject = 0.6f, - }; - - p.GetCommand(dist: 0.6f, headingDiff: 0f, out _, out HoldKey holdKey, out _); - - Assert.Equal(HoldKey.Run, holdKey); - } - - // ── the four capability quadrants × plain-towards distance bands ────── - - [Theory] - // (canRun, canWalk, canCharge, distBeyondThreshold) → expected HoldKey - [InlineData(true, true, false, false, HoldKey.None)] // both capable, close → walk - [InlineData(true, true, false, true, HoldKey.Run)] // both capable, far → run - [InlineData(true, false, false, false, HoldKey.Run)] // run-only, close → still run (no walk branch) - [InlineData(true, false, false, true, HoldKey.Run)] // run-only, far → run - [InlineData(false, true, false, false, HoldKey.None)] // walk-only → always walk - [InlineData(false, true, false, true, HoldKey.None)] // walk-only, far → still walk - [InlineData(false, false, false, false, HoldKey.None)] // neither capable, no charge → walk (falls through) - [InlineData(false, false, true, false, HoldKey.Run)] // can_charge alone → run regardless - public void HoldKey_FourCapabilityQuadrants_MatchRetailCascade( - bool canRun, bool canWalk, bool canCharge, bool distBeyondThreshold, HoldKey expected) - { - var p = new MovementParameters - { - CanRun = canRun, - CanWalk = canWalk, - CanCharge = canCharge, - WalkRunThreshhold = 15f, - DistanceToObject = 0.6f, - }; - - float dist = distBeyondThreshold ? 20f : 5f; // 20-0.6=19.4>15 ; 5-0.6=4.4<=15 - p.GetCommand(dist, headingDiff: 0f, out _, out HoldKey holdKey, out _); - - Assert.Equal(expected, holdKey); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetDesiredHeadingTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetDesiredHeadingTests.cs deleted file mode 100644 index 7931a5f3..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersGetDesiredHeadingTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V1 — MovementParameters::get_desired_heading (0x0052aad0), -/// PINNED by direct Ghidra decompile of patchmem.gpr (see -/// docs/research/2026-07-03-r4-moveto/ghidra-confirmations.md §P2 — fetched -/// live during V0, ACE-shaped constants CONFIRMED exact): -/// -/// forward|run + towards → 0 forward|run + away → 180 -/// backward + towards → 180 backward + away → 0 -/// any other command → 0 -/// -/// -public sealed class MovementParametersGetDesiredHeadingTests -{ - [Theory] - [InlineData(false, 0f)] // RunForward, towards → 0 - [InlineData(true, 180f)] // RunForward, away → 180 - public void RunForward_FourQuadrant(bool movingAway, float expected) - { - var p = new MovementParameters(); - float h = p.GetDesiredHeading(MotionCommand.RunForward, movingAway); - Assert.Equal(expected, h); - } - - [Theory] - [InlineData(false, 0f)] // WalkForward, towards → 0 - [InlineData(true, 180f)] // WalkForward, away → 180 - public void WalkForward_FourQuadrant(bool movingAway, float expected) - { - var p = new MovementParameters(); - float h = p.GetDesiredHeading(MotionCommand.WalkForward, movingAway); - Assert.Equal(expected, h); - } - - [Theory] - [InlineData(false, 180f)] // WalkBackward, towards → 180 (face the target while backing up) - [InlineData(true, 0f)] // WalkBackward, away → 0 - public void WalkBackward_FourQuadrant(bool movingAway, float expected) - { - var p = new MovementParameters(); - float h = p.GetDesiredHeading(MotionCommand.WalkBackward, movingAway); - Assert.Equal(expected, h); - } - - [Theory] - [InlineData(false)] - [InlineData(true)] - public void UnknownCommand_DefaultsToZero(bool movingAway) - { - var p = new MovementParameters(); - float h = p.GetDesiredHeading(MotionCommand.TurnRight, movingAway); - Assert.Equal(0f, h); - } - - [Fact] - public void ZeroCommand_DefaultsToZero() - { - var p = new MovementParameters(); - Assert.Equal(0f, p.GetDesiredHeading(0u, movingAway: false)); - Assert.Equal(0f, p.GetDesiredHeading(0u, movingAway: true)); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTests.cs deleted file mode 100644 index 6171f2d2..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTests.cs +++ /dev/null @@ -1,112 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R3-W1 — MovementParameters ctor-default pins. Oracle: -/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A4 (bitfield 0x1EE0F -/// expansion) + r3-motioninterp-decomp.md §0 (scalar ctor, raw 300510-300534, -/// 0x00524380). Every flag asserted individually against the retail -/// 0x1EE0F default so a future bit-numbering slip fails loudly, plus the two -/// ACE-divergence traps (CanCharge, WalkRunThreshhold) get dedicated tests. -/// -public sealed class MovementParametersTests -{ - [Fact] - public void Ctor_SetFlags_MatchRetail0x1EE0FExpansion() - { - var p = new MovementParameters(); - - // Bits SET in 0x1EE0F: 0x1,0x2,0x4,0x8,0x200,0x400,0x800,0x2000,0x4000,0x8000,0x10000 - Assert.True(p.CanWalk); // 0x1 - Assert.True(p.CanRun); // 0x2 - Assert.True(p.CanSidestep); // 0x4 - Assert.True(p.CanWalkBackwards); // 0x8 - Assert.True(p.MoveTowards); // 0x200 - Assert.True(p.UseSpheres); // 0x400 - Assert.True(p.SetHoldKey); // 0x800 - Assert.True(p.ModifyRawState); // 0x2000 - Assert.True(p.ModifyInterpretedState); // 0x4000 - Assert.True(p.CancelMoveTo); // 0x8000 - Assert.True(p.StopCompletelyFlag); // 0x10000 - } - - [Fact] - public void Ctor_ClearFlags_MatchRetail0x1EE0FExpansion() - { - var p = new MovementParameters(); - - // Bits CLEAR in 0x1EE0F: 0x10,0x20,0x40,0x80,0x100,0x1000,0x20000 - Assert.False(p.CanCharge); // 0x10 — ACE-divergence trap - Assert.False(p.FailWalk); // 0x20 - Assert.False(p.UseFinalHeading); // 0x40 - Assert.False(p.Sticky); // 0x80 - Assert.False(p.MoveAway); // 0x100 - Assert.False(p.Autonomous); // 0x1000 - Assert.False(p.DisableJumpDuringLink); // 0x20000 - } - - [Fact] - public void Ctor_Bitfield_ReconstitutesExactly0x1EE0F() - { - var p = new MovementParameters(); - - uint bitfield = 0; - if (p.CanWalk) bitfield |= 0x1; - if (p.CanRun) bitfield |= 0x2; - if (p.CanSidestep) bitfield |= 0x4; - if (p.CanWalkBackwards) bitfield |= 0x8; - if (p.CanCharge) bitfield |= 0x10; - if (p.FailWalk) bitfield |= 0x20; - if (p.UseFinalHeading) bitfield |= 0x40; - if (p.Sticky) bitfield |= 0x80; - if (p.MoveAway) bitfield |= 0x100; - if (p.MoveTowards) bitfield |= 0x200; - if (p.UseSpheres) bitfield |= 0x400; - if (p.SetHoldKey) bitfield |= 0x800; - if (p.Autonomous) bitfield |= 0x1000; - if (p.ModifyRawState) bitfield |= 0x2000; - if (p.ModifyInterpretedState) bitfield |= 0x4000; - if (p.CancelMoveTo) bitfield |= 0x8000; - if (p.StopCompletelyFlag) bitfield |= 0x10000; - if (p.DisableJumpDuringLink) bitfield |= 0x20000; - - Assert.Equal(0x1EE0Fu, bitfield); - } - - [Fact] - public void Ctor_ScalarDefaults_MatchRetail() - { - var p = new MovementParameters(); - - Assert.Equal(0f, p.MinDistance); - Assert.Equal(0.6f, p.DistanceToObject); - Assert.Equal(float.MaxValue, p.FailDistance); - Assert.Equal(0f, p.DesiredHeading); - Assert.Equal(1f, p.Speed); - // ACE-divergence trap: retail is 15.0, NOT ACE's 1.0. - Assert.Equal(15f, p.WalkRunThreshhold); - Assert.Equal(0u, p.ContextId); - Assert.Equal(HoldKey.Invalid, p.HoldKeyToApply); - Assert.Equal(0u, p.ActionStamp); - } - - [Fact] - public void Ctor_CanCharge_DefaultsFalse_NotAceTrue() - { - // Dedicated regression test for the single highest-risk ACE trap: - // ACE's MovementParameters.cs:58 sets CanCharge = true at construction. - // Retail's 0x1EE0F has bit 0x10 CLEAR. - var p = new MovementParameters(); - Assert.False(p.CanCharge); - } - - [Fact] - public void Ctor_WalkRunThreshhold_Is15_NotAce1() - { - var p = new MovementParameters(); - Assert.Equal(15f, p.WalkRunThreshhold); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTowardsAndAwayTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTowardsAndAwayTests.cs deleted file mode 100644 index 25d942b4..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MovementParametersTowardsAndAwayTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V1 — MovementParameters::towards_and_away (0x0052a9a0, -/// raw 307917-307942), verbatim per r4-moveto-decomp.md §5d. Three bands: -/// beyond distance_to_object → WalkForward towards; inside the -/// min_distance epsilon band → WalkBackwards away (no turn, unlike -/// the pure-away branch in §5c which uses WalkForward+turn-around); strictly -/// between → idle (cmd 0). -/// -public sealed class MovementParametersTowardsAndAwayTests -{ - [Fact] - public void DistGreaterThanDto_WalkForward_Towards() - { - var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; - - p.TowardsAndAway(dist: 5f, out uint cmd, out bool movingAway); - - Assert.Equal(MotionCommand.WalkForward, cmd); - Assert.False(movingAway); - } - - [Fact] - public void DistExactlyAtDto_NotGreater_FallsToMinBandCheck() - { - var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; - - // dist == dto is NOT > dto, so falls through to the min-band test; - // 0.6 - 0.2 = 0.4, not < epsilon → idle. - p.TowardsAndAway(dist: 0.6f, out uint cmd, out _); - - Assert.Equal(0u, cmd); - } - - [Fact] - public void InsideMinDistanceEpsilonBand_WalkBackwards_Away() - { - var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; - - // dist - min_distance < 0.000199999995f - p.TowardsAndAway(dist: 0.2f, out uint cmd, out bool movingAway); - - Assert.Equal(MotionCommand.WalkBackward, cmd); - Assert.True(movingAway); - } - - [Fact] - public void InsideMinDistanceEpsilonBand_JustBelowEpsilon_StillWalkBackwards() - { - var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; - - p.TowardsAndAway(dist: 0.2f + 0.0001f, out uint cmd, out bool movingAway); - - Assert.Equal(MotionCommand.WalkBackward, cmd); - Assert.True(movingAway); - } - - [Fact] - public void StrictlyBetweenMinAndDto_Idle() - { - var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; - - p.TowardsAndAway(dist: 0.4f, out uint cmd, out bool movingAway); - - Assert.Equal(0u, cmd); - Assert.False(movingAway); - } - - [Fact] - public void JustOutsideMinBand_NotYetIdle_Idle() - { - var p = new MovementParameters { DistanceToObject = 0.6f, MinDistance = 0.2f }; - - // dist - min = 0.0003, just over epsilon (0.0002) → NOT in the min band → idle - p.TowardsAndAway(dist: 0.2003f, out uint cmd, out _); - - Assert.Equal(0u, cmd); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementStructWideningTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementStructWideningTests.cs deleted file mode 100644 index bc9e9b87..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MovementStructWideningTests.cs +++ /dev/null @@ -1,108 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V1 — widening per r4-moveto-decomp.md §0 -/// (acclient.h:38069, struct #4067): -/// -/// struct __cppobj MovementStruct -/// { -/// MovementTypes::Type type; -/// unsigned int motion; // types 1-4 only -/// unsigned int object_id; // types 6, 8 -/// unsigned int top_level_id; // types 6, 8 -/// Position pos; // type 7 -/// float radius; // type 6 -/// float height; // type 6 -/// MovementParameters *params; // types 1-4, 6-9 -/// }; -/// -/// Additive-only (M11, mechanical) — no consumer wires these fields yet; -/// this test just pins the shape exists and round-trips. -/// -public sealed class MovementStructWideningTests -{ - [Fact] - public void ObjectId_TopLevelId_RoundTrip() - { - var mvs = new MovementStruct - { - Type = MovementType.MoveToObject, - ObjectId = 0x50001234u, - TopLevelId = 0x50005678u, - }; - - Assert.Equal(0x50001234u, mvs.ObjectId); - Assert.Equal(0x50005678u, mvs.TopLevelId); - } - - [Fact] - public void Pos_RoundTrips_WorldPositionAndCell() - { - var pos = new Position(0x12340001u, new Vector3(10f, 20f, 3f), Quaternion.Identity); - var mvs = new MovementStruct - { - Type = MovementType.MoveToPosition, - Pos = pos, - }; - - Assert.Equal(pos, mvs.Pos); - Assert.Equal(0x12340001u, mvs.Pos.ObjCellId); - Assert.Equal(new Vector3(10f, 20f, 3f), mvs.Pos.Frame.Origin); - } - - [Fact] - public void Radius_Height_RoundTrip() - { - var mvs = new MovementStruct - { - Type = MovementType.MoveToObject, - Radius = 0.75f, - Height = 1.8f, - }; - - Assert.Equal(0.75f, mvs.Radius); - Assert.Equal(1.8f, mvs.Height); - } - - [Fact] - public void Params_HoldsMovementParametersReference() - { - var p = new MovementParameters { CanCharge = true }; - var mvs = new MovementStruct - { - Type = MovementType.TurnToHeading, - Params = p, - }; - - Assert.Same(p, mvs.Params); - } - - [Fact] - public void ExistingFields_Type_Motion_StillPresent_NoRegression() - { - // The pre-R4 fields (Type/Motion/Speed/Autonomous/ModifyInterpretedState/ - // ModifyRawState) must survive the widening untouched — R4 is - // additive-only per the plan (M11, "no consumer changes"). - var mvs = new MovementStruct - { - Type = MovementType.RawCommand, - Motion = 0x45000005u, - Speed = 1.5f, - Autonomous = true, - ModifyInterpretedState = true, - ModifyRawState = false, - }; - - Assert.Equal(MovementType.RawCommand, mvs.Type); - Assert.Equal(0x45000005u, mvs.Motion); - Assert.Equal(1.5f, mvs.Speed); - Assert.True(mvs.Autonomous); - Assert.True(mvs.ModifyInterpretedState); - Assert.False(mvs.ModifyRawState); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/MovementTypeWideningTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/MovementTypeWideningTests.cs deleted file mode 100644 index ce6f8f05..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/MovementTypeWideningTests.cs +++ /dev/null @@ -1,33 +0,0 @@ -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R4-V1 — widening to retail's full -/// MovementTypes::Type enum (acclient.h:2856, enum #229): -/// -/// Invalid=0, RawCommand=1, InterpretedCommand=2, StopRawCommand=3, -/// StopInterpretedCommand=4, StopCompletely=5, MoveToObject=6, -/// MoveToPosition=7, TurnToObject=8, TurnToHeading=9 -/// -/// Mechanical, additive-only pin (M11) — the 1-5 values must not shift -/// (they're already load-bearing in MotionInterpreter.PerformMovement's -/// switch). -/// -public sealed class MovementTypeWideningTests -{ - [Theory] - [InlineData(MovementType.Invalid, 0)] - [InlineData(MovementType.RawCommand, 1)] - [InlineData(MovementType.InterpretedCommand, 2)] - [InlineData(MovementType.StopRawCommand, 3)] - [InlineData(MovementType.StopInterpretedCommand, 4)] - [InlineData(MovementType.StopCompletely, 5)] - [InlineData(MovementType.MoveToObject, 6)] - [InlineData(MovementType.MoveToPosition, 7)] - [InlineData(MovementType.TurnToObject, 8)] - [InlineData(MovementType.TurnToHeading, 9)] - public void EnumValues_MatchRetailMovementTypesTypeTable(MovementType value, int expected) - => Assert.Equal(expected, (int)value); -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs deleted file mode 100644 index 6e2bdc62..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/PositionManagerFacadeTests.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R5 conformance — facade (retail -/// 0x00555160-0x005553d0). Lazy sub-manager creation + fan-out. (Test class is -/// suffixed "Facade" to read distinctly from the renamed -/// RemoteMotionCombiner combiner tests.) -/// -public sealed class PositionManagerFacadeTests -{ - private static (R5Host self, R5Host target, PositionManager pm) Setup() - { - var world = new Dictionary(); - var self = new R5Host(10u, world); - var target = new R5Host(20u, world); - return (self, target, new PositionManager(self)); - } - - [Fact] - public void UnStick_BeforeAnyStick_IsNoOp() - { - var (_, _, pm) = Setup(); - pm.UnStick(); // no sticky manager yet — must not throw - Assert.Null(pm.Sticky); - Assert.Equal(0u, pm.GetStickyObjectId()); - } - - [Fact] - public void StickTo_LazilyCreatesSticky_AndForwards() - { - var (self, target, pm) = Setup(); - pm.StickTo(target.Id, radius: 0.5f, height: 1.0f); - - Assert.NotNull(pm.Sticky); - Assert.Equal(target.Id, pm.GetStickyObjectId()); - } - - [Fact] - public void IsFullyConstrained_FalseWhenNoConstraintManager() - { - var (_, _, pm) = Setup(); - Assert.False(pm.IsFullyConstrained()); - Assert.Null(pm.Constraint); - } - - [Fact] - public void ConstrainTo_LazilyCreatesConstraint() - { - var (self, _, pm) = Setup(); - self.SetOrigin(Vector3.Zero); - pm.ConstrainTo(new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity), 2f, 10f); - Assert.NotNull(pm.Constraint); - Assert.True(pm.Constraint!.IsConstrained); - } - - [Fact] - public void HandleUpdateTarget_ForwardsToSticky() - { - var (self, target, pm) = Setup(); - pm.StickTo(target.Id, 0.5f, 1.0f); - - var tp = new Position(1u, new Vector3(2f, 0f, 0f), Quaternion.Identity); - pm.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); - - Assert.True(pm.Sticky!.Initialized); - } - - [Fact] - public void AdjustOffset_ChainsStickyThenConstraint() - { - var (self, target, pm) = Setup(); - self.Radius = 0.5f; - self.MinterpMaxSpeed = 1.0f; - self.InContact = true; - - // Sticky toward +X: produces an offset the constraint then tapers. - pm.StickTo(target.Id, 0.5f, 1.0f); - target.SetOrigin(new Vector3(5f, 0f, 0f)); - var tp = new Position(1u, new Vector3(5f, 0f, 0f), Quaternion.Identity); - pm.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); - - // Arm a constraint whose band tapers by 0.5. - self.SetOrigin(Vector3.Zero); - pm.ConstrainTo(new Position(1u, new Vector3(6f, 0f, 0f), Quaternion.Identity), - startDistance: 2f, maxDistance: 10f); // offset 6 → taper (10-6)/(10-2)=0.5 - - var frame = new MotionDeltaFrame(); - pm.AdjustOffset(frame, quantum: 0.1); - - // Sticky writes +0.5 X; constraint tapers by 0.5 → 0.25. - Assert.Equal(0.25f, frame.Origin.X, 3); - } - - [Fact] - public void UseTime_DrivesStickyTimeout() - { - var (self, target, pm) = Setup(); - self.CurTime = 100.0; - pm.StickTo(target.Id, 0.5f, 1.0f); // deadline 101 - - // Strictly past the deadline (retail 0x00555626 keeps the stick AT - // the deadline; teardown is `>` — ACE too). - self.CurTime = 101.001; - pm.UseTime(); - - Assert.Equal(0u, pm.GetStickyObjectId()); // unstuck - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs b/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs deleted file mode 100644 index 55701c73..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/R5ManagerHarness.cs +++ /dev/null @@ -1,98 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R5 conformance harness — a scriptable fake -/// backed by a shared so resolves -/// OTHER hosts (the cross-entity seam the voyeur round-trip needs). Each host -/// lazily owns a and a -/// (the two R5 managers under test), and records every -/// the managers fan out so tests can assert on -/// delivery. -/// -/// Position/velocity/radius/contact/max-speed and both clocks are mutable -/// fields tests drive directly (retail's CPhysicsObj accessors). The -/// set_target/clear_target/add_voyeur/remove_voyeur/ -/// receive_target_update seams forward to the owned -/// exactly as retail's CPhysicsObj does. -/// -internal sealed class R5Host : IPhysicsObjHost -{ - public readonly Dictionary World; - - public R5Host(uint id, Dictionary world) - { - Id = id; - World = world; - World[id] = this; - } - - // ── scriptable CPhysicsObj state ─────────────────────────────────────── - public uint Id { get; } - public Position Position { get; set; } = new(1u, Vector3.Zero, Quaternion.Identity); - public Vector3 Velocity { get; set; } = Vector3.Zero; - public float Radius { get; set; } = 0.5f; - public bool InContact { get; set; } = true; - public float? MinterpMaxSpeed { get; set; } = 1.0f; - public double CurTime { get; set; } - public double PhysicsTimerTime { get; set; } - - /// Set to false to simulate a target that isn't currently - /// resolvable (out of streaming view) — returns - /// null for it even while it's in . - public bool Resolvable { get; set; } = true; - - // ── owned R5 managers ────────────────────────────────────────────────── - private TargetManager? _targetManager; - public TargetManager TargetManager => _targetManager ??= new TargetManager(this); - public TargetManager? TargetManagerOrNull => _targetManager; - - private PositionManager? _positionManager; - public PositionManager PositionManager => _positionManager ??= new PositionManager(this); - - // ── recorded fan-outs ────────────────────────────────────────────────── - public readonly List HandleUpdateTargetCalls = new(); - public int InterruptCurrentMovementCalls; - - // ── IPhysicsObjHost ──────────────────────────────────────────────────── - public IPhysicsObjHost? GetObjectA(uint id) - => World.TryGetValue(id, out var h) && h.Resolvable ? h : null; - - public void HandleUpdateTarget(TargetInfo info) - { - HandleUpdateTargetCalls.Add(info); - // Retail CPhysicsObj::HandleUpdateTarget also fans to the position - // manager's sticky sub-manager (context 0). Mirror that so sticky tests - // that rely on the full CPhysicsObj fan-out see the callback. - if (info.ContextId == 0) - _positionManager?.HandleUpdateTarget(info); - } - - public void InterruptCurrentMovement() => InterruptCurrentMovementCalls++; - - public void SetTarget(uint contextId, uint objectId, float radius, double quantum) - => TargetManager.SetTarget(contextId, objectId, radius, quantum); - - public void ClearTarget() => _targetManager?.ClearTarget(); - - public void ReceiveTargetUpdate(TargetInfo info) => _targetManager?.ReceiveUpdate(info); - - public void AddVoyeur(uint watcherId, float radius, double quantum) - => TargetManager.AddVoyeur(watcherId, radius, quantum); - - public void RemoveVoyeur(uint watcherId) => _targetManager?.RemoveVoyeur(watcherId); - - // ── test helpers ─────────────────────────────────────────────────────── - public void SetOrigin(Vector3 origin) - => Position = new Position(Position.ObjCellId, origin, Position.Frame.Orientation); - - public void AdvanceClocks(double seconds) - { - CurTime += seconds; - PhysicsTimerTime += seconds; - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RawMotionStateActionFifoTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RawMotionStateActionFifoTests.cs deleted file mode 100644 index cd27ee7c..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/RawMotionStateActionFifoTests.cs +++ /dev/null @@ -1,244 +0,0 @@ -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R3-W1 — action FIFO discipline + ApplyMotion/RemoveMotion -/// field effects on (closes J2). Oracle: -/// docs/research/named-retail/acclient_2013_pseudo_c.txt — verbatim bodies -/// quoted in RawMotionState.cs doc comments: -/// RawMotionState::AddAction (0x0051e840), RemoveAction -/// (0x0051e8a0), ApplyMotion (0x0051eb60), RemoveMotion -/// (0x0051e6e0). -/// -public sealed class RawMotionStateActionFifoTests -{ - // ── AddAction / RemoveAction / GetNumActions FIFO discipline ────────── - - [Fact] - public void AddAction_AppendsInOrder() - { - var raw = new RawMotionState(); - raw.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false); - raw.AddAction(0x10000050u, 1.5f, 2, autonomous: true); - - Assert.Equal(2, raw.Actions.Count); - Assert.Equal((ushort)0x004Bu, raw.Actions[0].Command); // widened to ushort on wire, verified below - Assert.Equal((ushort)0x0050u, raw.Actions[1].Command); - } - - [Fact] - public void RemoveAction_PopsHeadFirst_FifoOrder() - { - var raw = new RawMotionState(); - raw.AddAction(0x1000004Bu, 1.0f, 1, autonomous: false); - raw.AddAction(0x10000050u, 1.5f, 2, autonomous: true); - - uint first = raw.RemoveAction(); - uint second = raw.RemoveAction(); - - Assert.Equal(0x004Bu, first); // head popped first (FIFO) - Assert.Equal(0x0050u, second); - Assert.Empty(raw.Actions); - } - - [Fact] - public void RemoveAction_Empty_ReturnsZero() - { - var raw = new RawMotionState(); - Assert.Equal(0u, raw.RemoveAction()); - } - - [Fact] - public void AddAction_StoresSpeedStampAutonomous() - { - var raw = new RawMotionState(); - raw.AddAction(0x1000004Bu, speed: 2.5f, actionStamp: 0x7FFFu, autonomous: true); - - var a = raw.Actions[0]; - Assert.Equal(2.5f, a.Speed); - Assert.Equal((ushort)0x7FFFu, a.Stamp); - Assert.True(a.Autonomous); - } - - // ── ApplyMotion field effects (0x0051eb60) ───────────────────────────── - - [Fact] - public void ApplyMotion_TurnRight_SetsTurnCommandAndSpeed_HonorsSetHoldKeyBit() - { - var raw = new RawMotionState(); - var p = new MovementParameters { Speed = 1.5f, SetHoldKey = true }; - - raw.ApplyMotion(0x6500000du, p); // TurnRight - - Assert.Equal(0x6500000du, raw.TurnCommand); - Assert.Equal(1.5f, raw.TurnSpeed); - Assert.Equal(HoldKey.Invalid, raw.TurnHoldKey); // SetHoldKey bit set -> Invalid - } - - [Fact] - public void ApplyMotion_TurnRight_SetHoldKeyClear_UsesHoldKeyToApply() - { - var raw = new RawMotionState(); - var p = new MovementParameters { Speed = 1.5f, SetHoldKey = false, HoldKeyToApply = HoldKey.Run }; - - raw.ApplyMotion(0x6500000du, p); - - Assert.Equal(HoldKey.Run, raw.TurnHoldKey); - } - - [Fact] - public void ApplyMotion_SideStepRight_SetsSidestepCommandAndSpeed() - { - var raw = new RawMotionState(); - var p = new MovementParameters { Speed = 1.248f, SetHoldKey = true }; - - raw.ApplyMotion(0x6500000fu, p); // SideStepRight - - Assert.Equal(0x6500000fu, raw.SidestepCommand); - Assert.Equal(1.248f, raw.SidestepSpeed); - Assert.Equal(HoldKey.Invalid, raw.SidestepHoldKey); - } - - [Fact] - public void ApplyMotion_ForwardClassMotion_SetsForwardCommandAndSpeed() - { - // WalkForward = 0x45000005 has bit 0x40000000 set (forward-class) - // and is NOT 0x44000007 (RunForward) -> the write branch fires. - var raw = new RawMotionState(); - var p = new MovementParameters { Speed = 1.0f, SetHoldKey = true }; - - raw.ApplyMotion(0x45000005u, p); - - Assert.Equal(0x45000005u, raw.ForwardCommand); - Assert.Equal(1.0f, raw.ForwardSpeed); - Assert.Equal(HoldKey.Invalid, raw.ForwardHoldKey); - } - - [Fact] - public void ApplyMotion_RunForwardExactId_ForwardClassButExcluded_NoWrite() - { - // Verbatim retail quirk (0x0051eb60): arg2 == 0x44000007 (RunForward) - // with the forward-class bit (0x40000000) set falls through BOTH - // inner branches — no field write occurs. Port verbatim, not fixed. - var raw = new RawMotionState(); - var before = raw.ForwardCommand; - var p = new MovementParameters { Speed = 3.0f }; - - raw.ApplyMotion(0x44000007u, p); - - Assert.Equal(before, raw.ForwardCommand); // untouched - Assert.Equal(1.0f, raw.ForwardSpeed); // untouched (still ctor default) - } - - [Fact] - public void ApplyMotion_StyleClassMotion_SetsCurrentStyleAndResetsForwardToReady() - { - // High bit set (>= 0x80000000) and current_style differs -> style branch. - var raw = new RawMotionState { ForwardCommand = 0x45000005u }; - var p = new MovementParameters(); - - raw.ApplyMotion(0x80000042u, p); - - Assert.Equal(0x41000003u, raw.ForwardCommand); // reset to Ready - Assert.Equal(0x80000042u, raw.CurrentStyle); - } - - [Fact] - public void ApplyMotion_StyleClassMotion_SameAsCurrentStyle_NoOp() - { - var raw = new RawMotionState { CurrentStyle = 0x80000042u, ForwardCommand = 0x45000005u }; - var p = new MovementParameters(); - - raw.ApplyMotion(0x80000042u, p); - - // current_style already equals arg2 -> the style branch's condition - // (current_style != arg2) is false, so forward_command is untouched. - Assert.Equal(0x45000005u, raw.ForwardCommand); - Assert.Equal(0x80000042u, raw.CurrentStyle); - } - - [Fact] - public void ApplyMotion_ActionClassMotion_AddsAction() - { - // Outside turn/sidestep range, bit 0x40000000 clear, arg2 >= 0 - // (not style-class), bit 0x10000000 set -> AddAction. - var raw = new RawMotionState(); - var p = new MovementParameters { Speed = 1.0f, ActionStamp = 42u, Autonomous = true }; - - raw.ApplyMotion(0x1000004Bu, p); // Jumpup action id - - Assert.Single(raw.Actions); - var a = raw.Actions[0]; - Assert.Equal((ushort)0x004Bu, a.Command); - Assert.Equal(1.0f, a.Speed); - Assert.Equal((ushort)42u, a.Stamp); - Assert.True(a.Autonomous); - } - - // ── RemoveMotion field effects (0x0051e6e0) ──────────────────────────── - - [Fact] - public void RemoveMotion_TurnRange_ClearsTurnCommand() - { - var raw = new RawMotionState { TurnCommand = 0x6500000du }; - raw.RemoveMotion(0x6500000du); - Assert.Equal(0u, raw.TurnCommand); - } - - [Fact] - public void RemoveMotion_TurnLeftRange_ClearsTurnCommand() - { - var raw = new RawMotionState { TurnCommand = 0x6500000eu }; - raw.RemoveMotion(0x6500000eu); - Assert.Equal(0u, raw.TurnCommand); - } - - [Fact] - public void RemoveMotion_SidestepRange_ClearsSidestepCommand() - { - var raw = new RawMotionState { SidestepCommand = 0x6500000fu }; - raw.RemoveMotion(0x6500000fu); - Assert.Equal(0u, raw.SidestepCommand); - } - - [Fact] - public void RemoveMotion_ForwardClassMotion_MatchingCommand_ResetsToReady() - { - var raw = new RawMotionState { ForwardCommand = 0x45000005u, ForwardSpeed = 3.0f }; - raw.RemoveMotion(0x45000005u); - - Assert.Equal(0x41000003u, raw.ForwardCommand); - Assert.Equal(1f, raw.ForwardSpeed); - } - - [Fact] - public void RemoveMotion_ForwardClassMotion_NonMatchingCommand_NoOp() - { - var raw = new RawMotionState { ForwardCommand = 0x45000005u, ForwardSpeed = 3.0f }; - raw.RemoveMotion(0x44000007u); // different forward-class id - - Assert.Equal(0x45000005u, raw.ForwardCommand); // untouched - Assert.Equal(3.0f, raw.ForwardSpeed); - } - - [Fact] - public void RemoveMotion_StyleClassMotion_MatchingCurrentStyle_ResetsToNonCombat() - { - var raw = new RawMotionState { CurrentStyle = 0x80000042u }; - raw.RemoveMotion(0x80000042u); - - Assert.Equal(0x8000003du, raw.CurrentStyle); // reset to NonCombat - } - - [Fact] - public void RemoveMotion_StyleClassMotion_NonMatchingCurrentStyle_NoOp() - { - var raw = new RawMotionState { CurrentStyle = 0x80000042u }; - raw.RemoveMotion(0x80000099u); // different style id - - Assert.Equal(0x80000042u, raw.CurrentStyle); // untouched - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseDrainBisectTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseDrainBisectTests.cs deleted file mode 100644 index a52ef8f9..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseDrainBisectTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -using System; -using System.Linq; -using System.Text; -using AcDream.Core.Physics; -using Xunit; -using Xunit.Abstractions; - -namespace AcDream.Core.Tests.Physics.Motion; - -// ───────────────────────────────────────────────────────────────────────────── -// #170 drain-chain conformance: a stance-change UM's completion must flow -// CSequence link-anim completion → AnimDone hook → ConsumePendingHooks → -// MotionTableManager.AnimationDone countdown → MotionDoneTarget → -// CMotionInterp.MotionDone pop, fully emptying BOTH queues (retail cdb -// invariant: add_to_queue == MotionDone). -// -// History: the first harness run wedged here EXACTLY like the live #170 -// signature ([drainq] q=[0x8000003C ...] stuck) — because the harness body -// was unseeded (InWorld=false), so the TS-40 detached-object guard stripped -// every dispatched transition link while the manager kept counting its ticks. -// With the live-faithful RemoteMotion construction the chain drains in ~1 s. -// Kept as the regression pin for that whole chain (and as the canonical -// demonstration of what a link-strip-without-tick-zeroing wedge looks like). -// ───────────────────────────────────────────────────────────────────────────── -public sealed class RemoteChaseDrainBisectTests -{ - private readonly ITestOutputHelper _out; - public RemoteChaseDrainBisectTests(ITestOutputHelper output) => _out = output; - - [Fact] - public void StanceChange_DrainChain_TickByTick() - { - var h = new RemoteChaseHarness(); - - // settle spawn - for (int i = 0; i < 30; i++) h.Tick(); - DumpState(h, "pre-stance"); - - h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready); - DumpState(h, "post-UM (t=0)"); - - for (int i = 1; i <= 90; i++) - { - TickWithHookTrace(h, i); - if (i % 6 == 0 || i <= 3) - DumpState(h, $"tick {i} (t={i / 60f:F2})"); - if (!h.Interp.MotionsPending() && h.Seq.Manager.PendingAnimations.Any() == false) - { - DumpState(h, $"DRAINED at tick {i}"); - return; // chain healthy - } - } - - DumpState(h, "END — still wedged"); - Assert.Fail("drain chain wedged after stance UM — see output for where"); - } - - private void TickWithHookTrace(RemoteChaseHarness h, int i) - { - // Replicate RemoteChaseHarness.Tick but with hook visibility: we call - // the same phases, intercepting the hook list. - h.Now += RemoteChaseHarness.Dt; - h.Mgr.UseTime(); - if (h.Body.OnWalkable) - h.Body.set_local_velocity(h.Interp.get_state_velocity(), false); - - h.Seq.Advance(RemoteChaseHarness.Dt); - var hooks = h.Seq.ConsumePendingHooks(); - if (hooks.Count > 0) - { - var sb = new StringBuilder(); - sb.Append(FormattableString.Invariant($"tick {i}: hooks=[")); - for (int k = 0; k < hooks.Count; k++) - { - if (k > 0) sb.Append(", "); - sb.Append(hooks[k]?.GetType().Name ?? "null"); - } - sb.Append(']'); - _out.WriteLine(sb.ToString()); - } - for (int k = 0; k < hooks.Count; k++) - { - if (hooks[k] is DatReaderWriter.Types.AnimationDoneHook) - h.Seq.Manager.AnimationDone(success: true); - } - h.Seq.Manager.UseTime(); - } - - private void DumpState(RemoteChaseHarness h, string tag) - { - var mgrQ = string.Join(" ", h.Seq.Manager.PendingAnimations - .Select(p => FormattableString.Invariant($"(0x{p.Motion:X8},t={p.NumAnims})"))); - var core = h.Seq.Core; - var seqList = new StringBuilder(); - for (var n = core.AnimList.First; n is not null; n = n.Next) - { - if (seqList.Length > 0) seqList.Append(','); - seqList.Append(FormattableString.Invariant($"fr={n.Value.Framerate:F0}")); - if (ReferenceEquals(n, core.FirstCyclicNode)) seqList.Append('*'); - if (ReferenceEquals(n, core.CurrAnimNode)) seqList.Append('^'); - } - _out.WriteLine(FormattableString.Invariant( - $"[{tag}] interpPending={h.Interp.MotionsPending()} mgrQ=[{mgrQ}] counter={h.Seq.Manager.AnimationCounter} seq=[{seqList}] frame={core.FrameNumber:F1} substate=0x{h.Seq.Manager.State.Substate:X8} style=0x{h.Seq.Manager.State.Style:X8}")); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs deleted file mode 100644 index a1480435..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs +++ /dev/null @@ -1,960 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Numerics; -using System.Text; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Types; -using Xunit; -using Xunit.Abstractions; - -using DRWMotionCommand = DatReaderWriter.Enums.MotionCommand; -using CorePosition = AcDream.Core.Physics.Position; - -namespace AcDream.Core.Tests.Physics.Motion; - -// ───────────────────────────────────────────────────────────────────────────── -// #170 "sustain the run" — full-stack remote-chase harness. -// -// The R4-V2 MoveToManagerHarness scripts Heading directly (setHeading writes -// the scalar) and drains pending_motions synthetically, so the two legs where -// the live #170 residual actually lives have ZERO coverage: -// -// 1. the PHYSICAL turn: _DoMotion(TurnRight) → MotionTableDispatchSink. -// TurnApplied → ObservedOmega → per-tick quaternion integration → -// MoveToMath.GetHeading → HandleTurnToHeading's HeadingGreater test; -// 2. the REAL drain: MotionTableManager pending_animations countdown fed by -// CSequence AnimDone hooks (link-anim completions), popping -// CMotionInterp.pending_motions via the MotionDoneTarget seam. -// -// This harness wires a real MotionInterpreter + AnimationSequencer + -// MotionTableDispatchSink + MoveToManager EXACTLY like GameWindow's -// EnsureRemoteMotionBindings (GameWindow.cs:4251) and ticks them in -// GameWindow.TickAnimations' per-entity order for the grounded branch=MOVETO -// path (GameWindow.cs:9697 HandleTargetting → 9994 TickRemoteMoveTo → -// 10024 get_state_velocity refresh → 10050 manual omega integration → -// 10247 Sequencer.Advance → 10306 AnimationDone per AnimDoneHook → -// 10309 Manager.UseTime). Wire events (aggro stance UM, mt-6 arms, attack -// UMs) replay the exact live sequence captured in launch-drainq.log -// (2026-07-04, guid 0x80000BE5, Mite Scamp chasing the fleeing player). -// -// Retail acceptance bar (live cdb, 2026-07-04 session): BeginMoveForward ≈ -// MoveToObject arms (21/22), pending_motions add == MotionDone exactly, the -// chase turn completes within a couple of seconds and the run SUSTAINS -// between attack swings. -// ───────────────────────────────────────────────────────────────────────────── - -internal sealed class ChaseLoader : IAnimationLoader -{ - private readonly Dictionary _anims = new(); - public void Register(uint id, Animation anim) => _anims[id] = anim; - public Animation? LoadAnimation(uint id) => - _anims.TryGetValue(id, out var a) ? a : null; -} - -/// -/// The full-stack per-entity pipeline replica. Field-for-field mirror of the -/// GameWindow wiring for one grounded remote NPC (branch=MOVETO), plus a -/// scripted stand-in for the TargetManager voyeur delivery (SetTarget → -/// synchronous first delivery + one delivery per quantum thereafter — the -/// live cadence observed in launch-drainq.log: HandleUpdateTarget directly -/// after the arm, then sparse). -/// -internal sealed class RemoteChaseHarness -{ - // Styles / substates (full command words, as the wire produces them). - public const uint NonCombat = 0x8000003Du; - public const uint Combat = 0x8000003Cu; // the aggro stance in the live capture - public const uint Ready = 0x41000003u; - public const uint Walk = 0x45000005u; - public const uint Run = 0x44000007u; - public const uint TurnRight = 0x6500000Du; - public const uint AttackAction = 0x10000063u; // one of the live scamp swings - - public const uint CreatureGuid = 0x80000BE5u; - public const uint PlayerGuid = 0x5000000Au; - - public const float Dt = 1f / 60f; - - // R5-V3 (#171): setup cylsphere radii for the sticky/UseSpheres scenarios - // (GameWindow threads the real values via GetSetupCylinder; these are - // creature-typical stand-ins). - public const float OwnRadius = 0.3f; - public const float StickyTargetRadius = 0.5f; - - // Field-for-field mirror of GameWindow's RemoteMotion construction - // (GameWindow.cs:592-618): Contact+OnWalkable+Active, InWorld=true (the - // R4-V5 door fix — without it the interp's detached-object guard strips - // every dispatched transition link), and the R4-V5 #160 RemoteWeenie - // (null weenie degrades run-rate to 1.0). - public readonly PhysicsBody Body = new() - { - State = PhysicsStateFlags.ReportCollisions, - TransientState = TransientStateFlags.Contact - | TransientStateFlags.OnWalkable - | TransientStateFlags.Active, - InWorld = true, - }; - public readonly MotionInterpreter Interp; - public readonly AnimationSequencer Seq; - public readonly MotionTableDispatchSink Sink; - - /// R5-V5: GameWindow's RemoteMotion.Movement twin — the ONE - /// per-entity MovementManager facade owning Interp + the MoveToManager - /// (retail CPhysicsObj::movement_manager). - public readonly MovementManager Movement; - - /// The moveto child view (RemoteMotion.MoveTo twin) — non-null - /// after the ctor's MakeMoveToManager, kept so test bodies read - /// unchanged. - public MoveToManager Mgr => Movement.MoveTo!; - - /// R5-V3 (#171): the creature's PositionManager facade — the - /// EntityPhysicsHost-owned twin (GameWindow binds MoveToManager.StickTo/ - /// Unstick + MotionInterpreter.UnstickFromObject to it and drives - /// AdjustOffset/UseTime per tick). - public readonly PositionManager Pm; - private readonly CreatureHost _creatureHost; - private readonly TargetHost _playerHost; - - /// GameWindow's RemoteMotion.ObservedOmega twin. - public Vector3 ObservedOmega; - - /// Scripted player (chase target) world position. - public Vector3 PlayerPos; - public Vector3 PlayerVelocity; - - // Scripted targeting stand-in (GameWindow: EntityPhysicsHost/TargetManager). - private bool _targetArmed; - private double _lastDeliveryTime = double.NegativeInfinity; - private double _quantum = 0.5; - public double Now; - - // Counters (the live-probe equivalents). - public int BeginTurnBlocked; - public int BeginTurnUnblocked; - public int RunInstalls; // substate transitions INTO RunForward/Walk fwd - public int TicksInRun; - public int TicksInWalkFwd; - public int TotalTicks; - public int MaxRunStreak; - private int _runStreak; - private uint _prevSubstate; - - public readonly List Trace = new(); - private readonly ITestOutputHelper? _log; - - public RemoteChaseHarness(ITestOutputHelper? log = null) - { - _log = log; - var (setup, mtable, loader) = BuildFixture(); - Seq = new AnimationSequencer(setup, mtable, loader); - - // ── GameWindow spawn path (OnLiveEntitySpawnedLocked ~3781) ── - Seq.InitializeState(); - Seq.SetCycle(NonCombat, Ready); - - Body.Orientation = MoveToMath.SetHeading(Quaternion.Identity, 0f); // face North - Interp = new MotionInterpreter(Body) - { - WeenieObj = new RemoteWeenie(), - }; - - // ── EnsureRemoteMotionBindings (GameWindow.cs:4251) verbatim ── - Sink = new MotionTableDispatchSink(Seq) - { - TurnApplied = (turnMotion, turnSpeed) => - { - float signed = (turnMotion & 0xFFu) == 0x0E - ? -MathF.Abs(turnSpeed) - : turnSpeed; - ObservedOmega = new Vector3(0f, 0f, -(MathF.PI / 2f) * signed); - }, - TurnStopped = () => ObservedOmega = Vector3.Zero, - }; - Interp.DefaultSink = Sink; - // #174: production binds the seam to Manager.HandleEnterWorld - // (strip + full queue drain, retail 0x0050fe20 → 0x0051bdd0) — - // the bare sequence strip orphaned pending manager nodes. - Interp.RemoveLinkAnimations = () => Seq.Manager.HandleEnterWorld(); - Interp.InitializeMotionTables = () => Seq.Manager.InitializeState(); - Interp.CheckForCompletedMotions = Seq.Manager.CheckForCompletedMotions; - - // ── R5-V5: the MovementManager facade owns Interp + the moveto — - // GameWindow's RemoteMotion ctor + EnsureRemoteMotionBindings - // factory shape verbatim (sticky binds inside the factory; - // MakeMoveToManager after the host/Pm exist). ── - Movement = new MovementManager(Interp) - { - MoveToFactory = () => - { - var mtm = new MoveToManager( - Interp, - stopCompletely: () => Interp.StopCompletely(), - getPosition: () => new CorePosition(1u, Body.Position, Body.Orientation), - getHeading: () => MoveToMath.GetHeading(Body.Orientation), - setHeading: (h, _) => Body.Orientation = - MoveToMath.SetHeading(Body.Orientation, h), - getOwnRadius: () => OwnRadius, - getOwnHeight: () => 1f, - contact: () => Body.OnWalkable, - isInterpolating: () => false, - getVelocity: () => Body.Velocity, - getSelfId: () => CreatureGuid, - setTarget: (ctx, tlid, radius, q) => - { - _targetArmed = tlid == PlayerGuid; - // TargetManager delivers the FIRST info synchronously on - // SetTarget (live log: HandleUpdateTarget printed directly - // after the arm, same network phase). - DeliverTargetInfo(); - }, - clearTarget: () => _targetArmed = false, - getTargetQuantum: () => _quantum, - setTargetQuantum: q => _quantum = q, - curTime: () => Now); - // R5-V3 (#171) sticky seam binds (BeginNextNode arrival - // StickTo @0x00529d3a, PerformMovement-head Unstick). - mtm.StickTo = (tlid, radius, height) => Pm.StickTo(tlid, radius, height); - mtm.Unstick = Pm.UnStick; - return mtm; - }, - }; - - // TS-36: interrupt_current_movement → MovementManager::CancelMoveTo - // (the facade relay 0x005241b0) — EnsureRemoteMotionBindings twin. - Interp.InterruptCurrentMovement = - () => Movement.CancelMoveTo(WeenieError.ActionCancelled); - - // ── R5-V3 (#171): the PositionManager/sticky wiring — GameWindow's - // V3 additions verbatim: host-owned facade + the UM-funnel-head - // unstick_from_object bind; then MakeMoveToManager (0x00524000) - // invokes the factory above, mirroring the production bind order. ── - _playerHost = new TargetHost(this); - _creatureHost = new CreatureHost(this); - Pm = new PositionManager(_creatureHost); - Movement.MakeMoveToManager(); - Interp.UnstickFromObject = Pm.UnStick; - - // ── The anim-loop MotionDone binding (GameWindow.cs:10266) ── - Seq.MotionDoneTarget = (m, ok) => Interp.MotionDone(m, ok); - - _prevSubstate = Seq.Manager.State.Substate; - } - - // ── Wire events ───────────────────────────────────────────────────────── - - /// mt-0 UM (funnel apply): interrupt + unstick head, then - /// MoveToInterpretedState — GameWindow.cs:4893 + 5008. - public void UmInterpreted(uint stance, uint forward, float forwardSpeed = 1f, - params InboundMotionAction[] actions) - { - Interp.InterruptCurrentMovement?.Invoke(); - Interp.UnstickFromObject?.Invoke(); - - var ims = new InboundInterpretedState - { - CurrentStyle = stance, - ForwardCommand = forward, - ForwardSpeed = forwardSpeed, - SideStepCommand = 0u, - SideStepSpeed = 1f, - TurnCommand = 0u, - TurnSpeed = 1f, - }; - if (actions.Length > 0) - ims.Actions = new List(actions); - - Interp.MoveToInterpretedState(ims, Sink); - } - - /// mt-6 UM (MoveToObject arm): interrupt + unstick head, then the - /// RouteServerMoveTo MovementStruct — GameWindow.cs:4483-4508. Params match - /// the live scamp chase (spd 2.08, threshold 15, object distance 0.6). - public void UmMoveToObject( - float speed = 2.08f, - float distanceToObject = 0.6f, - float walkRunThreshold = 15f, - bool sticky = false, - bool useSpheres = false, - float targetRadius = 0f, - float targetHeight = 0f, - uint wireStance = 0u) - { - Interp.InterruptCurrentMovement?.Invoke(); - Interp.UnstickFromObject?.Invoke(); - - // R5-V4a: the unpack_movement HEAD style-on-change (GameWindow's - // routing-head mirror — 0x00524440 @00524502-0052452c): fires for - // EVERY movement type, BEFORE the type routing, on CHANGE only. - if (wireStance != 0u) - { - uint wireStyle = 0x80000000u | wireStance; - if (Interp.InterpretedState.CurrentStyle != wireStyle) - Interp.DoMotion(wireStyle, new MovementParameters()); - } - - var mp = new MovementParameters - { - CanWalk = true, - CanRun = true, - CanCharge = true, - CanSidestep = false, - CanWalkBackwards = false, - Speed = speed, - DistanceToObject = distanceToObject, - WalkRunThreshhold = walkRunThreshold, - FailDistance = float.MaxValue, - // R5-V3 (#171): ACE arms every melee chase Sticky + UseSpheres - // (Monster_Navigation.cs:406-419; UseSpheres is the ACE default). - Sticky = sticky, - UseSpheres = useSpheres, - }; - var ms = new MovementStruct - { - Type = MovementType.MoveToObject, - ObjectId = PlayerGuid, - TopLevelId = PlayerGuid, - Pos = new CorePosition(1u, PlayerPos, Quaternion.Identity), - Params = mp, - // R5-V3: the RouteServerMoveTo radius threading twin — retail - // reads the TARGET's PartArray radius/height at the call site. - Radius = targetRadius, - Height = targetHeight, - }; - // R5-V5: RouteServerMoveTo twin — through the facade - // (MovementManager::PerformMovement 0x005240d0). - Movement.PerformMovement(ms); - } - - private void DeliverTargetInfo() - { - if (!_targetArmed) return; - _lastDeliveryTime = Now; - var pos = new CorePosition(1u, PlayerPos, Quaternion.Identity); - var info = new TargetInfo - { - ObjectId = PlayerGuid, - Status = TargetStatus.Ok, - TargetPosition = pos, - InterpolatedPosition = pos, - }; - // R5-V3 fan order (EntityPhysicsHost.HandleUpdateTarget — retail - // CPhysicsObj::HandleUpdateTarget 0x00512bc0): the MovementManager - // relay (@0x00512bf0 → moveto) first, then PositionManager (the - // sticky consumer). - Movement.HandleUpdateTarget(info); - Pm.HandleUpdateTarget(info); - } - - // ── R5-V3 (#171): the IPhysicsObjHost twins of GameWindow's - // EntityPhysicsHost (creature side) and ResolvePhysicsHost's minimal - // target host (player side). ── - - private sealed class CreatureHost : IPhysicsObjHost - { - private readonly RemoteChaseHarness _h; - public CreatureHost(RemoteChaseHarness h) => _h = h; - public uint Id => CreatureGuid; - public CorePosition Position => new(1u, _h.Body.Position, _h.Body.Orientation); - public Vector3 Velocity => _h.Body.Velocity; - public float Radius => OwnRadius; - public bool InContact => _h.Body.OnWalkable; - public float? MinterpMaxSpeed => _h.Interp.GetMaxSpeed(); - public double CurTime => _h.Now; - public double PhysicsTimerTime => _h.Now; - public IPhysicsObjHost? GetObjectA(uint id) - => id == PlayerGuid ? _h._playerHost : null; - - public void HandleUpdateTarget(TargetInfo info) - { - _h.Movement.HandleUpdateTarget(info); - _h.Pm.HandleUpdateTarget(info); - } - - public void InterruptCurrentMovement() - => _h.Movement.CancelMoveTo(WeenieError.ActionCancelled); - - public void SetTarget(uint contextId, uint objectId, float radius, double quantum) - { - // The scripted TargetManager stand-in — StickyManager::StickTo - // subscribes at quantum 0.5 (0x00555710) with a synchronous first - // delivery (the AddVoyeur immediate snapshot). - _h._targetArmed = objectId == PlayerGuid; - _h._quantum = quantum; - _h.DeliverTargetInfo(); - } - - public void ClearTarget() => _h._targetArmed = false; - public void ReceiveTargetUpdate(TargetInfo info) { } - public void AddVoyeur(uint watcherId, float radius, double quantum) { } - public void RemoveVoyeur(uint watcherId) { } - } - - private sealed class TargetHost : IPhysicsObjHost - { - private readonly RemoteChaseHarness _h; - public TargetHost(RemoteChaseHarness h) => _h = h; - public uint Id => PlayerGuid; - public CorePosition Position => new(1u, _h.PlayerPos, Quaternion.Identity); - public Vector3 Velocity => _h.PlayerVelocity; - public float Radius => StickyTargetRadius; - public bool InContact => true; - public float? MinterpMaxSpeed => null; - public double CurTime => _h.Now; - public double PhysicsTimerTime => _h.Now; - public IPhysicsObjHost? GetObjectA(uint id) => null; - public void HandleUpdateTarget(TargetInfo info) { } - public void InterruptCurrentMovement() { } - public void SetTarget(uint contextId, uint objectId, float radius, double quantum) { } - public void ClearTarget() { } - public void ReceiveTargetUpdate(TargetInfo info) { } - public void AddVoyeur(uint watcherId, float radius, double quantum) { } - public void RemoveVoyeur(uint watcherId) { } - } - - // ── The per-tick pipeline (GameWindow.TickAnimations order) ──────────── - - public void Tick() - { - Now += Dt; - TotalTicks++; - - // Player (chase target) moves per its scripted velocity. - PlayerPos += PlayerVelocity * Dt; - - // 1. Voyeur delivery (player host HandleTargetting → this entity's - // HandleUpdateTarget; GameWindow.cs:8094 runs before the per-remote - // loop, 9697 in-loop). - if (_targetArmed && Now - _lastDeliveryTime >= _quantum) - DeliverTargetInfo(); - - // 2. MovementManager drive (TickRemoteMoveTo — the R5-V5 facade - // relay, MovementManager::UseTime 0x005242f0). - Movement.UseTime(); - - // 3. get_state_velocity → body velocity (the d2ccc80e refresh, - // GameWindow.cs:10024). - if (Body.OnWalkable) - Body.set_local_velocity(Interp.get_state_velocity(), false); - - // 4. Manual omega integration (GameWindow.cs:10050-10058 verbatim). - if (ObservedOmega.LengthSquared() > 1e-8f) - { - float omegaMag = ObservedOmega.Length(); - var axis = ObservedOmega / omegaMag; - float angle = omegaMag * Dt; - var deltaRot = Quaternion.CreateFromAxisAngle(axis, angle); - Body.Orientation = Quaternion.Normalize( - Quaternion.Multiply(Body.Orientation, deltaRot)); - } - - // 5. R5-V3 (#171): the sticky steer — GameWindow's legacy-branch slot - // (retail UpdatePositionInternal PositionManager::adjust_offset - // @0x00512d0e, composed BEFORE the velocity integration). Origin is - // mover-local; the rotation carries a RELATIVE heading (identity = - // untouched = no turn). - var pmDelta = new MotionDeltaFrame(); - Pm.AdjustOffset(pmDelta, Dt); - if (pmDelta.Origin != Vector3.Zero) - Body.Position += Vector3.Transform(pmDelta.Origin, Body.Orientation); - if (!pmDelta.Orientation.IsIdentity) - Body.Orientation = MoveToMath.SetHeading( - Body.Orientation, - MoveToMath.GetHeading(Body.Orientation) + pmDelta.GetHeading()); - - // 5b. Position integration (UpdatePhysicsInternal, simplified: grounded, - // no gravity participation for this scenario). - Body.Position += Body.Velocity * Dt; - - // 5c. R5-V3: PositionManager::UseTime (retail UpdateObjectInternal - // tail @0x005159b3) — the sticky 1 s lease watchdog. - Pm.UseTime(); - - // 6. Anim loop (GameWindow.cs:10247-10309): advance, drain AnimDone - // hooks into the manager countdown, zero-tick sweep. - Seq.Advance(Dt); - var hooks = Seq.ConsumePendingHooks(); - for (int i = 0; i < hooks.Count; i++) - { - if (hooks[i] is DatReaderWriter.Types.AnimationDoneHook) - Seq.Manager.AnimationDone(success: true); - } - Seq.Manager.UseTime(); - - // ── Observables ── - uint substate = Seq.Manager.State.Substate; - if (substate == Run) TicksInRun++; - if (substate == Walk) TicksInWalkFwd++; - bool inFwd = substate == Run || substate == Walk; - if (inFwd) - { - _runStreak++; - if (_runStreak > MaxRunStreak) MaxRunStreak = _runStreak; - } - else - { - _runStreak = 0; - } - if (inFwd && _prevSubstate != Run && _prevSubstate != Walk) - RunInstalls++; - _prevSubstate = substate; - } - - public float Heading => MoveToMath.GetHeading(Body.Orientation); - public float DistToPlayer => Vector3.Distance(Body.Position, PlayerPos); - - public void Snapshot(string tag) - { - string line = FormattableString.Invariant( - $"t={Now,6:F2} {tag,-14} mt={Mgr.MovementTypeState,-14} substate=0x{Seq.Manager.State.Substate:X8} heading={Heading,6:F1} dist={DistToPlayer,6:F2} pending={Interp.MotionsPending()} omega.Z={ObservedOmega.Z,6:F3}"); - Trace.Add(line); - _log?.WriteLine(line); - } - - // ── Fixture ───────────────────────────────────────────────────────────── - - private static Animation MakeAnim(int numFrames) - { - var anim = new Animation(); - for (int f = 0; f < numFrames; f++) - { - var pf = new AnimationFrame(1u); - pf.Frames.Add(new Frame { Origin = Vector3.Zero, Orientation = Quaternion.Identity }); - anim.PartFrames.Add(pf); - } - return anim; - } - - private static MotionData MakeMd(uint animId, float framerate = 30f, - Vector3? velocity = null, Vector3? omega = null) - { - var md = new MotionData(); - QualifiedDataId qid = animId; - md.Anims.Add(new AnimData - { - AnimId = qid, - LowFrame = 0, - HighFrame = -1, - Framerate = framerate, - }); - if (velocity is { } v) - { - md.Velocity = v; - md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasVelocity; - } - if (omega is { } o) - { - md.Omega = o; - md.Flags |= DatReaderWriter.Enums.MotionDataFlags.HasOmega; - } - return md; - } - - private static void AddLink(MotionTable mt, uint style, uint from, uint to, MotionData md) - { - int outer = (int)((style << 16) | (from & 0xFFFFFFu)); - if (!mt.Links.TryGetValue(outer, out var cmd)) - { - cmd = new MotionCommandData(); - mt.Links[outer] = cmd; - } - cmd.MotionData[(int)to] = md; - } - - /// - /// Two-stance creature table shaped like the Mite Scamp's: NonCombat + - /// Combat styles (default substate Ready in both), Walk/Run cycles with - /// entry/exit links (15 frames @30fps = 0.5 s links, the realistic stop/ - /// start durations), a stance-transition link, an attack action link, and - /// a global TurnRight physics-only modifier. - /// - private static (Setup, MotionTable, ChaseLoader) BuildFixture() - { - const uint ReadyAnimNC = 0x200u; - const uint ReadyAnimC = 0x201u; - const uint WalkAnim = 0x202u; - const uint RunAnim = 0x203u; - const uint ReadyToWalk = 0x204u; - const uint WalkToReady = 0x205u; - const uint ReadyToRun = 0x206u; - const uint RunToReady = 0x207u; - const uint StanceLink = 0x208u; // NonCombat.Ready → Combat (draw) - const uint AttackLink = 0x209u; // Combat.Ready → attack swing - const uint TurnAnim = 0x20Au; - - var setup = new Setup(); - setup.Parts.Add(0x01000000u); - setup.DefaultScale.Add(Vector3.One); - - var loader = new ChaseLoader(); - loader.Register(ReadyAnimNC, MakeAnim(30)); - loader.Register(ReadyAnimC, MakeAnim(30)); - loader.Register(WalkAnim, MakeAnim(30)); - loader.Register(RunAnim, MakeAnim(30)); - loader.Register(ReadyToWalk, MakeAnim(15)); - loader.Register(WalkToReady, MakeAnim(15)); - loader.Register(ReadyToRun, MakeAnim(15)); - loader.Register(RunToReady, MakeAnim(15)); - loader.Register(StanceLink, MakeAnim(15)); - loader.Register(AttackLink, MakeAnim(45)); // 1.5 s swing - loader.Register(TurnAnim, MakeAnim(30)); - - var mt = new MotionTable { DefaultStyle = (DRWMotionCommand)NonCombat }; - mt.StyleDefaults[(DRWMotionCommand)NonCombat] = (DRWMotionCommand)Ready; - mt.StyleDefaults[(DRWMotionCommand)Combat] = (DRWMotionCommand)Ready; - - static int CycleKey(uint style, uint substate) - => (int)((style << 16) | (substate & 0xFFFFFFu)); - - mt.Cycles[CycleKey(NonCombat, Ready)] = MakeMd(ReadyAnimNC); - mt.Cycles[CycleKey(Combat, Ready)] = MakeMd(ReadyAnimC); - foreach (uint style in new[] { NonCombat, Combat }) - { - mt.Cycles[CycleKey(style, Walk)] = - MakeMd(WalkAnim, velocity: new Vector3(0f, 3.12f, 0f)); - mt.Cycles[CycleKey(style, Run)] = - MakeMd(RunAnim, velocity: new Vector3(0f, 4.0f, 0f)); - AddLink(mt, style, Ready, Walk, MakeMd(ReadyToWalk)); - AddLink(mt, style, Walk, Ready, MakeMd(WalkToReady)); - AddLink(mt, style, Ready, Run, MakeMd(ReadyToRun)); - AddLink(mt, style, Run, Ready, MakeMd(RunToReady)); - } - AddLink(mt, NonCombat, Ready, Combat, MakeMd(StanceLink)); - AddLink(mt, Combat, Ready, NonCombat, MakeMd(StanceLink)); - AddLink(mt, Combat, Ready, AttackAction, MakeMd(AttackLink)); - - // Global (unstyled) TurnRight modifier — physics-only in Branch 4. - mt.Modifiers[(int)(TurnRight & 0xFFFFFFu)] = MakeMd(TurnAnim); - - return (setup, mt, loader); - } -} - -public sealed class RemoteChaseEndToEndHarnessTests -{ - private readonly ITestOutputHelper _out; - public RemoteChaseEndToEndHarnessTests(ITestOutputHelper output) => _out = output; - - private const float Dt = RemoteChaseHarness.Dt; - - private static int Seconds(float s) => (int)MathF.Round(s / Dt); - - /// - /// The core chase cycle: aggro stance change, one mt-6 arm at a target - /// 90° off the creature's facing, 15 m away, stationary. Retail bar: the - /// stance links play out (~1 s), the chase turn starts, completes at the - /// turn rate (90° at π/2·2.08 rad/s ≈ 0.5 s), and BeginMoveForward - /// installs the forward cycle. Total budget: 4 s of ticks is generous. - /// - [Theory] - [InlineData(90f)] // target to the East → TurnRight path - [InlineData(270f)] // target to the West → TurnLeft path - [InlineData(170f)] // near-reversal → long right turn - public void SingleArm_TurnCompletes_AndForwardInstalls(float bearingDeg) - { - var h = new RemoteChaseHarness(_out); - - float rad = (90f - bearingDeg) * MathF.PI / 180f; // compass → math angle - h.PlayerPos = new Vector3(MathF.Cos(rad), MathF.Sin(rad), 0f) * 15f; - h.PlayerVelocity = Vector3.Zero; - - // settle spawn state - for (int i = 0; i < Seconds(0.5f); i++) h.Tick(); - h.Snapshot("spawned"); - - // aggro: stance UM (the live capture's mt-0 stance=0x3C fwd=Ready) - h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready); - for (int i = 0; i < Seconds(1.5f); i++) h.Tick(); - h.Snapshot("post-stance"); - - // the arm - h.UmMoveToObject(); - h.Snapshot("armed"); - - int installTick = -1; - for (int i = 0; i < Seconds(6f); i++) - { - h.Tick(); - if (installTick < 0 - && (h.Seq.Manager.State.Substate == RemoteChaseHarness.Run - || h.Seq.Manager.State.Substate == RemoteChaseHarness.Walk)) - { - installTick = i; - h.Snapshot("fwd-install"); - } - if (i % Seconds(0.5f) == 0) h.Snapshot("tick"); - } - h.Snapshot("end"); - - Assert.True(installTick >= 0, - $"forward cycle never installed within 6 s of the arm (bearing {bearingDeg}°); " + - $"final: mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8} " + - $"heading={h.Heading:F1} pending={h.Interp.MotionsPending()} omega.Z={h.ObservedOmega.Z:F3}"); - Assert.True(installTick <= Seconds(4f), - $"forward cycle took {installTick * Dt:F2} s to install (bearing {bearingDeg}°) — " + - "retail installs within the turn duration (~1-2 s)"); - } - - /// - /// The live failure scenario: the player FLEES at 4 m/s and ACE re-arms - /// mt-6 every 2 s (launch-drainq.log cadence). Retail bar: BeginMoveForward - /// ≈ one per arm (21/22 in the live cdb trace) and the run is SUSTAINED — - /// the forward substate holds for most of the chase. - /// - [Fact] - public void FleeingTarget_RunSustainedAcrossRearms() - { - var h = new RemoteChaseHarness(_out); - - h.PlayerPos = new Vector3(0f, 10f, 0f); // dead ahead, 10 m - h.PlayerVelocity = new Vector3(0f, 4f, 0f); // fleeing straight away - - h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready); - for (int i = 0; i < Seconds(1.5f); i++) h.Tick(); - - int arms = 0; - int chaseTicks = Seconds(12f); - for (int i = 0; i < chaseTicks; i++) - { - if (i % Seconds(2f) == 0) - { - h.UmMoveToObject(); - arms++; - h.Snapshot($"arm#{arms}"); - } - h.Tick(); - if (i % Seconds(1f) == 0) h.Snapshot("tick"); - } - h.Snapshot("end"); - - int fwdTicks = h.TicksInRun + h.TicksInWalkFwd; - float fwdFraction = fwdTicks / (float)chaseTicks; - _out.WriteLine(FormattableString.Invariant( - $"arms={arms} installs={h.RunInstalls} fwdTicks={fwdTicks}/{chaseTicks} ({fwdFraction:P0}) maxStreak={h.MaxRunStreak * Dt:F2}s")); - - Assert.True(h.RunInstalls >= arms - 1, - $"run installed only {h.RunInstalls}× across {arms} arms — " + - "retail reinstalls per arm (21/22)"); - Assert.True(fwdFraction > 0.5f, - $"forward substate held only {fwdFraction:P0} of the chase — the run is not " + - "sustained (live #170 symptom: short bursts + idle glide)"); - } - - /// - /// Attack interleave: mid-chase, an attack UM (mt-0, action list carrying - /// the swing, fwd=Ready — the wire shape from the live capture) interrupts - /// the moveto (retail-faithful), the swing plays, and the next mt-6 re-arm - /// must reinstall the run. Also asserts the #170 drain criterion: - /// pending_motions fully empties after the swing (add == done — the retail - /// cdb invariant). - /// - [Fact] - public void AttackUm_ThenRearm_RunReinstalls_AndQueueDrains() - { - var h = new RemoteChaseHarness(_out); - - h.PlayerPos = new Vector3(0f, 12f, 0f); - h.PlayerVelocity = Vector3.Zero; - - h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready); - for (int i = 0; i < Seconds(1.5f); i++) h.Tick(); - - h.UmMoveToObject(); - for (int i = 0; i < Seconds(3f); i++) h.Tick(); - h.Snapshot("chasing"); - - // the swing: interrupt + action (stamp 1, autonomous false) - h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready, 1f, - new InboundMotionAction(RemoteChaseHarness.AttackAction, Stamp: 1, - Autonomous: false, Speed: 1f)); - for (int i = 0; i < Seconds(2.5f); i++) h.Tick(); - h.Snapshot("post-swing"); - - Assert.False(h.Interp.MotionsPending(), - "pending_motions did not fully empty after the swing — the #170 " + - "residual (retail: add_to_queue == MotionDone exactly)"); - - // the player breaks away (out of attack range), then ACE re-arms — - // the run must come back - h.PlayerPos += new Vector3(0f, 10f, 0f); - h.UmMoveToObject(); - int installTick = -1; - for (int i = 0; i < Seconds(5f); i++) - { - h.Tick(); - if (installTick < 0 - && (h.Seq.Manager.State.Substate == RemoteChaseHarness.Run - || h.Seq.Manager.State.Substate == RemoteChaseHarness.Walk)) - { - installTick = i; - } - } - h.Snapshot("post-rearm"); - - Assert.True(installTick >= 0, - $"run did not reinstall after the post-swing re-arm; " + - $"mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8} " + - $"pending={h.Interp.MotionsPending()}"); - } - - // ── R5-V3 (#171): sticky melee scenarios ──────────────────────────────── - - private static float AbsHeadingDiff(float a, float b) - { - float d = MathF.Abs(a - b) % 360f; - return d > 180f ? 360f - d : d; - } - - /// - /// The #171 fix's core: ACE arms melee chases Sticky+UseSpheres with the - /// target's real radii; on arrival BeginNextNode hands off to - /// PositionManager::StickTo (0x00529d3a) and StickyManager::adjust_offset - /// (0x00555430) holds a 0.3 m edge gap + live facing against a strafing - /// target — for the 1 s lease, which is set ONCE at StickTo and NOT - /// refreshed by target updates (retail 0x00555710/0x00555610): a stick - /// not re-issued by a fresh server arm tears itself down. - /// - [Fact] - public void StickyArrival_TracksStrafingTarget_ThenLeaseExpires() - { - var h = new RemoteChaseHarness(_out); - h.PlayerPos = new Vector3(0f, 10f, 0f); // dead ahead (North) - h.PlayerVelocity = Vector3.Zero; - - h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready); - for (int i = 0; i < Seconds(1.5f); i++) h.Tick(); - - h.UmMoveToObject(sticky: true, useSpheres: true, - targetRadius: RemoteChaseHarness.StickyTargetRadius, targetHeight: 1.2f); - - int stickTick = -1; - for (int i = 0; i < Seconds(8f) && stickTick < 0; i++) - { - h.Tick(); - if (h.Pm.GetStickyObjectId() == RemoteChaseHarness.PlayerGuid) - stickTick = i; - } - h.Snapshot("stuck"); - Assert.True(stickTick >= 0, - $"sticky never armed within 8 s of the arm; mt={h.Mgr.MovementTypeState} " + - $"dist={h.DistToPlayer:F2}"); - // Arrival cleaned the moveto BEFORE the handoff (BeginNextNode reads - // Sought* pre-CleanUp; CleanUp resets movement_type to Invalid). - Assert.Equal(MovementType.Invalid, h.Mgr.MovementTypeState); - - // The target strafes — the follower must track gap AND facing - // (adjust_offset resolves the LIVE target via GetObjectA per tick). - h.PlayerVelocity = new Vector3(2f, 0f, 0f); - for (int i = 0; i < Seconds(0.8f); i++) h.Tick(); - h.Snapshot("strafed"); - - Assert.Equal(RemoteChaseHarness.PlayerGuid, h.Pm.GetStickyObjectId()); // inside the lease - float gap = MoveToMath.CylinderDistanceNoZ( - RemoteChaseHarness.OwnRadius, h.Body.Position, - RemoteChaseHarness.StickyTargetRadius, h.PlayerPos); - Assert.True(MathF.Abs(gap - StickyManager.StickyRadius) < 0.1f, - $"stick gap {gap:F2} m — expected ≈{StickyManager.StickyRadius:F1} m (StickyRadius)"); - float bearing = MoveToMath.PositionHeading(h.Body.Position, h.PlayerPos); - Assert.True(AbsHeadingDiff(h.Heading, bearing) < 5f, - $"facing {h.Heading:F1}° vs bearing {bearing:F1}° — sticky facing not tracking"); - - // Lease expiry: >1 s since StickTo with no re-arm → the UseTime - // watchdog drops the stick (retail parity — ACE re-arms each attack - // cycle, renewing the stick server-side). - h.PlayerVelocity = Vector3.Zero; - for (int i = 0; i < Seconds(0.5f); i++) h.Tick(); - Assert.Equal(0u, h.Pm.GetStickyObjectId()); - } - - /// - /// The pack-melee reshuffle cycle: every fresh server arm - /// (PerformMovement) tears the previous stick down at the HEAD - /// (unstick_from_object → PositionManager::UnStick — the retail - /// PerformMovement:414 + UM-funnel-head sites), then the new chase - /// re-arrives and re-sticks. - /// - [Fact] - public void NextPerformMovement_Unsticks_ThenRearrivalResticks() - { - var h = new RemoteChaseHarness(_out); - h.PlayerPos = new Vector3(0f, 8f, 0f); - h.PlayerVelocity = Vector3.Zero; - - h.UmInterpreted(RemoteChaseHarness.Combat, RemoteChaseHarness.Ready); - for (int i = 0; i < Seconds(1.5f); i++) h.Tick(); - - h.UmMoveToObject(sticky: true, useSpheres: true, - targetRadius: RemoteChaseHarness.StickyTargetRadius, targetHeight: 1.2f); - for (int i = 0; i < Seconds(8f); i++) - { - h.Tick(); - if (h.Pm.GetStickyObjectId() != 0u) break; - } - Assert.Equal(RemoteChaseHarness.PlayerGuid, h.Pm.GetStickyObjectId()); - - // The target breaks away; ACE re-arms the chase — the arm must - // UNSTICK immediately (stale stick torn down before the new moveto). - h.PlayerPos += new Vector3(0f, 8f, 0f); - h.UmMoveToObject(sticky: true, useSpheres: true, - targetRadius: RemoteChaseHarness.StickyTargetRadius, targetHeight: 1.2f); - Assert.Equal(0u, h.Pm.GetStickyObjectId()); - Assert.Equal(MovementType.MoveToObject, h.Mgr.MovementTypeState); - - // ...and the new chase re-arrives and re-sticks. - int restick = -1; - for (int i = 0; i < Seconds(8f) && restick < 0; i++) - { - h.Tick(); - if (h.Pm.GetStickyObjectId() == RemoteChaseHarness.PlayerGuid) - restick = i; - } - Assert.True(restick >= 0, - $"chase did not re-stick after the re-arm; mt={h.Mgr.MovementTypeState} " + - $"dist={h.DistToPlayer:F2}"); - } - - /// - /// R5-V4a: the unpack-head style-on-change — a chase arm (mt-6) whose UM - /// carries a CHANGED stance applies the stance FIRST (retail - /// unpack_movement @00524502-0052452c dispatches DoMotion(style) before - /// the movement-type switch), so the creature draws into Combat and THEN - /// runs — instead of chasing in the old NonCombat stance until the next - /// mt-0 UM. Closes the RetailObserverTraceConformanceTests "S3 wires the - /// unpack-level style-on-change" exclusion's production gap. - /// - [Fact] - public void ChaseArm_WithStanceChange_AppliesStanceBeforeTheChase() - { - var h = new RemoteChaseHarness(_out); - h.PlayerPos = new Vector3(0f, 12f, 0f); - h.PlayerVelocity = Vector3.Zero; - - // settle in NonCombat — NO prior stance UM (the pre-V4 gap: nothing - // but an mt-0 could change the stance). - for (int i = 0; i < Seconds(0.5f); i++) h.Tick(); - Assert.Equal(RemoteChaseHarness.NonCombat, h.Seq.Manager.State.Style); - - // the arm carries the Combat stance in its UM header (mt-6). - h.UmMoveToObject(wireStance: RemoteChaseHarness.Combat & 0xFFFFu); - - // the stance adopts at the head — before any chase motion completes. - Assert.Equal(RemoteChaseHarness.Combat, h.Interp.InterpretedState.CurrentStyle); - - // ...and the chase still installs its forward cycle normally, now in - // the Combat style family. - int installTick = -1; - for (int i = 0; i < Seconds(6f) && installTick < 0; i++) - { - h.Tick(); - if (h.Seq.Manager.State.Substate == RemoteChaseHarness.Run - || h.Seq.Manager.State.Substate == RemoteChaseHarness.Walk) - installTick = i; - } - Assert.True(installTick >= 0, - $"forward cycle never installed after the stance-carrying arm; " + - $"mt={h.Mgr.MovementTypeState} substate=0x{h.Seq.Manager.State.Substate:X8}"); - Assert.Equal(RemoteChaseHarness.Combat, h.Seq.Manager.State.Style); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs deleted file mode 100644 index 744785df..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/StickyManagerTests.cs +++ /dev/null @@ -1,247 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R5 conformance — (retail 0x00555400-0x00555866). -/// Lifecycle (StickTo/UnStick/timeout/HandleUpdateTarget) + the decoded -/// adjust_offset steering math (port-plan §2a). -/// -public sealed class StickyManagerTests -{ - private static (R5Host self, R5Host target, StickyManager sticky) Setup( - uint targetId = 20u, float targetRadius = 0.5f) - { - var world = new Dictionary(); - var self = new R5Host(10u, world); - var target = new R5Host(targetId, world); - var sticky = new StickyManager(self); - return (self, target, sticky); - } - - private static StickyManager StuckAndInitialized( - R5Host self, R5Host target, Vector3 targetOrigin, float targetRadius = 0.5f) - { - var sticky = new StickyManager(self); - sticky.StickTo(target.Id, targetRadius, targetHeight: 1.0f); - target.SetOrigin(targetOrigin); - var tp = new Position(1u, targetOrigin, Quaternion.Identity); - sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); - return sticky; - } - - [Fact] - public void StickTo_SetsTargetAndTimeout_AndSubscribesVoyeurOnTarget() - { - var (self, target, sticky) = Setup(); - self.CurTime = 100.0; - - sticky.StickTo(target.Id, targetRadius: 0.5f, targetHeight: 2.0f); - - Assert.Equal(target.Id, sticky.TargetId); - Assert.Equal(0.5f, sticky.TargetRadius); - Assert.False(sticky.Initialized); - Assert.Equal(101.0, sticky.StickyTimeoutTime); // now + StickyTime(1.0) - // set_target(0, target, 0.5, 0.5) → watcher subscribes ON the target. - Assert.NotNull(target.TargetManagerOrNull); - Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); - } - - [Fact] - public void HandleUpdateTarget_Ok_MarksInitializedAndCachesPosition() - { - var (self, target, sticky) = Setup(); - sticky.StickTo(target.Id, 0.5f, 1.0f); - - var tp = new Position(1u, new Vector3(3f, 0f, 0f), Quaternion.Identity); - sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); - - Assert.True(sticky.Initialized); - Assert.Equal(new Vector3(3f, 0f, 0f), sticky.TargetPosition.Frame.Origin); - } - - [Fact] - public void HandleUpdateTarget_ForeignObject_Ignored() - { - var (self, target, sticky) = Setup(); - sticky.StickTo(target.Id, 0.5f, 1.0f); - - var tp = new Position(1u, Vector3.Zero, Quaternion.Identity); - sticky.HandleUpdateTarget(new TargetInfo(999u, TargetStatus.Ok, tp, tp)); - - Assert.False(sticky.Initialized); - Assert.Equal(target.Id, sticky.TargetId); // still stuck to the real target - } - - [Fact] - public void HandleUpdateTarget_NonOkStatus_TearsDown() - { - var (self, target, sticky) = Setup(); - sticky.StickTo(target.Id, 0.5f, 1.0f); - int interruptsBefore = self.InterruptCurrentMovementCalls; - - var tp = new Position(1u, Vector3.Zero, Quaternion.Identity); - sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.ExitWorld, tp, tp)); - - Assert.Equal(0u, sticky.TargetId); - Assert.False(sticky.Initialized); - Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls); - } - - [Fact] - public void UseTime_BeforeDeadline_KeepsStick() - { - var (self, target, sticky) = Setup(); - self.CurTime = 100.0; - sticky.StickTo(target.Id, 0.5f, 1.0f); // deadline 101.0 - - self.CurTime = 100.9; - sticky.UseTime(); - - Assert.Equal(target.Id, sticky.TargetId); - } - - [Fact] - public void UseTime_PastDeadline_UnsticksAndInterrupts() - { - var (self, target, sticky) = Setup(); - self.CurTime = 100.0; - sticky.StickTo(target.Id, 0.5f, 1.0f); // deadline 101.0 - - // AT the deadline the stick survives — retail 0x00555626 tears down - // strictly AFTER it (`test ah,0x41`: C0|C3 = less-or-equal keeps; - // ACE `>` too). The R5-V1 pin of `>=` was the wrong value. - self.CurTime = 101.0; - sticky.UseTime(); - Assert.Equal(target.Id, sticky.TargetId); - - int interruptsBefore = self.InterruptCurrentMovementCalls; - self.CurTime = 101.001; - sticky.UseTime(); - - Assert.Equal(0u, sticky.TargetId); - Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls); - } - - [Fact] - public void ReStick_TearsDownPreviousBeforeSettingNew() - { - var world = new Dictionary(); - var self = new R5Host(10u, world); - var a = new R5Host(20u, world); - var b = new R5Host(21u, world); - var sticky = new StickyManager(self); - - sticky.StickTo(a.Id, 0.5f, 1.0f); - int interruptsBefore = self.InterruptCurrentMovementCalls; - sticky.StickTo(b.Id, 0.5f, 1.0f); - - Assert.Equal(b.Id, sticky.TargetId); - Assert.Equal(interruptsBefore + 1, self.InterruptCurrentMovementCalls); // old stick torn down - } - - [Fact] - public void AdjustOffset_NotInitialized_IsNoOp() - { - var (self, target, sticky) = Setup(); - sticky.StickTo(target.Id, 0.5f, 1.0f); // no HandleUpdateTarget → not initialized - - var frame = new MotionDeltaFrame { Origin = new Vector3(9f, 9f, 9f) }; - sticky.AdjustOffset(frame, 0.1); - - Assert.Equal(new Vector3(9f, 9f, 9f), frame.Origin); // untouched - } - - [Fact] - public void AdjustOffset_SteersTowardTarget_ClampedToStep() - { - var world = new Dictionary(); - var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f }; - var target = new R5Host(20u, world); - var sticky = StuckAndInitialized(self, target, new Vector3(5f, 0f, 0f)); - - var frame = new MotionDeltaFrame(); - sticky.AdjustOffset(frame, quantum: 0.1); - - // dir = +X (east); speed = 1.0 * 5 = 5; delta = 5 * 0.1 = 0.5 (< dist 3.7). - Assert.Equal(0.5f, frame.Origin.X, 3); - Assert.Equal(0f, frame.Origin.Y, 3); - Assert.Equal(0f, frame.Origin.Z, 3); // horizontal-only - // heading toward +X (east) = 90° compass. - Assert.Equal(90f, frame.GetHeading(), 1); - } - - [Fact] - public void AdjustOffset_TooClose_BacksOff_SignedDistance() - { - var world = new Dictionary(); - var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f }; - var target = new R5Host(20u, world); - // centerDist 0.9, cyl = 0.9-0.5-0.5 = -0.1, minus 0.3 → dist = -0.4. - var sticky = StuckAndInitialized(self, target, new Vector3(0.9f, 0f, 0f)); - - var frame = new MotionDeltaFrame(); - sticky.AdjustOffset(frame, quantum: 0.1); - - // delta = 5*0.1 = 0.5 >= |dist|=0.4 → delta = dist = -0.4; dir +X * -0.4 → back off (-X). - Assert.Equal(-0.4f, frame.Origin.X, 3); - } - - [Fact] - public void AdjustOffset_DeepOverlap_BacksOff_RateLimited() - { - var world = new Dictionary(); - var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f }; - var target = new R5Host(20u, world); - // centerDist 0.1 → cyl = 0.1-0.5-0.5 = -0.9, minus 0.3 → dist = -1.2 - // (overlap DEEPER than one tick's step). - var sticky = StuckAndInitialized(self, target, new Vector3(0.1f, 0f, 0f)); - - var frame = new MotionDeltaFrame(); - sticky.AdjustOffset(frame, quantum: 0.1); - - // delta = 5*0.1 = 0.5 < |dist|=1.2 — ACE's literal branch kept +0.5 - // here (INWARD: the #171 gate-3 runaway-to-center; 1661 probe ticks, - // all inward, equilibrium at centers-coincident). The sign pin backs - // off rate-limited: dir +X × −0.5. - Assert.Equal(-0.5f, frame.Origin.X, 3); - } - - [Fact] - public void AdjustOffset_UsesCachedPosition_WhenTargetUnresolvable() - { - var world = new Dictionary(); - var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = 1.0f }; - var target = new R5Host(20u, world); - var sticky = new StickyManager(self); - sticky.StickTo(target.Id, 0.5f, 1.0f); - // Cache a position via HandleUpdateTarget, then make the target vanish. - var tp = new Position(1u, new Vector3(4f, 0f, 0f), Quaternion.Identity); - sticky.HandleUpdateTarget(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); - target.Resolvable = false; // GetObjectA(target) → null → fall back to cached - - var frame = new MotionDeltaFrame(); - sticky.AdjustOffset(frame, quantum: 0.1); - - Assert.Equal(0.5f, frame.Origin.X, 3); // still steers toward cached (4,0,0) - } - - [Fact] - public void AdjustOffset_NoMinterp_UsesFallbackSpeed() - { - var world = new Dictionary(); - var self = new R5Host(10u, world) { Radius = 0.5f, MinterpMaxSpeed = null }; - var target = new R5Host(20u, world); - var sticky = StuckAndInitialized(self, target, new Vector3(50f, 0f, 0f)); - - var frame = new MotionDeltaFrame(); - sticky.AdjustOffset(frame, quantum: 0.1); - - // fallback speed 15 → delta = 15 * 0.1 = 1.5 (dist ≈ 49 → not clamped). - Assert.Equal(1.5f, frame.Origin.X, 3); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs b/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs deleted file mode 100644 index 0fd2130b..00000000 --- a/tests/AcDream.Core.Tests/Physics/Motion/TargetManagerTests.cs +++ /dev/null @@ -1,255 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics.Motion; - -/// -/// R5 conformance — voyeur system (retail -/// 0x0051a370-0x0051ad90). Watcher role (SetTarget/ReceiveUpdate/timeout), -/// watched role (AddVoyeur/HandleTargetting/CheckAndUpdateVoyeur), and the full -/// cross-entity round-trip (port-plan §2d/§2e). Replaces the AP-79 adapter. -/// -public sealed class TargetManagerTests -{ - private static (R5Host self, R5Host target, Dictionary world) TwoHosts() - { - var world = new Dictionary(); - var self = new R5Host(10u, world); - var target = new R5Host(20u, world); - return (self, target, world); - } - - // ── watcher role ─────────────────────────────────────────────────────── - - [Fact] - public void SetTarget_SubscribesOnTarget_AndReceivesImmediateSnapshot() - { - var (self, target, _) = TwoHosts(); - target.SetOrigin(new Vector3(3f, 0f, 0f)); - - self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0); - - // Watcher subscribed on the target. - Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); - // Immediate Ok snapshot fanned to the watcher's host. - Assert.Single(self.HandleUpdateTargetCalls); - Assert.Equal(TargetStatus.Ok, self.HandleUpdateTargetCalls[0].Status); - Assert.Equal(target.Id, self.HandleUpdateTargetCalls[0].ObjectId); - Assert.Equal(new Vector3(3f, 0f, 0f), - self.HandleUpdateTargetCalls[0].InterpolatedPosition.Frame.Origin); - Assert.Equal(TargetStatus.Ok, self.TargetManager.TargetInfo!.Value.Status); - } - - [Fact] - public void SetTarget_Zero_SynthesizesTimedOut_LeavesTargetInfoNull() - { - var (self, _, _) = TwoHosts(); - self.TargetManager.SetTarget(contextId: 7, objectId: 0, radius: 1.0f, quantum: 0.0); - - Assert.Null(self.TargetManager.TargetInfo); - Assert.Single(self.HandleUpdateTargetCalls); - Assert.Equal(TargetStatus.TimedOut, self.HandleUpdateTargetCalls[0].Status); - Assert.Equal(7u, self.HandleUpdateTargetCalls[0].ContextId); - } - - [Fact] - public void ClearTarget_UnsubscribesFromTarget() - { - var (self, target, _) = TwoHosts(); - self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); - Assert.True(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); - - self.TargetManager.ClearTarget(); - - Assert.Null(self.TargetManager.TargetInfo); - Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); - } - - [Fact] - public void ReceiveUpdate_ForWrongObject_Ignored() - { - var (self, target, _) = TwoHosts(); - self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); - int before = self.HandleUpdateTargetCalls.Count; - - var p = new Position(1u, Vector3.Zero, Quaternion.Identity); - self.TargetManager.ReceiveUpdate(new TargetInfo(999u, TargetStatus.Ok, p, p)); - - Assert.Equal(before, self.HandleUpdateTargetCalls.Count); - } - - [Fact] - public void ReceiveUpdate_ExitWorld_ClearsTarget() - { - var (self, target, _) = TwoHosts(); - self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); - - var p = new Position(1u, Vector3.Zero, Quaternion.Identity); - self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.ExitWorld, p, p)); - - Assert.Null(self.TargetManager.TargetInfo); // cleared - Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); // unsubscribed - } - - [Fact] - public void ReceiveUpdate_ComputesInterpolatedHeadingTowardTarget() - { - var (self, target, _) = TwoHosts(); - self.SetOrigin(Vector3.Zero); - self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); - - var tp = new Position(1u, new Vector3(0f, 5f, 0f), Quaternion.Identity); - self.TargetManager.ReceiveUpdate(new TargetInfo(target.Id, TargetStatus.Ok, tp, tp)); - - // self→target interp position (0,5,0) normalized = +Y. - var heading = self.TargetManager.TargetInfo!.Value.InterpolatedHeading; - Assert.Equal(0f, heading.X, 3); - Assert.Equal(1f, heading.Y, 3); - } - - [Fact] - public void HandleTargetting_UndefinedTarget_TimesOutAfter10Seconds() - { - var (self, target, _) = TwoHosts(); - target.Resolvable = false; // no immediate snapshot → status stays Undefined - self.CurTime = 0; - self.PhysicsTimerTime = 0; - self.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); - Assert.Equal(TargetStatus.Undefined, self.TargetManager.TargetInfo!.Value.Status); - - self.AdvanceClocks(11.0); - self.TargetManager.HandleTargetting(); - - Assert.Equal(TargetStatus.TimedOut, self.TargetManager.TargetInfo!.Value.Status); - Assert.Contains(self.HandleUpdateTargetCalls, c => c.Status == TargetStatus.TimedOut); - } - - // ── watched role + gates ──────────────────────────────────────────────── - - [Fact] - public void HandleTargetting_Throttles_Within500ms() - { - var (self, target, _) = TwoHosts(); - var w = new TargettedVoyeurInfo(self.Id, radius: 0.1f, quantum: 0.0); - // seed a voyeur directly + advance so a sweep WOULD send if not throttled. - target.TargetManager.AddVoyeur(self.Id, 0.1f, 0.0); - target.SetOrigin(new Vector3(10f, 0f, 0f)); // far past radius - - target.PhysicsTimerTime = 0.3; // < 0.5 throttle - int before = self.HandleUpdateTargetCalls.Count; - target.TargetManager.HandleTargetting(); - - Assert.Equal(before, self.HandleUpdateTargetCalls.Count); // throttled, no sweep - } - - [Fact] - public void CheckAndUpdateVoyeur_SendsOnlyPastRadius() - { - var (self, target, _) = TwoHosts(); - // self must have a matching target_info for ReceiveUpdate to record. - self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0); - int afterSubscribe = self.HandleUpdateTargetCalls.Count; // includes immediate snapshot - - // small move → within radius → no send - target.SetOrigin(new Vector3(0.5f, 0f, 0f)); - target.AdvanceClocks(1.0); - target.TargetManager.HandleTargetting(); - Assert.Equal(afterSubscribe, self.HandleUpdateTargetCalls.Count); - - // large move → past radius → send - target.SetOrigin(new Vector3(2f, 0f, 0f)); - target.AdvanceClocks(1.0); - target.TargetManager.HandleTargetting(); - Assert.Equal(afterSubscribe + 1, self.HandleUpdateTargetCalls.Count); - var last = self.HandleUpdateTargetCalls[^1]; - Assert.Equal(TargetStatus.Ok, last.Status); - Assert.Equal(2f, last.InterpolatedPosition.Frame.Origin.X, 3); - } - - [Fact] - public void GetInterpolatedPosition_DeadReckonsWithVelocity() - { - var (_, target, _) = TwoHosts(); - target.SetOrigin(new Vector3(1f, 2f, 0f)); - target.Velocity = new Vector3(10f, 0f, 0f); - - var p = target.TargetManager.GetInterpolatedPosition(quantum: 0.5); - - Assert.Equal(new Vector3(6f, 2f, 0f), p.Frame.Origin); // 1 + 10*0.5 = 6 - } - - [Fact] - public void AddVoyeur_Existing_UpdatesInPlace_NoResend() - { - var (self, target, _) = TwoHosts(); - target.SetOrigin(Vector3.Zero); - target.TargetManager.AddVoyeur(self.Id, radius: 1.0f, quantum: 0.0); - var voyeur = target.TargetManager.VoyeurTable![self.Id]; - Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin); - - target.SetOrigin(new Vector3(5f, 0f, 0f)); - target.TargetManager.AddVoyeur(self.Id, radius: 2.5f, quantum: 0.3); // existing → update only - - Assert.Equal(2.5f, voyeur.Radius); - Assert.Equal(0.3, voyeur.Quantum); - Assert.Equal(Vector3.Zero, voyeur.LastSentPosition.Frame.Origin); // NOT re-sent - } - - [Fact] - public void RemoveVoyeur_RemovesEntry() - { - var (self, target, _) = TwoHosts(); - target.TargetManager.AddVoyeur(self.Id, 1.0f, 0.0); - Assert.True(target.TargetManager.RemoveVoyeur(self.Id)); - Assert.False(target.TargetManager.VoyeurTable!.ContainsKey(self.Id)); - Assert.False(target.TargetManager.RemoveVoyeur(self.Id)); // already gone - } - - [Fact] - public void NotifyVoyeurOfEvent_BroadcastsToAll_NoDistanceGate() - { - var world = new Dictionary(); - var target = new R5Host(20u, world); - var w1 = new R5Host(10u, world); - var w2 = new R5Host(11u, world); - // both watch the target (each gets a target_info via SetTarget) - w1.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); - w2.TargetManager.SetTarget(0, target.Id, 1.0f, 0.0); - int w1Before = w1.HandleUpdateTargetCalls.Count; - int w2Before = w2.HandleUpdateTargetCalls.Count; - - target.TargetManager.NotifyVoyeurOfEvent(TargetStatus.Teleported); - - Assert.Equal(w1Before + 1, w1.HandleUpdateTargetCalls.Count); - Assert.Equal(w2Before + 1, w2.HandleUpdateTargetCalls.Count); - Assert.Equal(TargetStatus.Teleported, w1.HandleUpdateTargetCalls[^1].Status); - } - - // ── full round-trip ───────────────────────────────────────────────────── - - [Fact] - public void RoundTrip_WatcherTracksMovingTarget() - { - var (self, target, _) = TwoHosts(); - target.SetOrigin(Vector3.Zero); - - // Watcher sets target with a 1.0 radius (moveto-style, quantum 0). - self.TargetManager.SetTarget(0, target.Id, radius: 1.0f, quantum: 0.0); - Assert.Single(self.HandleUpdateTargetCalls); // immediate snapshot at (0,0,0) - - // Target walks to (3,0,0); its per-tick HandleTargetting notices the - // drift and pushes an update the watcher receives. - target.SetOrigin(new Vector3(3f, 0f, 0f)); - target.AdvanceClocks(1.0); - target.TargetManager.HandleTargetting(); - - Assert.Equal(2, self.HandleUpdateTargetCalls.Count); - Assert.Equal(new Vector3(3f, 0f, 0f), - self.HandleUpdateTargetCalls[^1].InterpolatedPosition.Frame.Origin); - // The watcher's cached target position tracks the target. - Assert.Equal(3f, self.TargetManager.TargetInfo!.Value.InterpolatedPosition.Frame.Origin.X, 3); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogDatTests.cs b/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogDatTests.cs deleted file mode 100644 index da169456..00000000 --- a/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogDatTests.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.Linq; -using AcDream.Core.Physics; -using AcDream.Core.Tests.Conformance; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// Real-DAT availability tests for the L.1b dual command catalog. A command -/// only actually ANIMATES if the entity's MotionTable has a -/// Links or Modifiers entry for the full 32-bit value — an -/// enum/table value alone proves nothing about whether the local DATs will -/// ever play it. -/// -/// -/// Reproduces the scan from -/// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md as -/// pinned assertions: a command "exists" for a MotionTable when its full -/// 32-bit value appears as either (a) an outer Links key's low 16 -/// bits (the from-state -> command transition key), (b) an inner -/// Links[...].MotionData key (the actual animation-bearing target), -/// or (c) a Modifiers key. This three-way check is what reproduces -/// the gap doc's hit counts exactly (verified against a live scan of the -/// local client_portal.dat: LifestoneRecall/ACE=19, -/// MarketplaceRecall/ACE=19, AllegianceHometownRecall/ACE=19, -/// OffhandSlashHigh/ACE=31, HouseRecall/ACE=24 tables; all four -/// 2013-numbered equivalents=0 tables; HouseRecall/2013=42 tables, i.e. -/// "both exist" for HouseRecall specifically). -/// -/// -/// -/// Gated the same way as the existing DAT-backed conformance suite — see -/// (also mirrored by -/// DoorBugTrajectoryReplayTests.ResolveDatDir): resolve -/// ACDREAM_DAT_DIR or the default Documents\Asheron's Call -/// path, and skip cleanly (return, no failure) when the dats aren't -/// present (CI has no local AC install). -/// -/// -public class MotionCommandCatalogDatTests -{ - /// - /// True if any local MotionTable can actually animate - /// — the from-state transition key - /// (Links outer, low 16 bits), the animation-bearing inner key - /// (Links[...].MotionData), or a Modifiers entry. - /// - private static bool ExistsInAnyMotionTable(DatCollection dats, uint fullCommand) - { - ushort low16 = (ushort)(fullCommand & 0xFFFFu); - int fullAsInt = unchecked((int)fullCommand); - - foreach (var id in dats.GetAllIdsOfType()) - { - var mt = dats.Get(id); - if (mt is null) continue; - - bool outerLow = mt.Links.Keys.Any(k => (k & 0xFFFF) == low16); - if (outerLow) return true; - - bool innerFull = mt.Links.Values.Any(md => md.MotionData.ContainsKey(fullAsInt)); - if (innerFull) return true; - - if (mt.Modifiers.ContainsKey(fullAsInt)) return true; - } - - return false; - } - - [Fact] - public void AceShiftedRecallCommands_ExistInLocalMotionTables() - { - var datDir = ConformanceDats.ResolveDatDir(); - if (datDir is null) return; - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - Assert.True(ExistsInAnyMotionTable(dats, 0x10000153u), "LifestoneRecall (ACE 0x10000153) must exist in local DAT MotionTables"); - Assert.True(ExistsInAnyMotionTable(dats, 0x1000013Au), "HouseRecall (ACE 0x1000013A) must exist in local DAT MotionTables"); - } - - [Fact] - public void TwoThousandThirteenLifestoneAndHouseRecall_AlsoExist() - { - var datDir = ConformanceDats.ResolveDatDir(); - if (datDir is null) return; - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - // LifestoneRecall's 2013 numbering (0x10000150) does NOT exist locally — - // only the ACE-shifted value animates. HouseRecall's 2013 numbering - // (0x10000137) DOES exist (the gap doc: "both exist; the old value is - // very common"). - Assert.False(ExistsInAnyMotionTable(dats, 0x10000150u), "LifestoneRecall (2013 0x10000150) unexpectedly found"); - Assert.True(ExistsInAnyMotionTable(dats, 0x10000137u), "HouseRecall (2013 0x10000137) must also exist in local DAT MotionTables"); - } - - [Theory] - [InlineData(0x10000166u)] // MarketplaceRecall (ACE) - [InlineData(0x10000171u)] // AllegianceHometownRecall (ACE) - [InlineData(0x10000173u)] // OffhandSlashHigh (ACE) - public void AceOnlyCommands_ExistOnlyUnderShiftedIds(uint aceFullCommand) - { - var datDir = ConformanceDats.ResolveDatDir(); - if (datDir is null) return; - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - Assert.True(ExistsInAnyMotionTable(dats, aceFullCommand), - $"ACE value 0x{aceFullCommand:X8} must exist in local DAT MotionTables"); - } - - [Theory] - [InlineData(0x10000163u)] // MarketplaceRecall (2013) - [InlineData(0x1000016Eu)] // AllegianceHometownRecall (2013) - [InlineData(0x10000170u)] // OffhandSlashHigh (2013) - public void TwoThousandThirteenOnlyValues_HaveZeroLinkHits(uint retail2013FullCommand) - { - var datDir = ConformanceDats.ResolveDatDir(); - if (datDir is null) return; - - using var dats = new DatCollection(datDir, DatAccessType.Read); - - Assert.False(ExistsInAnyMotionTable(dats, retail2013FullCommand), - $"2013 value 0x{retail2013FullCommand:X8} unexpectedly found in local DAT MotionTables"); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogTests.cs b/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogTests.cs deleted file mode 100644 index 7610cd8b..00000000 --- a/tests/AcDream.Core.Tests/Physics/MotionCommandCatalogTests.cs +++ /dev/null @@ -1,169 +0,0 @@ -using System.Collections.Generic; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// Validates the dual command-catalog seam introduced for the L.1b -/// movement/animation wire-parity slice (IMotionCommandCatalog). -/// -/// -/// Two catalogs exist because the local DATs and ACE's wire protocol use a -/// LATER-client command numbering than the Sept 2013 EoR decomp: -/// (runtime default — matches ACE + -/// local DAT MotionTables) and -/// (conformance/reference — matches the 2013 decomp's -/// command_ids[0x198] table verbatim). See -/// docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md for the -/// full divergence analysis (130 common names with different values; the -/// contiguous low-word "+3" shift that begins at SnowAngelState -/// (0x43000115 -> 0x43000118), NOT at AllegianceHometownRecall as an -/// earlier code comment incorrectly claimed). -/// -/// -public class MotionCommandCatalogTests -{ - private static readonly AceModernCommandCatalog AceModern = new(); - private static readonly Retail2013CommandCatalog Retail2013 = new(); - - // ── ACE mode (AceModernCommandCatalog) ────────────────────────────── - // - // These four wires sit inside the old override range (0x016E-0x0197) - // that MotionCommandResolver used to force-map via a blind per-range - // override. Built cleanly from the DatReaderWriter MotionCommand enum, - // they already resolve correctly with NO override: - // LifestoneRecall = 0x10000153, MarketplaceRecall = 0x10000166, - // AllegianceHometownRecall = 0x10000171, OffhandSlashHigh = 0x10000173 - // (DatReaderWriter.Enums.MotionCommand, Chorizite.DatReaderWriter 2.1.7). - - [Theory] - [InlineData(0x0153, 0x10000153u)] // LifestoneRecall - [InlineData(0x0166, 0x10000166u)] // MarketplaceRecall - [InlineData(0x0171, 0x10000171u)] // AllegianceHometownRecall - [InlineData(0x0173, 0x10000173u)] // OffhandSlashHigh - public void AceModern_ResolvesShiftedRecallAndActionCommands(ushort wire, uint expected) - { - Assert.Equal(expected, AceModern.ReconstructFullCommand(wire)); - } - - // Same matrix as MotionCommandResolverTests.ReconstructsKnownCommands — - // proves AceModernCommandCatalog reproduces the existing resolver's - // behavior for the well-established low end of the command space. - [Theory] - [InlineData(0x0003, 0x41000003u)] // Ready - [InlineData(0x0005, 0x45000005u)] // WalkForward - [InlineData(0x0007, 0x44000007u)] // RunForward - [InlineData(0x0006, 0x45000006u)] // WalkBackward - [InlineData(0x000D, 0x6500000Du)] // TurnRight - [InlineData(0x000E, 0x6500000Eu)] // TurnLeft - [InlineData(0x000F, 0x6500000Fu)] // SideStepRight - [InlineData(0x0015, 0x40000015u)] // Falling - [InlineData(0x0011, 0x40000011u)] // Dead - [InlineData(0x0012, 0x41000012u)] // Crouch - [InlineData(0x0013, 0x41000013u)] // Sitting - [InlineData(0x0014, 0x41000014u)] // Sleeping - [InlineData(0x0057, 0x10000057u)] // Sanctuary (death) - [InlineData(0x0058, 0x10000058u)] // ThrustMed - [InlineData(0x005B, 0x1000005Bu)] // SlashHigh - [InlineData(0x0061, 0x10000061u)] // Shoot - [InlineData(0x004B, 0x1000004Bu)] // Jumpup - [InlineData(0x0050, 0x10000050u)] // FallDown - [InlineData(0x0087, 0x13000087u)] // Wave - [InlineData(0x0080, 0x13000080u)] // Laugh - [InlineData(0x007D, 0x1300007Du)] // BowDeep - public void AceModern_MatchesExistingResolverMatrix(ushort wire, uint expected) - { - Assert.Equal(expected, AceModern.ReconstructFullCommand(wire)); - } - - // ── 2013 mode (Retail2013CommandCatalog) ──────────────────────────── - // - // Direct-index reconstruction from acclient_2013_pseudo_c.txt's - // command_ids[0x198] table at 0x007c73e8 (line 1017259). The 2013 - // decomp's *unshifted* values for the same four logical commands. - - [Theory] - [InlineData(0x0150, 0x10000150u)] // LifestoneRecall (2013 value) - [InlineData(0x0163, 0x10000163u)] // MarketplaceRecall (2013 value) - [InlineData(0x016E, 0x1000016Eu)] // AllegianceHometownRecall (2013 value) - [InlineData(0x0170, 0x10000170u)] // OffhandSlashHigh (2013 value) - public void Retail2013_ResolvesUnshiftedRecallAndActionCommands(ushort wire, uint expected) - { - Assert.Equal(expected, Retail2013.ReconstructFullCommand(wire)); - } - - // Anchor checks straight off the extracted table — these pin the - // extraction itself, independent of the recall-command narrative. - [Theory] - [InlineData(0x0000, 0x80000000u)] - [InlineData(0x0003, 0x41000003u)] - [InlineData(0x0005, 0x45000005u)] - [InlineData(0x0007, 0x44000007u)] - [InlineData(0x000D, 0x6500000Du)] - [InlineData(0x0150, 0x10000150u)] - [InlineData(0x0153, 0x09000153u)] // NOT LifestoneRecall in 2013 numbering — anchor only. - public void Retail2013_AnchorValuesMatchExtractedTable(ushort wire, uint expected) - { - Assert.Equal(expected, Retail2013.ReconstructFullCommand(wire)); - } - - [Fact] - public void Retail2013_OutOfRangeWireReturnsZero() - { - Assert.Equal(0u, Retail2013.ReconstructFullCommand(0xFFFF)); - } - - [Fact] - public void Retail2013_LastInRangeIndexResolves() - { - // [0x197] is the final entry of command_ids[0x198]. - Assert.Equal(0x10000197u, Retail2013.ReconstructFullCommand(0x0197)); - } - - [Fact] - public void Retail2013_FirstOutOfRangeIndexReturnsZero() - { - // 0x198 is one past the table's last valid index. - Assert.Equal(0u, Retail2013.ReconstructFullCommand(0x0198)); - } - - // ── Class-priority collision resolution ───────────────────────────── - // - // Retail's class-byte priority is: lower class byte wins - // (Action 0x10 < ChatEmote 0x12/0x13 < Modifier 0x20 < SubState 0x41 - // < ... < Style 0x80). This is exercised directly against the build - // logic via a small synthetic set of colliding low-words, because the - // CURRENT DatReaderWriter.Enums.MotionCommand enum (2.1.7, 409 distinct - // values) happens to contain zero same-low16 collisions today — so a - // matrix test against the live enum alone wouldn't actually exercise - // the tie-break rule. - - [Fact] - public void ClassPriority_LowerClassByteWins() - { - var candidates = new Dictionary - { - // Same low word (0x0042) claimed by three different classes; - // Action (0x10) must win over Modifier (0x20) and Style (0x80). - [0x0042] = 0u, - }; - - uint[] colliding = { 0x80000042u, 0x20000042u, 0x10000042u }; - uint resolved = AceModernCommandCatalog.ResolveClassPriority(colliding); - - Assert.Equal(0x10000042u, resolved); - } - - [Fact] - public void ClassPriority_OrderOfInputDoesNotMatter() - { - uint[] collidingReversed = { 0x10000099u, 0x41000099u, 0x80000099u }; - uint[] collidingForward = { 0x80000099u, 0x41000099u, 0x10000099u }; - - Assert.Equal( - AceModernCommandCatalog.ResolveClassPriority(collidingForward), - AceModernCommandCatalog.ResolveClassPriority(collidingReversed)); - Assert.Equal(0x10000099u, AceModernCommandCatalog.ResolveClassPriority(collidingForward)); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterDoMotionFamilyTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterDoMotionFamilyTests.cs deleted file mode 100644 index f767a887..00000000 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterDoMotionFamilyTests.cs +++ /dev/null @@ -1,878 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -// ───────────────────────────────────────────────────────────────────────────── -// MotionInterpreterDoMotionFamilyTests — R3-W5 (closes J3, J4, J9, J14). -// -// Oracle: docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md -// §2 DoMotion 0x00528d20 @306159 -// §5a StopCompletely 0x00527e40 @305208 -// §5b StopMotion 0x00528530 @305674 -// §1a add_to_queue 0x00527b80 @305032 -// docs/research/2026-07-02-r3-motioninterp/W0-pins.md A4 (MovementParameters -// bit table), A9 (StopCompletely jump-snapshot quirk), A10 (combat-stance / -// depth-cap error codes). -// ───────────────────────────────────────────────────────────────────────────── - -/// Fake WeenieObject — IsThePlayer/IsCreature/CanJump independently -/// configurable, matching the ground-lifecycle test file's convention. -file sealed class FakeWeenie : IWeenieObject -{ - public bool IsThePlayerResult; - public bool IsCreatureResult = true; - public bool CanJumpResult = true; - - public bool InqJumpVelocity(float extent, out float vz) { vz = 10f; return true; } - public bool InqRunRate(out float rate) { rate = 1f; return true; } - public bool CanJump(float extent) => CanJumpResult; - public bool IsThePlayer() => IsThePlayerResult; - public bool IsCreature() => IsCreatureResult; -} - -public sealed class MotionInterpreterDoMotionFamilyTests -{ - // ── helpers ─────────────────────────────────────────────────────────────── - - private static PhysicsBody MakeGrounded() - { - var body = new PhysicsBody - { - State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, - }; - body.TransientState = TransientStateFlags.Contact - | TransientStateFlags.OnWalkable - | TransientStateFlags.Active; - return body; - } - - private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null) - { - body ??= MakeGrounded(); - return new MotionInterpreter(body, weenie); - } - - // ========================================================================= - // DoMotion — 0x00528d20 @306159 - // ========================================================================= - - [Fact] - public void DoMotion_NullPhysicsObj_Returns8() - { - var interp = new MotionInterpreter(); - - var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters()); - - Assert.Equal(WeenieError.NoPhysicsObject, result); - } - - [Fact] - public void DoMotion_CancelMoveToBit_FiresInterruptSeam() - { - var interp = MakeInterp(); - bool fired = false; - interp.InterruptCurrentMovement = () => fired = true; - var p = new MovementParameters { CancelMoveTo = true }; - - interp.DoMotion(MotionCommand.WalkForward, p); - - Assert.True(fired, "the sign-bit (CancelMoveTo) must fire InterruptCurrentMovement before adjust_motion"); - } - - [Fact] - public void DoMotion_CancelMoveToClear_DoesNotFireInterruptSeam() - { - var interp = MakeInterp(); - bool fired = false; - interp.InterruptCurrentMovement = () => fired = true; - var p = new MovementParameters { CancelMoveTo = false }; - - interp.DoMotion(MotionCommand.WalkForward, p); - - Assert.False(fired); - } - - [Fact] - public void DoMotion_SetHoldKeyBit_FiresSetHoldKey_WithCancelMoveToBitAsSecondArg() - { - // A4: @306188 SetHoldKey(hold_key_to_apply, ((__inner0_1 >> 0xf) & 1)) — - // the SECOND arg is the cancel_moveto (sign) bit, not a constant. - var interp = MakeInterp(); - var p = new MovementParameters - { - SetHoldKey = true, - CancelMoveTo = true, - HoldKeyToApply = HoldKey.Run, - }; - - interp.DoMotion(MotionCommand.WalkForward, p); - - Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey); - } - - [Fact] - public void DoMotion_SetHoldKeyBit_FiresBeforeAdjustMotion() - { - // SetHoldKey must land BEFORE adjust_motion runs so the new hold-key - // affects the walk/run reinterpretation in THIS same call (Run - // promotion of WalkForward). - var interp = MakeInterp(); - var p = new MovementParameters - { - SetHoldKey = true, - HoldKeyToApply = HoldKey.Run, - }; - - interp.DoMotion(MotionCommand.WalkForward, p); - - // adjust_motion promotes WalkForward -> RunForward only when the - // hold key is (already, as of THIS call) Run. - Assert.Equal(MotionCommand.RunForward, interp.InterpretedState.ForwardCommand); - } - - [Fact] - public void DoMotion_SetHoldKeyBitClear_DoesNotChangeHoldKey() - { - var interp = MakeInterp(); - interp.RawState.CurrentHoldKey = HoldKey.None; - var p = new MovementParameters - { - SetHoldKey = false, - HoldKeyToApply = HoldKey.Run, - }; - - interp.DoMotion(MotionCommand.WalkForward, p); - - Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey); - } - - [Fact] - public void DoMotion_ParamsReDefaulted_CallerDistanceHeadingDiscarded() - { - // DoMotion re-derives a canonical local MovementParameters for the - // DoInterpretedMotion call — caller-supplied distance/heading fields - // must not leak into anything the interpreted call reads (the - // params ARE discarded per the decomp; this test pins that a - // caller passing bizarre distance/heading values doesn't change - // dispatch behavior vs the defaults). - var interp1 = MakeInterp(); - var interp2 = MakeInterp(); - var weird = new MovementParameters { DistanceToObject = 999f, DesiredHeading = 12345f, FailDistance = 1f }; - var plain = new MovementParameters(); - - var r1 = interp1.DoMotion(MotionCommand.WalkForward, weird); - var r2 = interp2.DoMotion(MotionCommand.WalkForward, plain); - - Assert.Equal(r2, r1); - Assert.Equal(interp2.InterpretedState.ForwardCommand, interp1.InterpretedState.ForwardCommand); - Assert.Equal(interp2.InterpretedState.ForwardSpeed, interp1.InterpretedState.ForwardSpeed); - } - - // ── combat-stance gate (A10: 0x3f/0x40/0x41/0x42) ────────────────────── - - [Theory] - [InlineData(MotionCommand.Crouch, WeenieError.CrouchInCombatStance)] - [InlineData(MotionCommand.Sitting, WeenieError.SitInCombatStance)] - [InlineData(MotionCommand.Sleeping, WeenieError.SleepInCombatStance)] - public void DoMotion_JumpChargeMotion_RejectedInCombatStance(uint motion, WeenieError expected) - { - var interp = MakeInterp(); - interp.InterpretedState.CurrentStyle = 0x80000001u; // any non-NonCombat stance - - var result = interp.DoMotion(motion, new MovementParameters()); - - Assert.Equal(expected, result); - } - - [Theory] - [InlineData(MotionCommand.Crouch)] - [InlineData(MotionCommand.Sitting)] - [InlineData(MotionCommand.Sleeping)] - public void DoMotion_JumpChargeMotion_AllowedInNonCombatStance(uint motion) - { - var interp = MakeInterp(); - interp.InterpretedState.CurrentStyle = 0x8000003Du; // NonCombat - - var result = interp.DoMotion(motion, new MovementParameters()); - - Assert.NotEqual(WeenieError.CrouchInCombatStance, result); - Assert.NotEqual(WeenieError.SitInCombatStance, result); - Assert.NotEqual(WeenieError.SleepInCombatStance, result); - } - - [Fact] - public void DoMotion_ChatEmoteBit_RejectedInCombatStance() - { - // motion & 0x2000000 outside NonCombat -> 0x42. - var interp = MakeInterp(); - interp.InterpretedState.CurrentStyle = 0x80000001u; - uint motion = 0x10000000u | 0x2000000u; // action-class + chat-emote bit - - var result = interp.DoMotion(motion, new MovementParameters()); - - Assert.Equal(WeenieError.ChatEmoteOutsideNonCombat, result); - } - - [Fact] - public void DoMotion_ChatEmoteBit_AllowedInNonCombatStance() - { - var interp = MakeInterp(); - interp.InterpretedState.CurrentStyle = 0x8000003Du; // NonCombat - uint motion = 0x10000000u | 0x2000000u; - - var result = interp.DoMotion(motion, new MovementParameters()); - - Assert.NotEqual(WeenieError.ChatEmoteOutsideNonCombat, result); - } - - [Fact] - public void DoMotion_NonJumpChargeMotion_NotGatedByCombatStance() - { - // WalkForward (no 0x2000000 bit, not a jump-charge id) must pass - // through the combat-stance gate regardless of stance. - var interp = MakeInterp(); - interp.InterpretedState.CurrentStyle = 0x80000001u; // combat stance - - var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters()); - - Assert.Equal(WeenieError.None, result); - } - - // ── action-depth gate (A10: 0x45, GetNumActions >= 6) ────────────────── - - [Fact] - public void DoMotion_ActionClassMotion_DepthBelowSix_Allowed() - { - var interp = MakeInterp(); - for (int i = 0; i < 5; i++) - interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false); - - uint actionMotion = 0x10000060u; // action-class bit set, not itself a jump-charge id - var result = interp.DoMotion(actionMotion, new MovementParameters()); - - Assert.NotEqual(WeenieError.ActionDepthExceeded, result); - } - - [Fact] - public void DoMotion_ActionClassMotion_DepthExactlySix_Rejected() - { - var interp = MakeInterp(); - for (int i = 0; i < 6; i++) - interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false); - - uint actionMotion = 0x10000060u; - var result = interp.DoMotion(actionMotion, new MovementParameters()); - - Assert.Equal(WeenieError.ActionDepthExceeded, result); - } - - [Fact] - public void DoMotion_ActionClassMotion_DepthFive_NotRejected() - { - var interp = MakeInterp(); - for (int i = 0; i < 5; i++) - interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false); - - uint actionMotion = 0x10000060u; - var result = interp.DoMotion(actionMotion, new MovementParameters()); - - Assert.NotEqual(WeenieError.ActionDepthExceeded, result); - } - - [Fact] - public void DoMotion_NonActionClassMotion_IgnoresDepthCap() - { - // Bit 0x10000000 clear -> the depth cap never applies, even with 6+ - // queued actions. - var interp = MakeInterp(); - for (int i = 0; i < 10; i++) - interp.InterpretedState.AddAction(0x10000050u, 1f, (uint)i, false); - - var result = interp.DoMotion(MotionCommand.WalkForward, new MovementParameters()); - - Assert.NotEqual(WeenieError.ActionDepthExceeded, result); - } - - // ── raw mirror discipline: raw gets ORIGINAL id, interpreted gets ADJUSTED ── - - [Fact] - public void DoMotion_ModifyRawStateBit_MirrorsOriginalId_NotAdjusted() - { - // WalkBackward (ORIGINAL) adjusts to WalkForward w/ negated speed - // (interpreted) but RawState.ApplyMotion must be called with the - // ORIGINAL WalkBackward id (ebp), not the adjusted WalkForward id. - var interp = MakeInterp(); - var p = new MovementParameters { ModifyRawState = true, Speed = 1.0f }; - - interp.DoMotion(MotionCommand.WalkBackward, p); - - Assert.Equal(MotionCommand.WalkBackward, interp.RawState.ForwardCommand); - // Interpreted state, meanwhile, adopted the ADJUSTED (WalkForward) id. - Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); - } - - [Fact] - public void DoMotion_ModifyRawStateBitClear_DoesNotMirror() - { - var interp = MakeInterp(); - var p = new MovementParameters { ModifyRawState = false }; - - interp.DoMotion(MotionCommand.WalkForward, p); - - // RawState.ForwardCommand must remain at its ctor default (Ready) — - // no mirror happened. - Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); - } - - [Fact] - public void DoMotion_FailedDispatch_DoesNotMirrorToRawState() - { - // result == 0 is required for the mirror per @306184 ("if (result == - // 0 && bit0x2000)"). A combat-stance rejection must not mirror. - var interp = MakeInterp(); - interp.InterpretedState.CurrentStyle = 0x80000001u; - var p = new MovementParameters { ModifyRawState = true }; - - var result = interp.DoMotion(MotionCommand.Crouch, p); - - Assert.Equal(WeenieError.CrouchInCombatStance, result); - Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); // untouched - } - - [Fact] - public void DoMotion_2Arg_AppOverload_StillCompiles_AndDispatches() - { - // App-facing compat overload: DoMotion(uint, float speed = 1.0f). - var interp = MakeInterp(); - - var result = interp.DoMotion(MotionCommand.WalkForward, 0.8f); - - Assert.Equal(WeenieError.None, result); - Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); - } - - // ========================================================================= - // StopMotion — 0x00528530 @305674 (mirror shape, no gates) - // ========================================================================= - - [Fact] - public void StopMotion_NullPhysicsObj_Returns8() - { - var interp = new MotionInterpreter(); - - var result = interp.StopMotion(MotionCommand.WalkForward, new MovementParameters()); - - Assert.Equal(WeenieError.NoPhysicsObject, result); - } - - [Fact] - public void StopMotion_NoCombatStanceGate_CrouchStopsEvenInCombatStance() - { - // Unlike DoMotion, StopMotion has NO combat-stance/depth gating. - var interp = MakeInterp(); - interp.InterpretedState.CurrentStyle = 0x80000001u; - interp.InterpretedState.ForwardCommand = MotionCommand.Crouch; - - var result = interp.StopMotion(MotionCommand.Crouch, new MovementParameters()); - - Assert.Equal(WeenieError.None, result); - } - - [Fact] - public void StopMotion_ModifyRawStateBit_RemovesOriginalId_NotAdjusted() - { - var interp = MakeInterp(); - interp.RawState.ForwardCommand = MotionCommand.WalkBackward; - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - var p = new MovementParameters { ModifyRawState = true }; - - interp.StopMotion(MotionCommand.WalkBackward, p); - - // RemoveMotion(ORIGINAL WalkBackward) hits RawState.ForwardCommand - // via the forward-class branch (adjusted WalkForward would also - // match here in this simple case, but the ORIGINAL id is what must - // be passed — see the mismatch test below for a case that - // distinguishes them). - Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); - } - - [Fact] - public void StopMotion_ModifyRawStateBitClear_DoesNotMirror() - { - var interp = MakeInterp(); - interp.RawState.ForwardCommand = MotionCommand.WalkForward; - var p = new MovementParameters { ModifyRawState = false }; - - interp.StopMotion(MotionCommand.WalkForward, p); - - Assert.Equal(MotionCommand.WalkForward, interp.RawState.ForwardCommand); // untouched - } - - [Fact] - public void StopMotion_2Arg_AppOverload_StillCompiles() - { - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - - var result = interp.StopMotion(MotionCommand.WalkForward); - - Assert.Equal(WeenieError.None, result); - Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); - } - - // ========================================================================= - // StopCompletely — 0x00527e40 @305208 (A9 verbatim quirk) - // ========================================================================= - - [Fact] - public void StopCompletely_NullPhysicsObj_Returns8() - { - var interp = new MotionInterpreter(); - - var result = interp.StopCompletely(); - - Assert.Equal(WeenieError.NoPhysicsObject, result); - } - - [Fact] - public void StopCompletely_ZeroesForwardSidestepTurnCommands_OnBothStates() - { - var interp = MakeInterp(); - interp.RawState.ForwardCommand = MotionCommand.RunForward; - interp.RawState.SidestepCommand = MotionCommand.SideStepRight; - interp.RawState.TurnCommand = MotionCommand.TurnRight; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight; - interp.InterpretedState.TurnCommand = MotionCommand.TurnRight; - - interp.StopCompletely(); - - Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); - Assert.Equal(1.0f, interp.RawState.ForwardSpeed); - Assert.Equal(0u, interp.RawState.SidestepCommand); - Assert.Equal(0u, interp.RawState.TurnCommand); - Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); - Assert.Equal(1.0f, interp.InterpretedState.ForwardSpeed); - Assert.Equal(0u, interp.InterpretedState.SideStepCommand); - Assert.Equal(0u, interp.InterpretedState.TurnCommand); - } - - [Fact] - public void StopCompletely_DoesNotTouchSidestepOrTurnSpeeds_J9() - { - // A9/J9: retail touches ONLY forward cmd/speed + sidestep/turn - // COMMANDS — it does NOT write sidestep_speed or turn_speed. The - // pre-R3 acdream divergence (1.0 speed resets) must be gone. - var interp = MakeInterp(); - interp.RawState.SidestepSpeed = 2.5f; - interp.RawState.TurnSpeed = 3.5f; - interp.InterpretedState.SideStepSpeed = 4.5f; - interp.InterpretedState.TurnSpeed = 5.5f; - - interp.StopCompletely(); - - Assert.Equal(2.5f, interp.RawState.SidestepSpeed); - Assert.Equal(3.5f, interp.RawState.TurnSpeed); - Assert.Equal(4.5f, interp.InterpretedState.SideStepSpeed); - Assert.Equal(5.5f, interp.InterpretedState.TurnSpeed); - } - - [Fact] - public void StopCompletely_ZerosPhysicsBodyVelocity() - { - var body = MakeGrounded(); - body.set_velocity(new Vector3(5f, 3f, 0f)); - var interp = MakeInterp(body); - - interp.StopCompletely(); - - Assert.Equal(Vector3.Zero, body.Velocity); - } - - [Fact] - public void StopCompletely_InterruptsCurrentMovement() - { - var interp = MakeInterp(); - bool fired = false; - interp.InterruptCurrentMovement = () => fired = true; - - interp.StopCompletely(); - - Assert.True(fired); - } - - [Fact] - public void StopCompletely_QueuedNodeErrorCode_ReflectsPreStopForwardCommand() - { - // A9 golden: motion_allows_jump is evaluated on the OLD (pre-stop) - // interpreted forward_command, snapshotted BEFORE the overwrite, and - // that snapshot becomes the queued node's jump_error_code. Crouch - // (0x41000012) is in the motion_allows_jump BLOCKED range -> 0x48. - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.Crouch; - - interp.StopCompletely(); - - var node = Assert.Single(interp.PendingMotions); - Assert.Equal(0u, node.ContextId); - Assert.Equal(MotionCommand.Ready, node.Motion); - Assert.Equal((uint)WeenieError.YouCantJumpFromThisPosition, node.JumpErrorCode); - } - - [Fact] - public void StopCompletely_QueuedNodeErrorCode_ZeroWhenPreStopCommandAllowsJump() - { - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.Ready; // passes motion_allows_jump - - interp.StopCompletely(); - - var node = Assert.Single(interp.PendingMotions); - Assert.Equal(0u, node.JumpErrorCode); - } - - [Fact] - public void StopCompletely_CellNull_FiresRemoveLinkAnimations() - { - // CurCell proxy: PhysicsObj.CellPosition.ObjCellId == 0 (default/unseeded). - var interp = MakeInterp(); - bool fired = false; - interp.RemoveLinkAnimations = () => fired = true; - - interp.StopCompletely(); - - Assert.True(fired, "physics_obj->cell == 0 must fire RemoveLinkAnimations"); - } - - [Fact] - public void StopCompletely_CellNonNull_DoesNotFireRemoveLinkAnimations() - { - var body = MakeGrounded(); - body.SnapToCell(0x12340001u, Vector3.Zero, Vector3.Zero); - var interp = MakeInterp(body); - bool fired = false; - interp.RemoveLinkAnimations = () => fired = true; - - interp.StopCompletely(); - - Assert.False(fired); - } - - [Fact] - public void StopCompletely_AlwaysReturnsNone_OnceCallExecutes() - { - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.Crouch; // would fail motion_allows_jump - - var result = interp.StopCompletely(); - - Assert.Equal(WeenieError.None, result); // StopCompletely itself always succeeds - } - - // ========================================================================= - // StandingLongJump state-only branch: Walk/Run/SideStepRight while - // charging -> state write, NO dispatch, NO queue node. - // ========================================================================= - - private sealed class RecordingSink : IInterpretedMotionSink - { - public readonly List Calls = new(); - public bool ApplyMotion(uint motion, float speed) - { - Calls.Add($"APPLY {motion:x8}@{speed:F2}"); - return true; - } - public bool StopMotion(uint motion) - { - Calls.Add($"STOP {motion:x8}"); - return true; - } - } - - [Fact] - public void DoInterpretedMotion_StandingLongJumpCharging_WalkForward_StateOnly_NoDispatch_NoQueue() - { - var interp = MakeInterp(); - interp.StandingLongJump = true; - interp.DefaultSink = null; // no App-level sink wired directly on DoInterpretedMotion path - - var before = interp.PendingMotions.Count(); - var result = interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters()); - - Assert.Equal(WeenieError.None, result); - Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); - Assert.Equal(before, interp.PendingMotions.Count()); - } - - [Fact] - public void DoInterpretedMotion_StandingLongJumpCharging_RunForward_StateOnly_NoDispatch_NoQueue() - { - var interp = MakeInterp(); - interp.StandingLongJump = true; - - var before = interp.PendingMotions.Count(); - var result = interp.DoInterpretedMotion(MotionCommand.RunForward, new MovementParameters()); - - Assert.Equal(WeenieError.None, result); - Assert.Equal(MotionCommand.RunForward, interp.InterpretedState.ForwardCommand); - Assert.Equal(before, interp.PendingMotions.Count()); - } - - [Fact] - public void DoInterpretedMotion_StandingLongJumpCharging_SideStepRight_StateOnly_NoDispatch_NoQueue() - { - var interp = MakeInterp(); - interp.StandingLongJump = true; - - var before = interp.PendingMotions.Count(); - var result = interp.DoInterpretedMotion(MotionCommand.SideStepRight, new MovementParameters()); - - Assert.Equal(WeenieError.None, result); - Assert.Equal(MotionCommand.SideStepRight, interp.InterpretedState.SideStepCommand); - Assert.Equal(before, interp.PendingMotions.Count()); - } - - [Fact] - public void DoInterpretedMotion_StandingLongJumpCharging_OtherMotion_DispatchesNormally() - { - // Only Walk/Run/SideStepRight get the state-only shortcut while - // charging — everything else dispatches through the normal path - // (including queueing). - var interp = MakeInterp(); - interp.StandingLongJump = true; - - var result = interp.DoInterpretedMotion(MotionCommand.TurnRight, new MovementParameters()); - - Assert.Equal(WeenieError.None, result); - Assert.Single(interp.PendingMotions); - } - - [Fact] - public void DoInterpretedMotion_NotCharging_WalkForward_DispatchesNormally_AndQueues() - { - var interp = MakeInterp(); - interp.StandingLongJump = false; - - var result = interp.DoInterpretedMotion(MotionCommand.WalkForward, new MovementParameters()); - - Assert.Equal(WeenieError.None, result); - Assert.Single(interp.PendingMotions); - } - - // ========================================================================= - // DisableJumpDuringLink forces the queued node's jump_error_code to 0x48 - // ========================================================================= - - [Fact] - public void DoInterpretedMotion_DisableJumpDuringLink_ForcesQueuedNodeTo0x48() - { - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.Ready; // would otherwise PASS motion_allows_jump - var p = new MovementParameters { DisableJumpDuringLink = true }; - - interp.DoInterpretedMotion(MotionCommand.WalkForward, p); - - var node = Assert.Single(interp.PendingMotions); - Assert.Equal((uint)WeenieError.YouCantJumpFromThisPosition, node.JumpErrorCode); - } - - [Fact] - public void DoInterpretedMotion_DisableJumpDuringLinkClear_UsesComputedErrorCode() - { - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.Ready; - var p = new MovementParameters { DisableJumpDuringLink = false }; - - interp.DoInterpretedMotion(MotionCommand.WalkForward, p); - - var node = Assert.Single(interp.PendingMotions); - Assert.Equal(0u, node.JumpErrorCode); // WalkForward + Ready both pass motion_allows_jump - } - - // ── Dead -> RemoveLinkAnimations ─────────────────────────────────────── - - [Fact] - public void DoInterpretedMotion_Dead_FiresRemoveLinkAnimations() - { - var interp = MakeInterp(); - bool fired = false; - interp.RemoveLinkAnimations = () => fired = true; - - interp.DoInterpretedMotion(MotionCommand.Dead, new MovementParameters()); - - Assert.True(fired); - } - - // ── ModifyInterpretedState param gates the state write ───────────────── - - [Fact] - public void DoInterpretedMotion_ModifyInterpretedStateClear_DoesNotWriteState() - { - var interp = MakeInterp(); - var p = new MovementParameters { ModifyInterpretedState = false }; - - interp.DoInterpretedMotion(MotionCommand.WalkForward, p); - - Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); // untouched - } - - [Fact] - public void DoInterpretedMotion_ModifyInterpretedStateSet_WritesState() - { - var interp = MakeInterp(); - var p = new MovementParameters { ModifyInterpretedState = true, Speed = 1.5f }; - - interp.DoInterpretedMotion(MotionCommand.WalkForward, p); - - Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); - } - - // ── CurCell-null tail (proxy: CellPosition.ObjCellId == 0) ────────────── - // NOTE: this is the DoInterpretedMotion success-path CurCell check per - // the plan; the merged function fires RemoveLinkAnimations whenever the - // physics_obj has no cell, mirroring StopCompletely's identical tail. - - [Fact] - public void DoInterpretedMotion_ContactBlocked_ActionClass_Returns0x24() - { - var body = new PhysicsBody { State = PhysicsStateFlags.Gravity }; // airborne, no contact - var interp = MakeInterp(body); - - var result = interp.DoInterpretedMotion(0x10000060u, new MovementParameters()); - - Assert.Equal(WeenieError.NotGrounded, result); - } - - // ========================================================================= - // StopInterpretedMotion — merged verbatim pair - // ========================================================================= - - [Fact] - public void StopInterpretedMotion_NullPhysicsObj_Returns8() - { - var interp = new MotionInterpreter(); - - var result = interp.StopInterpretedMotion(MotionCommand.WalkForward, new MovementParameters()); - - Assert.Equal(WeenieError.NoPhysicsObject, result); - } - - [Fact] - public void StopInterpretedMotion_Success_EnqueuesReturnToNoneNode() - { - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - var p = new MovementParameters { ContextId = 42 }; - - interp.StopInterpretedMotion(MotionCommand.WalkForward, p); - - var node = Assert.Single(interp.PendingMotions); - Assert.Equal(42u, node.ContextId); - Assert.Equal(MotionCommand.Ready, node.Motion); - } - - [Fact] - public void StopInterpretedMotion_ModifyInterpretedStateClear_DoesNotWriteState() - { - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - var p = new MovementParameters { ModifyInterpretedState = false }; - - interp.StopInterpretedMotion(MotionCommand.WalkForward, p); - - Assert.Equal(MotionCommand.WalkForward, interp.InterpretedState.ForwardCommand); // untouched - } - - // ========================================================================= - // PerformMovement — zero-tick CheckForCompletedMotions flush (closes J14) - // ========================================================================= - - [Fact] - public void PerformMovement_RawCommand_FiresCheckForCompletedMotions() - { - var interp = MakeInterp(); - int flushCount = 0; - interp.CheckForCompletedMotions = () => flushCount++; - - interp.PerformMovement(new MovementStruct - { - Type = MovementType.RawCommand, - Motion = MotionCommand.WalkForward, - Speed = 1.0f, - }); - - Assert.Equal(1, flushCount); - } - - [Theory] - [InlineData(MovementType.RawCommand)] - [InlineData(MovementType.InterpretedCommand)] - [InlineData(MovementType.StopRawCommand)] - [InlineData(MovementType.StopInterpretedCommand)] - [InlineData(MovementType.StopCompletely)] - public void PerformMovement_EveryDispatchedOp_FiresFlushExactlyOnce(MovementType type) - { - var interp = MakeInterp(); - int flushCount = 0; - interp.CheckForCompletedMotions = () => flushCount++; - - interp.PerformMovement(new MovementStruct - { - Type = type, - Motion = MotionCommand.WalkForward, - Speed = 1.0f, - }); - - Assert.Equal(1, flushCount); - } - - [Fact] - public void PerformMovement_InvalidType_DoesNotFireFlush() - { - // The type-dispatch failure (0x47, bad MovementStruct.type) happens - // BEFORE any op is dispatched — no flush should fire for it. - var interp = MakeInterp(); - int flushCount = 0; - interp.CheckForCompletedMotions = () => flushCount++; - - var result = interp.PerformMovement(new MovementStruct { Type = (MovementType)99 }); - - Assert.Equal(WeenieError.GeneralMovementFailure, result); - Assert.Equal(0, flushCount); - } - - [Fact] - public void PerformMovement_FlushSeamUnset_DoesNotThrow() - { - var interp = MakeInterp(); - interp.CheckForCompletedMotions = null; - - var result = interp.PerformMovement(new MovementStruct - { - Type = MovementType.StopCompletely, - }); - - Assert.Equal(WeenieError.None, result); - } - - [Fact] - public void PerformMovement_StopCompletely_ResetsToReady_AndFlushes() - { - var interp = MakeInterp(); - interp.RawState.ForwardCommand = MotionCommand.WalkForward; - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - int flushCount = 0; - interp.CheckForCompletedMotions = () => flushCount++; - - interp.PerformMovement(new MovementStruct { Type = MovementType.StopCompletely }); - - Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); - Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); - Assert.Equal(1, flushCount); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs deleted file mode 100644 index 7c3cb617..00000000 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterFunnelTests.cs +++ /dev/null @@ -1,377 +0,0 @@ -using System.Collections.Generic; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// L.2g S2 — the inbound CMotionInterp funnel (deviation DEV-1). -/// -/// Oracle: docs/research/2026-07-02-s2-inbound-funnel-pseudocode.md -/// (decomp: move_to_interpreted_state 0x005289c0 @305936, -/// apply_interpreted_movement 0x00528600 @305713) — validated against the -/// LIVE retail-observer cdb trace (l2g-observer-trace.log), which showed the -/// per-UM dispatch order verbatim: style → forward → sidestep(-stop) → -/// turn(-stop). -/// -public class MotionInterpreterFunnelTests -{ - private sealed class RecordingSink : IInterpretedMotionSink - { - public readonly List Calls = new(); - public bool ApplyMotion(uint motion, float speed) - { - Calls.Add($"DIM {motion:x8}@{speed:F2}"); - // R3-W5: a style/stance id (>= 0x80000000, i.e. negative as - // int32) has no locomotion MotionData entry in the dat — retail's - // real CMotionTable::DoObjectMotion genuinely fails for it - // (MotionTableManagerError.MotionFailed). The fake sink mirrors - // that so InterpretedMotionState.ForwardCommand isn't clobbered - // by ApplyMotion's negative-motion branch before the very next - // dispatch reads it — matches the live retail-observer trace. - return motion < 0x80000000u; - } - public bool StopMotion(uint motion) - { - Calls.Add($"STOP {motion:x8}"); - return true; - } - } - - private static MotionInterpreter GroundedInterp() - { - var body = new PhysicsBody(); - body.State |= PhysicsStateFlags.Gravity; - body.TransientState |= TransientStateFlags.Contact - | TransientStateFlags.OnWalkable - | TransientStateFlags.Active; - return new MotionInterpreter(body); - } - - private static InboundInterpretedState Ims( - uint style = 0x8000003Du, - uint fwd = 0x41000003u, float fwdSpd = 1.0f, - uint side = 0u, float sideSpd = 1.0f, - uint turn = 0u, float turnSpd = 1.0f, - IReadOnlyList? actions = null) - => new() - { - CurrentStyle = style, - ForwardCommand = fwd, ForwardSpeed = fwdSpd, - SideStepCommand = side, SideStepSpeed = sideSpd, - TurnCommand = turn, TurnSpeed = turnSpd, - Actions = actions, - }; - - [Fact] - public void EmptyUm_DispatchesStyleThenReadyThenStops_RetailOrder() - { - // The flags=0 "empty" UM: all defaults → a wholesale stop. Live-trace - // golden (actor minterp 18e8b0f8): DIM style, DIM Ready, then the - // sidestep + turn stop notifications. - var mi = GroundedInterp(); - var sink = new RecordingSink(); - - mi.MoveToInterpretedState(Ims(), sink); - - Assert.Equal(new[] - { - "DIM 8000003d@1.00", - "DIM 41000003@1.00", - "STOP 6500000f", - "STOP 6500000d", - }, sink.Calls); - Assert.Equal(0x41000003u, mi.InterpretedState.ForwardCommand); - Assert.Equal(1.0f, mi.InterpretedState.ForwardSpeed); - } - - [Fact] - public void RunUm_DispatchesRunAtWireSpeed_AndCachesMyRunRate() - { - // fwd=RunForward@2.85 — apply_interpreted_movement caches - // my_run_rate from forward_speed BEFORE dispatching (305718). - var mi = GroundedInterp(); - var sink = new RecordingSink(); - - mi.MoveToInterpretedState(Ims(fwd: 0x44000007u, fwdSpd: 2.85f), sink); - - Assert.Equal(new[] - { - "DIM 8000003d@1.00", - "DIM 44000007@2.85", - "STOP 6500000f", - "STOP 6500000d", - }, sink.Calls); - Assert.Equal(2.85f, mi.MyRunRate); - Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); - Assert.Equal(2.85f, mi.InterpretedState.ForwardSpeed); - } - - [Fact] - public void WalkUm_DoesNotTouchMyRunRate() - { - var mi = GroundedInterp(); - mi.MyRunRate = 2.5f; - - mi.MoveToInterpretedState(Ims(fwd: 0x45000005u), new RecordingSink()); - - Assert.Equal(2.5f, mi.MyRunRate); // only RunForward caches - } - - [Fact] - public void RunPlusTurnUm_TurnDispatched_NoTurnStop_NoIdleEnqueue() - { - // turn_command != 0 → DIM(turn) then EARLY RETURN — no turn-stop, - // no idle bookkeeping (305711-305713 early return). - var mi = GroundedInterp(); - var sink = new RecordingSink(); - - mi.MoveToInterpretedState( - Ims(fwd: 0x44000007u, fwdSpd: 2.85f, turn: 0x6500000Du, turnSpd: 1.5f), sink); - - Assert.Equal(new[] - { - "DIM 8000003d@1.00", - "DIM 44000007@2.85", - "STOP 6500000f", - "DIM 6500000d@1.50", - }, sink.Calls); - } - - [Fact] - public void SidestepUm_DispatchedInsteadOfSidestepStop() - { - var mi = GroundedInterp(); - var sink = new RecordingSink(); - - mi.MoveToInterpretedState(Ims(side: 0x6500000Fu, sideSpd: -1.2f), sink); - - Assert.Equal(new[] - { - "DIM 8000003d@1.00", - "DIM 41000003@1.00", - "DIM 6500000f@-1.20", - "STOP 6500000d", - }, sink.Calls); - } - - [Fact] - public void AirborneBody_NoCycleDispatches_OnlyTurnStop() - { - // Airborne (gravity on, no Contact): apply_interpreted_movement - // substitutes DIM(Falling 0x40000015) for the forward block - // (305723-305727), but DoInterpretedMotion's OWN contact gate - // (0x00528360) then takes the apply-only path for style + Falling — - // GetObjectSequence never fires. This is retail's real mechanism - // behind the K-fix17 "preserve the Falling cycle while airborne" - // empirical guard: airborne remotes simply don't re-cycle from UMs. - // Only the unconditional turn-stop notification comes through. - var body = new PhysicsBody(); // no Contact flag - body.State |= PhysicsStateFlags.Gravity; - var mi = new MotionInterpreter(body); - var sink = new RecordingSink(); - - mi.MoveToInterpretedState(Ims(fwd: 0x44000007u, fwdSpd: 2.85f), sink); - - // Falling (0x40000015) is ALWAYS allowed by contact_allows_move - // (0x00528240 early-accept), so it reaches the sink; the style and - // forward dispatches are gate-blocked (apply-only path). - Assert.Equal(new[] { "DIM 40000015@1.00", "STOP 6500000d" }, sink.Calls); - // #161 correction (2026-07-03): the apply pass runs its dispatches - // with ModifyInterpretedState = FALSE — retail constructs var_2c - // then CLEARS bits 11/14/15 (SetHoldKey / ModifyInterpretedState / - // CancelMoveTo) and re-sets 15/17 from the args; the BN pseudo-C - // smears that bitfield store into the mush expression at raw - // 305778 (`(word & 0x37ff) | (arg2&1)<<15 | (arg3&1)<<17`). ACE - // MotionInterp.cs:444-449 confirms independently. So NEITHER the - // blocked style dispatch NOR the Falling substitution writes - // InterpretedState — the wire's forward command survives the - // airborne pass. This is the retail landing-exit mechanism: - // HitGround's re-apply dispatches the PRESERVED command, and the - // motion table plays the Falling→X landing link. (The previous - // revision of this assertion pinned 0x40000015 — the #161 bug - // itself: the ctor-default params let the Falling dispatch clobber - // forward_command, so a stand-still landing re-dispatched Falling - // forever.) - Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); - Assert.Equal(2.85f, mi.InterpretedState.ForwardSpeed); - } - - [Fact] - public void HitGround_AfterFall_RedispatchesPreservedForward_ExitsFalling() - { - // #161 (remote jump landing stuck in the falling pose): the full - // remote lifecycle. Wire says RunForward@2.85 while grounded; the - // body leaves the ground (LeaveGround engages Falling through the - // sink WITHOUT clobbering the interpreted forward command — the - // apply pass's ModifyInterpretedState=false, raw 305778 / ACE - // MotionInterp.cs:447); on touchdown, HitGround (0x00528ac0) — - // called with GRAVITY STILL SET, its verbatim state&0x400 gate — - // re-applies the PRESERVED command, which is what makes - // GetObjectSequence play the Falling→RunForward landing link. No - // wire input is needed to exit the falling pose. - var body = new PhysicsBody(); - body.State |= PhysicsStateFlags.Gravity; - body.TransientState |= TransientStateFlags.Contact - | TransientStateFlags.OnWalkable - | TransientStateFlags.Active; - var mi = new MotionInterpreter(body, new RemoteWeenie()); - var sink = new RecordingSink(); - mi.DefaultSink = sink; // HitGround/LeaveGround re-apply through DefaultSink - - mi.MoveToInterpretedState(Ims(fwd: 0x44000007u, fwdSpd: 2.85f), sink); - Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); - - // Jump start: ground contact drops FIRST (GameWindow's VectorUpdate - // handler order), then LeaveGround re-applies → Falling engages. - body.TransientState &= ~(TransientStateFlags.Contact - | TransientStateFlags.OnWalkable); - sink.Calls.Clear(); - mi.LeaveGround(); - - Assert.Contains(sink.Calls, c => c.StartsWith("DIM 40000015")); - Assert.Equal(0x44000007u, mi.InterpretedState.ForwardCommand); // NOT clobbered - Assert.Equal(2.85f, mi.InterpretedState.ForwardSpeed); - - // Touchdown: contact restored, Gravity still set (the retail - // contract — CMotionInterp::HitGround no-ops without it). - body.TransientState |= TransientStateFlags.Contact - | TransientStateFlags.OnWalkable; - sink.Calls.Clear(); - mi.HitGround(); - - // Retail re-apply order: style (from the copy_movement_from-adopted - // interpreted current_style, raw 0051e757) → preserved forward → - // sidestep-stop → turn-stop. The forward dispatch at the wire - // command IS the falling-pose exit. - Assert.Equal(new[] - { - "DIM 8000003d@1.00", - "DIM 44000007@2.85", - "STOP 6500000f", - "STOP 6500000d", - }, sink.Calls); - } - - [Fact] - public void FlatCopy_OverwritesEveryAxis() - { - // copy_movement_from (0x0051e750) is a FLAT overwrite — a fresh UM - // with defaults clears a previously-set sidestep/turn. - var mi = GroundedInterp(); - mi.MoveToInterpretedState( - Ims(fwd: 0x44000007u, fwdSpd: 2.85f, side: 0x6500000Fu, turn: 0x6500000Du), - new RecordingSink()); - - mi.MoveToInterpretedState(Ims(), new RecordingSink()); - - Assert.Equal(0x41000003u, mi.InterpretedState.ForwardCommand); - Assert.Equal(0u, mi.InterpretedState.SideStepCommand); - Assert.Equal(0u, mi.InterpretedState.TurnCommand); - } - - [Fact] - public void RawStateStyle_AdoptedFromIms() - { - // move_to_interpreted_state head: raw_state.current_style = - // ims.current_style (305944). - var mi = GroundedInterp(); - mi.MoveToInterpretedState(Ims(style: 0x8000003Cu), new RecordingSink()); - Assert.Equal(0x8000003Cu, mi.RawState.CurrentStyle); - } - - // ── action list: 15-bit server_action_stamp gate (305953-305989) ────── - - [Fact] - public void Actions_FreshStamp_DispatchedAfterMovement_AndStampAdopted() - { - var mi = GroundedInterp(); - var sink = new RecordingSink(); - var actions = new[] { new InboundMotionAction(0x10000062u, Stamp: 5, Autonomous: false, Speed: 1.25f) }; - - mi.MoveToInterpretedState(Ims(actions: actions), sink); - - Assert.Equal("DIM 10000062@1.25", sink.Calls[^1]); // after the movement dispatches - Assert.Equal(5, mi.ServerActionStamp); - } - - [Fact] - public void Actions_StaleStamp_Skipped() - { - var mi = GroundedInterp(); - mi.ServerActionStamp = 10; - var sink = new RecordingSink(); - - mi.MoveToInterpretedState( - Ims(actions: new[] { new InboundMotionAction(0x10000062u, 9, false, 1f) }), sink); - - Assert.DoesNotContain(sink.Calls, c => c.StartsWith("DIM 10000062")); - Assert.Equal(10, mi.ServerActionStamp); - } - - [Fact] - public void Actions_StampWrapsAt15Bits() - { - // The compare is 15-bit wraparound (mask 0x7fff, threshold 0x3fff). - var mi = GroundedInterp(); - mi.ServerActionStamp = 0x7FFE; - var sink = new RecordingSink(); - - mi.MoveToInterpretedState( - Ims(actions: new[] { new InboundMotionAction(0x10000062u, 2, false, 1f) }), sink); - - Assert.Contains(sink.Calls, c => c.StartsWith("DIM 10000062")); // 2 is newer than 0x7ffe - Assert.Equal(2, mi.ServerActionStamp); - } - - [Fact] - public void Actions_AutonomousOnLocalPlayer_Skipped() - { - // Retail skips autonomous action replay on the LOCAL player (its own - // echo); remotes always apply (305977-305987). - var body = new PhysicsBody(); - body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; - var mi = new MotionInterpreter(body) { IsLocalPlayer = true }; - var sink = new RecordingSink(); - - mi.MoveToInterpretedState( - Ims(actions: new[] { new InboundMotionAction(0x10000062u, 5, Autonomous: true, 1f) }), sink); - - Assert.DoesNotContain(sink.Calls, c => c.StartsWith("DIM 10000062")); - } - - [Fact] - public void Actions_ReplayCarriesAutonomyIntoTheInterpretedList() - { - // R5-V4 (#164): retail splices the ACTION's autonomy into the - // dispatch params (bit 0x1000 — raw 305982: - // `var_28 ^= ((action.autonomous << 0xc) ^ var_28) & 0x1000`), so a - // replayed action enters the interpreted actions list - // (InterpretedMotionState::AddAction consumes p.Autonomous) with its - // REAL autonomy. Pre-V4 the replay params were ctor-default → - // Autonomous always false. - var mi = GroundedInterp(); // remote posture: IsLocalPlayer = false - var sink = new RecordingSink(); - - mi.MoveToInterpretedState( - Ims(actions: new[] { new InboundMotionAction(0x10000062u, 5, Autonomous: true, 1f) }), sink); - - var entry = Assert.Single(mi.InterpretedState.Actions, a => a.Command == 0x0062); - Assert.True(entry.Autonomous); - } - - [Fact] - public void InboundState_Defaults_MatchRetailUnPack() - { - // InterpretedMotionState::UnPack absent-field defaults (0x0051f400): - // style NonCombat, fwd Ready, speeds 1.0, side/turn 0. - var d = InboundInterpretedState.Default(); - Assert.Equal(0x8000003Du, d.CurrentStyle); - Assert.Equal(0x41000003u, d.ForwardCommand); - Assert.Equal(1.0f, d.ForwardSpeed); - Assert.Equal(0u, d.SideStepCommand); - Assert.Equal(1.0f, d.SideStepSpeed); - Assert.Equal(0u, d.TurnCommand); - Assert.Equal(1.0f, d.TurnSpeed); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterGroundLifecycleTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterGroundLifecycleTests.cs deleted file mode 100644 index 3836fed0..00000000 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterGroundLifecycleTests.cs +++ /dev/null @@ -1,784 +0,0 @@ -using System.Linq; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -// ───────────────────────────────────────────────────────────────────────────── -// MotionInterpreterGroundLifecycleTests — R3-W4 (closes J8, J10, J11-shape, -// J12, J13; J18 one-liner rides along). -// -// Oracle: docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md -// §4 (HitGround 0x00528ac0 @305996, LeaveGround 0x00528b00 @306022, -// ReportExhaustion 0x005288d0 @305861, enter_default_state 0x00528c80 -// @306124) + §5c/5d (set_hold_run 0x00528b70 @306053, SetHoldKey 0x00528bb0 -// @306072) + raw acclient_2013_pseudo_c.txt:305838-305932 (apply_current_movement -// 0x00528870, SetWeenieObject 0x00528920, SetPhysicsObject 0x00528970) + -// W0-pins.md A3 (dual-dispatch gate) / A8 (enter_default_state no-clear). -// ───────────────────────────────────────────────────────────────────────────── - -/// Fake WeenieObject — IsThePlayer/IsCreature independently -/// configurable, for the A3 dispatch truth table. -file sealed class FakeWeenie : IWeenieObject -{ - public bool IsThePlayerResult; - public bool IsCreatureResult = true; - - public bool InqJumpVelocity(float extent, out float vz) { vz = 10f; return true; } - public bool InqRunRate(out float rate) { rate = 1f; return true; } - public bool CanJump(float extent) => true; - public bool IsThePlayer() => IsThePlayerResult; - public bool IsCreature() => IsCreatureResult; -} - -public sealed class MotionInterpreterGroundLifecycleTests -{ - // ── helpers ─────────────────────────────────────────────────────────────── - - private static PhysicsBody MakeGrounded() - { - var body = new PhysicsBody - { - State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, - }; - body.TransientState = TransientStateFlags.Contact - | TransientStateFlags.OnWalkable - | TransientStateFlags.Active; - return body; - } - - private static PhysicsBody MakeAirborne() - { - var body = new PhysicsBody - { - State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, - }; - body.TransientState = TransientStateFlags.Active; - return body; - } - - private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null) - { - body ??= MakeGrounded(); - return new MotionInterpreter(body, weenie); - } - - // ========================================================================= - // HitGround — 0x00528ac0 @305996 - // ========================================================================= - - [Fact] - public void HitGround_NoPhysicsObj_NoOp() - { - var interp = new MotionInterpreter(); - bool called = false; - interp.RemoveLinkAnimations = () => called = true; - - interp.HitGround(); - - Assert.False(called); - } - - [Fact] - public void HitGround_NonCreatureWeenie_NoOp() - { - // raw 305720-305724: (weenie_obj == 0 || eax_2 != 0) -- a WEENIE - // present whose IsCreature() returns false skips the whole body. - var weenie = new FakeWeenie { IsCreatureResult = false }; - var body = MakeGrounded(); - var interp = MakeInterp(body, weenie); - bool called = false; - interp.RemoveLinkAnimations = () => called = true; - - interp.HitGround(); - - Assert.False(called, "a non-creature weenie must skip HitGround's body entirely"); - } - - [Fact] - public void HitGround_NoWeenie_Proceeds() - { - // weenie_obj == 0 -> the OR short-circuits true, body proceeds. - var body = MakeGrounded(); - var interp = MakeInterp(body); - bool called = false; - interp.RemoveLinkAnimations = () => called = true; - - interp.HitGround(); - - Assert.True(called, "no weenie_obj must still proceed (weenie==0 half of the OR)"); - } - - [Fact] - public void HitGround_GravityFlagClear_NoOp() - { - // raw 305726-305730: state bit 0x400 (Gravity) gates the body. - var body = MakeGrounded(); - body.State &= ~PhysicsStateFlags.Gravity; - var interp = MakeInterp(body); - bool called = false; - interp.RemoveLinkAnimations = () => called = true; - - interp.HitGround(); - - Assert.False(called, "HitGround must no-op when the Gravity state flag is clear"); - } - - [Fact] - public void HitGround_CreatureWithGravity_CallsRemoveLinkAnimationsThenReapplies() - { - var weenie = new FakeWeenie { IsCreatureResult = true }; - var body = MakeGrounded(); - var interp = MakeInterp(body, weenie); - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - bool called = false; - interp.RemoveLinkAnimations = () => called = true; - - interp.HitGround(); - - Assert.True(called, "HitGround must call the RemoveLinkAnimations seam"); - // apply_current_movement(false, true) re-syncs velocity from the - // current interpreted state (grounded write, AP-75-adjacent). - Assert.True(body.Velocity.Length() > 0f, "HitGround must re-apply current movement"); - } - - // ========================================================================= - // LeaveGround — 0x00528b00 @306022 - // ========================================================================= - - [Fact] - public void LeaveGround_NoPhysicsObj_NoOp() - { - var interp = new MotionInterpreter(); - bool called = false; - interp.RemoveLinkAnimations = () => called = true; - - interp.LeaveGround(); - - Assert.False(called); - } - - [Fact] - public void LeaveGround_NonCreatureWeenie_NoOp() - { - var weenie = new FakeWeenie { IsCreatureResult = false }; - var body = MakeGrounded(); - var interp = MakeInterp(body, weenie); - interp.StandingLongJump = true; - interp.JumpExtent = 0.7f; - bool called = false; - interp.RemoveLinkAnimations = () => called = true; - - interp.LeaveGround(); - - Assert.False(called); - // Nothing in the body ran -- StandingLongJump/JumpExtent untouched. - Assert.True(interp.StandingLongJump); - Assert.Equal(0.7f, interp.JumpExtent); - } - - [Fact] - public void LeaveGround_GravityFlagClear_NoOp() - { - var body = MakeGrounded(); - body.State &= ~PhysicsStateFlags.Gravity; - var interp = MakeInterp(body); - interp.StandingLongJump = true; - bool called = false; - interp.RemoveLinkAnimations = () => called = true; - - interp.LeaveGround(); - - Assert.False(called); - Assert.True(interp.StandingLongJump); - } - - [Fact] - public void LeaveGround_CreatureWithGravity_SetsVelocityAndResetsJumpState() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.StandingLongJump = true; - interp.JumpExtent = 0.5f; - - interp.LeaveGround(); - - Assert.False(interp.StandingLongJump, "standing_longjump = 0 on leave-ground"); - Assert.Equal(0f, interp.JumpExtent, precision: 5); - } - - [Fact] - public void LeaveGround_CallsRemoveLinkAnimationsThenReapplies() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - bool called = false; - interp.RemoveLinkAnimations = () => called = true; - - interp.LeaveGround(); - - Assert.True(called, "LeaveGround must call the RemoveLinkAnimations seam"); - } - - [Fact] - public void LeaveGround_VelocityCarriesGetLeaveGroundVelocity_WithNonzeroJumpExtent() - { - // A6/A5: get_leave_ground_velocity composes get_state_velocity() + - // z=GetJumpVZ(). With JumpExtent set and a weenie present, the body - // local velocity written must match GetLeaveGroundVelocity()'s Z. - // - // Real call-site precondition: by the time LeaveGround fires, the - // body has ALREADY left the ground (jump() calls set_on_walkable(false) - // before LeaveGround runs; a ledge walk-off similarly clears OnWalkable - // via the physics resolver BEFORE this callback). With OnWalkable - // false, LeaveGround's own tail apply_current_movement(0,0) call - // takes the "airborne -- preserve integrated velocity" branch in the - // AP-77 adaptation (ApplyCurrentMovementInterpreted), so the Z this - // call just set is not immediately clobbered by a resync. - var weenie = new AcDream.Core.Physics.PlayerWeenie(); - var body = MakeGrounded(); - body.set_on_walkable(false); - var interp = MakeInterp(body, weenie); - interp.JumpExtent = 0.5f; - - var expected = interp.GetLeaveGroundVelocity(); - interp.LeaveGround(); - - // set_local_velocity transforms local->world via Orientation - // (Identity in these fixtures), so world == local here. - Assert.Equal(expected.Z, body.Velocity.Z, precision: 3); - } - - [Fact] - public void LeaveGround_SetLocalVelocity_UsesAutonomousFlag() - { - // raw 305763-305765: CPhysicsObj::set_local_velocity(&var_c, 1) -- - // the autonomous=1 arg. PhysicsBody.LastMoveWasAutonomous should - // reflect this after LeaveGround runs (movement_is_autonomous, A3's - // apply_current_movement dispatch reads this same flag). - var body = MakeGrounded(); - body.LastMoveWasAutonomous = false; - var interp = MakeInterp(body); - - interp.LeaveGround(); - - Assert.True(body.LastMoveWasAutonomous, "LeaveGround's set_local_velocity call carries autonomous=1"); - } - - // ========================================================================= - // enter_default_state — 0x00528c80 @306124 (A8: append sentinel, NO drain) - // ========================================================================= - - [Fact] - public void EnterDefaultState_AppendsReadySentinel_WithoutDrainingExistingNodes() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.AddToQueue(1, MotionCommand.RunForward, 0); - interp.AddToQueue(2, MotionCommand.WalkForward, 0x48); - - interp.EnterDefaultState(); - - var nodes = interp.PendingMotions.ToArray(); - Assert.Equal(3, nodes.Length); - Assert.Equal(new MotionNode(1, MotionCommand.RunForward, 0), nodes[0]); - Assert.Equal(new MotionNode(2, MotionCommand.WalkForward, 0x48), nodes[1]); - Assert.Equal(new MotionNode(0, MotionCommand.Ready, 0), nodes[2]); - } - - [Fact] - public void EnterDefaultState_SetsInitted() - { - var interp = new MotionInterpreter { PhysicsObj = MakeGrounded() }; - // Force Initted false to prove EnterDefaultState is what flips it - // (the ctor already defaults it true -- see the Initted-gating - // section below for why). - interp.Initted = false; - - interp.EnterDefaultState(); - - Assert.True(interp.Initted); - } - - [Fact] - public void EnterDefaultState_ResetsRawAndInterpretedStateToCtorDefaults() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.RawState.ForwardCommand = MotionCommand.RunForward; - interp.RawState.ForwardSpeed = 2.94f; - interp.RawState.CurrentHoldKey = HoldKey.Run; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - interp.InterpretedState.TurnCommand = MotionCommand.TurnRight; - - interp.EnterDefaultState(); - - Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); - Assert.Equal(1.0f, interp.RawState.ForwardSpeed); - Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey); - Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); - Assert.Equal(0u, interp.InterpretedState.TurnCommand); - } - - [Fact] - public void EnterDefaultState_CallsInitializeMotionTablesSeam() - { - var interp = MakeInterp(); - bool called = false; - interp.InitializeMotionTables = () => called = true; - - interp.EnterDefaultState(); - - Assert.True(called); - } - - [Fact] - public void EnterDefaultState_TailCallsLeaveGround() - { - // enter_default_state's LAST step is an unconditional LeaveGround() - // call (raw @306153). With a grounded creature+gravity body and a - // nonzero JumpExtent pre-seeded, LeaveGround's reset (standing_longjump=0, - // jump_extent=0) must be observable after EnterDefaultState. - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.StandingLongJump = true; - interp.JumpExtent = 0.9f; - - interp.EnterDefaultState(); - - Assert.False(interp.StandingLongJump, "EnterDefaultState's LeaveGround tail must fire"); - Assert.Equal(0f, interp.JumpExtent, precision: 5); - } - - // ========================================================================= - // Initted gating — apply_current_movement / ReportExhaustion (A3 entry gate) - // ========================================================================= - - [Fact] - public void ApplyCurrentMovement_NotInitted_NoOp() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.Initted = false; - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - - interp.apply_current_movement(cancelMoveTo: false, allowJump: false); - - Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity); - } - - [Fact] - public void ApplyCurrentMovement_Initted_Proceeds() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - Assert.True(interp.Initted, "the two-arg constructor defaults Initted=true (see final report)"); - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - - interp.apply_current_movement(cancelMoveTo: false, allowJump: false); - - Assert.True(body.Velocity.Length() > 0f); - } - - [Fact] - public void ReportExhaustion_NotInitted_NoOp() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.Initted = false; - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - - interp.ReportExhaustion(); - - Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity); - } - - [Fact] - public void ReportExhaustion_NoPhysicsObj_NoOp() - { - var interp = new MotionInterpreter(); - // No exception is the assertion -- retail's entry gate is - // physics_obj != 0 && initted != 0, both ANDed. - interp.ReportExhaustion(); - } - - // ========================================================================= - // Dual-dispatch truth table — A3: IsThePlayer && movement_is_autonomous - // gates apply_raw_movement vs apply_interpreted_movement, for - // apply_current_movement AND ReportExhaustion. - // - // Reading raw_movement's effect: apply_raw_movement copies RawState -> - // InterpretedState (see apply_raw_movement's existing doc comment) then - // re-normalizes via adjust_motion. apply_interpreted_movement instead - // reads InterpretedState directly and pushes velocity via - // get_state_velocity/set_local_velocity when grounded. We distinguish - // the two dispatch targets by seeding RawState and InterpretedState with - // DIFFERENT forward commands/speeds and observing which one drove the - // resulting body velocity. - // ========================================================================= - - [Theory] - [InlineData(true, true, /* expectRaw */ true)] // IsThePlayer && autonomous -> raw - [InlineData(true, false, /* expectRaw */ false)] // IsThePlayer but not autonomous -> interpreted - [InlineData(false, true, /* expectRaw */ false)] // autonomous but NOT the player -> interpreted (remote player IS a creature but not "the player") - [InlineData(false, false, /* expectRaw */ false)] // neither -> interpreted - public void ApplyCurrentMovement_DualDispatch_MatchesA3TruthTable( - bool isThePlayer, bool autonomous, bool expectRaw) - { - var weenie = new FakeWeenie { IsThePlayerResult = isThePlayer, IsCreatureResult = true }; - var body = MakeGrounded(); - body.LastMoveWasAutonomous = autonomous; - var interp = MakeInterp(body, weenie); - - // RawState drives WalkForward (speed 1 => WalkAnimSpeed); InterpretedState - // drives RunForward (speed 1 => RunAnimSpeed). WalkAnimSpeed != RunAnimSpeed - // so the resulting body.Velocity.Y distinguishes which path ran. - interp.RawState.ForwardCommand = MotionCommand.WalkForward; - interp.RawState.ForwardSpeed = 1.0f; - interp.RawState.ForwardHoldKey = HoldKey.None; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - - interp.apply_current_movement(cancelMoveTo: false, allowJump: false); - - float expectedY = expectRaw - ? MotionInterpreter.WalkAnimSpeed // apply_raw_movement re-derives from RawState - : MotionInterpreter.RunAnimSpeed; // apply_interpreted_movement reads InterpretedState as-is - Assert.Equal(expectedY, body.Velocity.Y, precision: 2); - } - - [Fact] - public void ApplyCurrentMovement_NoWeenie_Autonomous_DispatchesRaw() - { - // weenie_obj == 0 short-circuits the OR to true (raw 305849: - // weenie_obj == 0 || eax_2 != 0) -- no weenie means "treat as the - // player" for dispatch purposes. - var body = MakeGrounded(); - body.LastMoveWasAutonomous = true; - var interp = MakeInterp(body); // no weenie - - interp.RawState.ForwardCommand = MotionCommand.WalkForward; - interp.RawState.ForwardSpeed = 1.0f; - interp.RawState.ForwardHoldKey = HoldKey.None; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - - interp.apply_current_movement(cancelMoveTo: false, allowJump: false); - - Assert.Equal(MotionInterpreter.WalkAnimSpeed, body.Velocity.Y, precision: 2); - } - - [Fact] - public void ReportExhaustion_DualDispatch_PassesZeroZeroArgs() - { - // A3: ReportExhaustion's dispatch args are hardcoded (0, 0) -- - // distinct from apply_current_movement which passes its own - // (cancelMoveTo, allowJump) through. We can't observe the args - // directly (both paths are void), so this proves ReportExhaustion - // dispatches at all when initted+player+autonomous (raw path) by - // observing the same velocity-divergence trick. - var weenie = new FakeWeenie { IsThePlayerResult = true }; - var body = MakeGrounded(); - body.LastMoveWasAutonomous = true; - var interp = MakeInterp(body, weenie); - interp.RawState.ForwardCommand = MotionCommand.WalkForward; - interp.RawState.ForwardSpeed = 1.0f; - interp.RawState.ForwardHoldKey = HoldKey.None; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - - interp.ReportExhaustion(); - - Assert.Equal(MotionInterpreter.WalkAnimSpeed, body.Velocity.Y, precision: 2); - } - - [Fact] - public void ReportExhaustion_RemotePlayer_DispatchesInterpreted() - { - // The ACE-divergence pin: a remote player weenie (IsThePlayer=false, - // IsCreature=true) must route INTERPRETED, not raw -- even though - // it IS a creature. ACE's IsCreature-gated read would wrongly send - // this down apply_raw_movement. - var weenie = new FakeWeenie { IsThePlayerResult = false, IsCreatureResult = true }; - var body = MakeGrounded(); - body.LastMoveWasAutonomous = true; // even with autonomous=true... - var interp = MakeInterp(body, weenie); - interp.RawState.ForwardCommand = MotionCommand.WalkForward; - interp.RawState.ForwardSpeed = 1.0f; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - - interp.ReportExhaustion(); - - // ...IsThePlayer==false forces the interpreted path. - Assert.Equal(MotionInterpreter.RunAnimSpeed, body.Velocity.Y, precision: 2); - } - - // ========================================================================= - // SetWeenieObject / SetPhysicsObject — 0x00528920 / 0x00528970 re-apply - // ========================================================================= - - [Fact] - public void SetWeenieObject_WhilePhysicsBoundAndInitted_ReappliesMovement() - { - var body = MakeGrounded(); - var interp = new MotionInterpreter { PhysicsObj = body, Initted = true }; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - - interp.SetWeenieObject(new FakeWeenie { IsThePlayerResult = false }); - - Assert.True(body.Velocity.Length() > 0f, "SetWeenieObject must re-apply movement when bound+initted"); - } - - [Fact] - public void SetWeenieObject_NoPhysicsObj_DoesNotReapply() - { - var interp = new MotionInterpreter(); - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - - // Must not throw despite no PhysicsObj. - interp.SetWeenieObject(new FakeWeenie()); - - Assert.Null(interp.PhysicsObj); - } - - [Fact] - public void SetWeenieObject_NotInitted_DoesNotReapply() - { - var body = MakeGrounded(); - var interp = new MotionInterpreter { PhysicsObj = body, Initted = false }; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - - interp.SetWeenieObject(new FakeWeenie()); - - Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity); - } - - [Fact] - public void SetPhysicsObject_BindsAndReappliesWhenInitted() - { - var interp = new MotionInterpreter { Initted = true }; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - var body = MakeGrounded(); - - interp.SetPhysicsObject(body); - - Assert.Same(body, interp.PhysicsObj); - Assert.True(body.Velocity.Length() > 0f); - } - - [Fact] - public void SetPhysicsObject_NullArg_DoesNotReapply() - { - var interp = new MotionInterpreter { Initted = true }; - - // arg2 != 0 gates the reapply -- passing null must not throw and - // must not attempt InterpretedState velocity work (no body to write to). - interp.SetPhysicsObject(null); - - Assert.Null(interp.PhysicsObj); - } - - // ========================================================================= - // set_hold_run — 0x00528b70 @306053 (XOR toggle guard) - // ========================================================================= - - [Fact] - public void SetHoldRun_TogglesFromNoneToRun_WhenHoldingRunKey() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.RawState.CurrentHoldKey = HoldKey.None; - - interp.set_hold_run(holdingRun: true, interrupt: false); - - Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey); - } - - [Fact] - public void SetHoldRun_TogglesFromRunToNone_WhenReleasingRunKey() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.RawState.CurrentHoldKey = HoldKey.Run; - - interp.set_hold_run(holdingRun: false, interrupt: false); - - Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey); - } - - [Fact] - public void SetHoldRun_NoChange_WhenAlreadyInRequestedState_IsANoOp() - { - // XOR guard: eax(=arg2==0) != edx(=current!=Run) is FALSE when - // arg2 requests exactly the state we're already in -- skip. - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.RawState.CurrentHoldKey = HoldKey.Run; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - body.Velocity = System.Numerics.Vector3.Zero; - - interp.set_hold_run(holdingRun: true, interrupt: false); - - Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey); - // No re-apply fired (still zero) -- the guard skipped the whole body. - Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity); - } - - [Fact] - public void SetHoldRun_OnChange_CallsApplyCurrentMovementWithInterruptArg() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.RawState.CurrentHoldKey = HoldKey.None; - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - - interp.set_hold_run(holdingRun: true, interrupt: true); - - // apply_current_movement(arg3, 0) fired -- observable via the - // grounded velocity re-application (AP-75-adjacent write). - Assert.True(body.Velocity.Length() > 0f); - } - - // ========================================================================= - // SetHoldKey — 0x00528bb0 @306072 (None-only-meaningful-from-Run) - // ========================================================================= - - [Fact] - public void SetHoldKey_None_FromRun_TakesEffect() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.RawState.CurrentHoldKey = HoldKey.Run; - - interp.SetHoldKey(HoldKey.None, cancelMoveTo: false); - - Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey); - } - - [Fact] - public void SetHoldKey_None_FromInvalid_IsIgnored() - { - // raw @306072: setting None only takes effect from Run. Any other - // starting state (Invalid, or already None) is silently ignored. - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.RawState.CurrentHoldKey = HoldKey.Invalid; - - interp.SetHoldKey(HoldKey.None, cancelMoveTo: false); - - Assert.Equal(HoldKey.Invalid, interp.RawState.CurrentHoldKey); - } - - [Fact] - public void SetHoldKey_AlreadyNone_IsNoOp() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.RawState.CurrentHoldKey = HoldKey.None; - - interp.SetHoldKey(HoldKey.None, cancelMoveTo: false); - - Assert.Equal(HoldKey.None, interp.RawState.CurrentHoldKey); - } - - [Fact] - public void SetHoldKey_Run_FromNone_TakesEffect() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.RawState.CurrentHoldKey = HoldKey.None; - - interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false); - - Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey); - } - - [Fact] - public void SetHoldKey_Run_AlreadyRun_IsNoOp() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.RawState.CurrentHoldKey = HoldKey.Run; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - body.Velocity = System.Numerics.Vector3.Zero; - - interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false); - - Assert.Equal(HoldKey.Run, interp.RawState.CurrentHoldKey); - Assert.Equal(System.Numerics.Vector3.Zero, body.Velocity); - } - - [Fact] - public void SetHoldKey_EffectiveChange_ReappliesMovement() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.RawState.CurrentHoldKey = HoldKey.None; - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - - interp.SetHoldKey(HoldKey.Run, cancelMoveTo: false); - - Assert.True(body.Velocity.Length() > 0f, "an effective SetHoldKey change must reapply movement"); - } - - // ========================================================================= - // adjust_motion creature guard — J18 one-liner (retires register TS-34) - // ========================================================================= - - [Fact] - public void AdjustMotion_NonCreatureWeenie_SkipsNormalization() - { - var weenie = new FakeWeenie { IsCreatureResult = false }; - var interp = MakeInterp(weenie: weenie); - uint motion = MotionCommand.WalkBackward; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid); - - // Retail returns immediately for a non-creature weenie -- WalkBackward - // must NOT be remapped to WalkForward/negated-speed. - Assert.Equal(MotionCommand.WalkBackward, motion); - Assert.Equal(1.0f, speed); - } - - [Fact] - public void AdjustMotion_CreatureWeenie_NormalizesAsBefore() - { - var weenie = new FakeWeenie { IsCreatureResult = true }; - var interp = MakeInterp(weenie: weenie); - uint motion = MotionCommand.WalkBackward; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid); - - Assert.Equal(MotionCommand.WalkForward, motion); - Assert.Equal(-MotionInterpreter.BackwardsFactor, speed, precision: 5); - } - - [Fact] - public void AdjustMotion_NoWeenie_StillNormalizes() - { - // weenie == null must behave like a creature (the "always creature - // unless proven otherwise" retail idiom -- weenie_obj != 0 gates - // the query at all). - var interp = MakeInterp(); - uint motion = MotionCommand.WalkBackward; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid); - - Assert.Equal(MotionCommand.WalkForward, motion); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterJumpFamilyTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterJumpFamilyTests.cs deleted file mode 100644 index f32d67e7..00000000 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterJumpFamilyTests.cs +++ /dev/null @@ -1,955 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -// ───────────────────────────────────────────────────────────────────────────── -// MotionInterpreterJumpFamilyTests — R3-W3 (closes J5, J6, J7-interp-side, -// J16-epsilons). Pins per docs/research/2026-07-02-r3-motioninterp/W0-pins.md -// (A1, A2, A5, A6, A10) and r3-motioninterp-decomp.md §3a-3h. -// -// Source addresses tested: -// FUN_00527a50 (0x00527a50) jump_charge_is_allowed @304935 -// FUN_005281c0 (0x005281c0) charge_jump @305448 -// FUN_00527aa0 (0x00527aa0) get_jump_v_z @304953 -// FUN_005280c0 (0x005280c0) get_leave_ground_velocity @305404 -// FUN_005282b0 (0x005282b0) jump_is_allowed (full chain) -// FUN_00528780 (0x00528780) jump @305792 -// ───────────────────────────────────────────────────────────────────────────── - -/// Fake WeenieObject for jump-family test isolation. -file sealed class FakeWeenie : IWeenieObject -{ - public float RunRate = 1.0f; - public float JumpVz = 10.0f; - public bool CanJumpResult = true; - public bool InqRunRateResult = true; - public bool InqJumpVelocityResult = true; - - /// Controls the JumpStaminaCost virtual (vtable +0x44 per the - /// raw 305549-305556 shape): true = affordable (pass), false = 0x47. - public bool JumpStaminaCostResult = true; - public int JumpStaminaCostCalls; - - public bool InqJumpVelocity(float extent, out float vz) - { - vz = JumpVz * extent; - return InqJumpVelocityResult; - } - - public bool InqRunRate(out float rate) - { - rate = RunRate; - return InqRunRateResult; - } - - public bool CanJump(float extent) => CanJumpResult; - - public bool JumpStaminaCost(float extent, out int cost) - { - JumpStaminaCostCalls++; - cost = 0; - return JumpStaminaCostResult; - } -} - -public sealed class MotionInterpreterJumpFamilyTests -{ - // ── helpers ─────────────────────────────────────────────────────────────── - - private static PhysicsBody MakeGrounded() - { - var body = new PhysicsBody - { - State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, - }; - body.TransientState = TransientStateFlags.Contact - | TransientStateFlags.OnWalkable - | TransientStateFlags.Active; - return body; - } - - private static PhysicsBody MakeAirborne() - { - var body = new PhysicsBody - { - State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions, - }; - body.TransientState = TransientStateFlags.Active; - return body; - } - - private static MotionInterpreter MakeInterp(PhysicsBody? body = null, IWeenieObject? weenie = null) - { - body ??= MakeGrounded(); - return new MotionInterpreter(body, weenie); - } - - // ========================================================================= - // jump_charge_is_allowed — 0x00527a50 @304935 - // - // Raw (r3-motioninterp-decomp.md §3b): - // weenie_obj = this->weenie_obj; - // if (weenie_obj != 0 && weenie_obj->vtable->CanJump(this->jump_extent) == 0) - // return 0x49; - // forward_command = this->interpreted_state.forward_command; - // if (forward_command != 0x40000008 - // && (forward_command <= 0x41000011 || forward_command > 0x41000014)) - // return 0; - // return 0x48; - // ========================================================================= - - [Fact] - public void JumpChargeIsAllowed_NoWeenie_ReadyForward_ReturnsNone() - { - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.Ready; - - var result = interp.JumpChargeIsAllowed(0.5f); - - Assert.Equal(WeenieError.None, result); - } - - [Fact] - public void JumpChargeIsAllowed_WeenieCanJumpFalse_ReturnsCantJumpLoadedDown() - { - var weenie = new FakeWeenie { CanJumpResult = false }; - var interp = MakeInterp(weenie: weenie); - interp.InterpretedState.ForwardCommand = MotionCommand.Ready; - - var result = interp.JumpChargeIsAllowed(0.5f); - - Assert.Equal(WeenieError.CantJumpLoadedDown, result); - } - - [Fact] - public void JumpChargeIsAllowed_Fallen_ReturnsYouCantJumpFromThisPosition() - { - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.Fallen; // 0x40000008 - - var result = interp.JumpChargeIsAllowed(0.5f); - - Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result); - } - - [Theory] - [InlineData(MotionCommand.Crouch)] // 0x41000012 - [InlineData(MotionCommand.Sitting)] // 0x41000013 - [InlineData(MotionCommand.Sleeping)] // 0x41000014 - public void JumpChargeIsAllowed_CrouchSitSleepRange_ReturnsYouCantJumpFromThisPosition(uint forward) - { - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = forward; - - var result = interp.JumpChargeIsAllowed(0.5f); - - Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result); - } - - [Fact] - public void JumpChargeIsAllowed_CrouchLowerBoundExact_Passes() - { - // forward_command <= 0x41000011 passes (the gate is a strict - // inequality on the LOWER bound: forward_command > 0x41000011 is - // required to even consider the block). 0x41000011 == CrouchLowerBound - // itself must PASS (not blocked) — verbatim boundary. - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.CrouchLowerBound; // 0x41000011 - - var result = interp.JumpChargeIsAllowed(0.5f); - - Assert.Equal(WeenieError.None, result); - } - - [Fact] - public void JumpChargeIsAllowed_SleepUpperBoundExact_Blocked() - { - // forward_command > 0x41000014 passes; == 0x41000014 (Sleeping) is - // still inside the blocked range (already covered above), but this - // pins the boundary explicitly: 0x41000015 (one past Sleeping) must - // PASS. - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.CrouchUpperExclusive; // 0x41000015 - - var result = interp.JumpChargeIsAllowed(0.5f); - - Assert.Equal(WeenieError.None, result); - } - - [Fact] - public void JumpChargeIsAllowed_Falling_Passes() - { - // Falling (0x40000015) is NOT Fallen (0x40000008) — must pass this - // gate (though jump_is_allowed's separate ground check blocks - // mid-air jumps by a different mechanism — A1 adjudication). - var interp = MakeInterp(); - interp.InterpretedState.ForwardCommand = MotionCommand.Falling; - - var result = interp.JumpChargeIsAllowed(0.5f); - - Assert.Equal(WeenieError.None, result); - } - - [Fact] - public void JumpChargeIsAllowed_CanJumpCheckedBeforePostureCheck() - { - // Raw order: CanJump gate FIRST, forward_command gate SECOND. A - // weenie that refuses CanJump returns 0x49 even in a posture that - // would otherwise pass. - var weenie = new FakeWeenie { CanJumpResult = false }; - var interp = MakeInterp(weenie: weenie); - interp.InterpretedState.ForwardCommand = MotionCommand.Falling; // would pass posture gate - - var result = interp.JumpChargeIsAllowed(0.5f); - - Assert.Equal(WeenieError.CantJumpLoadedDown, result); - } - - // ========================================================================= - // charge_jump — 0x005281c0 @305448 (closes J6) - // - // Raw (r3-motioninterp-decomp.md §3e): - // weenie_obj = this->weenie_obj; - // if (weenie_obj != 0 && weenie_obj->vtable->CanJump(this->jump_extent) == 0) - // return 0x49; - // forward_command = this->interpreted_state.forward_command; - // if (forward_command == 0x40000008 - // || (forward_command > 0x41000011 && forward_command <= 0x41000014)) - // return 0x48; - // transient_state = physics_obj->transient_state; - // if ((transient_state & 1) != 0 && (transient_state & 2) != 0 - // && forward_command == 0x41000003 - // && interpreted_state.sidestep_command == 0 - // && interpreted_state.turn_command == 0) - // standing_longjump = 1; - // return 0; - // ========================================================================= - - [Fact] - public void ChargeJump_GroundedIdle_ArmsStandingLongJump() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.StandingLongJump = false; - // Default interpreted state: ForwardCommand=Ready, SideStep=0, Turn=0. - - var result = interp.ChargeJump(); - - Assert.Equal(WeenieError.None, result); - Assert.True(interp.StandingLongJump, "grounded + idle must arm StandingLongJump"); - } - - [Fact] - public void ChargeJump_GroundedButMoving_DoesNotArmStandingLongJump() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.StandingLongJump = false; - interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; - - var result = interp.ChargeJump(); - - Assert.Equal(WeenieError.None, result); - Assert.False(interp.StandingLongJump, "must not arm while moving forward"); - } - - [Fact] - public void ChargeJump_GroundedIdleButSidestepping_DoesNotArmStandingLongJump() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.StandingLongJump = false; - interp.InterpretedState.ForwardCommand = MotionCommand.Ready; - interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight; - - var result = interp.ChargeJump(); - - Assert.Equal(WeenieError.None, result); - Assert.False(interp.StandingLongJump, "must not arm while sidestepping"); - } - - [Fact] - public void ChargeJump_GroundedIdleButTurning_DoesNotArmStandingLongJump() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.StandingLongJump = false; - interp.InterpretedState.ForwardCommand = MotionCommand.Ready; - interp.InterpretedState.TurnCommand = MotionCommand.TurnRight; - - var result = interp.ChargeJump(); - - Assert.Equal(WeenieError.None, result); - Assert.False(interp.StandingLongJump, "must not arm while turning"); - } - - [Fact] - public void ChargeJump_Airborne_DoesNotArmStandingLongJump() - { - var body = MakeAirborne(); - var interp = MakeInterp(body); - interp.StandingLongJump = false; - - var result = interp.ChargeJump(); - - Assert.Equal(WeenieError.None, result); - Assert.False(interp.StandingLongJump, "must not arm while airborne (no Contact+OnWalkable)"); - } - - [Fact] - public void ChargeJump_WeenieBlocksCanJump_ReturnsCantJumpLoadedDown_NoArm() - { - var weenie = new FakeWeenie { CanJumpResult = false }; - var body = MakeGrounded(); - var interp = MakeInterp(body, weenie); - interp.StandingLongJump = false; - - var result = interp.ChargeJump(); - - Assert.Equal(WeenieError.CantJumpLoadedDown, result); - Assert.False(interp.StandingLongJump); - } - - [Fact] - public void ChargeJump_Fallen_ReturnsYouCantJumpFromThisPosition_NoArm() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.InterpretedState.ForwardCommand = MotionCommand.Fallen; - interp.StandingLongJump = false; - - var result = interp.ChargeJump(); - - Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result); - Assert.False(interp.StandingLongJump); - } - - [Fact] - public void ChargeJump_CrouchRange_ReturnsYouCantJumpFromThisPosition_NoArm() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.InterpretedState.ForwardCommand = MotionCommand.Sitting; - interp.StandingLongJump = false; - - var result = interp.ChargeJump(); - - Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result); - Assert.False(interp.StandingLongJump); - } - - // ── J6 regression pin: contact_allows_move no longer arms StandingLongJump ── - - [Fact] - public void ContactAllowsMove_GroundedAndIdle_DoesNotArmStandingLongJump() - { - // J6: the S2a-flagged misattribution is DELETED. Only ChargeJump - // arms StandingLongJump now — contact_allows_move must have no side - // effect on this flag at all, in either direction. - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.StandingLongJump = false; - - interp.contact_allows_move(MotionCommand.WalkForward); - - Assert.False(interp.StandingLongJump, - "contact_allows_move must never arm StandingLongJump (J6 — moved to ChargeJump exclusively)"); - } - - [Fact] - public void ContactAllowsMove_GroundedAndIdle_DoesNotClearPreArmedStandingLongJump() - { - // contact_allows_move must not touch the flag at all — verify it - // doesn't clear a flag armed by a prior ChargeJump call either. - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.StandingLongJump = true; - - interp.contact_allows_move(MotionCommand.WalkForward); - - Assert.True(interp.StandingLongJump, - "contact_allows_move must not clear an externally-armed StandingLongJump flag"); - } - - // ========================================================================= - // get_jump_v_z — 0x00527aa0 @304953 (A5; closes J16-epsilons) - // Epsilon: 0.000199999995f (NOT the old 0.001 JumpExtentEpsilon). - // ========================================================================= - - [Fact] - public void GetJumpVZ_EpsilonIsRetailLiteral() - { - Assert.Equal(0.000199999995f, MotionInterpreter.JumpVzEpsilon); - } - - [Fact] - public void GetJumpVZ_JustBelowEpsilon_ReturnsZero() - { - var weenie = new FakeWeenie { JumpVz = 10.0f }; - var interp = MakeInterp(weenie: weenie); - interp.JumpExtent = 0.0001f; // < 0.000199999995f - - Assert.Equal(0f, interp.GetJumpVZ(), precision: 6); - } - - [Fact] - public void GetJumpVZ_JustAboveEpsilon_DelegatesToWeenie() - { - var weenie = new FakeWeenie { JumpVz = 10.0f }; - var interp = MakeInterp(weenie: weenie); - interp.JumpExtent = 0.0003f; // > 0.000199999995f - - float vz = interp.GetJumpVZ(); - - // Not the zero-fallback: delegates to InqJumpVelocity(extent, ...). - Assert.Equal(weenie.JumpVz * 0.0003f, vz, precision: 4); - } - - [Fact] - public void GetJumpVZ_ExactlyOldWrongEpsilon_0_001_IsAboveRetailEpsilon_NotZero() - { - // Regression pin: the OLD (wrong) epsilon was 0.001. At extent = - // 0.0005 the old code would have returned 0 (0.0005 < 0.001), but - // retail's epsilon (0.0002) means 0.0005 must NOT hit the zero path. - var weenie = new FakeWeenie { JumpVz = 10.0f }; - var interp = MakeInterp(weenie: weenie); - interp.JumpExtent = 0.0005f; - - float vz = interp.GetJumpVZ(); - - Assert.NotEqual(0f, vz); - Assert.Equal(weenie.JumpVz * 0.0005f, vz, precision: 4); - } - - [Fact] - public void GetJumpVZ_NoWeenie_ReturnsDefault() - { - var interp = MakeInterp(); - interp.JumpExtent = 0.5f; - - Assert.Equal(MotionInterpreter.DefaultJumpVz, interp.GetJumpVZ(), precision: 4); - } - - [Fact] - public void GetJumpVZ_ExtentClampsAtMax1() - { - var weenie = new FakeWeenie { JumpVz = 10.0f }; - var interp = MakeInterp(weenie: weenie); - interp.JumpExtent = 5.0f; // over-clamped - - float vz = interp.GetJumpVZ(); - - Assert.Equal(10.0f, vz, precision: 4); - } - - [Fact] - public void GetJumpVZ_WeenieRefusesInqJumpVelocity_ReturnsZero() - { - var weenie = new FakeWeenie { InqJumpVelocityResult = false }; - var interp = MakeInterp(weenie: weenie); - interp.JumpExtent = 0.5f; - - Assert.Equal(0f, interp.GetJumpVZ(), precision: 5); - } - - // ========================================================================= - // get_leave_ground_velocity — 0x005280c0 @305404 (A6; closes J16-epsilons) - // ========================================================================= - - [Fact] - public void GetLeaveGroundVelocity_WalkingForward_HasPositiveYAndZ() - { - var weenie = new FakeWeenie { JumpVz = 10.0f }; - var body = MakeGrounded(); - var interp = new MotionInterpreter(body, weenie) - { - JumpExtent = 1.0f, - }; - interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; - interp.InterpretedState.ForwardSpeed = 1.0f; - - var vel = interp.GetLeaveGroundVelocity(); - - Assert.True(vel.Y > 0f, "Y velocity should be positive when walking forward"); - Assert.True(vel.Z > 0f, "Z (jump) velocity should be positive"); - } - - [Fact] - public void GetLeaveGroundVelocity_TrulyZero_FallsBackToWorldVelocityGlobalToLocal() - { - // A6: fallback fires ONLY when |x| AND |y| AND |z| are ALL < - // epsilon. When it fires, ALL THREE components (including the - // just-computed jump v_z) are overwritten with - // globaltolocal(m_velocityVector) — the existing - // Quaternion.Inverse(Orientation) transform IS global->local; keep. - var body = MakeGrounded(); - body.Velocity = new Vector3(2.0f, 3.0f, 0f); // nonzero WORLD velocity (momentum) - body.Orientation = Quaternion.Identity; // identity: local == global for this pin - var interp = new MotionInterpreter(body) - { - JumpExtent = 0f, // below epsilon -> get_jump_v_z returns 0 - }; - // Default interpreted state = Ready (no walk/run/sidestep command) -> XY also 0. - - var vel = interp.GetLeaveGroundVelocity(); - - // Fallback must have fired: velocity now reflects body.Velocity - // (global->local via inverse-identity == identity here), NOT the - // all-zero get_state_velocity()/get_jump_v_z() composition. - Assert.Equal(2.0f, vel.X, precision: 4); - Assert.Equal(3.0f, vel.Y, precision: 4); - Assert.Equal(0.0f, vel.Z, precision: 4); - } - - [Fact] - public void GetLeaveGroundVelocity_TrulyZero_NoWorldVelocity_StaysZero() - { - var body = MakeGrounded(); - body.Velocity = Vector3.Zero; - var interp = new MotionInterpreter(body) - { - JumpExtent = 0f, - }; - - var vel = interp.GetLeaveGroundVelocity(); - - Assert.Equal(0f, vel.X, precision: 5); - Assert.Equal(0f, vel.Y, precision: 5); - Assert.Equal(0f, vel.Z, precision: 5); - } - - [Fact] - public void GetLeaveGroundVelocity_NonzeroXOnly_DoesNotTriggerFallback() - { - // A6: the fallback requires ALL THREE axes below epsilon. A - // sidestep-only launch (nonzero X, zero Y, zero jump Z) must NOT - // trigger the momentum fallback — it should keep the computed X. - var body = MakeGrounded(); - body.Velocity = new Vector3(99f, 99f, 99f); // if fallback wrongly fired, this would show - var interp = new MotionInterpreter(body) - { - JumpExtent = 0f, // jump v_z stays 0 - }; - interp.InterpretedState.SideStepCommand = MotionCommand.SideStepRight; - interp.InterpretedState.SideStepSpeed = 1.0f; - - var vel = interp.GetLeaveGroundVelocity(); - - // X must be the sidestep-derived value, not 99 (fallback did not fire). - Assert.NotEqual(99f, vel.X); - Assert.True(vel.X > 0f, "sidestep should have produced nonzero X from get_state_velocity"); - } - - [Fact] - public void GetLeaveGroundVelocity_EpsilonBoundary_JustBelow_TriggersFallback() - { - var body = MakeGrounded(); - body.Velocity = new Vector3(5f, 0f, 0f); - body.Orientation = Quaternion.Identity; - var interp = new MotionInterpreter(body) - { - JumpExtent = 0.0001f, // < 0.000199999995f -> get_jump_v_z() == 0 - }; - // Default interpreted state -> XY == 0 too. All three axes are - // exactly at the "essentially zero" edge -> fallback fires. - - var vel = interp.GetLeaveGroundVelocity(); - - Assert.Equal(5f, vel.X, precision: 4); - } - - // ========================================================================= - // jump_is_allowed — 0x005282b0 (A2, A10; closes J5) - // - // Raw entry shape (r3-motioninterp-decomp.md §3h): - // if (physics_obj != 0) { - // if (weenie != 0) eax_2 = weenie->IsCreature(); - // if (weenie != 0 && eax_2 == 0) // non-creature weenie - // goto shared_gate; - // if (physics_obj == 0 || (state bit 0x400) == 0) // gravity-state off - // goto shared_gate; - // if (Contact && OnWalkable) // grounded - // goto shared_gate; - // } - // return 0x24; - // - // shared_gate: - // if (IsFullyConstrained) return 0x47; - // head = pending_motions.head_; - // if (head != 0) eax_6 = head.jump_error_code; - // if (head == 0 || eax_6 == 0) { - // eax_6 = jump_charge_is_allowed(); - // if (eax_6 == 0) { - // eax_7 = motion_allows_jump(interpreted_state.forward_command); - // if (eax_7 != 0) return eax_7; - // if (weenie_obj_1 == 0) return eax_7; // == 0 here - // eax_6 = 0x47; - // if (weenie_obj_1->JumpStaminaCost(extent, &cost) != 0) - // return eax_7; // == 0 (success) - // // JumpStaminaCost returned 0 (false) -> falls through, eax_6 stays 0x47 - // } - // } - // return eax_6; - // ========================================================================= - - [Fact] - public void JumpIsAllowed_NullPhysicsObj_ReturnsNotGrounded() - { - var interp = new MotionInterpreter(); - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.NotGrounded, result); - } - - [Fact] - public void JumpIsAllowed_CreatureWeenie_Airborne_GravityState_ReturnsNotGrounded() - { - var body = MakeAirborne(); - var interp = MakeInterp(body); - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.NotGrounded, result); - } - - [Fact] - public void JumpIsAllowed_CreatureWeenie_Grounded_ReturnsNone() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - - var result = interp.jump_is_allowed(0.5f, out int cost); - - Assert.Equal(WeenieError.None, result); - Assert.Equal(0, cost); - } - - [Fact] - public void JumpIsAllowed_NonCreatureWeenie_Airborne_SkipsGroundGate_ReturnsNone() - { - // weenie present && !IsCreature() -> skip the ground check entirely, - // go straight to the shared gate. Airborne here must NOT block. - var weenie = new FakeWeenie(); - var body = MakeAirborne(); - var interp = MakeInterp(body, weenie); - // Non-creature: use an explicit fake that overrides IsCreature. - var nonCreature = new NonCreatureFakeWeenie(); - interp.WeenieObj = nonCreature; - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.None, result); - } - - [Fact] - public void JumpIsAllowed_NoWeenie_GravityStateOff_SkipsGroundGate_ReturnsNone() - { - // "no weenie" -> weenie_obj == 0 branch is fine either way (the - // `weenie != 0 && eax_2 == 0` non-creature-skip only applies when a - // weenie exists); but the SEPARATE gravity-state-off skip - // (state bit 0x400 clear) also reaches the shared gate regardless - // of weenie presence. Pin: no gravity flag -> shared gate, not - // blocked outright. - var body = new PhysicsBody - { - State = PhysicsStateFlags.None, // gravity flag clear - TransientState = TransientStateFlags.None, - }; - var interp = MakeInterp(body); - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.None, result); - } - - [Fact] - public void JumpIsAllowed_IsFullyConstrained_ReturnsGeneralMovementFailure() - { - var body = MakeGrounded(); - body.IsFullyConstrained = true; - var interp = MakeInterp(body); - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.GeneralMovementFailure, result); // 0x47 - } - - [Fact] - public void JumpIsAllowed_IsFullyConstrained_BeatsPendingHeadPeek() - { - // A2 ordering: IsFullyConstrained is checked BEFORE the pending-head - // peek. Even if the head carries a DIFFERENT nonzero error, the - // IsFullyConstrained code (0x47) must win. - var body = MakeGrounded(); - body.IsFullyConstrained = true; - var interp = MakeInterp(body); - interp.AddToQueue(0, MotionCommand.WalkForward, (uint)WeenieError.CantJumpLoadedDown); - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.GeneralMovementFailure, result); - } - - [Fact] - public void JumpIsAllowed_PendingHeadNonzeroError_ShortCircuitsChain() - { - // A2: the peek fires WHENEVER the queue is non-empty (no Count>1 - // gate). A nonzero head.JumpErrorCode is returned VERBATIM, and the - // charge/motion/stamina chain is skipped entirely (proven via the - // weenie's CanJump/JumpStaminaCost never being consulted for THIS - // code path — CanJump would return CantJumpLoadedDown some other - // way, so instead assert JumpStaminaCost, which only the fallthrough - // chain calls, is never invoked). - var weenie = new FakeWeenie(); - var body = MakeGrounded(); - var interp = MakeInterp(body, weenie); - interp.AddToQueue(0, MotionCommand.WalkForward, (uint)WeenieError.YouCantJumpFromThisPosition); - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result); - Assert.Equal(0, weenie.JumpStaminaCostCalls); - } - - [Fact] - public void JumpIsAllowed_PendingHeadZeroError_FallsThroughToChain() - { - // A2: head exists but JumpErrorCode == 0 -> chain still runs. - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.AddToQueue(0, MotionCommand.WalkForward, jumpErrorCode: 0); - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.None, result); - } - - [Fact] - public void JumpIsAllowed_EmptyQueue_FallsThroughToChain() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - // No AddToQueue call -> head is null -> chain runs normally. - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.None, result); - } - - [Fact] - public void JumpIsAllowed_ChargeBlocked_Fallen_ReturnsYouCantJumpFromThisPosition() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.InterpretedState.ForwardCommand = MotionCommand.Fallen; - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result); - } - - [Fact] - public void JumpIsAllowed_ChargeBlocked_WeenieCanJumpFalse_ReturnsCantJumpLoadedDown() - { - var weenie = new FakeWeenie { CanJumpResult = false }; - var body = MakeGrounded(); - var interp = MakeInterp(body, weenie); - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.CantJumpLoadedDown, result); - } - - [Fact] - public void JumpIsAllowed_MotionAllowsJumpBlocks_ReturnsYouCantJumpFromThisPosition() - { - // jump_charge_is_allowed passes (Ready forward command), but the - // SEPARATE motion_allows_jump(forward_command) check blocks — use a - // forward command in the [0x4000001e, 0x40000039] blocklist band - // that jump_charge_is_allowed does NOT gate on (only Fallen/Crouch - // range does), so this pins the double-check shape. - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.InterpretedState.ForwardCommand = 0x40000020u; // inside [0x4000001e, 0x40000039] - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.YouCantJumpFromThisPosition, result); - } - - [Fact] - public void JumpIsAllowed_NoWeenie_PassesChain_ReturnsNone() - { - // motion_allows_jump passes and weenie_obj == null -> return eax_7 - // (== 0) directly, skipping the JumpStaminaCost consult entirely. - var body = MakeGrounded(); - var interp = MakeInterp(body); // no weenie - - var result = interp.jump_is_allowed(0.5f, out int cost); - - Assert.Equal(WeenieError.None, result); - Assert.Equal(0, cost); - } - - [Fact] - public void JumpIsAllowed_WeenieJumpStaminaCostRefuses_ReturnsGeneralMovementFailure() - { - var weenie = new FakeWeenie { JumpStaminaCostResult = false }; - var body = MakeGrounded(); - var interp = MakeInterp(body, weenie); - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.GeneralMovementFailure, result); // 0x47 - Assert.Equal(1, weenie.JumpStaminaCostCalls); - } - - [Fact] - public void JumpIsAllowed_WeenieJumpStaminaCostAffords_ReturnsNone() - { - var weenie = new FakeWeenie { JumpStaminaCostResult = true }; - var body = MakeGrounded(); - var interp = MakeInterp(body, weenie); - - var result = interp.jump_is_allowed(0.5f, out _); - - Assert.Equal(WeenieError.None, result); - Assert.Equal(1, weenie.JumpStaminaCostCalls); - } - - /// Fake weenie whose IsCreature() returns false, for the - /// non-creature ground-gate-skip pin. - private sealed class NonCreatureFakeWeenie : IWeenieObject - { - public bool InqJumpVelocity(float extent, out float vz) { vz = 10f; return true; } - public bool InqRunRate(out float rate) { rate = 1f; return true; } - public bool CanJump(float extent) => true; - public bool IsCreature() => false; - } - - // ========================================================================= - // jump — 0x00528780 @305792 (closes J7-interp-side) - // ========================================================================= - - [Fact] - public void Jump_Grounded_SetsJumpExtentAndLeavesWalkable() - { - var body = MakeGrounded(); - var interp = MakeInterp(body); - - var result = interp.jump(0.5f); - - Assert.Equal(WeenieError.None, result); - Assert.Equal(0.5f, interp.JumpExtent, precision: 5); - Assert.False(body.OnWalkable, "Body should no longer be on walkable after jump"); - } - - [Fact] - public void Jump_Airborne_ReturnsNotGrounded() - { - var body = MakeAirborne(); - var interp = MakeInterp(body); - - var result = interp.jump(0.5f); - - Assert.Equal(WeenieError.NotGrounded, result); - } - - [Fact] - public void Jump_WeenieBlocksJump_ClearsStandingLongJump() - { - var weenie = new FakeWeenie { CanJumpResult = false }; - var body = MakeGrounded(); - var interp = MakeInterp(body, weenie); - interp.StandingLongJump = true; - - var result = interp.jump(0.5f); - - Assert.Equal(WeenieError.CantJumpLoadedDown, result); - Assert.False(interp.StandingLongJump, "a failed jump attempt cancels any pending StandingLongJump"); - } - - [Fact] - public void Jump_NullPhysicsObj_ReturnsNoPhysicsObject() - { - var interp = new MotionInterpreter(); - - var result = interp.jump(0.5f); - - Assert.Equal(WeenieError.NoPhysicsObject, result); - } - - [Fact] - public void Jump_Success_DoesNotClearStandingLongJump() - { - // Only the FAILURE path clears StandingLongJump (raw 305800: - // `this->standing_longjump = 0;` sits inside the `if (result != 0)` - // branch only). A successful jump leaves whatever value the flag - // already had. - var body = MakeGrounded(); - var interp = MakeInterp(body); - interp.StandingLongJump = true; - - var result = interp.jump(0.5f); - - Assert.Equal(WeenieError.None, result); - Assert.True(interp.StandingLongJump, "success path must not touch StandingLongJump"); - } - - [Fact] - public void Jump_CallsInterruptCurrentMovementSeam() - { - // J7 / "Adjacent findings": jump() ALWAYS calls - // interrupt_current_movement first (raw @305800), regardless of - // outcome. Register no-op seam until R4's cancel_moveto exists. - var body = MakeGrounded(); - var interp = MakeInterp(body); - bool called = false; - interp.InterruptCurrentMovement = () => called = true; - - interp.jump(0.5f); - - Assert.True(called, "jump() must invoke the InterruptCurrentMovement seam"); - } - - [Fact] - public void Jump_CallsInterruptCurrentMovementSeam_EvenOnFailure() - { - var body = MakeAirborne(); - var interp = MakeInterp(body); - bool called = false; - interp.InterruptCurrentMovement = () => called = true; - - interp.jump(0.5f); - - Assert.True(called, "jump() calls interrupt_current_movement before jump_is_allowed, unconditionally"); - } - - // ========================================================================= - // IWeenieObject.IsThePlayer — default false; PlayerWeenie true (for W4's - // A3 dispatch gate; no consumer yet in W3). - // ========================================================================= - - [Fact] - public void IWeenieObject_IsThePlayer_DefaultsFalse() - { - // Default interface members are only reachable through the - // interface type, not the concrete implementer — call via - // IWeenieObject to exercise the DIM (not a compile error site). - IWeenieObject weenie = new FakeWeenie(); - - Assert.False(weenie.IsThePlayer()); - } - - [Fact] - public void PlayerWeenie_IsThePlayer_ReturnsTrue() - { - var weenie = new PlayerWeenie(); - - Assert.True(weenie.IsThePlayer()); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterPendingMotionsTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterPendingMotionsTests.cs deleted file mode 100644 index ac23a5f0..00000000 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterPendingMotionsTests.cs +++ /dev/null @@ -1,351 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using AcDream.Core.Physics; -using AcDream.Core.Physics.Motion; -using DatReaderWriter.DBObjs; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// R3-W2 — CMotionInterp::pending_motions lifecycle + the -/// MotionDone consumer (closes J1, J17). -/// -/// Oracle: docs/research/2026-07-02-r3-motioninterp/r3-motioninterp-decomp.md -/// §1 (add_to_queue 0x00527b80 @305032, MotionDone 0x00527ec0 @305238, -/// motions_pending 0x00527fe0, HandleExitWorld 0x00527f30, -/// is_standing_still 0x00527fa0 @305309) + §3a (motion_allows_jump -/// 0x005279e0 @304908) + W0-pins.md A1/A7. -/// -public class MotionInterpreterPendingMotionsTests -{ - private static MotionInterpreter GroundedInterp() - { - var body = new PhysicsBody(); - body.State |= PhysicsStateFlags.Gravity; - body.TransientState |= TransientStateFlags.Contact - | TransientStateFlags.OnWalkable - | TransientStateFlags.Active; - return new MotionInterpreter(body); - } - - // ── AddToQueue / MotionsPending — §1a/§1b verbatim ───────────────────── - - [Fact] - public void AddToQueue_AppendsToTail_InOrder() - { - var mi = new MotionInterpreter(); - - mi.AddToQueue(1, 0x41000003u, 0u); - mi.AddToQueue(2, 0x44000007u, 0x48u); - mi.AddToQueue(3, 0x45000005u, 0u); - - Assert.Equal( - new[] - { - new MotionNode(1, 0x41000003u, 0u), - new MotionNode(2, 0x44000007u, 0x48u), - new MotionNode(3, 0x45000005u, 0u), - }, - mi.PendingMotions.ToArray()); - } - - [Fact] - public void MotionsPending_FalseWhenEmpty_TrueWhenNonEmpty() - { - var mi = new MotionInterpreter(); - Assert.False(mi.MotionsPending()); - - mi.AddToQueue(0, MotionCommand.Ready, 0); - Assert.True(mi.MotionsPending()); - } - - // ── MotionDone — §1c verbatim (unconditional head pop, A7) ──────────── - - [Fact] - public void MotionDone_PopsHead_RegardlessOfMotionIdOrSuccessFlag() - { - // A7: motion + success are IGNORED — positional protocol, never - // match-by-id. A queue with a head that does NOT match the - // `motion` parameter must still pop that head. §1c gates the whole - // body on physics_obj != null (covered separately by - // MotionDone_NoPhysicsObj_NoOp), so this needs a grounded interp. - var mi = GroundedInterp(); - mi.AddToQueue(1, 0x44000007u /* RunForward */, 0u); - mi.AddToQueue(2, 0x45000005u /* WalkForward */, 0u); - - // Call MotionDone with a motion id that matches NEITHER queued - // node, and success=false — retail still pops the actual head. - mi.MotionDone(0xDEADBEEFu, false); - - Assert.Single(mi.PendingMotions); - Assert.Equal(0x45000005u, mi.PendingMotions.First().Motion); - } - - [Fact] - public void MotionDone_EmptyQueue_NoOp() - { - var mi = new MotionInterpreter(); - // Should not throw. - mi.MotionDone(0, true); - Assert.False(mi.MotionsPending()); - } - - [Fact] - public void MotionDone_NoPhysicsObj_NoOp() - { - // §1c: `if (physics_obj != 0) { ... }` — the whole body is - // gated on a non-null physics_obj. A queued node must survive - // MotionDone when PhysicsObj is null. - var mi = new MotionInterpreter(); - mi.AddToQueue(0, MotionCommand.Ready, 0); - - mi.MotionDone(MotionCommand.Ready, true); - - Assert.True(mi.MotionsPending()); - } - - [Fact] - public void MotionDone_ActionClassHead_FiresUnstickSeam_AndPopsBothActionFifos() - { - // §1c: head.motion & 0x10000000 != 0 -> unstick_from_object + - // InterpretedState.RemoveAction() + RawState.RemoveAction(), - // THEN the unconditional head pop. - var mi = GroundedInterp(); - mi.InterpretedState.AddAction(0x10000062u, 1.0f, 5, false); - mi.RawState.AddAction(0x10000062u, 1.0f, 5, false); - - bool unstickFired = false; - mi.UnstickFromObject = () => unstickFired = true; - - mi.AddToQueue(0, 0x10000062u /* action-class bit 0x10000000 set */, 0u); - - mi.MotionDone(0x10000062u, true); - - Assert.True(unstickFired); - Assert.Equal(0u, mi.InterpretedState.GetNumActions()); - Assert.Empty(mi.RawState.Actions); - Assert.False(mi.MotionsPending()); - } - - [Fact] - public void MotionDone_NonActionClassHead_DoesNotFireUnstick_OrPopActionFifos() - { - var mi = GroundedInterp(); - mi.InterpretedState.AddAction(0x10000062u, 1.0f, 5, false); - mi.RawState.AddAction(0x10000062u, 1.0f, 5, false); - - bool unstickFired = false; - mi.UnstickFromObject = () => unstickFired = true; - - // Non-action-class motion (no 0x10000000 bit). - mi.AddToQueue(0, MotionCommand.Ready, 0u); - - mi.MotionDone(MotionCommand.Ready, true); - - Assert.False(unstickFired); - Assert.Equal(1u, mi.InterpretedState.GetNumActions()); - Assert.Single(mi.RawState.Actions); - } - - [Fact] - public void MotionDone_ActionClassHead_UnstickSeamUnset_DoesNotThrow() - { - // UnstickFromObject is a no-op seam (register row -> R5 StickyManager) - // until wired — null must not throw. Needs a grounded interp so - // MotionDone's physics_obj != null gate actually pops (§1c). - var mi = GroundedInterp(); - mi.AddToQueue(0, 0x10000062u, 0u); - - mi.MotionDone(0x10000062u, true); - - Assert.False(mi.MotionsPending()); - } - - [Fact] - public void MotionDone_MultipleCalls_PopInFifoOrder() - { - // §1c gates the pop on physics_obj != null. - var mi = GroundedInterp(); - mi.AddToQueue(1, 0x41000003u, 0u); - mi.AddToQueue(2, 0x44000007u, 0u); - mi.AddToQueue(3, 0x45000005u, 0u); - - mi.MotionDone(0, true); - Assert.Equal(new uint[] { 2, 3 }, mi.PendingMotions.Select(n => n.ContextId)); - - mi.MotionDone(0, true); - Assert.Equal(new uint[] { 3 }, mi.PendingMotions.Select(n => n.ContextId)); - - mi.MotionDone(0, true); - Assert.Empty(mi.PendingMotions); - Assert.False(mi.MotionsPending()); - } - - // ── HandleExitWorld — §1d, same body looped ──────────────────────────── - - [Fact] - public void HandleExitWorld_DrainsWholeQueue() - { - var mi = new MotionInterpreter(); - mi.AddToQueue(1, 0x41000003u, 0u); - mi.AddToQueue(2, 0x44000007u, 0u); - mi.AddToQueue(3, 0x10000062u, 0u); // action-class - - mi.HandleExitWorld(); - - Assert.Empty(mi.PendingMotions); - Assert.False(mi.MotionsPending()); - } - - [Fact] - public void HandleExitWorld_ActionClassNodes_PopActionFifosForEach() - { - var mi = GroundedInterp(); - mi.InterpretedState.AddAction(0x10000062u, 1f, 1, false); - mi.InterpretedState.AddAction(0x10000063u, 1f, 2, false); - mi.RawState.AddAction(0x10000062u, 1f, 1, false); - mi.RawState.AddAction(0x10000063u, 1f, 2, false); - - mi.AddToQueue(0, 0x10000062u, 0u); - mi.AddToQueue(0, 0x10000063u, 0u); - - mi.HandleExitWorld(); - - Assert.Equal(0u, mi.InterpretedState.GetNumActions()); - Assert.Empty(mi.RawState.Actions); - } - - [Fact] - public void HandleExitWorld_EmptyQueue_NoOp() - { - var mi = new MotionInterpreter(); - mi.HandleExitWorld(); - Assert.False(mi.MotionsPending()); - } - - // ── is_standing_still — §... 0x00527fa0 verbatim predicate ───────────── - - [Theory] - [InlineData(true, true, MotionCommand.Ready, 0u, 0u, true)] // grounded + idle -> true - [InlineData(true, true, MotionCommand.Ready, 0x6500000fu, 0u, false)] // sidestep set -> false - [InlineData(true, true, MotionCommand.Ready, 0u, 0x6500000du, false)] // turn set -> false - [InlineData(true, true, MotionCommand.RunForward, 0u, 0u, false)] // not Ready -> false - [InlineData(true, false, MotionCommand.Ready, 0u, 0u, false)] // OnWalkable false -> false - [InlineData(false, true, MotionCommand.Ready, 0u, 0u, false)] // Contact false -> false - public void IsStandingStill_TruthTable( - bool contact, bool onWalkable, uint fwd, uint side, uint turn, bool expected) - { - var body = new PhysicsBody(); - body.State |= PhysicsStateFlags.Gravity; - if (contact) body.TransientState |= TransientStateFlags.Contact; - if (onWalkable) body.TransientState |= TransientStateFlags.OnWalkable; - var mi = new MotionInterpreter(body) - { - InterpretedState = - { - ForwardCommand = fwd, - SideStepCommand = side, - TurnCommand = turn, - }, - }; - - Assert.Equal(expected, mi.IsStandingStill()); - } - - [Fact] - public void IsStandingStill_NoPhysicsObj_False() - { - var mi = new MotionInterpreter(); - Assert.False(mi.IsStandingStill()); - } - - // ── MotionAllowsJump — A1 pinned blocklist, exact boundaries ─────────── - - [Theory] - // [0x1000006f, 0x10000078] MagicPowerUp01..10 -> BLOCKED - [InlineData(0x1000006fu, true)] - [InlineData(0x10000070u, true)] - [InlineData(0x10000078u, true)] - [InlineData(0x1000006eu, false)] // just below lower bound -> pass - [InlineData(0x10000079u, false)] // just above upper bound -> pass - // [0x10000128, 0x10000131] -> BLOCKED - [InlineData(0x10000128u, true)] - [InlineData(0x10000130u, true)] - [InlineData(0x10000131u, true)] - [InlineData(0x10000127u, false)] - [InlineData(0x10000132u, false)] - // 0x40000008 (Fallen) exact -> BLOCKED - [InlineData(0x40000008u, true)] - // Falling 0x40000015 -> PASS (the A1 polarity-corrected divergence from ACE) - [InlineData(0x40000015u, false)] - // [0x40000016, 0x40000018] Reload/Unload/Pickup -> BLOCKED - [InlineData(0x40000016u, true)] - [InlineData(0x40000017u, true)] - [InlineData(0x40000018u, true)] - [InlineData(0x40000019u, false)] - // [0x4000001e, 0x40000039] AimLevel..MagicPray -> BLOCKED - [InlineData(0x4000001eu, true)] - [InlineData(0x40000030u, true)] - [InlineData(0x40000039u, true)] - [InlineData(0x4000001du, false)] - [InlineData(0x4000003au, false)] - // [0x41000012, 0x41000014] Crouch/Sitting/Sleeping -> BLOCKED - [InlineData(0x41000012u, true)] - [InlineData(0x41000013u, true)] - [InlineData(0x41000014u, true)] - [InlineData(0x41000011u, false)] - [InlineData(0x41000015u, false)] - // Everything else -> pass - [InlineData(MotionCommand.Ready, false)] - [InlineData(MotionCommand.Dead, false)] - [InlineData(0x6500000du, false)] // TurnRight - [InlineData(0x6500000fu, false)] // SideStepRight - [InlineData(0x44000007u, false)] // RunForward - public void MotionAllowsJump_BlocklistTable(uint motion, bool blocked) - { - var expected = blocked ? WeenieError.YouCantJumpFromThisPosition : WeenieError.None; - Assert.Equal(expected, MotionInterpreter.MotionAllowsJump(motion)); - } - - // ── End-to-end chain: MotionTableManager.AnimationDone -> sink -> interp pops ── - - /// In-memory that never resolves — - /// matches MotionTableManagerTests.cs's fixture convention. - private sealed class NullLoader : IAnimationLoader - { - public Animation? LoadAnimation(uint id) => null; - } - - [Fact] - public void EndToEnd_ManagerAnimationDone_PopsInterpPendingHead_Synchronously() - { - // §4 diagram: MotionTableManager (IMotionDoneSink) -> - // MotionInterpreter.MotionDone -> pops the interp's OWN - // pending_motions queue head. Two DIFFERENT queues (manager's - // pending_animations vs interp's pending_motions) kept in lockstep - // only via this callback — never merged. MotionDone's body is - // gated on physics_obj != null (§1c), so the interp needs one. - var interp = GroundedInterp(); - var state = new MotionState(); - var sequence = new CSequence(new NullLoader()); - var manager = new MotionTableManager(table: null, state, sequence, sink: interp); - - // The interp independently queued its own motion node (as a real - // DispatchInterpretedMotion call would via AddToQueue). - interp.AddToQueue(contextId: 7, motion: 0x44000007u, jumpErrorCode: 0); - - // The manager separately queues a pending animation with a 1-tick - // duration, matching retail's decrementing countdown chain. - manager.AddToQueue(0x44000007u, ticks: 1); - - Assert.True(interp.MotionsPending()); - - manager.AnimationDone(success: true); - - // The manager's own queue drained AND, via the sink callback, the - // interp's independent queue popped in the same call. - Assert.Empty(manager.PendingAnimations); - Assert.False(interp.MotionsPending()); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs index cfecd6a1..e2c4f896 100644 --- a/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs +++ b/tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs @@ -176,7 +176,7 @@ public sealed class MotionInterpreterTests { var interp = MakeInterp(); interp.RawState.ForwardCommand = MotionCommand.WalkForward; - interp.RawState.SidestepCommand = MotionCommand.SideStepRight; + interp.RawState.SideStepCommand = MotionCommand.SideStepRight; interp.RawState.TurnCommand = MotionCommand.TurnRight; interp.InterpretedState.ForwardCommand = MotionCommand.RunForward; interp.InterpretedState.TurnCommand = MotionCommand.TurnLeft; @@ -186,7 +186,7 @@ public sealed class MotionInterpreterTests Assert.Equal(WeenieError.None, result); Assert.Equal(MotionCommand.Ready, interp.RawState.ForwardCommand); Assert.Equal(1.0f, interp.RawState.ForwardSpeed, precision: 5); - Assert.Equal(0u, interp.RawState.SidestepCommand); + Assert.Equal(0u, interp.RawState.SideStepCommand); Assert.Equal(0u, interp.RawState.TurnCommand); Assert.Equal(MotionCommand.Ready, interp.InterpretedState.ForwardCommand); Assert.Equal(1.0f, interp.InterpretedState.ForwardSpeed, precision: 5); @@ -423,20 +423,215 @@ public sealed class MotionInterpreterTests } // ========================================================================= - // jump (FUN_00529390) / get_jump_v_z (FUN_005286b0) / - // get_leave_ground_velocity (FUN_00528cd0) / jump_is_allowed (FUN_00528ec0) - // - // R3-W3 (closes J5, J6, J7-interp-side, J16-epsilons): the full jump - // family (jump/GetJumpVZ/GetLeaveGroundVelocity/jump_is_allowed) plus - // jump_charge_is_allowed/ChargeJump now live in - // MotionInterpreterJumpFamilyTests.cs, ported against the verbatim - // decomp (r3-motioninterp-decomp.md §3a-3h) and W0-pins.md (A2/A5/A6/A10) - // instead of this file's pre-W3 approximation-era pins (wrong 0.001 - // epsilon, jump_is_allowed(extent, int) shape, no IsFullyConstrained / - // pending-head-peek / stamina chain). See that file for the full gate - // tables and epsilon-boundary pins. + // jump (FUN_00529390) // ========================================================================= + [Fact] + public void Jump_Grounded_SetsJumpExtentAndLeavesWalkable() + { + var body = MakeGrounded(); + var interp = MakeInterp(body); + + var result = interp.jump(0.5f); + + Assert.Equal(WeenieError.None, result); + Assert.Equal(0.5f, interp.JumpExtent, precision: 5); + Assert.False(body.OnWalkable, "Body should no longer be on walkable after jump"); + } + + [Fact] + public void Jump_Airborne_ReturnsYouCantJumpWhileInTheAir() + { + var body = MakeAirborne(); + var interp = MakeInterp(body); + + var result = interp.jump(0.5f); + + Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result); + } + + [Fact] + public void Jump_WeenieBlocksJump_ClearStandingLongJump() + { + var weenie = new FakeWeenie { CanJumpResult = false }; + var body = MakeGrounded(); + var interp = MakeInterp(body, weenie); + interp.StandingLongJump = true; + + var result = interp.jump(0.5f); + + Assert.Equal(WeenieError.CantJumpLoadedDown, result); + Assert.False(interp.StandingLongJump); + } + + [Fact] + public void Jump_NullPhysicsObj_ReturnsNoPhysicsObject() + { + var interp = new MotionInterpreter(); + + var result = interp.jump(0.5f); + + Assert.Equal(WeenieError.NoPhysicsObject, result); + } + + // ========================================================================= + // get_jump_v_z (FUN_005286b0) + // ========================================================================= + + [Fact] + public void GetJumpVz_ZeroExtent_ReturnsZero() + { + var interp = MakeInterp(); + interp.JumpExtent = 0f; + + Assert.Equal(0f, interp.get_jump_v_z(), precision: 5); + } + + [Fact] + public void GetJumpVz_NoWeenie_ReturnsDefault() + { + var interp = MakeInterp(); + interp.JumpExtent = 0.5f; + + Assert.Equal(MotionInterpreter.DefaultJumpVz, interp.get_jump_v_z(), precision: 4); + } + + [Fact] + public void GetJumpVz_WithWeenie_DelegatesToInqJumpVelocity() + { + var weenie = new FakeWeenie { JumpVz = 8.0f }; + var interp = MakeInterp(weenie: weenie); + interp.JumpExtent = 1.0f; + + float vz = interp.get_jump_v_z(); + + // FakeWeenie returns JumpVz * extent = 8.0 * 1.0 = 8.0 + Assert.Equal(8.0f, vz, precision: 4); + } + + [Fact] + public void GetJumpVz_ExtentClampsAt1() + { + var weenie = new FakeWeenie { JumpVz = 10.0f }; + var interp = MakeInterp(weenie: weenie); + interp.JumpExtent = 5.0f; // over-clamped + + float vz = interp.get_jump_v_z(); + + // Should clamp extent to 1.0: FakeWeenie returns 10.0 * 1.0 = 10.0 + Assert.Equal(10.0f, vz, precision: 4); + } + + [Fact] + public void GetJumpVz_WeenieReturnsFailure_ReturnsZero() + { + var weenie = new FakeWeenie { InqJumpVelocityResult = false }; + var interp = MakeInterp(weenie: weenie); + interp.JumpExtent = 0.5f; + + Assert.Equal(0f, interp.get_jump_v_z(), precision: 5); + } + + // ========================================================================= + // get_leave_ground_velocity (FUN_00528cd0) + // ========================================================================= + + [Fact] + public void GetLeaveGroundVelocity_WalkingForward_HasPositiveYAndZ() + { + var weenie = new FakeWeenie { JumpVz = 10.0f }; + var body = MakeGrounded(); + var interp = new MotionInterpreter(body, weenie) + { + JumpExtent = 1.0f, + }; + interp.InterpretedState.ForwardCommand = MotionCommand.WalkForward; + interp.InterpretedState.ForwardSpeed = 1.0f; + + var vel = interp.get_leave_ground_velocity(); + + Assert.True(vel.Y > 0f, "Y velocity should be positive when walking forward"); + Assert.True(vel.Z > 0f, "Z (jump) velocity should be positive"); + } + + [Fact] + public void GetLeaveGroundVelocity_Standing_ZeroExtent_ReturnsZeroXY() + { + var body = MakeGrounded(); + var interp = new MotionInterpreter(body) + { + JumpExtent = 0f, // below epsilon + }; + // Default interpreted state = Ready (no walk/run command) + + var vel = interp.get_leave_ground_velocity(); + + Assert.Equal(0f, vel.X, precision: 5); + Assert.Equal(0f, vel.Y, precision: 5); + Assert.Equal(0f, vel.Z, precision: 5); + } + + // ========================================================================= + // jump_is_allowed (FUN_00528ec0) + // ========================================================================= + + [Fact] + public void JumpIsAllowed_Grounded_ReturnsNone() + { + var interp = MakeInterp(MakeGrounded()); + + var result = interp.jump_is_allowed(0.5f, 0); + + Assert.Equal(WeenieError.None, result); + } + + [Fact] + public void JumpIsAllowed_Airborne_ReturnsYouCantJumpWhileInTheAir() + { + var interp = MakeInterp(MakeAirborne()); + + var result = interp.jump_is_allowed(0.5f, 0); + + Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result); + } + + [Fact] + public void JumpIsAllowed_WeenieBlocks_ReturnsCantJumpLoadedDown() + { + var weenie = new FakeWeenie { CanJumpResult = false }; + var interp = MakeInterp(MakeGrounded(), weenie); + + var result = interp.jump_is_allowed(0.5f, 0); + + Assert.Equal(WeenieError.CantJumpLoadedDown, result); + } + + [Fact] + public void JumpIsAllowed_NoGravityFlag_ReturnsYouCantJumpWhileInTheAir() + { + var body = new PhysicsBody + { + State = PhysicsStateFlags.None, // no gravity + TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + }; + var interp = MakeInterp(body); + + // No gravity → must be airborne-style + var result = interp.jump_is_allowed(0.5f, 0); + + Assert.Equal(WeenieError.YouCantJumpWhileInTheAir, result); + } + + [Fact] + public void JumpIsAllowed_NullPhysicsObj_ReturnsGeneralFailure() + { + var interp = new MotionInterpreter(); + + var result = interp.jump_is_allowed(0.5f, 0); + + Assert.Equal(WeenieError.GeneralMovementFailure, result); + } + // ========================================================================= // contact_allows_move (FUN_00528dd0) // ========================================================================= @@ -478,24 +673,35 @@ public sealed class MotionInterpreterTests Assert.True(allowLeft); } - // L.2g S2 (2026-07-02): the posture-rejection tests that used to live - // here pinned a MISATTRIBUTED port — the Fallen/Dead/crouch-range checks - // belong to jump_charge_is_allowed (0x00527a50) / motion_allows_jump - // (0x005279e0), NOT to contact_allows_move. The real contact_allows_move - // (0x00528240, pseudo-C 305471) never reads the forward-command posture: - // a grounded creature in ANY posture may receive motion - // (GetObjectSequence handles the posture→locomotion transition via - // links). Verified against the live retail-observer trace - // (RetailObserverTraceConformanceTests, 183/183 dispatch conformant). + [Fact] + public void ContactAllowsMove_FallenState_RejectsMove() + { + var body = MakeGrounded(); + var interp = MakeInterp(body); + interp.InterpretedState.ForwardCommand = MotionCommand.Fallen; + + bool allowed = interp.contact_allows_move(MotionCommand.WalkForward); + + Assert.False(allowed); + } + + [Fact] + public void ContactAllowsMove_DeadState_RejectsMove() + { + var body = MakeGrounded(); + var interp = MakeInterp(body); + interp.InterpretedState.ForwardCommand = MotionCommand.Dead; + + bool allowed = interp.contact_allows_move(MotionCommand.WalkForward); + + Assert.False(allowed); + } [Theory] - [InlineData(MotionCommand.Fallen)] - [InlineData(MotionCommand.Dead)] [InlineData(MotionCommand.Crouch)] [InlineData(MotionCommand.Sitting)] [InlineData(MotionCommand.Sleeping)] - [InlineData(0x41000012u)] // inside the crouch range (0x41000011, 0x41000015) - public void ContactAllowsMove_GroundedPosture_StillAllowsMove(uint postureCommand) + public void ContactAllowsMove_PostureState_RejectsMove(uint postureCommand) { var body = MakeGrounded(); var interp = MakeInterp(body); @@ -503,25 +709,20 @@ public sealed class MotionInterpreterTests bool allowed = interp.contact_allows_move(MotionCommand.WalkForward); - Assert.True(allowed); + Assert.False(allowed); } [Fact] - public void ContactAllowsMove_AirborneCreature_AcceptsFallingAndTurns_BlocksWalk() + public void ContactAllowsMove_CrouchRange_RejectsMove() { - // Verbatim 0x00528240: Falling (0x40000015) / Dead-class (0x40000011) - // and TurnLeft/TurnRight are ALWAYS allowed; a gravity-bound creature - // without Contact+OnWalkable is blocked for everything else - // (including the style command, which falls through to the gate). - var body = new PhysicsBody { State = PhysicsStateFlags.Gravity }; + var body = MakeGrounded(); var interp = MakeInterp(body); + // 0x41000012 is inside (0x41000011, 0x41000015) — crouch state + interp.InterpretedState.ForwardCommand = 0x41000012u; - Assert.True(interp.contact_allows_move(MotionCommand.Falling)); - Assert.True(interp.contact_allows_move(0x40000011u)); - Assert.True(interp.contact_allows_move(MotionCommand.TurnRight)); - Assert.True(interp.contact_allows_move(MotionCommand.TurnLeft)); - Assert.False(interp.contact_allows_move(MotionCommand.WalkForward)); - Assert.False(interp.contact_allows_move(0x8000003Du)); + bool allowed = interp.contact_allows_move(MotionCommand.WalkForward); + + Assert.False(allowed); } [Fact] @@ -541,15 +742,8 @@ public sealed class MotionInterpreterTests } [Fact] - public void ContactAllowsMove_GroundedAndIdle_DoesNotSetStandingLongJump() + public void ContactAllowsMove_GroundedAndIdle_SetsStandingLongJump() { - // R3-W3 (closes J6): the S2a-flagged misattribution is DELETED. - // Retail arms StandingLongJump ONLY in charge_jump (0x005281c0) — - // contact_allows_move (0x00528240) never reads or writes it. See - // MotionInterpreterJumpFamilyTests.ContactAllowsMove_GroundedAndIdle_DoesNotArmStandingLongJump - // for the full regression pin (both arm and no-clear directions) - // and ChargeJump_GroundedIdle_ArmsStandingLongJump for where the - // arming now actually happens. var body = MakeGrounded(); var interp = MakeInterp(body); // All interpreted commands at default (Ready, no sidestep, no turn) @@ -557,7 +751,7 @@ public sealed class MotionInterpreterTests interp.contact_allows_move(MotionCommand.WalkForward); - Assert.False(interp.StandingLongJump, "contact_allows_move must never touch StandingLongJump (J6)"); + Assert.True(interp.StandingLongJump, "Should set StandingLongJump when grounded and idle"); } // ========================================================================= diff --git a/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs b/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs deleted file mode 100644 index 415bf828..00000000 --- a/tests/AcDream.Core.Tests/Physics/MotionNormalizationTests.cs +++ /dev/null @@ -1,299 +0,0 @@ -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -// ───────────────────────────────────────────────────────────────────────────── -// MotionNormalizationTests — Phase D6.1: adjust_motion + apply_run_to_command. -// -// Source addresses tested (docs/research/2026-07-01-d6-motion-interp-pseudocode.md): -// FUN_00528010 (0x00528010) CMotionInterp::adjust_motion -// FUN_00527be0 (0x00527be0) CMotionInterp::apply_run_to_command -// -// Golden constants (retail, verified against ACE MotionInterp.cs byte-for-byte): -// BackwardsFactor = 0.649999976 (0x007c8910) -// WalkAnimSpeed = 3.11999989 (0x007c891c, retail-exact — NOT 3.12f) -// SidestepAnimSpeed = 1.25 -// SidestepFactor = 0.5 -// RunTurnFactor = 1.5 -// MaxSidestepAnimRate = 3.0 -// ───────────────────────────────────────────────────────────────────────────── - -/// -/// Fake WeenieObject for injecting a known run rate into apply_run_to_command / -/// adjust_motion without a real weenie. -/// -file sealed class FakeRunRateWeenie : IWeenieObject -{ - public float RunRate; - public bool InqRunRateResult = true; - - public bool InqJumpVelocity(float extent, out float vz) - { - vz = 0f; - return false; - } - - public bool InqRunRate(out float rate) - { - rate = RunRate; - return InqRunRateResult; - } - - public bool CanJump(float extent) => true; -} - -public sealed class MotionNormalizationTests -{ - // Retail-exact sidestep scale: SidestepFactor(0.5) * (3.11999989 / 1.25) ≈ 1.24799995... - private const float SidestepScale = 0.5f * (3.11999989f / MotionInterpreter.SidestepAnimSpeed); - - private static MotionInterpreter MakeInterp(IWeenieObject? weenie = null) - { - return new MotionInterpreter { WeenieObj = weenie }; - } - - // ── adjust_motion: per-command remap + scale ───────────────────────────── - - [Fact] - public void AdjustMotion_WalkBackward_RemapsToWalkForward_NegatesAndScalesSpeed() - { - // 0x00528010: WalkBackwards -> cmd = WalkForward; speed *= -BackwardsFactor - var interp = MakeInterp(); - uint motion = MotionCommand.WalkBackward; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.None); - - Assert.Equal(MotionCommand.WalkForward, motion); - Assert.Equal(-MotionInterpreter.BackwardsFactor, speed, 5); - Assert.Equal(-0.649999976f, speed, 5); - } - - [Fact] - public void AdjustMotion_TurnLeft_RemapsToTurnRight_NegatesSpeed() - { - var interp = MakeInterp(); - uint motion = MotionCommand.TurnLeft; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.None); - - Assert.Equal(MotionCommand.TurnRight, motion); - Assert.Equal(-1.0f, speed, 5); - } - - [Fact] - public void AdjustMotion_SideStepLeft_RemapsToSideStepRight_NegatesBeforeScale() - { - // Negate happens BEFORE the ×1.248 sidestep scale, so the net factor is -1.248. - var interp = MakeInterp(); - uint motion = MotionCommand.SideStepLeft; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.None); - - Assert.Equal(MotionCommand.SideStepRight, motion); - Assert.Equal(-SidestepScale, speed, 5); - Assert.Equal(-1.24799995f, speed, 4); - } - - [Fact] - public void AdjustMotion_SideStepRight_ScalesSpeed_NoNegate() - { - var interp = MakeInterp(); - uint motion = MotionCommand.SideStepRight; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.None); - - Assert.Equal(MotionCommand.SideStepRight, motion); - Assert.Equal(SidestepScale, speed, 5); - Assert.Equal(1.24799995f, speed, 4); - } - - [Fact] - public void AdjustMotion_RunForward_EarlyReturns_NoScaleNoHoldKeyPath() - { - // GOTCHA: RunForward returns immediately -- no scale AND no holdkey promotion, - // even when holdKey == Run (there's nothing left to promote it to, but the - // early-return also means speed is untouched). - var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); - uint motion = MotionCommand.RunForward; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.Run); - - Assert.Equal(MotionCommand.RunForward, motion); - Assert.Equal(1.0f, speed, 5); - } - - [Fact] - public void AdjustMotion_NonSidestepNonTurnNonForward_Unchanged_ExceptHoldKeyPath() - { - // Ready is not one of the switch cases and not SideStepRight after remap, - // so command/speed pass through unchanged when holdKey doesn't promote it. - var interp = MakeInterp(); - uint motion = MotionCommand.Ready; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.None); - - Assert.Equal(MotionCommand.Ready, motion); - Assert.Equal(1.0f, speed, 5); - } - - // ── adjust_motion: holdKey inheritance + Run promotion ─────────────────── - - [Fact] - public void AdjustMotion_HoldKeyInvalid_FallsBackToCurrentHoldKey_PromotesWalkForwardWhenRun() - { - var interp = MakeInterp(); - interp.RawState.CurrentHoldKey = HoldKey.Run; // R3-W6: CurrentHoldKey aliases RawState - uint motion = MotionCommand.WalkForward; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.Invalid); - - // apply_run_to_command: WalkForward + speed>0 -> RunForward; speed *= speedMod (1.0, no weenie). - Assert.Equal(MotionCommand.RunForward, motion); - Assert.Equal(1.0f, speed, 5); - } - - [Fact] - public void AdjustMotion_HoldKeyNone_DoesNotPromote() - { - var interp = MakeInterp(); - interp.RawState.CurrentHoldKey = HoldKey.Run; // R3-W6: CurrentHoldKey aliases RawState - uint motion = MotionCommand.WalkForward; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.None); - - Assert.Equal(MotionCommand.WalkForward, motion); - Assert.Equal(1.0f, speed, 5); - } - - [Fact] - public void AdjustMotion_HoldKeyRunPassedDirectly_PromotesWalkForward() - { - var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.75f }); - uint motion = MotionCommand.WalkForward; - float speed = 1.0f; - - interp.adjust_motion(ref motion, ref speed, HoldKey.Run); - - Assert.Equal(MotionCommand.RunForward, motion); - Assert.Equal(2.75f, speed, 5); - } - - // ── apply_run_to_command ────────────────────────────────────────────────── - - [Fact] - public void ApplyRunToCommand_WalkForward_PositiveSpeed_PromotesToRunForward_ScalesByRunRate() - { - var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); - uint motion = MotionCommand.WalkForward; - float speed = 1.0f; - - interp.apply_run_to_command(ref motion, ref speed); - - Assert.Equal(MotionCommand.RunForward, motion); - Assert.Equal(2.94f, speed, 5); - } - - [Fact] - public void ApplyRunToCommand_WalkForward_NegativeSpeed_StaysWalkForward_ScalesUnconditionally() - { - // GOTCHA: speed *= speedMod is UNCONDITIONAL -- backward (negative speed) - // still gets run-scaled even though it is NOT promoted to RunForward. - var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); - uint motion = MotionCommand.WalkForward; - float speed = -0.649999976f; - - interp.apply_run_to_command(ref motion, ref speed); - - Assert.Equal(MotionCommand.WalkForward, motion); - Assert.Equal(-0.649999976f * 2.94f, speed, 4); - } - - [Fact] - public void ApplyRunToCommand_TurnRight_ScalesByRunTurnFactor_NoRunRateNoClamp() - { - var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); - uint motion = MotionCommand.TurnRight; - float speed = 1.0f; - - interp.apply_run_to_command(ref motion, ref speed); - - Assert.Equal(MotionCommand.TurnRight, motion); - Assert.Equal(1.5f, speed, 5); - } - - [Fact] - public void ApplyRunToCommand_SideStepRight_BelowClamp_ScalesByRunRate() - { - var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.0f }); - uint motion = MotionCommand.SideStepRight; - float speed = 1.0f; - - interp.apply_run_to_command(ref motion, ref speed); - - Assert.Equal(MotionCommand.SideStepRight, motion); - Assert.Equal(2.0f, speed, 5); - } - - [Fact] - public void ApplyRunToCommand_SideStepRight_AbovePositiveClamp_ClampsToPositiveMax() - { - // runRate 2.94 * speed 1.248 (post adjust_motion sidestep scale) ≈ 3.669 > 3.0 - var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); - uint motion = MotionCommand.SideStepRight; - float speed = 1.24799995f; - - interp.apply_run_to_command(ref motion, ref speed); - - Assert.Equal(MotionCommand.SideStepRight, motion); - Assert.Equal(3.0f, speed, 5); - } - - [Fact] - public void ApplyRunToCommand_SideStepRight_AboveNegativeClamp_ClampsToNegativeMax() - { - var interp = MakeInterp(new FakeRunRateWeenie { RunRate = 2.94f }); - uint motion = MotionCommand.SideStepRight; - float speed = -1.24799995f; - - interp.apply_run_to_command(ref motion, ref speed); - - Assert.Equal(MotionCommand.SideStepRight, motion); - Assert.Equal(-3.0f, speed, 5); - } - - [Fact] - public void ApplyRunToCommand_UsesMyRunRate_WhenInqRunRateFails() - { - var weenie = new FakeRunRateWeenie { RunRate = 999f, InqRunRateResult = false }; - var interp = MakeInterp(weenie); - interp.MyRunRate = 1.5f; - uint motion = MotionCommand.WalkForward; - float speed = 1.0f; - - interp.apply_run_to_command(ref motion, ref speed); - - Assert.Equal(MotionCommand.RunForward, motion); - Assert.Equal(1.5f, speed, 5); - } - - [Fact] - public void ApplyRunToCommand_NoWeenie_SpeedModDefaultsToOne() - { - var interp = MakeInterp(weenie: null); - uint motion = MotionCommand.SideStepRight; - float speed = 1.0f; - - interp.apply_run_to_command(ref motion, ref speed); - - Assert.Equal(1.0f, speed, 5); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs b/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs deleted file mode 100644 index 30430faf..00000000 --- a/tests/AcDream.Core.Tests/Physics/MotionSequenceGateTests.cs +++ /dev/null @@ -1,175 +0,0 @@ -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// Covers — the inbound movement-event -/// staleness gate ported from retail (L.2g S1, deviation DEV-6): -/// -/// -/// CPhysicsObj::is_newer (0x00451ad0) — the wraparound u16 -/// timestamp compare (ACE PhysicsObj.is_newer confirms the -/// semantics; the BN pseudo-C return values are setcc-garbled). -/// The 0xF74C dispatch INSTANCE_TS gate -/// (ACSmartBox::DispatchSmartBoxEvent, -/// acclient_2013_pseudo_c.txt:357214-357239): stale incarnation → drop -/// BEFORE any movement stamp is touched. -/// CPhysics::SetObjectMovement (0x00509690, -/// acclient_2013_pseudo_c.txt:271370): MOVEMENT_TS accepts only strictly -/// newer sequences and stamps BEFORE the server-control gate; -/// SERVER_CONTROLLED_MOVE_TS drops when the stored stamp is strictly -/// newer than the incoming one (equal passes). -/// -/// -public class MotionSequenceGateTests -{ - // CPhysicsObj::is_newer(old, new): abs(new-old) > 0x7fff ? new < old : old < new. - [Theory] - [InlineData((ushort)0, (ushort)1, true)] // simple newer - [InlineData((ushort)1, (ushort)0, false)] // simple older - [InlineData((ushort)5, (ushort)5, false)] // equal is not newer - [InlineData((ushort)0, (ushort)0x7FFF, true)] // abs diff exactly 0x7fff → normal compare - [InlineData((ushort)0, (ushort)0x8000, false)] // abs diff 0x8000 → wraparound branch - [InlineData((ushort)0xFFFF, (ushort)0, true)] // 0 is newer than 0xFFFF (wrap) - [InlineData((ushort)0xFFFE, (ushort)2, true)] // newer across the seam - [InlineData((ushort)2, (ushort)0xFFFE, false)] // older across the seam - public void IsNewer_MatchesRetailWraparoundCompare(ushort oldStamp, ushort newStamp, bool expected) - { - Assert.Equal(expected, MotionSequenceGate.IsNewer(oldStamp, newStamp)); - } - - [Fact] - public void FirstEvent_WithFreshMovementSequence_IsAccepted() - { - var gate = new MotionSequenceGate(); - Assert.True(gate.TryAcceptMovementEvent(instanceSeq: 0, movementSeq: 1, serverControlSeq: 0)); - } - - [Fact] - public void DuplicateMovementSequence_IsDropped() - { - // Gate 1 accepts strictly-newer only — an identical movementSeq is a - // duplicate delivery, not a new command. - var gate = new MotionSequenceGate(); - Assert.True(gate.TryAcceptMovementEvent(0, 5, 0)); - Assert.False(gate.TryAcceptMovementEvent(0, 5, 0)); - } - - [Fact] - public void StaleMovementSequence_IsDropped_ThenNewerAccepted() - { - var gate = new MotionSequenceGate(); - Assert.True(gate.TryAcceptMovementEvent(0, 5, 0)); - Assert.False(gate.TryAcceptMovementEvent(0, 4, 0)); // reordered straggler - Assert.True(gate.TryAcceptMovementEvent(0, 6, 0)); - } - - [Fact] - public void MovementSequence_WrapsAroundTheU16Seam() - { - // Stamps live in u16 space and wrap; retail seeds them from the - // CreateObject PhysicsDesc timestamp block, so a gate near the seam - // must accept the post-wrap values as newer. - var gate = new MotionSequenceGate(); - gate.Seed(instanceSeq: 0, movementSeq: 0xFFFD, serverControlSeq: 0); - Assert.True(gate.TryAcceptMovementEvent(0, 0xFFFE, 0)); - Assert.True(gate.TryAcceptMovementEvent(0, 2, 0)); // 2 is newer than 0xFFFE by wrap rule - Assert.False(gate.TryAcceptMovementEvent(0, 0xFFFE, 0)); // and the old stamp is now stale - } - - [Fact] - public void SeededGate_AcceptsNextSequence_RejectsThePast() - { - // The exact case zero-init would break: spawning near a long-lived - // entity whose movement sequence is already past 0x8000. Retail - // seeds update_times from CreateObject's PhysicsDesc timestamps - // (ACE WorldObject_Networking.cs:411-420 writes all 9 in enum - // order), so the first UM after spawn is judged against the - // seeded stamp, not zero. - var gate = new MotionSequenceGate(); - gate.Seed(instanceSeq: 3, movementSeq: 0x9000, serverControlSeq: 2); - Assert.True(gate.TryAcceptMovementEvent(3, 0x9001, 2)); - Assert.False(gate.TryAcceptMovementEvent(3, 0x8FFF, 2)); // pre-spawn straggler - } - - [Fact] - public void Reseed_WithOlderStamps_DoesNotRegress() - { - // The #138 rehydrate path replays RETAINED CreateObject spawns - // through the normal spawn handler (GameWindow.RehydrateServerEntities - // ForLandblock) — re-seeding from that cached spawn must not roll the - // live stamps backward, or a post-rehydrate straggler UM would be - // accepted as fresh. First Seed adopts wholesale (fresh object); - // later Seeds only advance. - var gate = new MotionSequenceGate(); - gate.Seed(instanceSeq: 3, movementSeq: 0x9000, serverControlSeq: 2); - Assert.True(gate.TryAcceptMovementEvent(3, 0x9001, 2)); - - gate.Seed(instanceSeq: 3, movementSeq: 0x8000, serverControlSeq: 2); // stale replay - Assert.False(gate.TryAcceptMovementEvent(3, 0x9001, 2)); // still a duplicate - Assert.True(gate.TryAcceptMovementEvent(3, 0x9002, 2)); - - gate.Seed(instanceSeq: 4, movementSeq: 0x9010, serverControlSeq: 2); // genuine re-create - Assert.False(gate.TryAcceptMovementEvent(3, 0x9011, 2)); // old incarnation stale - Assert.True(gate.TryAcceptMovementEvent(4, 0x9011, 2)); - } - - [Fact] - public void StaleServerControl_Drops_ButMovementStampStillAdvances() - { - // Retail stamps MOVEMENT_TS BEFORE evaluating the server-control gate - // (0x00509690: update_times[1] written at 0x005096dd, the SC compare - // runs after) — a movement event dropped for stale server-control - // still consumes its movement sequence. - var gate = new MotionSequenceGate(); - Assert.True(gate.TryAcceptMovementEvent(0, 1, 5)); - Assert.False(gate.TryAcceptMovementEvent(0, 2, 4)); // sc=4 older than stored 5 → drop - Assert.False(gate.TryAcceptMovementEvent(0, 2, 5)); // movementSeq 2 was consumed by the drop - Assert.True(gate.TryAcceptMovementEvent(0, 3, 5)); // next movementSeq proceeds - } - - [Fact] - public void EqualServerControl_Passes() - { - // The SC gate drops only when the STORED stamp is strictly newer than - // the incoming one; equal means "same server-control era" and applies. - var gate = new MotionSequenceGate(); - Assert.True(gate.TryAcceptMovementEvent(0, 1, 7)); - Assert.True(gate.TryAcceptMovementEvent(0, 2, 7)); - } - - [Fact] - public void StaleInstance_Drops_WithoutConsumingMovementSequence() - { - // The INSTANCE_TS gate runs at dispatch level, BEFORE - // CPhysics::SetObjectMovement — a stale-incarnation event must not - // touch the movement stamps. - var gate = new MotionSequenceGate(); - Assert.True(gate.TryAcceptMovementEvent(2, 1, 0)); - Assert.False(gate.TryAcceptMovementEvent(1, 2, 0)); // stale incarnation - Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // seq 2 was NOT consumed by the instance drop - } - - [Fact] - public void NewerInstance_IsAdoptedAndApplied() - { - // DIVERGENCE (register row added with this port): retail queues the - // blob until the newer incarnation exists (SmartBox::QueueBlobForObject, - // dispatch return 4); acdream adopts the newer instance stamp and - // applies immediately. - var gate = new MotionSequenceGate(); - Assert.True(gate.TryAcceptMovementEvent(1, 1, 0)); - Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // newer incarnation adopted - Assert.False(gate.TryAcceptMovementEvent(1, 3, 0)); // old incarnation now stale - } - - [Fact] - public void InstanceCompare_WrapsAroundTheU16Seam() - { - var gate = new MotionSequenceGate(); - gate.Seed(instanceSeq: 0xFFFE, movementSeq: 0, serverControlSeq: 0); - Assert.True(gate.TryAcceptMovementEvent(0xFFFE, 1, 0)); - Assert.True(gate.TryAcceptMovementEvent(2, 2, 0)); // instance 2 newer than 0xFFFE by wrap - } -} diff --git a/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs b/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs deleted file mode 100644 index 7df939fc..00000000 --- a/tests/AcDream.Core.Tests/Physics/MotionVelocityPipelineTests.cs +++ /dev/null @@ -1,217 +0,0 @@ -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -// ───────────────────────────────────────────────────────────────────────────── -// MotionVelocityPipelineTests — Phase D6.2: the full raw→interpreted→velocity -// pipeline (apply_raw_movement 0x005287e0 → get_state_velocity 0x00527d50). -// -// These pin the retail-faithful LOCAL velocity for every direction — the exact -// behavior the D6.2 controller integration now routes through, replacing the -// hand-mirrored backward/strafe formulas (register TS-22). Golden values are -// derived from the pseudocode doc -// (docs/research/2026-07-01-d6-motion-interp-pseudocode.md) and the retail -// constants on MotionInterpreter. -// -// Notable retail-faithful change vs the old hand-mirror: strafe is now -// 1.25 × (0.5·WalkAnimSpeed/SidestepAnimSpeed) × runRate = ~1.56 × runRate, -// clamped via SideStepSpeed ≤ 3.0 (so |v.X| ≤ 3.75). The old code used -// 1.25 × runRate with no sidestep-scale and no clamp. -// ───────────────────────────────────────────────────────────────────────────── - -file sealed class FakeRunRateWeenie : IWeenieObject -{ - public float RunRate; - public bool InqRunRateResult = true; - public bool InqJumpVelocity(float extent, out float vz) { vz = 0f; return false; } - public bool InqRunRate(out float rate) { rate = RunRate; return InqRunRateResult; } - public bool CanJump(float extent) => true; -} - -public sealed class MotionVelocityPipelineTests -{ - private const float RunRate = 2.75f; // ≈ the +Acdream test char's run rate - - private const float WalkAnim = MotionInterpreter.WalkAnimSpeed; // 3.11999989 - private const float RunAnim = MotionInterpreter.RunAnimSpeed; // 4.0 - private const float SideAnim = MotionInterpreter.SidestepAnimSpeed; // 1.25 - private const float BackFac = MotionInterpreter.BackwardsFactor; // 0.649999976 - private const float MaxSide = MotionInterpreter.MaxSidestepAnimRate;// 3.0 - // adjust_motion sidestep scale: 0.5 * (WalkAnim / SideAnim) ≈ 1.24799995 - private const float SideScale = MotionInterpreter.SidestepFactor * (WalkAnim / SideAnim); - - private static MotionInterpreter MakeInterp(float runRate) - => new() { WeenieObj = new FakeRunRateWeenie { RunRate = runRate } }; - - private static RawMotionState Raw( - uint forward = MotionCommand.Ready, uint sidestep = 0u, uint turn = 0u, - HoldKey hold = HoldKey.None) - => new() - { - CurrentHoldKey = hold, - ForwardCommand = forward, - ForwardHoldKey = forward != MotionCommand.Ready ? hold : HoldKey.Invalid, - ForwardSpeed = 1.0f, - SidestepCommand = sidestep, - SidestepHoldKey = sidestep != 0u ? hold : HoldKey.Invalid, - SidestepSpeed = 1.0f, - TurnCommand = turn, - TurnHoldKey = turn != 0u ? hold : HoldKey.Invalid, - TurnSpeed = 1.0f, - }; - - // ── forward ────────────────────────────────────────────────────────────── - - [Fact] - public void RunForward_VelocityIsRunAnimSpeedTimesRunRate() - { - var interp = MakeInterp(RunRate); - interp.apply_raw_movement(Raw(forward: MotionCommand.WalkForward, hold: HoldKey.Run)); - - // WalkForward + Run → RunForward @ runRate; v.Y = RunAnim * runRate (== maxSpeed, no clamp). - var v = interp.get_state_velocity(); - Assert.Equal(RunAnim * RunRate, v.Y, 3); - Assert.Equal(0f, v.X, 5); - } - - [Fact] - public void WalkForward_NoRun_VelocityIsWalkAnimSpeed() - { - var interp = MakeInterp(RunRate); - interp.apply_raw_movement(Raw(forward: MotionCommand.WalkForward, hold: HoldKey.None)); - - var v = interp.get_state_velocity(); - Assert.Equal(WalkAnim * 1.0f, v.Y, 3); // no run scaling; WalkForward @ 1.0 - } - - // ── backward (the TS-22 fix: no longer zero) ───────────────────────────── - - [Fact] - public void RunBackward_VelocityIsNegativeWalkTimesBackwardFactorTimesRunRate() - { - var interp = MakeInterp(RunRate); - interp.apply_raw_movement(Raw(forward: MotionCommand.WalkBackward, hold: HoldKey.Run)); - - // WalkBackward → WalkForward, speed *= -0.65; Run → *= runRate (unconditional, - // promotion sign-gated so it stays WalkForward). v.Y = WalkAnim * (-0.65 * runRate). - var v = interp.get_state_velocity(); - Assert.Equal(-(WalkAnim * BackFac * RunRate), v.Y, 3); - Assert.True(v.Y < 0f, "backward velocity must be negative"); - // Matches the OLD hand-mirror (WalkAnim * 0.65 * runMul) — backward is unchanged. - } - - // ── strafe (retail-faithful magnitude + ±3.0 clamp) ────────────────────── - - [Fact] - public void RunStrafeRight_ClampsSideStepSpeedToThree_VelocityIs3p75() - { - var interp = MakeInterp(RunRate); // 1.248*2.75 = 3.432 > 3.0 → clamps - interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepRight, hold: HoldKey.Run)); - - var v = interp.get_state_velocity(); - Assert.Equal(SideAnim * MaxSide, v.X, 3); // 1.25 * 3.0 = 3.75 - Assert.Equal(3.75f, v.X, 3); - Assert.Equal(0f, v.Y, 5); - } - - [Fact] - public void RunStrafeRight_BelowClamp_VelocityIsScaledSidestep() - { - const float lowRun = 2.0f; // 1.248*2.0 = 2.496 < 3.0 → no clamp - var interp = MakeInterp(lowRun); - interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepRight, hold: HoldKey.Run)); - - var v = interp.get_state_velocity(); - // v.X = SideAnim * (SideScale * lowRun) = 1.56 * lowRun ≈ 3.12 - Assert.Equal(SideAnim * SideScale * lowRun, v.X, 3); - } - - [Fact] - public void RunStrafeLeft_NegatedAndClamped_VelocityIsNeg3p75() - { - var interp = MakeInterp(RunRate); - interp.apply_raw_movement(Raw(sidestep: MotionCommand.SideStepLeft, hold: HoldKey.Run)); - - // SideStepLeft → SideStepRight, negated → -1.248; *runRate = -3.432; clamp → -3.0. - var v = interp.get_state_velocity(); - Assert.Equal(-(SideAnim * MaxSide), v.X, 3); // -3.75 - Assert.True(v.X < 0f, "strafe-left velocity must be negative"); - } - - // ── turn (drives the local Yaw omega: base π/2 × TurnSpeed) ─────────────── - - [Fact] - public void RunTurnRight_InterpretedTurnSpeedIsPositiveRunTurnFactor() - { - var interp = MakeInterp(RunRate); - interp.apply_raw_movement(Raw(turn: MotionCommand.TurnRight, hold: HoldKey.Run)); - - Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand); - Assert.Equal(MotionInterpreter.RunTurnFactor, interp.InterpretedState.TurnSpeed, 5); // +1.5 - } - - [Fact] - public void RunTurnLeft_RemapsToTurnRightWithNegativeRunTurnFactor() - { - var interp = MakeInterp(RunRate); - interp.apply_raw_movement(Raw(turn: MotionCommand.TurnLeft, hold: HoldKey.Run)); - - // TurnLeft → TurnRight, speed *= -1; Run → *= 1.5 → -1.5. - Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand); - Assert.Equal(-MotionInterpreter.RunTurnFactor, interp.InterpretedState.TurnSpeed, 5); // -1.5 - } - - [Fact] - public void WalkTurn_NoRun_TurnSpeedIsUnity() - { - var interp = MakeInterp(RunRate); - interp.apply_raw_movement(Raw(turn: MotionCommand.TurnRight, hold: HoldKey.None)); - - Assert.Equal(MotionCommand.TurnRight, interp.InterpretedState.TurnCommand); - Assert.Equal(1.0f, interp.InterpretedState.TurnSpeed, 5); // no run factor - } - - // ── idle ───────────────────────────────────────────────────────────────── - - [Fact] - public void Idle_ReadyState_ZeroVelocity() - { - var interp = MakeInterp(RunRate); - interp.apply_raw_movement(Raw()); // Ready, no sidestep/turn - - var v = interp.get_state_velocity(); - Assert.Equal(0f, v.X, 5); - Assert.Equal(0f, v.Y, 5); - } - - // ── attack / non-locomotion forward command (#170) ─────────────────────── - - [Theory] - [InlineData(0x10000062u)] // AttackHigh1 - [InlineData(0x10000063u)] // AttackMed1 - [InlineData(0x10000064u)] // AttackLow1 - [InlineData(0x10000186u)] // AttackHigh4 (shifted late block) - public void AttackForwardCommand_ZeroVelocity(uint attackCommand) - { - // #170: an ATTACK forward command (action-class 0x1000006x/0x100001xx) - // is neither WalkForward (0x45000005) nor RunForward (0x44000007), so - // retail's get_state_velocity (0x00527d50) hits its `else → 0` branch — - // the creature plants its feet instead of coasting at the last run - // velocity. Cross-checked against holtburger grounded_local_velocity - // (`_ => Vector3::zero()`) and the retail decomp. This is the invariant - // the #170 glide fix relies on: GameWindow's remote dead-reckon now - // refreshes the body velocity from get_state_velocity each tick, so a - // creature that switches from a run-chase (ForwardCommand=RunForward) - // to an attack (ForwardCommand=0x1000006x) resolves to zero velocity - // and stops gliding. - var interp = MakeInterp(RunRate); - interp.InterpretedState.ForwardCommand = attackCommand; - interp.InterpretedState.ForwardSpeed = 0.97f; - - var v = interp.get_state_velocity(); - Assert.Equal(0f, v.X, 5); - Assert.Equal(0f, v.Y, 5); - Assert.Equal(0f, v.Z, 5); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs b/tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs deleted file mode 100644 index f824f73e..00000000 --- a/tests/AcDream.Core.Tests/Physics/ObstructionEtherealTests.cs +++ /dev/null @@ -1,512 +0,0 @@ -using System.Collections.Generic; -using System.Numerics; -using AcDream.Core.Physics; -using DatReaderWriter.Enums; -using DatReaderWriter.Types; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// Unit tests for the obstruction_ethereal mechanism ported verbatim -/// from retail in Task 3 of the collision-inclusion phase. -/// -/// -/// Retail oracle: CPhysicsObj::FindObjCollisions @ 0x0050f050 -/// (acclient_2013_pseudo_c.txt:276782): -/// -/// pc:276782 -- Gate 1: instant-skip requires BOTH ETHEREAL_PS (0x4) AND -/// IGNORE_COLLISIONS_PS (0x10). ETHEREAL alone does NOT instant-skip. -/// pc:276806 -- ETHEREAL-alone sets sphere_path.obstruction_ethereal = 1 -/// and continues to the shape dispatch. -/// pc:276989 -- After the per-object shape test, retail clears -/// obstruction_ethereal = 0. -/// -/// -/// -/// -/// Consume site 1: BSPTREE::find_collisions @ 0x0053a496 -/// (acclient_2013_pseudo_c.txt:323742): Path 1 fires when -/// insert_type == PLACEMENT_INSERT || obstruction_ethereal != 0, -/// routing to sphere_intersects_solid (passable-ethereal) instead -/// of the blocking paths. -/// -/// -/// -/// Consume site 2: CSphere::intersects_sphere @ 0x00537ae4 -/// (acclient_2013_pseudo_c.txt:321692): The ethereal branch performs -/// a proximity check only (no push-back, no COLLIDED return). All code -/// paths in the branch are void returns -- the player is fully passable. -/// -/// -/// -/// Consume site 3: CCylSphere::intersects_sphere @ 0x0053b4a0 -/// (acclient_2013_pseudo_c.txt:324573): Same pattern -- the ethereal -/// branch calls collides_with_sphere for an overlap check only, all -/// returns are void, no COLLIDED result -- the player is fully passable. -/// -/// -/// -/// Task 3 follow-up (2026-06-24): the Sphere and Cylinder consume sites -/// are now implemented in SphereCollision and CylinderCollision -/// in TransitionTypes.cs. The tests below verify: -/// -/// Ethereal-alone Cylinder target -> passable (no CollisionNormalValid). -/// Ethereal-alone Sphere target -> passable (no CollisionNormalValid). -/// Non-ethereal Cylinder target -> still blocks (regression guard). -/// Non-ethereal Sphere target -> still blocks (regression guard). -/// -/// -/// -/// -/// Layer-2 BSP override (D4+W1, 2026-06-24): retail -/// CPhysicsObj::FindObjCollisions pc:276961-276989 force-resets -/// a Collided result from BSP Path-1 to OK when the target is ETHEREAL and -/// NOT STATIC. Tests for this Layer-2 path cover the three sub-cases: -/// ethereal non-static (passable), ethereal static (still blocks), -/// and non-ethereal (still blocks -- regression guard). -/// -/// -/// -/// Retail divergence register: row AD-7 retired in the original Task 3 commit -- -/// the ETHEREAL-alone shim is replaced by the faithful port. -/// -/// -public class ObstructionEtherealTests -{ - private const uint ETHEREAL_PS = 0x4u; - private const uint IGNORE_COLLISIONS_PS = 0x10u; - - // Gate-1 tests: ShouldSkip dual-bit contract - - [Fact] - public void ShouldSkip_BothBits_InstantSkip() - { - // ETHEREAL | IGNORE_COLLISIONS together -> instant-skip (Gate 1 fires). - // Retail pc:276782: `if ((state & 4) AND (state & 0x10)) return 1`. - Assert.True(CollisionExemption.ShouldSkip( - targetState: ETHEREAL_PS | IGNORE_COLLISIONS_PS, - targetFlags: EntityCollisionFlags.None, - moverState: ObjectInfoState.IsPlayer)); - } - - [Fact] - public void ShouldSkip_EtherealAlone_NotInstantSkip() - { - // ETHEREAL alone (0x4) must NOT instant-skip -- retail takes the - // obstruction_ethereal branch instead. The old AD-7 shim is retired. - // Retail pc:276782: only the COMBINED gate returns early; ETHEREAL-alone - // falls through to set obstruction_ethereal and run the shape test. - Assert.False(CollisionExemption.ShouldSkip( - targetState: ETHEREAL_PS, - targetFlags: EntityCollisionFlags.None, - moverState: ObjectInfoState.IsPlayer)); - } - - [Fact] - public void ShouldSkip_IgnoreCollisionsAlone_NotSkipped() - { - // IGNORE_COLLISIONS alone (0x10) without ETHEREAL -> not instant-skipped; - // falls through to shape dispatch (no obstruction_ethereal set either). - Assert.False(CollisionExemption.ShouldSkip( - targetState: IGNORE_COLLISIONS_PS, - targetFlags: EntityCollisionFlags.None, - moverState: ObjectInfoState.IsPlayer)); - } - - // SpherePath.ObstructionEthereal field - - [Fact] - public void SpherePath_HasObstructionEtherealField() - { - // Compile-time check that the field was added to SpherePath. - var sp = new SpherePath(); - Assert.False(sp.ObstructionEthereal); // default is false - sp.ObstructionEthereal = true; - Assert.True(sp.ObstructionEthereal); - } - - // Cylinder shape: ethereal-alone -> passable - // - // Retail oracle: CCylSphere::intersects_sphere @ 0x0053b4a0 (pc:324573). - // When obstruction_ethereal != 0, all code paths return void (no COLLIDED). - // acdream mirrors this via `if (sp.ObstructionEthereal) return OK` at the - // top of CylinderCollision, before any overlap/slide logic. - - [Fact] - public void CylinderEtherealAlone_IsPassable() - { - // A Cylinder-type shadow with state=ETHEREAL_PS (0x4, no 0x10). - // Player sphere sweeps directly into it. Expect NO collision response - // (CollisionNormalValid must stay false throughout all 20 ticks). - // Retail ref: CCylSphere::intersects_sphere pc:324573 -- ethereal branch - // does collides_with_sphere checks only (void return, never COLLIDED). - var engine = BuildEngineWithSingleShadow( - collisionType: ShadowCollisionType.Cylinder, - objectState: ETHEREAL_PS, - objectPos: new Vector3(12f, 12f, 0f), - radius: 0.3f, - cylHeight: 1.5f); - - // Approach dead-center from -Y, sweeping +Y through the cylinder. - var start = new Vector3(12f, 11f, 0.48f); - var perTick = new Vector3(0f, 0.10f, 0f); - - var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20); - - Assert.False(blocked, - $"Ethereal-alone Cylinder must be passable (no collision). " + - $"Sphere stopped at ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})."); - } - - [Fact] - public void CylinderNonEthereal_StillBlocks() - { - // Control: same Cylinder, state=0 (no ETHEREAL). Must still block. - // This guards the regression that the ethereal gate does NOT affect - // normal (non-ethereal) Cylinder objects. - var engine = BuildEngineWithSingleShadow( - collisionType: ShadowCollisionType.Cylinder, - objectState: 0u, // no ethereal bit - objectPos: new Vector3(12f, 12f, 0f), - radius: 0.3f, - cylHeight: 1.5f); - - var start = new Vector3(12f, 11f, 0.48f); - var perTick = new Vector3(0f, 0.10f, 0f); - - var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20); - - Assert.True(blocked, - $"Non-ethereal Cylinder must still block. " + - $"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after 20 ticks."); - } - - // Sphere shape: ethereal-alone -> passable - // - // Retail oracle: CSphere::intersects_sphere @ 0x00537ae4 (pc:321692). - // When obstruction_ethereal != 0, all code paths return void (no COLLIDED). - // acdream mirrors this via `if (sp.ObstructionEthereal) return OK` at the - // top of SphereCollision. - - [Fact] - public void SphereEtherealAlone_IsPassable() - { - // A Sphere-type shadow with state=ETHEREAL_PS (0x4, no 0x10). - // Player sphere sweeps directly into it. Expect NO collision response. - // Retail ref: CSphere::intersects_sphere pc:321692 -- ethereal branch - // does a proximity check only (void return, never COLLIDED / Slid). - var engine = BuildEngineWithSingleShadow( - collisionType: ShadowCollisionType.Sphere, - objectState: ETHEREAL_PS, - objectPos: new Vector3(12f, 12f, 0.48f), - radius: 0.4f, - cylHeight: 0f); - - var start = new Vector3(12f, 11f, 0.48f); - var perTick = new Vector3(0f, 0.10f, 0f); - - var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20); - - Assert.False(blocked, - $"Ethereal-alone Sphere must be passable (no collision). " + - $"Sphere stopped at ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})."); - } - - [Fact] - public void SphereNonEthereal_StillBlocks() - { - // Control: same Sphere, state=0 (no ETHEREAL). Must still block. - var engine = BuildEngineWithSingleShadow( - collisionType: ShadowCollisionType.Sphere, - objectState: 0u, // no ethereal bit - objectPos: new Vector3(12f, 12f, 0.48f), - radius: 0.4f, - cylHeight: 0f); - - var start = new Vector3(12f, 11f, 0.48f); - var perTick = new Vector3(0f, 0.10f, 0f); - - var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 20); - - Assert.True(blocked, - $"Non-ethereal Sphere must still block. " + - $"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3}) after 20 ticks."); - } - - // Layer-2 BSP override -- retail CPhysicsObj::FindObjCollisions pc:276961-276989 - // - // After BSP Path-1 (sphere_intersects_solid) returns Collided for a thin - // ethereal slab, retail's Layer-2 force-resets the result to OK for any - // NON-STATIC ethereal target (pc:276963-276977). - // - // Synthetic BSP fixture: a 1x2 m vertical wall polygon (normal=-Y) at - // world y=12, blocking the player approaching from y<12. The leaf has - // Solid=0 (default private-set value), so the collision comes from - // HitsSphere in SphereIntersectsSolidInternal -- exactly the "thin slab - // polygon contact" scenario that Layer-2 was designed to rescue. - // - // Sub-cases: - // A. Ethereal non-static BSP wall -> passable (Layer-2 fires, pc:276973). - // B. Ethereal STATIC BSP wall -> still blocks (STATIC_PS=0x1 suppresses - // Layer-2 per pc:276969: `if ((this->state & 1) == 0)`). - // C. Non-ethereal BSP wall -> still blocks (ObstructionEthereal=false; - // Layer-2 condition false -- regression guard). - - private const uint Layer2LandblockId = 0xA9B50000u; - private const uint Layer2CellId = Layer2LandblockId | 0x0001u; - private const uint Layer2EntityId = 0xF4300u; - private const uint Layer2GfxObjId = 0xF4301u; - private const ushort WallPolyId = 1; - - /// - /// Build a PhysicsEngine containing a synthetic BSP slab (vertical wall - /// polygon at y=12) registered with the given . - /// - /// - /// The wall polygon's normal is -Y (faces the player approaching from y less than 12). - /// The leaf has Solid=0 (the default private-set value), so the only - /// collision path is HitsSphere in SphereIntersectsSolidInternal - /// -- the "thin slab polygon contact" case requiring Layer-2. - /// - /// - private static PhysicsEngine BuildEngineWithBspSlab(uint objectState) - { - var heights = new byte[81]; - var heightTable = new float[256]; - for (int i = 0; i < 256; i++) heightTable[i] = -1000f; - - var cache = new PhysicsDataCache(); - var engine = new PhysicsEngine { DataCache = cache }; - engine.AddLandblock( - landblockId: Layer2LandblockId, - terrain: new TerrainSurface(heights, heightTable), - cells: System.Array.Empty(), - portals: System.Array.Empty(), - worldOffsetX: 0f, - worldOffsetY: 0f); - - // Vertical wall polygon in OBJECT-LOCAL space (object is at world (12,12,0)). - // Wall at local y=0, x in [-1,1], z in [-2,5]. Normal = -Y (faces the - // approaching player who is at world y<12 = local y<0). - // The player sphere is transformed to local space before BSP query: - // localOrigin = globalSphereOrigin - objectWorldPos = (12,y,0.48)-(12,12,0) - // = (0, y-12, 0.48). Starts at y-12=-1, sweeps toward 0. - var wallVerts = new[] - { - new Vector3(-1f, 0f, -2f), - new Vector3( 1f, 0f, -2f), - new Vector3( 1f, 0f, 5f), - new Vector3(-1f, 0f, 5f), - }; - var wallNormal = new Vector3(0f, -1f, 0f); - float dVal = -Vector3.Dot(wallNormal, wallVerts[0]); // = 0 - var wallPoly = new ResolvedPolygon - { - Vertices = wallVerts, - Plane = new System.Numerics.Plane(wallNormal, dVal), - NumPoints = 4, - SidesType = CullMode.None, - }; - - // Single leaf node, Solid=0 (private set -- default value = 0). - // This means SphereIntersectsSolidInternal won't short-circuit at the - // solid-leaf check; collision comes from HitsSphere on wallPoly. - // Bounding sphere in LOCAL space: covers x[-1,1], y=0, z[-2,5]. - var leaf = new PhysicsBSPNode - { - Type = BSPNodeType.Leaf, - BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 6f }, - }; - leaf.Polygons.Add(WallPolyId); - - var bspTree = new PhysicsBSPTree { Root = leaf }; - var physics = new GfxObjPhysics - { - BSP = bspTree, - PhysicsPolygons = new Dictionary(), - Vertices = new VertexArray(), - Resolved = new Dictionary { [WallPolyId] = wallPoly }, - BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 6f }, - }; - cache.RegisterGfxObjForTest(Layer2GfxObjId, physics); - - engine.ShadowObjects.Register( - entityId: Layer2EntityId, - gfxObjId: Layer2GfxObjId, - worldPos: new Vector3(12f, 12f, 0f), - rotation: System.Numerics.Quaternion.Identity, - radius: 10f, - worldOffsetX: 0f, - worldOffsetY: 0f, - landblockId: Layer2LandblockId, - collisionType: ShadowCollisionType.BSP, - cylHeight: 0f, - scale: 1.0f, - state: objectState, - flags: EntityCollisionFlags.None); - - return engine; - } - - [Fact] - public void BspEtherealNonStatic_Layer2_IsPassable() - { - // Retail pc:276961-276989 Layer-2 gate: ETHEREAL (0x4) + NOT STATIC (no 0x1) - // -> force result = OK regardless of what BSP Path-1 returned. - // The synthetic wall polygon causes HitsSphere=true -> Path-1 Collided; - // Layer-2 must rescue it so the player passes through. - // Retail gate (pc:276969): `if ((this->state & 1) == 0)` -- non-static only. - const uint EtherealNonStatic = 0x4u; // ETHEREAL_PS, no STATIC_PS - var engine = BuildEngineWithBspSlab(EtherealNonStatic); - - var start = new Vector3(12f, 11f, 0.48f); - var perTick = new Vector3(0f, 0.10f, 0f); - var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 30, - startCellId: Layer2CellId); - - Assert.False(blocked, - $"Ethereal non-static BSP slab must be passable via Layer-2 override " + - $"(retail pc:276973). Sphere stopped at " + - $"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})."); - } - - [Fact] - public void BspEtherealStatic_Layer2Suppressed_StillBlocks() - { - // Retail pc:276969 gate: STATIC_PS (0x1) set -> Layer-2 does NOT fire. - // Even though the target is ETHEREAL (0x4), the STATIC bit keeps the - // Collided result intact. Static ethereal geometry (env shell nodes) - // should still block per retail's `if ((this->state & 1) == 0)` check. - const uint EtherealStatic = 0x4u | 0x1u; // ETHEREAL_PS | STATIC_PS - var engine = BuildEngineWithBspSlab(EtherealStatic); - - var start = new Vector3(12f, 11f, 0.48f); - var perTick = new Vector3(0f, 0.10f, 0f); - var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 30, - startCellId: Layer2CellId); - - Assert.True(blocked, - $"Ethereal STATIC BSP slab must still block (Layer-2 suppressed by STATIC_PS=0x1). " + - $"Sphere reached ({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})."); - } - - [Fact] - public void BspNonEthereal_NotPassable_RegressionGuard() - { - // Control: non-ethereal BSP wall (state=0) must block as normal. - // sp.ObstructionEthereal is false -> Layer-2 condition is false -> - // the Collided result from BSP Path-1 is not reset. Regression guard - // that the Layer-2 insert does NOT affect ordinary walls/doors. - const uint NonEthereal = 0x0u; - var engine = BuildEngineWithBspSlab(NonEthereal); - - var start = new Vector3(12f, 11f, 0.48f); - var perTick = new Vector3(0f, 0.10f, 0f); - var (blocked, finalPos, _) = SweepUntilBlocked(engine, start, perTick, maxTicks: 30, - startCellId: Layer2CellId); - - Assert.True(blocked, - $"Non-ethereal BSP slab must still block (Layer-2 does not apply when " + - $"sp.ObstructionEthereal=false). Sphere reached " + - $"({finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})."); - } - - // Helpers - - private const uint TestLandblockId = 0xA9B40000u; - private const uint TestCellId = TestLandblockId | 0x0001u; - private const uint TestEntityId = 0xF4201u; - private const uint TestGfxObjId = 0xF4202u; - - private const float SphereRadius = 0.48f; - private const float SphereHeight = 1.20f; - private const float StepUpHeight = 0.60f; - private const float StepDownHeight = 0.04f; - - /// - /// Build a minimal PhysicsEngine with a synthetic flat landblock - /// and a single registered shadow entry at - /// with the given collision type and state bits. - /// No dat required -- Cylinder and Sphere shapes need no GfxObj/BSP. - /// - private static PhysicsEngine BuildEngineWithSingleShadow( - ShadowCollisionType collisionType, - uint objectState, - Vector3 objectPos, - float radius, - float cylHeight) - { - var cache = new PhysicsDataCache(); - var engine = new PhysicsEngine { DataCache = cache }; - - // Stub landblock: flat terrain far below so it never interferes. - var heights = new byte[81]; - var heightTable = new float[256]; - for (int i = 0; i < 256; i++) heightTable[i] = -1000f; - engine.AddLandblock( - landblockId: TestLandblockId, - terrain: new TerrainSurface(heights, heightTable), - cells: System.Array.Empty(), - portals: System.Array.Empty(), - worldOffsetX: 0f, - worldOffsetY: 0f); - - // Register the target shadow. Cylinder/Sphere shapes don't need a - // GfxObj BSP in the cache -- FindObjCollisions only queries the cache - // for BSP collision types. - engine.ShadowObjects.Register( - entityId: TestEntityId, - gfxObjId: TestGfxObjId, - worldPos: objectPos, - rotation: System.Numerics.Quaternion.Identity, - radius: radius, - worldOffsetX: 0f, - worldOffsetY: 0f, - landblockId: TestLandblockId, - collisionType: collisionType, - cylHeight: cylHeight, - scale: 1.0f, - state: objectState, - flags: EntityCollisionFlags.None); - - return engine; - } - - /// - /// Sweep a player sphere by each step until - /// CollisionNormalValid fires or elapse. - /// Accepts an optional for tests that use a - /// different landblock than the default . - /// - private static (bool blocked, Vector3 finalPos, Vector3 normal) - SweepUntilBlocked(PhysicsEngine engine, Vector3 start, Vector3 perTick, int maxTicks, - uint startCellId = 0) - { - Vector3 pos = start; - uint cellId = startCellId != 0 ? startCellId : TestCellId; - bool onGround = false; - - for (int tick = 0; tick < maxTicks; tick++) - { - Vector3 target = pos + perTick; - var result = engine.ResolveWithTransition( - pos, target, cellId, - SphereRadius, SphereHeight, - StepUpHeight, StepDownHeight, - onGround, - body: null, - moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, - movingEntityId: 0); - - if (result.CollisionNormalValid) - return (true, result.Position, result.CollisionNormal); - - pos = result.Position; - cellId = result.CellId; - onGround = result.IsOnGround; - } - - return (false, pos, Vector3.Zero); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs deleted file mode 100644 index fed0ca92..00000000 --- a/tests/AcDream.Core.Tests/Physics/PhysicsBodyCellSyncTests.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -// #145 Slice 2a — PhysicsBody carries a cell-relative CellPosition that rides alongside -// the world Vector3 Position. The wire's local is LANDBLOCK-relative [0,192); the cell id -// low word (which 24 m cell) = floor(local/24), so a consistent pair has -// cell.lowword == cellIndex(local). Placement canonicalizes the outdoor seed (retail -// SetPositionInternal/adjust_to_outside); motion mirrors the world delta into the local -// and re-derives the cell every tick via AdjustToOutside. -public class PhysicsBodyCellSyncTests -{ - [Fact] - public void SnapToCell_ConsistentOutdoorPair_SeedsAsGiven() - { - var body = new PhysicsBody(); - // cell 0x23 = (lx=4, ly=2); local (100,50) is in that cell (floor(100/24)=4, floor(50/24)=2). - body.SnapToCell(0xC95B0023u, worldPos: new Vector3(1000f, 2000f, 12f), cellLocal: new Vector3(100f, 50f, 12f)); - Assert.Equal(0xC95B0023u, body.CellPosition.ObjCellId); - Assert.Equal(new Vector3(100f, 50f, 12f), body.CellPosition.Frame.Origin); - Assert.Equal(new Vector3(1000f, 2000f, 12f), body.Position); - } - - [Fact] - public void SnapToCell_InconsistentOutdoorPair_CanonicalizesCellFromLocal() - { - // #107 protection: retail re-derives the cell from the position on placement. - // (cell 0x01, local in cell 0x23) is fixed to 0x23. - var body = new PhysicsBody(); - body.SnapToCell(0xC95B0001u, new Vector3(1000f, 2000f, 12f), new Vector3(100f, 50f, 12f)); - Assert.Equal(0xC95B0023u, body.CellPosition.ObjCellId); - Assert.Equal(new Vector3(100f, 50f, 12f), body.CellPosition.Frame.Origin); - } - - [Fact] - public void PositionDelta_WithinLandblock_UpdatesCellIndex() - { - var body = new PhysicsBody(); - body.SnapToCell(0xC95B0023u, new Vector3(1000f, 2000f, 12f), new Vector3(100f, 50f, 12f)); - body.Position += new Vector3(0f, 24f, 0f); // local (100,74) → cell (4,3) - Assert.Equal(new Vector3(100f, 74f, 12f), body.CellPosition.Frame.Origin); - // low word for (lx=4, ly=3): (ly&7) + ((lx&7)<<3) + 1 = 3 + 32 + 1 = 0x24 - Assert.Equal(0xC95B0024u, body.CellPosition.ObjCellId); - Assert.Equal(new Vector3(1000f, 2024f, 12f), body.Position); // world still authoritative + moved - } - - [Fact] - public void PositionDelta_AcrossSouthLandblockEdge_BumpsCellAndRewraps() - { - var body = new PhysicsBody(); - body.SnapToCell(0xC95B0001u, new Vector3(1000f, 2000f, 12f), new Vector3(100f, 0.3f, 12f)); - body.Position += new Vector3(0f, -1f, 0f); // local Y → -0.7 → south neighbour, ~191.3 - int lbY = (int)((body.CellPosition.ObjCellId >> 16) & 0xFFu); - Assert.Equal(0x5A, lbY); // bumped to south neighbour 0xC95A - Assert.InRange(body.CellPosition.Frame.Origin.Y, 190f, 192f); // re-wrapped near top of southern block - } - - [Fact] - public void PositionDelta_WithoutSeed_LeavesCellPositionDefault() - { - // A body never SnapToCell'd (e.g. a remote entity) gets no synthesized cell. - var body = new PhysicsBody(); - body.Position += new Vector3(10f, 10f, 0f); - Assert.Equal(0u, body.CellPosition.ObjCellId); - } - - [Fact] - public void ContinuousTracking_LongWalkAcrossCellsAndBlock_NeverGoesStale() - { - // Refutes the "CellPosition goes stale" concern (incl. the indoor-transit case): - // because the Position setter re-derives the cell via AdjustToOutside on EVERY - // write, CellPosition continuously tracks the outdoor cell under the body's WORLD - // position. The body is always world-space, so the world delta is frame-invariant — - // there is no separate "interior" frame to desync. On any exit it is the correct - // cell, never stale. - var body = new PhysicsBody(); - body.SnapToCell(0xC95B0001u, new Vector3(1000f, 2000f, 12f), new Vector3(10f, 10f, 12f)); - - // A winding walk: east within the block, then north across the 192 m boundary. - foreach (var step in new[] - { - new Vector3(50f, 0f, 0f), - new Vector3(50f, 0f, 0f), - new Vector3(0f, 100f, 0f), - new Vector3(0f, 100f, 0f), // local Y 110 → 210, crosses north into 0xC95C - }) - body.Position += step; - - // local accum (10,10)+(100,200) = (110,210); Y 210 wraps to the north neighbour - // 0xC95C (lbY 0x5B → 0x5C), local Y = 210−192 = 18; cell (lx=4, ly=0) → low 0x21. - Assert.Equal(0xC95C0021u, body.CellPosition.ObjCellId); - Assert.Equal(new Vector3(110f, 18f, 12f), body.CellPosition.Frame.Origin); - - // And the carried (cell, local) is already canonical — re-deriving does not march. - uint cell = body.CellPosition.ObjCellId; - var local = body.CellPosition.Frame.Origin; - Assert.True(LandDefs.AdjustToOutside(ref cell, ref local)); - Assert.Equal(body.CellPosition.ObjCellId, cell); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsDataCachePhantomSourceTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsDataCachePhantomSourceTests.cs new file mode 100644 index 00000000..3f3c55c1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PhysicsDataCachePhantomSourceTests.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Issue #101 (2026-05-25) — phantom-stair fix. Retail's +/// CPartArray::InitParts emits collision shapes only from +/// Setup-level CylSpheres/Spheres or per-Part +/// PhysicsBSP. There is NO synthesis from visual mesh AABB. +/// Acdream's mesh-aabb-fallback path at +/// GameWindow.cs:6127 previously fired for ANY entity that +/// reached it with entityBsp==0 && entityCyl==0, +/// including GfxObj-only stabs whose GfxObj has +/// HasPhysics=False. This produced the 10 phantom 0.80 m +/// cylinders that block the Holtburg upper-floor staircase (see +/// docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md). +/// +/// +/// captures the +/// retail rule as a predicate: "the entity's source is a GfxObj +/// (high byte 0x01) AND the cache has no +/// entry for it." When this returns true, the caller suppresses the +/// fallback synthesis. +/// +/// +public class PhysicsDataCachePhantomSourceTests +{ + [Fact] + public void IsPhantomGfxObjSource_SetupHighByte_ReturnsFalse() + { + // Setup source (high byte 0x02) is never the GfxObj-phantom case. + // The existing isPhantomSetup check at GameWindow.cs:6090 handles + // the Setup-side phantom. This predicate is scoped to GfxObj only. + var cache = new PhysicsDataCache(); + Assert.False(cache.IsPhantomGfxObjSource(0x020019FFu)); // door setup + Assert.False(cache.IsPhantomGfxObjSource(0x02000266u)); // some setup + } + + [Fact] + public void IsPhantomGfxObjSource_GfxObjUncached_ReturnsTrue() + { + // GfxObj source (high byte 0x01) with NO cached GfxObjPhysics + // = the phantom case. The stair-step GfxObj 0x0100081A from + // issue #101's broken-stairs capture has HasPhysics=False and + // does not enter the cache. Acdream should treat it as phantom. + var cache = new PhysicsDataCache(); + Assert.True(cache.IsPhantomGfxObjSource(0x0100081Au)); + } + + [Fact] + public void IsPhantomGfxObjSource_GfxObjCached_ReturnsFalse() + { + // GfxObj source (high byte 0x01) WITH a cached GfxObjPhysics + // (i.e. the GfxObj's HasPhysics flag was set and its PhysicsBSP + // root is non-null) is NOT phantom. The staircase BSP entity + // 0x40B50089 from issue #101's capture, backed by GfxObj + // 0x01000C16 with hasPhys=True, is this case. + var cache = new PhysicsDataCache(); + var leaf = new PhysicsBSPNode { Type = BSPNodeType.Leaf }; + var fakePhysics = new GfxObjPhysics + { + BSP = new PhysicsBSPTree { Root = leaf }, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = new Dictionary(), + }; + cache.RegisterGfxObjForTest(0x01000C16u, fakePhysics); + Assert.False(cache.IsPhantomGfxObjSource(0x01000C16u)); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs deleted file mode 100644 index 3cb17aad..00000000 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineResidencyTests.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -public class PhysicsEngineResidencyTests -{ - [Fact] - public void IsLandblockTerrainResident_trueOnlyAfterAddLandblock() - { - var eng = new PhysicsEngine(); - uint canonical = 0xA9B4FFFFu; // canonical (streaming) form - uint lbId = canonical & 0xFFFF0000u; // AddLandblock id - - Assert.False(eng.IsLandblockTerrainResident(canonical)); - - eng.AddLandblock(lbId, terrain: null!, cells: Array.Empty(), - portals: Array.Empty(), worldOffsetX: 0f, worldOffsetY: 0f); - - Assert.True(eng.IsLandblockTerrainResident(canonical)); - } - - [Fact] - public void IsLandblockTerrainResident_matchesOnHigh16_acceptsCellResolvedId() - { - var eng = new PhysicsEngine(); - eng.AddLandblock(0xA9B40000u, null!, Array.Empty(), - Array.Empty(), 0f, 0f); - Assert.True(eng.IsLandblockTerrainResident(0xA9B40019u)); // cell-resolved id, same LB - Assert.False(eng.IsLandblockTerrainResident(0xC6A90019u)); // different LB - } -} diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index b61d4329..f6850e6e 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -459,13 +459,7 @@ public class PhysicsEngineTests // FindObjCollisions early-returns when DataCache is null. An empty // cache is enough for cylinder objects; only BSP objects look up // entries inside. - // #145 D: register terrain for 0xA9B4 at (0,0,0) so TryGetTerrainOrigin - // succeeds for the outdoor seed cell (0xA9B40039). In production the - // streaming-center landblock is always resident before outdoor resolves run; - // we replicate that invariant here by registering a flat dummy terrain. - var freshCache = new PhysicsDataCache(); - freshCache.CellGraph.RegisterTerrain(0xA9B4FFFFu, new TerrainSurface(FlatHeightmap(50), LinearHeightTable()), Vector3.Zero); - engine.DataCache = freshCache; + engine.DataCache = new PhysicsDataCache(); const uint movingEntityId = 0xDEADBEEFu; var bodyPos = new Vector3(96f, 96f, 50f); @@ -487,20 +481,9 @@ public class PhysicsEngineTests collisionType: ShadowCollisionType.Cylinder, cylHeight: 1.835f); - // Without the gate (movingEntityId == 0): the sweep must be - // INTERFERED WITH by the self-entry. This proves the registry - // actually causes a collision, so the following filtered case is not - // a vacuous pass. - // - // Observable updated for the 2026-07-05 CCylSphere family port: the - // old hand-rolled response radial-pushed the sphere ~1 m sideways - // (the original #42 symptom this test asserted). Retail's dispatcher - // (0x0053b440) resolves this geometry — airborne, dead-center on the - // cylinder axis, moving up — through land_on_cylinder → the Collide - // re-test, whose interp gate hard-stops (COLLIDED); ValidateTransition - // then reverts to a stay-put (no sideways teleport, Ok=true). The - // response-model-independent interference signal is the DENIED +Z - // movement: the sweep must NOT reach the +0.022 target. + // Without the gate (movingEntityId == 0): the sweep must self-push. + // This proves the registry actually causes a collision, so the + // following filtered case is not a vacuous pass. var unfiltered = engine.ResolveWithTransition( currentPos: bodyPos, targetPos: targetPos, cellId: 0xA9B40039u, @@ -509,11 +492,11 @@ public class PhysicsEngineTests isOnGround: false, movingEntityId: 0u); - Assert.True(unfiltered.Position.Z < targetPos.Z - 0.01f, - $"Without movingEntityId, the sweep must collide with the mover's own " + - $"ShadowEntry and deny the +Z movement (retail: land_on_cylinder → " + - $"Collide re-test → COLLIDED → stay-put). Got Z={unfiltered.Position.Z:F4}, " + - $"target Z={targetPos.Z:F4}"); + float unfilteredXY = MathF.Sqrt( + (unfiltered.Position.X - targetPos.X) * (unfiltered.Position.X - targetPos.X) + + (unfiltered.Position.Y - targetPos.Y) * (unfiltered.Position.Y - targetPos.Y)); + Assert.True(unfilteredXY > 0.5f, + $"Without movingEntityId, sweep should self-push (got XY drift {unfilteredXY:F3}m)"); // With the gate: the sweep must leave XY unchanged. var filtered = engine.ResolveWithTransition( diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMotionCombinerTests.cs b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs similarity index 97% rename from tests/AcDream.Core.Tests/Physics/RemoteMotionCombinerTests.cs rename to tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs index e6d4300f..2920d65c 100644 --- a/tests/AcDream.Core.Tests/Physics/RemoteMotionCombinerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PositionManagerTests.cs @@ -6,19 +6,18 @@ using Xunit; namespace AcDream.Core.Tests.Physics; // ───────────────────────────────────────────────────────────────────────────── -// RemoteMotionCombinerTests — 6 tests covering ComputeOffset (class renamed R5 -// from PositionManager; see RemoteMotionCombiner's class doc). +// PositionManagerTests — 6 tests covering ComputeOffset. // // Mirrors retail CPhysicsObj::UpdateObjectInternal (acclient @ 0x00513730). // Pure-function combiner: animation root motion (seqVel × dt, rotated by // body orientation) + InterpolationManager.AdjustOffset correction. // ───────────────────────────────────────────────────────────────────────────── -public sealed class RemoteMotionCombinerTests +public sealed class PositionManagerTests { // ── helpers ─────────────────────────────────────────────────────────────── - private static RemoteMotionCombiner Make() => new(); + private static PositionManager Make() => new(); private static InterpolationManager EmptyInterp() => new(); diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs new file mode 100644 index 00000000..659452a6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs @@ -0,0 +1,330 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Phase L.1c (2026-04-28). Covers — the +/// per-tick steering port of retail +/// MoveToManager::HandleMoveToPosition for server-controlled remote +/// creatures. +/// +public class RemoteMoveToDriverTests +{ + private const float Epsilon = 1e-3f; + + private static float Yaw(Quaternion q) + { + var fwd = Vector3.Transform(new Vector3(0, 1, 0), q); + return MathF.Atan2(-fwd.X, fwd.Y); + } + + [Fact] + public void Drive_AlreadyAtTarget_ReportsArrived() + { + var bodyPos = new Vector3(10f, 20f, 0f); + var bodyRot = Quaternion.Identity; + var dest = new Vector3(10f, 20.3f, 0f); + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0.5f, distanceToObject: 0.6f, + dt: 0.016f, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); + Assert.Equal(bodyRot, newOrient); // orientation untouched + } + + [Fact] + public void Drive_AceMeleePacket_UsesDistanceToObjectAsArrival() + { + // ACE chase packet: MinDistance=0, DistanceToObject=0.6 (melee). + // Body at 0.5m from target should ARRIVE — not keep oscillating + // around the target the way it did pre-fix when only MinDistance + // was the gate. This is the "monster keeps running in different + // directions when it should be attacking" regression fix. + var bodyPos = new Vector3(0f, 0f, 0f); + var bodyRot = Quaternion.Identity; + var dest = new Vector3(0f, 0.5f, 0f); + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0f, distanceToObject: 0.6f, + dt: 0.016f, moveTowards: true, + out _); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); + } + + [Fact] + public void Drive_FleeArrival_UsesMinDistance() + { + // Flee branch (moveTowards=false): arrival when dist >= MinDistance. + // Retail / ACE both use MinDistance for the flee-arrival threshold. + var bodyPos = new Vector3(0f, 0f, 0f); + var bodyRot = Quaternion.Identity; + var dest = new Vector3(0f, 6f, 0f); + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 5.0f, distanceToObject: 0.6f, + dt: 0.016f, moveTowards: false, + out _); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); + } + + [Fact] + public void Drive_ChaseDoesNotArriveAtMinDistanceFloor() + { + // Regression: my earlier max(MinDistance, DistanceToObject) port + // would have arrived here because dist (1.5) <= MinDistance (2.0). + // Retail uses DistanceToObject for chase arrival, so a chase at + // dist=1.5 with DistanceToObject=0.6 should still STEER, not arrive. + var bodyPos = new Vector3(0f, 0f, 0f); + var bodyRot = Quaternion.Identity; + var dest = new Vector3(0f, 1.5f, 0f); + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 2.0f, distanceToObject: 0.6f, + dt: 0.016f, moveTowards: true, + out _); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); + } + + [Fact] + public void Drive_ChasingButNotInRange_ReportsSteering() + { + var bodyPos = new Vector3(0f, 0f, 0f); + var bodyRot = Quaternion.Identity; // facing +Y + var dest = new Vector3(0f, 50f, 0f); // straight ahead + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0f, distanceToObject: 0f, + dt: 0.016f, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); + // Already facing target → snap branch keeps yaw at 0. + Assert.InRange(Yaw(newOrient), -Epsilon, Epsilon); + } + + [Fact] + public void Drive_TargetSlightlyOffAxis_SnapsWithinTolerance() + { + // Body facing +Y; target at (1, 10, 0) — that's a small angle + // (about 5.7°), well within the 20° snap tolerance. + var bodyPos = Vector3.Zero; + var bodyRot = Quaternion.Identity; + var dest = new Vector3(1f, 10f, 0f); + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0f, distanceToObject: 0f, + dt: 0.016f, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); + // Snap should land us pointing at (1, 10): yaw = atan2(-1, 10) ≈ -0.0997 rad. + float expectedYaw = MathF.Atan2(-1f, 10f); + Assert.InRange(Yaw(newOrient), expectedYaw - Epsilon, expectedYaw + Epsilon); + + // Verify orientation actually transforms +Y onto the (1,10) line. + var worldFwd = Vector3.Transform(new Vector3(0, 1, 0), newOrient); + Assert.InRange(worldFwd.X / worldFwd.Y, 0.1f - 1e-3f, 0.1f + 1e-3f); + } + + [Fact] + public void Drive_TargetBeyondTolerance_RotatesByLimitedStep() + { + // Body facing +Y; target at (-10, 0) — that's 90° to the left + // (well beyond the 20° snap tolerance), so we turn by at most + // TurnRateRadPerSec * dt this tick rather than snapping. + var bodyPos = Vector3.Zero; + var bodyRot = Quaternion.Identity; // yaw = 0 + var dest = new Vector3(-10f, 0f, 0f); // yaw = +π/2 (left) + const float dt = 0.1f; + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0f, distanceToObject: 0f, + dt: dt, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); + float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt; + // We should turn LEFT (positive yaw) toward the target. + Assert.InRange(Yaw(newOrient), expectedStep - Epsilon, expectedStep + Epsilon); + } + + [Fact] + public void Drive_TargetBehind_TurnsRightOrLeftViaShortestPath() + { + // Body facing +Y; target directly behind at (0, -10, 0). + // |delta| = π, equally close either way; the implementation + // picks one (sign depends on float wobble) — just assert + // we made progress (yaw changed by exactly TurnRate * dt). + var bodyPos = Vector3.Zero; + var bodyRot = Quaternion.Identity; + var dest = new Vector3(0f, -10f, 0f); + const float dt = 0.1f; + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0f, distanceToObject: 0f, + dt: dt, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Steering, result); + float expectedStep = RemoteMoveToDriver.TurnRateRadPerSec * dt; + Assert.InRange(MathF.Abs(Yaw(newOrient)), expectedStep - Epsilon, expectedStep + Epsilon); + } + + [Fact] + public void Drive_PreservesOrientationAtArrival() + { + var bodyPos = new Vector3(5f, 5f, 0f); + var bodyRot = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 1.234f); + var dest = new Vector3(5.01f, 5.01f, 0f); + + var result = RemoteMoveToDriver.Drive( + bodyPos, bodyRot, dest, + minDistance: 0.5f, distanceToObject: 0.6f, + dt: 0.016f, moveTowards: true, + out var newOrient); + + Assert.Equal(RemoteMoveToDriver.DriveResult.Arrived, result); + // Caller would zero velocity; orientation should be untouched + // so the body settles facing whatever direction it was already. + Assert.Equal(bodyRot, newOrient); + } + + [Fact] + public void ClampApproachVelocity_NoOverShoot_LandsExactlyAtThreshold() + { + // Body 1 m from destination, running at 4 m/s, dt = 0.1 s. + // Naive advance = 0.4 m → would end at 0.6 m from dest, exactly + // on the threshold. With threshold=0.6 and remaining=0.4, the + // clamp should let the full velocity through (advance == remaining). + var bodyPos = new Vector3(0f, 0f, 0f); + var dest = new Vector3(0f, 1f, 0f); + var vel = new Vector3(0f, 4f, 0f); + + var clamped = RemoteMoveToDriver.ClampApproachVelocity( + bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.1f, moveTowards: true); + + // Within float-precision: 4 m/s × 0.1 s = 0.4 m, exactly the + // remaining distance. The clamp may apply a 0.99999×-style + // tiny scale due to FP rounding — accept anything ≥ 99.9% of + // the input as "no meaningful overshoot prevention applied." + Assert.InRange(clamped.Y, 4f * 0.999f, 4f); + Assert.Equal(0f, clamped.X); + Assert.Equal(0f, clamped.Z); + } + + [Fact] + public void ClampApproachVelocity_WouldOverShoot_ScalesDownToExactLanding() + { + // Body 1 m from destination, running at 4 m/s, dt = 0.2 s. + // Naive advance = 0.8 m → would overshoot 0.6 m threshold by 0.4 m. + // remaining = 0.4 m, advance = 0.8 m → scale = 0.5. + // Velocity should be halved → 2 m/s. + var bodyPos = new Vector3(0f, 0f, 0f); + var dest = new Vector3(0f, 1f, 0f); + var vel = new Vector3(0f, 4f, 0f); + + var clamped = RemoteMoveToDriver.ClampApproachVelocity( + bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.2f, moveTowards: true); + + Assert.InRange(clamped.Y, 2f - Epsilon, 2f + Epsilon); + Assert.Equal(0f, clamped.X); + } + + [Fact] + public void ClampApproachVelocity_AlreadyAtThreshold_ZeroesHorizontal() + { + // Body exactly 0.6 m from dest with threshold 0.6 → remaining ≈ 0. + // Any horizontal velocity would overshoot; clamp must zero it. + var bodyPos = new Vector3(0f, 0f, 0f); + var dest = new Vector3(0f, 0.6f, 0f); + var vel = new Vector3(0f, 4f, 0.5f); // some Z to confirm Z is preserved + + var clamped = RemoteMoveToDriver.ClampApproachVelocity( + bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.016f, moveTowards: true); + + Assert.Equal(0f, clamped.X); + Assert.Equal(0f, clamped.Y); + Assert.Equal(0.5f, clamped.Z); // gravity / Z handling unaffected + } + + [Fact] + public void ClampApproachVelocity_FleeBranch_NoOp() + { + // moveTowards=false (flee): no overshoot risk, return velocity unchanged. + var bodyPos = Vector3.Zero; + var dest = new Vector3(0f, 1f, 0f); + var vel = new Vector3(0f, -4f, 0f); + + var clamped = RemoteMoveToDriver.ClampApproachVelocity( + bodyPos, vel, dest, arrivalThreshold: 5f, dt: 0.5f, moveTowards: false); + + Assert.Equal(vel, clamped); + } + + [Fact] + public void OriginToWorld_AppliesLandblockGridShift() + { + // Cell ID 0xA8B4000E → landblock x=0xA8, y=0xB4. With live center + // at (0xA9, 0xB4), that's one landblock west and zero north, + // so origin (10, 20, 0) inside that landblock should map to + // (10 - 192, 20 + 0, 0) = (-182, 20, 0) in render-world space. + var w = RemoteMoveToDriver.OriginToWorld( + originCellId: 0xA8B4000Eu, + originX: 10f, originY: 20f, originZ: 0f, + liveCenterLandblockX: 0xA9, liveCenterLandblockY: 0xB4); + + Assert.Equal(-182f, w.X); + Assert.Equal(20f, w.Y); + Assert.Equal(0f, w.Z); + } + + [Fact] + public void TurnRateFor_WalkingReturnsBaseRate() + { + // Retail: omega.z = ±π/2 × turn_speed (1.0) = π/2 rad/s ≈ 90°/s + // Anchor: docs/research/named-retail/acclient_2013_pseudo_c.txt + // CMotionInterp::apply_run_to_command 0x00527be0 only + // multiplies under HoldKey.Run — walking is unscaled. + float rate = RemoteMoveToDriver.TurnRateFor(running: false); + Assert.Equal(MathF.PI / 2.0f, rate, precision: 5); + } + + [Fact] + public void TurnRateFor_RunningAppliesRunTurnFactor() + { + // Retail: omega.z = ±π/2 × turn_speed × run_turn_factor + // run_turn_factor = 1.5f at 0x007c8914 (PDB-named). + // apply_run_to_command (acclient_2013_pseudo_c.txt:305098) + // multiplies turn_speed by 1.5f when input is TurnRight + // under HoldKey.Run. + float rate = RemoteMoveToDriver.TurnRateFor(running: true); + Assert.Equal(MathF.PI / 2.0f * 1.5f, rate, precision: 5); + } + + [Fact] + public void TurnRateRadPerSec_BackCompatStillResolvesToWalkingRate() + { + // Existing call sites that haven't yet migrated to TurnRateFor + // (e.g., RemoteMoveToDriver.Drive's TurnSpeed=1.0 callers) still + // see the walking-rate constant. Same numerical value as + // BaseTurnRateRadPerSec. + Assert.Equal(RemoteMoveToDriver.BaseTurnRateRadPerSec, + RemoteMoveToDriver.TurnRateRadPerSec, precision: 5); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/RemoteWeenieRunRateTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteWeenieRunRateTests.cs deleted file mode 100644 index e54d8aa3..00000000 --- a/tests/AcDream.Core.Tests/Physics/RemoteWeenieRunRateTests.cs +++ /dev/null @@ -1,52 +0,0 @@ -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// R4-V5 #160 fix: retail's apply_run_to_command (0x00527be0) run-rate -/// chain is weenie ? (InqRunRate() ?: my_run_rate) : 1.0. Remote -/// weenies FAIL InqRunRate, landing on my_run_rate — the field the mt-6/7 -/// unpack writes with the wire's MoveToRunRate (M13). These pin that a -/// RemoteWeenie-equipped interp scales the run promotion by MyRunRate -/// (observer-side movetos ran in slow motion when the interp had no -/// weenie and took the degenerate 1.0 branch). -/// -public class RemoteWeenieRunRateTests -{ - [Fact] - public void ApplyRunToCommand_RemoteWeenie_ScalesByWireRunRate() - { - var interp = new MotionInterpreter(new PhysicsBody()) - { - WeenieObj = new RemoteWeenie(), - MyRunRate = 4.5f, // the mt-6 wire write (unpack @300603) - }; - - uint motion = MotionCommand.WalkForward; - float speed = 1.0f; - interp.apply_run_to_command(ref motion, ref speed); - - Assert.Equal(MotionCommand.RunForward, motion); - Assert.Equal(4.5f, speed, 3); - } - - [Fact] - public void ApplyRunToCommand_NoWeenie_KeepsRetailDegenerateBranch() - { - // Verbatim retail: a weenie-LESS (detached-class) object scales by - // 1.0 (raw 305062-305076 `else x87_r7 = 1f`). Production bodies all - // carry a weenie (player: PlayerWeenie; remotes: RemoteWeenie). - var interp = new MotionInterpreter(new PhysicsBody()) - { - MyRunRate = 4.5f, - }; - - uint motion = MotionCommand.WalkForward; - float speed = 1.0f; - interp.apply_run_to_command(ref motion, ref speed); - - Assert.Equal(MotionCommand.RunForward, motion); - Assert.Equal(1.0f, speed, 3); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs b/tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs deleted file mode 100644 index bd12f7c8..00000000 --- a/tests/AcDream.Core.Tests/Physics/RemoveCellsForLandblockTests.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Collections.Generic; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// D8 (2026-06-24): verify that -/// evicts every cached cell belonging to the given landblock prefix and leaves -/// cells belonging to other landblocks untouched — symmetric with -/// (#146). -/// -public class RemoveCellsForLandblockTests -{ - /// - /// Build the absolute minimum that satisfies the - /// required init property. BSP is left null because the test exercises - /// only the cache-eviction key logic, not BSP traversal. - /// - private static CellPhysics BuildMinimalCell() => new() - { - Resolved = new Dictionary(), - }; - - [Fact] - public void RemoveCellsForLandblock_EvictsOnlyMatchingPrefix() - { - var cache = new PhysicsDataCache(); - - // Two cells from landblock 0xAAAA0000 and one from 0xBBBB0000. - cache.RegisterCellStructForTest(0xAAAA0100u, BuildMinimalCell()); - cache.RegisterCellStructForTest(0xAAAA0200u, BuildMinimalCell()); - cache.RegisterCellStructForTest(0xBBBB0100u, BuildMinimalCell()); - - cache.RemoveCellsForLandblock(0xAAAA0000u); - - // Both AAAA cells must be gone. - Assert.Null(cache.GetCellStruct(0xAAAA0100u)); - Assert.Null(cache.GetCellStruct(0xAAAA0200u)); - - // The BBBB cell must survive. - Assert.NotNull(cache.GetCellStruct(0xBBBB0100u)); - } - - [Fact] - public void RemoveCellsForLandblock_EmptyCache_DoesNotThrow() - { - // Must be a no-op on an empty cache. - var cache = new PhysicsDataCache(); - cache.RemoveCellsForLandblock(0xAAAA0000u); - Assert.Equal(0, cache.CellStructCount); - } - - [Fact] - public void RemoveCellsForLandblock_NoMatchingCells_LeavesOthersIntact() - { - var cache = new PhysicsDataCache(); - cache.RegisterCellStructForTest(0xBBBB0100u, BuildMinimalCell()); - - // Evicting a prefix that has no cached entries should not disturb others. - cache.RemoveCellsForLandblock(0xAAAA0000u); - - Assert.NotNull(cache.GetCellStruct(0xBBBB0100u)); - Assert.Equal(1, cache.CellStructCount); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs deleted file mode 100644 index 80200142..00000000 --- a/tests/AcDream.Core.Tests/Physics/RetailObserverTraceConformanceTests.cs +++ /dev/null @@ -1,191 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// L.2g S2 conformance harness, layer 1: golden fixtures parsed from a LIVE -/// cdb trace of a RETAIL observer client -/// (Fixtures/l2g-observer-trace.log, captured 2026-07-02 with -/// tools/cdb/l2g-observer.cdb while a retail actor ran the structured -/// motion protocol through ACE). -/// -/// Each [MTIS] block is an exact INPUT (the InterpretedMotionState retail -/// received, dumped field-by-field) and the [DIM] lines that follow on the -/// same minterp are retail's exact OUTPUT (every DoInterpretedMotion -/// dispatch, in order). We replay every input through acdream's funnel and -/// assert an identical dispatch sequence — retail's actual runtime is the -/// oracle, not anyone's reading of the decomp. -/// -/// Exclusions (documented, not silent): -/// - Action-class dispatches (0x10000000 bit) come from the UM's action -/// LIST, which the [MTIS] struct dump doesn't include (LList pointer -/// only) — filtered from both sides; covered by the synthetic action -/// tests in MotionInterpreterFunnelTests instead. -/// - motion==0x80000000 entries (the unpack-level DoMotion(command_ids[0]) -/// style call for wire style index 0) — outside -/// move_to_interpreted_state, so it can never appear in a -/// MoveToInterpretedState replay; the filter stays for that trace- -/// mechanics reason. The PRODUCTION gap the old note pointed at ("S3 -/// wires the unpack-level style-on-change") CLOSED in R5-V4: the -/// GameWindow routing heads dispatch DoMotion(style) on change for every -/// movement type (unpack_movement @00524502-0052452c; conformance: -/// RemoteChaseEndToEndHarnessTests.ChaseArm_WithStanceChange_ -/// AppliesStanceBeforeTheChase). -/// -public class RetailObserverTraceConformanceTests -{ - private sealed record TraceCase( - int Line, string Minterp, InboundInterpretedState Ims, List Dims); - - private sealed class RecordingSink : IInterpretedMotionSink - { - public readonly List Applied = new(); - public bool ApplyMotion(uint motion, float speed) - { - Applied.Add(motion); - // R3-W5: style/stance ids (>= 0x80000000) have no dat MotionData - // entry — retail's real motion-table lookup fails for them - // (see MotionInterpreterDoMotionFamilyTests / the interface - // doc on IInterpretedMotionSink.ApplyMotion for the full - // rationale). The fake sink mirrors that so the style dispatch - // doesn't clobber ForwardCommand before the next read. - return motion < 0x80000000u; - } - public bool StopMotion(uint motion) => true; - } - - private static string TracePath() - { - var dir = AppContext.BaseDirectory; - while (dir is not null && !File.Exists(Path.Combine(dir, "AcDream.slnx"))) - dir = Directory.GetParent(dir)?.FullName; - Assert.NotNull(dir); - return Path.Combine(dir!, "tests", "AcDream.Core.Tests", "Fixtures", "l2g-observer-trace.log"); - } - - private static List ParseTrace() - { - var lines = File.ReadAllLines(TracePath()); - var cases = new List(); - var mtisRe = new Regex(@"\[MTIS\] minterp=(\w+)"); - var fieldRe = new Regex(@"\+0x(\w+) (\w+)\s+: (\S+)"); - var dimRe = new Regex(@"\[DIM\] minterp=(\w+) motion=(\w+)"); - - for (int i = 0; i < lines.Length; i++) - { - var m = mtisRe.Match(lines[i]); - if (!m.Success) continue; - string minterp = m.Groups[1].Value; - - var ims = InboundInterpretedState.Default(); - int j = i + 1; - for (; j < lines.Length; j++) - { - var f = fieldRe.Match(lines[j]); - if (!f.Success) break; - string name = f.Groups[2].Value; - string val = f.Groups[3].Value; - uint ParseHex() => Convert.ToUInt32(val, 16); - float ParseF() => float.Parse(val, CultureInfo.InvariantCulture); - switch (name) - { - case "current_style": ims.CurrentStyle = ParseHex(); break; - case "forward_command": ims.ForwardCommand = ParseHex(); break; - case "forward_speed": ims.ForwardSpeed = ParseF(); break; - case "sidestep_command": ims.SideStepCommand = ParseHex(); break; - case "sidestep_speed": ims.SideStepSpeed = ParseF(); break; - case "turn_command": ims.TurnCommand = ParseHex(); break; - case "turn_speed": ims.TurnSpeed = ParseF(); break; - } - } - - // Collect this minterp's DIMs until the next UNPACK/MTIS event. - var dims = new List(); - for (; j < lines.Length; j++) - { - if (lines[j].Contains("[UNPACK]") || lines[j].Contains("[MTIS]")) break; - var d = dimRe.Match(lines[j]); - if (d.Success && d.Groups[1].Value == minterp) - dims.Add(Convert.ToUInt32(d.Groups[2].Value, 16)); - } - - // A SECOND style dispatch marks a second apply_current_movement - // pass fired by the physics tick (HitGround / LeaveGround - // re-apply, CMotionInterp::HitGround 0x00528ac0) — not by this - // UM. Keep only the first pass as this UM's output. (2 cases in - // the capture — the actor's jump/land moments.) - int secondStyle = -1; - for (int k = 1; k < dims.Count; k++) - if (dims[k] == ims.CurrentStyle) { secondStyle = k; break; } - if (secondStyle > 0) dims = dims.Take(secondStyle).ToList(); - - cases.Add(new TraceCase(i + 1, minterp, ims, dims)); - } - return cases; - } - - private static bool IsExcluded(uint motion) - => (motion & 0x10000000u) != 0 // action-list dispatches, not in the dump - || motion == 0x80000000u; // unpack-level style-index-0 call - - [Fact] - public void EveryTracedUm_ProducesRetailsExactDispatchSequence() - { - var cases = ParseTrace(); - Assert.True(cases.Count > 100, $"expected >100 traced UMs, parsed {cases.Count}"); - - int verified = 0; - var failures = new List(); - foreach (var c in cases) - { - // The trace breakpoint sits at DoInterpretedMotion ENTRY - // (pre-gate); the sink records POST-gate (GetObjectSequence) - // calls. For grounded bodies they coincide. A Falling dispatch - // for a non-Falling forward command marks the traced body as - // AIRBORNE at that moment (the actor's jump) — replay those - // with contact cleared, and drop the traced style entry from - // the expectation (called pre-gate, blocked post-gate). - bool airborne = c.Ims.ForwardCommand != 0x40000015u - && c.Dims.Contains(0x40000015u); - - var body = new PhysicsBody(); - body.State |= PhysicsStateFlags.Gravity; - body.TransientState |= TransientStateFlags.Active; - if (!airborne) - body.TransientState |= TransientStateFlags.Contact - | TransientStateFlags.OnWalkable; - var mi = new MotionInterpreter(body); - var sink = new RecordingSink(); - - mi.MoveToInterpretedState(c.Ims, sink); - - var expected = c.Dims.Where(d => !IsExcluded(d) - && (!airborne || d != c.Ims.CurrentStyle)).ToList(); - var actual = sink.Applied.Where(d => !IsExcluded(d)).ToList(); - if (!expected.SequenceEqual(actual)) - { - failures.Add( - $"line {c.Line} minterp {c.Minterp}: " + - $"fwd=0x{c.Ims.ForwardCommand:X8}@{c.Ims.ForwardSpeed:F2} " + - $"side=0x{c.Ims.SideStepCommand:X8} turn=0x{c.Ims.TurnCommand:X8} — " + - $"retail [{string.Join(",", expected.Select(d => d.ToString("X8")))}] " + - $"vs acdream [{string.Join(",", actual.Select(d => d.ToString("X8")))}]"); - } - else - { - verified++; - } - } - - Assert.True(failures.Count == 0, - $"{failures.Count} of {cases.Count} traced UMs diverged " - + $"({verified} conformant):\n" + string.Join("\n", failures.Take(12))); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs index 9ea2ca04..65cc50da 100644 --- a/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ServerControlledLocomotionTests.cs @@ -6,8 +6,41 @@ namespace AcDream.Core.Tests.Physics; public sealed class ServerControlledLocomotionTests { + [Fact] + public void PlanMoveToStart_SeedsImmediateRunCycle() + { + var plan = ServerControlledLocomotion.PlanMoveToStart(); + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.RunForward, plan.Motion); + Assert.Equal(1.0f, plan.SpeedMod); + } + [Fact] + public void PlanMoveToStart_AppliesRetailRunRate() + { + var plan = ServerControlledLocomotion.PlanMoveToStart( + moveToSpeed: 1.25f, + runRate: 1.5f, + canRun: true); + + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.RunForward, plan.Motion); + Assert.Equal(1.875f, plan.SpeedMod); + } + + [Fact] + public void PlanMoveToStart_UsesWalkWhenRunDisallowed() + { + var plan = ServerControlledLocomotion.PlanMoveToStart( + moveToSpeed: 0.75f, + runRate: 2.0f, + canRun: false); + + Assert.True(plan.IsMoving); + Assert.Equal(MotionCommand.WalkForward, plan.Motion); + Assert.Equal(0.75f, plan.SpeedMod); + } [Fact] public void PlanFromVelocity_StopsBelowRetailNoiseThreshold() diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs deleted file mode 100644 index 7677b548..00000000 --- a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System; -using System.Collections.Generic; -using AcDream.Core.Physics; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Enums; -using DatReaderWriter.Types; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// Guards the DAT-only shape-source rule (Slice 1 of the unified -/// collision-inclusion phase, 2026-06-24). -/// -/// -/// Retail oracle: CPartArray::InitParts@0x00517F40 (parts come from -/// the Setup list only), CGfxObj::Serialize@0x00534970 (physics_bsp -/// gated on serialized-flags bit-0), CPhysicsPart::find_obj_collisions@0x0050D8D0 -/// (returns OK / passable when physics_bsp == null). Render-mesh bounds -/// never enter collision. -/// -/// -public class ShadowShapeBuilderShapeSourceTests -{ - // Retail: an object with no DAT physics shape (no CylSphere, no Sphere, - // no part with a PhysicsBSP) emits NO collision shape and is passable. - // CPhysicsPart::find_obj_collisions@0x0050D8D0 returns OK when physics_bsp==null. - [Fact] - public void Setup_WithNoCylSpheres_NoSpheres_NoPhysicsBspParts_YieldsEmptyShapeList() - { - var setup = new Setup - { - CylSpheres = new List(), - Spheres = new List(), - Parts = { 0x01000ABCu }, - PlacementFrames = new Dictionary(), - }; - var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, hasPhysicsBsp: _ => false); - Assert.Empty(shapes); - } - - // D4+W1 (2026-06-24) — BSP-only furniture weenie guard. - // - // A Setup with no CylSpheres, no Spheres, no Radius, but whose Part has a - // PhysicsBSP (e.g. a candle holder or floor candelabra) MUST produce a BSP - // ShadowShape. The premature `if (!hasCyl && !hasSphere && !hasRadius) return` - // gate in GameWindow.RegisterLiveEntityCollision was dropped because it fired - // BEFORE ShadowShapeBuilder ran and discarded these entities entirely. - // - // Retail oracle: CPhysicsPart::find_obj_collisions@0x0050D8D0 -- when - // physics_bsp is non-null the part IS tested; the outer loop in - // CPartArray::FindObjCollisions iterates all parts regardless of - // CylSpheres/Spheres. ShadowShapeBuilder.FromSetup mirrors this by emitting - // one BSP shape per part that `hasPhysicsBsp` returns true for. - [Fact] - public void Setup_WithBspPart_NoCylSpheres_EmitsBspShape() - { - // hasPhysicsBsp predicate returns true for 0x0100AAAAu, simulating a - // GfxObj that has a PhysicsBSP but no CylSpheres/Spheres. - const uint BspGfxObjId = 0x0100AAAAu; - var setup = new Setup - { - CylSpheres = new List(), - Spheres = new List(), - Parts = { BspGfxObjId }, - PlacementFrames = new Dictionary(), - }; - var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, - hasPhysicsBsp: id => id == BspGfxObjId); - - Assert.Contains(shapes, s => s.CollisionType == ShadowCollisionType.BSP); - } - - // Complementary regression guard: a Setup with no CylSpheres, no Spheres, - // and a Part that has NO PhysicsBSP must still produce an empty shape list. - // The premature gate removal does not accidentally register shapeless entities. - [Fact] - public void Setup_WithPartButNoBsp_NoCylSpheres_YieldsEmptyShapeList() - { - const uint NoBspGfxObjId = 0x0100BBBBu; - var setup = new Setup - { - CylSpheres = new List(), - Spheres = new List(), - Parts = { NoBspGfxObjId }, - PlacementFrames = new Dictionary(), - }; - var shapes = ShadowShapeBuilder.FromSetup(setup, entScale: 1f, - hasPhysicsBsp: _ => false); - - Assert.Empty(shapes); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs index 21a5c88e..b1679358 100644 --- a/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs +++ b/tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderTests.cs @@ -57,17 +57,14 @@ public class ShadowShapeBuilderTests Assert.Equal(4, shapes.Count); - // Task 2 (2026-06-24): Setup.Spheres now emit ShadowCollisionType.Sphere, - // not Cylinder. A door's Sphere entry contributes the Sphere-typed shape; - // the 3 parts (all with physics BSP) contribute the 3 BSP shapes. - int sphereCount = 0; + int cylinderCount = 0; int bspCount = 0; foreach (var s in shapes) { - if (s.CollisionType == ShadowCollisionType.Sphere) sphereCount++; + if (s.CollisionType == ShadowCollisionType.Cylinder) cylinderCount++; else if (s.CollisionType == ShadowCollisionType.BSP) bspCount++; } - Assert.Equal(1, sphereCount); + Assert.Equal(1, cylinderCount); Assert.Equal(3, bspCount); } @@ -77,16 +74,12 @@ public class ShadowShapeBuilderTests var setup = CreateDoorSetup(); var shapes = ShadowShapeBuilder.FromSetup(setup, 1.0f, _ => true); - // Task 2 (2026-06-24): Spheres emit ShadowCollisionType.Sphere (not Cylinder). - // Retail: CSphere::intersects_sphere @ 0x00537A80 uses 3-D distance; no height cap. - var sphereShape = shapes.FirstOrDefault(s => s.CollisionType == ShadowCollisionType.Sphere); - Assert.NotEqual(default, sphereShape); - Assert.Equal(0f, sphereShape.LocalPosition.X, 4); - Assert.Equal(0f, sphereShape.LocalPosition.Y, 4); - Assert.Equal(0.018f, sphereShape.LocalPosition.Z, 4); - Assert.Equal(0.100f, sphereShape.Radius, 4); - // CylHeight must be 0 — spheres have no height cap. - Assert.Equal(0f, sphereShape.CylHeight, 4); + var sphereAsCyl = shapes.FirstOrDefault(s => s.CollisionType == ShadowCollisionType.Cylinder); + Assert.NotEqual(default, sphereAsCyl); + Assert.Equal(0f, sphereAsCyl.LocalPosition.X, 4); + Assert.Equal(0f, sphereAsCyl.LocalPosition.Y, 4); + Assert.Equal(0.018f, sphereAsCyl.LocalPosition.Z, 4); + Assert.Equal(0.100f, sphereAsCyl.Radius, 4); } [Fact] diff --git a/tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs b/tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs deleted file mode 100644 index b0b116b9..00000000 --- a/tests/AcDream.Core.Tests/Physics/SphereIntersectsSphereConformanceTests.cs +++ /dev/null @@ -1,218 +0,0 @@ -using System; -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// Conformance tests for . -/// -/// -/// All anchor cases are geometrically verifiable independently of the -/// implementation — they are derived from first-principles geometry, not -/// from the method under test, to avoid a circular test. -/// -/// -/// -/// Retail oracle: CSphere::intersects_sphere @ 0x00537A80 (named-retail -/// decomp) + ACE.Server/Physics/Sphere.cs::FindTimeOfCollision (C# port). -/// -/// -public class SphereIntersectsSphereConformanceTests -{ - // ----------------------------------------------------------------------- - // Geometry anchors — verified by hand before the implementation existed - // ----------------------------------------------------------------------- - - /// - /// Two unit spheres (r=1 each) 5 units apart on the X axis. - /// Mover at origin, target at (5, 0, 0). - /// Sweep: move 5 units in +X. - /// Combined radius = 2. - /// Expected first contact at x = 3 from start = t = 3/5 = 0.6. - /// - [Fact] - public void HeadOn_HitsAtExpectedTime() - { - bool hit = CollisionPrimitives.SweptSphereHitsSphere( - moverCenter: Vector3.Zero, - moverRadius: 1f, - sweepDelta: new Vector3(5f, 0f, 0f), - targetCenter: new Vector3(5f, 0f, 0f), - targetRadius: 1f, - out float t); - - Assert.True(hit, "Head-on sweep should hit"); - // t = 3/5 = 0.6 — surface contact when mover centre is at x=3, - // target centre at x=5, gap = 2 = combined radius. Within ±1e-4. - Assert.True(MathF.Abs(t - 0.6f) < 1e-4f, - $"Expected t≈0.6, got {t:G6}"); - } - - /// - /// Two unit spheres. Mover sweeps purely in +Y, target is offset 3 units - /// in +X. The sweep never reaches within combined radius (2) of the target. - /// - [Fact] - public void PerpendicularSweep_TooFar_Misses() - { - // Mover at origin, target at (3, 0, 0). Sweep in +Y by 10 units. - // Closest approach = 3 units (> combined radius 2). - bool hit = CollisionPrimitives.SweptSphereHitsSphere( - moverCenter: Vector3.Zero, - moverRadius: 1f, - sweepDelta: new Vector3(0f, 10f, 0f), - targetCenter: new Vector3(3f, 0f, 0f), - targetRadius: 1f, - out float _); - - Assert.False(hit, "Perpendicular sweep at distance 3 > combinedR 2 should miss"); - } - - /// - /// A sweep that grazes the target sphere (closest approach = exactly - /// combined radius). Geometrically this is a tangent hit and should - /// return true with t in (0, 1]. - /// Mover at origin, sweep in +Y by 6. Target at (2, 3, 0). - /// Combined radius = 2 (r1=r2=1). Closest approach = 2 (tangent). - /// - [Fact] - public void TangentSweep_Hits() - { - // Mover sweeps from (0,0,0) to (0,6,0). - // Target at (2, 3, 0). At t=0.5 mover centre is at (0,3,0). - // Distance at closest = 2, exactly combinedR → tangent touch. - bool hit = CollisionPrimitives.SweptSphereHitsSphere( - moverCenter: Vector3.Zero, - moverRadius: 1f, - sweepDelta: new Vector3(0f, 6f, 0f), - targetCenter: new Vector3(2f, 3f, 0f), - targetRadius: 1f, - out float t); - - Assert.True(hit, "Tangent sweep (distance = combinedR) should register as a hit"); - Assert.True(t > 0f && t <= 1f, $"t={t:G6} should be in (0,1]"); - } - - /// - /// Sweep that completely passes by: target is beside the path, offset - /// 2.1 units (> combined radius 2). Should miss. - /// - [Fact] - public void OffAxisSweep_JustOutside_Misses() - { - bool hit = CollisionPrimitives.SweptSphereHitsSphere( - moverCenter: Vector3.Zero, - moverRadius: 1f, - sweepDelta: new Vector3(0f, 6f, 0f), - targetCenter: new Vector3(2.1f, 3f, 0f), - targetRadius: 1f, - out float _); - - Assert.False(hit, "Lateral offset 2.1 > combinedR 2 — should miss"); - } - - /// - /// Sweep away from the target — degenerate "wrong direction". - /// Mover at (0,0,0) sweeps in −X while target is at (5,0,0). - /// The sweep is directly away; no forward contact. - /// - [Fact] - public void SweepAwayFromTarget_Misses() - { - bool hit = CollisionPrimitives.SweptSphereHitsSphere( - moverCenter: Vector3.Zero, - moverRadius: 1f, - sweepDelta: new Vector3(-5f, 0f, 0f), - targetCenter: new Vector3(5f, 0f, 0f), - targetRadius: 1f, - out float _); - - Assert.False(hit, "Sweep directly away from target should not hit"); - } - - /// - /// Sweep within the step but the target is too far for the step to reach. - /// Target is 10 units away, sweep is only 3 units — t would be >1. - /// - [Fact] - public void TargetBeyondStep_Misses() - { - // combinedR = 2; target centre 10 away; contact at t = (10-2)/3 ≈ 2.67 > 1. - bool hit = CollisionPrimitives.SweptSphereHitsSphere( - moverCenter: Vector3.Zero, - moverRadius: 1f, - sweepDelta: new Vector3(3f, 0f, 0f), - targetCenter: new Vector3(10f, 0f, 0f), - targetRadius: 1f, - out float _); - - Assert.False(hit, "Target 10 away with only a 3-unit sweep should miss (t>1)"); - } - - /// - /// Zero-length sweep is degenerate — should not hit regardless of position. - /// - [Fact] - public void DegenerateSweep_Misses() - { - bool hit = CollisionPrimitives.SweptSphereHitsSphere( - moverCenter: Vector3.Zero, - moverRadius: 1f, - sweepDelta: Vector3.Zero, - targetCenter: new Vector3(0.5f, 0f, 0f), - targetRadius: 0.1f, - out float _); - - Assert.False(hit, "Zero-length sweep should return false (degenerate)"); - } - - /// - /// Already-overlapping spheres: gap < 0 — the static overlap case. - /// Retail returns -1 (no forward collision time) for already-overlapping - /// spheres; returns - /// false (caller handles static overlap separately). - /// - [Fact] - public void AlreadyOverlapping_ReturnsFalse() - { - // Centres 0.5 apart, combined radius 2 — deeply overlapping. - bool hit = CollisionPrimitives.SweptSphereHitsSphere( - moverCenter: Vector3.Zero, - moverRadius: 1f, - sweepDelta: new Vector3(1f, 0f, 0f), - targetCenter: new Vector3(0.5f, 0f, 0f), - targetRadius: 1f, - out float _); - - Assert.False(hit, - "Already-overlapping spheres: retail FindTimeOfCollision returns -1 (no forward t); SweptSphereHitsSphere should return false"); - } - - // ----------------------------------------------------------------------- - // 3-D geometry — sphere primitive must use full 3-D distance - // ----------------------------------------------------------------------- - - /// - /// Pure Z-axis sweep: verifies the primitive uses 3-D distance (not XY-only). - /// A purely vertical sweep toward a sphere directly below should hit. - /// - [Fact] - public void VerticalSweep_HitsTargetBelow() - { - // Mover at (0,0,5), sweeps down -Z by 5 to (0,0,0). - // Target at (0,0,0) with radius 1. Combined radius = 2. - // First contact when mover centre Z = 2 → t = (5-2)/5 = 0.6. - bool hit = CollisionPrimitives.SweptSphereHitsSphere( - moverCenter: new Vector3(0f, 0f, 5f), - moverRadius: 1f, - sweepDelta: new Vector3(0f, 0f, -5f), - targetCenter: Vector3.Zero, - targetRadius: 1f, - out float t); - - Assert.True(hit, "Vertical sweep toward sphere below should hit"); - Assert.True(MathF.Abs(t - 0.6f) < 1e-4f, $"Expected t≈0.6, got {t:G6}"); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs b/tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs deleted file mode 100644 index ef338054..00000000 --- a/tests/AcDream.Core.Tests/Physics/TeleportFarTownRunawayTests.cs +++ /dev/null @@ -1,65 +0,0 @@ -using System.Numerics; -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -// #145 — far-town teleport resolver runaway. A teleport to a far town places the -// player at a landblock EDGE; the first physics tick crosses into a neighbour that -// has NOT streamed in yet. Pre-fix, the membership pick derived the block origin from -// the terrain registry, which returns (0,0) for an unstreamed neighbour, so -// AdjustToOutside marched the cell id one landblock per tick (the cascade) until lbY -// hit 0 and the outbound wire synthesized the bogus localY (17410) that ACE rejects. -// -// The fix (Slice 3): the resolve threads the CARRIED cell-relative frame anchor -// (body.Position − body.CellPosition.Frame.Origin) into the pick. That anchor is the -// TRUE landblock world origin — correct even for an unstreamed neighbour — so the pick -// re-derives the SAME (consistent) cell and never marches. -// -// These tests exercise CellTransit.FindCellSet directly with an EMPTY cache (so the -// neighbour landblock is unstreamed → TryGetTerrainOrigin returns false). With -// #145 Slice 3 (CarriedBlockOrigin), the explicit anchor prevents the march. With -// #145 D (2026-06-22, the TryGetTerrainOrigin-bool fix), the no-anchor path ALSO -// preserves the seed — "no frame → preserve verbatim". Both are verified here. -public class TeleportFarTownRunawayTests -{ - private static int LbX(uint cellId) => (int)((cellId >> 24) & 0xFFu); - private static int LbY(uint cellId) => (int)((cellId >> 16) & 0xFFu); - - [Fact] - public void SouthEdge_UnstreamedNeighbour_CarriedAnchor_DoesNotMarch() - { - // Streaming center is the far town 0xC95B; the player just crossed its south - // edge (world Y ≈ −0.088) into 0xC95A (origin (0,−192), UNSTREAMED). - var cache = new PhysicsDataCache(); - var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(14.8f, -0.088f, 12f), Radius = 0.48f } }; - const uint currentCell = 0xC95A0001u; - var anchor = new Vector3(0f, -192f, 0f); // body.Position − CellPosition.Origin - - uint withAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, anchor); - Assert.Equal(0x5A, LbY(withAnchor)); // stays in 0xC95A — no march - - // #145 D (2026-06-22): no-anchor path also preserves when terrain is unregistered. - // Pre-D this marched south to 0x59; post-D returns currentCell unchanged. - uint noAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null); - Assert.Equal(currentCell, noAnchor); - } - - [Fact] - public void EastEdge_UnstreamedNeighbour_CarriedAnchor_DoesNotMarch() - { - // Player crossed the center's east edge (world X ≈ 192.088) into 0xCA5B - // (origin (+192,0), UNSTREAMED). lbX 0xCA > 0xC9. - var cache = new PhysicsDataCache(); - var spheres = new[] { new DatReaderWriter.Types.Sphere { Origin = new Vector3(192.088f, 14.8f, 12f), Radius = 0.48f } }; - const uint currentCell = 0xCA5B0001u; - var anchor = new Vector3(192f, 0f, 0f); - - uint withAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, anchor); - Assert.Equal(0xCA, LbX(withAnchor)); // stays in 0xCA5B — no march - - // #145 D: no-anchor path also preserves when terrain is unregistered. - uint noAnchor = CellTransit.FindCellSet(cache, spheres, 1, currentCell, out _, null); - Assert.Equal(currentCell, noAnchor); - } -} diff --git a/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs b/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs deleted file mode 100644 index e42b23cc..00000000 --- a/tests/AcDream.Core.Tests/Physics/WeenieErrorCodeTableTests.cs +++ /dev/null @@ -1,136 +0,0 @@ -using AcDream.Core.Physics; -using Xunit; - -namespace AcDream.Core.Tests.Physics; - -/// -/// R3-W1 — pins 's numeric values against the -/// definitive retail table in -/// docs/research/2026-07-02-r3-motioninterp/W0-pins.md §A10 (an exhaustive -/// sweep of every return <code> site across raw 304908-306277 + -/// 300150-300540: 19 return sites + 1 store site, all attributed). These -/// codes are local-only (never on the wire), so the renumber from the -/// pre-R3 shuffled names is a safe rename — this test is the single -/// source of truth that a future edit can't silently un-pin. -/// -public sealed class WeenieErrorCodeTableTests -{ - [Fact] - public void None_Is0x00() - => Assert.Equal(0x00u, (uint)WeenieError.None); - - [Fact] - public void NoPhysicsObject_Is0x08() - => Assert.Equal(0x08u, (uint)WeenieError.NoPhysicsObject); - - /// - /// 0x0B — NoMotionInterpreter. R4-V1 addition (M12), per - /// docs/research/2026-07-03-r4-moveto/r4-moveto-decomp.md §12 constants - /// inventory row (8, 0xb, 0x36, 0x37, 0x38, 0x3d, 0x47). - /// - [Fact] - public void NoMotionInterpreter_Is0x0B() - => Assert.Equal(0x0Bu, (uint)WeenieError.NoMotionInterpreter); - - [Fact] - public void NotGrounded_Is0x24() - => Assert.Equal(0x24u, (uint)WeenieError.NotGrounded); - - [Fact] - public void CrouchInCombatStance_Is0x3f() - => Assert.Equal(0x3fu, (uint)WeenieError.CrouchInCombatStance); - - [Fact] - public void SitInCombatStance_Is0x40() - => Assert.Equal(0x40u, (uint)WeenieError.SitInCombatStance); - - [Fact] - public void SleepInCombatStance_Is0x41() - => Assert.Equal(0x41u, (uint)WeenieError.SleepInCombatStance); - - [Fact] - public void ChatEmoteOutsideNonCombat_Is0x42() - => Assert.Equal(0x42u, (uint)WeenieError.ChatEmoteOutsideNonCombat); - - /// - /// 0x36 — ActionCancelled. R4-V1 addition (M12). Site: - /// MoveToManager::PerformMovement (§3a @0052a901) — every new - /// moveto cancels the previous one with this code before dispatching; - /// also CPhysicsObj::interrupt_current_movement's - /// MovementManager::CancelMoveTo(0x36) call (§9e). Per §7c, the - /// arg is NEVER READ inside MoveToManager::CancelMoveTo's body in - /// this build — kept for parity/logging, not behavior. - /// - [Fact] - public void ActionCancelled_Is0x36() - => Assert.Equal(0x36u, (uint)WeenieError.ActionCancelled); - - /// - /// 0x37 — ObjectGone. R4-V1 addition (M12). Site: - /// MoveToManager::HandleUpdateTarget (§6d @307866-307867) — - /// retarget delivery with a non-OK target status. - /// - [Fact] - public void ObjectGone_Is0x37() - => Assert.Equal(0x37u, (uint)WeenieError.ObjectGone); - - /// - /// 0x38 — NoObject. R4-V1 addition (M12). Site: - /// MoveToManager::HandleUpdateTarget (§6d @307857-307858) — the - /// FIRST target callback arrives with a non-OK status (target never - /// resolved). - /// - [Fact] - public void NoObject_Is0x38() - => Assert.Equal(0x38u, (uint)WeenieError.NoObject); - - [Fact] - public void ActionDepthExceeded_Is0x45() - => Assert.Equal(0x45u, (uint)WeenieError.ActionDepthExceeded); - - [Fact] - public void GeneralMovementFailure_Is0x47() - => Assert.Equal(0x47u, (uint)WeenieError.GeneralMovementFailure); - - [Fact] - public void YouCantJumpFromThisPosition_Is0x48() - => Assert.Equal(0x48u, (uint)WeenieError.YouCantJumpFromThisPosition); - - [Fact] - public void CantJumpLoadedDown_Is0x49() - => Assert.Equal(0x49u, (uint)WeenieError.CantJumpLoadedDown); - - /// - /// 0x3D — YouChargedTooFar. R4-V1 addition (M12). Site: - /// MoveToManager::HandleMoveToPosition Phase 2 arrival check — - /// fail_distance exceeded (r4-moveto-decomp.md §6b). - /// - [Fact] - public void YouChargedTooFar_Is0x3D() - => Assert.Equal(0x3Du, (uint)WeenieError.YouChargedTooFar); - - /// - /// Every code in the A10 table in one pass — guards against a - /// future partial edit desyncing an individual test above from the - /// enum declaration. - /// - [Theory] - [InlineData(WeenieError.None, 0x00u)] - [InlineData(WeenieError.NoPhysicsObject, 0x08u)] - [InlineData(WeenieError.NoMotionInterpreter, 0x0Bu)] - [InlineData(WeenieError.NotGrounded, 0x24u)] - [InlineData(WeenieError.ActionCancelled, 0x36u)] - [InlineData(WeenieError.ObjectGone, 0x37u)] - [InlineData(WeenieError.NoObject, 0x38u)] - [InlineData(WeenieError.CrouchInCombatStance, 0x3fu)] - [InlineData(WeenieError.SitInCombatStance, 0x40u)] - [InlineData(WeenieError.SleepInCombatStance, 0x41u)] - [InlineData(WeenieError.ChatEmoteOutsideNonCombat, 0x42u)] - [InlineData(WeenieError.ActionDepthExceeded, 0x45u)] - [InlineData(WeenieError.GeneralMovementFailure, 0x47u)] - [InlineData(WeenieError.YouCantJumpFromThisPosition, 0x48u)] - [InlineData(WeenieError.CantJumpLoadedDown, 0x49u)] - [InlineData(WeenieError.YouChargedTooFar, 0x3Du)] - public void A10Table_EveryCode_MatchesRetailNumericValue(WeenieError code, uint expected) - => Assert.Equal(expected, (uint)code); -} diff --git a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs index da508aab..2fdafc97 100644 --- a/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs +++ b/tests/AcDream.Core.Tests/Plugins/PluginLoaderTests.cs @@ -30,12 +30,6 @@ public class PluginLoaderTests public IPluginLogger Log { get; } = new StubLogger(); public IGameState State { get; } = new StubState(); public IEvents Events { get; } = new StubEvents(); - public IUiRegistry Ui { get; } = new StubUiRegistry(); - } - - private sealed class StubUiRegistry : IUiRegistry - { - public void AddMarkupPanel(string markupPath, object binding) { } } private sealed class StubLogger : IPluginLogger diff --git a/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs b/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs deleted file mode 100644 index e861f5fc..00000000 --- a/tests/AcDream.Core.Tests/Rendering/Issue176177DungeonSeamInspectionTests.cs +++ /dev/null @@ -1,624 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Numerics; -using DatReaderWriter; -using DatReaderWriter.DBObjs; -using DatReaderWriter.Options; -using Xunit; -using Xunit.Abstractions; -using Env = System.Environment; - -namespace AcDream.Core.Tests.Rendering; - -/// -/// #176 (purple flashing on dungeon floors at cell seams) + #177 (stairs pop -/// in/out across levels) — dat-truth inspection for the Facility Hub anchor -/// cells. The load-bearing topology fact from the #137 arc: corridor FLOORS -/// are portal polygons (PortalSide floor-portals to under-rooms, e.g. -/// 0x8A02016E visual polys 1/3/5 → 0x011E). These dumps answer: -/// -/// (a) are the floor-portal VISUAL polys textured (drawn by CellMesh.Build) -/// or NoPos-stippled (skipped)? Same question for the RECIPROCAL portal -/// polys in the other cell — two textured coincident planes would -/// z-fight (#176's angle-dependent flash candidate); -/// (b) which cell owns the actual stair-step geometry at the -/// 0x8A020182 → 0x8A020183 level transit (#177's pop-in subject); -/// (c) do any drawn polys reference surfaces that fail to resolve -/// (the magenta-placeholder class)? -/// -/// ⚠️ id-space trap (cost the #137 saga a wrong mechanism): -/// CellPortal.PolygonId indexes CellStruct.Polygons (VISUAL), never -/// PhysicsPolygons — same ids in both tables are unrelated polygons. -/// -public class Issue176177DungeonSeamInspectionTests -{ - private readonly ITestOutputHelper _out; - public Issue176177DungeonSeamInspectionTests(ITestOutputHelper output) => _out = output; - - private static string? ResolveDatDir() - { - var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR") - ?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile), - "Documents", "Asheron's Call"); - return Directory.Exists(datDir) ? datDir : null; - } - - private static Matrix4x4 WorldTransform(EnvCell cell) - { - var rot = new Quaternion( - cell.Position.Orientation.X, cell.Position.Orientation.Y, - cell.Position.Orientation.Z, cell.Position.Orientation.W); - return Matrix4x4.CreateFromQuaternion(rot) - * Matrix4x4.CreateTranslation( - cell.Position.Origin.X, cell.Position.Origin.Y, cell.Position.Origin.Z); - } - - private static (EnvCell cell, DatReaderWriter.Types.CellStruct cs)? LoadCell(DatCollection dats, uint cellId) - { - var envCell = dats.Get(cellId); - if (envCell is null) return null; - var environment = dats.Get(0x0D000000u | envCell.EnvironmentId); - if (environment is null) return null; - if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cs)) return null; - return (envCell, cs!); - } - - private static List WorldVerts( - DatReaderWriter.Types.CellStruct cs, DatReaderWriter.Types.Polygon poly, Matrix4x4 world) - { - var result = new List(poly.VertexIds.Count); - foreach (var vid in poly.VertexIds) - if (cs.VertexArray.Vertices.TryGetValue((ushort)vid, out var v)) - result.Add(Vector3.Transform(v.Origin, world)); - return result; - } - - /// - /// Mirror of CellMesh.Build's inclusion rules (verts ≥ 3, no NoPos - /// stippling, PosSurface index in range) — the DRAWN verdict per poly. - /// - private static bool WouldDraw(DatReaderWriter.Types.Polygon poly, EnvCell cell) => - poly.VertexIds.Count >= 3 - && !poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos) - && poly.PosSurface >= 0 - && poly.PosSurface < cell.Surfaces.Count; - - /// - /// (a)+(c): every CellPortal of the cell — the visual portal poly's - /// stippling/sides/surface, world plane, span, DRAWN verdict, and whether - /// the referenced Surface resolves in the dat. - /// - [Theory] - [InlineData(0x8A02016Eu)] // corridor with floor-portals 1/3/5 → 0x011E (#176 anchor) - [InlineData(0x8A02011Eu)] // the under-hall at z=−12 those portals lead to - [InlineData(0x8A02017Au)] // adjacent corridor cell (the #137 seam partner) - [InlineData(0x8A020182u)] // stair transit upper cell, z −6 (#177 anchor) - [InlineData(0x8A020183u)] // stair transit lower cell, z −9 (#177 anchor) - public void PortalPolys_SurfaceAndDrawVerdict_Dump(uint cellId) - { - var datDir = ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - var loaded = LoadCell(dats, cellId); - Assert.NotNull(loaded); - var (cell, cs) = loaded!.Value; - var world = WorldTransform(cell); - - _out.WriteLine($"=== 0x{cellId:X8} Env=0x{cell.EnvironmentId:X4} struct={cell.CellStructure} " + - $"pos=({cell.Position.Origin.X:F2},{cell.Position.Origin.Y:F2},{cell.Position.Origin.Z:F2}) ==="); - _out.WriteLine($" Surfaces ({cell.Surfaces.Count}): " + - string.Join(" ", cell.Surfaces.ConvertAll(s => $"0x{0x08000000u | (uint)s:X8}"))); - _out.WriteLine($" visualPolys={cs.Polygons.Count} physicsPolys={cs.PhysicsPolygons.Count} portals={cell.CellPortals.Count}"); - - // #177 pivot check: dungeon staircases are often EnvCell STATICS (the - // #119 tower class) — if one lives here, the vanish subject is the - // static's cull, not the shell flood. - _out.WriteLine($" StaticObjects={cell.StaticObjects.Count}"); - foreach (var so in cell.StaticObjects) - _out.WriteLine($" static id=0x{so.Id:X8} at ({so.Frame.Origin.X:F2},{so.Frame.Origin.Y:F2},{so.Frame.Origin.Z:F2})"); - - foreach (var p in cell.CellPortals) - { - if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly)) - { - _out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] NOT IN VISUAL TABLE"); - continue; - } - - var w = WorldVerts(cs, poly, world); - var n = w.Count >= 3 - ? Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0])) - : Vector3.Zero; - var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); - foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); } - - bool drawn = WouldDraw(poly, cell); - string surfInfo = "posSurf=OUT-OF-RANGE"; - if (poly.PosSurface >= 0 && poly.PosSurface < cell.Surfaces.Count) - { - uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface]; - var surface = dats.Get(surfaceId); - surfInfo = surface is null - ? $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} SURFACE-MISS" - : $"posSurf[{poly.PosSurface}]=0x{surfaceId:X8} type={surface.Type} origTex=0x{(uint)surface.OrigTextureId:X8}"; - } - - _out.WriteLine( - $" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] " + - $"stip={poly.Stippling} sides={poly.SidesType} verts={poly.VertexIds.Count} " + - $"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) " + - $"x=[{min.X:F2},{max.X:F2}] y=[{min.Y:F2},{max.Y:F2}] z=[{min.Z:F2},{max.Z:F2}] " + - $"{surfInfo} DRAWN={drawn}"); - } - - // (c) sweep: any DRAWN poly in the whole cell whose surface misses. - int drawnCount = 0, missCount = 0; - foreach (var (id, poly) in cs.Polygons) - { - if (!WouldDraw(poly, cell)) continue; - drawnCount++; - uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface]; - if (dats.Get(surfaceId) is null) - { - missCount++; - _out.WriteLine($" >>> DRAWN poly {id} has MISSING surface 0x{surfaceId:X8}"); - } - } - _out.WriteLine($" drawn-poly sweep: {drawnCount} drawn, {missCount} with missing surfaces"); - } - - /// - /// (a) reciprocal check: for each anchor pair, world-transform BOTH - /// sides' portal polys and test plane coincidence + both-drawn — the - /// #176 z-fight candidate is a coincident pair with DRAWN=true twice. - /// - [Theory] - [InlineData(0x8A02016Eu, 0x8A02011Eu)] - [InlineData(0x8A02016Eu, 0x8A02017Au)] - [InlineData(0x8A020182u, 0x8A020183u)] - public void ReciprocalPortalPolys_CoincidenceAndDrawVerdict(uint cellA, uint cellB) - { - var datDir = ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - var la = LoadCell(dats, cellA); - var lb = LoadCell(dats, cellB); - Assert.NotNull(la); - Assert.NotNull(lb); - var (ca, csa) = la!.Value; - var (cb, csb) = lb!.Value; - var wa = WorldTransform(ca); - var wb = WorldTransform(cb); - - ushort lowA = (ushort)(cellA & 0xFFFFu); - ushort lowB = (ushort)(cellB & 0xFFFFu); - - _out.WriteLine($"=== reciprocal pair 0x{cellA:X8} <-> 0x{cellB:X8} ==="); - foreach (var pa in ca.CellPortals) - { - if (pa.OtherCellId != lowB) continue; - if (!csa.Polygons.TryGetValue((ushort)pa.PolygonId, out var polyA)) continue; - var va = WorldVerts(csa, polyA, wa); - if (va.Count < 3) continue; - var na = Vector3.Normalize(Vector3.Cross(va[1] - va[0], va[2] - va[0])); - float da = Vector3.Dot(na, va[0]); - bool drawnA = WouldDraw(polyA, ca); - - _out.WriteLine($" A poly={pa.PolygonId} [{pa.Flags}] n=({na.X:F2},{na.Y:F2},{na.Z:F2}) planeD={da:F2} " + - $"stip={polyA.Stippling} sides={polyA.SidesType} DRAWN={drawnA}"); - - foreach (var pb in cb.CellPortals) - { - if (pb.OtherCellId != lowA) continue; - if (!csb.Polygons.TryGetValue((ushort)pb.PolygonId, out var polyB)) continue; - var vb = WorldVerts(csb, polyB, wb); - if (vb.Count < 3) continue; - var nb = Vector3.Normalize(Vector3.Cross(vb[1] - vb[0], vb[2] - vb[0])); - float db = Vector3.Dot(nb, vb[0]); - bool drawnB = WouldDraw(polyB, cb); - - float align = Vector3.Dot(na, nb); - // Coincident planes: |align|≈1 and same plane offset (sign per normal direction). - bool coincident = MathF.Abs(align) > 0.99f - && MathF.Abs(MathF.Abs(da) - MathF.Abs(db)) < 0.05f; - - _out.WriteLine($" B poly={pb.PolygonId} [{pb.Flags}] n=({nb.X:F2},{nb.Y:F2},{nb.Z:F2}) planeD={db:F2} " + - $"stip={polyB.Stippling} sides={polyB.SidesType} DRAWN={drawnB} " + - $"align={align:F3} coincident={coincident}" + - (coincident && drawnA && drawnB ? " >>> Z-FIGHT CANDIDATE (both drawn, same plane)" : "")); - } - } - } - - /// - /// #176 THE STRIPES (user screenshot, 2026-07-06 evening): a floor region - /// z-fights in regular bands between a purple-lit copy and an unlit copy — - /// two COINCIDENT DRAWN surfaces with different per-cell light sets. This - /// sweep hunts the pair in the dat: every pair of DRAWN polys across the - /// corridor neighborhood that is coplanar AND overlapping in area. Before - /// the light-cap fix both copies were usually equally unlit (the purple - /// portal light was cap-evicted) so the fight was invisible; the stable - /// light exposed it. - /// - [Fact] - public void CorridorNeighborhood_CoplanarOverlappingDrawnPolyPairs() - { - var datDir = ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - // Seed cells around the screenshot location (the 016E/017A seam) + - // one portal ring. - var cellIds = new HashSet { 0x8A020165u, 0x8A02016Eu, 0x8A02017Au }; - foreach (var seed in new List(cellIds)) - { - var seedCell = dats.Get(seed); - if (seedCell is null) continue; - foreach (var p in seedCell.CellPortals) - cellIds.Add(0x8A020000u | p.OtherCellId); - } - - // Collect all DRAWN polys world-space per cell. - var drawn = new List<(uint CellId, ushort PolyId, Vector3 N, float D, - Vector3 Min, Vector3 Max, uint SurfaceId)>(); - foreach (var cellId in cellIds) - { - var loaded = LoadCell(dats, cellId); - if (loaded is null) continue; - var (cell, cs) = loaded.Value; - var world = WorldTransform(cell); - - foreach (var (id, poly) in cs.Polygons) - { - if (!WouldDraw(poly, cell)) continue; - var w = WorldVerts(cs, poly, world); - if (w.Count < 3) continue; - var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0])); - float d = Vector3.Dot(n, w[0]); - var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); - foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); } - uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface]; - drawn.Add((cellId, id, n, d, min, max, surfaceId)); - } - } - _out.WriteLine($"cells={cellIds.Count} drawnPolys={drawn.Count}"); - - int pairs = 0; - for (int i = 0; i < drawn.Count; i++) - { - for (int j = i + 1; j < drawn.Count; j++) - { - var a = drawn[i]; var b = drawn[j]; - if (a.CellId == b.CellId && a.PolyId == b.PolyId) continue; - - float align = Vector3.Dot(a.N, b.N); - if (MathF.Abs(align) < 0.999f) continue; - float dB = align > 0 ? b.D : -b.D; - if (MathF.Abs(a.D - dB) > 0.02f) continue; // same plane within 2 cm - - // Overlap in world AABB, with meaningful area in the plane. - float ox = MathF.Min(a.Max.X, b.Max.X) - MathF.Max(a.Min.X, b.Min.X); - float oy = MathF.Min(a.Max.Y, b.Max.Y) - MathF.Max(a.Min.Y, b.Min.Y); - float oz = MathF.Min(a.Max.Z, b.Max.Z) - MathF.Max(a.Min.Z, b.Min.Z); - if (ox < 0.05f || oy < 0.05f) continue; - // For horizontal planes require XY overlap area; for walls allow thin Z. - bool horizontal = MathF.Abs(a.N.Z) > 0.85f; - if (horizontal && ox * oy < 0.05f) continue; - if (!horizontal && oz < 0.05f) continue; - - pairs++; - _out.WriteLine( - $">>> COPLANAR-OVERLAP {(a.CellId == b.CellId ? "SAME-CELL" : "CROSS-CELL")}: " + - $"0x{a.CellId:X8} poly {a.PolyId} (surf 0x{a.SurfaceId:X8}) <-> " + - $"0x{b.CellId:X8} poly {b.PolyId} (surf 0x{b.SurfaceId:X8}) " + - $"n=({a.N.X:F2},{a.N.Y:F2},{a.N.Z:F2}) align={align:F3} " + - $"overlap x={ox:F2} y={oy:F2} z=[{MathF.Max(a.Min.Z, b.Min.Z):F2}..{MathF.Min(a.Max.Z, b.Max.Z):F2}]"); - } - } - _out.WriteLine($"coplanar overlapping drawn pairs: {pairs}"); - } - - /// - /// #176 candidate (A2C): the opaque pass derives GL_SAMPLE_ALPHA_TO_COVERAGE - /// from the sampled texture alpha (mesh_modern.frag uRenderPass==0 keeps - /// alpha as-sampled). If the corridor floor texture decodes with alpha - /// below 1.0, MSAA coverage punches see-through holes in the floor — - /// fog-purple clear color — worst at grazing angles (mip-level dependent - /// → camera-angle dependent, far floor = at seams). Decode the floor - /// surface chain and histogram the alpha channel. - /// - [Theory] - [InlineData(0x08000377u)] // corridor floor (portal strips + plain floors) - [InlineData(0x08000376u)] - [InlineData(0x08000375u)] - [InlineData(0x08000378u)] - [InlineData(0x08000379u)] // under-level walls - [InlineData(0x080000DFu)] // stair-transit cells' 5th surface - public void FloorSurface_DecodedAlphaHistogram(uint surfaceId) - { - var datDir = ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - var surface = dats.Get(surfaceId); - Assert.NotNull(surface); - _out.WriteLine($"Surface 0x{surfaceId:X8}: type={surface!.Type} origTex=0x{(uint)surface.OrigTextureId:X8} " + - $"transl={surface.Translucency:F2}"); - if (surface.OrigTextureId == 0) { _out.WriteLine(" (no texture — solid color surface)"); return; } - - var surfTex = dats.Get((uint)surface.OrigTextureId); - Assert.NotNull(surfTex); - _out.WriteLine($" SurfaceTexture 0x{(uint)surface.OrigTextureId:X8}: {surfTex!.Textures.Count} textures " + - $"[{string.Join(" ", surfTex.Textures.ConvertAll(t => $"0x{t:X8}"))}]"); - - foreach (var texId in surfTex.Textures) - { - var rs = dats.Get((uint)texId); - if (rs is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: MISS"); continue; } - - // Decode with the production Core helpers (same paths the WB atlas uses). - var data = new byte[rs.Width * rs.Height * 4]; - bool decodedOk = true; - switch (rs.Format) - { - case DatReaderWriter.Enums.PixelFormat.PFID_INDEX16: - { - var pal = dats.Get(rs.DefaultPaletteId); - if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: INDEX16 with no palette 0x{rs.DefaultPaletteId:X8}"); decodedOk = false; break; } - AcDream.Core.Rendering.Wb.TextureHelpers.FillIndex16(rs.SourceData, pal, data, rs.Width, rs.Height); - break; - } - case DatReaderWriter.Enums.PixelFormat.PFID_P8: - { - var pal = dats.Get(rs.DefaultPaletteId); - if (pal is null) { _out.WriteLine($" RenderSurface 0x{texId:X8}: P8 with no palette"); decodedOk = false; break; } - AcDream.Core.Rendering.Wb.TextureHelpers.FillP8(rs.SourceData, pal, data, rs.Width, rs.Height); - break; - } - case DatReaderWriter.Enums.PixelFormat.PFID_R5G6B5: - AcDream.Core.Rendering.Wb.TextureHelpers.FillR5G6B5(rs.SourceData, data, rs.Width, rs.Height); - break; - case DatReaderWriter.Enums.PixelFormat.PFID_A4R4G4B4: - AcDream.Core.Rendering.Wb.TextureHelpers.FillA4R4G4B4(rs.SourceData, data, rs.Width, rs.Height); - break; - case DatReaderWriter.Enums.PixelFormat.PFID_A8R8G8B8: - AcDream.Core.Rendering.Wb.TextureHelpers.FillA8R8G8B8(rs.SourceData, data, rs.Width, rs.Height); - break; - case DatReaderWriter.Enums.PixelFormat.PFID_R8G8B8: - AcDream.Core.Rendering.Wb.TextureHelpers.FillR8G8B8(rs.SourceData, data, rs.Width, rs.Height); - break; - case DatReaderWriter.Enums.PixelFormat.PFID_DXT1: - { - // DXT1/BC1: 8-byte blocks — c0 (u16 LE), c1 (u16 LE), 16×2-bit - // indices. c0 <= c1 selects 3-COLOR mode where index 3 decodes - // to TRANSPARENT BLACK (alpha=0). Our atlas uploads DXT1 as the - // RGBA variant (TextureFormatExtensions.ToCompressedGL), so any - // such texel reaches the shader with alpha=0 — and the opaque - // pass discards alpha<0.05 fragments. Count them. - int blocks = rs.SourceData.Length / 8; - int threeColorBlocks = 0; - long transparentTexels = 0; - for (int b = 0; b < blocks; b++) - { - int o = b * 8; - ushort c0 = (ushort)(rs.SourceData[o] | (rs.SourceData[o + 1] << 8)); - ushort c1 = (ushort)(rs.SourceData[o + 2] | (rs.SourceData[o + 3] << 8)); - if (c0 > c1) continue; // 4-color opaque mode - threeColorBlocks++; - for (int bi = 0; bi < 4; bi++) - { - byte row = rs.SourceData[o + 4 + bi]; - for (int t = 0; t < 4; t++) - if (((row >> (t * 2)) & 0x3) == 3) - transparentTexels++; - } - } - _out.WriteLine($" RenderSurface 0x{texId:X8} DXT1 {rs.Width}x{rs.Height}: blocks={blocks} " + - $"threeColorBlocks={threeColorBlocks} alpha0Texels={transparentTexels}" + - (transparentTexels > 0 - ? " >>> ALPHA=0 TEXELS PRESENT (opaque-pass discard punches holes)" - : " (no transparent-mode texels)")); - decodedOk = false; // histogram printed above; skip the RGBA path - break; - } - default: - _out.WriteLine($" RenderSurface 0x{texId:X8}: fmt={rs.Format} (not decoded by this test)"); - decodedOk = false; - break; - } - if (!decodedOk) continue; - - // Alpha histogram over the decoded RGBA bytes (stride 4, alpha at +3). - int n = data.Length / 4; - int a255 = 0, aHigh = 0, aMid = 0, aLow = 0, a0 = 0; - byte minA = 255; - for (int i = 0; i < n; i++) - { - byte a = data[i * 4 + 3]; - if (a < minA) minA = a; - if (a == 255) a255++; - else if (a >= 243) aHigh++; // ≥0.95 — safe for A2C - else if (a >= 13) aMid++; // 0.05..0.95 — partial coverage - else if (a > 0) aLow++; - else a0++; - } - _out.WriteLine($" RenderSurface 0x{texId:X8} fmt={rs.Format} {rs.Width}x{rs.Height}: " + - $"alpha histogram n={n} a=255:{a255} 243-254:{aHigh} 13-242:{aMid} 1-12:{aLow} 0:{a0} minA={minA}" + - (aMid + aLow + a0 > 0 ? " >>> SUB-OPAQUE ALPHA PRESENT (A2C hole candidate)" : " (fully opaque)")); - } - } - - /// - /// #176 candidate: the under-hall 0x011E floods in at down-pitches and - /// its surface list carries 0x08000034 (Base1Solid|Translucent — a - /// COLORED translucent solid). If its drawn polys sit at z=−6 (coplanar - /// with the corridor floor), the transparent pass blends that color over - /// the floor whenever the under-hall is admitted — angle-dependent - /// purple at seams. Dump every DRAWN poly (plane, z-span, surface, and - /// the surface's ColorValue) of the under-hall + its under-level - /// neighbors. - /// - [Theory] - [InlineData(0x8A02011Eu)] - [InlineData(0x8A020119u)] - [InlineData(0x8A02011Du)] - [InlineData(0x8A020122u)] - [InlineData(0x8A02011Fu)] - [InlineData(0x8A02016Eu)] // corridor cells — the striped-floor screenshot area - [InlineData(0x8A02017Au)] - [InlineData(0x8A020165u)] - public void UnderHall_DrawnPolys_SurfaceColors(uint cellId) - { - var datDir = ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - var loaded = LoadCell(dats, cellId); - if (loaded is null) { _out.WriteLine($"0x{cellId:X8} NOT FOUND"); return; } - var (cell, cs) = loaded.Value; - var world = WorldTransform(cell); - - _out.WriteLine($"=== 0x{cellId:X8} drawn polys ==="); - foreach (var (id, poly) in cs.Polygons) - { - if (!WouldDraw(poly, cell)) continue; - var w = WorldVerts(cs, poly, world); - if (w.Count < 3) continue; - var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0])); - float minZ = float.MaxValue, maxZ = float.MinValue; - foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); } - - uint surfaceId = 0x08000000u | (uint)cell.Surfaces[poly.PosSurface]; - var surface = dats.Get(surfaceId); - string surfInfo = surface is null - ? $"0x{surfaceId:X8} MISS" - : $"0x{surfaceId:X8} type={surface.Type} color=0x{surface.ColorValue:X8} origTex=0x{(uint)surface.OrigTextureId:X8} lum={surface.Luminosity:F2} transl={surface.Translucency:F2}"; - _out.WriteLine($" poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " + - $"verts={poly.VertexIds.Count} surf={surfInfo}"); - } - } - - /// - /// The transit-lag question (#176/#177 root-cause fork): production - /// [cell-transit] lines fire 0.1–0.6 m PAST the portal plane. Is that - /// (a) dat-real — the cells' CellBSP volumes OVERLAP past the plane, so - /// retail's point_in_cell (same dat, same walk) keeps the old cell too — - /// or (b) our membership's bug? Probe raw CellBSP containment for both - /// cells of each seam across the portal plane. - /// - [Theory] - [InlineData(0x8A02016Eu, 0x8A02017Au, 85.00f, -40f, -5.0f)] - [InlineData(0x8A020182u, 0x8A020183u, 98.333f, -40f, -7.5f)] - public void SeamCells_CellBspContainment_AcrossPortalPlane( - uint cellAId, uint cellBId, float planeX, float y, float z) - { - var datDir = ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - var la = LoadCell(dats, cellAId); - var lb = LoadCell(dats, cellBId); - Assert.NotNull(la); - Assert.NotNull(lb); - var (ca, csa) = la!.Value; - var (cb, csb) = lb!.Value; - - Matrix4x4.Invert(WorldTransform(ca), out var invA); - Matrix4x4.Invert(WorldTransform(cb), out var invB); - - _out.WriteLine($"=== CellBSP containment across plane x={planeX:F2} " + - $"(A=0x{cellAId:X8}, B=0x{cellBId:X8}) ==="); - for (float dx = -0.6f; dx <= 0.65f; dx += 0.05f) - { - var world = new Vector3(planeX + dx, y, z); - bool inA = csa.CellBSP?.Root is not null - && Physics.PointInCellBspViaBspQuery(csa.CellBSP.Root, Vector3.Transform(world, invA)); - bool inB = csb.CellBSP?.Root is not null - && Physics.PointInCellBspViaBspQuery(csb.CellBSP.Root, Vector3.Transform(world, invB)); - _out.WriteLine($" x=plane{(dx >= 0 ? "+" : "")}{dx:F2} inA={(inA ? "Y" : "-")} inB={(inB ? "Y" : "-")}" + - (inA && inB ? " <<< OVERLAP" : !inA && !inB ? " <<< NEITHER" : "")); - } - } - - private static class Physics - { - // Thin forwarder so this Rendering-side test reads clearly; the walk - // is the production BSPQuery.PointInsideCellBsp. - public static bool PointInCellBspViaBspQuery( - DatReaderWriter.Types.CellBSPNode node, Vector3 localPoint) - => AcDream.Core.Physics.BSPQuery.PointInsideCellBsp(node, localPoint); - } - - /// - /// (b) #177: which cell owns the stair-step geometry? Histogram of DRAWN - /// visual polys by normal class + the z-ladder of horizontal polys - /// (stair steps show as a ladder of small floor polys at stepped - /// z-levels). Also lists every portal with its plane orientation — - /// is the 0x0182↔0x0183 connection a floor-portal or a wall-portal? - /// - [Theory] - [InlineData(0x8A020182u)] - [InlineData(0x8A020183u)] - public void StairTransit_GeometryOwnerAndPortalOrientation(uint cellId) - { - var datDir = ResolveDatDir(); - if (datDir is null) { _out.WriteLine("SKIP: no dat dir"); return; } - using var dats = new DatCollection(datDir, DatAccessType.Read); - - var loaded = LoadCell(dats, cellId); - Assert.NotNull(loaded); - var (cell, cs) = loaded!.Value; - var world = WorldTransform(cell); - - int floors = 0, ceilings = 0, walls = 0, inclined = 0; - var floorZLevels = new SortedDictionary(); // z rounded to 0.1 m → count - - foreach (var (id, poly) in cs.Polygons) - { - var w = WorldVerts(cs, poly, world); - if (w.Count < 3) continue; - var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0])); - float az = MathF.Abs(n.Z); - if (az > 0.85f) - { - // Horizontal poly — bucket by mean z. - float meanZ = 0; foreach (var v in w) meanZ += v.Z; meanZ /= w.Count; - int zKey = (int)MathF.Round(meanZ * 10f); - floorZLevels.TryGetValue(zKey, out var c); - floorZLevels[zKey] = c + 1; - if (n.Z > 0) floors++; else ceilings++; - } - else if (az < 0.25f) walls++; - else - { - inclined++; - float minZ = float.MaxValue, maxZ = float.MinValue; - foreach (var v in w) { minZ = MathF.Min(minZ, v.Z); maxZ = MathF.Max(maxZ, v.Z); } - _out.WriteLine($" INCLINED poly {id}: n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{minZ:F2},{maxZ:F2}] " + - $"verts={poly.VertexIds.Count} stip={poly.Stippling} DRAWN={WouldDraw(poly, cell)}"); - } - } - - _out.WriteLine($"=== 0x{cellId:X8} poly histogram: floors={floors} ceilings={ceilings} walls={walls} inclined={inclined} ==="); - _out.WriteLine(" horizontal-poly z-ladder (z → count): " + - string.Join(" ", System.Linq.Enumerable.Select(floorZLevels, kv => $"{kv.Key / 10f:F1}:{kv.Value}"))); - - foreach (var p in cell.CellPortals) - { - if (!cs.Polygons.TryGetValue((ushort)p.PolygonId, out var poly)) continue; - var w = WorldVerts(cs, poly, world); - if (w.Count < 3) continue; - var n = Vector3.Normalize(Vector3.Cross(w[1] - w[0], w[2] - w[0])); - string orient = MathF.Abs(n.Z) > 0.85f ? "HORIZONTAL (floor/ceiling portal)" - : MathF.Abs(n.Z) < 0.25f ? "vertical (wall portal)" - : "INCLINED portal"; - var min = new Vector3(float.MaxValue); var max = new Vector3(float.MinValue); - foreach (var v in w) { min = Vector3.Min(min, v); max = Vector3.Max(max, v); } - _out.WriteLine($" portal poly={p.PolygonId} -> 0x{p.OtherCellId:X4} [{p.Flags}] {orient} " + - $"n=({n.X:F2},{n.Y:F2},{n.Z:F2}) z=[{min.Z:F2},{max.Z:F2}]"); - } - } -} diff --git a/tests/AcDream.Core.Tests/Streaming/DungeonStreamingGateTests.cs b/tests/AcDream.Core.Tests/Streaming/DungeonStreamingGateTests.cs deleted file mode 100644 index 007bdf81..00000000 --- a/tests/AcDream.Core.Tests/Streaming/DungeonStreamingGateTests.cs +++ /dev/null @@ -1,67 +0,0 @@ -using AcDream.App.Streaming; -using Xunit; - -namespace AcDream.Core.Tests.Streaming; - -/// -/// The GameWindow side of the dungeon streaming gate (AP-36): given the player's -/// CURRENT cell, decide whether to collapse streaming and where to pin the observer. -/// The StreamingController's reaction to these inputs is covered by -/// ; this fixture covers the -/// INPUT computation — in particular the teleport-hold rule (#145/#138). -/// -public class DungeonStreamingGateTests -{ - // A 0x0007 Town Network dungeon cell (landblock 0x0007, cell 0x0145). - private const uint DungeonCell = 0x00070145u; - // An outdoor terrain cell near Holtburg (landblock 0xAB34, structure cell 0x0001). - private const uint OutdoorCell = 0xAB340001u; - - [Fact] - public void SealedDungeonCell_NotHold_CollapsesAndPinsObserverToCellLandblock() - { - var r = DungeonStreamingGate.Compute( - isTeleportHold: false, currCellIsSealedDungeon: true, currCellId: DungeonCell); - - Assert.True(r.InsideDungeon); - Assert.Equal(0x0007u, r.ObserverLandblockKey); // 0x00070145 >> 16 - } - - [Fact] - public void OutdoorCell_NotHold_NoCollapse_NoObserverOverride() - { - var r = DungeonStreamingGate.Compute( - isTeleportHold: false, currCellIsSealedDungeon: false, currCellId: OutdoorCell); - - Assert.False(r.InsideDungeon); - Assert.Null(r.ObserverLandblockKey); - } - - [Fact] - public void TeleportHold_StaleSealedDungeonCurrCell_SuppressesGate() - { - // #145/#138 — teleport OUT of a dungeon. During the arrival hold the player is - // unplaced, so CurrCell is still the frozen SOURCE dungeon cell. The gate MUST be - // suppressed: streaming has to follow the DESTINATION (the PortalSpace observer - // pin), not re-pin onto the source dungeon. The pre-fix logic returned - // (true, 0x0007) here, which kept streaming collapsed on the source dungeon → the - // outdoor destination never hydrated → 600-frame timeout → force-snap to ocean. - var r = DungeonStreamingGate.Compute( - isTeleportHold: true, currCellIsSealedDungeon: true, currCellId: DungeonCell); - - Assert.False(r.InsideDungeon); - Assert.Null(r.ObserverLandblockKey); - } - - [Fact] - public void TeleportHold_OutdoorCurrCell_AlsoNoCollapse() - { - // Sanity: an outdoor→outdoor or building→outdoor hold is already correct, but the - // suppression rule must not change that. - var r = DungeonStreamingGate.Compute( - isTeleportHold: true, currCellIsSealedDungeon: false, currCellId: OutdoorCell); - - Assert.False(r.InsideDungeon); - Assert.Null(r.ObserverLandblockKey); - } -} diff --git a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs index dfe186e1..814d2d51 100644 --- a/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/GpuWorldStateTests.cs @@ -92,47 +92,6 @@ public class GpuWorldStateTests Assert.Equal(1, state.PendingLiveEntityCount); // 0xAAAAFFFF entry still parked } - [Fact] - public void RelocateEntity_StrandedInPending_MovesToLoadedTarget() - { - // Regression: the cold-spawn / run-out "invisible player" bug - // (2026-07-03). A persistent (server-spawned) entity that spawned into a - // not-yet-loaded landblock is parked in the pending bucket. The per-frame - // RelocateEntity is supposed to keep it homed to its current landblock so - // it draws — but the old implementation scanned ONLY _loaded, so it - // silently no-op'd on a pending entity and the player stayed hidden - // forever. Confirmed live: "[ent] APPEND guid=0x5000000A -> PENDING(hidden)" - // with no later DRAWSET PRESENT, even after the landblock loaded (its - // sibling NPCs re-hydrated into it fine — the player was excluded). - var state = new GpuWorldState(); - - var player = new WorldEntity - { - Id = 1, - ServerGuid = 0x5000000Au, // server-spawned + persistent - SourceGfxObjOrSetupId = 0x01000001u, - Position = System.Numerics.Vector3.Zero, - Rotation = System.Numerics.Quaternion.Identity, - MeshRefs = Array.Empty(), - }; - state.MarkPersistent(0x5000000Au); - - // Spawned before its landblock streamed in → parked in pending, hidden. - state.AppendLiveEntity(0xADAF0011u, player); - Assert.Empty(state.Entities); - Assert.Equal(1, state.PendingLiveEntityCount); - - // The player's current landblock IS loaded now (the live log shows the - // sibling NPCs re-hydrated into it). RelocateEntity — called every frame - // to keep the player homed to its current landblock — must promote the - // stranded pending entity into the loaded bucket so it draws. - state.AddLandblock(MakeStubLandblock(0xBBBBFFFFu)); - state.RelocateEntity(player, 0xBBBB0011u); - - Assert.Single(state.Entities); // now drawn - Assert.Equal(0, state.PendingLiveEntityCount); // no longer stranded - } - [Fact] public void RemoveLandblock_DropsPendingForThatLandblock() { @@ -178,51 +137,4 @@ public class GpuWorldStateTests state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu)); Assert.True(state.IsLoaded(0xA9B4FFFFu)); } - - private static WorldEntity MakeServerEntity(uint id, uint serverGuid) - => new() - { - Id = id, - ServerGuid = serverGuid, - SourceGfxObjOrSetupId = 0x01000001u, - Position = System.Numerics.Vector3.Zero, - Rotation = System.Numerics.Quaternion.Identity, - MeshRefs = Array.Empty(), - }; - - [Fact] - public void RemoveLandblock_RescuesPersistentEntity_FromPendingBucket() - { - // #138 (secondary): the player is re-injected via AppendLiveEntity every - // frame; right after a teleport its landblock hasn't streamed in, so it - // lands in the pending bucket. If that landblock is then unloaded before - // it finishes loading, the persistent entity must still be rescued — - // otherwise the avatar vanishes after a couple round-trips. - var state = new GpuWorldState(); - const uint playerGuid = 0x50000001u; - state.MarkPersistent(playerGuid); - - var player = MakeServerEntity(id: 1, serverGuid: playerGuid); - state.AppendLiveEntity(0xA9B40011u, player); // landblock not loaded → pending - Assert.Equal(1, state.PendingLiveEntityCount); - - state.RemoveLandblock(0xA9B4FFFFu); // unloaded while still pending - - Assert.Contains(player, state.DrainRescued()); // rescued, not dropped - } - - [Fact] - public void RemoveLandblock_DoesNotRescue_NonPersistentPendingEntity() - { - // A normal server object (door) in the pending bucket is NOT persistent; - // it must still be dropped (and is recoverable via #138 re-hydrate from - // _lastSpawnByGuid, not via the rescue path). - var state = new GpuWorldState(); - var door = MakeServerEntity(id: 2, serverGuid: 0x7A9B4001u); // not marked persistent - state.AppendLiveEntity(0xA9B40011u, door); - - state.RemoveLandblock(0xA9B4FFFFu); - - Assert.Empty(state.DrainRescued()); - } } diff --git a/tests/AcDream.Core.Tests/Streaming/LandblockEntityRehydratorTests.cs b/tests/AcDream.Core.Tests/Streaming/LandblockEntityRehydratorTests.cs deleted file mode 100644 index db7bc597..00000000 --- a/tests/AcDream.Core.Tests/Streaming/LandblockEntityRehydratorTests.cs +++ /dev/null @@ -1,128 +0,0 @@ -using System.Collections.Generic; -using AcDream.App.Streaming; -using Xunit; - -namespace AcDream.Core.Tests.Streaming; - -/// -/// #138 — selection logic for re-hydrating server objects when a landblock -/// reloads after a dungeon collapse (or a Near→Far demote). See -/// for the retail/ACE rationale. -/// -public class LandblockEntityRehydratorTests -{ - private const uint PlayerGuid = 0x50000001u; - // A door spawned in cell 0x00070123 of dungeon landblock 0x0007; its - // cell-resolved spawn id must match the streamed canonical id 0x0007FFFF. - private const uint DungeonLb = 0x0007FFFFu; - private const uint DoorGuid = 0x7A9B4001u; - private const uint DoorSpawnId = 0x00070123u; - - private static LandblockEntityRehydrator.RetainedSpawn Spawn( - uint guid, uint spawnLbId, bool hasMesh = true) - => new(guid, spawnLbId, hasMesh); - - [Fact] - public void RetainedSpawnInLoadedLandblock_NotPresent_IsSelected() - { - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, - new[] { Spawn(DoorGuid, DoorSpawnId) }, - new HashSet(), - PlayerGuid); - - Assert.Equal(new[] { DoorGuid }, result); - } - - [Fact] - public void CellResolvedSpawnId_MatchesCanonicalLoadedId() - { - // The streamed landblock id is canonical (0xAAAAFFFF); the spawn id is - // cell-resolved (0xAAAA00CC). They name the same landblock, so the door - // must be selected — the canonicalization is the load-bearing step. - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, // 0x0007FFFF - new[] { Spawn(DoorGuid, 0x000701A9u) }, // a different cell, same landblock - new HashSet(), - PlayerGuid); - - Assert.Equal(new[] { DoorGuid }, result); - } - - [Fact] - public void SpawnInDifferentLandblock_IsNotSelected() - { - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, - new[] { Spawn(DoorGuid, 0xA9B30123u) }, // Holtburg, not the loaded dungeon - new HashSet(), - PlayerGuid); - - Assert.Empty(result); - } - - [Fact] - public void AlreadyPresentGuid_IsNotSelected() - { - // Already rendered (server re-sent it, or it was never dropped) — a - // re-hydrate would be a redundant mesh rebuild. - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, - new[] { Spawn(DoorGuid, DoorSpawnId) }, - new HashSet { DoorGuid }, - PlayerGuid); - - Assert.Empty(result); - } - - [Fact] - public void PlayerGuid_IsNeverSelected() - { - // The player has a retained spawn too, but the persistent-rescue path - // owns it (preserves its live pose). Re-hydrating would reset it. - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, - new[] { Spawn(PlayerGuid, DoorSpawnId) }, - new HashSet(), - PlayerGuid); - - Assert.Empty(result); - } - - [Fact] - public void SpawnWithoutWorldMesh_IsNotSelected() - { - // Inventory items / setup-less spawns build no visible entity. - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, - new[] { Spawn(DoorGuid, DoorSpawnId, hasMesh: false) }, - new HashSet(), - PlayerGuid); - - Assert.Empty(result); - } - - [Fact] - public void MixedSet_SelectsOnlyAbsentWorldObjectsInLoadedLandblock() - { - uint npcGuid = 0x7A9B4002u; // same dungeon landblock, absent → select - uint chestGuid = 0x7A9B4003u; // same landblock but already present → skip - uint holtburgGuid = 0x7A9B4004u; // different landblock → skip - uint heldItemGuid = 0x7A9B4005u; // no world mesh → skip - - var spawns = new[] - { - Spawn(DoorGuid, DoorSpawnId), // select - Spawn(npcGuid, 0x00070177u), // select (same lb, different cell) - Spawn(chestGuid, 0x00070123u), // skip (present) - Spawn(holtburgGuid, 0xA9B30100u), // skip (other lb) - Spawn(heldItemGuid, 0x00070123u, hasMesh: false), // skip (no mesh) - Spawn(PlayerGuid, 0x00070100u), // skip (player) - }; - - var result = LandblockEntityRehydrator.SelectGuidsToRehydrate( - DungeonLb, spawns, new HashSet { chestGuid }, PlayerGuid); - - Assert.Equal(new HashSet { DoorGuid, npcGuid }, new HashSet(result)); - } -} diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs deleted file mode 100644 index 522a4d07..00000000 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs +++ /dev/null @@ -1,257 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using AcDream.App.Streaming; -using AcDream.Core.World; -using Xunit; - -namespace AcDream.Core.Tests.Streaming; - -/// -/// The dungeon streaming gate (#133 FPS). AC dungeons have no adjacent -/// landblocks (ACE LandblockManager.GetAdjacentIDs returns empty for a -/// dungeon); they sit packed in the ocean grid, so the normal 25×25 window -/// pulls in ~129 unrelated neighbor dungeons + their emitters. When the player -/// is inside a sealed dungeon cell, Tick(insideDungeon: true) collapses -/// streaming to the single dungeon landblock and unloads the neighbors. -/// -public class StreamingControllerDungeonGateTests -{ - private static uint Encode(int x, int y) => ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu; - - private static LoadedLandblock MakeLb(int x, int y) => new LoadedLandblock( - Encode(x, y), - Heightmap: null!, - Entities: Array.Empty()); - - private sealed record Harness( - StreamingController Ctrl, - List<(uint Id, LandblockStreamJobKind Kind)> Loads, - List Unloads, - Func ClearCalls, - GpuWorldState State); - - private static Harness Make() - { - var loads = new List<(uint, LandblockStreamJobKind)>(); - var unloads = new List(); - int clearCalls = 0; - var state = new GpuWorldState(); - var ctrl = new StreamingController( - enqueueLoad: (id, kind) => loads.Add((id, kind)), - enqueueUnload: unloads.Add, - drainCompletions: _ => Array.Empty(), - applyTerrain: (_, _) => { }, - state: state, - nearRadius: 4, - farRadius: 12, - clearPendingLoads: () => clearCalls++); - return new Harness(ctrl, loads, unloads, () => clearCalls, state); - } - - [Fact] - public void EntersDungeon_CancelsPending_UnloadsNeighbors_KeepsCenter() - { - var h = Make(); - uint center = Encode(0, 7); - h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock - h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon - h.State.AddLandblock(MakeLb(1, 7)); // another neighbor - - h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true); - - Assert.Equal(1, h.ClearCalls()); // in-flight window load cancelled - Assert.Contains(Encode(0, 8), h.Unloads); // neighbor unloaded - Assert.Contains(Encode(1, 7), h.Unloads); // neighbor unloaded - Assert.DoesNotContain(center, h.Unloads); // dungeon landblock kept - Assert.DoesNotContain(h.Loads, l => l.Id == center); // already loaded → no reload - } - - [Fact] - public void EntersDungeon_CenterNotLoaded_EnqueuesCenterLoad() - { - var h = Make(); // empty state — the dungeon landblock isn't resident yet - - h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true); - - Assert.Equal(1, h.ClearCalls()); - Assert.Contains(h.Loads, l => l.Id == Encode(0, 7) - && l.Kind == LandblockStreamJobKind.LoadNear); - } - - [Fact] - public void StayingCollapsed_SweepsStragglerThatFinishedAfterTheEdge() - { - var h = Make(); - h.State.AddLandblock(MakeLb(0, 7)); - h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse edge - h.Unloads.Clear(); - - // A Load the worker had already dequeued before ClearLoads now completes. - h.State.AddLandblock(MakeLb(0, 8)); - h.Ctrl.Tick(0, 7, insideDungeon: true); // sweep - - Assert.Contains(Encode(0, 8), h.Unloads); - Assert.DoesNotContain(Encode(0, 7), h.Unloads); - } - - [Fact] - public void StayingCollapsed_DoesNotReClearOrReloadCenter() - { - var h = Make(); - h.State.AddLandblock(MakeLb(0, 7)); - h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse (clear #1) - h.Loads.Clear(); - - h.Ctrl.Tick(0, 7, insideDungeon: true); // stay collapsed - - Assert.Equal(1, h.ClearCalls()); // clear only fired on the edge - Assert.Empty(h.Loads); // no spurious center reloads - } - - [Fact] - public void Collapsed_CurrCellFlickersToAdjacentOffByOne_DoesNotExpand() - { - // Regression: the live run broke because a dungeon cell's negative local-Y - // makes the position-derived observer landblock land one row off (0,7→0,6). - // When CurrCell flickers null mid-frame, GameWindow stops overriding to the - // cell landblock and passes that adjacent (0,6). The Chebyshev>1 guard must - // treat that as a flicker and HOLD — never expand (which would unload the - // real dungeon and re-stream the 25×25 neighbor window). - var h = Make(); - h.State.AddLandblock(MakeLb(0, 7)); - h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse onto the dungeon (0,7) - h.Loads.Clear(); - h.Unloads.Clear(); - - h.Ctrl.Tick(0, 6, insideDungeon: false); // flicker → adjacent off-by-one - - Assert.Empty(h.Loads); // NO full-window reload - Assert.Empty(h.Unloads); // dungeon (0,7) preserved; nothing else resident - } - - [Fact] - public void ExitsDungeon_RebuildsFullWindow_UnloadsStaleDungeonLandblock() - { - var h = Make(); - h.State.AddLandblock(MakeLb(0, 7)); - h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse - h.Loads.Clear(); - h.Unloads.Clear(); - - // Exit through a portal to an outdoor location far from the dungeon block. - h.Ctrl.Tick(observerCx: 100, observerCy: 100, insideDungeon: false); - - Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear); - Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); - Assert.Contains(Encode(0, 7), h.Unloads); // stale dungeon block, outside new window - } - - [Fact] - public void PreCollapse_BeforeAnyTick_LoadsOnlyDungeon_NeverBootstrapsWindow() - { - // #135: at a dungeon login/teleport we pre-collapse the instant we recenter, - // BEFORE the first Tick. The full 25×25 neighbor window must NEVER be enqueued - // — only the single dungeon landblock loads. - var h = Make(); // empty state — nothing resident, _region is null - - h.Ctrl.PreCollapseToDungeon(0, 7); - - Assert.Single(h.Loads); // exactly one load - Assert.Equal(Encode(0, 7), h.Loads[0].Id); // the dungeon landblock - Assert.Equal(LandblockStreamJobKind.LoadNear, h.Loads[0].Kind); - Assert.DoesNotContain(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); - } - - [Fact] - public void PreCollapse_AfterBootstrapTick_CancelsWindow_UnloadsResidentNeighbors_KeepsDungeon() - { - // The REAL runtime ordering at a dungeon login: the per-frame streaming Tick - // runs FIRST and bootstraps the full 25×25 window, THEN the spawn handler fires - // PreCollapseToDungeon. The pre-collapse must cancel the queued window loads - // (_clearPendingLoads) and unload any neighbor that already finished streaming. - var h = Make(); - - h.Ctrl.Tick(0, 7, insideDungeon: false); // frame 1: NormalTick bootstraps the window - Assert.True(h.Loads.Count > 1); // the full window was enqueued - - // Simulate neighbor landblocks that finished loading during the bootstrap, - // before the collapse edge. - h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock itself - h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon that loaded - h.State.AddLandblock(MakeLb(1, 7)); // another neighbor - h.Loads.Clear(); - h.Unloads.Clear(); - - h.Ctrl.PreCollapseToDungeon(0, 7); - - Assert.Equal(1, h.ClearCalls()); // queued window loads cancelled - Assert.Contains(Encode(0, 8), h.Unloads); // resident neighbor unloaded - Assert.Contains(Encode(1, 7), h.Unloads); - Assert.DoesNotContain(Encode(0, 7), h.Unloads); // dungeon landblock kept - } - - [Fact] - public void PreCollapse_ThenHoldTicksWithStaleObserver_StaysCollapsed() - { - // After pre-collapse the player is held (CurrCell still null → insideDungeon - // false) while the dungeon hydrates. A stale observer that is the SAME dungeon - // landblock must keep streaming collapsed — no full-window reload. - var h = Make(); - h.Ctrl.PreCollapseToDungeon(0, 7); - h.Loads.Clear(); - h.Unloads.Clear(); - - h.Ctrl.Tick(0, 7, insideDungeon: false); // hold frame: not placed yet - - Assert.Empty(h.Loads); // no neighbor window - Assert.Empty(h.Unloads); - } - - [Fact] - public void PreCollapse_IsIdempotent_OnSameLandblock() - { - // A re-sent player spawn / a same-frame double call must not re-clear or - // re-enqueue. - var h = Make(); - h.Ctrl.PreCollapseToDungeon(0, 7); - h.Loads.Clear(); - - h.Ctrl.PreCollapseToDungeon(0, 7); - - Assert.Equal(1, h.ClearCalls()); // clear fired only on the first collapse - Assert.Empty(h.Loads); // no second dungeon load - } - - [Fact] - public void PreCollapse_ThenPlaced_InsideDungeonTick_StaysCollapsed() - { - // When placement finally fires, the per-frame Tick(insideDungeon: true) sees - // the same collapsed landblock and holds — no re-collapse churn. - var h = Make(); - h.State.AddLandblock(MakeLb(0, 7)); // dungeon landblock finished loading - h.Ctrl.PreCollapseToDungeon(0, 7); - h.Loads.Clear(); - h.Unloads.Clear(); - - h.Ctrl.Tick(0, 7, insideDungeon: true); // placed: gate now fires - - Assert.Equal(1, h.ClearCalls()); // no second clear - Assert.Empty(h.Loads); - Assert.DoesNotContain(Encode(0, 7), h.Unloads); - } - - [Fact] - public void NormalOutdoorTick_Unchanged_NoCollapseNoClear() - { - var h = Make(); - - h.Ctrl.Tick(observerCx: 100, observerCy: 100); // default insideDungeon: false - - Assert.Equal(0, h.ClearCalls()); - Assert.Empty(h.Unloads); - // 9 near (9×9? no — nearRadius 4 → 9×9=81) + far ring loads enqueued. - Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear); - Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar); - } -} diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs deleted file mode 100644 index 4668b867..00000000 --- a/tests/AcDream.Core.Tests/Streaming/StreamingControllerPriorityApplyTests.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using AcDream.App.Streaming; -using AcDream.Core.Terrain; -using AcDream.Core.World; -using DatReaderWriter.DBObjs; -using Xunit; - -namespace AcDream.Core.Tests.Streaming; - -public class StreamingControllerPriorityApplyTests -{ - private static LandblockStreamResult.Loaded LoadedOf(uint canonicalId) - => new(canonicalId, LandblockStreamTier.Near, - new LoadedLandblock(canonicalId, new LandBlock(), Array.Empty()), - new LandblockMeshData(Array.Empty(), Array.Empty())); - - [Fact] - public void PriorityLandblock_isApplied_evenWhenBeyondPerFrameCap() - { - uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); - var outbox = new Queue(new LandblockStreamResult[] - { - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)), - LoadedOf(priority), - }); - - var applied = new List(); - var state = new GpuWorldState(); - var ctrl = new StreamingController( - enqueueLoad: (_, _) => { }, - enqueueUnload: _ => { }, - drainCompletions: max => - { - var batch = new List(); - while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); - return batch; - }, - applyTerrain: (lb, _) => applied.Add(lb.LandblockId), - state: state, nearRadius: 4, farRadius: 12) - { MaxCompletionsPerFrame = 4 }; - - ctrl.PriorityLandblockId = priority; - ctrl.Tick(169, 180); - - Assert.Contains(priority, applied); // priority applied THIS tick - Assert.True(applied.Count <= 5); // did not blindly flush the whole outbox - } - - [Fact] - public void Deferred_nonPriority_completions_applyOnLaterFrames_withoutLoss() - { - // After the priority is found, the non-priority items drained past it must - // still be applied (over subsequent ticks), never dropped. - uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); - var outbox = new Queue(new LandblockStreamResult[] - { - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 0)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 1)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 2)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 3)), - LoadedOf(StreamingRegion.EncodeLandblockIdForTest(0, 4)), - LoadedOf(priority), - }); - var applied = new List(); - var state = new GpuWorldState(); - var ctrl = new StreamingController( - enqueueLoad: (_, _) => { }, - enqueueUnload: _ => { }, - drainCompletions: max => - { - var batch = new List(); - while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); - return batch; - }, - applyTerrain: (lb, _) => applied.Add(lb.LandblockId), - state: state, - nearRadius: 4, - farRadius: 12) - { MaxCompletionsPerFrame = 4 }; - ctrl.PriorityLandblockId = priority; - - ctrl.Tick(169, 180); // priority + some others - ctrl.PriorityLandblockId = 0u; - ctrl.Tick(169, 180); // drains the deferred remainder - ctrl.Tick(169, 180); - - Assert.Contains(priority, applied); - Assert.Equal(6, applied.Count); // all six applied, none lost - } - - [Fact] - public void PriorityNeverArrives_noThrow_noLoss_noDoubleApply() - { - // While a PriorityLandblockId is set but the matching completion never - // arrives (e.g. the load failed), the hunt moves outbox completions into - // _deferredApply and they drain at the per-frame budget — same backpressure - // as the normal throttle, just relocated — until the caller clears - // PriorityLandblockId (the TAS MaxContinue safety net does this on timeout). - uint priority = StreamingRegion.EncodeLandblockIdForTest(169, 180); - uint otherId0 = StreamingRegion.EncodeLandblockIdForTest(1, 0); - uint otherId1 = StreamingRegion.EncodeLandblockIdForTest(1, 1); - uint otherId2 = StreamingRegion.EncodeLandblockIdForTest(1, 2); - - var outbox = new Queue(new LandblockStreamResult[] - { - LoadedOf(otherId0), - LoadedOf(otherId1), - LoadedOf(otherId2), - }); - - var applied = new List(); - var state = new GpuWorldState(); - var ctrl = new StreamingController( - enqueueLoad: (_, _) => { }, - enqueueUnload: _ => { }, - drainCompletions: max => - { - var batch = new List(); - while (batch.Count < max && outbox.Count > 0) batch.Add(outbox.Dequeue()); - return batch; - }, - applyTerrain: (lb, _) => applied.Add(lb.LandblockId), - state: state, - nearRadius: 4, - farRadius: 12) - { MaxCompletionsPerFrame = 4 }; - - // Set a priority id that will NEVER appear in the outbox. - ctrl.PriorityLandblockId = priority; - - // Tick several times — should not throw and every non-priority completion - // must still be applied without duplication. - ctrl.Tick(169, 180); - ctrl.Tick(169, 180); - ctrl.Tick(169, 180); - - // (a) no throw — covered by reaching here without exception - // (b) every non-priority completion is applied (no loss) - Assert.Contains(otherId0, applied); - Assert.Contains(otherId1, applied); - Assert.Contains(otherId2, applied); - // (c) no double-apply: applied count matches completions enqueued - Assert.Equal(3, applied.Count); - } -} diff --git a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs b/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs deleted file mode 100644 index ffb4b427..00000000 --- a/tests/AcDream.Core.Tests/Textures/SurfaceDecoderSolidColorTests.cs +++ /dev/null @@ -1,17 +0,0 @@ -using AcDream.Core.Textures; -using Xunit; - -namespace AcDream.Core.Tests.Textures; - -public class SurfaceDecoderSolidColorTests -{ - [Fact] - public void DecodeSolidColor_NullColor_ReturnsMagenta_DoesNotThrow() - { - // A malformed Base1Solid surface can carry a null ColorValue. DecodeSolidColor - // is called outside DecodeRenderSurface's try/catch (from TextureCache), so it - // must be null-safe itself — return the undecodable sentinel, never NRE. - var result = SurfaceDecoder.DecodeSolidColor(null!, 0f); - Assert.Equal(DecodedTexture.Magenta, result); - } -} diff --git a/tests/AcDream.Core.Tests/World/PhysicsDatBundleTests.cs b/tests/AcDream.Core.Tests/World/PhysicsDatBundleTests.cs deleted file mode 100644 index 3178e01a..00000000 --- a/tests/AcDream.Core.Tests/World/PhysicsDatBundleTests.cs +++ /dev/null @@ -1,18 +0,0 @@ -using AcDream.Core.World; -using Xunit; - -namespace AcDream.Core.Tests.World; - -public class PhysicsDatBundleTests -{ - [Fact] - public void Empty_ReturnsNullInfoAndEmptyMaps() - { - var b = PhysicsDatBundle.Empty; - Assert.Null(b.Info); - Assert.Empty(b.EnvCells); - Assert.Empty(b.Environments); - Assert.Empty(b.Setups); - Assert.Empty(b.GfxObjs); - } -} diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs index 4ceeddba..d07d0a64 100644 --- a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs @@ -100,23 +100,22 @@ public sealed class SkyDescLoaderTests { // The loader stores DirColor and DirBright RAW. The SunColor property // composes them via |sunVec| per retail's UpdateLightsInternal at - // 0x59b57c (decomp 424118) — diffuse = DirColor × |LScape::sunlight|. - // cdb-verified (reference-retail-ambient-values): |LScape::sunlight| == - // DirBright for every keyframe (world-space spherical vector, magnitude - // DirBright·sqrt(cos²P+sin²P) = DirBright). + // 0x59b57c (decomp 424118) — the diffuse magnitude is sqrt(x²+y²+z²) + // where the sun vector is built from heading/pitch/brightness with + // Y unscaled by brightness (decomp 261352). // // For this region: H=180°, P=70°, B=1.5 - // sunVec = 1.5 × (cos(70)·sin(180), cos(70)·cos(180), sin(70)) - // = (0, -0.513, 1.410) - // |sunVec| = sqrt(0 + 0.263 + 1.988) = 1.500 (= DirBright) + // sunVec = (sin(180)*1.5*cos(70), cos(70), 1.5*sin(70)) + // = (0, 0.342, 1.410) + // |sunVec| = sqrt(0 + 0.117 + 1.988) = 1.4509 // DirColor.X = 200/255 = 0.7843 - // SunColor.X = 0.7843 × 1.500 = 1.1765 + // SunColor.X = 0.7843 × 1.4509 = 1.138 var region = MakeRegion(dirBright: 1.5f, rBgrOrder: 200); var loaded = SkyDescLoader.LoadFromRegion(region); Assert.NotNull(loaded); var kf = loaded!.DayGroups[0].SkyTimes[0].Keyframe; - Assert.InRange(kf.SunColor.X, 1.17f, 1.18f); + Assert.InRange(kf.SunColor.X, 1.13f, 1.15f); } [Fact] diff --git a/tests/AcDream.Core.Tests/World/SkyStateTests.cs b/tests/AcDream.Core.Tests/World/SkyStateTests.cs index 3d87da00..1c677204 100644 --- a/tests/AcDream.Core.Tests/World/SkyStateTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyStateTests.cs @@ -66,33 +66,24 @@ public sealed class SkyStateTests } [Fact] - public void RetailSunVector_MagnitudeAlwaysEqualsDirBright() + public void RetailSunVector_AtHorizonNorth_MagnitudeIsOne() { - // cdb-verified (2026-06-18, reference-retail-ambient-values): retail's - // world-space LScape::sunlight = DirBright × (cosP·sinH, cosP·cosH, sinP), - // whose magnitude is DirBright·sqrt(cos²P·(sin²H+cos²H)+sin²P) = DirBright - // for ALL headings/pitches. (The prior y=cos(P) port gave |sunVec|≈1 at the - // horizon — that was the ~30% over-bright bug.) - // Horizon north (H=0°, P=0°): (0, B, 0), |.| = B. - var horizon = new SkyKeyframe( - Begin: 0f, SunHeadingDeg: 0f, SunPitchDeg: 0f, - DirColor: Vector3.One, DirBright: 2.0f, - AmbColor: Vector3.One, AmbBright: 1f, - FogColor: Vector3.One, FogDensity: 0f); - Assert.InRange(SkyStateProvider.RetailSunVector(horizon).Length(), 1.99f, 2.01f); + // Sun on horizon to the north (H=0°, P=0°): cos(P)=1, sin(P)=0. + // sunVec = (sin(0)×B×1, 1, B×0) = (0, 1, 0) + // |sunVec| = 1 regardless of B (because Y is unscaled by B) + var kf = new SkyKeyframe( + Begin: 0f, + SunHeadingDeg: 0f, + SunPitchDeg: 0f, + DirColor: Vector3.One, + DirBright: 2.0f, // anything + AmbColor: Vector3.One, + AmbBright: 1f, + FogColor: Vector3.One, + FogDensity: 0f); - // Reproduce the live cdb capture: dawn keyframe H=90°, P=0.9°, DirBright=0.224 - // → LScape::sunlight = (0.2238, ~0, 0.00352), magnitude 0.224 = DirBright. - var dawn = new SkyKeyframe( - Begin: 0f, SunHeadingDeg: 90f, SunPitchDeg: 0.9f, - DirColor: Vector3.One, DirBright: 0.224f, - AmbColor: Vector3.One, AmbBright: 0.40f, - FogColor: Vector3.One, FogDensity: 0f); - var v = SkyStateProvider.RetailSunVector(dawn); - Assert.InRange(v.X, 0.223f, 0.225f); // DirBright·cosP·sin(90°) ≈ 0.224 - Assert.InRange(v.Y, -0.001f, 0.001f); // DirBright·cosP·cos(90°) ≈ 0 (was the bug: ≈1) - Assert.InRange(v.Z, 0.003f, 0.004f); // DirBright·sin(0.9°) ≈ 0.0035 - Assert.InRange(v.Length(), 0.223f, 0.225f); // = DirBright + var v = SkyStateProvider.RetailSunVector(kf); + Assert.InRange(v.Length(), 0.99f, 1.01f); } [Fact] diff --git a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs b/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs deleted file mode 100644 index 1b200eef..00000000 --- a/tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs +++ /dev/null @@ -1,470 +0,0 @@ -using AcDream.Core.World; -using Xunit; - -namespace AcDream.Core.Tests.World; - -public sealed class TeleportAnimSequencerTests -{ - // --- Task 1.1: type presence --- - - [Fact] - public void Enums_HaveExpectedValues() - { - Assert.Equal(0, (int)TeleportAnimState.Off); - Assert.Equal(1, (int)TeleportAnimState.WorldFadeOut); - Assert.Equal(2, (int)TeleportAnimState.TunnelFadeIn); - Assert.Equal(3, (int)TeleportAnimState.Tunnel); - Assert.Equal(4, (int)TeleportAnimState.TunnelContinue); - Assert.Equal(5, (int)TeleportAnimState.TunnelFadeOut); - Assert.Equal(6, (int)TeleportAnimState.WorldFadeIn); - - _ = TeleportEntryKind.Portal; - _ = TeleportEntryKind.Login; - _ = TeleportEntryKind.Death; - _ = TeleportEntryKind.Logout; - - _ = TeleportAnimEvent.PlayEnterSound; - _ = TeleportAnimEvent.PlayExitSound; - _ = TeleportAnimEvent.Place; - _ = TeleportAnimEvent.FireLoginComplete; - _ = TeleportAnimEvent.EnterTunnel; - } - - [Fact] - public void Snapshot_DefaultsAreOff_ClearAlpha() - { - var snap = new TeleportAnimSnapshot( - TeleportAnimState.Off, FadeAlpha: 0f, ShowTunnel: false, ShowPleaseWait: false); - Assert.Equal(TeleportAnimState.Off, snap.State); - Assert.Equal(0f, snap.FadeAlpha); - Assert.False(snap.ShowTunnel); - Assert.False(snap.ShowPleaseWait); - } - - // --- Task 1.2: Begin(), IsActive, initial state --- - - [Theory] - [InlineData(TeleportEntryKind.Portal, TeleportAnimState.Tunnel)] - [InlineData(TeleportEntryKind.Login, TeleportAnimState.Tunnel)] - [InlineData(TeleportEntryKind.Death, TeleportAnimState.Tunnel)] - [InlineData(TeleportEntryKind.Logout, TeleportAnimState.WorldFadeOut)] - public void Begin_SetsCorrectStartState(TeleportEntryKind kind, TeleportAnimState expectedStart) - { - var seq = new TeleportAnimSequencer(); - Assert.False(seq.IsActive); - - seq.Begin(kind); - - Assert.True(seq.IsActive); - Assert.Equal(expectedStart, seq.State); - } - - [Fact] - public void Begin_EmitsPlayEnterSoundOnFirstTick() - { - // PlayEnterSound is edge-triggered on the first Tick after Begin. - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - var (_, events) = seq.Tick(dt: 0f, worldReady: false); - Assert.Contains(TeleportAnimEvent.PlayEnterSound, events); - } - - // --- Task 1.3: State transitions — timing-correct Tick() --- - - // Helper: drive the sequencer forward by total elapsed time using small fixed steps. - private static (TeleportAnimSnapshot snap, List allEvents) - DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f) - { - var allEvts = new List(); - float remaining = seconds; - TeleportAnimSnapshot last = default; - while (remaining > 0f) - { - float dt = Math.Min(step, remaining); - var (snap, evts) = seq.Tick(dt, worldReady); - last = snap; - allEvts.AddRange(evts); - remaining -= dt; - } - return (last, allEvts); - } - - // --- Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel --- - - [Fact] - public void Logout_AfterFadeTime_TransitionsToTunnelFadeIn() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - - // Consume the enter-sound tick at dt=0 - seq.Tick(0f, worldReady: false); - - // Drive just past FadeTime (1.0s) - DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false); - - Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State); - } - - [Fact] - public void Logout_After2xFadeTime_TransitionsToTunnel() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - seq.Tick(0f, worldReady: false); // consume enter-sound - - DriveSeconds(seq, 2f * TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false); - - Assert.Equal(TeleportAnimState.Tunnel, seq.State); - } - - // --- Portal/Login/Death path: enters at Tunnel --- - - [Fact] - public void Portal_StartsInTunnel_HoldsWhileNotReady() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); // consume enter-sound - - // Drive 10s — should not advance past Tunnel while !worldReady - DriveSeconds(seq, 10f, worldReady: false); - - Assert.Equal(TeleportAnimState.Tunnel, seq.State); - } - - [Fact] - public void Portal_WhenWorldReady_TransitionsToTunnelContinue_EmitsEnterTunnelAndPlace() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); // consume enter-sound - - // The very first tick with worldReady=true should advance Tunnel→TunnelContinue and emit Place. - var (_, evts) = seq.Tick(0.016f, worldReady: true); - - Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); - Assert.Contains(TeleportAnimEvent.Place, evts); - } - - // --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut --- - - [Fact] - public void TunnelContinue_DoesNotAdvance_BeforeMinContinue() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - // Enter TunnelContinue - seq.Tick(0.016f, worldReady: true); - Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); - - // Drive MinContinue - ε — should still be TunnelContinue - DriveSeconds(seq, TeleportAnimSequencer.MinContinue - 0.1f, worldReady: true); - - Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); - } - - [Fact] - public void TunnelContinue_AdvancesToTunnelFadeOut_AfterMinContinue() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - seq.Tick(0.016f, worldReady: true); // -> TunnelContinue - - DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true); - - Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State); - } - - // --- MAX_CONTINUE forces progress even when !worldReady --- - - [Fact] - public void TunnelContinue_ForcesAdvance_AtMaxContinue_EvenIfNotReady() - { - // Simulate: world never becomes fully ready but MAX_CONTINUE forces fade-out - // This path exercises the safety-net from spec §3.4. - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - // Manually push the state to TunnelContinue by passing worldReady=true for one tick, - // then simulate worldReady toggling back to false. - seq.Tick(0.016f, worldReady: true); // -> TunnelContinue - - // Drive MaxContinue + ε with worldReady=false (simulating "world never loaded") - DriveSeconds(seq, TeleportAnimSequencer.MaxContinue + 0.05f, worldReady: false); - - Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State); - } - - // --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event --- - - [Fact] - public void TunnelFadeOut_EmitsPlayExitSound_OnTransitionToWorldFadeIn() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - seq.Tick(0.016f, worldReady: true); // -> TunnelContinue - - // Drive through MinContinue -> TunnelFadeOut - DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true); - Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State); - - // Drive through FadeTime -> WorldFadeIn; PlayExitSound should fire on that edge - var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); - Assert.Equal(TeleportAnimState.WorldFadeIn, seq.State); - Assert.Contains(TeleportAnimEvent.PlayExitSound, evts); - } - - // --- WorldFadeIn -> Off: FireLoginComplete edge event --- - - [Fact] - public void WorldFadeIn_EmitsFireLoginComplete_OnTransitionToOff() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - seq.Tick(0.016f, worldReady: true); // -> TunnelContinue - DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true); - DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); // -> WorldFadeIn - - var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); - - Assert.Equal(TeleportAnimState.Off, seq.State); - Assert.False(seq.IsActive); - Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts); - } - - // --- Task 1.4: FadeAlpha endpoints and monotonicity --- - - [Fact] - public void FadeAlpha_IsZeroAtStart_OfWorldFadeOut() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - var (snap, _) = seq.Tick(dt: 0f, worldReady: false); - // At elapsed=0 in WorldFadeOut: smoothstep(0)=0 => alpha=0 (world fully visible). - Assert.Equal(0f, snap.FadeAlpha, precision: 4); - } - - [Fact] - public void FadeAlpha_IsOneAtEnd_OfWorldFadeOut() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0 - - // Drive to just BEFORE the transition (so we're still in WorldFadeOut) - DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.02f, worldReady: false); - Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State); - - var (snap, _) = seq.Tick(0f, worldReady: false); - // smoothstep(clamp((1.0-0.02)/1.0,0,1)) should be close to 1 - Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}"); - } - - [Fact] - public void FadeAlpha_IsMonotonicallyIncreasing_DuringWorldFadeOut() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - seq.Tick(0f, worldReady: false); - - float prev = -1f; - // Sample 50 steps within FadeTime - float step = TeleportAnimSequencer.FadeTime / 50f; - for (int i = 0; i < 48; i++) // stop before transition - { - var (snap, _) = seq.Tick(step, worldReady: false); - if (seq.State != TeleportAnimState.WorldFadeOut) break; - Assert.True(snap.FadeAlpha >= prev - 0.001f, - $"Alpha decreased: {prev} -> {snap.FadeAlpha} at step {i}"); - prev = snap.FadeAlpha; - } - } - - [Fact] - public void FadeAlpha_IsZero_DuringTunnelStates() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - - // In Tunnel state, overlay alpha should be 0 (the tunnel viewport is shown; no alpha quad needed) - var (snap, _) = seq.Tick(0.016f, worldReady: false); - Assert.Equal(TeleportAnimState.Tunnel, seq.State); - Assert.Equal(0f, snap.FadeAlpha); - } - - [Fact] - public void ShowTunnel_TrueInTunnelFamilyStates_FalseOtherwise() - { - // Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel -> TunnelContinue -> TunnelFadeOut -> WorldFadeIn -> Off - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Logout); - seq.Tick(0f, worldReady: false); - - // WorldFadeOut: ShowTunnel = false - var (snap, _) = seq.Tick(0.016f, worldReady: false); - Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State); - Assert.False(snap.ShowTunnel); - - // Advance to TunnelFadeIn - DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false); - Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State); - var (snap2, _) = seq.Tick(0f, worldReady: false); - Assert.True(snap2.ShowTunnel); - - // Advance to Tunnel - DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false); - Assert.Equal(TeleportAnimState.Tunnel, seq.State); - var (snap3, _) = seq.Tick(0f, worldReady: false); - Assert.True(snap3.ShowTunnel); - } - - [Fact] - public void ShowPleaseWait_TrueOnlyInTunnelContinue() - { - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - seq.Tick(0f, worldReady: false); - - // Tunnel: ShowPleaseWait false - var (snap1, _) = seq.Tick(0.016f, worldReady: false); - Assert.Equal(TeleportAnimState.Tunnel, seq.State); - Assert.False(snap1.ShowPleaseWait); - - // Advance to TunnelContinue - var (snap2, _) = seq.Tick(0.016f, worldReady: true); - Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); - Assert.True(snap2.ShowPleaseWait); - } - - // --- Task 1.5: Full portal event sequence and logout event sequence --- - - // Helper: run the sequencer to completion collecting ordered events. - private static List RunToOff( - TeleportEntryKind kind, bool worldReadyAfterFirstTunnel = true, float step = 0.05f) - { - var seq = new TeleportAnimSequencer(); - var allEvts = new List(); - seq.Begin(kind); - - int safetyNet = 5000; - while (seq.IsActive && --safetyNet > 0) - { - // Simulate world becoming ready on first Tick inside Tunnel - bool ready = worldReadyAfterFirstTunnel && seq.State == TeleportAnimState.Tunnel; - var (_, evts) = seq.Tick(step, worldReady: ready); - allEvts.AddRange(evts); - } - return allEvts; - } - - [Fact] - public void Portal_FullSequence_EventsInOrder() - { - var evts = RunToOff(TeleportEntryKind.Portal); - - // Required events in order: PlayEnterSound, Place, PlayExitSound, FireLoginComplete. - // EnterTunnel is optional but must come before Place if present. - int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound); - int placeIdx = evts.IndexOf(TeleportAnimEvent.Place); - int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound); - int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete); - - Assert.True(enterIdx >= 0, "PlayEnterSound must fire"); - Assert.True(placeIdx >= 0, "Place must fire"); - Assert.True(exitIdx >= 0, "PlayExitSound must fire"); - Assert.True(loginIdx >= 0, "FireLoginComplete must fire"); - - Assert.True(enterIdx < placeIdx, "PlayEnterSound must precede Place"); - Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound"); - Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete"); - } - - [Fact] - public void Logout_FullSequence_EventsInOrder_WithEnterTunnelAfterFades() - { - var evts = RunToOff(TeleportEntryKind.Logout); - - int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound); - int tunnelIdx = evts.IndexOf(TeleportAnimEvent.EnterTunnel); - int placeIdx = evts.IndexOf(TeleportAnimEvent.Place); - int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound); - int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete); - - Assert.True(enterIdx >= 0, "PlayEnterSound must fire"); - Assert.True(tunnelIdx >= 0, "EnterTunnel must fire (Logout path goes through WorldFadeOut->TunnelFadeIn->Tunnel)"); - Assert.True(placeIdx >= 0, "Place must fire"); - Assert.True(exitIdx >= 0, "PlayExitSound must fire"); - Assert.True(loginIdx >= 0, "FireLoginComplete must fire"); - - Assert.True(enterIdx < tunnelIdx, "PlayEnterSound must precede EnterTunnel"); - Assert.True(tunnelIdx < placeIdx, "EnterTunnel must precede Place"); - Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound"); - Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete"); - } - - [Fact] - public void Portal_NoEventsFiredTwice() - { - var evts = RunToOff(TeleportEntryKind.Portal); - Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayEnterSound)); - Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.Place)); - Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayExitSound)); - Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.FireLoginComplete)); - } - - [Fact] - public void Death_BehavesIdenticallyToPortal_FullSequence() - { - var portalEvts = RunToOff(TeleportEntryKind.Portal); - var deathEvts = RunToOff(TeleportEntryKind.Death); - // Same event sequence (both enter at Tunnel) - Assert.Equal(portalEvts, deathEvts); - } - - [Fact] - public void Login_BehavesIdenticallyToPortal_FullSequence() - { - var portalEvts = RunToOff(TeleportEntryKind.Portal); - var loginEvts = RunToOff(TeleportEntryKind.Login); - Assert.Equal(portalEvts, loginEvts); - } - - [Fact] - public void AfterOff_IsActiveIsFalse_AndStateIsOff() - { - RunToOff(TeleportEntryKind.Portal); - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - - // Drain to Off - for (int i = 0; i < 5000 && seq.IsActive; i++) - { - bool ready = seq.State == TeleportAnimState.Tunnel; - seq.Tick(0.05f, worldReady: ready); - } - - Assert.False(seq.IsActive); - Assert.Equal(TeleportAnimState.Off, seq.State); - } - - // --- Task 1.6: EnterTunnel fired for Portal path via Begin() --- - - [Fact] - public void Portal_EmitsEnterTunnel_OnFirstTick() - { - // Portal begins directly in Tunnel; EnterTunnel signals "world is now hidden". - var seq = new TeleportAnimSequencer(); - seq.Begin(TeleportEntryKind.Portal); - - // First tick: should emit PlayEnterSound AND EnterTunnel (both on entry) - var (_, evts) = seq.Tick(0f, worldReady: false); - Assert.Contains(TeleportAnimEvent.PlayEnterSound, evts); - Assert.Contains(TeleportAnimEvent.EnterTunnel, evts); - } -} diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs index e10d56e3..d5003bba 100644 --- a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs +++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs @@ -148,30 +148,27 @@ public class InputDispatcherIsActionHeldTests } [Fact] - public void IsActionHeld_gated_off_while_keyboard_captured() + public void IsActionHeld_does_not_check_WantCaptureMouse() { - // Write-mode gate (2026-06-16): a focused chat input sets - // WantCaptureKeyboard, and held-key polling then reads RELEASED so typing - // "swd" doesn't move the character. This SUPERSEDES the old design (where the - // per-frame OnUpdate guard early-returned out of the whole movement block) — - // that approach also killed AUTORUN. By gating here instead, the movement block - // keeps running, so autorun (a separate latched bool ORed into Forward at the - // call site, NOT a polled key) survives write mode. WantCaptureMouse alone does - // NOT gate held-key polling — only keyboard capture does. + // Per-frame held-state lookup is independent of UI capture: even + // with WantCaptureMouse=true a movement key already held when + // ImGui took focus continues to read as held until KeyUp. Press + // events ARE gated (the Press wouldn't fire while UI captures), + // but IsActionHeld answers the keyboard's underlying "is the + // physical key down right now" — which the legacy IsKeyPressed + // also did. The per-frame OnUpdate guard on + // ImGui.GetIO().WantCaptureKeyboard is what suppresses movement + // when chat is focused. var (dispatcher, kb, mouse, bindings) = Build(); bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward)); kb.EmitKeyDown(Key.W, ModifierMask.None); - // Held, no capture → reads held. - Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); - - // Keyboard captured (write mode) → held-key polling reads released. - mouse.WantCaptureKeyboard = true; - Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward)); - - // Mouse capture alone must NOT gate movement polling (only keyboard does). - mouse.WantCaptureKeyboard = false; mouse.WantCaptureMouse = true; + mouse.WantCaptureKeyboard = true; + + // Even with both capture flags set, IsActionHeld remains true + // because W is physically held. The dispatcher only suppresses + // press transitions. Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward)); } } diff --git a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs b/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs deleted file mode 100644 index e0f1daad..00000000 --- a/tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatCommandRouterTests.cs +++ /dev/null @@ -1,74 +0,0 @@ -using AcDream.Core.Chat; -using AcDream.UI.Abstractions; -using AcDream.UI.Abstractions.Panels.Chat; -using Xunit; - -namespace AcDream.UI.Abstractions.Tests.Panels.Chat; - -public class ChatCommandRouterTests -{ - private sealed class CaptureBus : ICommandBus - { - public SendChatCmd? Last; - public void Publish(T command) where T : notnull - { - if (command is SendChatCmd c) Last = c; - } - } - - private static (ChatVM vm, ChatLog log, CaptureBus bus) Fixture() - { - var log = new ChatLog(); - var vm = new ChatVM(log, displayLimit: 50); - return (vm, log, new CaptureBus()); - } - - [Fact] - public void PlainText_PublishesOnDefaultChannel() - { - var (vm, _, bus) = Fixture(); - var outcome = ChatCommandRouter.Submit("hello there", vm, bus, ChatChannelKind.Say); - Assert.Equal(SubmitOutcome.Sent, outcome); - Assert.NotNull(bus.Last); - Assert.Equal(ChatChannelKind.Say, bus.Last!.Channel); - Assert.Equal("hello there", bus.Last.Text); - } - - [Fact] - public void DefaultChannel_IsHonored() - { - var (vm, _, bus) = Fixture(); - ChatCommandRouter.Submit("hi", vm, bus, ChatChannelKind.Fellowship); - Assert.Equal(ChatChannelKind.Fellowship, bus.Last!.Channel); - } - - [Fact] - public void ClearCommand_DrainsLog_DoesNotPublish() - { - var (vm, log, bus) = Fixture(); - log.OnSystemMessage("x", chatType: 0); - var outcome = ChatCommandRouter.Submit("/clear", vm, bus, ChatChannelKind.Say); - Assert.Equal(SubmitOutcome.ClientHandled, outcome); - Assert.Null(bus.Last); - Assert.Empty(log.Snapshot()); - } - - [Fact] - public void UnknownSlashVerb_ShowsSystemMessage_DoesNotPublish() - { - var (vm, log, bus) = Fixture(); - var outcome = ChatCommandRouter.Submit("/notacommand", vm, bus, ChatChannelKind.Say); - Assert.Equal(SubmitOutcome.UnknownCommand, outcome); - Assert.Null(bus.Last); - Assert.Contains(log.Snapshot(), e => e.Text.Contains("Unknown command")); - } - - [Fact] - public void EmptyInput_DoesNothing() - { - var (vm, _, bus) = Fixture(); - var outcome = ChatCommandRouter.Submit(" ", vm, bus, ChatChannelKind.Say); - Assert.Equal(SubmitOutcome.Empty, outcome); - Assert.Null(bus.Last); - } -} diff --git a/tools/cdb/a7-fixd-golden-probe.cdb b/tools/cdb/a7-fixd-golden-probe.cdb deleted file mode 100644 index 07627206..00000000 --- a/tools/cdb/a7-fixd-golden-probe.cdb +++ /dev/null @@ -1,15 +0,0 @@ -$$ A7 Fix D — GOLDEN: dump the nearest static lights (the meeting-hall wall torches) -$$ + the ambient/sun that acdream folds into its accumulator. Breakpoint-free, instant. -$$ Render::world_lights @ 0x008672a0; sorted_static_lights[] (RenderLight*) @ +0x3498 -$$ (verified: num_static_lights@+0x104=38, num_dynamic_lights@+0x3588=2). -$$ Stand near the meeting-hall torches so the nearest sorted lights ARE them. -.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-golden-probe.log -.sympath C:\Users\erikn\source\repos\acdream\refs -.symopt+ 0x40 -.reload /f acclient.exe -.echo === ambient_color / sunlight_color / sunlight (what acdream folds into the accumulator) === -dt -r1 acclient!Render::world_lights ambient_color sunlight_color sunlight num_static_lights num_dynamic_lights -.echo === nearest 10 sorted static lights (RenderLight.d3dLightIndex + info: type/intensity/falloff/color) === -.for (r $t0=0; @$t0 < 10; r $t0=@$t0+1) { r $t1 = poi(acclient!Render::world_lights + 0x3498 + @$t0*4); .printf "--- sorted_static[%d] RenderLight=%p ---\n", @$t0, @$t1; dt -r2 acclient!RenderLight @$t1 d3dLightIndex distancesq info } -.echo === END === -qd diff --git a/tools/cdb/a7-fixd-golden2-probe.cdb b/tools/cdb/a7-fixd-golden2-probe.cdb deleted file mode 100644 index e4ed7d0e..00000000 --- a/tools/cdb/a7-fixd-golden2-probe.cdb +++ /dev/null @@ -1,17 +0,0 @@ -$$ A7 Fix D — GOLDEN v2: explicit LIGHTINFO/RGBColor dump of the nearest static -$$ lights. info @ RenderLight+0x70 (LIGHTINFO); within info: color@+0x50, intensity@+0x5C, -$$ falloff@+0x60 -> absolute color@RL+0xC0, intensity@RL+0xCC, falloff@RL+0xD0. -$$ Characterizes the 38-light static set (warm town torches?) + golden for the fix. -$$ Breakpoint-free, instant, uses current scene. -.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-golden2-probe.log -.sympath C:\Users\erikn\source\repos\acdream\refs -.symopt+ 0x40 -.reload /f acclient.exe -.echo === ambient_color (r,g,b) === -dt acclient!RGBColor acclient!Render::world_lights+0x0 -.echo === sunlight_color (r,g,b) === -dt acclient!RGBColor acclient!Render::world_lights+0xc -.echo === nearest 8 sorted static lights: type/intensity/falloff + color(r,g,b) + distsq === -.for (r $t0=0; @$t0 < 8; r $t0=@$t0+1) { r $t1 = poi(acclient!Render::world_lights + 0x3498 + @$t0*4); .printf "--- sorted_static[%d] RL=%p d3dIdx=%d ---\n", @$t0, @$t1, dwo(@$t1+0x68); dt acclient!LIGHTINFO @$t1+0x70 type intensity falloff; .echo color(r,g,b):; dt acclient!RGBColor @$t1+0xc0; .echo distancesq:; dd @$t1+0xd8 L1 } -.echo === END === -qd diff --git a/tools/cdb/a7-fixd-lights-v2.cdb b/tools/cdb/a7-fixd-lights-v2.cdb deleted file mode 100644 index 03345800..00000000 --- a/tools/cdb/a7-fixd-lights-v2.cdb +++ /dev/null @@ -1,36 +0,0 @@ -$$ -$$ A7 Fix D (#140) v2 — fills the two gaps v1 left: -$$ (1) light COLORS (v1's dt did not expand RGBColor); expanded here as a -$$ typed RGBColor dump + raw dd hex backup (reinterpret IEEE-754 if dt fails). -$$ (2) the STATIC wall torches (the lights that actually BAKE the walls) — these -$$ only re-register on a visible-cell-set change, so the player must MOVE -$$ (walk IN and OUT of the meeting hall, circle past the torches) to trigger -$$ Render::add_static_light. -$$ -$$ v1 already proved: intensity=100/falloff=6 light is DYNAMIC (add_dynamic_light, -$$ d3dIdx=2) = the portal/effect on the hardware path, NOT a baked wall torch. -$$ viewer light = intensity 2.25 / falloff 10 (dynamic, d3dIdx=1). -$$ -$$ add_static_light / add_dynamic_light(LIGHTINFO* info, cellID, Frame* offset): -$$ LIGHTINFO* = poi(@esp+4). color@+0x50 (r/g/b floats), origin@+0x38, intensity@+0x5C, falloff@+0x60. -$$ -$$ Dynamic logging is limited to the first 8 hits (we already characterised them); -$$ ALL static hits log. qd when 12 static torches captured OR 1500 total hits (safety). - -.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-lights-v2.log -.sympath C:\Users\erikn\source\repos\acdream\refs -.symopt+ 0x40 -.reload /f acclient.exe - -r $t0 = 0 -r $t2 = 0 -r $t3 = 0 - -$$ STATIC wall torches (baked path) — MOVE to trigger. Color (typed + hex) + origin. -bp acclient!Render::add_static_light "r $t0=@$t0+1; r $t2=@$t2+1; .printf /D \"[STATIC torch] hit#%d\\n\", @$t2; dt acclient!LIGHTINFO poi(@esp+4) type intensity falloff cone_angle; .echo color_typed:; dt acclient!RGBColor poi(@esp+4)+0x50; .echo color_hex(r,g,b):; dd poi(@esp+4)+0x50 L3; .echo origin_hex(x,y,z):; dd poi(@esp+4)+0x38 L3; .if (@$t2 >= 12) { qd } .elsif (@$t0 >= 1500) { qd } .else { gc }" - -$$ DYNAMIC lights (portal/viewer) — log first 8 with color, then silent gc. -bp acclient!Render::add_dynamic_light "r $t0=@$t0+1; r $t3=@$t3+1; .if (@$t3 <= 8) { .printf /D \"[DYNAMIC light] hit#%d\\n\", @$t3; dt acclient!LIGHTINFO poi(@esp+4) type intensity falloff; .echo color_typed:; dt acclient!RGBColor poi(@esp+4)+0x50; .echo color_hex(r,g,b):; dd poi(@esp+4)+0x50 L3 }; .if (@$t0 >= 1500) { qd } .else { gc }" - -.printf "v2 armed: STATIC=wall torches (MOVE in/out of hall to trigger), DYNAMIC=portal/viewer; colors expanded. qd at 12 statics or 1500 total.\\n" -g diff --git a/tools/cdb/a7-fixd-lights.cdb b/tools/cdb/a7-fixd-lights.cdb deleted file mode 100644 index 34d2558b..00000000 --- a/tools/cdb/a7-fixd-lights.cdb +++ /dev/null @@ -1,50 +0,0 @@ -$$ -$$ A7 Fix D (#140) — wall-torch vs portal light OWNERSHIP + the actual LIGHTINFO -$$ values that feed the EnvCell wall bake. 2026-06-18. -$$ -$$ Decomp already settled the render path (workflow wf_f660eb88): -$$ STATIC lights -> CPU per-vertex bake (SetStaticLightingVertexColors -> -$$ calc_point_light), DOUBLE-clamped (per-light min(scale*color,color) + -$$ per-vertex [0,1]) -> walls stay DIM even at intensity=100. -$$ DYNAMIC lights -> D3D hardware FF (minimize_envcell_lighting). -$$ Render::insert_light copies intensity VERBATIM to BOTH paths, so the only -$$ open empirical question is: which light carries intensity=100, and what do -$$ the actual wall-torch LIGHTINFOs look like (intensity/falloff/color)? -$$ -$$ CLASSIFICATION via config_hardware_light's d3dLightIndex (arg1 @ [esp+4]): -$$ add_dynamic_light base index = 1 -> dynamic idx in [1..10] (viewer light / teleport PORTAL) -$$ add_static_light base index = 11 -> static idx in [11..70] (WALL TORCHES, baked) -$$ -$$ config_hardware_light(d3dIndex, _D3DLIGHT9* out, ulong cellID, LIGHTINFO* info): -$$ d3dIndex = dwo(@esp+4) ; LIGHTINFO* = poi(@esp+0x10) (PROVEN last session) -$$ add_static_light / add_dynamic_light(LIGHTINFO* info, cellID, Frame* offset): -$$ LIGHTINFO* = poi(@esp+4) -$$ `dt acclient!LIGHTINFO type intensity falloff color` resolves the -$$ float fields symbolically (PDB types) -> readable values, no hex reinterp. -$$ -$$ USAGE: with retail in-world standing in/near the Holtburg meeting hall by a -$$ wall torch, WALK around the hall (and past the teleport portal if present) -$$ for ~15 s so static torch sets re-register. Auto-detaches (qd) after 600 -$$ total hits, leaving retail running. - -.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-lights-capture.log -.sympath C:\Users\erikn\source\repos\acdream\refs -.symopt+ 0x40 -.reload /f acclient.exe - -r $t0 = 0 -r $t1 = 0 -r $t2 = 0 -r $t3 = 0 - -$$ BP1: config_hardware_light — EVERY light (static+dynamic); d3dIdx classifies. -bp acclient!PrimD3DRender::config_hardware_light "r $t0=@$t0+1; r $t1=@$t1+1; .printf /D \"[CHL] hit#%d d3dIdx=%d (1-10=DYNAMIC portal/viewer, 11+=STATIC torch)\\n\", @$t1, dwo(@esp+4); dt acclient!LIGHTINFO dwo(@esp+0x10) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }" - -$$ BP2: add_static_light — every hit is a WALL TORCH (baked path). -bp acclient!Render::add_static_light "r $t0=@$t0+1; r $t2=@$t2+1; .printf /D \"[STATIC torch] hit#%d\\n\", @$t2; dt acclient!LIGHTINFO dwo(@esp+4) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }" - -$$ BP3: add_dynamic_light — viewer light + teleport PORTAL (hardware path). -bp acclient!Render::add_dynamic_light "r $t0=@$t0+1; r $t3=@$t3+1; .printf /D \"[DYNAMIC light] hit#%d\\n\", @$t3; dt acclient!LIGHTINFO dwo(@esp+4) type intensity falloff color; .if (@$t0 >= 600) { qd } .else { gc }" - -.printf "a7-fixd-lights armed: BP1 CHL (classify via d3dIdx), BP2 STATIC=torch, BP3 DYNAMIC=portal/viewer. qd after 600 total hits.\\n" -g diff --git a/tools/cdb/a7-fixd-numstatic-probe.cdb b/tools/cdb/a7-fixd-numstatic-probe.cdb deleted file mode 100644 index 155bbbce..00000000 --- a/tools/cdb/a7-fixd-numstatic-probe.cdb +++ /dev/null @@ -1,18 +0,0 @@ -$$ A7 Fix D — instant (breakpoint-free) read of how many STATIC lights the -$$ current scene bakes with. Confirms whether the meeting hall has static torches -$$ (-> D-1 summed-torches matters) or near-zero (-> D-2 leaked-SSBO is the cause). -$$ Stand where the meeting-hall walls are visible. No movement / no breakpoints. -.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\thirsty-goldberg-51bb9b\a7-fixd-numstatic-probe.log -.sympath C:\Users\erikn\source\repos\acdream\refs -.symopt+ 0x40 -.reload /f acclient.exe -.echo === x acclient!*world_lights* === -x acclient!*world_lights* -.echo === x acclient!Render::world_lights === -x acclient!Render::world_lights -.echo === dt typed (num_static_lights / num_dynamic_lights / ambient_color) === -dt acclient!Render::world_lights num_static_lights num_dynamic_lights ambient_color sunlight_color -.echo === dt LightParms at symbol (fallback by explicit type) === -dt acclient!LightParms acclient!Render::world_lights num_static_lights num_dynamic_lights -.echo === END === -qd diff --git a/tools/cdb/chat-colors.cdb b/tools/cdb/chat-colors.cdb deleted file mode 100644 index b9010838..00000000 --- a/tools/cdb/chat-colors.cdb +++ /dev/null @@ -1,12 +0,0 @@ -.symopt+ 0x40 -.reload /f acclient.exe -.echo ===BASE=== -lm m acclient -.echo ===DISASM_BuildChatColorLookupTable=== -uf acclient!ChatInterface::BuildChatColorLookupTable -.echo ===TABLE_REL_0x41c4a8=== -dd acclient+0x41c4a8 L40 -.echo ===TABLE_ABS_0x81c4a8=== -dd 0x81c4a8 L40 -.echo ===END=== -qd diff --git a/tools/cdb/chat-colors2.cdb b/tools/cdb/chat-colors2.cdb deleted file mode 100644 index 24b7a382..00000000 --- a/tools/cdb/chat-colors2.cdb +++ /dev/null @@ -1,6 +0,0 @@ -.echo ===COLOR_SYMS=== -x acclient!color* -.echo ===CHATCOLOR_SYMS=== -x acclient!*ChatColor* -.echo ===END=== -qd diff --git a/tools/cdb/l2g-observer.cdb b/tools/cdb/l2g-observer.cdb deleted file mode 100644 index c14ebb00..00000000 --- a/tools/cdb/l2g-observer.cdb +++ /dev/null @@ -1,42 +0,0 @@ -* L.2g S2-blocker trace — what does a RETAIL OBSERVER do with ACE's -* flags=0 "empty" UpdateMotion while the actor toggles walk/run? -* -* Attach target: the retail OBSERVER client (standing still, watching). -* Driver: the user Shift-toggles + starts/stops the ACTOR from the other -* retail client. -* -* Expected chain per decomp (to confirm or refute): -* CPhysics::SetObjectMovement (gates, stamps) [SOM] + obj id -* MovementManager::unpack_movement (10-way dispatch) [UNPACK] -* CMotionInterp::move_to_interpreted_state (flat copy) [MTIS] + IMS dump -* CMotionInterp::apply_interpreted_movement [AIM] -* CMotionInterp::apply_raw_movement (local-player alt) [ARM] -* CMotionInterp::DoInterpretedMotion per axis [DIM] + motion id -* -* The [MTIS] InterpretedMotionState dump on an empty UM is THE answer: -* if forward_command=0x41000003 (Ready) is flat-applied and [DIM] shows -* Ready, retail really does stop the remote — and the observer's clean -* rendering must come from somewhere else. If the dump or the DIM stream -* shows something else (e.g. WalkForward), we found the mechanism. -* -* Auto-detach: after 600 SetObjectMovement hits via .detach (NOT qd — -* qd is silently ignored in bp actions, see project_retail_debugger.md). - -.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\vigorous-joliot-f0c3ad\l2g-observer-trace.log -.sympath C:\Users\erikn\source\repos\acdream\refs -.symopt+ 0x40 -.reload /f acclient.exe - -x acclient!CPhysics::SetObjectMovement -x acclient!MovementManager::unpack_movement -x acclient!CMotionInterp::move_to_interpreted_state -x acclient!CMotionInterp::DoInterpretedMotion - -r $t0 = 0 -bp acclient!CPhysics::SetObjectMovement "r $t0 = @$t0 + 1; .printf \"\\n[SOM %d] obj=%p movSeq=%x scSeq=%x auto=%x id: \", @$t0, poi(@esp+4), poi(@esp+10)&0xffff, poi(@esp+14)&0xffff, poi(@esp+18); dt acclient!CPhysicsObj poi(@esp+4) id; .if (@$t0 >= 250) { .detach } .else { gc }" -bp acclient!MovementManager::unpack_movement ".printf \"[UNPACK] mm=%p\\n\", @ecx; gc" -bp acclient!CMotionInterp::move_to_interpreted_state ".printf \"[MTIS] minterp=%p ims:\\n\", @ecx; dt acclient!InterpretedMotionState poi(@esp+4); gc" -bp acclient!CMotionInterp::apply_interpreted_movement ".printf \"[AIM] minterp=%p\\n\", @ecx; gc" -bp acclient!CMotionInterp::apply_raw_movement ".printf \"[ARM] minterp=%p\\n\", @ecx; gc" -bp acclient!CMotionInterp::DoInterpretedMotion ".printf \"[DIM] minterp=%p motion=%08x\\n\", @ecx, poi(@esp+4); gc" -g diff --git a/tools/cdb/paperdoll-pose.cdb b/tools/cdb/paperdoll-pose.cdb deleted file mode 100644 index a5a14b13..00000000 --- a/tools/cdb/paperdoll-pose.cdb +++ /dev/null @@ -1,9 +0,0 @@ -.logopen C:\Users\erikn\source\repos\acdream\.claude\worktrees\hopeful-maxwell-214a12\paperdoll-pose-v2.log -.sympath C:\Users\erikn\source\repos\acdream\refs -.symopt+ 0x40 -.reload /f acclient.exe -.echo ==== PAPERDOLL POSE TRACE v2 (reads m_didAnimation VALUE at +0x66c) ARMED ==== -r $t0 = 0 -bp acclient!gmPaperDollUI::RedressCreature ".echo === RedressCreature: poseDID value ===; ? poi(@ecx+0x66c); dt acclient!gmPaperDollUI @ecx m_didAnimation.id; r $t0 = @$t0 + 1; .if (@$t0 >= 3) { .echo ==== DETACHING (qd) ====; qd } .else { gc }" -bp acclient!gmPaperDollUI::UpdateForRace ".echo === UpdateForRace: bodyType then poseDID value ===; ? poi(@esp+4); ? poi(@ecx+0x66c); r $t0 = @$t0 + 1; .if (@$t0 >= 3) { .echo ==== DETACHING (qd) ====; qd } .else { gc }" -g